Skip to content

Commit 02042d7

Browse files
committed
Let git worktree grants clear the restricted-path guard
A standing 'git worktree *' Always-allow grant was never consulted for a later worktree command whose destination was an uncreated sibling directory: the pre-grant restricted-path guard forced an operator ask before grant matching ran. Exempt contained/permitted-sibling worktree add/remove destinations from that guard, reusing the same check auto mode already applies, so a matching grant can cover the request.
1 parent 64542d7 commit 02042d7

3 files changed

Lines changed: 72 additions & 7 deletions

File tree

src/permission/auto-shell-policy.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,12 @@ function worktreePathArgs(
361361
// Contained non-force add/remove and ordinary prune/list auto-allow so dispatch
362362
// can create sibling worktrees without a human click; force flags, uncontained
363363
// paths, and uncommon subcommands still ask.
364-
function safeWorktreeCommand(
364+
// Exported for gate.ts's pre-grant restricted-path guard: a contained or
365+
// permitted-sibling `git worktree add/remove` destination must not force an
366+
// operator ask ahead of grant matching (CL-5638) the same way it already
367+
// skips the auto-mode ask below — one authority for "is this worktree
368+
// destination safe," used by both auto mode and the standing-grant guard.
369+
export function safeWorktreeCommand(
365370
command: string,
366371
isRestricted: (path: string, isWrite: boolean) => boolean,
367372
cwd: string,

src/permission/gate.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,41 @@ describe("director writePaths authz on evaluate", () => {
219219
expect(verdict.allowed).toBe(true);
220220
});
221221
});
222+
223+
// CL-5638: an Always-allow grant minted for `git worktree *` must cover a later
224+
// worktree command whose destination is a sibling directory the operator has
225+
// already implicitly approved under that pattern, without a second prompt.
226+
describe("standing grant covers a later git worktree command (CL-5638)", () => {
227+
const root = mkdtempSync(join(tmpdir(), "gate-worktree-grant-"));
228+
const sessionCwd = join(root, "main");
229+
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
230+
mkdirSync(sessionCwd);
231+
git(["init", "-q"], sessionCwd);
232+
git(["config", "user.email", "t@example.com"], sessionCwd);
233+
git(["config", "user.name", "t"], sessionCwd);
234+
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
235+
git(["add", "."], sessionCwd);
236+
git(["commit", "-qm", "seed"], sessionCwd);
237+
238+
test("second sibling worktree add is not re-prompted after Always-allow", async () => {
239+
let prompts = 0;
240+
const gate = createPermissionGate({
241+
approvals: [],
242+
interactive: true,
243+
skipPermissions: false,
244+
cwd: sessionCwd,
245+
requestApproval: async () => {
246+
prompts += 1;
247+
return { allow: true, persist: { id: "always", label: "Always allow", pattern: "git worktree *", grant: "project" } };
248+
},
249+
});
250+
251+
const first = await gate.evaluate(shellCall("git worktree add ../sibling-a -b br-a"));
252+
expect(first.allowed).toBe(true);
253+
expect(prompts).toBe(1);
254+
255+
const second = await gate.evaluate(shellCall("git worktree add ../sibling-b -b br-b"));
256+
expect(second.allowed).toBe(true);
257+
expect(prompts).toBe(1);
258+
});
259+
});

src/permission/gate.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
commandTargetsRestricted,
1212
MEGA_CHAIN_SEGMENT_THRESHOLD,
1313
} from "./classify.js";
14-
import { autoShellRuleForCall } from "./auto-shell-policy.js";
14+
import { autoShellRuleForCall, safeWorktreeCommand } from "./auto-shell-policy.js";
1515
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
1616
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
1717
import { matchesPattern, escapeGlobLiteral } from "./matcher.js";
@@ -84,8 +84,28 @@ function hasExactFullCommandGrant(
8484
// preGrantGuardReason (which only needs to know whether one tripped).
8585
type SegmentGuard = { kind: "secret" | "restricted" };
8686

87-
function segmentGuard(segment: string, isRestricted: (path: string, isWrite: boolean) => boolean): SegmentGuard | undefined {
87+
// `cwd`/`rootsProvider`, when both supplied, let a contained or
88+
// permitted-sibling `git worktree add/remove` destination (see
89+
// safeWorktreeCommand) skip the generic restricted-path scan below — the
90+
// same exemption auto mode already applies (autoShellRuleForCall) — so a
91+
// standing `git worktree *` grant gets a chance to match instead of the
92+
// destination forcing an ask on every call regardless of any grant. Omitted
93+
// (as from call sites with no cwd on hand) simply skips the exemption and
94+
// falls back to today's behavior.
95+
function segmentGuard(
96+
segment: string,
97+
isRestricted: (path: string, isWrite: boolean) => boolean,
98+
cwd?: string,
99+
rootsProvider?: RootsProvider,
100+
): SegmentGuard | undefined {
88101
if (commandReferencesSensitivePath(segment) !== undefined) return { kind: "secret" };
102+
if (
103+
cwd !== undefined &&
104+
rootsProvider !== undefined &&
105+
safeWorktreeCommand(segment, isRestricted, cwd, rootsProvider) === true
106+
) {
107+
return undefined;
108+
}
89109
if (commandTargetsRestricted(segment, isRestricted)) return { kind: "restricted" };
90110
return undefined;
91111
}
@@ -119,6 +139,7 @@ function bindRestrictedToProcessCwd(
119139
export function preGrantGuardReason(
120140
request: PermissionRequest,
121141
isRestricted: (path: string, isWrite: boolean) => boolean,
142+
rootsProvider?: RootsProvider,
122143
): string | undefined {
123144
if (request.tool !== "run_shell") return undefined;
124145
const fullCommand = request.subject;
@@ -131,7 +152,7 @@ export function preGrantGuardReason(
131152
const restricted =
132153
request.cwd !== undefined ? bindRestrictedToProcessCwd(isRestricted, request.cwd) : isRestricted;
133154
for (const segment of segments) {
134-
const guard = segmentGuard(segment, restricted);
155+
const guard = segmentGuard(segment, restricted, request.cwd, rootsProvider);
135156
if (guard !== undefined) {
136157
return guard.kind === "secret"
137158
? `${segment} references a sensitive path`
@@ -154,12 +175,13 @@ export function isRequestCoveredByGrant(
154175
activeProviderModel: string | undefined,
155176
isRestricted: (path: string, isWrite: boolean) => boolean,
156177
workspace: GrantWorkspace,
178+
rootsProvider?: RootsProvider,
157179
): boolean {
158180
if (!grantScopeMatches(approval, request.tool, activeProviderModel, request.cwd, workspace)) return false;
159181
if (request.tool !== "run_shell") {
160182
return matchesPattern(request.subject, approval.pattern);
161183
}
162-
if (preGrantGuardReason(request, isRestricted) !== undefined) return false;
184+
if (preGrantGuardReason(request, isRestricted, rootsProvider) !== undefined) return false;
163185
const segments = splitChainedCommand(request.subject).filter((s) => !isShellCommentOnly(s));
164186
if (segments.length === 0) return false;
165187
if (segments.length > 1) {
@@ -334,7 +356,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
334356
persist?.(approval, grant);
335357
}
336358
options.onGrant?.(approval, (request) =>
337-
isRequestCoveredByGrant(request, approval, activeProviderModel, isRestricted, grantWorkspace()),
359+
isRequestCoveredByGrant(request, approval, activeProviderModel, isRestricted, grantWorkspace(), rootsProvider),
338360
);
339361
};
340362

@@ -473,7 +495,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
473495
// replay for a guarded one just because the pattern also matches it.
474496
// segmentGuard is the same guard preGrantGuardReason applies before
475497
// isRequestCoveredByGrant lets a queued request skip the prompt.
476-
const guard = segmentGuard(segment, isRestrictedHere);
498+
const guard = segmentGuard(segment, isRestrictedHere, effectiveCwd, rootsProvider);
477499
if (guard !== undefined) {
478500
if (guard.kind === "secret") anySecret = true;
479501
needsOperator = true;

0 commit comments

Comments
 (0)