Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { extractBashTargetDirs } from './bashTargets';

const AGENTS_MD_BASENAMES: ReadonlySet<string> = new Set<string>(AGENTS_MD_PLAIN_NAMES);

const BASH_PARSE_OPTIONS = { timeoutMs: 20, maxNodes: 10_000 } as const;
const BASH_PARSE_OPTIONS = { timeoutMs: 500, maxNodes: 10_000 } as const;

const DISCOVERY_REMINDER_VARIANT = 'agents_md';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
PermissionPolicyResult,
} from '#/agent/permissionPolicy/types';

const PARSE_OPTIONS = { timeoutMs: 20, maxNodes: 10_000 } as const;
const PARSE_OPTIONS = { timeoutMs: 500, maxNodes: 10_000 } as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep synchronous permission parsing within a short deadline

For large single-token Bash inputs, such as a multi-megabyte quoted string, maxNodes does not constrain the character scan, so raising this deadline from 20 ms to 500 ms lets the synchronous evaluate() path block the process event loop for roughly half a second before degrading to unanalyzable. The Bash tool schema accepts an unbounded string, and kap-server can host multiple sessions in the same process, so one generated command can stall unrelated sessions; retain a short deadline or impose an input-size/off-thread bound instead. The matching 500 ms change in agentsMdReminderService.ts has the same synchronous-stall risk.

Useful? React with 👍 / 👎.


const MAX_NESTED_SHELL_DEPTH = 4;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ describe('AgentPermissionPolicyService chain', () => {
},
);

it('approves a heredoc command containing a single quote in yolo mode', async () => {
mode = 'yolo';

await expect(evaluate({
toolName: 'Bash',
args: { command: 'gh --body "$(cat <<\'EOF\'\nit\'s\nEOF\n)"', timeout: 60 },
})).resolves.toMatchObject({
policyName: 'yolo-mode-approve',
result: { kind: 'approve' },
});
});

