Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/permission/auto-shell-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,124 @@ const shellCall = (command: string): ToolCall => ({
arguments: { command },
});

describe("local file URL glob expansion", () => {
const localBraceURLs = [
"file:///tmp/{%2Eenv,README.md}",
"file:///tmp/{README.md,%2Eenv}",
"file:///tmp/{.env,README.md}",
"file:///tmp/%2E{env,missing}",
"file:///tmp/%7BREADME.md,%2Eenv%7D",
"file:///tmp/{README.md",
"file:///tmp/README.md}",
];

test("requires approval for balanced and malformed local brace syntax", () => {
for (const url of localBraceURLs) {
expect(autoShellRuleForCall(shellCall(`curl '${url}'`))).toMatchObject({
name: "sensitive-path",
effect: "ask",
});
}
});

test("requires approval for synthesized file URL schemes through wrappers", () => {
const synthesizedURLs = [
"{file,https}:///tmp/%2Eenv",
"{https,file}:///tmp/%2Eenv",
"file{,s}:///tmp/%2Eenv",
"file{s,}:///tmp/%2Eenv",
"f{ile,oo}:///tmp/%2Eenv",
"f{oo,ile}:///tmp/%2Eenv",
"{file:///tmp/%2Eenv,https://example.com/README.md}",
"{https://example.com/README.md,file:///tmp/%2Eenv}",
"f{i,oo}{le,tp}:///tmp/%2Eenv",
"F{ILE,OO}:///tmp/%2Eenv",
"f[i-i]le:///tmp/%2Eenv",
"f[a-z]le:///tmp/%2Eenv",
"f[a-z:02]le:///tmp/%2Eenv",
"f[a-z:0002]le:///tmp/%2Eenv",
"f[a-z:0]le:///tmp/%2Eenv",
"F[I-I]LE:///tmp/%2Eenv",
"f[i-i]l{e,x}:///tmp/%2Eenv",
"f[i]le:///tmp/%2Eenv",
"f[i-i le:///tmp/%2Eenv",
];
const commands = synthesizedURLs.flatMap((url) => [
`curl '${url}'`,
`env curl '${url}'`,
`bash -c "curl '${url}'"`,
`sh -c "curl '${url}'"`,
]);

for (const command of commands) {
expect(autoShellRuleForCall(shellCall(command))).toMatchObject({
name: "sensitive-path",
effect: "ask",
});
}
});

test("does not apply the local brace rule to remote URLs", () => {
const remoteSchemes: string[] = Array.from({ length: 800 }, (_, index) =>
index % 2 === 0 ? "https" : "http",
);
const overlengthRemote = `{${remoteSchemes.join(",")}}://example.com/{one,two}`;
for (const url of [
"https://example.com/{one,two}",
"https://example.com/%7Bone,two%7D",
"https://example.com/{one",
"https://example.com/two}",
"h[t-t]tp://example.com/[a-z]",
"f[a-z:3]le:///tmp/%2Eenv",
"f[z-a:02]le:///tmp/%2Eenv",
"h[t-z:02]tp://example.com/%2Eenv",
"https://example.com/[a-z]?q=[0-9]",
overlengthRemote,
]) {
expect(autoShellRuleForCall(shellCall(`curl '${url}'`))).toBeUndefined();
}

remoteSchemes[799] = "file";
const overlengthFileCapable = `{${remoteSchemes.join(",")}}:///tmp/%2Eenv`;
expect(
autoShellRuleForCall(shellCall(`curl '${overlengthFileCapable}'`)),
).toMatchObject({ name: "sensitive-path", effect: "ask" });
});
});

describe("curl proto-default file", () => {
test("requires approval for inferred local file operands through wrappers", () => {
for (const command of [
"curl --silent --proto-default file $PWD/%2Eenv",
"curl --proto-default=file /tmp/%2Eenv",
"curl --proto-default FILE ./%2Eenv",
"curl /tmp/%2Eenv --proto-default file",
"curl --proto-default file //localhost/tmp/%2Eenv",
"curl --proto-default file README.md /tmp/%2Eenv",
"env curl --proto-default file /tmp/%2Eenv",
"bash -c 'curl --proto-default file /tmp/%2Eenv'",
"sh -c 'curl --proto-default file /tmp/%2Eenv'",
]) {
expect(autoShellRuleForCall(shellCall(command))).toMatchObject({
name: "sensitive-path",
effect: "ask",
});
}
});

test("keeps explicit and default HTTPS operands unflagged", () => {
for (const command of [
"curl --proto-default file https://example.com/%2Eenv",
"curl --proto-default FILE HTTP://example.com/%2Eenv",
"curl --proto-default https example.com/%2Eenv",
"curl example.com/%2Eenv",
"curl --output /tmp/%2Eenv --proto-default file https://example.com",
]) {
expect(autoShellRuleForCall(shellCall(command))).toBeUndefined();
}
});
});

// Base git-global-config routing (--global/--system/--edit, --file targets,
// unset/reassignment of GIT_CONFIG_GLOBAL, repo-local pass-through) is pinned
// in classify-security.test.ts. This file pins the surface that file does not:
Expand Down
19 changes: 19 additions & 0 deletions src/permission/classify-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,25 @@ describe("sensitive-path shell commands require approval, not a hard deny", () =
expect(asked).toBe(1);
});

test("stored curl grants do not authorize stepped file-scheme synthesis", async () => {
let asked = 0;
const gate = createPermissionGate({
approvals: [{ tool: "run_shell", pattern: "curl *" }],
requestApproval: async () => {
asked++;
return { allow: true };
},
interactive: true,
skipPermissions: false,
reactorGated: false,
});
const verdict = await gate.evaluate(
shellCall("curl 'f[a-z:02]le:///tmp/%2Eenv'"),
);
expect(verdict.allowed).toBe(true);
expect(asked).toBe(1);
});

test("stored grants still authorize ordinary shell reads", async () => {
let asked = 0;
const gate = createPermissionGate({
Expand Down
Loading
Loading