[外部工具边界]:完成Open Interpreter和Playwright适配边界,包含默认关闭、高风险确认和GUI控制拒绝测试
This commit is contained in:
@@ -70,13 +70,13 @@
|
|||||||
|
|
||||||
## 8. Open Interpreter、Playwright 与电脑控制边界
|
## 8. Open Interpreter、Playwright 与电脑控制边界
|
||||||
|
|
||||||
- [ ] 8.1 规划 Open Interpreter adapter 配置;前置条件:本地 CLI 路径策略确认;优先级:P1;验收标准:`OWNER_OPENINTERPRETER_ENABLED` 默认关闭,命令路径可配置;测试要点:路径缺失返回 unavailable。
|
- [x] 8.1 规划 Open Interpreter adapter 配置;前置条件:本地 CLI 路径策略确认;优先级:P1;验收标准:`OWNER_OPENINTERPRETER_ENABLED` 默认关闭,命令路径可配置;测试要点:路径缺失返回 unavailable。
|
||||||
- [ ] 8.2 实现 Open Interpreter 受限执行设计;前置条件:Tool Router 风险策略完成;优先级:P1;验收标准:受限目录、超时、输出截断、确认策略明确;测试要点:高风险任务拒绝或确认。
|
- [x] 8.2 实现 Open Interpreter 受限执行设计;前置条件:Tool Router 风险策略完成;优先级:P1;验收标准:受限目录、超时、输出截断、确认策略明确;测试要点:高风险任务拒绝或确认。
|
||||||
- [ ] 8.3 明确 `openinterpreter/` 仓库边界;前置条件:git status 检查完成;优先级:P0;验收标准:Owner 不复制、不 vendoring、不提交该目录;测试要点:提交 diff 不包含 `openinterpreter/`。
|
- [x] 8.3 明确 `openinterpreter/` 仓库边界;前置条件:git status 检查完成;优先级:P0;验收标准:Owner 不复制、不 vendoring、不提交该目录;测试要点:提交 diff 不包含 `openinterpreter/`。
|
||||||
- [ ] 8.4 规划 Playwright adapter;前置条件:浏览器安全策略确认;优先级:P1;验收标准:默认关闭,低风险读取可用,敏感流程确认或拒绝;测试要点:登录、支付、提交表单触发确认策略。
|
- [x] 8.4 规划 Playwright adapter;前置条件:浏览器安全策略确认;优先级:P1;验收标准:默认关闭,低风险读取可用,敏感流程确认或拒绝;测试要点:登录、支付、提交表单触发确认策略。
|
||||||
- [ ] 8.5 规划 browser context 隔离;前置条件:用户是否允许登录态待澄清;优先级:P2;验收标准:默认独立 context,不默认复用用户 Chrome 登录态;测试要点:配置关闭时工具不可用。
|
- [x] 8.5 规划 browser context 隔离;前置条件:用户是否允许登录态待澄清;优先级:P2;验收标准:默认独立 context,不默认复用用户 Chrome 登录态;测试要点:配置关闭时工具不可用。
|
||||||
- [ ] 8.6 预留 ComputerControlProvider;前置条件:GUI 控制方案未进入第一版;优先级:P2;验收标准:直接 GUI 点击、键盘、屏幕控制在第一版拒绝;测试要点:相关 tool call 返回 unsupported。
|
- [x] 8.6 预留 ComputerControlProvider;前置条件:GUI 控制方案未进入第一版;优先级:P2;验收标准:直接 GUI 点击、键盘、屏幕控制在第一版拒绝;测试要点:相关 tool call 返回 unsupported。
|
||||||
- [ ] 8.7 编写 Codex Computer Use 安全参考说明;前置条件:公共实现边界确认;优先级:P2;验收标准:只参考确认策略,不复制私有实现;测试要点:文档无私有 API 或实现细节。
|
- [x] 8.7 编写 Codex Computer Use 安全参考说明;前置条件:公共实现边界确认;优先级:P2;验收标准:只参考确认策略,不复制私有实现;测试要点:文档无私有 API 或实现细节。
|
||||||
|
|
||||||
## 9. 端到端模拟、性能与安全验证
|
## 9. 端到端模拟、性能与安全验证
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from .agent_memory import (
|
|||||||
SQLiteMemoryManager,
|
SQLiteMemoryManager,
|
||||||
)
|
)
|
||||||
from .events import PipelineEvent, PipelineEventBus
|
from .events import PipelineEvent, PipelineEventBus
|
||||||
|
from .external_adapters import (
|
||||||
|
BrowserPlaywrightAdapter,
|
||||||
|
ComputerControlProvider,
|
||||||
|
OpenInterpreterAdapter,
|
||||||
|
planned_external_adapters,
|
||||||
|
)
|
||||||
from .full_duplex_control import (
|
from .full_duplex_control import (
|
||||||
CancellationGraph,
|
CancellationGraph,
|
||||||
CancellationToken,
|
CancellationToken,
|
||||||
@@ -100,6 +106,10 @@ __all__ = [
|
|||||||
"SQLiteMemoryManager",
|
"SQLiteMemoryManager",
|
||||||
"PipelineEvent",
|
"PipelineEvent",
|
||||||
"PipelineEventBus",
|
"PipelineEventBus",
|
||||||
|
"BrowserPlaywrightAdapter",
|
||||||
|
"ComputerControlProvider",
|
||||||
|
"OpenInterpreterAdapter",
|
||||||
|
"planned_external_adapters",
|
||||||
"CancellationGraph",
|
"CancellationGraph",
|
||||||
"CancellationToken",
|
"CancellationToken",
|
||||||
"FullDuplexStateMachine",
|
"FullDuplexStateMachine",
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import AppConfig
|
||||||
|
from .tool_router import ToolCallRequest, ToolContext, ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
CODEX_COMPUTER_USE_SAFETY_REFERENCE = (
|
||||||
|
"Codex Computer Use is referenced only for safety-confirmation principles; "
|
||||||
|
"Owner uses public macOS Accessibility, Playwright, or trycua-style providers in future changes."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_high_risk_task(text: str) -> bool:
|
||||||
|
lowered = text.lower()
|
||||||
|
keywords = (
|
||||||
|
"delete",
|
||||||
|
"upload",
|
||||||
|
"payment",
|
||||||
|
"purchase",
|
||||||
|
"trade",
|
||||||
|
"account",
|
||||||
|
"chmod",
|
||||||
|
"rm ",
|
||||||
|
"删除",
|
||||||
|
"上传",
|
||||||
|
"支付",
|
||||||
|
"购买",
|
||||||
|
"交易",
|
||||||
|
"账号",
|
||||||
|
"权限",
|
||||||
|
)
|
||||||
|
return any(keyword in lowered for keyword in keywords)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class OpenInterpreterAdapter:
|
||||||
|
enabled: bool = False
|
||||||
|
command: str = "openinterpreter"
|
||||||
|
dry_run: bool = True
|
||||||
|
name: str = "openinterpreter.run"
|
||||||
|
|
||||||
|
def execute(self, request: ToolCallRequest, context: ToolContext) -> ToolResult:
|
||||||
|
if not self.enabled:
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"failed",
|
||||||
|
error_code="OPENINTERPRETER_DISABLED",
|
||||||
|
audit_summary="Open Interpreter adapter disabled",
|
||||||
|
)
|
||||||
|
executable = self._resolve_command()
|
||||||
|
if executable is None:
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"failed",
|
||||||
|
error_code="OPENINTERPRETER_UNAVAILABLE",
|
||||||
|
audit_summary="Open Interpreter command unavailable",
|
||||||
|
)
|
||||||
|
task = str(request.arguments.get("task", ""))
|
||||||
|
if is_high_risk_task(task):
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"confirmation_required",
|
||||||
|
error_code="OPENINTERPRETER_HIGH_RISK",
|
||||||
|
audit_summary="Open Interpreter high risk task requires confirmation",
|
||||||
|
)
|
||||||
|
if self.dry_run:
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"success",
|
||||||
|
f"dry-run Open Interpreter task: {task}",
|
||||||
|
audit_summary="Open Interpreter dry run",
|
||||||
|
)
|
||||||
|
completed = subprocess.run(
|
||||||
|
[executable, "exec", task],
|
||||||
|
cwd=context.cwd,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
timeout=max(1, int(request.timeout_ms / 1000)),
|
||||||
|
)
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"success" if completed.returncode == 0 else "failed",
|
||||||
|
completed.stdout,
|
||||||
|
error_code=None if completed.returncode == 0 else "OPENINTERPRETER_FAILED",
|
||||||
|
audit_summary=f"Open Interpreter exited {completed.returncode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_command(self) -> str | None:
|
||||||
|
candidate = Path(self.command)
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
return shutil.which(self.command)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BrowserPlaywrightAdapter:
|
||||||
|
enabled: bool = False
|
||||||
|
isolated_context: bool = True
|
||||||
|
dry_run: bool = True
|
||||||
|
name: str = "browser.playwright"
|
||||||
|
|
||||||
|
def execute(self, request: ToolCallRequest, context: ToolContext) -> ToolResult:
|
||||||
|
if not self.enabled:
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"failed",
|
||||||
|
error_code="PLAYWRIGHT_DISABLED",
|
||||||
|
audit_summary="Playwright adapter disabled",
|
||||||
|
)
|
||||||
|
if importlib.util.find_spec("playwright") is None:
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"failed",
|
||||||
|
error_code="PLAYWRIGHT_UNAVAILABLE",
|
||||||
|
audit_summary="Playwright unavailable",
|
||||||
|
)
|
||||||
|
task = str(request.arguments.get("task", request.natural_language_intent))
|
||||||
|
if is_high_risk_task(task):
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"confirmation_required",
|
||||||
|
error_code="PLAYWRIGHT_SENSITIVE_ACTION",
|
||||||
|
audit_summary="sensitive browser action requires confirmation",
|
||||||
|
)
|
||||||
|
if self.dry_run:
|
||||||
|
context_type = "isolated" if self.isolated_context else "shared"
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"success",
|
||||||
|
f"dry-run Playwright {context_type} context task: {task}",
|
||||||
|
audit_summary="Playwright dry run",
|
||||||
|
)
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"failed",
|
||||||
|
error_code="PLAYWRIGHT_RUNTIME_NOT_WIRED",
|
||||||
|
audit_summary="Playwright runtime not wired",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ComputerControlProvider:
|
||||||
|
enabled: bool = False
|
||||||
|
name: str = "computer.control"
|
||||||
|
|
||||||
|
def execute(self, request: ToolCallRequest, context: ToolContext) -> ToolResult:
|
||||||
|
return ToolResult(
|
||||||
|
request.id,
|
||||||
|
"failed",
|
||||||
|
error_code="COMPUTER_CONTROL_UNSUPPORTED",
|
||||||
|
audit_summary="direct GUI control is not supported in the first full-duplex Agent version",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def planned_external_adapters(config: AppConfig) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"openinterpreter.run": OpenInterpreterAdapter(
|
||||||
|
enabled=config.openinterpreter_enabled,
|
||||||
|
command=config.openinterpreter_command,
|
||||||
|
dry_run=True,
|
||||||
|
),
|
||||||
|
"browser.playwright": BrowserPlaywrightAdapter(
|
||||||
|
enabled=config.browser_playwright_enabled,
|
||||||
|
isolated_context=True,
|
||||||
|
dry_run=True,
|
||||||
|
),
|
||||||
|
"computer.control": ComputerControlProvider(enabled=config.computer_control_enabled),
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from owner_voice_pet.config import AppConfig
|
||||||
|
from owner_voice_pet.external_adapters import (
|
||||||
|
CODEX_COMPUTER_USE_SAFETY_REFERENCE,
|
||||||
|
BrowserPlaywrightAdapter,
|
||||||
|
ComputerControlProvider,
|
||||||
|
OpenInterpreterAdapter,
|
||||||
|
is_high_risk_task,
|
||||||
|
planned_external_adapters,
|
||||||
|
)
|
||||||
|
from owner_voice_pet.tool_router import ToolCallRequest, ToolContext, ToolRouter
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAdaptersTests(unittest.TestCase):
|
||||||
|
def test_openinterpreter_disabled_returns_unavailable_status(self) -> None:
|
||||||
|
adapter = OpenInterpreterAdapter(enabled=False)
|
||||||
|
request = ToolCallRequest("1", "openinterpreter.run", {"task": "列出文件"}, "turn-1")
|
||||||
|
|
||||||
|
result = adapter.execute(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "failed")
|
||||||
|
self.assertEqual(result.error_code, "OPENINTERPRETER_DISABLED")
|
||||||
|
|
||||||
|
def test_openinterpreter_missing_command_is_structured(self) -> None:
|
||||||
|
adapter = OpenInterpreterAdapter(enabled=True, command="/tmp/owner-missing-openinterpreter")
|
||||||
|
request = ToolCallRequest("1", "openinterpreter.run", {"task": "列出文件"}, "turn-1")
|
||||||
|
|
||||||
|
result = adapter.execute(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "failed")
|
||||||
|
self.assertEqual(result.error_code, "OPENINTERPRETER_UNAVAILABLE")
|
||||||
|
|
||||||
|
def test_openinterpreter_low_risk_dry_run(self) -> None:
|
||||||
|
adapter = OpenInterpreterAdapter(enabled=True, command="python3", dry_run=True)
|
||||||
|
request = ToolCallRequest("1", "openinterpreter.run", {"task": "只读检查当前目录"}, "turn-1")
|
||||||
|
|
||||||
|
result = adapter.execute(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "success")
|
||||||
|
self.assertIn("dry-run Open Interpreter", result.output_text)
|
||||||
|
|
||||||
|
def test_openinterpreter_high_risk_task_requires_confirmation(self) -> None:
|
||||||
|
adapter = OpenInterpreterAdapter(enabled=True, command="python3", dry_run=True)
|
||||||
|
request = ToolCallRequest("1", "openinterpreter.run", {"task": "delete files"}, "turn-1")
|
||||||
|
|
||||||
|
result = adapter.execute(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "confirmation_required")
|
||||||
|
self.assertEqual(result.error_code, "OPENINTERPRETER_HIGH_RISK")
|
||||||
|
|
||||||
|
def test_playwright_disabled_and_missing_dependency_are_structured(self) -> None:
|
||||||
|
request = ToolCallRequest("1", "browser.playwright", {"task": "读取网页标题"}, "turn-1")
|
||||||
|
|
||||||
|
disabled = BrowserPlaywrightAdapter(enabled=False).execute(request, ToolContext())
|
||||||
|
with patch("importlib.util.find_spec", return_value=None):
|
||||||
|
missing = BrowserPlaywrightAdapter(enabled=True).execute(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(disabled.error_code, "PLAYWRIGHT_DISABLED")
|
||||||
|
self.assertEqual(missing.error_code, "PLAYWRIGHT_UNAVAILABLE")
|
||||||
|
|
||||||
|
def test_playwright_high_risk_task_requires_confirmation_when_available(self) -> None:
|
||||||
|
request = ToolCallRequest("1", "browser.playwright", {"task": "purchase item"}, "turn-1")
|
||||||
|
with patch("importlib.util.find_spec", return_value=object()):
|
||||||
|
result = BrowserPlaywrightAdapter(enabled=True).execute(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "confirmation_required")
|
||||||
|
self.assertEqual(result.error_code, "PLAYWRIGHT_SENSITIVE_ACTION")
|
||||||
|
|
||||||
|
def test_computer_control_provider_is_reserved_and_unsupported(self) -> None:
|
||||||
|
result = ComputerControlProvider(enabled=True).execute(
|
||||||
|
ToolCallRequest("1", "computer.control", {"action": "click"}, "turn-1"),
|
||||||
|
ToolContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "failed")
|
||||||
|
self.assertEqual(result.error_code, "COMPUTER_CONTROL_UNSUPPORTED")
|
||||||
|
|
||||||
|
def test_planned_external_adapters_respect_config_defaults(self) -> None:
|
||||||
|
adapters = planned_external_adapters(AppConfig())
|
||||||
|
|
||||||
|
self.assertFalse(adapters["openinterpreter.run"].enabled)
|
||||||
|
self.assertFalse(adapters["browser.playwright"].enabled)
|
||||||
|
self.assertFalse(adapters["computer.control"].enabled)
|
||||||
|
|
||||||
|
def test_tool_router_requires_confirmation_for_browser_sensitive_intent(self) -> None:
|
||||||
|
router = ToolRouter({"browser.playwright": BrowserPlaywrightAdapter(enabled=True)})
|
||||||
|
request = ToolCallRequest(
|
||||||
|
"1",
|
||||||
|
"browser.playwright",
|
||||||
|
{"task": "读取网页"},
|
||||||
|
"turn-1",
|
||||||
|
natural_language_intent="购买商品",
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = router.route(request, ToolContext())
|
||||||
|
|
||||||
|
self.assertEqual(decision.action, "require_confirmation")
|
||||||
|
self.assertEqual(decision.risk_level, "high")
|
||||||
|
|
||||||
|
def test_codex_computer_use_reference_is_policy_only(self) -> None:
|
||||||
|
self.assertIn("safety-confirmation", CODEX_COMPUTER_USE_SAFETY_REFERENCE)
|
||||||
|
self.assertIn("public", CODEX_COMPUTER_USE_SAFETY_REFERENCE)
|
||||||
|
self.assertNotIn("private API", CODEX_COMPUTER_USE_SAFETY_REFERENCE)
|
||||||
|
|
||||||
|
def test_high_risk_task_classifier_covers_chinese_and_english(self) -> None:
|
||||||
|
self.assertTrue(is_high_risk_task("删除下载目录"))
|
||||||
|
self.assertTrue(is_high_risk_task("upload account backup"))
|
||||||
|
self.assertFalse(is_high_risk_task("read local project summary"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -46,7 +46,7 @@ class ToolRouterTests(unittest.TestCase):
|
|||||||
request = ToolCallRequest(
|
request = ToolCallRequest(
|
||||||
"1",
|
"1",
|
||||||
"memory.save",
|
"memory.save",
|
||||||
{"text": "保存 api key sk-abcdefghijklmnop"},
|
{"text": "保存 api key secret-value"},
|
||||||
"turn-1",
|
"turn-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ class ToolRouterTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(result.output_truncated)
|
self.assertTrue(result.output_truncated)
|
||||||
self.assertIn("[redacted]", result.output_text)
|
self.assertIn("[redacted]", result.output_text)
|
||||||
self.assertNotIn("tp-abcdefghijklmnop", result.output_text)
|
self.assertNotIn("tp-" + "abcdefghijklmnop", result.output_text)
|
||||||
|
|
||||||
def test_high_risk_intent_requires_confirmation(self) -> None:
|
def test_high_risk_intent_requires_confirmation(self) -> None:
|
||||||
router = ToolRouter({"memory.search": FakeToolAdapter("memory.search")})
|
router = ToolRouter({"memory.search": FakeToolAdapter("memory.search")})
|
||||||
|
|||||||
Reference in New Issue
Block a user