Files
oh-my-pi/packages/coding-agent/test/shell-snapshot.test.ts
T
roboomp de1368fd2d fix(bash): tightened snapshot perms and denied secret-shaped env vars
PR #3474 review: the new export pass writes referenced env-var values
into a snapshot file under `os.tmpdir()/omp-shell-snapshots`. On Linux
where `os.tmpdir()` is `/tmp` and the umask is the default 022, the
file ended up world-readable (0644) until postmortem cleanup. A user
rcfile defining `deploy(){ curl -H "Authorization: $GITHUB_TOKEN" ...; }`
would have its token written verbatim to that file.

Three-layer mitigation:
 - `umask 077` at the top of the snapshot script so the file is 0600
   from the first byte (the shell creates it via redirection, not JS).
 - JS caller now passes `mode: 0o700` to `mkdirSync` and chmods the
   dir + file defensively after the script exits, covering pre-existing
   dirs and exotic shells where the umask call might not take.
 - Helper denylist gained the common secret-shaped name patterns
   (`*TOKEN*`, `*SECRET*`, `*API_KEY*`, `*PASSWORD*`, `*PASSWD*`,
   `*PRIVATE_KEY*`, `*ACCESS_KEY*`, `*CREDENTIAL*`, `*SESSION_KEY*`)
   so even when the file is locked down, we don't materialise tokens
   onto disk in the first place.

Tests cover both: a new helper-level test asserts none of the secret
names (or their values) appear in the export stream, and the e2e test
now stats the snapshot file + dir and asserts `mode & 0o077 === 0`.
2026-06-25 15:19:23 +00:00

