Skip to content

Commit aa537c0

Browse files
Merge pull request #1286 from corbitsdev/cl-8999-secret-guard-dollar-tokens
fix(secret-guard): fail closed on dollar-prefixed secret paths
2 parents ed4fe81 + 8f91770 commit aa537c0

4 files changed

Lines changed: 120 additions & 3 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ tool call
411411
- **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). `tool-output://` and `archive:///` refs pass through unresolved. Runs first so later plugins see resolved paths.
412412
- **Evidence archive** (`evidence-archive-search-plugin.ts`, `evidence-archive-path-guard.ts`) — Primary-session compaction evidence is a first-class search/read surface on `search_files` / `read_file` / `grep` via `archive:///` refs. Dump paths (`evidence-archive/`, `tool-output/archive-*`) stay blocked so the on-disk sidecar is not the retrieval API. Blob keys reject `/` so they cannot nest under `tool-output`.
413413
- **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched).
414-
- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. An operator-chosen `--config` path cannot be covered by static patterns, so entry points pass the resolved active settings path as `extraDeniedPaths` (exact match, lexical plus realpath). Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here. Normal mode requires operator approval via the permission gate, and auto mode forces the same ask through the auto-shell policy (`sensitive-path` rule). Yolo/skip-permissions bypasses that prompt after catastrophic authorization checks, but path-keyed access to statically protected secret paths remains hard-denied. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output.
414+
- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. An operator-chosen `--config` path cannot be covered by static patterns, so entry points pass the resolved active settings path as `extraDeniedPaths` (exact match, lexical plus realpath). Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here. Normal mode requires operator approval via the permission gate, and auto mode forces the same ask through the auto-shell policy (`sensitive-path` rule). Yolo/skip-permissions bypasses that prompt after catastrophic authorization checks, but path-keyed access to statically protected secret paths remains hard-denied. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (bare-variable indirection, `printf` assembly). A leading `$HOME`/`${HOME}` or `$USER`/`${USER}` expands from the environment before matching, and any other path-shaped `$-token` fails closed to ask. Tool-result secret scrub still redacts credential-shaped output.
415415
- **Authorization** (`run-shell-authz.ts`, enforced by the permission gate) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The gate hard-denies these at the top of its verdict path — before auto-allow, prompting, grants, and skipPermissions — so no mode or stored grant can admit them.
416416
- **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate.
417417
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 120s foreground default (`settings.shell.timeoutMs` overrides that default only; a positive per-call `timeout` is the bound with no ceiling — `maxTimeoutMs` does not clamp it), background `run_shell` has no default (only a per-call timeout arms a timer), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout, abort, and plugin dispose (live children tracked in the plugin and reaped by `posixTools.dispose`), and `background: true` — the call returns a `shell_id` at once (registry in `src/shell/background-shell.ts`), the process group keeps running past the turn, completion is process-exit (stdio-close is not required), delivered on a later turn via `buildShellBackgroundMessage`, and `shell_collect` retrieves or cancels with a 300s wait cap (schema advertised by `advertiseShellGuardTimeout` only when `shell_collect` is mounted; evaluated by the permission chain at start time like any shell call). Foreground expiry is exit 124 + `timedOut:true` plus a nudge to retry with `background:true`. Also applies a 10s wall-clock budget to `grep`/`search_files` (budget expiry and budget-torn-down aborts surface as explicit timeout errors, never as empty results; an unbounded workspace-root `search_files` walk is refused up front with scope guidance instead of running to the budget). Ripgrep detached spawns are not tracked.

