Skip to content

Commit 551abb3

Browse files
committed
feat(permissions): scope shell trust to the command prefix
Trusting one `git commit -m "wip"` used to trust ALL of shell for the session — so a later `rm -rf` ran without a prompt. Now trust-tool on a shell command extracts the command prefix ("git commit", "npm run", "ls") via a new commandPrefix() helper and trusts only that family; other commands still prompt. Mirrors Claude Code's getSimpleCommandPrefix. The prefix logic: first command of a compound, binary + subcommand for known subcommand tools (git/npm/cargo/docker/…), else just the binary, after stripping env-assignments and sudo/nice/env wrappers. Falls back to whole-tool trust when no prefix can be extracted. 14 prefix tests + a store test proving 'trust git commit doesn't trust rm'.
1 parent 83c40a3 commit 551abb3

4 files changed

Lines changed: 179 additions & 5 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from "vitest";
2+
import { commandPrefix } from "./command-prefix.js";
3+
4+
describe("commandPrefix", () => {
5+
it("keeps binary + subcommand for subcommand-style tools", () => {
6+
expect(commandPrefix('git commit -m "wip"')).toBe("git commit");
7+
expect(commandPrefix("npm run build")).toBe("npm run");
8+
expect(commandPrefix("cargo test --all")).toBe("cargo test");
9+
expect(commandPrefix("docker compose up -d")).toBe("docker compose");
10+
});
11+
12+
it("keeps just the binary for plain commands", () => {
13+
expect(commandPrefix("ls -la")).toBe("ls");
14+
expect(commandPrefix("python script.py")).toBe("python");
15+
expect(commandPrefix("cat /etc/hosts")).toBe("cat");
16+
});
17+
18+
it("takes only the first command of a compound", () => {
19+
expect(commandPrefix("git add . && git commit -m x")).toBe("git add");
20+
expect(commandPrefix("make build; make test")).toBe("make build");
21+
expect(commandPrefix("cat foo | grep bar")).toBe("cat");
22+
});
23+
24+
it("strips leading env assignments and bare wrappers", () => {
25+
expect(commandPrefix("FOO=bar npm run dev")).toBe("npm run");
26+
expect(commandPrefix("sudo systemctl restart nginx")).toBe("systemctl restart");
27+
expect(commandPrefix("env cargo build")).toBe("cargo build");
28+
});
29+
30+
it("strips a directory path from the binary", () => {
31+
expect(commandPrefix("/usr/bin/git status")).toBe("git status");
32+
expect(commandPrefix("./scripts/deploy.sh")).toBe("deploy.sh");
33+
});
34+
35+
it("does not attach a flag as a subcommand", () => {
36+
expect(commandPrefix("git --version")).toBe("git");
37+
expect(commandPrefix("npm -v")).toBe("npm");
38+
});
39+
40+
it("returns null for empty / whitespace", () => {
41+
expect(commandPrefix("")).toBeNull();
42+
expect(commandPrefix(" ")).toBeNull();
43+
});
44+
});

