From e0fffd6845ffe81bc7d9b0cc639e2ccb9768018a Mon Sep 17 00:00:00 2001 From: laily Date: Sun, 23 Aug 2026 22:28:58 +0800 Subject: [PATCH] feat(ack): make OMP yolo config-driven via approvalMode --- docs/PRD-ack-omp-worker.md | 4 +- docs/ack/tasks.yaml | 3 ++ skills/ack/README.md | 3 +- skills/ack/SKILL.md | 8 ++-- skills/ack/references/model-routing.md | 15 ++++--- skills/ack/scripts/worker_profiles.py | 29 +++++++++++--- skills/ack/templates/tasks.schema.json | 5 +++ tests/test_ack_omp_worker.py | 54 +++++++++++++++++++++----- 8 files changed, 94 insertions(+), 27 deletions(-) diff --git a/docs/PRD-ack-omp-worker.md b/docs/PRD-ack-omp-worker.md index da79b44..e33eaa2 100644 --- a/docs/PRD-ack-omp-worker.md +++ b/docs/PRD-ack-omp-worker.md @@ -10,7 +10,7 @@ 2. 增加 OMP 的模型 allowlist 与 role/profile 配置能力;模型使用 OMP 要求的精确 `provider/model` 选择器,例如 `opencode-go/gpt-5.6-luna`,不从当前 Coordinator 会话自动推断。 3. launcher 在固定可信目录中解析 `omp`,探测并记录版本,生成确定性的启动 argv,并把 CLI、模型、thinking、审批模式、worktree 和版本纳入 fingerprint/receipt。 4. OMP worker 使用交互式 `omp` 命令,通过结构化参数指定模型、thinking、approval mode 和工作目录;Orca 仍负责 terminal orchestration 与任务 dispatch。 -5. OMP profile 不允许写入自由 command、额外 argv、shell、环境变量或凭据值;不得使用 `--auto-approve`、`--plan-yolo`、会话续接或其它复用参数;本轮用户已明确批准 workspace-write OMP worker 使用 `--approval-mode yolo` 以避免 exec 工具逐次审批。 +5. OMP profile 不允许写入自由 command、额外 argv、shell、环境变量或凭据值;不使用 `--auto-approve`、`--plan-yolo` 或会话复用;是否启用 `--approval-mode yolo` 由 tasks.yaml 的 `approvalMode` 配置决定(可开可关,缺省 workspace-write 开启)。 6. 为 OMP 增加 profile 校验、argv 渲染、环境凭据隔离、可信 executable 解析和 launcher plan 的白盒/黑盒测试。 7. 保持 Codex、Cursor、Grok 既有行为不变;不修改 ACK 的三角色职责或 delivery 流程。 @@ -26,7 +26,7 @@ - `omp` 可执行文件必须通过 ACK 固定可信目录解析,不能从调用者 PATH 任意拾取。 - OMP 模型 ID 必须由项目 allowlist 明确声明;当前会话中的 `opencode-go/gpt-5.6-luna` 只有在 profile 明确配置后才可使用。 -- `workspace-write` 映射到 OMP 的 `--approval-mode yolo`,`read-only` 映射到 `--approval-mode always-ask`;不使用 `--auto-approve`。 +- `approvalMode`(可选,仅 OMP profile)显式控制审批:`yolo` / `write` / `always-ask`;缺省 `workspace-write` → yolo、`read-only` → always-ask。 - `workspace-write` 下的 exec 工具是否会因 OMP 审批提示阻塞,由独立 Test 在黑盒环境中验证;若阻塞,必须记录为环境/运行模式问题,不伪报成功。 ## 可观测验收标准 diff --git a/docs/ack/tasks.yaml b/docs/ack/tasks.yaml index 08d369d..dbffab6 100644 --- a/docs/ack/tasks.yaml +++ b/docs/ack/tasks.yaml @@ -57,6 +57,7 @@ project: model: "opencode-go/gpt-5.6-luna" reasoningEffort: "medium" permissionMode: "workspace-write" + approvalMode: "yolo" omp-test-standard: role: "test" cli: "omp" @@ -64,6 +65,7 @@ project: model: "opencode-go/gpt-5.6-luna" reasoningEffort: "low" permissionMode: "workspace-write" + approvalMode: "yolo" omp-dev-strong: role: "developer" cli: "omp" @@ -71,6 +73,7 @@ project: model: "opencode-go/gpt-5.6-luna" reasoningEffort: "high" permissionMode: "workspace-write" + approvalMode: "yolo" defaults: developer: "codex-dev-standard" test: "codex-test-standard" diff --git a/skills/ack/README.md b/skills/ack/README.md index b3e9a5a..0efe52e 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -208,7 +208,8 @@ v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭 sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件 伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用 -停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数。旧任务板没有结构化 +停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数; +`approvalMode` 在 tasks.yaml profile 中显式开关 yolo(缺省 workspace-write 开启)。旧任务板没有结构化 `project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。 持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 52267b2..44a1757 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -250,9 +250,11 @@ description: >- 当前一律 fail closed。Grok worker 由 launcher 固定带 `--always-approve`, 仍必须带 sandbox。 - OMP worker 使用结构化 `--model`、`--thinking` 和 `--approval-mode` 参数。 - `--approval-mode yolo` 是 OMP worker 的审批模式,不是 CLI 绕过标志:只有 - profile 明确为 `cli: omp` 且用户显式授权时才由 launcher 构造;仍禁止 - `--auto-approve`,也不适用于 codex/cursor-agent/grok。 + `--approval-mode yolo` 是 OMP worker 的审批模式,不是 CLI 绕过标志:规则层 + 直接允许,是否启用由 `tasks.yaml` profile 的 `approvalMode` 配置决定 + (`yolo` / `write` / `always-ask`,仅 `cli: omp` 可用;缺省 + workspace-write → yolo、read-only → always-ask);仍禁止 `--auto-approve`, + 也不适用于 codex/cursor-agent/grok。 - 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation; 没有可信空闲状态、配置匹配和历史消息清理证明时不复用既有 worker。 - launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index 941ded7..a4e0a1f 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -88,9 +88,11 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的 CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构造。 OMP profile 使用 `cli: omp`;`model` 必须是精确的 `provider/model` 选择器, -`reasoningEffort` 映射为 `--thinking`,`workspace-write` 映射为 -`--approval-mode yolo`,`read-only` 映射为 `--approval-mode always-ask`。 -这是 OMP worker 的显式用户授权策略;launcher 仍不接受 `--auto-approve` 或会话复用。 +`reasoningEffort` 映射为 `--thinking`。可选的 `approvalMode` 字段在 +`tasks.yaml` 中显式控制审批:`yolo` / `write` / `always-ask`,仅对 OMP +profile 有效。缺省时 `workspace-write` 映射为 `--approval-mode yolo`, +`read-only` 映射为 `--approval-mode always-ask`。规则层直接允许 yolo; +launcher 仍不接受 `--auto-approve` 或会话复用。 ### 安全默认 @@ -102,9 +104,10 @@ OMP profile 使用 `cli: omp`;`model` 必须是精确的 `provider/model` 选 **未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。 Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框; 它不关闭 `--sandbox`。 - 例外:OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker - 的审批模式,由 launcher 按 `workspace-write` 固定构造,且必须由用户显式 - 授权;它不授予 full-access,不用于其它 CLI,也不等同于 Grok `--yolo`。 + OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的 + 审批模式,规则层直接允许,是否启用由 `tasks.yaml` profile 的 + `approvalMode` 字段决定(可开可关,缺省 workspace-write 开启);它不授予 + full-access,不用于其它 CLI,也不等同于 Grok `--yolo`。 - 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和 launcher。 diff --git a/skills/ack/scripts/worker_profiles.py b/skills/ack/scripts/worker_profiles.py index da9c27a..060130f 100644 --- a/skills/ack/scripts/worker_profiles.py +++ b/skills/ack/scripts/worker_profiles.py @@ -28,6 +28,7 @@ CLIS = frozenset({"codex", "cursor-agent", "grok", "omp"}) TIERS = frozenset({"standard", "strong"}) REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"}) PERMISSION_MODES = frozenset({"read-only", "workspace-write"}) +OMP_APPROVAL_MODES = frozenset({"yolo", "write", "always-ask"}) ORCHESTRATION_MODES = frozenset({"orca", "manual"}) DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"}) CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"}) @@ -66,6 +67,9 @@ PROFILE_FIELDS = frozenset( "permissionMode", } ) +# approvalMode is optional and valid only for cli=omp: it switches whether the +# OMP worker runs with yolo approval. Rules allow it; tasks.yaml decides. +PROFILE_OPTIONAL_FIELDS = frozenset({"approvalMode"}) RECEIPT_FIELDS = frozenset( { "receiptVersion", @@ -219,7 +223,9 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: if not isinstance(profile, dict): return [f"{where}: must be an object"] - errors = _unknown_fields(profile, PROFILE_FIELDS, where) + errors = _unknown_fields( + profile, PROFILE_FIELDS | PROFILE_OPTIONAL_FIELDS, where + ) errors.extend(_missing_fields(profile, PROFILE_FIELDS, where)) role = profile.get("role") @@ -228,6 +234,18 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: model = profile.get("model") effort = profile.get("reasoningEffort") permission = profile.get("permissionMode") + approval = profile.get("approvalMode") + if "approvalMode" in profile and cli != "omp": + errors.append(f"{where}.approvalMode: only valid for cli=omp") + if approval is not None: + if not isinstance(approval, str) or approval not in OMP_APPROVAL_MODES: + errors.append( + f"{where}.approvalMode: must be yolo/write/always-ask" + ) + elif permission == "read-only" and approval != "always-ask": + errors.append( + f"{where}.approvalMode: read-only requires always-ask" + ) if not isinstance(role, str) or role not in ROLES: errors.append(f"{where}.role: must be developer/test") @@ -503,10 +521,11 @@ def render_worker_argv( return argv if cli == "omp": - # OMP --approval-mode yolo is a worker approval mode, not a CLI bypass flag. - # It applies only to cli=omp and requires explicit user authorization for - # workspace-write profiles; --auto-approve stays forbidden. - omp_approval = "always-ask" if permission == "read-only" else "yolo" + # approvalMode is the explicit per-profile switch (yolo/write/always-ask). + # Default: workspace-write -> yolo, read-only -> always-ask. + omp_approval = profile.get("approvalMode") + if omp_approval is None: + omp_approval = "always-ask" if permission == "read-only" else "yolo" return [ executable, "--model", diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index cbb4351..6b7b448 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -377,6 +377,11 @@ "permissionMode": { "type": "string", "enum": ["read-only", "workspace-write"] + }, + "approvalMode": { + "type": "string", + "enum": ["yolo", "write", "always-ask"], + "description": "OMP-only switch; default workspace-write -> yolo, read-only -> always-ask" } } }, diff --git a/tests/test_ack_omp_worker.py b/tests/test_ack_omp_worker.py index e5996dd..f812044 100644 --- a/tests/test_ack_omp_worker.py +++ b/tests/test_ack_omp_worker.py @@ -15,8 +15,8 @@ import launch_worker # noqa: E402 import worker_profiles # noqa: E402 -def omp_profile(*, role: str = "developer", permission: str = "workspace-write") -> dict: - return { +def omp_profile(*, role: str = "developer", permission: str = "workspace-write", approval: str | None = None) -> dict: + profile = { "role": role, "cli": "omp", "tier": "standard", @@ -24,9 +24,12 @@ def omp_profile(*, role: str = "developer", permission: str = "workspace-write") "reasoningEffort": "low", "permissionMode": permission, } + if approval is not None: + profile["approvalMode"] = approval + return profile -def omp_orchestration() -> dict: +def omp_orchestration(**profile_kwargs: object) -> dict: return { "profileVersion": 1, "mode": "orca", @@ -38,7 +41,7 @@ def omp_orchestration() -> dict: } }, "profiles": { - "omp-dev-standard": omp_profile(), + "omp-dev-standard": omp_profile(**profile_kwargs), "omp-test-standard": omp_profile(role="test"), }, "defaults": { @@ -70,8 +73,8 @@ class OmpProfileValidationTests(unittest.TestCase): self.assertTrue(any("Test cannot define a strong allowlist" in error for error in errors)) -class OmpArgvTests(unittest.TestCase): - def test_workspace_write_uses_write_approval_and_no_session(self) -> None: +class OmpApprovalModeTests(unittest.TestCase): + def test_default_workspace_write_uses_yolo(self) -> None: argv = worker_profiles.render_worker_argv( omp_profile(), "/usr/local/bin/omp", "/repo/demo" ) @@ -91,16 +94,47 @@ class OmpArgvTests(unittest.TestCase): ], ) self.assertNotIn("--auto-approve", argv) - self.assertIn("yolo", argv) self.assertNotIn("--plan-yolo", argv) - def test_read_only_uses_always_ask_approval(self) -> None: + def test_explicit_write_turns_approval_off(self) -> None: + argv = worker_profiles.render_worker_argv( + omp_profile(approval="write"), "/usr/local/bin/omp", "/repo/demo" + ) + self.assertEqual(argv[argv.index("--approval-mode") + 1], "write") + + def test_explicit_always_ask_on_workspace_write(self) -> None: + argv = worker_profiles.render_worker_argv( + omp_profile(approval="always-ask"), "/usr/local/bin/omp", "/repo/demo" + ) + self.assertEqual(argv[argv.index("--approval-mode") + 1], "always-ask") + + def test_read_only_defaults_to_always_ask(self) -> None: argv = worker_profiles.render_worker_argv( omp_profile(permission="read-only"), "/usr/local/bin/omp", "/repo/demo" ) - self.assertIn("--approval-mode", argv) self.assertEqual(argv[argv.index("--approval-mode") + 1], "always-ask") - self.assertEqual(argv[-2:], ["/repo/demo", "--no-session"]) + + def test_read_only_rejects_explicit_yolo(self) -> None: + routing = omp_orchestration(permission="read-only", approval="yolo") + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any("read-only requires always-ask" in error for error in errors)) + + def test_approval_mode_is_only_valid_for_omp(self) -> None: + routing = omp_orchestration() + codex = routing["profiles"]["omp-dev-standard"].copy() + codex["cli"] = "codex" + codex["approvalMode"] = "yolo" + routing["profiles"]["codex-dev-standard"] = codex + routing["modelAllowlist"]["codex"] = { + "developer": {"standard": ["opencode-go/gpt-5.6-luna"]} + } + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any("only valid for cli=omp" in error for error in errors)) + + def test_invalid_approval_value_is_rejected(self) -> None: + routing = omp_orchestration(approval="always-prompt") + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any("must be yolo/write/always-ask" in error for error in errors)) def test_omp_executable_identity_matches_only_omp(self) -> None: self.assertTrue(worker_profiles.executable_basename_matches_cli("/bin/omp", "omp"))