Skip to content

Commit b600e99

Browse files
committed
fix(secret-guard): extras-deny hyphen-digit grep and resume scopes
1 parent 9daa425 commit b600e99

6 files changed

Lines changed: 179 additions & 11 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
11051105
resolveParkedCallId: (correlationId) =>
11061106
resolveParkedCallIdFromStore(activeStorage, correlationId),
11071107
gate: permissionGate,
1108+
cwd: config.cwd,
1109+
extraDeniedPaths: [config.globalSettingsPath],
11081110
deliver: async (message, stillCurrent) => {
11091111
if (!stillCurrent()) return;
11101112
await approvalDeliverer.deliver(message);

‎src/plugins/ripgrep-plugin.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,24 @@ export function ripgrepPlugin(
109109
const maxBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES;
110110
const isExtraDenied = createExtraDeniedPathMatcher(extraDeniedPaths);
111111

112-
// The file a `file:line:match` grep line came from, resolved so it can be
113-
// tested against the extras-denied set. Context separators (`--`) and our
114-
// own `...` notice lines carry no file and are never matches.
112+
// The file a `file:line:match` (or context `file-line-`) grep line came from,
113+
// resolved so it can be tested against the extras-denied set. Context
114+
// separators (`--`) and our own `...` notice lines carry no file and are
115+
// never matches.
116+
//
117+
// rg's separator is `:-digits-:` / `:digits:`. A non-greedy first match
118+
// treats `-<digits>-` inside a dated or versioned path (`2026-09-26-config.json`,
119+
// `gpt-4-1.json`) as the line-number field and tests the wrong prefix. Every
120+
// separator is a candidate so a hyphen-digit path is still extras-denied, and
121+
// a later `:digits:` in the match text cannot un-deny the real file.
115122
const grepLineDeniedFile = (line: string, rgCwd: string): boolean => {
116123
if (line.startsWith("...") || line === "--") return false;
117-
const match = /^(.*?)[:-]\d+[:-]/.exec(line);
118-
if (match?.[1] === undefined) return false;
119-
const candidate = match[1].replace(/^\.\//, "");
120-
if (candidate.length === 0) return false;
121-
return isExtraDenied(resolvePath(rgCwd, candidate));
124+
for (const match of line.matchAll(/[:-]\d+[:-]/g)) {
125+
const candidate = line.slice(0, match.index).replace(/^\.\//, "");
126+
if (candidate.length === 0) continue;
127+
if (isExtraDenied(resolvePath(rgCwd, candidate))) return true;
128+
}
129+
return false;
122130
};
123131

124132
// Drop extras-denied matches from grep output (both legs). Filtering before

‎src/plugins/secret-guard-config-denylist.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ async function withFixture<T>(
5050
}
5151
}
5252

53+
async function withNamedConfig<T>(
54+
name: string,
55+
run: (paths: { cwd: string; customConfig: string }) => Promise<T>,
56+
): Promise<T> {
57+
const parent = await mkdtemp(join(tmpdir(), "cl9386-dated-config-"));
58+
const cwd = join(parent, "ws");
59+
await mkdir(cwd, { recursive: true });
60+
const customConfig = join(cwd, name);
61+
await writeFile(customConfig, `${SKIP_PAYLOAD}\n`);
62+
await writeFile(join(cwd, "scratch.txt"), "ordinary workspace file\n");
63+
try {
64+
return await run({ cwd, customConfig });
65+
} finally {
66+
await rm(parent, { recursive: true, force: true });
67+
}
68+
}
69+
5370
function runner(
5471
cwd: string,
5572
skipPermissions: boolean,
@@ -158,6 +175,32 @@ describe("CL-9386 runtime-denylist the active --config path holding skip", () =>
158175
});
159176
});
160177

178+
for (const datedName of [
179+
"2026-09-26-config.json",
180+
"gpt-4-1.json",
181+
] as const) {
182+
test(`${mode}: dir-scoped grep does not surface extras-denied ${datedName}`, async () => {
183+
await withNamedConfig(datedName, async ({ cwd, customConfig }) => {
184+
const { tools } = runner(cwd, skipPermissions, customConfig);
185+
const result = await tools.run(
186+
{
187+
id: "1",
188+
name: "grep",
189+
arguments: {
190+
pattern: "dangerouslySkipPermissions",
191+
path: ".",
192+
},
193+
},
194+
new AbortController().signal,
195+
);
196+
expect(result.isError !== true).toBe(true);
197+
expect(String(result.content)).not.toContain(
198+
"dangerouslySkipPermissions",
199+
);
200+
});
201+
});
202+
}
203+
161204
test(`${mode}: dir-scoped search_files does not surface extras-denied names`, async () => {
162205
await withFixture(async ({ cwd, customConfig }) => {
163206
const { tools } = runner(cwd, skipPermissions, customConfig);
@@ -254,6 +297,29 @@ describe("CL-9386 runtime-denylist the active --config path holding skip", () =>
254297
});
255298
});
256299

300+
for (const datedName of ["2026-09-26-config.json", "gpt-4-1.json"] as const) {
301+
test(`rg missing: fallback grep does not surface extras-denied ${datedName}`, async () => {
302+
await withNamedConfig(datedName, async ({ cwd, customConfig }) => {
303+
const tools = createPosixTools({
304+
cwd,
305+
plugins: [ripgrepPlugin(cwd, {}, rgMissingSpawn, [customConfig])],
306+
});
307+
const result = await tools.run(
308+
{
309+
id: "1",
310+
name: "grep",
311+
arguments: { pattern: "dangerouslySkipPermissions", path: cwd },
312+
},
313+
new AbortController().signal,
314+
);
315+
expect(result.isError !== true).toBe(true);
316+
expect(String(result.content)).not.toContain(
317+
"dangerouslySkipPermissions",
318+
);
319+
});
320+
});
321+
}
322+
257323
test("rg missing: fallback search_files does not surface extras-denied names", async () => {
258324
await withFixture(async ({ cwd, customConfig }) => {
259325
const tools = createPosixTools({

‎src/session/approval-resume.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, expect, mock, test } from "bun:test";
2+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import type { Agent, SendResult } from "@intx/agent";
36
import type {
47
ApprovalSnapshot,
@@ -10,6 +13,7 @@ import type {
1013

1114
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js";
1215
import type { PermissionGate } from "../permission/gate.js";
16+
import { createExtraDeniedPathMatcher } from "../plugins/secret-guard-plugin.js";
1317
import {
1418
APPROVAL_DROPPED_NOTICE,
1519
createApprovalResume,
@@ -137,6 +141,69 @@ describe("requestFromApprovalSnapshot aliased file tools", () => {
137141
});
138142
});
139143

144+
function shellSnapshot(command: string): ApprovalSnapshot {
145+
return {
146+
name: "run_shell",
147+
description: "run a shell command",
148+
inputSchema: {},
149+
arguments: { command },
150+
};
151+
}
152+
153+
describe("requestFromApprovalSnapshot secret persist scopes", () => {
154+
test("static secret shell strips persist scopes without extras", () => {
155+
const request = requestFromApprovalSnapshot(
156+
shellSnapshot("cat .env"),
157+
"corr-env",
158+
);
159+
expect(request?.tool).toBe("run_shell");
160+
expect(request?.scopes).toEqual([]);
161+
});
162+
163+
test("non-secret shell keeps persist scopes", () => {
164+
const request = requestFromApprovalSnapshot(
165+
shellSnapshot("cat README.md"),
166+
"corr-readme",
167+
);
168+
expect(request?.tool).toBe("run_shell");
169+
expect(request?.scopes.length).toBeGreaterThan(0);
170+
});
171+
172+
test("extras-secret shell strips persist scopes", async () => {
173+
const parent = await mkdtemp(join(tmpdir(), "cl9386-resume-extras-"));
174+
const cwd = join(parent, "ws");
175+
const customConfig = join(cwd, "operator-config.json");
176+
try {
177+
await mkdir(cwd, { recursive: true });
178+
await writeFile(
179+
customConfig,
180+
`${JSON.stringify({ dangerouslySkipPermissions: true }, null, 2)}\n`,
181+
);
182+
const request = requestFromApprovalSnapshot(
183+
shellSnapshot("cat operator-config.json"),
184+
"corr-extras",
185+
{
186+
cwd,
187+
isExtraDenied: createExtraDeniedPathMatcher([customConfig]),
188+
},
189+
);
190+
expect(request?.tool).toBe("run_shell");
191+
expect(request?.scopes).toEqual([]);
192+
} finally {
193+
await rm(parent, { recursive: true, force: true });
194+
}
195+
});
196+
197+
test("pin: custom config path without extras keeps persist scopes", () => {
198+
const request = requestFromApprovalSnapshot(
199+
shellSnapshot("cat operator-config.json"),
200+
"corr-no-extras",
201+
);
202+
expect(request?.tool).toBe("run_shell");
203+
expect(request?.scopes.length).toBeGreaterThan(0);
204+
});
205+
});
206+
140207
describe("approval decision intent headers", () => {
141208
for (const allow of [true, false]) {
142209
test(`preserves ${allow ? "granted" : "denied"} intent and correlation through the session queue`, async () => {

‎src/session/approval-resume.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import { getLogger } from "@intx/log";
2323

2424
import { LOG_NAMESPACE_ROOT } from "../branding.js";
2525
import { canonicalToolName } from "../agent/canonical-tool-name.js";
26-
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
26+
import {
27+
commandReferencesSensitivePath,
28+
createExtraDeniedPathMatcher,
29+
} from "../plugins/secret-guard-plugin.js";
2730
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js";
2831
import { buildRequests } from "../permission/classify.js";
2932
import type { PermissionGate } from "../permission/gate.js";
@@ -59,6 +62,10 @@ export interface ApprovalResume {
5962
export function requestFromApprovalSnapshot(
6063
snapshot: ApprovalSnapshot,
6164
correlationId: string,
65+
extras: {
66+
cwd?: string;
67+
isExtraDenied?: (value: string) => boolean;
68+
} = {},
6269
): PermissionRequest | null {
6370
const parsed = ApprovalSnapshotShape(snapshot);
6471
if (parsed instanceof type.errors) return null;
@@ -71,7 +78,11 @@ export function requestFromApprovalSnapshot(
7178
if (request === undefined) return null;
7279
const anySecret =
7380
request.tool === "run_shell" &&
74-
commandReferencesSensitivePath(request.subject) !== undefined;
81+
commandReferencesSensitivePath(
82+
request.subject,
83+
extras.cwd ?? process.cwd(),
84+
extras.isExtraDenied ?? (() => false),
85+
) !== undefined;
7586
return anySecret ? { ...request, scopes: [] } : request;
7687
}
7788

@@ -184,7 +195,16 @@ export function createApprovalResume(args: {
184195
correlationId: string,
185196
) => string | undefined | Promise<string | undefined>;
186197
gate: PermissionGate;
198+
// Workspace the parked shell ran in, and extras-denied config paths the live
199+
// decide()/resolveSuspended secret check already consults. Resume rebuilds
200+
// persistable scopes from the snapshot and must apply the same extras so
201+
// Always/Project are not offered for extras-secret shell.
202+
cwd?: string;
203+
extraDeniedPaths?: readonly string[];
187204
}): ApprovalResume {
205+
const isExtraDenied = createExtraDeniedPathMatcher(
206+
args.extraDeniedPaths ?? [],
207+
);
188208
// Retry re-await wiring: correlation ids whose decision was handed to the
189209
// reactor reuse that acceptance. A retry after an observed acceptance
190210
// returns without opening the gate or delivering again, so the parked call
@@ -254,7 +274,10 @@ export function createApprovalResume(args: {
254274
const request =
255275
approvalSnapshot === undefined
256276
? null
257-
: requestFromApprovalSnapshot(approvalSnapshot, correlationId);
277+
: requestFromApprovalSnapshot(approvalSnapshot, correlationId, {
278+
...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
279+
isExtraDenied,
280+
});
258281
if (request === null) {
259282
args.registerParkedCancel?.(undefined);
260283
await deliverDecision(

‎src/tui/runner/session.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,8 @@ export async function assembleTUISession(
541541
});
542542
},
543543
gate: permissionGate,
544+
cwd: config.cwd,
545+
extraDeniedPaths: [config.globalSettingsPath],
544546
});
545547
state.enqueueAgentDeliver = (
546548
deliverToLiveAgent: () => void,

0 commit comments

Comments
 (0)