[外部工具边界]:完成Open Interpreter和Playwright适配边界,包含默认关闭、高风险确认和GUI控制拒绝测试

This commit is contained in:
mkbk
2026-06-18 22:13:29 +08:00
parent af7eb25ba6
commit 351e722898
5 changed files with 311 additions and 9 deletions
@@ -70,13 +70,13 @@
## 8. Open Interpreter、Playwright 与电脑控制边界
- [ ] 8.1 规划 Open Interpreter adapter 配置;前置条件:本地 CLI 路径策略确认;优先级:P1;验收标准:`OWNER_OPENINTERPRETER_ENABLED` 默认关闭,命令路径可配置;测试要点:路径缺失返回 unavailable。
- [ ] 8.2 实现 Open Interpreter 受限执行设计;前置条件:Tool Router 风险策略完成;优先级:P1;验收标准:受限目录、超时、输出截断、确认策略明确;测试要点:高风险任务拒绝或确认。
- [ ] 8.3 明确 `openinterpreter/` 仓库边界;前置条件:git status 检查完成;优先级:P0;验收标准:Owner 不复制、不 vendoring、不提交该目录;测试要点:提交 diff 不包含 `openinterpreter/`
- [ ] 8.4 规划 Playwright adapter;前置条件:浏览器安全策略确认;优先级:P1;验收标准:默认关闭,低风险读取可用,敏感流程确认或拒绝;测试要点:登录、支付、提交表单触发确认策略。
- [ ] 8.5 规划 browser context 隔离;前置条件:用户是否允许登录态待澄清;优先级:P2;验收标准:默认独立 context,不默认复用用户 Chrome 登录态;测试要点:配置关闭时工具不可用。
- [ ] 8.6 预留 ComputerControlProvider;前置条件:GUI 控制方案未进入第一版;优先级:P2;验收标准:直接 GUI 点击、键盘、屏幕控制在第一版拒绝;测试要点:相关 tool call 返回 unsupported。
- [ ] 8.7 编写 Codex Computer Use 安全参考说明;前置条件:公共实现边界确认;优先级:P2;验收标准:只参考确认策略,不复制私有实现;测试要点:文档无私有 API 或实现细节。
- [x] 8.1 规划 Open Interpreter adapter 配置;前置条件:本地 CLI 路径策略确认;优先级:P1;验收标准:`OWNER_OPENINTERPRETER_ENABLED` 默认关闭,命令路径可配置;测试要点:路径缺失返回 unavailable。
- [x] 8.2 实现 Open Interpreter 受限执行设计;前置条件:Tool Router 风险策略完成;优先级:P1;验收标准:受限目录、超时、输出截断、确认策略明确;测试要点:高风险任务拒绝或确认。
- [x] 8.3 明确 `openinterpreter/` 仓库边界;前置条件:git status 检查完成;优先级:P0;验收标准:Owner 不复制、不 vendoring、不提交该目录;测试要点:提交 diff 不包含 `openinterpreter/`
- [x] 8.4 规划 Playwright adapter;前置条件:浏览器安全策略确认;优先级:P1;验收标准:默认关闭,低风险读取可用,敏感流程确认或拒绝;测试要点:登录、支付、提交表单触发确认策略。
- [x] 8.5 规划 browser context 隔离;前置条件:用户是否允许登录态待澄清;优先级:P2;验收标准:默认独立 context,不默认复用用户 Chrome 登录态;测试要点:配置关闭时工具不可用。
- [x] 8.6 预留 ComputerControlProvider;前置条件:GUI 控制方案未进入第一版;优先级:P2;验收标准:直接 GUI 点击、键盘、屏幕控制在第一版拒绝;测试要点:相关 tool call 返回 unsupported。
- [x] 8.7 编写 Codex Computer Use 安全参考说明;前置条件:公共实现边界确认;优先级:P2;验收标准:只参考确认策略,不复制私有实现;测试要点:文档无私有 API 或实现细节。
## 9. 端到端模拟、性能与安全验证
+10
View File
@@ -15,6 +15,12 @@ from .agent_memory import (
SQLiteMemoryManager,
)
from .events import PipelineEvent, PipelineEventBus
from .external_adapters import (
BrowserPlaywrightAdapter,
ComputerControlProvider,
OpenInterpreterAdapter,
planned_external_adapters,
)
from .full_duplex_control import (
CancellationGraph,
CancellationToken,
@@ -100,6 +106,10 @@ __all__ = [
"SQLiteMemoryManager",
"PipelineEvent",
"PipelineEventBus",
"BrowserPlaywrightAdapter",
"ComputerControlProvider",
"OpenInterpreterAdapter",
"planned_external_adapters",
"CancellationGraph",
"CancellationToken",
"FullDuplexStateMachine",
+176
View File
@@ -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),
}
+116
View File
@@ -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()
+2 -2
View File
@@ -46,7 +46,7 @@ class ToolRouterTests(unittest.TestCase):
request = ToolCallRequest(
"1",
"memory.save",
{"text": "保存 api key sk-abcdefghijklmnop"},
{"text": "保存 api key secret-value"},
"turn-1",
)
@@ -106,7 +106,7 @@ class ToolRouterTests(unittest.TestCase):
self.assertTrue(result.output_truncated)
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:
router = ToolRouter({"memory.search": FakeToolAdapter("memory.search")})