Skip to content

Commit 793255b

Browse files
Realpath secret-guard denylist so yolo symlinks cannot bypass (CL-6971) (#669)
Under skip-permissions, pathEscape absolutizes without resolving links, so an innocuous name could defeat the secret denylist. Match lexical and realpath forms (and Codex raw-read) before allowing the path.
1 parent 3d4efca commit 793255b

3 files changed

Lines changed: 206 additions & 9 deletions

File tree

src/agent/codex-read-raw-file.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
*
1515
* Reuses the existing containment and secret-file authorities rather than
1616
* reimplementing them: `resolveWorkspacePath` (symlink-aware realpath
17-
* containment, the same function pathEscapePlugin calls) and `isSensitivePath`
18-
* (the secretGuardPlugin denylist). Both are hard denials — the latter has no
17+
* containment, the same function pathEscapePlugin calls) and
18+
* `isSensitivePathResolved` (the secretGuardPlugin denylist, including the
19+
* CL-6971 realpath floor). Both are hard denials — the latter has no
1920
* yolo/allowOutside bypass, matching secretGuardPlugin's own unconditional
2021
* behavior.
2122
*/
@@ -25,7 +26,7 @@ import { resolve } from "node:path";
2526
import { hasCode } from "@intx/types";
2627
import { resolveWorkspacePath } from "../permission/path-restriction.js";
2728
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
28-
import { isSensitivePath } from "../plugins/secret-guard-plugin.js";
29+
import { isSensitivePath, isSensitivePathResolved } from "../plugins/secret-guard-plugin.js";
2930
import type { PermissionGate } from "../permission/gate.js";
3031
import type { CodexReadRawFile } from "./codex-tool-proxies.js";
3132