it.each(['$CMD --force', 'bash -c "echo $HOME"', 'env $FLAGS'])(
'denies unanalyzable command `%s` in auto mode',
async (command) => {
Expand Down
212 changes: 211 additions & 1 deletion packages/tree-sitter-bash/src/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,19 @@ const CASE_ENABLING_WORDS: ReadonlySet<string> = new Set(['if', 'then', 'elif',
* keywords in CASE_ENABLING_WORDS — so `echo case` does not confuse the
* scan. `esac` pops the innermost open case regardless of position (the
* reference scanner emits the esac token even in argument position).
*
* Heredoc-aware: a `<<` / `<<-` operator queues its delimiter word, and the
* body lines of every pending heredoc are skipped wholesale right after
* the next newline — quotes, parens and substitutions inside a heredoc
* body must not affect the paren count. `((` opens an arithmetic region,
* skipped as one balanced unit so a left-shift `<<` is not mistaken for a
* heredoc operator. Comments (`#` at the start of a word, judged by the
* preceding character after looking through `\`+newline continuations)
* are skipped to end of line, `${ ... }` / `$[ ... ]` expansions,
* word-glued `[ ... ]` subscripts, and `[[ ... ]]` conditional regions
* are skipped as balanced units, so a `<<` inside any of these
* non-redirection contexts is likewise not mistaken for a heredoc
* operator.
*/
export function scanBalancedStatements(
source: string,
Expand All @@ -266,6 +279,8 @@ export function scanBalancedStatements(
let nesting = 0;
/** Paren depths at which each open case_statement started. */
const caseDepths: number[] = [];
/** Heredoc delimiters queued since the last newline. */
const pendingHeredocs: { delimiter: string; stripTabs: boolean }[] = [];
let j = i;
/** What preceded the current position: 'start' | 'sep' | 'keyword' | 'word'. */
let previous: 'start' | 'sep' | 'keyword' | 'word' = 'start';
Expand Down Expand Up @@ -299,7 +314,16 @@ export function scanBalancedStatements(
j++;
continue;
}
if (ch === '\n' || ch === ';' || ch === '&' || ch === '|') {
if (ch === '\n') {
j++;
if (pendingHeredocs.length > 0) {
j = skipHeredocBodies(source, budget, j, end, pendingHeredocs);
pendingHeredocs.length = 0;
}
previous = 'sep';
continue;
}
if (ch === ';' || ch === '&' || ch === '|') {
previous = 'sep';
j++;
continue;
Expand All @@ -309,7 +333,50 @@ export function scanBalancedStatements(
j++;
continue;
}
if (ch === '#') {
let p = j - 1;
while (p - 1 >= i && source[p] === '\n' && source[p - 1] === '\\') p -= 2;
const prev = p >= i ? source[p] : undefined;
if (prev === undefined || isBlank(prev) || prev === '\n' || prev === ';' || prev === '&' || prev === '|' || prev === '(') {
while (j < end && source[j] !== '\n') j++;
Comment on lines +336 to +341

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve line continuations when classifying comments

When # follows a backslash-newline inside a command substitution, Bash removes the continuation, so the hash remains part of the preceding word rather than starting a comment. For example, the Bash-valid input represented as echo $(printf foo\<newline>#bar) parsed without error before this commit, but this raw-character check sees the preceding newline, skips the closing ) as comment text, and returns hasError: true; classify comments using the logical token context after continuations instead.

AGENTS.md reference: AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

continue;
}
}
if (ch === '$' && (source[j + 1] === '{' || source[j + 1] === '[')) {
const open = source[j + 1]!;
j = scanBalanced(source, budget, j + 1, end, open, open === '{' ? '}' : ']', depth + 1).end;
previous = 'word';
continue;
}
if (ch === '[' && source[j + 1] === '[' && previous !== 'word') {
j = scanBalanced(source, budget, j, end, '[', ']', depth + 1).end;
previous = 'word';
continue;
}
if (ch === '[' && j > i && isWordChar(source[j - 1])) {
j = scanBalanced(source, budget, j, end, '[', ']', depth + 1).end;
previous = 'word';
continue;
}
if (ch === '<') {
const heredoc = scanHeredocDelimiter(source, budget, j, end, depth);
if (heredoc !== null) {
pendingHeredocs.push(heredoc);
Comment on lines +361 to +364

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip subscript shifts before detecting heredocs

When a multiline command substitution contains an indexed assignment such as echo $(a[x<<2]=3\n), Bash accepts it and the previous parser returned hasError: false, but this unconditional < branch interprets the arithmetic shift inside [...] as a heredoc delimiter. At the newline it consequently consumes through the real closing ), producing an ERROR node and hasError: true; permission callers then deny the command in auto mode or prompt in yolo mode. This is fresh evidence beyond the earlier comment: its comment and $[...] examples are now handled, while assignment subscripts remain unhandled. Heredoc recognition must be restricted to redirect-token contexts, or assignment subscript regions must also be skipped.

AGENTS.md reference: AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

Comment on lines +361 to +364

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude extglob patterns from heredoc detection

Fresh evidence in the final commit is the Bash-valid echo $( [[ x == @(<<EOF) ]]\n) case: the parent parser returns hasError: false, but this branch interprets <<EOF inside the conditional's extglob pattern as a heredoc and consumes through the closing substitution, producing hasError: true. Skip balanced extglob-pattern regions before recognizing heredoc operators; otherwise permission callers deny this command in auto mode or prompt in yolo mode.

AGENTS.md reference: AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

Comment on lines +361 to +364

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip compound-assignment subscripts before heredoc detection

Fresh evidence beyond the earlier indexed-assignment comment is the compound-array form echo $(a=([x<<2]=3)\n): it is valid Bash and the parent parser returned hasError: false, but the subscript starts after ( rather than a word character, so the new scan reaches this branch and queues <<2 as a heredoc. At the newline it consumes the real closing ), making the command unanalyzable and therefore denied in auto mode or prompted in yolo mode; skip [... ] compound-assignment subscripts before recognizing heredocs.

AGENTS.md reference: AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

j = heredoc.end;
Comment on lines +361 to +365

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict heredoc detection to shell-token contexts

When << occurs outside a redirection, this unconditional check still queues a heredoc. For example, both echo $(printf x # <<EOF\n) (<<EOF is in a comment) and echo $(echo $[x << 2]\n) (legacy arithmetic) are valid Bash and parsed without errors before this commit, but now skipHeredocBodies consumes the remainder and returns hasError: true. The permission policy consequently treats these commands as unanalyzable and denies them in auto mode or prompts in yolo mode; skip comment and $[...] regions, or otherwise establish token context, before recognizing heredocs.

AGENTS.md reference: AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

} else {
j++;
}
previous = 'word';
continue;
}
if (ch === '(') {
if (source[j + 1] === '(') {
const arith = scanBalanced(source, budget, j, end, '(', ')', depth + 1);
if (j === i) return { end: arith.end, balanced: arith.balanced };
j = arith.end;
previous = 'word';
continue;
}
nesting++;
previous = 'sep';
j++;
Expand Down Expand Up @@ -351,6 +418,149 @@ export function scanBalancedStatements(
return { end, balanced: false };
}

/** Parse a heredoc operator (`<<` / `<<-`) and its delimiter word, starting
* at `i` (which points at the first `<`). Returns the delimiter with quotes
* and backslashes removed (mirroring the parser's extractHeredocSpec),
* whether `<<-` strips leading tabs, and the index just past the delimiter
* word — or null when this `<` does not open a heredoc with a non-empty
* delimiter (`<<<` herestring, another redirect, or malformed input).
* Substitution syntax inside the delimiter word (`$( )`, `${ }`, `$[ ]`,
* backticks) is scanned wholesale as part of the word. */
function scanHeredocDelimiter(
source: string,
budget: ParseBudget,
i: number,
end: number,
depth: number,
): { delimiter: string; stripTabs: boolean; end: number } | null {
if (source[i + 1] !== '<') return null;
let j = i + 2;
if (source[j] === '<') return null;
let stripTabs = false;
if (source[j] === '-') {
stripTabs = true;
j++;
}
while (j < end && (source[j] === ' ' || source[j] === '\t' || source[j] === '\r')) j++;
let raw = '';
while (j < end) {
const ch = source[j]!;
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') break;
if (ch === '&' || ch === '|' || ch === ';' || ch === '(' || ch === ')' || ch === '<' || ch === '>') break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan substitutions as part of heredoc delimiters

When a heredoc delimiter contains command or arithmetic-substitution syntax, Bash uses the whole word as the delimiter, but this branch truncates it at the first (. For example, echo $(cat <<$(foo)\nbody\n$(foo)\n) passes bash -n and the parent parser returned hasError: false; the new scanner instead queues $, never recognizes the $(foo) terminator, consumes through the closing substitution, and returns hasError: true. Parse these constructs as part of the delimiter word so permission callers do not deny or prompt for an otherwise analyzable command.

AGENTS.md reference: AGENTS.md:L31-L31

Useful? React with 👍 / 👎.

if (ch === '$' && (source[j + 1] === '(' || source[j + 1] === '{' || source[j + 1] === '[')) {
const open = source[j + 1]!;
const region =
open === '('
? scanBalancedStatements(source, budget, j + 1, end, depth + 1)
: scanBalanced(source, budget, j + 1, end, open, open === '{' ? '}' : ']', depth + 1);
if (!region.balanced) return null;
raw += source.slice(j, region.end);
j = region.end;
continue;
}
if (ch === '`') {
const backtickEnd = skipBacktick(source, budget, j, end);
if (backtickEnd >= end) return null;
raw += source.slice(j, backtickEnd);
j = backtickEnd;
continue;
}
if (ch === '\\') {
if (j + 1 >= end || source[j + 1] === '\n') return null;
raw += ch + source[j + 1];
j += 2;
continue;
}
if (ch === "'") {
const close = source.indexOf("'", j + 1);
if (close === -1 || close >= end) return null;
raw += source.slice(j, close + 1);
j = close + 1;
continue;
}
if (ch === '"') {
let k = j + 1;
for (;;) {
if (k >= end) return null;
if (source[k] === '\\') {
k += 2;
continue;
}
if (source[k] === '"') break;
k++;
}
raw += source.slice(j, k + 1);
j = k + 1;
continue;
}
raw += ch;
j++;
}
let delimiter = '';
for (let k = 0; k < raw.length; k++) {
const ch = raw[k]!;
if (ch === '\\' && k + 1 < raw.length) {
delimiter += raw[k + 1];
k++;
} else if (ch !== '"' && ch !== "'") {
delimiter += ch;
}
}
if (delimiter.length === 0) return null;
return { delimiter, stripTabs, end: j };
}

/** Skip the body lines of each queued heredoc, starting at `i` (just past
* the newline that ended the command line). Bodies are consumed in queue
* order, each up to its delimiter line (`<<-` allows leading tabs before
* the marker), mirroring readHeredocBody; a delimiter directly followed
* by `)` also closes the body — the paren belongs to the enclosing
* substitution and is left for the paren scan. A body whose delimiter
* never appears swallows the rest of the range. */
function skipHeredocBodies(
source: string,
budget: ParseBudget,
i: number,
end: number,
specs: readonly { delimiter: string; stripTabs: boolean }[],
): number {
let j = i;
for (const spec of specs) {
let lineStart = j;
let closed = false;
while (lineStart < end) {
budget.progress();
let marker = lineStart;
if (spec.stripTabs) {
while (marker < end && source[marker] === '\t') marker++;
}
if (source.startsWith(spec.delimiter, marker)) {
const after = marker + spec.delimiter.length;
if (after >= end) {
j = end;
closed = true;
break;
}
if (source[after] === '\n') {
j = after + 1;
closed = true;
break;
}
if (source[after] === ')') {
j = after;
closed = true;
break;
}
}
const newline = source.indexOf('\n', lineStart);
if (newline === -1 || newline >= end) break;
lineStart = newline + 1;
}
if (!closed) return end;
}
return j;
}

/** Skip a $-construct starting at `i` (which points at the `$`). Handles
* $(...), $((...)), ${...}, $'...' (escape-aware: \' does not close),
* $name and the single-character specials. A `$` followed by anything else
Expand Down
45 changes: 45 additions & 0 deletions packages/tree-sitter-bash/test/fixtures/differential/heredoc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,48 @@ program [0,18] "<<EOF cat\nbody\nEOF"
heredoc_content [10,15] "body\n"
heredoc_end [15,18] "EOF"
===
@match: gh --body "$(cat <<'EOF'
gh --body "$(cat <<'EOF'
it's
EOF
)"
===
@match: echo $(cat <<'EOF'
echo $(cat <<'EOF'
it's
EOF
)
===
@match: echo $(cat <<'EOF'
echo $(cat <<'EOF'
) paren
EOF
)
===
@match: cat <(cat <<'EOF'
cat <(cat <<'EOF'
it's
EOF
)
===
@match: echo "$(cat <<'EOF'
echo "$(cat <<'EOF'
$(broken
EOF
)"
===
@match: echo "$(cat <<-'EOF'
echo "$(cat <<-'EOF'
it's
EOF
)"
===
@match: echo $(echo $((x << 2)))
echo $(echo $((x << 2)))
echo $(echo $[x << 2]
)
echo $(printf x # <<EOF
)
echo $( [[ x == @(<<EOF) ]]
)
===
Loading
Loading