Skip to content

Commit 0a9b723

Browse files
refactor(shell): share transparent command peeling (#1166)
* refactor(shell): share transparent command peeling * fix(shell): consume clustered env value shorts inside -S The -S payload walker exact-matched -u/-C and otherwise skipped any dash token, so -iu did not consume PATH and auto mode allowed open-ended find. Rejoin also dropped NAME=value tokens after -u, so env -u HOME FOO=bar ls auto-allowed.
1 parent db82f11 commit 0a9b723

5 files changed

Lines changed: 1035 additions & 123 deletions

File tree

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,45 @@ describe("isAutoAllowedShellCall — sensitive-path arguments", () => {
6767
});
6868
});
6969

70+
describe("clustered shell command options", () => {
71+
test("classifies clustered command payloads like canonical command payloads", () => {
72+
for (const options of ["-c", "-lc", "-xec", "-cc", "-cache"]) {
73+
const call = shellCall(`bash ${options} "echo x > .env"`);
74+
expect(autoShellRuleForCall(call)?.name).toBe("file-mutation");
75+
expect(autoShellRuleForCall(call)?.effect).toBe("deny");
76+
}
77+
});
78+
79+
test("classifies interpreter-specific and conservative alphabetic clusters", () => {
80+
for (const [shell, options] of [
81+
["zsh", "-yc"],
82+
["dash", "-Vc"],
83+
["ksh", "-Gc"],
84+
["bash", "-zc"],
85+
["bash", "-lc"],
86+
["sh", "-ec"],
87+
]) {
88+
expect(
89+
autoShellRuleForCall(shellCall(`${shell} ${options} "echo x > .env"`)),
90+
).toMatchObject({ name: "file-mutation", effect: "deny" });
91+
}
92+
});
93+
94+
test("classifies complete adjacent-fragment payloads", () => {
95+
for (const command of [
96+
`bash -c "echo x "'> .env'`,
97+
`bash -lc 'echo x '" > .env"`,
98+
`bash -xec "echo x"' > .env'`,
99+
`bash -cc echo" x > .env"`,
100+
]) {
101+
expect(autoShellRuleForCall(shellCall(command))).toMatchObject({
102+
name: "file-mutation",
103+
effect: "deny",
104+
});
105+
}
106+
});
107+
});
108+
70109
describe("isAutoAllowedShellCall — environment dump", () => {
71110
test("does not auto-allow printenv (full env dump)", () => {
72111
expect(isAutoAllowedShellCall(shellCall("printenv"))).toBe(false);
@@ -716,6 +755,19 @@ describe("env-assignment shell commands force ask in auto mode", () => {
716755
expect(
717756
autoShellRuleForCall(shellCall("env -i FOO=bar npm start"))?.name,
718757
).toBe("env-assignment");
758+
expect(autoShellRuleForCall(shellCall("env -i FOO=bar ls"))?.name).toBe(
759+
"env-assignment",
760+
);
761+
});
762+
763+
test("env -u HOME with a following assignment still asks", () => {
764+
expect(
765+
autoShellRuleForCall(shellCall("env -u HOME FOO=bar ls"))?.name,
766+
).toBe("env-assignment");
767+
expect(
768+
autoShellRuleForCall(shellCall("env -u HOME LD_PRELOAD=./evil.so ls"))
769+
?.name,
770+
).toBe("env-assignment");
719771
});
720772

721773
test("stacked short flags (env -iS) with an embedded assignment ask", () => {
@@ -780,6 +832,16 @@ describe("content inside an env -S payload never receives a weaker tier than it
780832
);
781833
expect(rule?.name).toBe("dependency-install");
782834
});
835+
836+
test("trailing env terminal flags remain arguments to split payloads", () => {
837+
expect(
838+
autoShellRuleForCall(shellCall(`env -S "rm -rf /" --version`))?.name,
839+
).toBe("recursive-rm");
840+
expect(
841+
autoShellRuleForCall(shellCall(`env -S "npm install left-pad" --help`))
842+
?.name,
843+
).toBe("dependency-install");
844+
});
783845
});
784846

785847
describe("upload-shaped network shell commands force ask in auto mode", () => {

‎src/shell/run-shell-authz.test.ts‎

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,121 @@ describe("recursive rm detection", () => {
101101
});
102102
});
103103

104+
describe("clustered shell command options", () => {
105+
const payload = `cat $'.envrc'`;
106+
107+
for (const [shell, options] of [
108+
["zsh", "-yc"],
109+
["dash", "-Vc"],
110+
["ksh", "-Gc"],
111+
] as const) {
112+
test.skipIf(Bun.which(shell) === null)(
113+
`${shell} ${options} executes the following argument as a payload`,
114+
() => {
115+
const result = Bun.spawnSync([shell, options, "printf clustered-ok"]);
116+
expect(result.exitCode).toBe(0);
117+
expect(result.stdout.toString()).toBe("clustered-ok");
118+
},
119+
);
120+
}
121+
122+
test("reconstructs double-quoted payloads with canonical and clustered options", () => {
123+
for (const options of ["-c", "-lc", "-xec", "-cc", "-cache"]) {
124+
const command = `bash ${options} "cat \\$'.envrc'"`;
125+
expect(expandShellSubjects(command)).toEqual({
126+
subjects: [command, payload],
127+
opaque: false,
128+
});
129+
}
130+
});
131+
132+
test("supports repeated command options for sh-compatible interpreters", () => {
133+
for (const shell of ["bash", "sh", "zsh", "dash", "ksh"]) {
134+
const command = `${shell} -cc "find /"`;
135+
expect(expandShellSubjects(command).subjects).toContain("find /");
136+
expect(runShellAuthzBlockReason(command)).toMatch(
137+
/Open-ended shell search blocked/,
138+
);
139+
}
140+
});
141+
142+
test("inspects interpreter-specific and conservative alphabetic clusters", () => {
143+
for (const [shell, options] of [
144+
["zsh", "-yc"],
145+
["dash", "-Vc"],
146+
["ksh", "-Gc"],
147+
["bash", "-zc"],
148+
["bash", "-lc"],
149+
["sh", "-ec"],
150+
]) {
151+
const openEnded = `${shell} ${options} "find /"`;
152+
expect(expandShellSubjects(openEnded).subjects).toContain("find /");
153+
expect(runShellAuthzBlockReason(openEnded)).toMatch(
154+
/Open-ended shell search blocked/,
155+
);
156+
expect(
157+
runShellAuthzBlockReason(`${shell} ${options} "rm -rf /"`),
158+
).toMatch(/Destructive command blocked/);
159+
}
160+
});
161+
162+
test("reconstructs the complete shell word from adjacent fragments", () => {
163+
const cases = [
164+
{ command: `bash -c "rm "'-rf /'`, payload: "rm -rf /" },
165+
{ command: `bash -lc 'rm '"-rf /"`, payload: "rm -rf /" },
166+
{ command: `bash -xec "fi"'nd /'`, payload: "find /" },
167+
{ command: `bash -cc fi"nd /"`, payload: "find /" },
168+
{
169+
command: `env -u -c bash -c "fi"'nd /'`,
170+
payload: "find /",
171+
},
172+
];
173+
174+
for (const { command, payload: expectedPayload } of cases) {
175+
expect(expandShellSubjects(command)).toEqual({
176+
subjects: [command, expectedPayload],
177+
opaque: false,
178+
});
179+
}
180+
});
181+
182+
test("does not treat true lookalikes as command options", () => {
183+
for (const command of [
184+
`bash script-c "find /"`,
185+
`bash --rcfile "find /"`,
186+
`bash -y+c "find /"`,
187+
`bash -c1 "find /"`,
188+
`bash script.sh -c "find /"`,
189+
]) {
190+
expect(expandShellSubjects(command)).toEqual({
191+
subjects: [command],
192+
opaque: false,
193+
});
194+
}
195+
});
196+
197+
test("hard-denies complete adjacent-fragment payloads", () => {
198+
for (const command of [
199+
`bash -c "rm "'-rf /'`,
200+
`bash -lc 'rm '"-rf /"`,
201+
`bash -xec "fi"'nd /'`,
202+
`bash -cc fi"nd /"`,
203+
]) {
204+
expect(runShellAuthzBlockReason(command)).toMatch(
205+
/Destructive command blocked|Open-ended shell search blocked/,
206+
);
207+
}
208+
});
209+
210+
test("hard-denies clustered command payloads like canonical command payloads", () => {
211+
for (const options of ["-c", "-lc", "-xec", "-cc", "-cache"]) {
212+
expect(runShellAuthzBlockReason(`bash ${options} "find /"`)).toMatch(
213+
/Open-ended shell search blocked/,
214+
);
215+
}
216+
});
217+
});
218+
104219
describe("stdin-blocking with quote-aware tokenizeSegment", () => {
105220
test("unquoted readers with no file operand are blocked", () => {
106221
expect(runShellAuthzBlockReason("cat")).toMatch(/standard input/);
@@ -388,6 +503,35 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => {
388503
);
389504
});
390505

506+
test("split payloads own trailing terminal-looking arguments", () => {
507+
const cases = [
508+
{
509+
command: `env -S "find ." --help`,
510+
subject: "find . --help",
511+
reason: openEnded,
512+
},
513+
{
514+
command: `env -S "rm -rf /" --version`,
515+
subject: "rm -rf / --version",
516+
reason: destructive,
517+
},
518+
{
519+
command: `env -S "npm install left-pad" --help`,
520+
subject: "npm install left-pad --help",
521+
},
522+
];
523+
524+
for (const { command, subject, reason } of cases) {
525+
expect(expandShellSubjects(command)).toEqual({
526+
subjects: [command, subject],
527+
opaque: false,
528+
});
529+
if (reason !== undefined) {
530+
expect(runShellAuthzBlockReason(command)).toMatch(reason);
531+
}
532+
}
533+
});
534+
391535
test("G7: soft-allow non-catastrophic rm inside -S is not hard-denied", () => {
392536
expect(
393537
runShellAuthzBlockReason(`env -S "rm -rf node_modules"`),
@@ -433,6 +577,71 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => {
433577
);
434578
});
435579

580+
test("transparent time options and end-of-options expose hard-denied utilities", () => {
581+
expect(runShellAuthzBlockReason(`time -p find .`)).toMatch(openEnded);
582+
expect(runShellAuthzBlockReason(`time -- find .`)).toMatch(openEnded);
583+
expect(runShellAuthzBlockReason(`/usr/bin/time -o report find .`)).toMatch(
584+
openEnded,
585+
);
586+
expect(runShellAuthzBlockReason(`/usr/bin/time -ao report find .`)).toMatch(
587+
openEnded,
588+
);
589+
expect(runShellAuthzBlockReason(`time -f %e find .`)).toMatch(openEnded);
590+
});
591+
592+
test("clustered env and timeout value options expose hard-denied utilities", () => {
593+
expect(runShellAuthzBlockReason(`env -iu PATH find .`)).toMatch(openEnded);
594+
expect(runShellAuthzBlockReason(`timeout -vs KILL 1 find .`)).toMatch(
595+
openEnded,
596+
);
597+
});
598+
599+
test("valid GNU timeout durations expose hard-denied utilities", () => {
600+
for (const duration of [
601+
".5s",
602+
"1",
603+
"1e3",
604+
"1e3s",
605+
"0x1p4",
606+
"2m",
607+
"3h",
608+
"4d",
609+
"inf",
610+
"infinity",
611+
]) {
612+
expect(runShellAuthzBlockReason(`timeout ${duration} find .`)).toMatch(
613+
openEnded,
614+
);
615+
}
616+
});
617+
618+
test("env value operands are parsed before terminal modes", () => {
619+
expect(runShellAuthzBlockReason(`env -u --help find .`)).toMatch(openEnded);
620+
expect(runShellAuthzBlockReason(`env -C --version find .`)).toMatch(
621+
openEnded,
622+
);
623+
});
624+
625+
test("env continues assignment parsing after end-of-options", () => {
626+
expect(runShellAuthzBlockReason(`env -- FILE=x find .`)).toMatch(openEnded);
627+
expect(runShellAuthzBlockReason(`env -i -- FILE=x find .`)).toMatch(
628+
openEnded,
629+
);
630+
});
631+
632+
test("terminal wrapper modes do not peel their operands as commands", () => {
633+
for (const command of [
634+
"command --help find .",
635+
"command -p -v find",
636+
"command -pv find",
637+
"env --help find",
638+
"nice --help find",
639+
"timeout --help find",
640+
]) {
641+
expect(runShellAuthzBlockReason(command)).toBeUndefined();
642+
}
643+
});
644+
436645
test("G13: empty/whitespace -S payload with trailing utility is hard-denied", () => {
437646
// Runtime still executes the trailing utility; do not opaque-drop it.
438647
expect(runShellAuthzBlockReason(`env -S " " find /`)).toMatch(openEnded);
@@ -463,6 +672,22 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => {
463672
expect(runShellAuthzBlockReason(`env -S -v cat`)).toMatch(stdinHang);
464673
});
465674

675+
test("G15b: clustered env value shorts inside -S consume the flag operand", () => {
676+
expect(runShellAuthzBlockReason(`env -S "-iu PATH find /"`)).toMatch(
677+
openEnded,
678+
);
679+
expect(runShellAuthzBlockReason(`env -S "-iu PATH rm -rf /"`)).toMatch(
680+
destructive,
681+
);
682+
expect(runShellAuthzBlockReason(`env -S "-iC /tmp find /"`)).toMatch(
683+
openEnded,
684+
);
685+
expect(runShellAuthzBlockReason(`env -iu PATH find /`)).toMatch(openEnded);
686+
expect(runShellAuthzBlockReason(`env -S "-i -u PATH find /"`)).toMatch(
687+
openEnded,
688+
);
689+
});
690+
466691
test("G16: env -S quoted rm flags still hard-deny catastrophic targets", () => {
467692
expect(runShellAuthzBlockReason(`env -S "rm '-rf' '/'"`)).toMatch(
468693
destructive,

0 commit comments

Comments
 (0)