278 lines
11 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { getOrCreateSnapshot, sanitizeSnapshotForBrush } from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
import fnEnvHelper from "../src/utils/shell-snapshot-fn-env.sh" with { type: "text" };
// `sanitizeSnapshotForBrush` is the snapshot-side mitigation for brush's
// whitespace-only alias expander (`crates/brush-core-vendored/src/interp.rs:1500`,
// brush issue reubeno/brush#57). Aliases whose body needs real shell parsing
// must be dropped or brush turns the first whitespace piece into the command
// name and the user sees `error: command not found: (alias;` (issue #3234).
describe("sanitizeSnapshotForBrush", () => {
it("drops the Fedora `which` alias and reports the dropped name", () => {
const snapshot = [
"unalias -a 2>/dev/null || true",
"alias -- which='(alias; declare -f) | /usr/bin/which --tty-only --read-alias --show-dot --show-tilde'",
"alias -- ll='ls -l'",
"",
].join("\n");
const result = sanitizeSnapshotForBrush(snapshot);
expect(result.dropped).toEqual(["which"]);
expect(result.content).not.toContain("alias -- which=");
// Simple aliases must still pass through untouched.
expect(result.content).toContain("alias -- ll='ls -l'");
});
it.each([
["subshell", "alias -- a='(echo hi)'"],
["pipe", "alias -- a='cat /etc/hostname | head -n1'"],
["semicolon", "alias -- a='echo a; echo b'"],
["redirect-out", "alias -- a='tee >foo'"],
["redirect-in", "alias -- a='cat <foo'"],
["background", "alias -- a='sleep 1 &'"],
["command-substitution", "alias -- a='echo `date`'"],
// Embedded single-quote `'\''` → `'` decodes to a body with a literal
// single quote (and a pipe), so the filter still trips on the pipe.
["decoded-pipe", "alias -- a='cat '\\''one'\\'' | head'"],
])("drops aliases whose body needs shell parsing (%s)", (_label, line) => {
const result = sanitizeSnapshotForBrush(`${line}\n`);
expect(result.dropped).toEqual(["a"]);
expect(result.content).not.toContain("alias -- a=");
});
it.each([
["simple", "alias -- ll='ls -l'"],
["flag-with-equals", "alias -- gc='git --color=auto commit'"],
["multi-flag", "alias -- la='ls -lAh --group-directories-first'"],
// A plain single quote escape that decodes to a metachar-free body
// must survive — we only ban truly unparseable bodies.
["embedded-quote", "alias -- say='echo '\\''hello'\\'''"],
])("preserves aliases brush can handle by whitespace split (%s)", (_label, line) => {
const result = sanitizeSnapshotForBrush(`${line}\n`);
expect(result.dropped).toEqual([]);
expect(result.content).toContain(line);
});
it("leaves non-alias lines (functions, exports, unalias) untouched", () => {
const snapshot = [
"# Shell snapshot - generated by omp agent",
"unalias -a 2>/dev/null || true",
"my_fn () { echo hi; }",
"export PATH='/usr/bin:/bin'",
"shopt -s expand_aliases",
"alias -- which='(alias; declare -f) | /usr/bin/which'", // poisoned
].join("\n");
const result = sanitizeSnapshotForBrush(snapshot);
expect(result.dropped).toEqual(["which"]);
for (const keep of [
"# Shell snapshot - generated by omp agent",
"unalias -a 2>/dev/null || true",
"my_fn () { echo hi; }", // function body contains `;` but is not an alias line
"export PATH='/usr/bin:/bin'",
"shopt -s expand_aliases",
]) {
expect(result.content).toContain(keep);
}
});
});
// `__omp_emit_referenced_exports` (shell-snapshot-fn-env.sh) re-exports env
// vars that snapshotted functions reference. mise activates a `mise()` shell
// function whose body expands `$__MISE_EXE`; the snapshot used to persist the
// function but discard the sidecar var, so the replay shell ran
// `command "" "$@"` and died with `command: command not found:` (issue #3470).
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
if (!stream) return "";
return await new Response(stream).text();
}
describe("shell-snapshot fn-env helper", () => {
it("emits export lines for env vars referenced by captured functions, skips unset and shell-internal names", async () => {
const funcs = [
`mise () { command "$__MISE_EXE" "$@"; }`,
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal shell parameter expansion `${FOO_TEST_DIR}`
'my_fn () { echo "$FOO_TEST_DIR ${FOO_TEST_DIR}"; }',
`uses_path () { echo "$PATH"; }`,
`uses_locale () { echo "$LC_ALL"; }`,
`secret () { : "$NEVER_SET_TEST_VAR"; }`,
``,
].join("\n");
const child = Bun.spawn(["bash", "-c", `${fnEnvHelper}\n__omp_emit_referenced_exports`], {
env: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
__MISE_EXE: "/opt/echo",
FOO_TEST_DIR: "/opt/dir",
LC_ALL: "C",
},
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
child.stdin.write(funcs);
await child.stdin.end();
const out = await readStream(child.stdout as ReadableStream<Uint8Array> | null);
await child.exited;
expect(child.exitCode).toBe(0);
expect(out).toContain("export __MISE_EXE='/opt/echo'");
expect(out).toContain("export FOO_TEST_DIR='/opt/dir'");
// Shell-internal names must never be re-exported.
expect(out).not.toMatch(/^export PATH=/m);
expect(out).not.toMatch(/^export LC_ALL=/m);
// Unset names produce no line.
expect(out).not.toContain("NEVER_SET_TEST_VAR");
});
it("never emits export lines for likely-secret env var names", async () => {
const funcs = [
`deploy () { curl -H "Authorization: $GITHUB_TOKEN" .; }`,
`call_openai () { curl -H "Authorization: Bearer $OPENAI_API_KEY" .; }`,
`aws_sign () { echo "$AWS_SECRET_ACCESS_KEY"; }`,
`db () { mysql --password="$DB_PASSWORD" -u root; }`,
`legacy () { echo "$LDAP_PASSWD"; }`,
`vault () { echo "$VAULT_PRIVATE_KEY"; }`,
`session () { echo "$REDIS_SESSION_KEY"; }`,
`creds () { echo "$AZURE_CREDENTIAL"; }`,
// Control: non-secret-shaped var must still be emitted.
`mise () { command "$__MISE_EXE" "$@"; }`,
``,
].join("\n");
const child = Bun.spawn(["bash", "-c", `${fnEnvHelper}\n__omp_emit_referenced_exports`], {
env: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
GITHUB_TOKEN: "ghp_REDACTED",
OPENAI_API_KEY: "sk-REDACTED",
AWS_SECRET_ACCESS_KEY: "REDACTED",
DB_PASSWORD: "hunter2",
LDAP_PASSWD: "hunter2",
VAULT_PRIVATE_KEY: "-----BEGIN-----",
REDIS_SESSION_KEY: "abc",
AZURE_CREDENTIAL: "xyz",
__MISE_EXE: "/opt/echo",
},
stdin: "pipe",
stdout: "pipe",
stderr: "ignore",
});
child.stdin.write(funcs);
await child.stdin.end();
const out = await readStream(child.stdout as ReadableStream<Uint8Array> | null);
await child.exited;
expect(child.exitCode).toBe(0);
for (const secret of [
"GITHUB_TOKEN",
"OPENAI_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"DB_PASSWORD",
"LDAP_PASSWD",
"VAULT_PRIVATE_KEY",
"REDIS_SESSION_KEY",
"AZURE_CREDENTIAL",
]) {
expect(out).not.toContain(secret);
}
// And the secret VALUES — make sure nothing leaked through a different
// quoting path.
for (const value of ["ghp_REDACTED", "sk-REDACTED", "hunter2", "-----BEGIN-----"]) {
expect(out).not.toContain(value);
}
// The non-secret helper var still goes through.
expect(out).toContain("export __MISE_EXE='/opt/echo'");
});
it("single-quote-escapes values containing apostrophes and preserves newlines", async () => {
const funcs = `shout () { echo "$TRICKY_VAL $NL_VAL"; }\n`;
const child = Bun.spawn(["bash", "-c", `${fnEnvHelper}\n__omp_emit_referenced_exports`], {
env: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TRICKY_VAL: "it's 'tricky'",
NL_VAL: "line1\nline2",
},
stdin: "pipe",
stdout: "pipe",
stderr: "ignore",
});
child.stdin.write(funcs);
await child.stdin.end();
const out = await readStream(child.stdout as ReadableStream<Uint8Array> | null);
await child.exited;
expect(out).toContain(`export TRICKY_VAL='it'\\''s '\\''tricky'\\'''`);
expect(out).toContain(`export NL_VAL='line1\nline2'`);
// Eval the emitted lines and verify the round-trip values match.
const round = Bun.spawn(
["bash", "-c", `eval "$1"; printf '%s\\n' "$TRICKY_VAL"; printf '%s\\n' "$NL_VAL"`, "_", out],
{ stdout: "pipe", stderr: "ignore" },
);
const echoed = await readStream(round.stdout as ReadableStream<Uint8Array> | null);
await round.exited;
expect(echoed).toBe("it's 'tricky'\nline1\nline2\n");
});
});
describe("getOrCreateSnapshot", () => {
it("re-exports env vars referenced by snapshotted functions (issue #3470)", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "omp-snap-3470-"));
await fs.writeFile(
path.join(home, ".bashrc"),
[
`export __MISE_EXE=/usr/bin/echo`,
`export FOO_TEST_DIR=/opt/foo`,
`mise () { command "$__MISE_EXE" "$@"; }`,
`shout () { echo "$FOO_TEST_DIR"; }`,
``,
].join("\n"),
);
// Symlink bash under a unique path so this call misses the process-wide
// snapshot cache (`cachedSnapshotPaths` is keyed on the shell path, and
// sibling tests in the same worker create a cached `/usr/bin/bash` entry
// from a different HOME before this test runs).
const realBash = "/usr/bin/bash";
const shellLink = path.join(home, "bash-omp-3470");
await fs.symlink(realBash, shellLink);
// `getShellConfigFile` honours `env.HOME` so the spawned shell sources
// our fixture bashrc rather than the test runner's home.
const env = { ...process.env, HOME: home };
const snapshotPath = await getOrCreateSnapshot(shellLink, env);
expect(snapshotPath).not.toBeNull();
const content = await fs.readFile(snapshotPath!, "utf8");
expect(content).toContain(`export __MISE_EXE='/usr/bin/echo'`);
expect(content).toContain(`export FOO_TEST_DIR='/opt/foo'`);
// Replay the snapshot in a fresh bash with `set -u` and confirm the
// mise() function resolves cleanly instead of dying on the empty var.
const replay = Bun.spawn(
[realBash, "--noprofile", "--norc", "-c", `set -u; source "$1"; mise hello world; shout`, "_", snapshotPath!],
{ stdout: "pipe", stderr: "pipe" },
);
const stdout = await readStream(replay.stdout as ReadableStream<Uint8Array> | null);
const stderr = await readStream(replay.stderr as ReadableStream<Uint8Array> | null);
await replay.exited;
expect({ exitCode: replay.exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" });
expect(stdout).toBe("hello world\n/opt/foo\n");
// PR-review hardening: snapshot file must be group/world-unreadable since
// it now inlines env-var values. Directory must be 0700 for the same
// reason — UUID filenames shouldn't leak via `ls /tmp/omp-shell-snapshots`.
const fileStat = await fs.stat(snapshotPath!);
expect(fileStat.mode & 0o077).toBe(0);
const dirStat = await fs.stat(path.dirname(snapshotPath!));
expect(dirStat.mode & 0o077).toBe(0);
});
});