Skip to content

Commit 9dc2f49

Browse files
corbits-builderTheGreatAxios
authored andcommitted
test(secret-guard): active custom --config holding skip must be denied
1 parent eb12f0a commit 9dc2f49

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
9+
/**
10+
* CL-9386: an operator-chosen --config path inside the workspace is
11+
* model-readable/writable while carrying standing skip-permissions (persisted
12+
* there by /yolo, which writes the active settings source). The static
13+
* secret-guard denylist only covers the default .corbits/settings.json shapes,
14+
* so the active custom path must be runtime-denylisted for the path-keyed
15+
* tools — reads and writes — even under skip-permissions.
16+
*/
17+
18+
const SKIP_PAYLOAD = JSON.stringify(
19+
{ dangerouslySkipPermissions: true },
20+
null,
21+
2,
22+
);
23+
24+
async function withFixture<T>(
25+
run: (paths: {
26+
cwd: string;
27+
customConfig: string;
28+
configLink: string;
29+
}) => Promise<T>,
30+
): Promise<T> {
31+
const parent = await mkdtemp(join(tmpdir(), "cl9386-config-denylist-"));
32+
const cwd = join(parent, "ws");
33+
await mkdir(cwd, { recursive: true });
34+
// Standing skip-permissions persisted into the operator-chosen --config file,
35+
// exactly what /yolo writes when --config points inside the workspace.
36+
const customConfig = join(cwd, "operator-config.json");
37+
await writeFile(customConfig, `${SKIP_PAYLOAD}\n`);
38+
// An innocuous symlink name for the same file: the resolve leg must hold.
39+
const configLink = join(cwd, "notes.txt");
40+
await symlink(customConfig, configLink);
41+
await writeFile(join(cwd, "scratch.txt"), "ordinary workspace file\n");
42+
try {
43+
return await run({ cwd, customConfig, configLink });
44+
} finally {
45+
await rm(parent, { recursive: true, force: true });
46+
}
47+
}
48+
49+
function runner(
50+
cwd: string,
51+
skipPermissions: boolean,
52+
activeConfigPath?: string,
53+
) {
54+
const gate = createPermissionGate({
55+
approvals: [],
56+
interactive: false,
57+
skipPermissions,
58+
reactorGated: false,
59+
auto: false,
60+
cwd,
61+
});
62+
const args = { cwd, permissionGate: gate };
63+
if (activeConfigPath !== undefined) {
64+
// CL-9386 seam: entry points thread the active --config path into the tool
65+
// stack here. Until the option exists this assignment is ignored and the
66+
// custom-config tests below fail (red).
67+
(args as { secretGuardExtraDeniedPaths?: string[] })
68+
.secretGuardExtraDeniedPaths = [activeConfigPath];
69+
}
70+
return {
71+
gate,
72+
tools: createPosixTools({
73+
cwd,
74+
plugins: buildCorePosixToolPlugins(args),
75+
}),
76+
};
77+
}
78+
79+
describe("CL-9386 runtime-denylist the active --config path holding skip", () => {
80+
for (const skipPermissions of [true, false] as const) {
81+
const mode = skipPermissions ? "yolo" : "normal";
82+
83+
test(`${mode}: active custom --config is blocked for read_file`, async () => {
84+
await withFixture(async ({ cwd, customConfig }) => {
85+
const { tools } = runner(cwd, skipPermissions, customConfig);
86+
const result = await tools.run(
87+
{
88+
id: "1",
89+
name: "read_file",
90+
arguments: { path: "operator-config.json" },
91+
},
92+
new AbortController().signal,
93+
);
94+
expect(result.isError).toBe(true);
95+
expect(String(result.content)).toMatch(/sensitive file/i);
96+
expect(String(result.content)).not.toContain(
97+
"dangerouslySkipPermissions",
98+
);
99+
});
100+
});
101+
102+
test(`${mode}: active custom --config is blocked for write_file`, async () => {
103+
await withFixture(async ({ cwd, customConfig }) => {
104+
const before = await Bun.file(customConfig).text();
105+
const { tools } = runner(cwd, skipPermissions, customConfig);
106+
const result = await tools.run(
107+
{
108+
id: "1",
109+
name: "write_file",
110+
arguments: {
111+
path: "operator-config.json",
112+
content: '{"dangerouslySkipPermissions":false}\n',
113+
},
114+
},
115+
new AbortController().signal,
116+
);
117+
expect(result.isError).toBe(true);
118+
expect(String(result.content)).toMatch(/sensitive file/i);
119+
expect(await Bun.file(customConfig).text()).toBe(before);
120+
});
121+
});
122+
123+
test(`${mode}: active custom --config via symlink name is blocked for read_file`, async () => {
124+
await withFixture(async ({ cwd, customConfig }) => {
125+
const { tools } = runner(cwd, skipPermissions, customConfig);
126+
const result = await tools.run(
127+
{ id: "1", name: "read_file", arguments: { path: "notes.txt" } },
128+
new AbortController().signal,
129+
);
130+
expect(result.isError).toBe(true);
131+
expect(String(result.content)).toMatch(/sensitive file/i);
132+
expect(String(result.content)).not.toContain(
133+
"dangerouslySkipPermissions",
134+
);
135+
});
136+
});
137+
}
138+
139+
test("pin: default settings-shaped file stays statically denied without extras", async () => {
140+
await withFixture(async ({ cwd }) => {
141+
const defaultShaped = join(cwd, ".corbits", "settings.json");
142+
await mkdir(join(cwd, ".corbits"), { recursive: true });
143+
await writeFile(defaultShaped, `${SKIP_PAYLOAD}\n`);
144+
for (const skipPermissions of [true, false] as const) {
145+
const { tools } = runner(cwd, skipPermissions);
146+
const result = await tools.run(
147+
{
148+
id: "1",
149+
name: "read_file",
150+
arguments: { path: ".corbits/settings.json" },
151+
},
152+
new AbortController().signal,
153+
);
154+
expect(result.isError).toBe(true);
155+
expect(String(result.content)).toMatch(/sensitive file/i);
156+
expect(String(result.content)).not.toContain(
157+
"dangerouslySkipPermissions",
158+
);
159+
}
160+
});
161+
});
162+
163+
test("pin: unrelated workspace file stays readable with extras set", async () => {
164+
await withFixture(async ({ cwd, customConfig }) => {
165+
const { tools } = runner(cwd, true, customConfig);
166+
const result = await tools.run(
167+
{ id: "1", name: "read_file", arguments: { path: "scratch.txt" } },
168+
new AbortController().signal,
169+
);
170+
expect(result.isError !== true).toBe(true);
171+
expect(String(result.content)).toContain("ordinary workspace file");
172+
});
173+
});
174+
});

0 commit comments

Comments
 (0)