Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -473,6 +478,7 @@ async function executeToolCall(
preToolUseHooks: readonly PreToolUseHook[],
bottleneck: BottleneckCollector | undefined,
failureTaxonomy: FailureTaxonomyCollector | undefined,
readOnly: boolean,
turnImages?: TurnImageCollector,
): Promise<ToolResult> {
const tool = toolMap.get(tc.name);
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>",
"Replay a deterministic task fixture (bounded prompt + scripted responses) for a reproducible unattended run",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2201,6 +2206,7 @@ program
bottleneck: bottleneck?.collector,
failureTaxonomy: failureTaxonomy?.collector,
streamProvider,
readOnly: Boolean(opts.readOnly),
});
sealSession();
recordTurnCheckpoint(turnImages);
Expand Down
1 change: 1 addition & 0 deletions src/run-failure-taxonomy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const FAILURE_CATEGORIES = [
"hook_denied",
"approval_denied",
"folder_trust_denied",
"read_only_denied",
"path_escape",
"unknown_tool",
"tool_error",
Expand Down
107 changes: 107 additions & 0 deletions tests/integration/read-only-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>,
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<string, string>;

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);
});
});
124 changes: 124 additions & 0 deletions tests/unit/read-only-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading