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
15 changes: 12 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

34 changes: 31 additions & 3 deletions src/agent/batch-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,34 @@ describe("executeBatch", () => {
expect(elapsed).toBeLessThan(250);
});

it("hands the step's readRoots to every call's tool context, unchanged", async () => {
// The read scope (`src/tools/read-scope/`) widens by what the user
// named; the step computes that once and the batch must not lose it.
const seen: (readonly string[] | undefined)[] = [];
const registry = new ToolRegistry();
registry.register({
name: "os.fs.read",
description: "read",
readonly: true,
run: async (_args, toolCtx) => {
seen.push(toolCtx.readRoots);
return okResult("os.fs.read");
},
});
const inputs = toBatchInputs([
{ tool: "os.fs.read", args: { path: "a" } },
{ tool: "os.fs.read", args: { path: "b" } },
]);
const ctrl = new AbortController();
await executeBatch(inputs, registry, {
...ctx(ctrl.signal),
readRoots: ["/named/one"],
});
expect(seen).toEqual([["/named/one"], ["/named/one"]]);
await executeBatch(inputs.slice(0, 1), registry, ctx(ctrl.signal));
expect(seen[2]).toBeUndefined();
});

it("chunks pure_read fan-out into bounded waves when maxWaveSize is set", async () => {
// 5 reads with a wave size of 2 → waves of [0,1], [2,3], [4]. Track
// peak concurrency: it must never exceed 2, and all 5 must run.
Expand Down Expand Up @@ -1613,12 +1641,12 @@ describe("executeBatch refuses a call with unknown argument keys (F40)", () => {
const result = out.results[0]!.compressed!;
expect(result.status).toBe("error");
expect(result.summary).toBe(
'unknown argument `-e` for os.shell.run (expected: cmd, args, cwd, timeoutMs; put the script in args: ["-c", "…"]) — the call was not run; re-emit it with the right keys',
'unknown argument `-e` for os.shell.run (expected: cmd, args, cwd, timeoutMs, keep, wait, kill, jobs; put the script in args: ["-c", "…"]) — the call was not run; re-emit it with the right keys',
);
expect(result.summary).not.toContain("rename");
expect(result.details).toEqual({
unknownKeys: ["-e"],
expectedKeys: ["cmd", "args", "cwd", "timeoutMs"],
expectedKeys: ["cmd", "args", "cwd", "timeoutMs", "keep", "wait", "kill", "jobs"],
});
expect(out.cancelled).toBe(false);
});
Expand All @@ -1638,7 +1666,7 @@ describe("executeBatch refuses a call with unknown argument keys (F40)", () => {
);
expect(run).not.toHaveBeenCalled();
expect(out.results[0]!.compressed!.summary).toBe(
"unknown argument `-args` for os.shell.run (expected: cmd, args, cwd, timeoutMs; did you mean `args`?) — the call was not run; re-emit it with the right keys",
"unknown argument `-args` for os.shell.run (expected: cmd, args, cwd, timeoutMs, keep, wait, kill, jobs; did you mean `args`?) — the call was not run; re-emit it with the right keys",
);
});

Expand Down
7 changes: 7 additions & 0 deletions src/agent/batch-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ export interface BatchExecutionContext {
sessionId: string;
stepIndex: number;
signal: AbortSignal;
/**
* The paths the user named in this session's messages, for the read
* scope (`ToolContext.readRoots`). Computed by the step from the
* transcript and handed to every call of the batch unchanged.
*/
readRoots?: readonly string[];
/**
* Fired immediately before the registry is invoked for each call.
* Order: matches the order the executor reaches each call (within a
Expand Down Expand Up @@ -421,6 +427,7 @@ export async function executeBatch(
stepIndex: ctx.stepIndex,
signal: ctx.signal,
...(ctx.toolRole !== undefined ? { toolRole: ctx.toolRole } : {}),
...(ctx.readRoots !== undefined ? { readRoots: ctx.readRoots } : {}),
});
} catch (err) {
if (ctx.signal.aborted) {
Expand Down
6 changes: 6 additions & 0 deletions src/agent/step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
toolResultTurn,
} from "../session/conversation-turn.js";
import type { ToolRegistry } from "../tools/tool-registry.js";
import { userNamedPaths } from "../tools/read-scope/index.js";
import { hashPrefix, type SlotManager } from "../llm/slot-manager.js";
import {
NO_SERVER_TEMPLATE,
Expand Down Expand Up @@ -1487,12 +1488,17 @@ async function executeStepInner(
// these is short-circuited inside `executeBatch` with a terse pointer
// instead of re-reading and re-dumping the body.
const loadedSkillNames = new Set(ctx.session.loadedSkills.map((s) => s.name));
// The paths the user named so far, for the read scope: re-read from the
// transcript every step so a path named mid-turn (steering) counts on
// the next call, and nothing the model wrote ever widens it.
const readRoots = userNamedPaths(ctx.session.turns);
const runBatch = runInOrder ? executeCallsInOrder : executeBatch;
const batchOutcome = await runBatch(inputs, deps.registry, {
workingDir: ctx.session.workingDir,
sessionId: ctx.session.id,
stepIndex: ctx.stepIndex,
signal: ctx.signal,
...(readRoots.length > 0 ? { readRoots } : {}),
...(deps.tracker ? { tracker: deps.tracker } : {}),
...(ctx.terminalOnly ? { terminalOnly: true } : {}),
...(deps.isPlanMode ? { isPlanMode: deps.isPlanMode } : {}),
Expand Down
11 changes: 11 additions & 0 deletions src/approval/approval-gate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FanoutScopeRegistry } from "./fanout-scope.js";
import { ReadScopeGrants } from "./read-scope-grants.js";
import { randomUUID } from "node:crypto";
import {
clampApprovalLevel,
Expand Down Expand Up @@ -164,6 +165,15 @@ export class ApprovalGate {
*/
readonly fanoutScopes = new FanoutScopeRegistry();

/**
* Directories a session may read in without asking again — the `y`
* of an `fs_read_outside` prompt, remembered per session (see
* `read-scope-grants.ts`). A session grant in every sense but its
* unit (a path, not a category), so it lives here and is dropped by
* `clearSessionGrants` with the rest.
*/
readonly readScopeGrants = new ReadScopeGrants();

constructor(options: { emit: ApprovalEmitter; level?: ApprovalLevel }) {
this.emitter = options.emit;
this.level = options.level ?? MIN_APPROVAL_LEVEL;
Expand Down Expand Up @@ -207,6 +217,7 @@ export class ApprovalGate {
* standing level is untouched: it is a durable posture, grants are not.
*/
clearSessionGrants(sessionId?: string): void {
this.readScopeGrants.clear(sessionId);
if (sessionId === undefined) {
this.grantsBySession.clear();
return;
Expand Down
8 changes: 8 additions & 0 deletions src/approval/approval-level.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const ALL_CATEGORIES: readonly ApprovalCategory[] = [
"browser_nonweb",
"trust_config",
"email",
"fs_read_outside",
"other",
];

Expand Down Expand Up @@ -66,6 +67,9 @@ describe("approval ladder", () => {
browser_nonweb: 5,
trust_config: 5,
email: 5,
// Reads outside the working directory ask until full trust: a
// wandering read is what the read scope exists to stop.
fs_read_outside: 5,
other: 5,
};
for (const [category, from] of Object.entries(silentFrom) as [
Expand Down Expand Up @@ -94,6 +98,9 @@ describe("approval ladder", () => {
expect(formatApprovalCategory("trust_config")).toBe("agent trust config");
expect(formatApprovalCategory("shell")).toBe("shell command");
expect(formatApprovalCategory("git_remote")).toBe("git · remote");
expect(formatApprovalCategory("fs_read_outside")).toBe(
"read outside the working directory",
);
});

it("level 1 asks for every category and level 5 for none (cumulative ladder)", () => {
Expand All @@ -109,6 +116,7 @@ describe("approval ladder", () => {
"browser_nonweb",
"trust_config",
"email",
"fs_read_outside",
"other",
];
for (const category of categories) {
Expand Down
20 changes: 18 additions & 2 deletions src/approval/approval-level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ export type ApprovalCategory =
* unrelated prompt should be able to silence it.
*/
| "fusion_fanout"
/**
* A filesystem read (or a shell command naming a path) outside the
* session's working directory and the paths the user named
* (`src/tools/read-scope/`). Pinned at level 5: wandering reads are
* what the scope exists to stop, so nothing short of full trust runs
* them unasked. A `y` at the prompt also widens the session's read
* roots to the directory it named (`ReadScopeGrants`), so one answer
* covers the reads that follow under it.
*/
| "fs_read_outside"
| "other";

/**
Expand All @@ -67,8 +77,8 @@ export type ApprovalCategory =
* stricter than the escape hatch. The remote-sync switch
* (`git.remoteSync`) is checked before this ladder is consulted.
* - level 5 (full trust): everything, including browser navigation to
* non-web URLs, writes to the agent's own trust config, and
* uncategorised requests.
* non-web URLs, writes to the agent's own trust config, reads outside
* the working directory, and uncategorised requests.
*
* `trust_config` is deliberately pinned at 5: a write to the file that
* holds `agent.approvalLevel` (or the `.env` holding API tokens) is the
Expand All @@ -91,6 +101,7 @@ const AUTO_APPROVE_FROM_LEVEL: Record<ApprovalCategory, ApprovalLevel> = {
browser_nonweb: 5,
trust_config: 5,
email: 5,
fs_read_outside: 5,
other: 5,
};

Expand Down Expand Up @@ -154,6 +165,10 @@ const GRANTABLE_CATEGORY: Record<ApprovalCategory, boolean> = {
// A session grant would let the agent mail anyone for the rest of
// the session; each mail is its own decision.
email: false,
// "Read anywhere this session" is a legitimate answer for an operator
// who would otherwise set `agent.readScope: unrestricted`; the
// narrower answer, one directory, is the prompt's plain `y`.
fs_read_outside: true,
other: true,
};

Expand Down Expand Up @@ -182,6 +197,7 @@ export const APPROVAL_CATEGORY_LABELS: Record<ApprovalCategory, string> = {
browser_nonweb: "browser · non-web URL",
trust_config: "agent trust config",
email: "e-mail send",
fs_read_outside: "read outside the working directory",
other: "uncategorised",
};

Expand Down
1 change: 1 addition & 0 deletions src/approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
resolveBootApprovalLevel,
} from "./approval-level.js";
export type { ApprovalCategory, ApprovalLevel } from "./approval-level.js";
export { ReadScopeGrants } from "./read-scope-grants.js";
export { ApprovalRouter } from "./approval-router.js";
export type { ApprovalHandler } from "./approval-router.js";
export { requireApproval, ApprovalDeniedError } from "./dangerous-tool.js";
Expand Down
53 changes: 53 additions & 0 deletions src/approval/read-scope-grants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";

import { ApprovalGate } from "./approval-gate.js";
import { ReadScopeGrants } from "./read-scope-grants.js";

describe("ReadScopeGrants", () => {
it("remembers a directory per session and keeps sessions apart", () => {
const grants = new ReadScopeGrants();
grants.widen("s-1", "/srv/homes/me/Desktop");
expect(grants.rootsFor("s-1")).toEqual(["/srv/homes/me/Desktop"]);
expect(grants.rootsFor("s-2")).toEqual([]);
});

it("keeps the roots a disjoint set: a covered directory is a no-op, a wider one folds the narrower in", () => {
const grants = new ReadScopeGrants();
grants.widen("s", "/srv/homes/me/Desktop");
grants.widen("s", "/srv/homes/me/Desktop/reports");
expect(grants.rootsFor("s")).toEqual(["/srv/homes/me/Desktop"]);
grants.widen("s", "/srv/homes/me");
expect(grants.rootsFor("s")).toEqual(["/srv/homes/me"]);
// A sibling is its own root; a lookalike prefix is not containment.
grants.widen("s", "/srv/homes/me-backup");
expect(grants.rootsFor("s")).toEqual(["/srv/homes/me", "/srv/homes/me-backup"]);
});

it("ignores a relative directory", () => {
const grants = new ReadScopeGrants();
grants.widen("s", "Desktop");
expect(grants.rootsFor("s")).toEqual([]);
});

it("clears one session or every session", () => {
const grants = new ReadScopeGrants();
grants.widen("s-1", "/a");
grants.widen("s-2", "/b");
grants.clear("s-1");
expect(grants.rootsFor("s-1")).toEqual([]);
expect(grants.rootsFor("s-2")).toEqual(["/b"]);
grants.clear();
expect(grants.rootsFor("s-2")).toEqual([]);
});

it("is dropped by the gate's clearSessionGrants, both forms, like the category grants", () => {
const gate = new ApprovalGate({ emit: () => undefined });
gate.readScopeGrants.widen("s-1", "/a");
gate.readScopeGrants.widen("s-2", "/b");
gate.clearSessionGrants("s-1");
expect(gate.readScopeGrants.rootsFor("s-1")).toEqual([]);
expect(gate.readScopeGrants.rootsFor("s-2")).toEqual(["/b"]);
gate.clearSessionGrants();
expect(gate.readScopeGrants.rootsFor("s-2")).toEqual([]);
});
});
67 changes: 67 additions & 0 deletions src/approval/read-scope-grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { isAbsolute, resolve } from "node:path";

import { isInside } from "./fanout-scope.js";

/**
* Directories a session may READ in without being asked again.
*
* A read outside the working directory and the paths the user named
* asks through the ladder as `fs_read_outside` (`src/tools/read-scope/`).
* The operator's plain `y` is not a one-shot: it names a directory —
* the one the model reached for, or the parent of the file it reached
* for — and every later read under that directory in the same session
* runs unasked. One question per place, not one per file: a model
* summarising a folder of reports would otherwise ask once per report,
* and answering the same question fifty times is attrition, not
* consent.
*
* **Why this is not an `ApprovalGate` category grant.** A category
* grant (`[s]`) is "read anywhere this session" — the honest scope for
* an operator who would otherwise set `agent.readScope: unrestricted`,
* and it is offered too. This registry is the narrower answer, and it
* has to be keyed by a path, which the gate's grants have nowhere to
* hold (the same reason `fanout-scope.ts` gives). It lives on the gate
* beside them so it shares their lifetime: `clearSessionGrants` drops
* both when the operator leaves the session.
*
* The roots here join the session's working directory and user-named
* paths (`ToolContext.readRoots`) when the read scope is checked; they
* are never written back into the transcript, so a saved session starts
* its next run with only what the user named.
*/
export class ReadScopeGrants {
private readonly rootsBySession = new Map<string, string[]>();

/**
* Remember that `sessionId` may read under `dir`. A directory already
* covered by an earlier root is a no-op; a root the new one covers is
* folded into it, so the list stays a set of disjoint roots.
*/
widen(sessionId: string, dir: string): void {
if (!isAbsolute(dir)) return;
const root = resolve(dir);
const roots = this.rootsBySession.get(sessionId) ?? [];
if (roots.some((known) => isInside(known, root))) return;
this.rootsBySession.set(sessionId, [
...roots.filter((known) => !isInside(root, known)),
root,
]);
}

/** The directories `sessionId` was granted, in the order they were. */
rootsFor(sessionId: string): readonly string[] {
return this.rootsBySession.get(sessionId) ?? [];
}

/**
* Drop a session's roots — or every session's, with no argument — the
* same two forms `ApprovalGate.clearSessionGrants` takes.
*/
clear(sessionId?: string): void {
if (sessionId === undefined) {
this.rootsBySession.clear();
return;
}
this.rootsBySession.delete(sessionId);
}
}
Loading