Skip to content

Commit 46a35a7

Browse files
authored
Merge pull request #34 from kenryu42/fix/positional-args-parsing
fix: positional args parsing
2 parents fb5ccdd + b388c23 commit 46a35a7

8 files changed

Lines changed: 2259 additions & 82 deletions

File tree

dist/bin/cc-safety-net.js

Lines changed: 531 additions & 26 deletions
Large diffs are not rendered by default.

dist/index.js

Lines changed: 531 additions & 26 deletions
Large diffs are not rendered by default.

src/core/shell.ts

Lines changed: 687 additions & 26 deletions
Large diffs are not rendered by default.

tests/bin/doctor/system-info.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('getSystemInfo', () => {
3535
const sysInfo = await getSystemInfo();
3636
expect(sysInfo.bunVersion).toMatch(/^\d+\.\d+/);
3737
expect(sysInfo.platform).toContain(process.platform);
38-
});
38+
}, 15000);
3939

4040
test('handles non-existent commands gracefully', async () => {
4141
const sysInfo = await getSystemInfo();
@@ -48,7 +48,7 @@ describe('getSystemInfo', () => {
4848
expect(sysInfo.geminiCliVersion === null || typeof sysInfo.geminiCliVersion === 'string').toBe(
4949
true,
5050
);
51-
});
51+
}, 15000);
5252

5353
test('handles commands that exit with non-zero code', async () => {
5454
const failingFetcher = async (_args: string[]) => null;

tests/bin/explain/command.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,106 @@ describe('explainCommand edge cases', () => {
158158
const result = explainCommand('git status', { cwd: '/tmp' });
159159
expect(result.result).toBe('allowed');
160160
});
161+
162+
test('rm redirect to dev null is allowed when target is safe', () => {
163+
const result = explainCommand('rm -rf /tmp/foo 2>/dev/null');
164+
expect(result.result).toBe('allowed');
165+
expect(result.trace.steps).toContainEqual({
166+
type: 'parse',
167+
input: 'rm -rf /tmp/foo 2>/dev/null',
168+
segments: [['rm', '-rf', '/tmp/foo']],
169+
});
170+
});
171+
172+
test('redirect target command substitution remains blocked', () => {
173+
const result = explainCommand('echo x >$(git reset --hard)');
174+
expect(result.result).toBe('blocked');
175+
expect(result.reason).toContain('git reset --hard');
176+
});
177+
178+
test('nested rm redirect to dev null is allowed in command substitution', () => {
179+
const result = explainCommand('echo $(rm -rf /tmp/foo 2>/dev/null)');
180+
expect(result.result).toBe('allowed');
181+
expect(result.trace.steps).toContainEqual({
182+
type: 'parse',
183+
input: 'echo $(rm -rf /tmp/foo 2>/dev/null)',
184+
segments: [['echo'], ['rm', '-rf', '/tmp/foo']],
185+
});
186+
});
187+
188+
test('numeric rm target before redirect is preserved in explain trace', () => {
189+
const result = explainCommand('rm -rf 7 > /dev/null');
190+
expect(result.result).toBe('allowed');
191+
expect(result.trace.steps).toContainEqual({
192+
type: 'parse',
193+
input: 'rm -rf 7 > /dev/null',
194+
segments: [['rm', '-rf', '7']],
195+
});
196+
});
197+
198+
test('attached io-number redirect is stripped from explain trace', () => {
199+
const result = explainCommand('rm -rf 123>/dev/null');
200+
expect(result.result).toBe('allowed');
201+
expect(result.trace.steps).toContainEqual({
202+
type: 'parse',
203+
input: 'rm -rf 123>/dev/null',
204+
segments: [['rm', '-rf']],
205+
});
206+
});
207+
208+
test('spaced numeric rm arg before redirect stays visible in explain trace', () => {
209+
const result = explainCommand('rm -rf 123 >/dev/null');
210+
expect(result.result).toBe('allowed');
211+
expect(result.trace.steps).toContainEqual({
212+
type: 'parse',
213+
input: 'rm -rf 123 >/dev/null',
214+
segments: [['rm', '-rf', '123']],
215+
});
216+
});
217+
218+
test('backticks inside arithmetic expansion remain blocked in explain trace', () => {
219+
const result = explainCommand('echo $((`git reset --hard` + 1))');
220+
expect(result.result).toBe('blocked');
221+
expect(result.reason).toContain('git reset --hard');
222+
});
223+
224+
test('process substitution remains blocked in explain trace', () => {
225+
const result = explainCommand('echo <(git reset --hard)');
226+
expect(result.result).toBe('blocked');
227+
expect(result.reason).toContain('git reset --hard');
228+
expect(result.trace.steps).toContainEqual({
229+
type: 'parse',
230+
input: 'echo <(git reset --hard)',
231+
segments: [['echo'], ['git', 'reset', '--hard']],
232+
});
233+
});
234+
235+
test('quoted literal backticks in redirect target do not hide blocked args in explain trace', () => {
236+
const result = explainCommand("git checkout >'file`name' -- foo");
237+
expect(result.result).toBe('blocked');
238+
expect(result.reason).toContain('git checkout --');
239+
expect(result.trace.steps).toContainEqual({
240+
type: 'parse',
241+
input: "git checkout >'file`name' -- foo",
242+
segments: [['git', 'checkout', '--', 'foo']],
243+
});
244+
});
245+
246+
test('single-quoted backticks in redirect targets stay literal in explain trace', () => {
247+
const result = explainCommand("echo >'a`git reset --hard`b'");
248+
expect(result.result).toBe('allowed');
249+
expect(result.trace.steps).toContainEqual({
250+
type: 'parse',
251+
input: "echo >'a`git reset --hard`b'",
252+
segments: [['echo']],
253+
});
254+
});
255+
256+
test('attached backtick substitutions outside redirect targets stay blocked in explain trace', () => {
257+
const result = explainCommand('echo foo`git reset --hard`bar');
258+
expect(result.result).toBe('blocked');
259+
expect(result.reason).toContain('git reset --hard');
260+
});
161261
});
162262

163263
describe('explainCommand rm with home directory', () => {

tests/core/analyze/edge-cases.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,92 @@ describe('edge cases', () => {
202202
test('command substitution find without delete allowed', () => {
203203
assertAllowed('echo $(find . -name foo )');
204204
});
205+
206+
test('grouped subshell command substitution git reset hard blocked', () => {
207+
assertBlocked('echo $( (git reset --hard) )', 'git reset --hard');
208+
});
209+
210+
test('grouped subshell command substitution rm root blocked', () => {
211+
assertBlocked('echo $( (rm -rf /) )', 'extremely dangerous');
212+
});
213+
214+
test('command substitution in redirect target git reset hard blocked', () => {
215+
assertBlocked('echo x >$(git reset --hard)', 'git reset --hard');
216+
});
217+
218+
test('command substitution in redirect target rm root blocked', () => {
219+
assertBlocked('echo x >$(rm -rf /)', 'extremely dangerous');
220+
});
221+
222+
test('attached command substitution in redirect target git reset hard blocked', () => {
223+
assertBlocked('echo x >file$(git reset --hard)', 'git reset --hard');
224+
});
225+
226+
test('attached command substitution in redirect target rm root blocked', () => {
227+
assertBlocked('echo x >$TMPDIR/$(rm -rf /)', 'extremely dangerous');
228+
});
229+
230+
test('command substitution keeps arguments after redirects blocked', () => {
231+
assertBlocked('echo $(rm -rf 2>/dev/null /)', 'extremely dangerous');
232+
});
233+
234+
test('arithmetic expansion keeps nested git reset blocked', () => {
235+
assertBlocked('echo $(( $(git reset --hard) + 1 ))', 'git reset --hard');
236+
});
237+
238+
test('arithmetic expansion keeps nested rm root blocked', () => {
239+
assertBlocked('echo $(( $(rm -rf /) + 1 ))', 'extremely dangerous');
240+
});
241+
242+
test('arithmetic expansion with adjacent nested git reset blocked', () => {
243+
assertBlocked('echo $((foo+$(git reset --hard)))', 'git reset --hard');
244+
});
245+
246+
test('arithmetic expansion with adjacent nested rm root blocked', () => {
247+
assertBlocked('echo $((1+$(rm -rf /)))', 'extremely dangerous');
248+
});
249+
250+
test('arithmetic expansion with backticks keeps nested git reset blocked', () => {
251+
assertBlocked('echo $((`git reset --hard` + 1))', 'git reset --hard');
252+
assertBlocked('echo $((foo`git reset --hard`bar))', 'git reset --hard');
253+
});
254+
255+
test('quoted arithmetic expressions that resemble guarded commands stay allowed', () => {
256+
assertAllowed('echo "$(( rm -rf /x ))"');
257+
assertAllowed('echo "$(( foo + bar ))"');
258+
});
259+
260+
test('quoted backtick substitution in redirect target git reset hard blocked', () => {
261+
assertBlocked('echo x >"`git reset --hard`"', 'git reset --hard');
262+
});
263+
264+
test('bare backtick redirect target git reset hard blocked', () => {
265+
assertBlocked('echo x >`git reset --hard`', 'git reset --hard');
266+
});
267+
268+
test('bare backtick redirect target inside command substitution blocked', () => {
269+
assertBlocked('echo $(echo x >`git reset --hard`)', 'git reset --hard');
270+
});
271+
272+
test('process substitution git reset hard blocked', () => {
273+
assertBlocked('echo <(git reset --hard)', 'git reset --hard');
274+
assertBlocked('cat >(git reset --hard)', 'git reset --hard');
275+
assertBlocked('echo x > >(git reset --hard)', 'git reset --hard');
276+
assertBlocked('echo foo < <(git reset --hard)', 'git reset --hard');
277+
});
278+
279+
test('quoted literal backticks in redirect targets do not hide blocked args', () => {
280+
assertBlocked("git checkout >'file`name' -- foo", 'git checkout --');
281+
assertBlocked("rm -rf >'file`name' /", 'extremely dangerous');
282+
});
283+
284+
test('single-quoted backticks in redirect targets stay literal', () => {
285+
assertAllowed("echo >'a`git reset --hard`b'");
286+
});
287+
288+
test('attached backtick substitutions outside redirect targets stay blocked', () => {
289+
assertBlocked('echo foo`git reset --hard`bar', 'git reset --hard');
290+
});
205291
});
206292

207293
describe('xargs', () => {
@@ -491,6 +577,10 @@ describe('edge cases', () => {
491577
assertBlocked('echo ok &>out && git reset --hard', 'git reset --hard');
492578
});
493579

580+
test('redirect before checkout path still blocks', () => {
581+
assertBlocked('git checkout 2>/dev/null -- foo', 'git checkout --');
582+
});
583+
494584
test('pipe stderr and stdout split blocked', () => {
495585
assertBlocked('echo ok |& git reset --hard', 'git reset --hard');
496586
});

0 commit comments

Comments
 (0)