Skip to content

Commit 956982f

Browse files
bash-policy: Add nix and shell command unwrapping support
Co-authored-by: SCE <sce@crocoder.dev>
1 parent 11b0dd1 commit 956982f

2 files changed

Lines changed: 244 additions & 152 deletions

File tree

config/.opencode/plugins/bash-policy/runtime.ts

Lines changed: 122 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const WRAPPER_BINARIES = new Set([
1010
"nohup",
1111
"sudo",
1212
]);
13+
const SHELL_BINARIES = new Set(["sh", "bash"]);
1314

1415
interface PolicyMatch {
1516
id: string;
@@ -81,18 +82,20 @@ export async function evaluateBashCommandPolicy({
8182
const activePolicies = buildActivePolicies(policyConfig, presetCatalog);
8283

8384
for (const segment of segments) {
84-
const normalizedArgv = normalizeSegment(segment);
85-
if (!normalizedArgv || normalizedArgv.length === 0) {
86-
continue;
87-
}
85+
const normalizedArgvCandidates = normalizeSegment(segment);
86+
for (const normalizedArgv of normalizedArgvCandidates) {
87+
if (normalizedArgv.length === 0) {
88+
continue;
89+
}
8890

89-
const match = selectMatchingPolicy(activePolicies, normalizedArgv);
90-
if (match) {
91-
return {
92-
allowed: false,
93-
normalizedArgv,
94-
policy: match,
95-
};
91+
const match = selectMatchingPolicy(activePolicies, normalizedArgv);
92+
if (match) {
93+
return {
94+
allowed: false,
95+
normalizedArgv,
96+
policy: match,
97+
};
98+
}
9699
}
97100
}
98101

@@ -101,9 +104,9 @@ export async function evaluateBashCommandPolicy({
101104
};
102105
}
103106

104-
function normalizeSegment(segment: string[]): string[] | null {
107+
function normalizeSegment(segment: string[]): string[][] {
105108
if (segment.length === 0) {
106-
return null;
109+
return [];
107110
}
108111

109112
const normalized = [...segment];
@@ -120,11 +123,78 @@ function normalizeSegment(segment: string[]): string[] | null {
120123
}
121124

122125
if (normalized.length === 0) {
123-
return null;
126+
return [];
124127
}
125128

126129
normalized[0] = path.basename(normalized[0] ?? "");
127-
return normalized;
130+
131+
const nestedSegments = unwrapNestedCommandSegments(normalized);
132+
if (!nestedSegments) {
133+
return [normalized];
134+
}
135+
136+
const nestedNormalized: string[][] = [];
137+
for (const nestedSegment of nestedSegments) {
138+
nestedNormalized.push(...normalizeSegment(nestedSegment));
139+
}
140+
141+
return nestedNormalized.length > 0 ? nestedNormalized : [normalized];
142+
}
143+
144+
function unwrapNestedCommandSegments(segment: string[]): string[][] | null {
145+
const executable = segment[0];
146+
if (!executable) {
147+
return null;
148+
}
149+
150+
if (executable === "nix") {
151+
const nestedArgv = extractNixCommandArgv(segment);
152+
return nestedArgv ? [nestedArgv] : null;
153+
}
154+
155+
if (SHELL_BINARIES.has(executable)) {
156+
const shellPayload = extractShellCommandPayload(segment);
157+
if (!shellPayload) {
158+
return null;
159+
}
160+
161+
return parseCommandSegments(shellPayload);
162+
}
163+
164+
return null;
165+
}
166+
167+
function extractNixCommandArgv(segment: string[]): string[] | null {
168+
for (let index = 1; index < segment.length; index += 1) {
169+
const token = segment[index];
170+
if (token !== "-c" && token !== "--command") {
171+
continue;
172+
}
173+
174+
const nestedArgv = segment.slice(index + 1);
175+
return nestedArgv.length > 0 ? nestedArgv : null;
176+
}
177+
178+
return null;
179+
}
180+
181+
function extractShellCommandPayload(segment: string[]): string | null {
182+
for (let index = 1; index < segment.length; index += 1) {
183+
const token = segment[index];
184+
if (!token || token === "--") {
185+
continue;
186+
}
187+
188+
if (token === "-c") {
189+
return segment[index + 1] ?? null;
190+
}
191+
192+
if (token.startsWith("-") && token.includes("c")) {
193+
return segment[index + 1] ?? null;
194+
}
195+
}
196+
197+
return null;
128198
}
129199

130200
export function formatPolicyBlockMessage(match: PolicyMatch): string {
@@ -439,16 +509,28 @@ function tokenizeShellCommand(command: string): string[] | null {
439509
let current = "";
440510
let quote: string | null = null;
441511
let escaping = false;
512+
let index = 0;
513+
514+
const pushCurrent = () => {
515+
if (current.length > 0) {
516+
tokens.push(current);
517+
current = "";
518+
}
519+
};
520+
521+
while (index < command.length) {
522+
const character = command[index] ?? "";
442523

443-
for (const character of command) {
444524
if (escaping) {
445525
current += character;
446526
escaping = false;
527+
index += 1;
447528
continue;
448529
}
449530

450531
if (character === "\\" && quote !== "'") {
451532
escaping = true;
533+
index += 1;
452534
continue;
453535
}
454536

@@ -458,32 +540,49 @@ function tokenizeShellCommand(command: string): string[] | null {
458540
} else {
459541
current += character;
460542
}
543+
index += 1;
461544
continue;
462545
}
463546

464547
if (character === '"' || character === "'") {
465548
quote = character;
549+
index += 1;
466550
continue;
467551
}
468552

469553
if (/\s/.test(character)) {
470-
if (current.length > 0) {
471-
tokens.push(current);
472-
current = "";
554+
pushCurrent();
555+
index += 1;
556+
continue;
557+
}
558+
559+
if (character === "&" || character === "|" || character === ";") {
560+
pushCurrent();
561+
562+
const nextCharacter = command[index + 1] ?? "";
563+
if (
564+
(character === "&" || character === "|") &&
565+
nextCharacter === character
566+
) {
567+
tokens.push(character + nextCharacter);
568+
index += 2;
569+
continue;
473570
}
571+
572+
tokens.push(character);
573+
index += 1;
474574
continue;
475575
}
476576

477577
current += character;
578+
index += 1;
478579
}
479580

480581
if (escaping || quote) {
481582
return null;
482583
}
483584

484-
if (current.length > 0) {
485-
tokens.push(current);
486-
}
585+
pushCurrent();
487586

488587
return tokens;
489588
}
@@ -505,52 +604,6 @@ function isShellOperator(token: string): boolean {
505604
return SHELL_OPERATORS.has(token);
506605
}
507606

508-
/**
509-
* Splits a token that may contain embedded operators (e.g., "ls;" -> ["ls", ";"])
510-
*/
511-
function splitTokenWithEmbeddedOperators(token: string): string[] {
512-
const result: string[] = [];
513-
let current = "";
514-
let i = 0;
515-
516-
while (i < token.length) {
517-
// Check for multi-character operators first (&&, ||)
518-
if (i + 1 < token.length) {
519-
const twoChar = token.slice(i, i + 2);
520-
if (twoChar === "&&" || twoChar === "||") {
521-
if (current.length > 0) {
522-
result.push(current);
523-
current = "";
524-
}
525-
result.push(twoChar);
526-
i += 2;
527-
continue;
528-
}
529-
}
530-
531-
// Check for single-character operators
532-
const char = token[i];
533-
if (SHELL_OPERATORS.has(char)) {
534-
if (current.length > 0) {
535-
result.push(current);
536-
current = "";
537-
}
538-
result.push(char);
539-
i++;
540-
continue;
541-
}
542-
543-
current += char;
544-
i++;
545-
}
546-
547-
if (current.length > 0) {
548-
result.push(current);
549-
}
550-
551-
return result;
552-
}
553-
554607
/**
555608
* Parses a command string into segments separated by shell control operators.
556609
*
@@ -571,17 +624,10 @@ export function parseCommandSegments(command: string): string[][] | null {
571624
return null;
572625
}
573626

574-
// Flatten tokens that may contain embedded operators (e.g., "ls;" -> ["ls", ";"])
575-
const flattenedTokens: string[] = [];
576-
for (const token of tokens) {
577-
const splitTokens = splitTokenWithEmbeddedOperators(token);
578-
flattenedTokens.push(...splitTokens);
579-
}
580-
581627
const segments: string[][] = [];
582628
let currentSegment: string[] = [];
583629

584-
for (const token of flattenedTokens) {
630+
for (const token of tokens) {
585631
if (isShellOperator(token)) {
586632
// Start a new segment, skipping empty segments (consecutive operators)
587633
if (currentSegment.length > 0) {

0 commit comments

Comments
 (0)