From 8f64be37bb8fa4f47b9122cbb14c8e23c5c2a136 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:07:05 +0800 Subject: [PATCH] feat(cli): add a --read-only mode restricting runs to non-mutating tools for safe parallel investigation (#234) Mutating parallel agents need isolated worktrees (#50) and safety guards (#41), but pure-investigation tasks never mutate and forcing them into isolation is unnecessary overhead, with no guarantee an investigation agent stays read-only. Add an opt-in --read-only mode that restricts an unattended run to read-only tools (list/glob/grep/read) and refuses any mutating tool fail-closed, regardless of approval mode (even yolo) or folder-trust state, so several investigators can share one workspace safely without isolation. The refusal is recorded as a new read_only_denied failure-taxonomy category. --- src/agent.ts | 18 ++++ src/index.ts | 6 ++ src/run-failure-taxonomy.ts | 1 + tests/integration/read-only-mode.test.ts | 107 +++++++++++++++++++ tests/unit/read-only-mode.test.ts | 124 +++++++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 tests/integration/read-only-mode.test.ts create mode 100644 tests/unit/read-only-mode.test.ts diff --git a/src/agent.ts b/src/agent.ts index 0a24250..b5d71a3 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -65,6 +65,10 @@ export interface AgentOptions { // the deterministic task-fixture replay (#224) supplies a scripted provider so // the same fixture always produces the same run. Undefined ⇒ network provider. streamProvider?: StreamProvider; + // Read-only mode (#234): restrict the run to read-only tools and refuse any + // mutating tool fail-closed, regardless of approval mode or folder trust, so + // parallel investigators can safely share one workspace. Defaults to false. + readOnly?: boolean; } // Cumulative usage and cost reported after each round. `estimatedCostUsd` is an @@ -431,6 +435,7 @@ export async function runAgent( preToolUseHooks, opts.bottleneck, opts.failureTaxonomy, + opts.readOnly ?? false, opts.turnImages, ); sink.toolResult({ id: tc.id, name: tc.name, result, round }); @@ -473,6 +478,7 @@ async function executeToolCall( preToolUseHooks: readonly PreToolUseHook[], bottleneck: BottleneckCollector | undefined, failureTaxonomy: FailureTaxonomyCollector | undefined, + readOnly: boolean, turnImages?: TurnImageCollector, ): Promise { const tool = toolMap.get(tc.name); @@ -481,6 +487,18 @@ async function executeToolCall( return { content: `Error: unknown tool "${tc.name}"`, isError: true }; } + // Read-only mode (e.g. parallel investigation, #234) restricts the run to + // read-only tools and refuses any mutating tool fail-closed, regardless of + // approval mode or folder-trust state — yolo cannot widen it. This is what lets + // several investigators share one workspace safely without isolation. + if (readOnly && tool.category !== "read") { + failureTaxonomy?.record("read_only_denied"); + return { + content: "Tool execution denied: read-only mode allows only read-only tools (list, glob, grep, read)", + isError: true, + }; + } + // Folder-trust enforcement runs first and regardless of approval mode, so an // untrusted workspace fails closed for every mutating tool — yolo cannot widen // the boundary. Read-only tools (list/glob/grep/read) are always permitted. diff --git a/src/index.ts b/src/index.ts index 59bc222..c34eeac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -403,6 +403,10 @@ program "--failure-taxonomy", "Print a privacy-safe failure-cause taxonomy report for the run (unattended use)", ) + .option( + "--read-only", + "Restrict the run to read-only tools (list, glob, grep, read); refuse any mutating tool fail-closed (for safe parallel investigation)", + ) .option( "--replay-fixture ", "Replay a deterministic task fixture (bounded prompt + scripted responses) for a reproducible unattended run", @@ -2106,6 +2110,7 @@ program bottleneck: bottleneck?.collector, failureTaxonomy: failureTaxonomy?.collector, streamProvider, + readOnly: Boolean(opts.readOnly), }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -2201,6 +2206,7 @@ program bottleneck: bottleneck?.collector, failureTaxonomy: failureTaxonomy?.collector, streamProvider, + readOnly: Boolean(opts.readOnly), }); sealSession(); recordTurnCheckpoint(turnImages); diff --git a/src/run-failure-taxonomy.ts b/src/run-failure-taxonomy.ts index 5656423..33e71b0 100644 --- a/src/run-failure-taxonomy.ts +++ b/src/run-failure-taxonomy.ts @@ -19,6 +19,7 @@ export const FAILURE_CATEGORIES = [ "hook_denied", "approval_denied", "folder_trust_denied", + "read_only_denied", "path_escape", "unknown_tool", "tool_error", diff --git a/tests/integration/read-only-mode.test.ts b/tests/integration/read-only-mode.test.ts new file mode 100644 index 0000000..3426a37 --- /dev/null +++ b/tests/integration/read-only-mode.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { createFakeServer } from "../fake-provider.js"; +import type { FakeServer } from "../fake-provider.js"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +function runCli( + args: string[], + env: Record, + timeoutMs = 20_000, +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const cliPath = path.resolve(import.meta.dirname, "../../dist/index.js"); + const proc = spawn("node", [cliPath, ...args], { + env: { ...process.env, ...env }, + timeout: timeoutMs, + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => { stdout += d; }); + proc.stderr.on("data", (d) => { stderr += d; }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + proc.on("error", reject); + }); +} + +// The tool result the agent fed back on the follow-up request. +function toolResultContent(requests: Array<{ body: unknown }>, reqIndex: number): string { + const body = requests[reqIndex]?.body as { messages?: Array<{ role: string; content: unknown }> }; + const toolMsgs = (body?.messages ?? []).filter((m) => m.role === "tool"); + return String(toolMsgs[toolMsgs.length - 1]?.content ?? ""); +} + +describe("Integration: read-only mode (--read-only)", () => { + let server: FakeServer; + let tmpDir: string; + let homeDir: string; + let env: Record; + + beforeAll(async () => { + server = await createFakeServer(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-readonly-int-")); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-readonly-int-home-")); + fs.writeFileSync(path.join(tmpDir, "existing.txt"), "hello\n"); + env = { + HOME: homeDir, + OPENAI_API_KEY: "fake-key", + OPENAI_BASE_URL: server.url, + OPENAI_MODEL: "fake-model", + }; + }); + + afterAll(async () => { + await server.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + server.requests.length = 0; + }); + + it("refuses a mutating write fail-closed in read-only mode (even under yolo)", async () => { + server.setResponses([ + { type: "tool_calls", toolCalls: [{ id: "c1", name: "write", arguments: JSON.stringify({ path: "new.txt", content: "hi" }) }] }, + { type: "text", content: "done" }, + ]); + const r = await runCli( + ["-p", "do work", "--read-only", "--approval-mode", "yolo", "--workspace", tmpDir], + env, + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toMatch(/read-only mode allows only read-only tools/); + // The write did not happen. + expect(fs.existsSync(path.join(tmpDir, "new.txt"))).toBe(false); + }); + + it("allows a read-only tool in read-only mode", async () => { + server.setResponses([ + { type: "tool_calls", toolCalls: [{ id: "c1", name: "read", arguments: JSON.stringify({ path: "existing.txt" }) }] }, + { type: "text", content: "done" }, + ]); + const r = await runCli( + ["-p", "investigate", "--read-only", "--approval-mode", "yolo", "--workspace", tmpDir], + env, + ); + expect(r.code).toBe(0); + // The read succeeded and returned the file content. + expect(toolResultContent(server.requests, 1)).toContain("hello"); + }); + + it("allows a mutating write when read-only mode is off (control)", async () => { + server.setResponses([ + { type: "tool_calls", toolCalls: [{ id: "c1", name: "write", arguments: JSON.stringify({ path: "control.txt", content: "hi" }) }] }, + { type: "text", content: "done" }, + ]); + const r = await runCli( + ["-p", "do work", "--approval-mode", "yolo", "--workspace", tmpDir], + env, + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toContain("Wrote"); + expect(fs.existsSync(path.join(tmpDir, "control.txt"))).toBe(true); + }); +}); diff --git a/tests/unit/read-only-mode.test.ts b/tests/unit/read-only-mode.test.ts new file mode 100644 index 0000000..2d2405d --- /dev/null +++ b/tests/unit/read-only-mode.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { runAgent } from "../../src/agent.js"; +import { Workspace } from "../../src/workspace.js"; +import type { StreamProvider, StreamEvent } from "../../src/provider.js"; +import type { AgentSink } from "../../src/agent.js"; + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); +}); + +function git(cwd: string, ...args: string[]): void { + execFileSync("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "ignore"] }); +} + +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-readonly-")); + tmpDirs.push(repo); + execFileSync("git", ["init", "-q", "-b", "main", repo], { stdio: ["ignore", "pipe", "ignore"] }); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "config", "user.name", "Test"); + fs.writeFileSync(path.join(repo, "a.txt"), "base\n"); + git(repo, "add", "a.txt"); + git(repo, "commit", "-q", "-m", "base"); + return repo; +} + +const noopSink: AgentSink = { + assistantDelta: () => {}, + assistantTurn: () => {}, + toolStart: () => {}, + toolResult: () => {}, + providerError: () => {}, + usage: () => {}, + retry: () => {}, +}; + +const usage: StreamEvent = { type: "usage", promptTokens: 5, completionTokens: 5, totalTokens: 10 }; + +// A scripted provider: yields the given events on the first round, then a final +// text response on every subsequent round. +function scriptedProvider(firstRound: StreamEvent[]): StreamProvider { + let call = 0; + return async function* () { + if (call === 0) { + call++; + for (const e of firstRound) yield e; + } else { + yield { type: "text", delta: "done" } as StreamEvent; + yield usage; + } + }; +} + +function writeCallEvent(fileName: string): StreamEvent { + return { + type: "tool_call", + id: "c1", + name: "write", + arguments: JSON.stringify({ path: fileName, content: "hi" }), + }; +} + +function readCallEvent(fileName: string): StreamEvent { + return { + type: "tool_call", + id: "c1", + name: "read", + arguments: JSON.stringify({ path: fileName }), + }; +} + +async function run( + repo: string, + firstRound: StreamEvent[], + opts: { readOnly: boolean; approvalMode: "yolo" | "default" }, +) { + return runAgent("investigate", [], { + config: { apiKey: "test-key", baseUrl: "https://example.com/v1", model: "test-model" }, + workspace: new Workspace(repo), + approvalMode: opts.approvalMode, + sessionId: "test-session", + onMessage: () => {}, + sink: noopSink, + streamProvider: scriptedProvider(firstRound), + readOnly: opts.readOnly, + }); +} + +describe("read-only mode gating", () => { + it("refuses a mutating (write) tool fail-closed in read-only mode", async () => { + const repo = makeRepo(); + const result = await run(repo, [writeCallEvent("x.txt"), usage], { readOnly: true, approvalMode: "yolo" }); + expect(result.stats.toolFailures.write).toBe(1); + // The write did not happen. + expect(fs.existsSync(path.join(repo, "x.txt"))).toBe(false); + }); + + it("refuses a mutating tool even under yolo (read-only is a hard floor)", async () => { + const repo = makeRepo(); + const result = await run(repo, [writeCallEvent("y.txt"), usage], { readOnly: true, approvalMode: "yolo" }); + expect(result.stats.toolFailures.write).toBe(1); + expect(fs.existsSync(path.join(repo, "y.txt"))).toBe(false); + }); + + it("allows a read-only tool in read-only mode", async () => { + const repo = makeRepo(); + const result = await run(repo, [readCallEvent("a.txt"), usage], { readOnly: true, approvalMode: "yolo" }); + // The read succeeded (not counted as a failure). + expect(result.stats.toolFailures.read).toBeUndefined(); + expect(result.stats.toolCalls.read).toBe(1); + }); + + it("allows a mutating tool when read-only mode is off (control)", async () => { + const repo = makeRepo(); + const result = await run(repo, [writeCallEvent("z.txt"), usage], { readOnly: false, approvalMode: "yolo" }); + expect(result.stats.toolFailures.write).toBeUndefined(); + expect(fs.existsSync(path.join(repo, "z.txt"))).toBe(true); + }); +});