Skip to content

Commit a2a233c

Browse files
committed
Unify tool-name classification constants (CL-6809)
Three READ_TOOLS constants had drifted apart under the same name, one of them (permission's auto-allow gate) security-relevant. Add src/agent/tool-classification.ts as the single source of truth: - AUTO_ALLOW_READ_TOOLS: derived from the director read surface minus run_shell/web_fetch/web_search (which have their own auto-allow rules) plus manage_tasks. Replaces classify.ts's inline READ_ONLY_TOOLS with the identical membership — no auto-allow behavior change. - PATH_KEYED_READ_TOOLS: the {read_file} set that was defined identically in both compactor.ts and thrash.ts, now shared. - SEARCH_QUERY_TOOLS: the {grep, search_files} base shared by compaction's QUERY_TOOLS (adds list_dir) and thrash's SEARCH_TOOLS (deliberately omits it — a repeated list_dir isn't the stuck read/search loop thrash watches for). Adds a pinning test so future drift fails CI instead of spreading silently.
1 parent d1594a2 commit a2a233c

5 files changed

Lines changed: 108 additions & 16 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
AUTO_ALLOW_READ_TOOLS,
4+
PATH_KEYED_READ_TOOLS,
5+
SEARCH_QUERY_TOOLS,
6+
} from "./tool-classification.js";
7+
8+
// Pins membership so a future edit to any of these sets — or to the director
9+
// READ_TOOLS they derive from — fails CI instead of silently drifting one
10+
// call site out of sync with the others (CL-6809).
11+
describe("AUTO_ALLOW_READ_TOOLS", () => {
12+
test("gates auto-allow with exactly this membership", () => {
13+
expect([...AUTO_ALLOW_READ_TOOLS].sort()).toEqual(
14+
["grep", "list_dir", "lsp", "manage_tasks", "read_file", "search_files"].sort(),
15+
);
16+
});
17+
18+
test("excludes tools with their own, narrower auto-allow logic", () => {
19+
for (const tool of ["run_shell", "web_fetch", "web_search"]) {
20+
expect(AUTO_ALLOW_READ_TOOLS.has(tool)).toBe(false);
21+
}
22+
});
23+
});
24+
25+
describe("PATH_KEYED_READ_TOOLS", () => {
26+
test("is read_file only", () => {
27+
expect([...PATH_KEYED_READ_TOOLS]).toEqual(["read_file"]);
28+
});
29+
});
30+
31+
describe("SEARCH_QUERY_TOOLS", () => {
32+
test("is grep and search_files only", () => {
33+
expect([...SEARCH_QUERY_TOOLS].sort()).toEqual(["grep", "search_files"]);
34+
});
35+
});

src/agent/tool-classification.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Shared tool-name classification constants (CL-6809).
3+
*
4+
* Three separate READ_TOOLS constants (director tool-sets, session compactor,
5+
* subagent thrash tracker) had drifted to different memberships under the
6+
* same name, and the permission classifier's auto-allow gate carried a fourth
7+
* (READ_ONLY_TOOLS) with no declared relationship to the others. Same name,
8+
* different meanings, one of them security-relevant.
9+
*
10+
* These are genuinely different concepts, not the same list typed four times:
11+
* - the director read surface (tool-sets.ts READ_TOOLS) is "everything a
12+
* read-only leaf may call", including run_shell and the web tools;
13+
* - the auto-allow gate is "never needs an approval prompt", a strict
14+
* subset (shell/web get their own, narrower auto-allow logic) plus
15+
* manage_tasks (side-effect-free, see classify.ts);
16+
* - compaction's re-read dedup and thrash's read tracking both care about
17+
* "read_file specifically, because its result is keyed by path" — this
18+
* one actually was the same set twice, so it is unified here.
19+
* Where the concepts differ, the sets stay separate but are derived from the
20+
* same base and named for what they mean, so a future difference reads as
21+
* intentional instead of drift.
22+
*/
23+
24+
import { READ_TOOLS as DIRECTOR_READ_TOOLS } from "./directors/tool-sets.js";
25+
26+
/**
27+
* read_file: the one read tool whose result is keyed by path, so an older
28+
* result for the same path is safely superseded by a newer one. Shared by
29+
* compaction's re-read dedup and thrash's read-count bookkeeping — both are
30+
* asking the same question ("was this path already read?").
31+
*/
32+
export const PATH_KEYED_READ_TOOLS: ReadonlySet<string> = new Set(["read_file"]);
33+
34+
/**
35+
* grep / search_files: pattern-keyed query tools whose repeated identical
36+
* call reflects current workspace state, not stale history. This is the base
37+
* both compaction and thrash build on; each adds/omits list_dir for its own
38+
* reason (see compactor.ts's QUERY_TOOLS and thrash.ts's SEARCH_TOOLS).
39+
*/
40+
export const SEARCH_QUERY_TOOLS: ReadonlySet<string> = new Set(["grep", "search_files"]);
41+
42+
/**
43+
* Tools that never need an approval prompt because they cannot change the
44+
* workspace: the director's read surface minus run_shell/web_fetch/web_search
45+
* (which get their own, narrower auto-allow rules — see
46+
* isAutoAllowedShellCommand and the webfetch/websearch permission classes),
47+
* plus manage_tasks (side-effect-free by the time the tool executes — see
48+
* classify.ts). SECURITY-RELEVANT: this gates auto-allow. A tool added here
49+
* is auto-approved everywhere; get it wrong in either direction deliberately,
50+
* not by accident.
51+
*/
52+
export const AUTO_ALLOW_READ_TOOLS: ReadonlySet<string> = new Set([
53+
...DIRECTOR_READ_TOOLS.filter(
54+
(tool) => tool !== "run_shell" && tool !== "web_fetch" && tool !== "web_search",
55+
),
56+
"manage_tasks",
57+
]);

src/permission/classify.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { resolveWorkspacePath } from "./path-restriction.js";
1818
import type { RootsProvider } from "./worktree-roots.js";
1919
import { isProductMutationTool, productMutationPaths } from "../agent/product-mutation-tools.js";
20+
import { AUTO_ALLOW_READ_TOOLS as READ_ONLY_TOOLS } from "../agent/tool-classification.js";
2021

2122
// Read-only tools never need approval as long as they don't touch a restricted
2223
// path; they cannot change the workspace. `lsp` is included here even though
@@ -30,14 +31,9 @@ import { isProductMutationTool, productMutationPaths } from "../agent/product-mu
3031
// for a denial to prevent. Every other posix tool is consequential and
3132
// defaults to the "ask" tier. Catastrophic commands are denied earlier by the
3233
// authorization plugin, so they never reach here.
33-
const READ_ONLY_TOOLS = new Set([
34-
"read_file",
35-
"search_files",
36-
"grep",
37-
"list_dir",
38-
"lsp",
39-
"manage_tasks",
40-
]);
34+
//
35+
// Membership lives in tool-classification.ts (AUTO_ALLOW_READ_TOOLS, imported
36+
// here as READ_ONLY_TOOLS) — see CL-6809.
4137

4238
// Tools that take a single path-like argument the gate should check against
4339
// restriction (outside the workspace boundary, or writes under the session state root).

src/session/compactor.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
} from "@intx/types/runtime";
2222
import { ageImageBlocks } from "./attachment-store.js";
2323
import type { SummaryContext } from "./summarizer.js";
24+
import { PATH_KEYED_READ_TOOLS, SEARCH_QUERY_TOOLS } from "../agent/tool-classification.js";
2425

2526
// ---------------------------------------------------------------------------
2627
// Task boundary decision
@@ -237,19 +238,18 @@ export function compactorNoOpFloor(keepRecentTurns: number): number {
237238
// Minimum anchor score for a turn to be pulled forward past the summary boundary.
238239
const ANCHOR_SCORE_THRESHOLD = 5;
239240

240-
// Tool names whose results are path-keyed for re-read dedup during compaction.
241-
const READ_TOOLS = new Set(["read_file"]);
242-
243241
// Replayable query tools deduped by full-argument identity: a later identical
244242
// grep/search_files/list_dir call reflects newer workspace state, so an older
245243
// identical result is stale the same way an older read_file body is.
246244
// run_shell is deliberately excluded — the same command is not idempotent
247245
// (builds, tests, mutations), so an older run_shell result can be the only
248-
// record of a genuinely distinct outcome.
249-
const QUERY_TOOLS = new Set(["grep", "search_files", "list_dir"]);
246+
// record of a genuinely distinct outcome. Built on the shared SEARCH_QUERY_TOOLS
247+
// base plus list_dir, which compaction treats as replayable the same way
248+
// (unlike thrash's narrower SEARCH_TOOLS — see tool-classification.ts).
249+
const QUERY_TOOLS = new Set([...SEARCH_QUERY_TOOLS, "list_dir"]);
250250

251251
function isReplayableResultTool(name: string): boolean {
252-
return READ_TOOLS.has(name) || QUERY_TOOLS.has(name);
252+
return PATH_KEYED_READ_TOOLS.has(name) || QUERY_TOOLS.has(name);
253253
}
254254

255255
// Call-id index for stub rendering (name + path). Dedup keys live on `readKey`.

src/subagent/thrash.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { isProductMutationTool, productMutationPaths } from "../agent/product-mutation-tools.js";
18+
import { PATH_KEYED_READ_TOOLS, SEARCH_QUERY_TOOLS } from "../agent/tool-classification.js";
1819
import { classifyShellFileEvidence } from "./shell-evidence.js";
1920

2021
/** Tunable thresholds for force-report detection. */
@@ -53,8 +54,11 @@ export interface ThrashToolCallBlock {
5354
arguments?: unknown;
5455
}
5556

56-
const READ_TOOLS = new Set(["read_file"]);
57-
const SEARCH_TOOLS = new Set(["grep", "search_files"]);
57+
// list_dir is deliberately excluded: a repeated identical listing of the same
58+
// directory is not the stuck read/search loop this bookkeeping watches for
59+
// the way a repeated read_file or grep is (see tool-classification.ts).
60+
const READ_TOOLS = PATH_KEYED_READ_TOOLS;
61+
const SEARCH_TOOLS = SEARCH_QUERY_TOOLS;
5862
const SHELL_TOOL = "run_shell";
5963

6064
function parseArgs(raw: unknown): Record<string, unknown> {

0 commit comments

Comments
 (0)