src/permissions/command-prefix.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Extract a stable "command prefix" from a shell command line so that
3+
* trusting one `git commit -m "wip"` persists as trust for `git commit`
4+
* generally — not the exact string, and not all of `shell`. Mirrors
5+
* Claude Code's getSimpleCommandPrefix.
6+
*
7+
* Rules, kept deliberately simple (this is a UX convenience, not a
8+
* security boundary — the shell validator is the hard guard):
9+
* - Take the first command of a compound (`a && b` → `a`).
10+
* - Keep the binary + one subcommand if the binary is a known
11+
* subcommand-style tool (git, npm, cargo, docker, kubectl, …):
12+
* `git commit -m x` → `git commit`, `npm run build` → `npm run`.
13+
* - Otherwise keep just the binary: `ls -la` → `ls`, `python x.py` → `python`.
14+
* - Stop at the first token that looks like a flag, path, or value.
15+
*
16+
* Returns null when no meaningful prefix can be extracted (empty,
17+
* shell-builtin noise) — caller should fall back to whole-tool trust.
18+
*/
19+
const SUBCOMMAND_TOOLS: ReadonlySet<string> = new Set([
20+
"git",
21+
"npm",
22+
"pnpm",
23+
"yarn",
24+
"bun",
25+
"cargo",
26+
"go",
27+
"docker",
28+
"kubectl",
29+
"gh",
30+
"pip",
31+
"pip3",
32+
"poetry",
33+
"brew",
34+
"apt",
35+
"apt-get",
36+
"systemctl",
37+
"terraform",
38+
"make",
39+
]);
40+
41+
export function commandPrefix(command: string): string | null {
42+
const trimmed = command.trim();
43+
if (!trimmed) return null;
44+
45+
// First command of a compound / pipeline. Split on the common
46+
// separators; we only care about the leading segment for the prefix.
47+
const firstSegment = trimmed.split(/&&|\|\||;|\||\n/)[0]?.trim() ?? "";
48+
if (!firstSegment) return null;
49+
50+
// Strip a leading env-assignment prefix (`FOO=bar cmd …`) and common
51+
// wrappers that don't change what's really being run.
52+
const tokens = firstSegment.split(/\s+/).filter(Boolean);
53+
let i = 0;
54+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[i])) i++;
55+
while (i < tokens.length && (tokens[i] === "sudo" || tokens[i] === "nice" || tokens[i] === "env")) i++;
56+
if (i >= tokens.length) return null;
57+
58+
const binary = baseName(tokens[i]);
59+
if (!binary) return null;
60+
61+
if (SUBCOMMAND_TOOLS.has(binary)) {
62+
const sub = tokens[i + 1];
63+
// Only attach the subcommand if it's a bare word (not a flag/path).
64+
if (sub && /^[a-z][a-z0-9-]*$/i.test(sub)) {
65+
return `${binary} ${sub}`;
66+
}
67+
}
68+
return binary;
69+
}
70+
71+
/** Strip a directory path from a binary token: `/usr/bin/git` → `git`. */
72+
function baseName(token: string): string {
73+
const slash = token.lastIndexOf("/");
74+
const name = slash >= 0 ? token.slice(slash + 1) : token;
75+
// Drop a trailing path-ish or quote noise; keep word chars + - .
76+
return name.replace(/['"]/g, "");
77+
}

src/permissions/store.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ describe("PermissionStore trust state", () => {
5757
await expect(second).resolves.toBe("block");
5858
});
5959

60+
it("trust-tool on a shell command scopes to the command prefix", async () => {
61+
const store = new PermissionStore();
62+
// `git commit` needs permission (write-ish). Trust it.
63+
const first = store.evaluate("shell", { command: 'git commit -m "wip"' });
64+
store.respond(store.current()!.id, "trust-tool");
65+
await expect(first).resolves.toBe("allow");
66+
67+
// Another `git commit` is auto-allowed by prefix.
68+
await expect(store.evaluate("shell", { command: 'git commit -m "more"' })).resolves.toBe("allow");
69+
expect(store.current()).toBeUndefined();
70+
71+
// But a DIFFERENT command family still prompts — trust didn't leak.
72+
const danger = store.evaluate("shell", { command: "rm -rf build" });
73+
expect(store.current()).toMatchObject({ tool: "shell" });
74+
store.respond(store.current()!.id, "deny");
75+
await expect(danger).resolves.toBe("block");
76+
});
77+
6078
it("trust-all auto-allows everything for the rest of the session", async () => {
6179
const store = new PermissionStore();
6280
const first = store.evaluate("shell", { command: "rm file" });

src/permissions/store.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { shellNeedsPermission } from "../tools/permission.js";
2+
import { commandPrefix } from "./command-prefix.js";
23

34
export type Decision = "allow" | "block";
45

@@ -167,7 +168,16 @@ export interface PermissionStoreOptions {
167168
export class PermissionStore {
168169
private trustAll = false;
169170
private readonly trustedTools = new Set<string>();
170-
private readonly queue: Array<{ request: PermissionRequest; resolve: (d: Decision) => void }> = [];
171+
/** Trusted shell command prefixes (e.g. "git commit") from a trust-tool
172+
* response to a shell prompt. Scopes trust to the command family rather
173+
* than all of shell — trusting one `git commit` doesn't trust `rm`. */
174+
private readonly trustedShellPrefixes = new Set<string>();
175+
private readonly queue: Array<{
176+
request: PermissionRequest;
177+
resolve: (d: Decision) => void;
178+
/** Command prefix for a shell prompt, used to scope trust-tool. */
179+
shellPrefix?: string;
180+
}> = [];
171181
private readonly listeners = new Set<(req: PermissionRequest | undefined) => void>();
172182
private counter = 0;
173183
private readonly matchAllow: (toolName: string, args: unknown) => boolean;
@@ -194,7 +204,15 @@ export class PermissionStore {
194204
detail: detailFor(toolName, args),
195205
risk: riskFor(toolName, args),
196206
};
197-
this.queue.push({ request, resolve });
207+
// For shell, capture the command prefix so a trust-tool response
208+
// trusts the command family (e.g. "git commit") rather than all
209+
// of shell.
210+
let shellPrefix: string | undefined;
211+
if (toolName === "shell") {
212+
const cmd = (args as { command?: string } | undefined)?.command;
213+
if (typeof cmd === "string") shellPrefix = commandPrefix(cmd) ?? undefined;
214+
}
215+
this.queue.push({ request, resolve, shellPrefix });
198216
this.notify();
199217
});
200218
}
@@ -215,8 +233,19 @@ export class PermissionStore {
215233
const head = this.queue[0];
216234
if (!head || head.request.id !== id) return;
217235

218-
if (choice === "trust-tool") this.trustedTools.add(head.request.tool);
219-
else if (choice === "trust-all") this.trustAll = true;
236+
if (choice === "trust-tool") {
237+
// Shell trust is scoped to the command prefix when we have one,
238+
// so "trust" on a `git commit` prompt auto-allows future
239+
// `git commit …` calls but NOT every shell command. Falls back to
240+
// whole-tool trust when no prefix could be extracted.
241+
if (head.request.tool === "shell" && head.shellPrefix) {
242+
this.trustedShellPrefixes.add(head.shellPrefix);
243+
} else {
244+
this.trustedTools.add(head.request.tool);
245+
}
246+
} else if (choice === "trust-all") {
247+
this.trustAll = true;
248+
}
220249

221250
head.resolve(choice === "deny" ? "block" : "allow");
222251
this.queue.shift();
@@ -227,6 +256,7 @@ export class PermissionStore {
227256
clear(): void {
228257
this.trustAll = false;
229258
this.trustedTools.clear();
259+
this.trustedShellPrefixes.clear();
230260
}
231261

232262
private shouldAutoAllow(toolName: string, args: unknown): boolean {
@@ -235,7 +265,12 @@ export class PermissionStore {
235265
if (this.trustedTools.has(toolName)) return true;
236266
if (toolName === "shell") {
237267
const cmd = (args as { command?: string } | undefined)?.command;
238-
if (typeof cmd === "string" && !shellNeedsPermission(cmd)) return true;
268+
if (typeof cmd === "string") {
269+
if (!shellNeedsPermission(cmd)) return true;
270+
// Auto-allow if the command's prefix was trusted earlier.
271+
const prefix = commandPrefix(cmd);
272+
if (prefix && this.trustedShellPrefixes.has(prefix)) return true;
273+
}
239274
}
240275
// git_branch with no name (or just listing) is read-only.
241276
if (toolName === "git_branch") {

0 commit comments

Comments
 (0)