‎src/permission/classify-security.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ describe("isAutoAllowedShellCall — sensitive-path arguments", () => {
4343
["cat .ssh/known_hosts", false],
4444
["cat .netrc", false],
4545
["cat .git-credentials", false],
46+
["cat $HOME/.ssh/id_rsa", false],
47+
["cat $HOME/.env", false],
48+
["cat ${HOME}/.aws/credentials", false],
4649
["env FILE=.envrc sh -c 'cat \"$FILE\"'", false],
4750
["sed -Enf.flaskenv input", false],
4851
["sed --fil=.envrc input.txt", false],
@@ -290,6 +293,28 @@ describe("sensitive-path shell commands require approval, not a hard deny", () =
290293
expect(rule?.effect).toBe("ask");
291294
});
292295

296+
test("auto mode forces ask for dollar-prefixed home secret paths", () => {
297+
for (const command of [
298+
"cat $HOME/.ssh/id_rsa",
299+
"cat $HOME/.env",
300+
"cat ${HOME}/.aws/credentials",
301+
]) {
302+
expect(autoShellRuleForCall(shellCall(command))).toMatchObject({
303+
name: "sensitive-path",
304+
effect: "ask",
305+
});
306+
expect(isAutoAllowedShellCall(shellCall(command))).toBe(false);
307+
}
308+
});
309+
310+
test("auto mode fails closed on path-shaped unknown-variable tokens", () => {
311+
expect(autoShellRuleForCall(shellCall("cat $BASE/mylink"))).toMatchObject({
312+
name: "sensitive-path",
313+
effect: "ask",
314+
});
315+
expect(isAutoAllowedShellCall(shellCall("cat $BASE/mylink"))).toBe(false);
316+
});
317+
293318
test("auto mode asks for clustered bash secret reads", () => {
294319
for (const command of [
295320
"bash -lc \"cat \\$'.envrc'\"",

‎src/plugins/secret-guard-plugin.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { mkdir, mkdtemp, rm } from "node:fs/promises";
2+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { createPosixTools } from "@intx/tools-posix";
@@ -10,6 +10,7 @@ import { loadProjectApprovals } from "../permission/store.js";
1010
import {
1111
secretGuardPlugin,
1212
isSensitivePath,
13+
isSensitiveShellToken,
1314
commandReferencesSensitivePath,
1415
} from "./secret-guard-plugin.js";
1516

@@ -287,6 +288,14 @@ describe("commandReferencesSensitivePath", () => {
287288
"cat release.keystore",
288289
"cat server.ppk",
289290
"cat service-account.json",
291+
// Dollar-prefixed home tokens resolve before matching (CL-8999): the
292+
// shell expands these to the operator's home at runtime, so they must
293+
// ask exactly like their literal forms.
294+
"cat $HOME/.ssh/id_rsa",
295+
"cat $HOME/.env",
296+
"cat ${HOME}/.aws/credentials",
297+
// Literal tilde keeps asking (unchanged behavior, pinned here).
298+
"cat ~/.ssh/id_rsa",
290299
];
291300
test("flags every command touching a credential path", () => {
292301
expect(
@@ -308,6 +317,9 @@ describe("commandReferencesSensitivePath", () => {
308317
"sed --f=.envrc input.txt",
309318
"grep --fil=.envrc needle",
310319
"bun test",
320+
// A bare variable with no path shape never names a file (CL-8999).
321+
"echo $HOME",
322+
"cat $HOMEPATH",
311323
];
312324
test("allows every benign command", () => {
313325
expect(
@@ -316,6 +328,40 @@ describe("commandReferencesSensitivePath", () => {
316328
});
317329
});
318330

331+
describe("dollar-prefixed secret paths", () => {
332+
test("$HOME-prefixed symlink into a secret file asks like the literal form", async () => {
333+
const dir = await mkdtemp(join(tmpdir(), "cl8999-dollar-"));
334+
try {
335+
await writeFile(join(dir, ".env"), "SECRET=x\n");
336+
await symlink(join(dir, ".env"), join(dir, "mylink"));
337+
const savedHome = process.env.HOME;
338+
process.env.HOME = dir;
339+
try {
340+
expect(isSensitiveShellToken("mylink", dir)).toBe(true);
341+
expect(isSensitiveShellToken("$HOME/mylink", dir)).toBe(true);
342+
expect(isSensitiveShellToken("${HOME}/mylink", dir)).toBe(true);
343+
expect(
344+
commandReferencesSensitivePath("cat $HOME/mylink", dir),
345+
).not.toBeUndefined();
346+
} finally {
347+
if (savedHome === undefined) delete process.env.HOME;
348+
else process.env.HOME = savedHome;
349+
}
350+
} finally {
351+
await rm(dir, { recursive: true, force: true });
352+
}
353+
});
354+
355+
test("path-shaped tokens under unknown variables fail closed to ask", () => {
356+
expect(isSensitiveShellToken("$BASE/mylink")).toBe(true);
357+
expect(
358+
commandReferencesSensitivePath("cat $BASE/mylink"),
359+
).not.toBeUndefined();
360+
expect(isSensitiveShellToken("$HOME")).toBe(false);
361+
expect(commandReferencesSensitivePath("echo $HOME")).toBeUndefined();
362+
});
363+
});
364+
319365
describe("secretGuardPlugin run_shell", () => {
320366
// Shell commands that mention a secret path are no longer hard-denied here —
321367
// they require operator approval at the permission gate. The plugin only

‎src/plugins/secret-guard-plugin.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ export function createExtraDeniedPathMatcher(
211211
// symlink the matcher only ever saw as `*`. Perfect shell sandboxing is out
212212
// of scope; the goal is to force a prompt for the trivial, single-token
213213
// references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output.
214+
// A leading $HOME/${HOME} or $USER/${USER} expands from the environment
215+
// before matching, so `$HOME/.ssh/id_rsa` meets the same legs as the literal
216+
// path; any other path-shaped $-token (`$BASE/mylink`) fails closed to ask
217+
// because the matcher cannot know what it expands to. A bare `$VAR` with no
218+
// path shape stays invisible — when the value is a secret path the matcher
219+
// never sees one token that names it.
214220
// Programs that only print directory names / metadata — listing a name never
215221
// dumps file contents. Single owner for this set: the resolve-leg skip below
216222
// and classify.ts's pure-listing exemption both read it, so a new names-only
@@ -225,6 +231,30 @@ export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]);
225231
// pattern, so `cat *.txt` cannot resolve without running the shell — but a
226232
// glob CAN expand into a symlink at runtime, which stays a stated residual
227233
// (see the threat model below), not something this filter disproves.
234+
235+
// CL-8999: the shell expands a leading $HOME (or ${HOME}) before opening the
236+
// path, so expand it here (string-only, from the environment — never shell
237+
// eval) and let the normal legs judge the result. $USER (or ${USER}) gets the
238+
// same treatment. Only these two prefixes expand: arbitrary variables stay
239+
// unexpanded and fail closed to ask as path-shaped tokens below.
240+
function expandLeadingDollarToken(token: string): string {
241+
const home = process.env.HOME;
242+
if (home !== undefined && home.length > 0) {
243+
if (token === "$HOME" || token === "${HOME}") return home;
244+
if (token.startsWith("$HOME/")) return home + token.slice("$HOME".length);
245+
if (token.startsWith("${HOME}/"))
246+
return home + token.slice("${HOME}".length);
247+
}
248+
const user = process.env.USER ?? process.env.USERNAME;
249+
if (user !== undefined && user.length > 0) {
250+
if (token === "$USER" || token === "${USER}") return user;
251+
if (token.startsWith("$USER/")) return user + token.slice("$USER".length);
252+
if (token.startsWith("${USER}/"))
253+
return user + token.slice("${USER}".length);
254+
}
255+
return token;
256+
}
257+
228258
function isPathLikeShellToken(token: string): boolean {
229259
if (
230260
token.startsWith("-") ||
@@ -303,9 +333,25 @@ export function isSensitiveShellToken(
303333
isExtraDenied: (value: string) => boolean = () => false,
304334
dialect: ShellDialect = nativeShellDialect(process.platform),
305335
): boolean {
306-
const expanded = expandHome(token);
336+
const expanded = expandHome(expandLeadingDollarToken(token));
307337
if (isSensitivePath(expanded, dialect)) return true;
308338
if (isExtraDenied(expanded)) return true;
339+
// CL-8999: a token the shell could still expand at runtime into a path
340+
// ($BASE/mylink, or $HOME/mylink with HOME unset) never reaches either leg
341+
// below — `$` tokens skip both the path-like and bare filters — so fail
342+
// closed to ask when the `$` starts a live variable expansion ($VAR or
343+
// ${VAR}) joined to a path separator. Quoted dollars (`'$'.envrc`), command
344+
// substitutions (`$(...)`), ANSI-C quotes (`$'...'`), and special parameters
345+
// ($?, $$, $1) never match, and neither do ADS streams (`.env.sample::$DATA`,
346+
// no separator) or bare `$VAR` tokens with no path shape: a bare variable
347+
// can never name a file the shell opens without expanding into something
348+
// the matcher sees.
349+
if (
350+
expanded.includes("$") &&
351+
/\$[{]?[A-Za-z_]/.test(expanded) &&
352+
(expanded.includes("/") || expanded.includes("\\"))
353+
)
354+
return true;
309355
if (!resolveSymlinks) {
310356
if (!isBareProbeCandidate(expanded)) return false;
311357
return isExtraDenied(

0 commit comments

Comments
 (0)