Skip to content

Commit 9daa425

Browse files
committed
fix(secret-guard): extras-deny dir-scoped grep and shell ask legs
1 parent cfd9d2e commit 9daa425

7 files changed

Lines changed: 370 additions & 29 deletions

File tree

‎src/agent/posix-tool-plugins.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ export function buildCorePosixToolPlugins(
109109
// One shared workspace-roots provider for every bound in this stack, so
110110
// pathEscape and delete_file admit the same registered sibling worktrees.
111111
const rootsProvider = createWorktreeRootsProvider(cwd);
112+
// CL-1187 finding 2: the gate's shell legs (segmentGuard, auto-allow, auto
113+
// policy) must treat the extras-denied paths as sensitive exactly like the
114+
// secret-guard plugin below does. The gate is built before this stack and
115+
// shared across stacks, so forward the list here — the single funnel every
116+
// entry point (exec, TUI) and worker flows through — rather than wiring
117+
// each runner's gate construction separately.
118+
if (secretGuardExtraDeniedPaths !== undefined) {
119+
permissionGate.setSensitiveExtraDeniedPaths?.(secretGuardExtraDeniedPaths);
120+
}
112121
const truncationOptions =
113122
getBlobWriter !== undefined ||
114123
getContextDir !== undefined ||
@@ -146,7 +155,7 @@ export function buildCorePosixToolPlugins(
146155
? [evidenceArchiveSearchPlugin(getEvidenceArchive)]
147156
: []),
148157
readFileGuardPlugin(cwd, readFileGuard),
149-
ripgrepPlugin(cwd),
158+
ripgrepPlugin(cwd, {}, undefined, secretGuardExtraDeniedPaths ?? []),
150159
// Verify wraps the line-range short-circuit (composeMiddleware runs plugins
151160
// outer-to-inner in array order) so its before/after check still covers
152161
// start_line/end_line edits instead of only substring-mode edit_file calls.

‎src/permission/auto-shell-policy.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ export function autoShellRuleForCall(
504504
isRestricted: (path: string, isWrite: boolean) => boolean = () => false,
505505
cwd: string = process.cwd(),
506506
rootsProvider: RootsProvider = NO_ROOTS,
507+
isExtraDenied: (value: string) => boolean = () => false,
507508
): AutoShellRule | undefined {
508509
if (call.name !== "run_shell") return undefined;
509510
const command = call.arguments.command;
@@ -533,7 +534,9 @@ export function autoShellRuleForCall(
533534
}
534535

535536
for (const subject of subjects) {
536-
if (commandReferencesSensitivePath(subject, cwd) !== undefined)
537+
if (
538+
commandReferencesSensitivePath(subject, cwd, isExtraDenied) !== undefined
539+
)
537540
return SENSITIVE_PATH_ASK_RULE;
538541
}
539542

‎src/permission/classify.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -420,25 +420,27 @@ export function isAutoAllowedShellSegment(
420420
segment: string,
421421
cwd: string = process.cwd(),
422422
rootsProvider: RootsProvider = NO_ROOTS,
423+
isExtraDenied: (value: string) => boolean = () => false,
423424
): boolean {
424425
const trimmed = segment.trim();
425426
// Empty is not auto-allowed as a "command"; full-line comments and pure shell
426427
// no-ops (true/false/: and bare control-flow keywords) never need approval.
427428
if (trimmed.length === 0) return false;
428429
if (isShellCommentOnly(trimmed) || isShellNoOp(trimmed)) return true;
429430
if (runShellAuthzSegmentBlockReason(trimmed) !== undefined) return false;
430-
return isAutoAllowedSegment(segment, cwd, rootsProvider);
431+
return isAutoAllowedSegment(segment, cwd, rootsProvider, isExtraDenied);
431432
}
432433

433434
function isAutoAllowedSegment(
434435
segment: string,
435436
cwd: string,
436437
rootsProvider: RootsProvider,
438+
isExtraDenied: (value: string) => boolean = () => false,
437439
): boolean {
438440
const trimmed = segment.trim();
439441
if (trimmed.length === 0) return false;
440442
if (isShellCommentOnly(trimmed) || isShellNoOp(trimmed)) return true;
441-
if (commandReferencesSensitivePath(trimmed, cwd)) return false;
443+
if (commandReferencesSensitivePath(trimmed, cwd, isExtraDenied)) return false;
442444
// Same metacharacter gate as isAutoAllowedShellCommand: this classifier also
443445
// runs standalone per pipeline/chain segment (see isAutoAllowedShellSegment),
444446
// so a segment carrying its own command substitution or redirect must not
@@ -466,7 +468,11 @@ function isAutoAllowedSegment(
466468
// symlink into a secret file (notes.txt -> .env) asks exactly like the
467469
// secret name itself. Pure name-listings skip the resolve leg: `ls
468470
// notes.txt` lists freely (CL-5420), and an impure listing fails above.
469-
if (args.some((token) => isSensitiveShellToken(token, cwd, !pureListing)))
471+
if (
472+
args.some((token) =>
473+
isSensitiveShellToken(token, cwd, !pureListing, isExtraDenied),
474+
)
475+
)
470476
return false;
471477
// Pure directory listing may target outside-workspace paths (names only).
472478
// Content readers must stay inside the workspace.
@@ -483,6 +489,7 @@ export function isAutoAllowedShellCommand(
483489
command: string,
484490
cwd: string = process.cwd(),
485491
rootsProvider: RootsProvider = NO_ROOTS,
492+
isExtraDenied: (value: string) => boolean = () => false,
486493
): boolean {
487494
const trimmed = command.trim();
488495
if (trimmed.length === 0) return false;
@@ -495,7 +502,7 @@ export function isAutoAllowedShellCommand(
495502
(isShellCommentOnly(trimmed) || isShellNoOp(trimmed))
496503
)
497504
return true;
498-
if (commandReferencesSensitivePath(trimmed, cwd)) return false;
505+
if (commandReferencesSensitivePath(trimmed, cwd, isExtraDenied)) return false;
499506
// Never auto-allow a command the authz layer would hard-deny at execution.
500507
if (runShellAuthzBlockReason(trimmed) !== undefined) return false;
501508
// Reject anything with metacharacters that compose or redirect (& ; < > ` $ etc).
@@ -504,19 +511,23 @@ export function isAutoAllowedShellCommand(
504511

505512
// Split on pipe and require every segment to be a safe read-only program.
506513
const segments = trimmed.split("|");
507-
return segments.every((seg) => isAutoAllowedSegment(seg, cwd, rootsProvider));
514+
return segments.every((seg) =>
515+
isAutoAllowedSegment(seg, cwd, rootsProvider, isExtraDenied),
516+
);
508517
}
509518

510519
export function isAutoAllowedShellCall(
511520
call: ToolCall,
512521
cwd: string = process.cwd(),
513522
rootsProvider: RootsProvider = NO_ROOTS,
523+
isExtraDenied: (value: string) => boolean = () => false,
514524
): boolean {
515525
if (canonicalToolName(call.name) !== "run_shell") return false;
516526
return isAutoAllowedShellCommand(
517527
stringArg(call, "command"),
518528
cwd,
519529
rootsProvider,
530+
isExtraDenied,
520531
);
521532
}
522533

‎src/permission/gate.ts‎

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
safeWorktreeCommand,
2121
isWorktreeForceFlag,
2222
} from "./auto-shell-policy.js";
23-
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
23+
import {
24+
commandReferencesSensitivePath,
25+
createExtraDeniedPathMatcher,
26+
} from "../plugins/secret-guard-plugin.js";
2427
import {
2528
normalizePathArguments,
2629
pathEscapeBlockReason,
@@ -133,8 +136,9 @@ function segmentGuard(
133136
isRestricted: (path: string, isWrite: boolean) => boolean,
134137
cwd?: string,
135138
rootsProvider?: RootsProvider,
139+
isExtraDenied: (value: string) => boolean = () => false,
136140
): SegmentGuard | undefined {
137-
if (commandReferencesSensitivePath(segment, cwd) !== undefined)
141+
if (commandReferencesSensitivePath(segment, cwd, isExtraDenied) !== undefined)
138142
return { kind: "secret" };
139143
if (
140144
cwd !== undefined &&
@@ -223,6 +227,7 @@ export function preGrantGuardReason(
223227
request: PermissionRequest,
224228
isRestricted: (path: string, isWrite: boolean) => boolean,
225229
rootsProvider?: RootsProvider,
230+
isExtraDenied: (value: string) => boolean = () => false,
226231
): string | undefined {
227232
if (request.tool !== "run_shell") return undefined;
228233
const fullCommand = request.subject;
@@ -239,7 +244,13 @@ export function preGrantGuardReason(
239244
? bindRestrictedToProcessCwd(isRestricted, request.cwd)
240245
: isRestricted;
241246
for (const segment of segments) {
242-
const guard = segmentGuard(segment, restricted, request.cwd, rootsProvider);
247+
const guard = segmentGuard(
248+
segment,
249+
restricted,
250+
request.cwd,
251+
rootsProvider,
252+
isExtraDenied,
253+
);
243254
if (guard !== undefined) {
244255
return guard.kind === "secret"
245256
? `${segment} references a sensitive path`
@@ -268,6 +279,7 @@ export function isRequestCoveredByGrant(
268279
isRestricted: (path: string, isWrite: boolean) => boolean,
269280
workspace: GrantWorkspace,
270281
rootsProvider?: RootsProvider,
282+
isExtraDenied: (value: string) => boolean = () => false,
271283
): boolean {
272284
return isRequestCoveredByApprovals(
273285
request,
@@ -276,6 +288,7 @@ export function isRequestCoveredByGrant(
276288
isRestricted,
277289
workspace,
278290
rootsProvider,
291+
isExtraDenied,
279292
);
280293
}
281294

@@ -290,6 +303,7 @@ function isRequestCoveredByApprovals(
290303
isRestricted: (path: string, isWrite: boolean) => boolean,
291304
workspace: GrantWorkspace,
292305
rootsProvider?: RootsProvider,
306+
isExtraDenied: (value: string) => boolean = () => false,
293307
): boolean {
294308
const scoped = approvals.filter((a) =>
295309
grantScopeMatches(
@@ -304,7 +318,10 @@ function isRequestCoveredByApprovals(
304318
if (request.tool !== "run_shell") {
305319
return scoped.some((a) => matchesPattern(request.subject, a.pattern));
306320
}
307-
if (preGrantGuardReason(request, isRestricted, rootsProvider) !== undefined)
321+
if (
322+
preGrantGuardReason(request, isRestricted, rootsProvider, isExtraDenied) !==
323+
undefined
324+
)
308325
return false;
309326
const segments = splitChainedCommand(request.subject).filter(
310327
(s) => !isShellCommentOnly(s),
@@ -313,7 +330,12 @@ function isRequestCoveredByApprovals(
313330
const cwd = request.cwd ?? workspace.resolvedCwd;
314331
return segments.every((segment) => {
315332
if (scoped.some((a) => matchesPattern(segment, a.pattern))) return true;
316-
return isAutoAllowedShellSegment(segment, cwd, rootsProvider);
333+
return isAutoAllowedShellSegment(
334+
segment,
335+
cwd,
336+
rootsProvider,
337+
isExtraDenied,
338+
);
317339
});
318340
}
319341

@@ -382,6 +404,13 @@ export interface PermissionGateOptions {
382404
// Writes and deletes remain path-escape denies; plugin trust is not write
383405
// consent.
384406
trustedPluginRoots?: RootsProvider;
407+
// Extras-denied config paths the shell legs treat as sensitive (CL-9386):
408+
// the active settings source, including a --config override. Mirrors the
409+
// secret-guard plugin's extraDeniedPaths so a custom config path asks in
410+
// shell commands exactly like the default settings file. May also be set
411+
// after construction via setSensitiveExtraDeniedPaths when the paths are
412+
// learned later (the toolset builder forwards them).
413+
sensitiveExtraDeniedPaths?: readonly string[];
385414
// Tiers learned from connected MCP servers (tools/list annotations). Tests may
386415
// inject a shared registry; production gates create one when omitted.
387416
mcpTiers?: McpToolPermissionRegistry;
@@ -488,6 +517,11 @@ export interface PermissionGate {
488517
// posix plugin stack reads this so authorize-time and execution-time
489518
// containment share one list.
490519
getTrustedPluginRoots: () => readonly string[];
520+
// Replace the extras-denied config paths the shell legs treat as sensitive
521+
// (CL-9386). The toolset builder calls this to forward the active settings
522+
// source after gate construction, so the gate and the secret-guard plugin
523+
// share one list. Optional so test doubles of this interface keep compiling.
524+
setSensitiveExtraDeniedPaths?: (paths: readonly string[]) => void;
491525
}
492526

493527
// True when splitChainedCommand can be trusted to yield only real segments for
@@ -570,6 +604,14 @@ export function createPermissionGate(
570604
// Session grants live only in this array; persisted grants are seeded in via
571605
// options.approvals and re-routed to a store by the persist callback.
572606
const sessionGrants: Approval[] = [];
607+
// CL-9386: the extras-denied config paths (the active settings source) the
608+
// shell legs consult, mirrored from the secret-guard plugin's own matcher so
609+
// both agree on what "denied" means. Mutable via
610+
// setSensitiveExtraDeniedPaths because the toolset builder learns the paths
611+
// after the gate is constructed.
612+
let isExtraDenied = createExtraDeniedPathMatcher(
613+
options.sensitiveExtraDeniedPaths ?? [],
614+
);
573615

574616
// Record an operator-granted approval in the live list and route it to the
575617
// scope-appropriate home: session grants stay in memory, everything else is
@@ -638,6 +680,7 @@ export function createPermissionGate(
638680
isRestricted,
639681
grantWorkspace(),
640682
rootsProvider,
683+
isExtraDenied,
641684
),
642685
);
643686
}
@@ -798,14 +841,15 @@ export function createPermissionGate(
798841
// segment mentions a secret path.
799842
const shellReferencesSecret =
800843
shellCmd !== undefined &&
801-
commandReferencesSensitivePath(shellCmd, effectiveCwd) !== undefined;
844+
commandReferencesSensitivePath(shellCmd, effectiveCwd, isExtraDenied) !==
845+
undefined;
802846
if (!restricted && classifyTool(call.name, mcpTiers) === "allow") {
803847
return { kind: "allow" };
804848
}
805849
if (
806850
!restricted &&
807851
!shellReferencesSecret &&
808-
isAutoAllowedShellCall(call, effectiveCwd, rootsProvider)
852+
isAutoAllowedShellCall(call, effectiveCwd, rootsProvider, isExtraDenied)
809853
) {
810854
return { kind: "allow" };
811855
}
@@ -821,6 +865,7 @@ export function createPermissionGate(
821865
isRestrictedHere,
822866
effectiveCwd,
823867
rootsProvider,
868+
isExtraDenied,
824869
);
825870
if (shellRule?.effect === "deny") {
826871
recordAutoDecision(call.name, shellRule.name, "auto-deny");
@@ -876,6 +921,7 @@ export function createPermissionGate(
876921
isRestrictedHere,
877922
effectiveCwd,
878923
rootsProvider,
924+
isExtraDenied,
879925
);
880926
if (guard !== undefined) {
881927
if (guard.kind === "secret") anySecret = true;
@@ -912,7 +958,14 @@ export function createPermissionGate(
912958
}
913959
// Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip.
914960
// Containment is judged against the process cwd, not the session cwd.
915-
if (isAutoAllowedShellSegment(segment, effectiveCwd, rootsProvider)) {
961+
if (
962+
isAutoAllowedShellSegment(
963+
segment,
964+
effectiveCwd,
965+
rootsProvider,
966+
isExtraDenied,
967+
)
968+
) {
916969
continue;
917970
}
918971
needsOperator = true;
@@ -1141,8 +1194,11 @@ export function createPermissionGate(
11411194
) => {
11421195
const anySecret =
11431196
request.tool === "run_shell" &&
1144-
commandReferencesSensitivePath(request.subject, request.cwd) !==
1145-
undefined;
1197+
commandReferencesSensitivePath(
1198+
request.subject,
1199+
request.cwd,
1200+
isExtraDenied,
1201+
) !== undefined;
11461202
const decision = {
11471203
kind: "ask" as const,
11481204
request,
@@ -1256,5 +1312,10 @@ export function createPermissionGate(
12561312
registerMcpClient,
12571313
unregisterMcpServer,
12581314
getTrustedPluginRoots: () => trustedPluginRoots(),
1315+
setSensitiveExtraDeniedPaths: (paths: readonly string[]) => {
1316+
isExtraDenied = createExtraDeniedPathMatcher(paths);
1317+
// The denied set changed — cached denies re-evaluate.
1318+
denialMemory.clear();
1319+
},
12591320
};
12601321
}

0 commit comments

Comments
 (0)