Skip to content
Open
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
77 changes: 77 additions & 0 deletions src/agent/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,81 @@ describe("createDangerousCommandBlocker", () => {

expect(result).toEqual({ continue: true });
});

test("allows heredoc body that names a forbidden phrase as data", async () => {
const hook = createDangerousCommandBlocker();
const callback = hook.hooks[0];

const result = await callback(
makeHookInput({
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: {
command: "cat > note.md <<EOF\nReminder: git push --force is forbidden\nEOF",
},
}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toEqual({ continue: true });
});

test("allows heredoc with quoted delimiter and dangerous-looking body", async () => {
const hook = createDangerousCommandBlocker();
const callback = hook.hooks[0];

const result = await callback(
makeHookInput({
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: {
command:
"node <<'NODEEOF'\n// repro: docker compose down should not be triggered\nconsole.log('ok');\nNODEEOF",
},
}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toEqual({ continue: true });
});

test("blocks real dangerous command outside the heredoc", async () => {
const hook = createDangerousCommandBlocker();
const callback = hook.hooks[0];

const result = await callback(
makeHookInput({
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: {
command: "cat > note.md <<EOF\nharmless body\nEOF\ngit push --force origin main",
},
}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toHaveProperty("decision", "block");
});

test("blocks dash-stripped heredoc opener with real dangerous trailing command", async () => {
const hook = createDangerousCommandBlocker();
const callback = hook.hooks[0];

const result = await callback(
makeHookInput({
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: {
command: "cat <<-EOF\n\tharmless body\n\tEOF\nrm -rf /",
},
}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toHaveProperty("decision", "block");
});
});
16 changes: 15 additions & 1 deletion src/agent/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ const DANGEROUS_COMMANDS: { pattern: RegExp; label: string }[] = [
{ pattern: /kill\s+-9\s+1(\s|$)/, label: "kill init" },
];

// Heredoc bodies are data, not commands. Stripping them before the
// dangerous-command scan prevents prose, repros, and journal entries
// that quote a forbidden phrase from tripping the blocker.
// Matches `<<DELIM`, `<<-DELIM`, `<<"DELIM"`, `<<'DELIM'`; replaces the
// payload (and the closing delimiter line) with an empty string and
// keeps the surrounding command text so a real destructive command
// outside the heredoc still matches. The optional `[ \t]*` before the
// closing delimiter handles `<<-` heredocs where Bash strips leading
// tabs from the terminator at parse time.
function stripHeredocBodies(command: string): string {
return command.replace(/<<-?\s*['"]?(\w+)['"]?\n[\s\S]*?\n[ \t]*\1\s*$/gm, "");
}

export function createFileTracker(): {
hook: HookCallbackMatcher;
getTrackedFiles: () => string[];
Expand Down Expand Up @@ -59,8 +72,9 @@ export function createDangerousCommandBlocker(): HookCallbackMatcher {
if (input.hook_event_name !== "PreToolUse") return { continue: true };
const command = (input.tool_input as Record<string, unknown>)?.command;
if (typeof command === "string") {
const scannable = stripHeredocBodies(command);
for (const { pattern, label } of DANGEROUS_COMMANDS) {
if (pattern.test(command)) {
if (pattern.test(scannable)) {
return {
decision: "block",
reason: `Blocked dangerous command: "${label}"`,
Expand Down