diff --git a/pyproject.toml b/pyproject.toml index 023fe6295..11babe3f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ py-modules = ["media", "sol"] [tool.setuptools.package-data] apps = ["*/templates/*.html", "*/talent/*.md"] -think = ["*.md", "*.json", "templates/*.md"] +think = ["*.md", "*.json", "templates/*.md", "policies/*.toml"] talent = ["*.md", "*.py"] observe = ["*.md", "categories/*.md", "transcribe/*.md"] convey = [ diff --git a/tests/test_cogitate_coder.py b/tests/test_cogitate_coder.py index f79b51b26..045a400b2 100644 --- a/tests/test_cogitate_coder.py +++ b/tests/test_cogitate_coder.py @@ -147,8 +147,8 @@ class TestGoogleWriteFlag: return importlib.import_module("think.providers.google") @patch("think.providers.google.CLIRunner") - def test_no_write_uses_plan_mode(self, mock_runner_cls): - """Without write flag, approval-mode is plan (read-only).""" + def test_no_write_uses_yolo_with_policy(self, mock_runner_cls): + """Without write flag, approval-mode is yolo with scoped policy.""" provider = self._provider() mock_instance = AsyncMock() mock_instance.run = AsyncMock(return_value="result") @@ -160,7 +160,9 @@ class TestGoogleWriteFlag: cmd = mock_runner_cls.call_args.kwargs["cmd"] idx = cmd.index("--approval-mode") - assert cmd[idx + 1] == "plan" + assert cmd[idx + 1] == "yolo" + policy_idx = cmd.index("--policy") + assert cmd[policy_idx + 1].endswith("policies/cogitate.toml") @patch("think.providers.google.CLIRunner") def test_write_true_uses_yolo_mode(self, mock_runner_cls): @@ -177,6 +179,15 @@ class TestGoogleWriteFlag: cmd = mock_runner_cls.call_args.kwargs["cmd"] idx = cmd.index("--approval-mode") assert cmd[idx + 1] == "yolo" + assert "--policy" not in cmd + + def test_cogitate_policy_file_exists_on_disk(self): + """The policy path wired into argv must resolve to a real file.""" + from think.providers.google import _COGITATE_POLICY_PATH + + assert _COGITATE_POLICY_PATH.is_file(), ( + f"Expected policy file at {_COGITATE_POLICY_PATH}" + ) # --------------------------------------------------------------------------- diff --git a/tests/test_google_cli.py b/tests/test_google_cli.py index 3306b1b6e..db5cf6ae3 100644 --- a/tests/test_google_cli.py +++ b/tests/test_google_cli.py @@ -29,6 +29,7 @@ def _assert_write_mode_uses_yolo_approval(make_runner): cmd = MockCLIRunner.last_instance.cmd idx = cmd.index("--approval-mode") assert cmd[idx + 1] == "yolo" + assert "--policy" not in cmd class TestTranslateGemini: @@ -329,7 +330,7 @@ class TestRunCogitateCommand: return MockCLIRunner - def test_yolo_mode_with_sol_allowed(self): + def test_no_write_uses_yolo_with_policy(self): provider = _google_provider() MockCLIRunner = self._mock_runner() with patch("think.providers.google.CLIRunner", MockCLIRunner): @@ -340,7 +341,9 @@ class TestRunCogitateCommand: ) cmd = MockCLIRunner.last_instance.cmd idx = cmd.index("--approval-mode") - assert cmd[idx + 1] == "plan" + assert cmd[idx + 1] == "yolo" + policy_idx = cmd.index("--policy") + assert cmd[policy_idx + 1].endswith("policies/cogitate.toml") def test_write_mode_uses_yolo_approval(self): _assert_write_mode_uses_yolo_approval(self._mock_runner) diff --git a/think/policies/cogitate.toml b/think/policies/cogitate.toml new file mode 100644 index 000000000..591f366e6 --- /dev/null +++ b/think/policies/cogitate.toml @@ -0,0 +1,32 @@ +# Scoped-yolo policy for non-write cogitate talents. +# +# Posture: start from yolo's default-allow, then subtract the two behaviors +# we don't want — direct filesystem writes, and arbitrary shell commands. +# Run `sol` invocations (including pipelines like `echo ... | sol call ...`) +# are the one shell surface cogitate talents legitimately need. +# +# Priority: highest matching priority wins regardless of allow/deny. The +# allow rule for sol run_shell_command (priority 200) overrides the blanket +# run_shell_command deny (priority 100). User-tier rules here override the +# engine's built-in yolo catch-all. +# +# Rationale: vpe/workspace/gemini-cli-tool-hallucination-research.md — plan +# mode strips run_shell_command from the tool registry, which caused the +# tool-name hallucination loop we saw in cortex. Scoped yolo keeps the +# registry intact without widening the blast radius to direct writes. + +[[rule]] +toolName = ["write_file", "replace"] +decision = "deny" +priority = 200 + +[[rule]] +toolName = "run_shell_command" +commandRegex = "(^sol\\s|\\bsol call\\b)" +decision = "allow" +priority = 200 + +[[rule]] +toolName = "run_shell_command" +decision = "deny" +priority = 100 diff --git a/think/providers/google.py b/think/providers/google.py index b09f3d5ee..4090c7325 100644 --- a/think/providers/google.py +++ b/think/providers/google.py @@ -65,6 +65,8 @@ logger = logging.getLogger(__name__) # Backend detection cache _detected_backend: str | None = None +_COGITATE_POLICY_PATH = Path(__file__).parent.parent / "policies" / "cogitate.toml" + def _structured_to_google_contents( messages: list[dict[str, str]], @@ -749,14 +751,17 @@ async def run_cogitate( if system_instruction: prompt_body = system_instruction + "\n\n" + prompt_body - # Build CLI command. approval-mode controls tool access: - # "yolo" — auto-approve all tools (write-enabled agents only) - # "plan" — read-only mode (no file writes, no destructive tools) - # The deprecated --allowed-tools flag did NOT restrict tool - # availability, only auto-approval — combined with --yolo it - # provided zero protection. --approval-mode plan is the - # replacement that actually enforces read-only. - approval = "yolo" if config.get("write") else "plan" + # Approval posture: + # - Write-enabled talents (coder) run unpolicied yolo: full tool registry, + # write_file / replace allowed. + # - Read-only cogitate talents run yolo + a scoped policy: full tool + # registry (no plan-mode stripping), but write_file / replace denied + # and run_shell_command narrowed to `sol` invocations. + # Plan mode strips run_shell_command from the registry, which drove the + # tool-name hallucination loop documented in + # vpe/workspace/gemini-cli-tool-hallucination-research.md. Deprecated + # --allowed-tools controls auto-approval, not availability, so it can't + # replace the policy file for this purpose. cmd = [ "gemini", "-p", @@ -764,11 +769,13 @@ async def run_cogitate( "-o", "stream-json", "--approval-mode", - approval, + "yolo", "-m", model, "--sandbox=none", ] + if not config.get("write"): + cmd.extend(["--policy", str(_COGITATE_POLICY_PATH)]) # Resume from previous session if continuing if session_id: