diff --git a/src/capabilities/permissions.test.ts b/src/capabilities/permissions.test.ts index 53c44393..f046e64e 100644 --- a/src/capabilities/permissions.test.ts +++ b/src/capabilities/permissions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { splitShellSegments } from './permissions.js'; +import { hasShellRedirection, splitShellSegments } from './permissions.js'; describe('splitShellSegments', () => { it('passes simple commands through as a single segment', () => { @@ -45,3 +45,17 @@ describe('splitShellSegments', () => { expect(splitShellSegments('{ reboot now; }')).toEqual(['reboot now']); }); }); + +describe('hasShellRedirection', () => { + it('detects write and heredoc redirections outside quotes', () => { + expect(hasShellRedirection('cat README.md > variant.txt')).toBe(true); + expect(hasShellRedirection('cat README.md >> variant.txt')).toBe(true); + expect(hasShellRedirection('cat < { + expect(hasShellRedirection('echo ">"')).toBe(false); + expect(hasShellRedirection("echo '>'")).toBe(false); + expect(hasShellRedirection('echo \\> file')).toBe(false); + }); +}); diff --git a/src/capabilities/permissions.ts b/src/capabilities/permissions.ts index c7148b5a..ed04b554 100644 --- a/src/capabilities/permissions.ts +++ b/src/capabilities/permissions.ts @@ -280,6 +280,44 @@ export function splitShellSegments(command: string): string[] { return out; } +export function hasShellRedirection(segment: string): boolean { + let single = false; + let double = false; + let escaped = false; + + for (let i = 0; i < segment.length; i++) { + const ch = segment[i]; + const next = segment[i + 1]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (!double && ch === "'") { + single = !single; + continue; + } + + if (!single && ch === '"') { + double = !double; + continue; + } + + if (single || double) continue; + + if (ch === '>') return true; + if (ch === '<' && next === '<') return true; + } + + return false; +} + export class PermissionManager { private manifest: PermissionsManifest; private readonly cwd: string; @@ -502,6 +540,7 @@ export class PermissionManager { // Matching the full trimmed string would let `cat foo; rm -rf ~` slip // through because `cat *` matches the entire concatenation. const allSegmentsSafeRead = segments.length > 0 && segments.every((segment) => + !hasShellRedirection(segment) && PermissionManager.SAFE_READ_PATTERNS.some((p) => this.matchPattern(segment, p)) ); if (allSegmentsSafeRead) {