[外部工具边界]:完成Open Interpreter和Playwright适配边界,包含默认关闭、高风险确认和GUI控制拒绝测试
This commit is contained in:
@@ -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. 端到端模拟、性能与安全验证
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
"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")})
|
||||
|
||||
Reference in New Issue
Block a user