From 28e72d44f4a7be12485cd9bc514e31c1da5626f8 Mon Sep 17 00:00:00 2001 From: Aei <256851514+aeitwoen@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:37:48 +0200 Subject: [PATCH] feat(claude): confine read-only runs to Read, Glob and Grep --- README.md | 2 +- docs/app/data/harnesses.json | 23 +++++++++++++-- docs/content/1.guide/03.invoke.md | 2 +- docs/content/1.guide/10.explorer.md | 2 +- docs/content/2.harnesses/00.index.md | 6 ++-- docs/content/2.harnesses/01.claude.md | 2 +- src/harnesses/claude.ts | 13 ++++++++- test/invoke.test.ts | 42 ++++++++++++++++++++++++--- 8 files changed, 77 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a5b4687..a593b68 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ harnesses agents sync --check # doctor: link global AGENTS.md files to one mas harnesses mcp # run the MCP server over stdio ``` -`tools` defaults to `false` in the library and CLI. The MCP, Pi, and OMP tools require agents to choose it explicitly. `false` must use a native CLI flag that removes tools from the model context; it is a lightweight advisor, not an agent constrained only by prompt wording. Set `tools: true` (or CLI `--tools`) whenever the task needs harness tools, including Grok's native X search. Add `readOnly: true` when those tools must stay inside a sandbox enforced by the harness CLI; the agent tools pass it beside `tools: true`, while the library and CLI let it imply tools. Read-only mode is rejected when a harness has no verified native recipe, so it never falls back to broader access. A recipe can also carry the lowest CLI version whose enforcement was verified, and `invoke()` rejects read-only runs on older or unknown versions: Grok runs `--sandbox read-only` from 1.0.13. Harnesses whose CLI cannot disable tools reject advisor mode instead of silently running an agent and return an explicit `tools` retry when their full agent mode can handle the request. +`tools` defaults to `false` in the library and CLI. The MCP, Pi, and OMP tools require agents to choose it explicitly. `false` must use a native CLI flag that removes tools from the model context; it is a lightweight advisor, not an agent constrained only by prompt wording. Set `tools: true` (or CLI `--tools`) whenever the task needs harness tools, including Grok's native X search. Add `readOnly: true` when those tools must stay inside a sandbox enforced by the harness CLI; the agent tools pass it beside `tools: true`, while the library and CLI let it imply tools. Read-only mode is rejected when a harness has no verified native recipe, so it never falls back to broader access. A recipe can also carry the lowest CLI version whose enforcement was verified, and `invoke()` rejects read-only runs on older or unknown versions: Grok runs `--sandbox read-only` from 1.0.13, and Claude Code keeps read-only runs on `Read`, `Glob` and `Grep` with `--strict-mcp-config` from 2.1.175. Harnesses whose CLI cannot disable tools reject advisor mode instead of silently running an agent and return an explicit `tools` retry when their full agent mode can handle the request. ## How harnesses compares to unagent diff --git a/docs/app/data/harnesses.json b/docs/app/data/harnesses.json index d29afe9..f3ba00d 100644 --- a/docs/app/data/harnesses.json +++ b/docs/app/data/harnesses.json @@ -226,18 +226,35 @@ "--tools", "" ], + "readOnlyArgs": [ + "-p", + "{prompt}", + "--strict-mcp-config", + "--tools", + "Read,Glob,Grep" + ], + "readOnlyJsonArgs": [ + "-p", + "--output-format", + "json", + "{prompt}", + "--strict-mcp-config", + "--tools", + "Read,Glob,Grep" + ], + "readOnlyMinVersion": "2.1.175", "modelArgs": [ "--model", "{model}" ], "level": "official", - "note": "Headless print mode; add --output-format json for structured output." + "note": "Headless print mode; add --output-format json for structured output. Read-only runs keep the built-in Read, Glob and Grep tools and --strict-mcp-config drops every configured MCP server, so no write tool exists whatever permission mode the settings carry; verified on 2.1.175 and 2.1.268. --permission-mode plan does not qualify: its shell commands run through the auto mode classifier, which let a file write through." }, "invocationModes": { "advisor": true, "advisorStructured": true, - "readOnly": false, - "readOnlyStructured": false, + "readOnly": true, + "readOnlyStructured": true, "agent": true, "agentStructured": true }, diff --git a/docs/content/1.guide/03.invoke.md b/docs/content/1.guide/03.invoke.md index a80e41c..ff7726f 100644 --- a/docs/content/1.guide/03.invoke.md +++ b/docs/content/1.guide/03.invoke.md @@ -40,7 +40,7 @@ result.exitCode; // number, or null when stopped by a timeout or a signal `readOnly` implies `tools`. A harness that lacks the template for the requested mode rejects the call and says which retry would work: ``` -Harness claude has no read-only full agent invocation +Harness gemini has no read-only full agent invocation Harness codex has no advisor without tools invocation; retry with tools: true to start its full agent ``` diff --git a/docs/content/1.guide/10.explorer.md b/docs/content/1.guide/10.explorer.md index 8c73ad1..d2dbf9a 100644 --- a/docs/content/1.guide/10.explorer.md +++ b/docs/content/1.guide/10.explorer.md @@ -10,7 +10,7 @@ Nothing is spawned and nothing is read from your machine. One liberty: on Window ```bash harnesses paths claude -harnesses run claude --read-only "…" # rejected, and the explorer shows you why before you try +harnesses run gemini --read-only "…" # rejected, and the explorer shows you why before you try ``` Every state of the page is in the URL, so a link to `/explorer?id=codex&platform=win32&mode=readOnly` opens exactly that. "Where does Grok keep sessions on a Mac" is a question a page answers faster than a file in `src/harnesses/`, and that's the whole reason it exists. diff --git a/docs/content/2.harnesses/00.index.md b/docs/content/2.harnesses/00.index.md index bcabbbb..f8686e2 100644 --- a/docs/content/2.harnesses/00.index.md +++ b/docs/content/2.harnesses/00.index.md @@ -11,19 +11,19 @@ Each page is generated from the same registry the package ships, so a path here | Harness | ID | Detection | Headless | Sessions | | --- | --- | --- | --- | --- | | [Antigravity CLI](/harnesses/antigravity) | `antigravity` | project | agent | Protocol Buffers, JSONL, SQLite | -| [Claude Code](/harnesses/claude) | `claude` | env + project | advisor, agent | JSONL | +| [Claude Code](/harnesses/claude) | `claude` | env + project | advisor, read-only, agent | JSONL | | [Codex CLI](/harnesses/codex) | `codex` | project | read-only, agent | JSONL, SQLite | | [Cursor](/harnesses/cursor) | `cursor` | env + project | agent | - | | [Freebuff](/harnesses/freebuff) | `freebuff` | project | - | JSON, JSONL | | [Gemini CLI](/harnesses/gemini) | `gemini` | env + project | agent | JSON | | [GitHub Copilot](/harnesses/github-copilot) | `github-copilot` | env + project | agent | - | -| [Grok CLI](/harnesses/grok) | `grok` | env + project | agent | JSONL, JSON, SQLite | +| [Grok CLI](/harnesses/grok) | `grok` | env + project | read-only, agent | JSONL, JSON, SQLite | | [Mastra Code](/harnesses/mastracode) | `mastracode` | project | - | SQLite | | [OMP](/harnesses/omp) | `omp` | env + project | agent | JSONL, SQLite | | [OpenCode](/harnesses/opencode) | `opencode` | project | agent | SQLite | | [Pi](/harnesses/pi) | `pi` | env + project | advisor, read-only, agent | JSONL | -"Headless" lists the mode families `invoke()` can run; each has a structured variant where the CLI has a JSON output flag. Read-only means a sandbox the CLI enforces itself, which today is Codex's `--sandbox read-only` and Pi's tool allowlist. Copilot is the one harness with an agent mode and no structured variant. +"Headless" lists the mode families `invoke()` can run; each has a structured variant where the CLI has a JSON output flag. Read-only means the CLI itself keeps the run from writing: Codex and Grok through `--sandbox read-only`, Pi and Claude Code through a tool allowlist. Copilot is the one harness with an agent mode and no structured variant. ## Audio and video diff --git a/docs/content/2.harnesses/01.claude.md b/docs/content/2.harnesses/01.claude.md index f06ee78..e7c267f 100644 --- a/docs/content/2.harnesses/01.claude.md +++ b/docs/content/2.harnesses/01.claude.md @@ -8,7 +8,7 @@ description: Anthropic's CLI. Headless -p mode with a real advisor, JSONL transc Claude Code is the harness most of the others borrow from: Cursor, Copilot, OpenCode and Mastra Code read `.claude/skills/`, Grok loads `CLAUDE.md`. Its own layout is settings in JSON at user, project, local and managed scopes, transcripts under `~/.claude/projects/` in a directory named after the dash-encoded working directory, and MCP servers in `~/.claude.json` rather than in the settings file, which surprises everyone once. -`--tools ""` removes every tool from the model, so the advisor mode is real. There is no native read-only sandbox, so `readOnly: true` is rejected rather than approximated with a permission prompt you'd never see in headless mode. +`--tools ""` removes every tool from the model, so the advisor mode is real. `--tools Read,Glob,Grep` with `--strict-mcp-config` is the read-only mode: three inspection tools and no MCP servers, so nothing is left that writes. Plan mode is not it. It refuses Write and MCP calls, but a shell redirect went through the auto mode classifier and landed on disk. ::harness-sheet{id="claude"} :: diff --git a/src/harnesses/claude.ts b/src/harnesses/claude.ts index 2ee3f9e..94978cd 100644 --- a/src/harnesses/claude.ts +++ b/src/harnesses/claude.ts @@ -126,9 +126,20 @@ export default class Claude extends Harness { jsonArgs: ["-p", "--output-format", "json", "{prompt}"], noToolsArgs: ["-p", "{prompt}", "--tools", ""], noToolsJsonArgs: ["-p", "--output-format", "json", "{prompt}", "--tools", ""], + readOnlyArgs: ["-p", "{prompt}", "--strict-mcp-config", "--tools", "Read,Glob,Grep"], + readOnlyJsonArgs: [ + "-p", + "--output-format", + "json", + "{prompt}", + "--strict-mcp-config", + "--tools", + "Read,Glob,Grep", + ], + readOnlyMinVersion: "2.1.175", modelArgs: ["--model", "{model}"], level: "official", - note: "Headless print mode; add --output-format json for structured output.", + note: "Headless print mode; add --output-format json for structured output. Read-only runs keep the built-in Read, Glob and Grep tools and --strict-mcp-config drops every configured MCP server, so no write tool exists whatever permission mode the settings carry; verified on 2.1.175 and 2.1.268. --permission-mode plan does not qualify: its shell commands run through the auto mode classifier, which let a file write through.", }; override readonly mcpConfigs: Harness["mcpConfigs"] = [ { diff --git a/test/invoke.test.ts b/test/invoke.test.ts index ec88eb0..4bb9365 100644 --- a/test/invoke.test.ts +++ b/test/invoke.test.ts @@ -97,6 +97,40 @@ describe("normalized invocation", () => { }); }); + it("keeps Claude read-only runs on its built-in inspection tools", () => { + const claude = getHarness("claude"); + + expect(claude.invocation?.readOnlyMinVersion).toBe("2.1.175"); + expect(claude.buildInvocation("review this", { readOnly: true })).toEqual({ + command: "claude", + args: ["-p", "review this", "--strict-mcp-config", "--tools", "Read,Glob,Grep"], + }); + expect(claude.buildInvocation("review this", { readOnly: true, structured: true })).toEqual({ + command: "claude", + args: [ + "-p", + "--output-format", + "json", + "review this", + "--strict-mcp-config", + "--tools", + "Read,Glob,Grep", + ], + }); + expect(claude.buildInvocation("review this", { readOnly: true, model: "sonnet" })).toEqual({ + command: "claude", + args: [ + "-p", + "review this", + "--strict-mcp-config", + "--tools", + "Read,Glob,Grep", + "--model", + "sonnet", + ], + }); + }); + it("uses the full agent invocation only when tools are enabled", () => { const claude = getHarness("claude"); expect(claude.buildInvocation("do the thing", { tools: true })).toEqual({ @@ -176,7 +210,7 @@ describe("normalized invocation", () => { }); it("rejects read-only access when a harness cannot enforce it", () => { - expect(getHarness("claude").invocationError({ readOnly: true })).toContain( + expect(getHarness("gemini").invocationError({ readOnly: true })).toContain( "no read-only full agent invocation", ); }); @@ -483,8 +517,8 @@ describe("harness metadata for agents", () => { expect(getHarness("claude").invocationModes).toEqual({ advisor: true, advisorStructured: true, - readOnly: false, - readOnlyStructured: false, + readOnly: true, + readOnlyStructured: true, agent: true, agentStructured: true, }); @@ -721,7 +755,7 @@ describe("runHarness tool operation", () => { }); it("keeps unsupported read-only access from widening to a full agent", async () => { - const result = await runHarness("claude", "inspect", { tools: true, readOnly: true }); + const result = await runHarness("omp", "inspect", { tools: true, readOnly: true }); expect(result.isError).toBe(true); expect(result.content[0]?.text).toContain("no read-only full agent invocation");