@@ -52,7 +53,9 @@ export function createCodexReadRawFile(
5253
if (resolved !== undefined) {
5354
absolutePath = resolved;
5455
} else if (allowOutside()) {
55-
// Mirrors pathEscapePlugin's own allowOutside fallback (yolo mode).
56+
// Mirrors pathEscapePlugin's own allowOutside fallback (yolo mode):
57+
// lexical absolutize. The realpath floor below still catches symlinks
58+
// whose innocuous names would otherwise defeat the denylist (CL-6971).
5659
absolutePath = resolve(cwd, path);
5760
} else {
5861
return {
@@ -61,9 +64,10 @@ export function createCodexReadRawFile(
6164
};
6265
}
6366

64-
// Re-check the resolved, symlink-realpath'd form too: a symlink can name
65-
// something innocuous while pointing at a sensitive real path.
66-
if (isSensitivePath(absolutePath)) {
67+
// Re-check after resolve — and realpath when the allowOutside branch left
68+
// a symlink unresolved — so a link can name something innocuous while
69+
// pointing at a sensitive real path.
70+
if (isSensitivePathResolved(absolutePath)) {
6771
return {
6872
content: `Access to sensitive file blocked by policy: ${path}`,
6973
isError: true,

src/plugins/secret-guard-plugin.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { isAbsolute } from "node:path";
12
import type { ToolPlugin } from "@intx/tools-posix";
3+
import { realpathNearestOr, UNRESOLVABLE } from "../permission/path-restriction.js";
24
import { looksLikePath } from "./path-escape-plugin.js";
35

46
// Files that hold secrets and must never be read or written by path-keyed tools
@@ -81,6 +83,20 @@ export function isSensitivePath(value: string): boolean {
8183
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalized));
8284
}
8385

86+
// Secret-guard floor (CL-6971): match the lexical path AND its realpath. Under
87+
// yolo, pathEscape absolutizes outside paths without resolving symlinks, so an
88+
// innocuous name (config.txt → .env, or cache/ → ~/.aws) would otherwise pass
89+
// the denylist. realpathNearestOr also covers write targets that don't exist
90+
// yet when a parent component is a symlink into a sensitive directory.
91+
// Absolute-only for the realpath leg — pathEscape absolutizes in the live
92+
// stack; relative unit-test args still match on the lexical form.
93+
export function isSensitivePathResolved(value: string): boolean {
94+
if (isSensitivePath(value)) return true;
95+
if (!isAbsolute(value)) return false;
96+
const real = realpathNearestOr(value);
97+
return real !== UNRESOLVABLE && isSensitivePath(real);
98+
}
99+
84100
// Break a shell command into the bare path-like tokens it references so each can
85101
// be matched against the secret-file denylist. Quote, backtick and backslash
86102
// characters are stripped first so split obfuscations (`.e''nv`, `'.env'`,
@@ -126,12 +142,13 @@ export function commandReferencesSensitivePath(command: string): string | undefi
126142
// permission gate (and auto-shell policy in auto mode).
127143
//
128144
// Path-arg hard deny runs before the permission plugin, so it holds even under
129-
// --dangerously-skip-permissions.
145+
// --dangerously-skip-permissions. Symlink resolution is part of that floor
146+
// (CL-6971): yolo must not let an innocuous link name defeat the denylist.
130147
export function secretGuardPlugin(): ToolPlugin {
131148
return {
132149
middleware: (next) => async (call, signal) => {
133150
for (const [key, value] of Object.entries(call.arguments)) {
134-
if (typeof value === "string" && looksLikePath(key) && isSensitivePath(value)) {
151+
if (typeof value === "string" && looksLikePath(key) && isSensitivePathResolved(value)) {
135152
return {
136153
callId: call.id,
137154
content: `Access to sensitive file blocked by policy: ${value}`,
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createPosixTools } from "@intx/tools-posix";
6+
import { createPermissionGate } from "../permission/gate.js";
7+
import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js";
8+
import { createCodexReadRawFile } from "../agent/codex-read-raw-file.js";
9+
10+
/**
11+
* CL-6971: under skip-permissions (yolo), pathEscape absolutizes outside paths
12+
* without realpath'ing, so an innocuous symlink name defeats the secret-guard
13+
* denylist. Secret guard is a floor — always realpath before isSensitivePath,
14+
* yolo or not. Covers both shapes: file symlink → secret, and dir symlink →
15+
* outside secret dir (where the lexical path no longer contains the sensitive
16+
* segment).
17+
*/
18+
19+
async function withFixture<T>(
20+
run: (paths: {
21+
cwd: string;
22+
outsideEnv: string;
23+
awsDir: string;
24+
fileLink: string;
25+
dirLink: string;
26+
}) => Promise<T>,
27+
): Promise<T> {
28+
const parent = await mkdtemp(join(tmpdir(), "cl6971-secret-symlink-"));
29+
const cwd = join(parent, "ws");
30+
const outside = join(parent, "outside");
31+
const awsDir = join(parent, ".aws");
32+
await mkdir(cwd);
33+
await mkdir(outside);
34+
await mkdir(awsDir);
35+
const outsideEnv = join(outside, ".env");
36+
await writeFile(outsideEnv, "SECRET=outside-env\n");
37+
await writeFile(join(awsDir, "credentials"), "aws_secret_access_key=LEAKED\n");
38+
// Shape 1: file symlink with an innocuous name → outside .env
39+
const fileLink = join(cwd, "config.txt");
40+
await symlink(outsideEnv, fileLink);
41+
// Shape 2: dir symlink → ~/.aws-shaped dir; lexical path is cache/credentials
42+
// (no ".aws/" segment) so basename-only matching is not enough.
43+
const dirLink = join(cwd, "cache");
44+
await symlink(awsDir, dirLink);
45+
try {
46+
return await run({ cwd, outsideEnv, awsDir, fileLink, dirLink });
47+
} finally {
48+
await rm(parent, { recursive: true, force: true });
49+
}
50+
}
51+
52+
function runner(cwd: string, skipPermissions: boolean) {
53+
const gate = createPermissionGate({
54+
approvals: [],
55+
interactive: false,
56+
skipPermissions,
57+
auto: false,
58+
cwd,
59+
});
60+
return {
61+
gate,
62+
tools: createPosixTools({
63+
cwd,
64+
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
65+
}),
66+
};
67+
}
68+
69+
describe("CL-6971 secret-guard realpaths before denylist (symlink floor)", () => {
70+
for (const skipPermissions of [true, false] as const) {
71+
const mode = skipPermissions ? "yolo" : "normal";
72+
73+
test(`${mode}: file symlink → outside .env is blocked for read_file`, async () => {
74+
await withFixture(async ({ cwd }) => {
75+
const { tools } = runner(cwd, skipPermissions);
76+
const result = await tools.run(
77+
{ id: "1", name: "read_file", arguments: { path: "config.txt" } },
78+
new AbortController().signal,
79+
);
80+
expect(result.isError).toBe(true);
81+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
82+
expect(String(result.content)).not.toContain("SECRET=outside-env");
83+
});
84+
});
85+
86+
test(`${mode}: file symlink → outside .env is blocked for write_file`, async () => {
87+
await withFixture(async ({ cwd, outsideEnv }) => {
88+
const before = await Bun.file(outsideEnv).text();
89+
const { tools } = runner(cwd, skipPermissions);
90+
const result = await tools.run(
91+
{
92+
id: "1",
93+
name: "write_file",
94+
arguments: { path: "config.txt", content: "pwned" },
95+
},
96+
new AbortController().signal,
97+
);
98+
expect(result.isError).toBe(true);
99+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
100+
expect(await Bun.file(outsideEnv).text()).toBe(before);
101+
});
102+
});
103+
104+
test(`${mode}: dir symlink → outside .aws/credentials is blocked for read_file`, async () => {
105+
await withFixture(async ({ cwd }) => {
106+
const { tools } = runner(cwd, skipPermissions);
107+
const result = await tools.run(
108+
{ id: "1", name: "read_file", arguments: { path: "cache/credentials" } },
109+
new AbortController().signal,
110+
);
111+
expect(result.isError).toBe(true);
112+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
113+
expect(String(result.content)).not.toContain("LEAKED");
114+
});
115+
});
116+
117+
test(`${mode}: dir symlink → outside .aws/credentials is blocked for write_file`, async () => {
118+
await withFixture(async ({ cwd, awsDir }) => {
119+
const target = join(awsDir, "credentials");
120+
const before = await Bun.file(target).text();
121+
const { tools } = runner(cwd, skipPermissions);
122+
const result = await tools.run(
123+
{
124+
id: "1",
125+
name: "write_file",
126+
arguments: { path: "cache/credentials", content: "pwned" },
127+
},
128+
new AbortController().signal,
129+
);
130+
expect(result.isError).toBe(true);
131+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
132+
expect(await Bun.file(target).text()).toBe(before);
133+
});
134+
});
135+
136+
test(`${mode}: file symlink → outside .env is blocked for apply_patch raw read`, async () => {
137+
await withFixture(async ({ cwd }) => {
138+
const { gate } = runner(cwd, skipPermissions);
139+
const result = await createCodexReadRawFile(cwd, gate)("config.txt");
140+
expect(result.isError).toBe(true);
141+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
142+
expect(String(result.content)).not.toContain("SECRET=outside-env");
143+
});
144+
});
145+
146+
test(`${mode}: dir symlink → outside .aws/credentials is blocked for apply_patch raw read`, async () => {
147+
await withFixture(async ({ cwd }) => {
148+
const { gate } = runner(cwd, skipPermissions);
149+
const result = await createCodexReadRawFile(cwd, gate)("cache/credentials");
150+
expect(result.isError).toBe(true);
151+
expect(String(result.content)).toMatch(/sensitive file|escapes working directory/i);
152+
expect(String(result.content)).not.toContain("LEAKED");
153+
});
154+
});
155+
}
156+
157+
// In-workspace file symlink → .env: pathEscape already realpaths in-bounds, but
158+
// the floor must still hold under yolo without relying on that alone.
159+
test("yolo: in-workspace file symlink → .env is blocked for read_file", async () => {
160+
const cwd = await mkdtemp(join(tmpdir(), "cl6971-inws-"));
161+
try {
162+
await writeFile(join(cwd, ".env"), "SECRET=in-ws\n");
163+
await symlink(join(cwd, ".env"), join(cwd, "looks-safe.txt"));
164+
const { tools } = runner(cwd, true);
165+
const result = await tools.run(
166+
{ id: "1", name: "read_file", arguments: { path: "looks-safe.txt" } },
167+
new AbortController().signal,
168+
);
169+
expect(result.isError).toBe(true);
170+
expect(String(result.content)).toMatch(/sensitive file/i);
171+
expect(String(result.content)).not.toContain("SECRET=in-ws");
172+
} finally {
173+
await rm(cwd, { recursive: true, force: true });
174+
}
175+
});
176+
});

0 commit comments

Comments
 (0)