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
99 changes: 99 additions & 0 deletions packages/cli/src/__tests__/hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Command } from "commander";
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";

Check warning on line 3 in packages/cli/src/__tests__/hook.test.ts

View workflow job for this annotation

GitHub Actions / Lint

'unlinkSync' is defined but never used. Allowed unused vars must match /^_/u
import { tmpdir, homedir } from "node:os";
import { join, resolve } from "node:path";
import { registerHookCommand } from "../commands/hook.js";
Expand Down Expand Up @@ -172,6 +172,8 @@
expect(action.toolCalls[0]!.name).toBe("Edit");
expect(action.fileEdits).toHaveLength(1);
expect(action.fileEdits[0]!.path).toBe("/src/index.ts");
// diff carries the written content so post-hoc SecretDetection can scan it
expect(action.fileEdits[0]!.diff).toBe("bar");
expect(action.commands).toHaveLength(0);
});

Expand All @@ -186,6 +188,44 @@

expect(action.fileEdits).toHaveLength(1);
expect(action.fileEdits[0]!.path).toBe("/src/new.ts");
expect(action.fileEdits[0]!.diff).toBe("hello");
});

it("truncates oversized written content in the diff field", () => {
const input: HookInput = {
session_id: "test",
tool_name: "Write",
tool_input: { file_path: "/src/big.ts", content: "x".repeat(20000) },
};

const action = _mapToolToAction(input);
expect(action.fileEdits[0]!.diff).toHaveLength(10000);
});

it("falls back to empty diff when written content is missing", () => {
const input: HookInput = {
session_id: "test",
tool_name: "Edit",
tool_input: { file_path: "/src/index.ts" },
};

const action = _mapToolToAction(input);
expect(action.fileEdits).toHaveLength(1);
expect(action.fileEdits[0]!.diff).toBe("");
});

it("maps NotebookEdit tool to a file edit via notebook_path", () => {
const input: HookInput = {
session_id: "test",
tool_name: "NotebookEdit",
tool_input: { notebook_path: "/nb/analysis.ipynb", new_source: "print('hi')" },
};

const action = _mapToolToAction(input);
expect(action.fileEdits).toHaveLength(1);
expect(action.fileEdits[0]!.path).toBe("/nb/analysis.ipynb");
expect(action.fileEdits[0]!.diff).toBe("print('hi')");
expect(action.commands).toHaveLength(0);
});

it("maps Read tool to action with no edits", () => {
Expand Down Expand Up @@ -328,6 +368,40 @@
expect(violations[0]!.message).toContain("~/.ssh/");
});

it("detects blocked file paths reached via ../ traversal", () => {
const input: HookInput = {
session_id: "test",
tool_name: "Write",
tool_input: { file_path: "/tmp/../etc/passwd" },
};

const violations = _checkPreToolPolicies(input, [pathPolicy]);
expect(violations).toHaveLength(1);
});

it("detects blocked file paths with case variants", () => {
const input: HookInput = {
session_id: "test",
tool_name: "Edit",
tool_input: { file_path: "/ETC/passwd" },
};

const violations = _checkPreToolPolicies(input, [pathPolicy]);
expect(violations).toHaveLength(1);
});

it("detects blocked file paths on NotebookEdit via notebook_path", () => {
const input: HookInput = {
session_id: "test",
tool_name: "NotebookEdit",
tool_input: { notebook_path: "/etc/evil.ipynb", new_source: "x" },
};

const violations = _checkPreToolPolicies(input, [pathPolicy]);
expect(violations).toHaveLength(1);
expect(violations[0]!.message).toContain("/etc/");
});

it("returns no violations for safe commands", () => {
const input: HookInput = {
session_id: "test",
Expand Down Expand Up @@ -539,6 +613,31 @@
expect(violations).toHaveLength(0);
});

it("blocks Bash command carrying a secret", () => {
const input: HookInput = {
session_id: "test",
tool_name: "Bash",
tool_input: { command: "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE" },
};

const violations = _checkPreToolPolicies(input, [secretPolicy]);
expect(violations).toHaveLength(1);
expect(violations[0]!.message).toContain("Secret pattern");
// The block message must never echo the secret it caught.
expect(violations[0]!.message).not.toContain("AKIAIOSFODNN7EXAMPLE");
});

it("allows Bash command with no secrets", () => {
const input: HookInput = {
session_id: "test",
tool_name: "Bash",
tool_input: { command: "npm run build" },
};

const violations = _checkPreToolPolicies(input, [secretPolicy]);
expect(violations).toHaveLength(0);
});

it("allows Read tool (not scanned by SecretDetection)", () => {
const input: HookInput = {
session_id: "test",
Expand Down Expand Up @@ -1022,7 +1121,7 @@
let validToolEntries = 0;
for (const line of parsed) {
try {
const entry = JSON.parse(line) as Record<string, unknown>;

Check warning on line 1124 in packages/cli/src/__tests__/hook.test.ts

View workflow job for this annotation

GitHub Actions / Lint

A `require()` style import is forbidden
if (entry.type === "tool_use" || entry.tool_name) {
validToolEntries++;
}
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,28 @@ function mapToolToAction(input: HookInput): Action {
});
}

// Populate `diff` with the content the tool wrote — post-hoc SecretDetection
// regex-tests edit.diff, so an empty string here would make that check
// structurally unable to match on hook-produced runs. Truncated to bound
// the row size (a secret past the cap is still caught by the pre-tool
// guard, which scans the full input).
const MAX_DIFF_CHARS = 10000;

if ((toolName === "Edit" || toolName === "Write") && typeof toolInput["file_path"] === "string") {
const written =
toolName === "Write" ? toolInput["content"] : toolInput["new_string"];
fileEdits.push({
path: toolInput["file_path"] as string,
diff: "",
diff: typeof written === "string" ? written.slice(0, MAX_DIFF_CHARS) : "",
timestamp,
});
}

if (toolName === "NotebookEdit" && typeof toolInput["notebook_path"] === "string") {
const written = toolInput["new_source"];
fileEdits.push({
path: toolInput["notebook_path"] as string,
diff: typeof written === "string" ? written.slice(0, MAX_DIFF_CHARS) : "",
timestamp,
});
}
Expand Down
Loading
Loading