7cebe901b7
- 28 symbols across discovery, mcp header policy, agent-hub projection and rendering, the agent registry, shell tokenizing and changelog comparison were exported but referenced only inside their own module; they are now module-private, shrinking the deep-import surface. - Kept AGENT_PLUGIN_MANIFEST_SCHEMA, AGENT_PLUGIN_MCP_SCHEMA, parseAgentPluginManifest, clearAgentPluginRootCache and mergeMCPHeaders exported: each is a seam for tests that defend real parsing or header precedence behavior. - Nothing reachable from an explicit exports entry or public barrel changed.
312 lines
9.0 KiB
TypeScript
312 lines
9.0 KiB
TypeScript
/**
|
|
* Conservative shell command tokenizer shared by the bash approval-pattern
|
|
* matcher and the gh-cache invalidator.
|
|
*
|
|
* Splits a bash command into independent command segments, each a list of word
|
|
* tokens. Handles single/double-quoted strings, backslash escapes, and the
|
|
* standard operators (`;`, `&&`, `||`, `|`, `&`, `(`, `)`, newlines) as segment
|
|
* boundaries so callers treat the pieces as independent command sequences.
|
|
*
|
|
* It is deliberately not a full POSIX parser — heredocs, command substitution,
|
|
* and arithmetic expansion are out of scope; callers fall through when they
|
|
* cannot find the structure they need.
|
|
*/
|
|
export function tokenizeShellSegments(command: string): string[][] {
|
|
const segments: string[][] = [];
|
|
let current: string[] = [];
|
|
let buffer = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
const pushBuffer = () => {
|
|
if (buffer.length > 0) {
|
|
current.push(buffer);
|
|
buffer = "";
|
|
}
|
|
};
|
|
const pushSegment = () => {
|
|
pushBuffer();
|
|
if (current.length > 0) segments.push(current);
|
|
current = [];
|
|
};
|
|
for (let i = 0; i < command.length; i++) {
|
|
const ch = command[i];
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
continue;
|
|
}
|
|
buffer += ch;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === "\\" && i + 1 < command.length) {
|
|
const next = command[i + 1];
|
|
if (next === '"' || next === "\\" || next === "$" || next === "`") {
|
|
buffer += next;
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
continue;
|
|
}
|
|
buffer += ch;
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && i + 1 < command.length) {
|
|
buffer += command[i + 1];
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === " " || ch === "\t") {
|
|
pushBuffer();
|
|
continue;
|
|
}
|
|
if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
|
|
pushSegment();
|
|
// `&&`, `||` already collapsed by the segment break above.
|
|
continue;
|
|
}
|
|
buffer += ch;
|
|
}
|
|
pushSegment();
|
|
return segments;
|
|
}
|
|
|
|
/**
|
|
* A flat shell command segment with the context needed to decide interception.
|
|
*
|
|
* @see extractFlatShellCommandSegments
|
|
*/
|
|
interface FlatShellCommandSegment {
|
|
/** Original segment text with quoting and escaping preserved. */
|
|
text: string;
|
|
/**
|
|
* True when this segment consumes the previous stage's stdout via an
|
|
* unquoted `|` or `|&`. Blank and comment-only continuation lines preserve
|
|
* the pending pipe state. Such a stage reads piped stdin, so path-based
|
|
* dedicated tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, and
|
|
* `&&` start an independent command and leave this false.
|
|
*/
|
|
pipedStdin: boolean;
|
|
}
|
|
|
|
/**
|
|
* Returns the flat shell command segments with the original text of each. Unlike
|
|
* `tokenizeShellSegments`, this preserves quoting and escaping so the results
|
|
* are safe to match against user-configured regular expressions, and flags
|
|
* segments that receive piped stdin.
|
|
*
|
|
* The extractor deliberately declines to split syntax whose execution context
|
|
* cannot be determined with this small scanner (heredocs, command substitution,
|
|
* backticks, grouping, and malformed quoting). Callers must still check the
|
|
* complete input in that case.
|
|
*/
|
|
export function extractFlatShellCommandSegments(command: string): FlatShellCommandSegment[] {
|
|
const segments: FlatShellCommandSegment[] = [];
|
|
let segmentStart = 0;
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let atWordStart = true;
|
|
let currentPiped = false;
|
|
|
|
const pushSegment = (end: number): boolean => {
|
|
const segment = command.slice(segmentStart, end).trim();
|
|
if (segment.length === 0) return false;
|
|
segments.push({ text: segment, pipedStdin: currentPiped });
|
|
return true;
|
|
};
|
|
|
|
for (let i = 0; i < command.length; i++) {
|
|
const ch = command[i];
|
|
if (inSingle) {
|
|
if (ch === "'") inSingle = false;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === "\\") {
|
|
if (i + 1 >= command.length) return [];
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
continue;
|
|
}
|
|
if (ch === "`" || (ch === "$" && command[i + 1] === "(")) return [];
|
|
continue;
|
|
}
|
|
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
atWordStart = false;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
atWordStart = false;
|
|
continue;
|
|
}
|
|
if (ch === "\\") {
|
|
if (i + 1 >= command.length) return [];
|
|
i++;
|
|
atWordStart = false;
|
|
continue;
|
|
}
|
|
if (
|
|
ch === "`" ||
|
|
ch === "(" ||
|
|
ch === ")" ||
|
|
(ch === "$" && command[i + 1] === "(") ||
|
|
(ch === "$" && command[i + 1] === "{") ||
|
|
(ch === "<" && command[i + 1] === "<") ||
|
|
((ch === "{" || ch === "}") &&
|
|
atWordStart &&
|
|
(command[i + 1] === undefined || /[ \t\n;]/.test(command[i + 1])))
|
|
) {
|
|
return [];
|
|
}
|
|
if (ch === "#" && atWordStart) {
|
|
const pushed = pushSegment(i);
|
|
const newline = command.indexOf("\n", i + 1);
|
|
if (newline === -1) return segments;
|
|
i = newline;
|
|
segmentStart = newline + 1;
|
|
atWordStart = true;
|
|
// Preserve a pending pipe through a comment-only continuation.
|
|
if (pushed) currentPiped = false;
|
|
continue;
|
|
}
|
|
const isRedirectionOperatorCharacter =
|
|
ch === "|"
|
|
? command[i - 1] === ">"
|
|
: ch === "&"
|
|
? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">"
|
|
: false;
|
|
if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) {
|
|
const pushed = pushSegment(i);
|
|
const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch;
|
|
const pipeStderr = ch === "|" && command[i + 1] === "&";
|
|
if (doubled || pipeStderr) i++;
|
|
// `|` and `|&` pipe into the next segment. Blank continuation
|
|
// lines preserve that pending state; all other operators reset it.
|
|
if (pushed || ch !== "\n") currentPiped = ch === "|" && !doubled;
|
|
segmentStart = i + 1;
|
|
atWordStart = true;
|
|
continue;
|
|
}
|
|
atWordStart = ch === " " || ch === "\t";
|
|
}
|
|
|
|
if (inSingle || inDouble) return [];
|
|
pushSegment(command.length);
|
|
return segments;
|
|
}
|
|
|
|
/**
|
|
* Shell metacharacters that end an unquoted `cd` target token. A redirect,
|
|
* extra argument, or any operator in this set means the leading construct is
|
|
* more than a bare `cd <path>`, so extraction must bail.
|
|
*/
|
|
const CD_TARGET_TERMINATORS: Record<string, true> = {
|
|
" ": true,
|
|
"\t": true,
|
|
"\n": true,
|
|
"\r": true,
|
|
"&": true,
|
|
"|": true,
|
|
";": true,
|
|
"<": true,
|
|
">": true,
|
|
"(": true,
|
|
")": true,
|
|
};
|
|
|
|
/**
|
|
* Parses a leading `cd <path> && ...` prefix so the bash tool can route the
|
|
* target through its structured `cwd` parameter when the model omits it.
|
|
*
|
|
* Returns the single path token (quotes and backslash escapes resolved to their
|
|
* literal value) and the command remainder after the top-level `&&`, or `null`
|
|
* when the command does not begin with exactly `cd`, one path token, and a
|
|
* top-level `&&`. The scanner deliberately bails on anything else in the prefix
|
|
* — redirects (`cd /tmp 2>/dev/null && ...`), extra arguments, or paths needing
|
|
* shell expansion (`$`, backticks, `(`) — leaving the whole command for the
|
|
* shell instead of absorbing shell syntax into `cwd`.
|
|
*/
|
|
export function extractLeadingCdTarget(command: string): { path: string; rest: string } | null {
|
|
const prefix = /^cd[ \t]+/.exec(command);
|
|
if (!prefix) return null;
|
|
let i = prefix[0].length;
|
|
let path = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
for (; i < command.length; i++) {
|
|
const ch = command[i];
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
continue;
|
|
}
|
|
path += ch;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === "\\" && i + 1 < command.length) {
|
|
const next = command[i + 1];
|
|
// A line continuation crosses the first physical line. Leave it to
|
|
// the shell rather than turning the escaped newline into cwd text.
|
|
if (next === "\n" || next === "\r") return null;
|
|
if (next === '"' || next === "\\" || next === "$" || next === "`") {
|
|
path += next;
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
continue;
|
|
}
|
|
path += ch;
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && i + 1 < command.length) {
|
|
// Preserve shell line-continuation semantics by declining extraction.
|
|
if (command[i + 1] === "\n" || command[i + 1] === "\r") return null;
|
|
path += command[i + 1];
|
|
i++;
|
|
continue;
|
|
}
|
|
if (CD_TARGET_TERMINATORS[ch]) break;
|
|
path += ch;
|
|
}
|
|
// Unterminated quote or empty target: leave the command for the shell.
|
|
if (inSingle || inDouble || path.length === 0) return null;
|
|
// A path needing shell expansion can't be resolved literally through cwd.
|
|
if (/[$`(]/.test(path)) return null;
|
|
// Skip inter-token whitespace, then require a top-level `&&` (a single `&`,
|
|
// `||`, `;`, `|`, or a redirect all mean this is not a bare `cd <path>`).
|
|
while (command[i] === " " || command[i] === "\t") i++;
|
|
if (command[i] !== "&" || command[i + 1] !== "&") return null;
|
|
i += 2;
|
|
while (command[i] === " " || command[i] === "\t") i++;
|
|
return { path, rest: command.slice(i) };
|
|
}
|