diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 21da80e6..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,316 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook SessionStart", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook SessionEnd", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook UserPromptSubmit", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PreToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PreToolUse", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PermissionRequest": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PermissionRequest", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PermissionDenied": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PermissionDenied", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PostToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PostToolUse", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PostToolUseFailure": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PostToolUseFailure", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "Notification": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook Notification", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "SubagentStart": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook SubagentStart", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "SubagentStop": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook SubagentStop", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "TaskCreated": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook TaskCreated", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "TaskCompleted": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook TaskCompleted", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook Stop", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "StopFailure": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook StopFailure", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "TeammateIdle": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook TeammateIdle", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "InstructionsLoaded": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook InstructionsLoaded", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "ConfigChange": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook ConfigChange", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "CwdChanged": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook CwdChanged", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "FileChanged": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook FileChanged", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "WorktreeCreate": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook WorktreeCreate", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "WorktreeRemove": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook WorktreeRemove", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PreCompact", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "PostCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook PostCompact", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "Elicitation": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook Elicitation", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ], - "ElicitationResult": [ - { - "hooks": [ - { - "type": "command", - "command": "bun ./bin/failproofai.mjs --hook ElicitationResult", - "timeout": 60000, - "__failproofai_hook__": true - } - ] - } - ] - } -} diff --git a/.failproofai/policies-config.json b/.failproofai/policies-config.json index 5b8f1a86..23bd10cd 100644 --- a/.failproofai/policies-config.json +++ b/.failproofai/policies-config.json @@ -1,39 +1,3 @@ { - "enabledPolicies": [ - "sanitize-jwt", - "sanitize-api-keys", - "sanitize-connection-strings", - "sanitize-private-key-content", - "sanitize-bearer-tokens", - "protect-env-vars", - "block-env-files", - "block-read-outside-cwd", - "block-sudo", - "block-curl-pipe-sh", - "block-rm-rf", - "block-failproofai-commands", - "block-secrets-write", - "block-push-master", - "block-force-push", - "block-work-on-main", - "warn-git-amend", - "warn-git-stash-drop", - "warn-all-files-staged", - "warn-destructive-sql", - "warn-schema-alteration", - "warn-package-publish", - "warn-global-package-install", - "warn-large-file-write", - "warn-background-process", - "warn-repeated-tool-calls", - "require-commit-before-stop", - "require-push-before-stop", - "require-pr-before-stop", - "require-ci-green-before-stop" - ], - "policyParams": { - "block-force-push": { - "hint": "Create a new branch from your current HEAD (e.g. `git checkout -b `) and push that instead." - } - } + "enabledPolicies": [] } diff --git a/.failproofai/policies/workflow-policies.mjs b/.failproofai/policies/workflow-policies.mjs deleted file mode 100644 index 80e874dd..00000000 --- a/.failproofai/policies/workflow-policies.mjs +++ /dev/null @@ -1,63 +0,0 @@ -/** - * workflow-policies.mjs — Convention-based workflow policies for this repo - * - * Automatically loaded from .failproofai/policies/ — no config changes needed. - */ -import { customPolicies, allow, instruct } from "failproofai"; - -// Remind to update CHANGELOG before committing -customPolicies.add({ - name: "changelog-check", - description: "Remind Claude to update CHANGELOG.md before committing", - match: { events: ["PreToolUse"] }, - fn: async (ctx) => { - if (ctx.toolName !== "Bash") return allow(); - const cmd = String(ctx.toolInput?.command ?? ""); - if (/git\s+commit/.test(cmd)) { - return instruct( - "Check whether CHANGELOG.md needs an update for this commit. " + - "Every PR must include an entry under the `## Unreleased` section. " + - "Use the appropriate subsection: Features, Fixes, Docs, or Dependencies.\n" + - "Check the version in package.json and ensure the changelog entry matches the current version." - ); - } - return allow(); - }, -}); - -// Remind to update docs, README, and examples before committing -customPolicies.add({ - name: "docs-check", - description: "Remind Claude to update documentation, README, and examples if relevant", - match: { events: ["PreToolUse"] }, - fn: async (ctx) => { - if (ctx.toolName !== "Bash") return allow(); - const cmd = String(ctx.toolInput?.command ?? ""); - if (/git\s+commit/.test(cmd)) { - return instruct( - "Check whether documentation needs updating for this change. " + - "Consider: docs/*.mdx files, README.md, and examples/ directory. " + - "If you added or changed a feature, make sure the relevant docs reflect it." - ); - } - return allow(); - }, -}); - -// Remind to update PR description if a PR is open -customPolicies.add({ - name: "pr-description-check", - description: "Remind Claude to update the PR description after pushing", - match: { events: ["PreToolUse"] }, - fn: async (ctx) => { - if (ctx.toolName !== "Bash") return allow(); - const cmd = String(ctx.toolInput?.command ?? ""); - if (/git\s+push/.test(cmd)) { - return instruct( - "After pushing, check if there is an open PR for this branch. " + - "If so, update the PR description to reflect the latest changes." - ); - } - return allow(); - }, -}); diff --git a/.gitignore b/.gitignore index 24e4feb5..5c7c5f27 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ next-env.d.ts # cursor .cursor +# codex +.codex + # custom hooks loader temp files *.__failproofai_tmp__.* @@ -67,3 +70,6 @@ packages/*/assets/ # closed-source platform (cloned separately) /platform + +# scratch / planning files +HOOKS_MINDMAP.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a99986f2..9fed383f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,25 @@ - Add cloud platform client: `login`, `logout`, `whoami`, `relay start|stop|status`, and `sync` subcommands. Hook events are appended to a local queue and streamed to the failproofai cloud server via a background relay daemon that lazy-starts from the hook handler and survives reboots (#132) ### Fixes +- Fix cross-CLI dedup collision: integration type is now always the first component of both the firing-lock hash and the dedup key, so Cursor and Claude Code firing the same event concurrently no longer drop each other's entries +- Fix session ID env-var bleed: fallbacks (`CURSOR_SESSION_ID`, `COPILOT_SESSION_ID`, etc.) are now scoped to the matching integration only +- Fix Silence Guard to also fire after payload parse, preventing Gemini/Copilot-unique events from being processed as claude-code events when `--cli` is not passed +- Fix Cursor `normalizePayload` to map `beforeTabFileEdit`/`afterFileEdit` → `tool_name: "Write"` and `beforeTabFileRead`/`beforeReadFile` → `tool_name: "Read"`, so `block-secrets-write` and `warn-large-file-write` now fire for Cursor file operations +- Fix Gemini `normalizePayload` to extract `session_id` from known payload fields and `GEMINI_SESSION_ID` env var so `warn-repeated-tool-calls` tracks the correct session +- Fix OpenCode `removeHooksFromFile` to delete only `failproofai.ts`, not the entire `.opencode/plugins/` parent directory +- Fix TypeScript custom hook files: `.ts` sources are now transpiled via `bun build` before ESM rewriting, so TS policies load without syntax errors +- Fix custom policy `policyParams` silently dropped: raw params are now forwarded to custom policies that have no schema definition +- Add Stop-event compatibility warning when a workflow policy (`require-commit-before-stop`, etc.) is installed for OpenCode or Pi (which have no Stop event) +- Fix `block-curl-pipe-sh` false positive: `curl url.sh > file.sh` (shell redirect) is now correctly allowed; only `curl -o`/`wget -O` with explicit output flags are blocked +- Add E2E tests for four previously untested CLIs: claude-code, codex, opencode, and pi +- Fix Gemini hook blocking to match the official Gemini CLI spec: remove `continue: false` from tool-level deny responses (agent now explains the block instead of dying silently), use exit code 2 for `BeforeToolSelection` (spec: `decision` field unsupported), and separate `reason` (concise, agent-facing) from `systemMessage` (verbose, terminal-facing) - Stop stderr leakage from workflow policies (`require-push-before-stop`, `require-pr-before-stop`, `require-ci-green-before-stop`, etc.): git probes that are expected to sometimes fail no longer leak "fatal: Needed a single revision" or similar messages to the user's terminal (#132) - `block-read-outside-cwd` now uses `CLAUDE_PROJECT_DIR` (the stable project root) instead of the live hook `cwd`, which drifts when Claude `cd`s into a subdirectory. Reads at the project root are no longer wrongly denied after a `cd`. Falls back to `ctx.session.cwd` when that variable is unset (#134) - Shrink the npm package by excluding sharp from the Next.js standalone build (unused — image optimization is disabled) and stripping docs, tests, and sourcemaps from the bundled `node_modules`. Tarball drops from ~20 MB to under a few MB (#136) +### Docs +- Add a CLI scope-support matrix in install docs covering all integrations and scope levels + ## 0.0.6-beta.2 — 2026-04-21 ### Features @@ -157,3 +172,9 @@ Features included in this release: - **Hooks & Policies**: 35+ built-in security policies for Claude Code hooks (PreToolUse, PostToolUse, etc.), custom policy support, activity logging - **Projects**: Browse and search Claude Code projects and sessions - **Session Viewer**: Inspect session logs, tool calls, and per-session hook activity + +## 0.0.2 — 2026-04-14 + +- **Expanded Integrations**: Added support for **Gemini CLI** and **GitHub Copilot** hooks. +- **Modular Hook Handler**: Refactored the hook evaluation engine to support multiple agents with integration-specific payload normalization and output formats. +- **Bug Fixes**: Resolved detection collisions and improved telemetry accuracy across platforms. diff --git a/__tests__/codex/trace-parser.test.ts b/__tests__/codex/trace-parser.test.ts new file mode 100644 index 00000000..95324bd5 --- /dev/null +++ b/__tests__/codex/trace-parser.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { parseCodexLogToTraceRecords } from "../../src/codex/trace-parser"; + +describe("codex/trace-parser", () => { + it("extracts exec_command lines and ignores plugin noise", () => { + const content = [ + "2026-04-15T12:00:00.000Z WARN codex_core::plugins::manifest ignoring interface.defaultPrompt: prompt must be at most 128 characters", + '2026-04-15T12:00:01.100Z INFO codex_core::runtime thread_id=thr_abc ToolCall: exec_command {"command":"ls -la"}', + ].join("\n"); + + const records = parseCodexLogToTraceRecords(content); + expect(records).toEqual([ + { + timestamp: "2026-04-15T12:00:01.100Z", + thread_id: "thr_abc", + tool_call: "exec_command", + command: "ls -la", + }, + ]); + }); + + it("extracts custom_tool_call lines with command assignment format", () => { + const content = + '2026-04-15T12:00:02.300Z INFO codex_engine thread_id=worker-2 ToolCall: custom_tool_call command="python script.py --check"'; + + const records = parseCodexLogToTraceRecords(content); + expect(records).toEqual([ + { + timestamp: "2026-04-15T12:00:02.300Z", + thread_id: "worker-2", + tool_call: "custom_tool_call", + command: "python script.py --check", + }, + ]); + }); + + it("skips malformed ToolCall lines without a command", () => { + const content = + "2026-04-15T12:00:03.300Z INFO codex_engine thread_id=worker-3 ToolCall: exec_command"; + + expect(parseCodexLogToTraceRecords(content)).toEqual([]); + }); +}); diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index 9a224f9e..84d4e680 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -38,6 +38,7 @@ function makeFolders(count: number): ProjectFolder[] { isDirectory: true, lastModified: new Date(Date.now() - i * 86400000), lastModifiedFormatted: `Jun ${15 - i}, 2024`, + sources: ["claude-code"], })); } @@ -59,6 +60,7 @@ describe("ProjectList", () => { isDirectory: true, lastModified: new Date(), lastModifiedFormatted: "Jun 15, 2024", + sources: ["claude-code"], }, ]; render(); diff --git a/__tests__/e2e/helpers/hook-runner.ts b/__tests__/e2e/helpers/hook-runner.ts index 5faf8c4b..a7747838 100644 --- a/__tests__/e2e/helpers/hook-runner.ts +++ b/__tests__/e2e/helpers/hook-runner.ts @@ -52,6 +52,7 @@ export function runHook( ...process.env, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_DIST_PATH: getDistPath(), + FAILPROOFAI_SKIP_KILL: "true", ...(opts?.homeDir ? { HOME: opts.homeDir } : {}), }; @@ -86,21 +87,31 @@ export function assertAllow(result: HookRunResult): void { } export function assertPreToolUseDeny(result: HookRunResult): void { - expect(result.exitCode).toBe(0); - const output = result.parsed?.hookSpecificOutput as Record | undefined; - expect(output?.permissionDecision).toBe("deny"); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + // Support any of our specialized blocking prefixes + const hasPrefix = + result.stderr.includes("[FailproofAI") || + result.stderr.includes("MANDATORY ACTION REQUIRED") || + result.stderr.includes("ACTION BLOCKED BY FAILPROOFAI"); + expect(hasPrefix).toBe(true); } export function assertPostToolUseDeny(result: HookRunResult): void { - expect(result.exitCode).toBe(0); - const output = result.parsed?.hookSpecificOutput as Record | undefined; - expect(output?.additionalContext).toMatch(/Blocked/i); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + // Support any of our specialized blocking prefixes + const hasPrefix = + result.stderr.includes("[FailproofAI") || + result.stderr.includes("MANDATORY ACTION REQUIRED") || + result.stderr.includes("ACTION BLOCKED BY FAILPROOFAI"); + expect(hasPrefix).toBe(true); } export function assertInstruct(result: HookRunResult): void { expect(result.exitCode).toBe(0); - const output = result.parsed?.hookSpecificOutput as Record | undefined; - expect(output?.additionalContext).toMatch(/^Instruction from failproofai:/); + expect(result.stdout).toBe(""); + expect(result.stderr).toBeTruthy(); } export function assertStopInstruct(result: HookRunResult): void { diff --git a/__tests__/e2e/helpers/payloads.ts b/__tests__/e2e/helpers/payloads.ts index 3b08ea00..cc3c0f6b 100644 --- a/__tests__/e2e/helpers/payloads.ts +++ b/__tests__/e2e/helpers/payloads.ts @@ -101,3 +101,191 @@ export const Payloads = { }; }, }; + +export const CursorPayloads = { + preToolUse: { + bash(command: string, cwd: string): Record { + return { + session_id: SESSION_ID, + workspace_roots: [cwd], + integration: "cursor", + hook_event_name: "preToolUse", // Note: cursor uses camelCase in payload too + tool_name: "run_terminal_command", + tool_input: { command }, + }; + }, + + write(filePath: string, content: string, cwd: string): Record { + return { + session_id: SESSION_ID, + workspace_roots: [cwd], + integration: "cursor", + hook_event_name: "afterFileEdit", + tool_name: "edit_file", + tool_input: { file_path: filePath, content }, + }; + }, + }, + + postToolUse: { + bash(command: string, output: string, cwd: string): Record { + return { + session_id: SESSION_ID, + workspace_roots: [cwd], + integration: "cursor", + hook_event_name: "postToolUse", + tool_name: "run_terminal_command", + tool_input: { command }, + tool_result: output, + }; + }, + }, + + stop(cwd: string): Record { + return { + session_id: SESSION_ID, + workspace_roots: [cwd], + integration: "cursor", + hook_event_name: "stop", + }; + }, +}; + +export const GeminiPayloads = { + beforeTool: { + bash(command: string, cwd: string): Record { + return { + session_id: SESSION_ID, + cwd, + hook_event_name: "BeforeTool", + tool_name: "bash", + tool_input: command, + }; + }, + }, + afterAgent(cwd: string): Record { + return { + session_id: SESSION_ID, + cwd, + hook_event_name: "AfterAgent", + }; + }, +}; + +export const CopilotPayloads = { + sessionStart(cwd: string, overrides: Record = {}): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "sessionStart", + ...overrides, + }; + }, + + sessionEnd(cwd: string, overrides: Record = {}): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "sessionEnd", + ...overrides, + }; + }, + + userPromptSubmitted(prompt: string, cwd: string, overrides: Record = {}): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "userPromptSubmitted", + prompt, + ...overrides, + }; + }, + + preToolUse: { + bash(command: string, cwd: string): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "preToolUse", + toolName: "bash", + toolInput: command, + }; + }, + + bashViaToolArgs( + command: string, + cwd: string, + overrides: Record = {}, + ): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "preToolUse", + toolName: "bash", + toolArgs: JSON.stringify({ command }), + ...overrides, + }; + }, + + malformedToolArgs( + raw: string, + cwd: string, + overrides: Record = {}, + ): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "preToolUse", + toolName: "bash", + toolArgs: raw, + ...overrides, + }; + }, + }, + + postToolUse: { + bash( + command: string, + cwd: string, + overrides: Record = {}, + ): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "postToolUse", + toolName: "bash", + toolInput: { command }, + toolResult: "ok", + ...overrides, + }; + }, + }, + + agentStop(cwd: string, overrides: Record = {}): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "agentStop", + ...overrides, + }; + }, + + subagentStop(cwd: string, overrides: Record = {}): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "subagentStop", + ...overrides, + }; + }, + + errorOccurred(message: string, cwd: string, overrides: Record = {}): Record { + return { + sessionId: SESSION_ID, + cwd, + hookEventName: "errorOccurred", + message, + ...overrides, + }; + }, +}; diff --git a/__tests__/e2e/hooks/claude-code-integration.e2e.test.ts b/__tests__/e2e/hooks/claude-code-integration.e2e.test.ts new file mode 100644 index 00000000..65dfdb17 --- /dev/null +++ b/__tests__/e2e/hooks/claude-code-integration.e2e.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { writeFileSync, existsSync, mkdtempSync, mkdirSync, rmSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { Payloads } from "../helpers/payloads"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const DEDUP_DIR = resolve(homedir(), ".failproofai", "cache", "dedup"); + +describe("E2E: Claude Code Integration", () => { + let projectDir: string; + let isoHome: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "fp-e2e-claude-")); + isoHome = mkdtempSync(join(tmpdir(), "fp-e2e-claude-home-")); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + mkdirSync(resolve(projectDir, ".claude"), { recursive: true }); + writeFileSync(resolve(projectDir, ".claude", "settings.json"), JSON.stringify({ hooks: {} })); + }); + + afterEach(() => { + if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true }); + if (existsSync(isoHome)) rmSync(isoHome, { recursive: true, force: true }); + }); + + const baseEnv = () => ({ ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }); + + it("denies sudo via PreToolUse hook (exit 2 + stderr message)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli claude-code --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = Payloads.preToolUse.bash("sudo rm -rf /", projectDir); + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse", "--cli", "claude-code"], { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }); + + expect(status).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("[FailproofAI]"); + expect(stderr).toContain("block-sudo"); + expect(stderr).toContain("sudo"); + }); + + it("allows benign commands with exit 0 and empty output", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli claude-code --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = Payloads.preToolUse.bash("ls -la", projectDir); + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse", "--cli", "claude-code"], { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }); + + expect(status).toBe(0); + expect(stdout.trim()).toBe(""); + expect(stderr.trim()).toBe(""); + }); + + it("denies PostToolUse with sanitize-jwt policy when output contains a JWT", () => { + execSync(`bun ${BINARY_PATH} policies --install sanitize-jwt --cli claude-code --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const jwtOutput = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const payload = Payloads.postToolUse.bash("cat /tmp/token", jwtOutput, projectDir); + + const { status, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "PostToolUse", "--cli", "claude-code"], { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }); + + expect(status).toBe(2); + expect(stderr).toContain("sanitize-jwt"); + }); + + it("writes hooks to .claude/settings.json for project scope", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli claude-code --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const settingsPath = resolve(projectDir, ".claude", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + expect(settings.hooks).toBeDefined(); + const preToolUseHooks = settings.hooks.PreToolUse ?? []; + expect(preToolUseHooks.length).toBeGreaterThan(0); + expect(preToolUseHooks.some((h: any) => h.hooks?.some((e: any) => e.command?.includes("--hook PreToolUse")))).toBe(true); + }); +}); diff --git a/__tests__/e2e/hooks/codex-integration.e2e.test.ts b/__tests__/e2e/hooks/codex-integration.e2e.test.ts new file mode 100644 index 00000000..419abdbf --- /dev/null +++ b/__tests__/e2e/hooks/codex-integration.e2e.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { writeFileSync, readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const DEDUP_DIR = resolve(homedir(), ".failproofai", "cache", "dedup"); + +describe("E2E: OpenAI Codex Integration", () => { + let projectDir: string; + let isoHome: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "fp-e2e-codex-")); + isoHome = mkdtempSync(join(tmpdir(), "fp-e2e-codex-home-")); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + mkdirSync(resolve(projectDir, ".codex"), { recursive: true }); + writeFileSync(resolve(projectDir, ".codex", "hooks.json"), JSON.stringify({ version: 1, hooks: {} })); + }); + + afterEach(() => { + if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true }); + if (existsSync(isoHome)) rmSync(isoHome, { recursive: true, force: true }); + }); + + const baseEnv = () => ({ ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }); + + it("denies sudo via pre_tool_use (snake_case) event with exit 2", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli codex --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + // Codex sends snake_case hook_event_name and plain string tool_input + const payload = { + session_id: "test-session-codex-001", + cwd: projectDir, + hook_event_name: "pre_tool_use", + tool_name: "bash", + tool_input: "sudo rm -rf /", + integration: "codex", + }; + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "pre_tool_use", "--cli", "codex"], { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }); + + expect(status).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("block-sudo"); + expect(stderr).toContain("sudo"); + }); + + it("allows benign commands with exit 0 and empty output", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli codex --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "test-session-codex-002", + cwd: projectDir, + hook_event_name: "pre_tool_use", + tool_name: "bash", + tool_input: "ls -la", + integration: "codex", + }; + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "pre_tool_use", "--cli", "codex"], { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }); + + expect(status).toBe(0); + expect(stdout.trim()).toBe(""); + expect(stderr.trim()).toBe(""); + }); + + it("writes hooks to .codex/hooks.json for project scope", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli codex --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const codexHooksPath = resolve(projectDir, ".codex", "hooks.json"); + const hooks = JSON.parse(readFileSync(codexHooksPath, "utf8")); + expect(hooks.version).toBe(1); + expect(hooks.hooks).toBeDefined(); + // Codex stores hooks under PascalCase keys (CODEX_EVENT_MAP: pre_tool_use → PreToolUse) + expect(hooks.hooks.PreToolUse).toBeDefined(); + expect(hooks.hooks.PreToolUse.length).toBeGreaterThan(0); + // Codex uses ClaudeHookMatcher format: [{hooks: [{command: "..."}]}] + expect(hooks.hooks.PreToolUse.some((h: any) => h.hooks?.[0]?.command?.includes("--hook PreToolUse --cli codex"))).toBe(true); + }); + + it("deny message uses default [FailproofAI Security Stop] format (not claude/gemini/cursor format)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli codex --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "test-session-codex-003", + cwd: projectDir, + hook_event_name: "pre_tool_use", + tool_name: "bash", + tool_input: "sudo whoami", + integration: "codex", + }; + + const { stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "pre_tool_use", "--cli", "codex"], { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }); + + // Codex uses the default deny format (not the claude-code, gemini, or cursor styles) + expect(stderr).toContain("[FailproofAI Security Stop]"); + expect(stderr).not.toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(stderr).not.toContain("MANDATORY ACTION REQUIRED"); + expect(stderr).not.toContain("[FailproofAI] block-sudo:"); + }); +}); diff --git a/__tests__/e2e/hooks/copilot-integration.e2e.test.ts b/__tests__/e2e/hooks/copilot-integration.e2e.test.ts new file mode 100644 index 00000000..a47eeafd --- /dev/null +++ b/__tests__/e2e/hooks/copilot-integration.e2e.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { CopilotPayloads } from "../helpers/payloads"; +import { + _resetForTest, + getAllHookActivityEntries, + searchHookActivity, +} from "../../../src/hooks/hook-activity-store"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const PROJECT_DIR = resolve(__dirname, "../../fixtures/copilot-project"); +const HOME_DIR = resolve(PROJECT_DIR, ".test-home"); +const COPILOT_HOME = resolve(HOME_DIR, ".copilot"); +const COPILOT_CONFIG_PATH = resolve(COPILOT_HOME, "config.json"); +const COPILOT_SESSION_STATE_DIR = resolve(COPILOT_HOME, "session-state"); +const COPILOT_PROJECT_HOOKS_PATH = resolve(PROJECT_DIR, ".github", "hooks", "failproofai.json"); +const BASHRC_PATH = resolve(HOME_DIR, ".bashrc"); +const ACTIVITY_DIR = resolve(HOME_DIR, ".failproofai", "cache", "hook-activity"); +const DEDUP_DIR = resolve(HOME_DIR, ".failproofai", "cache", "dedup"); +const COPILOT_SESSION_ID = "11111111-2222-3333-4444-555555555555"; + +function cliEnv(extraEnv: Partial = {}): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: HOME_DIR, + COPILOT_HOME, + FAILPROOFAI_DIST_PATH: process.cwd(), + FAILPROOFAI_TELEMETRY_DISABLED: "1", + FAILPROOFAI_SKIP_KILL: "true", + ...extraEnv, + }; +} + +function resetActivityStore(): void { + _resetForTest(ACTIVITY_DIR); +} + +function readCopilotConfig(): Record { + return JSON.parse(readFileSync(COPILOT_CONFIG_PATH, "utf8")); +} + +function readActivityEntries(sessionId?: string) { + resetActivityStore(); + if (sessionId) { + return searchHookActivity({ sessionId }, 1).entries; + } + return getAllHookActivityEntries(); +} + +function runCopilotHook( + event: string, + payload: Record | string, + extraEnv: Partial = {}, + integration = "copilot", +) { + return spawnSync("bun", [BINARY_PATH, "--hook", event, "--cli", integration], { + input: typeof payload === "string" ? payload : JSON.stringify(payload), + cwd: PROJECT_DIR, + env: cliEnv(extraEnv), + encoding: "utf8", + }); +} + +describe("E2E: Copilot Integration", () => { + beforeEach(() => { + if (existsSync(PROJECT_DIR)) rmSync(PROJECT_DIR, { recursive: true, force: true }); + mkdirSync(PROJECT_DIR, { recursive: true }); + mkdirSync(resolve(PROJECT_DIR, ".github", "hooks"), { recursive: true }); + mkdirSync(COPILOT_HOME, { recursive: true }); + mkdirSync(COPILOT_SESSION_STATE_DIR, { recursive: true }); + writeFileSync(BASHRC_PATH, "# shell rc\n", "utf8"); + writeFileSync(COPILOT_CONFIG_PATH, JSON.stringify({ version: 1, hooks: {} }, null, 2) + "\n", "utf8"); + if (existsSync(ACTIVITY_DIR)) rmSync(ACTIVITY_DIR, { recursive: true, force: true }); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + resetActivityStore(); + }); + + afterEach(() => { + if (existsSync(PROJECT_DIR)) rmSync(PROJECT_DIR, { recursive: true, force: true }); + }); + + it("installs project hooks with Copilot native camelCase event names", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli copilot --scope project`, { + cwd: PROJECT_DIR, + env: cliEnv(), + }); + + const hooks = JSON.parse(readFileSync(COPILOT_PROJECT_HOOKS_PATH, "utf8")); + + expect(hooks.version).toBe(1); + expect(hooks.hooks.sessionStart[0].bash).toContain("--hook sessionStart --cli copilot"); + expect(hooks.hooks.preToolUse[0].bash).toContain("--hook preToolUse --cli copilot"); + expect(hooks.hooks.userPromptSubmitted[0].bash).toContain("--hook userPromptSubmitted --cli copilot"); + expect(hooks.hooks.SessionStart).toBeUndefined(); + expect(hooks.hooks.PreToolUse).toBeUndefined(); + }); + + it("installs user hooks without wiping existing config and appends copilot-sync bootstrap", () => { + writeFileSync( + COPILOT_CONFIG_PATH, + JSON.stringify({ + version: 1, + copilotTokens: ["keep-me"], + loggedInUsers: [{ login: "octocat" }], + hooks: { + customEvent: [{ bash: "echo untouched" }], + }, + }, null, 2) + "\n", + "utf8", + ); + + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli copilot --scope user`, { + cwd: PROJECT_DIR, + env: cliEnv(), + }); + + const config = readCopilotConfig(); + const bashrc = readFileSync(BASHRC_PATH, "utf8"); + + expect(config.copilotTokens).toEqual(["keep-me"]); + expect(config.loggedInUsers).toEqual([{ login: "octocat" }]); + expect(config.hooks.customEvent).toEqual([{ bash: "echo untouched" }]); + expect(config.hooks.sessionStart[0].bash).toContain("--hook sessionStart --cli copilot"); + expect(config.hooks.preToolUse[0].bash).toContain("--hook preToolUse --cli copilot"); + expect(bashrc).toContain("env failproofai copilot-sync 2>/dev/null"); + }); + + it("uninstalls only failproofai hooks and preserves unrelated Copilot config", () => { + writeFileSync( + COPILOT_CONFIG_PATH, + JSON.stringify({ + version: 1, + copilotTokens: ["keep-me"], + hooks: { + preToolUse: [{ bash: "echo untouched" }], + }, + }, null, 2) + "\n", + "utf8", + ); + + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli copilot --scope user`, { + cwd: PROJECT_DIR, + env: cliEnv(), + }); + execSync(`bun ${BINARY_PATH} policies --uninstall --cli copilot --scope user`, { + cwd: PROJECT_DIR, + env: cliEnv(), + }); + + const config = readCopilotConfig(); + + expect(config.copilotTokens).toEqual(["keep-me"]); + expect(config.hooks.preToolUse).toEqual([{ bash: "echo untouched" }]); + expect(config.hooks.sessionStart).toBeUndefined(); + expect(config.hooks.userPromptSubmitted).toBeUndefined(); + }); + + it("denies sudo from stringified toolArgs and persists a complete Copilot activity entry", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli copilot --scope project`, { + cwd: PROJECT_DIR, + env: cliEnv(), + }); + + const payload = CopilotPayloads.preToolUse.bashViaToolArgs( + "sudo rm -rf /", + PROJECT_DIR, + { sessionId: COPILOT_SESSION_ID }, + ); + + const { status, stdout, stderr } = runCopilotHook("preToolUse", payload); + const entries = readActivityEntries(COPILOT_SESSION_ID); + + expect(status).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.permissionDecision).toBe("deny"); + expect(stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "PreToolUse", + integration: "copilot", + sessionId: COPILOT_SESSION_ID, + toolName: "bash", + transcriptPath: join(HOME_DIR, ".copilot", "session-state", COPILOT_SESSION_ID, "events.jsonl"), + }), + ]), + ); + }); + + it("persists sessionStart and userPromptSubmitted for the policies page with the same session id", () => { + const sessionStart = CopilotPayloads.sessionStart(PROJECT_DIR, { sessionId: COPILOT_SESSION_ID }); + const prompt = CopilotPayloads.userPromptSubmitted("review the diff", PROJECT_DIR, { + sessionId: COPILOT_SESSION_ID, + }); + + const startResult = runCopilotHook("sessionStart", sessionStart); + const promptResult = runCopilotHook("userPromptSubmitted", prompt); + const entries = readActivityEntries(COPILOT_SESSION_ID); + + expect(startResult.status).toBe(0); + expect(promptResult.status).toBe(0); + expect(entries.map((entry) => entry.eventType)).toEqual( + expect.arrayContaining(["SessionStart", "UserPromptSubmit"]), + ); + expect(entries.every((entry) => entry.integration === "copilot")).toBe(true); + expect(entries.every((entry) => entry.sessionId === COPILOT_SESSION_ID)).toBe(true); + }); + + it("recovers the session id from COPILOT_SESSION_ID when the payload is empty", () => { + const result = runCopilotHook("sessionStart", "", { + COPILOT_SESSION_ID: COPILOT_SESSION_ID, + }); + const entries = readActivityEntries(COPILOT_SESSION_ID); + + expect(result.status).toBe(0); + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "SessionStart", + integration: "copilot", + sessionId: COPILOT_SESSION_ID, + transcriptPath: join(HOME_DIR, ".copilot", "session-state", COPILOT_SESSION_ID, "events.jsonl"), + }), + ]), + ); + }); + + it("silently ignores corrupted legacy claude-code Copilot lifecycle duplicates", () => { + const payload = CopilotPayloads.sessionStart(PROJECT_DIR, { sessionId: COPILOT_SESSION_ID }); + + const result = runCopilotHook("sessionStart", payload, {}, "claude-code"); + const entries = readActivityEntries(COPILOT_SESSION_ID); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr.trim()).toBe(""); + expect(entries).toEqual([]); + }); +}); diff --git a/__tests__/e2e/hooks/cursor-integration.e2e.test.ts b/__tests__/e2e/hooks/cursor-integration.e2e.test.ts new file mode 100644 index 00000000..472bed89 --- /dev/null +++ b/__tests__/e2e/hooks/cursor-integration.e2e.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import { CursorPayloads } from "../helpers/payloads"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const PROJECT_DIR = resolve(__dirname, "../../fixtures/cursor-project"); +const CURSOR_HOOKS_PATH = resolve(PROJECT_DIR, ".cursor", "hooks.json"); +const CONFIG_PATH = resolve(PROJECT_DIR, ".failproofai", "policies-config.json"); +// Firing-lock files can persist across test cases. Clear them. +const DEDUP_DIR = resolve(require("node:os").homedir(), ".failproofai", "cache", "dedup"); + +describe("E2E: Cursor Integration", () => { + beforeEach(() => { + if (existsSync(PROJECT_DIR)) rmSync(PROJECT_DIR, { recursive: true, force: true }); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + mkdirSync(PROJECT_DIR, { recursive: true }); + // Initialize empty cursor hooks + mkdirSync(resolve(PROJECT_DIR, ".cursor"), { recursive: true }); + writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify({ version: 1, hooks: {} })); + }); + + afterEach(() => { + if (existsSync(PROJECT_DIR)) rmSync(PROJECT_DIR, { recursive: true, force: true }); + }); + + it("denies sudo command via Cursor preToolUse hook", () => { + // 1. Install block-sudo for Cursor project scope + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + // 2. Verify hooks.json was written correctly + const hooks = JSON.parse(readFileSync(CURSOR_HOOKS_PATH, "utf8")); + expect(hooks.version).toBe(1); + expect(hooks.hooks.beforeShellExecution[0].command).toContain("--hook PreToolUse"); + + // 3. Trigger the hook with a sudo payload + const payload = CursorPayloads.preToolUse.bash("sudo rm -rf /", PROJECT_DIR); + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + + // Cursor expects Exit 0 for a protocol-compliant JSON denial. + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.continue).toBe(false); + expect(parsed.permission).toBe("deny"); + expect(stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(stderr).toContain("sudo"); + }); + + it("normalizes workspace_roots to cwd", () => { + // 1. Install block-sudo + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + // 2. Trigger hook with ONLY workspace_roots (no cwd) + const payload = CursorPayloads.preToolUse.bash("sudo ls", PROJECT_DIR); + delete payload.cwd; // Force normalization from workspace_roots[0] + + const output = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + expect(output.status).toBe(0); + const parsedDeny = JSON.parse(output.stdout.trim()); + expect(parsedDeny.continue).toBe(false); + expect(output.stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + }); + + it("allows benign commands", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + const payload = CursorPayloads.preToolUse.bash("ls -la", PROJECT_DIR); + + const { status, stdout } = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + + expect(status).toBe(0); + expect(JSON.parse(stdout.trim())).toEqual({ continue: true, permission: "allow" }); + }); + + it("blocks sudo via beforeShellExecution event (tool_name normalization)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + // beforeShellExecution events don't include tool_name — normalizePayload must map to run_terminal_command + const payload = { + session_id: "test-session", + workspace_roots: [PROJECT_DIR], + integration: "cursor", + hook_event_name: "beforeShellExecution", + command: "sudo rm -rf /tmp/test", + }; + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.continue).toBe(false); + expect(parsed.permission).toBe("deny"); + expect(stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(stderr).toContain("sudo"); + }); + + it("blocks env file read via beforeReadFile event (file_path normalization)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-env-files --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + // beforeReadFile events send file_path at the top level — normalizePayload must wrap it + const payload = { + session_id: "test-session", + workspace_roots: [PROJECT_DIR], + integration: "cursor", + hook_event_name: "beforeReadFile", + file_path: `${PROJECT_DIR}/.env`, + }; + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "PreToolUse"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.continue).toBe(false); + expect(parsed.permission).toBe("deny"); + expect(stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + }); + + it("uninstalls cursor hooks correctly", () => { + // Install + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + expect(JSON.parse(readFileSync(CURSOR_HOOKS_PATH, "utf8")).hooks.beforeShellExecution).toBeDefined(); + + // Uninstall + execSync(`bun ${BINARY_PATH} policies --uninstall --cli cursor --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + const hooks = JSON.parse(readFileSync(CURSOR_HOOKS_PATH, "utf8")); + expect(hooks.hooks).toBeUndefined(); + }); +}); diff --git a/__tests__/e2e/hooks/custom-hooks.e2e.test.ts b/__tests__/e2e/hooks/custom-hooks.e2e.test.ts index a7601d0e..38e15eb4 100644 --- a/__tests__/e2e/hooks/custom-hooks.e2e.test.ts +++ b/__tests__/e2e/hooks/custom-hooks.e2e.test.ts @@ -46,8 +46,8 @@ describe("custom-hooks core mechanics", () => { env.writeConfig({ enabledPolicies: [], customPoliciesPath: hookPath }); const result = runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }); assertInstruct(result); - const output = result.parsed?.hookSpecificOutput as Record | undefined; - expect(output?.additionalContext).toContain("do this first"); + // Instruction is in stderr + expect(result.stderr).toContain("do this first"); }); it("custom hook that calls allow() → allow with empty stdout", () => { @@ -114,7 +114,7 @@ describe("custom-hooks core mechanics", () => { env.writeConfig({ enabledPolicies: [], customPoliciesPath: hookPath }); const result = runHook("Stop", Payloads.stop(env.cwd), { homeDir: env.home }); assertStopInstruct(result); - expect(result.stderr).toContain("wrap up before stopping"); + expect(result.stderr).toContain("Wrap up before stopping"); }); it("builtin fires before custom: builtin deny short-circuits, custom never runs", () => { @@ -130,10 +130,9 @@ describe("custom-hooks core mechanics", () => { `); env.writeConfig({ enabledPolicies: ["block-sudo"], customPoliciesPath: hookPath }); const result = runHook("PreToolUse", Payloads.preToolUse.bash("sudo rm /", env.cwd), { homeDir: env.home }); - // Builtin deny — permissionDecisionReason should mention block-sudo, not custom + // Builtin deny — stderr should mention block-sudo, not custom assertPreToolUseDeny(result); - const output = result.parsed?.hookSpecificOutput as Record | undefined; - expect(output?.permissionDecisionReason).toMatch(/sudo/i); + expect(result.stderr).toMatch(/sudo/i); }); it("custom fires after builtin allow: builtin allows ls, custom denies", () => { diff --git a/__tests__/e2e/hooks/gemini-integration.e2e.test.ts b/__tests__/e2e/hooks/gemini-integration.e2e.test.ts new file mode 100644 index 00000000..f57043d0 --- /dev/null +++ b/__tests__/e2e/hooks/gemini-integration.e2e.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import { GeminiPayloads } from "../helpers/payloads"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const PROJECT_DIR = resolve(__dirname, "../../fixtures/gemini-project"); +const GEMINI_SETTINGS_PATH = resolve(PROJECT_DIR, ".gemini", "settings.json"); +// Cursor and Copilot e2e tests share the same SESSION_ID + sudo fingerprint as +// Gemini, so their firing-lock file (5s bucket) can still be on disk when +// Gemini runs and block this test with "instant-catch twin". Clear it. +const DEDUP_DIR = resolve(homedir(), ".failproofai", "cache", "dedup"); + +describe("E2E: Gemini Integration", () => { + beforeEach(() => { + if (existsSync(PROJECT_DIR)) rmSync(PROJECT_DIR, { recursive: true, force: true }); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + mkdirSync(PROJECT_DIR, { recursive: true }); + mkdirSync(resolve(PROJECT_DIR, ".gemini"), { recursive: true }); + writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify({ hooks: {} })); + }); + + afterEach(() => { + if (existsSync(PROJECT_DIR)) rmSync(PROJECT_DIR, { recursive: true, force: true }); + }); + + it("denies sudo via Gemini BeforeTool hook with deny decision", () => { + // 1. Install block-sudo + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli gemini --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + // 2. Trigger the hook + const payload = GeminiPayloads.beforeTool.bash("sudo rm -rf /", PROJECT_DIR); + + // We pass --cli gemini to ensure it doesn't fallback to claude-code + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "BeforeTool", "--cli", "gemini"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_LOG_LEVEL: "info", FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + console.log("Gemini STDOUT:", stdout); + console.log("Gemini STDERR:", stderr); + + // Gemini expects Exit 0 for a protocol-compliant JSON denial. + // If we exit with 2, it may "fail open" and proceed with the action. + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.decision).toBe("deny"); + expect(parsed.systemMessage).toContain("Action prohibited by FailproofAI"); + expect(parsed.reason).toBe(parsed.systemMessage); + expect(parsed.reason).toContain("policy: block-sudo"); + expect(stderr).toContain("MANDATORY ACTION REQUIRED"); + expect(stderr).toContain("sudo"); + }); + + it("allows benign commands with empty output", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli gemini --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + const payload = GeminiPayloads.beforeTool.bash("ls", PROJECT_DIR); + const output = execSync(`bun ${BINARY_PATH} --hook BeforeTool --cli gemini`, { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" } + }).toString(); + + expect(JSON.parse(output.trim())).toEqual({ decision: "allow" }); + }); + + it("denies sudo from stringified Gemini toolArgs payloads", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli gemini --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + const payload = { + session_id: "test-session-gemini-json-001", + cwd: PROJECT_DIR, + hook_event_name: "BeforeTool", + toolName: "Shell", + toolArgs: "{\"command\":\"sudo apt-get update\",\"cwd\":\"" + PROJECT_DIR.replace(/\\/g, "\\\\") + "\"}", + }; + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "BeforeTool", "--cli", "gemini"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.decision).toBe("deny"); + expect(parsed.systemMessage).toContain("Action prohibited by FailproofAI"); + expect(parsed.reason).toBe(parsed.systemMessage); + expect(parsed.reason).toContain("policy: block-sudo"); + expect(stderr).toContain("MANDATORY ACTION REQUIRED"); + expect(stderr).toContain("sudo"); + }); + + it("blocks env on Gemini Shell tool name via BeforeTool", () => { + execSync(`bun ${BINARY_PATH} policies --install protect-env-vars --cli gemini --scope project`, { + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd() } + }); + + const payload = { + session_id: "test-session-e2e-001", + cwd: PROJECT_DIR, + hook_event_name: "BeforeTool", + tool_name: "Shell", + tool_input: "env", + }; + + const { status, stdout, stderr } = spawnSync("bun", [BINARY_PATH, "--hook", "BeforeTool", "--cli", "gemini"], { + input: JSON.stringify(payload), + cwd: PROJECT_DIR, + env: { ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }, + encoding: "utf8" + }); + + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.decision).toBe("deny"); + expect(parsed.systemMessage).toContain("Action prohibited by FailproofAI"); + expect(parsed.reason).toBe(parsed.systemMessage); + expect(parsed.reason).toContain("policy: protect-env-vars"); + expect(stderr).toContain("MANDATORY ACTION REQUIRED"); + expect(stderr).toContain("environment variables"); + }); +}); diff --git a/__tests__/e2e/hooks/opencode-integration.e2e.test.ts b/__tests__/e2e/hooks/opencode-integration.e2e.test.ts new file mode 100644 index 00000000..df36dbb7 --- /dev/null +++ b/__tests__/e2e/hooks/opencode-integration.e2e.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const DEDUP_DIR = resolve(homedir(), ".failproofai", "cache", "dedup"); + +describe("E2E: OpenCode Integration", () => { + let projectDir: string; + let isoHome: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "fp-e2e-opencode-")); + isoHome = mkdtempSync(join(tmpdir(), "fp-e2e-opencode-home-")); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + }); + + afterEach(() => { + if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true }); + if (existsSync(isoHome)) rmSync(isoHome, { recursive: true, force: true }); + }); + + const baseEnv = () => ({ ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }); + + it("denies sudo via tool.execute.before event (exit 2 + stderr message)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli opencode --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "ses_test001", + cwd: projectDir, + hook_event_name: "tool.execute.before", + tool_name: "bash", + tool_input: "sudo rm -rf /", + integration: "opencode", + }; + + const { status, stdout, stderr } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool.execute.before", "--cli", "opencode"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(status).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("block-sudo"); + expect(stderr).toContain("sudo"); + }); + + it("allows benign commands with exit 0 and empty output", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli opencode --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "ses_test002", + cwd: projectDir, + hook_event_name: "tool.execute.before", + tool_name: "bash", + tool_input: "ls -la", + integration: "opencode", + }; + + const { status, stdout, stderr } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool.execute.before", "--cli", "opencode"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(status).toBe(0); + expect(stdout.trim()).toBe(""); + expect(stderr.trim()).toBe(""); + }); + + it("writes TypeScript plugin to .opencode/plugins/failproofai.ts for project scope", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli opencode --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const pluginPath = resolve(projectDir, ".opencode", "plugins", "failproofai.ts"); + expect(existsSync(pluginPath)).toBe(true); + const content = readFileSync(pluginPath, "utf8"); + expect(content).toContain("FailproofAIPlugin"); + expect(content).toContain("--hook"); + expect(content).toContain("--cli opencode"); + }); + + it("emits warning at install time when require-commit-before-stop is installed (no Stop event)", () => { + const result = spawnSync( + "bun", + [BINARY_PATH, "policies", "--install", "require-commit-before-stop", "--cli", "opencode", "--scope", "project"], + { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + const combinedOutput = result.stdout + result.stderr; + expect(combinedOutput).toContain("Stop"); + expect(combinedOutput).toContain("require-commit-before-stop"); + }); + + it("deny message uses default [FailproofAI Security Stop] format", () => { + execSync(`bun ${BINARY_PATH} policies --install block-rm-rf --cli opencode --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "ses_test003", + cwd: projectDir, + hook_event_name: "tool.execute.before", + tool_name: "bash", + tool_input: "rm -rf /tmp", + integration: "opencode", + }; + + const { stderr } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool.execute.before", "--cli", "opencode"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(stderr).toContain("[FailproofAI Security Stop]"); + expect(stderr).not.toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(stderr).not.toContain("MANDATORY ACTION REQUIRED"); + }); +}); diff --git a/__tests__/e2e/hooks/pi-integration.e2e.test.ts b/__tests__/e2e/hooks/pi-integration.e2e.test.ts new file mode 100644 index 00000000..95222d89 --- /dev/null +++ b/__tests__/e2e/hooks/pi-integration.e2e.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +const BINARY_PATH = resolve(__dirname, "../../../bin/failproofai.mjs"); +const DEDUP_DIR = resolve(homedir(), ".failproofai", "cache", "dedup"); + +describe("E2E: Pi Coding Agent Integration", () => { + let projectDir: string; + let isoHome: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "fp-e2e-pi-")); + isoHome = mkdtempSync(join(tmpdir(), "fp-e2e-pi-home-")); + if (existsSync(DEDUP_DIR)) rmSync(DEDUP_DIR, { recursive: true, force: true }); + }); + + afterEach(() => { + if (existsSync(projectDir)) rmSync(projectDir, { recursive: true, force: true }); + if (existsSync(isoHome)) rmSync(isoHome, { recursive: true, force: true }); + }); + + const baseEnv = () => ({ ...process.env, FAILPROOFAI_DIST_PATH: process.cwd(), FAILPROOFAI_SKIP_KILL: "true" }); + + it("denies sudo via tool_call event (exit 2 + stderr message)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "pi-test-001", + cwd: projectDir, + hook_event_name: "tool_call", + tool_name: "bash", + tool_input: { command: "sudo rm -rf /" }, + integration: "pi", + }; + + const { status, stdout, stderr } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool_call", "--cli", "pi"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(status).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("block-sudo"); + expect(stderr).toContain("sudo"); + }); + + it("allows benign commands with exit 0 and empty output", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "pi-test-002", + cwd: projectDir, + hook_event_name: "tool_call", + tool_name: "bash", + tool_input: { command: "echo hello" }, + integration: "pi", + }; + + const { status, stdout, stderr } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool_call", "--cli", "pi"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(status).toBe(0); + expect(stdout.trim()).toBe(""); + expect(stderr.trim()).toBe(""); + }); + + it("writes TypeScript extension to .pi/extensions/failproofai.ts for project scope", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const pluginPath = resolve(projectDir, ".pi", "extensions", "failproofai.ts"); + expect(existsSync(pluginPath)).toBe(true); + const content = readFileSync(pluginPath, "utf8"); + expect(content).toContain("FailproofAI"); + expect(content).toContain("--hook"); + expect(content).toContain("--cli pi"); + }); + + it("deny message uses default [FailproofAI Security Stop] format", () => { + execSync(`bun ${BINARY_PATH} policies --install block-rm-rf --cli pi --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + const payload = { + session_id: "pi-test-003", + cwd: projectDir, + hook_event_name: "tool_call", + tool_name: "bash", + tool_input: { command: "rm -rf /tmp" }, + integration: "pi", + }; + + const { stderr } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool_call", "--cli", "pi"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(stderr).toContain("[FailproofAI Security Stop]"); + expect(stderr).not.toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(stderr).not.toContain("MANDATORY ACTION REQUIRED"); + }); + + it("PostToolUse tool_result event routes to PostToolUse canonical event (allow with block-sudo)", () => { + execSync(`bun ${BINARY_PATH} policies --install block-sudo --cli pi --scope project`, { + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + }); + + // block-sudo only fires on PreToolUse; PostToolUse should allow + const payload = { + session_id: "pi-test-004", + cwd: projectDir, + hook_event_name: "tool_result", + tool_name: "bash", + tool_output: "command output", + integration: "pi", + }; + + const { status } = spawnSync( + "bun", + [BINARY_PATH, "--hook", "tool_result", "--cli", "pi"], + { + input: JSON.stringify(payload), + cwd: projectDir, + env: { ...baseEnv(), HOME: isoHome }, + encoding: "utf8", + }, + ); + + expect(status).toBe(0); + }); +}); diff --git a/__tests__/e2e/hooks/policy-params.e2e.test.ts b/__tests__/e2e/hooks/policy-params.e2e.test.ts index b5cbc608..7d0e53b4 100644 --- a/__tests__/e2e/hooks/policy-params.e2e.test.ts +++ b/__tests__/e2e/hooks/policy-params.e2e.test.ts @@ -285,8 +285,7 @@ describe("policyParams hint", () => { }); const result = runHook("PreToolUse", Payloads.preToolUse.bash("sudo rm -rf /", env.cwd), { homeDir: env.home }); assertPreToolUseDeny(result); - const output = result.parsed?.hookSpecificOutput as Record; - expect(output.permissionDecisionReason).toContain("Use apt-get directly instead."); + expect(result.stderr).toContain("Use apt-get directly instead."); }); it("appends hint to instruct message for PreToolUse", () => { @@ -298,8 +297,7 @@ describe("policyParams hint", () => { const content = "x".repeat(150 * 1024); // 150KB > 100KB threshold const result = runHook("PreToolUse", Payloads.preToolUse.write(`${env.cwd}/out.txt`, content, env.cwd), { homeDir: env.home }); assertInstruct(result); - const output = result.parsed?.hookSpecificOutput as Record; - expect(output.additionalContext).toContain("Split into smaller files."); + expect(result.stderr).toContain("Split into smaller files."); }); it("deny message is unchanged when no hint is configured", () => { @@ -309,10 +307,9 @@ describe("policyParams hint", () => { }); const result = runHook("PreToolUse", Payloads.preToolUse.bash("sudo rm -rf /", env.cwd), { homeDir: env.home }); assertPreToolUseDeny(result); - const output = result.parsed?.hookSpecificOutput as Record; - const reason = output.permissionDecisionReason as string; + const reason = result.stderr; // Should contain the standard deny message but NOT any hint appendage - expect(reason).toContain("failproofai because:"); + expect(reason).toContain("[FailproofAI]"); expect(reason).not.toContain(". ."); }); @@ -325,8 +322,7 @@ describe("policyParams hint", () => { const output = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const result = runHook("PostToolUse", Payloads.postToolUse.bash("cat key.txt", output, env.cwd), { homeDir: env.home }); assertPostToolUseDeny(result); - const hookOutput = result.parsed?.hookSpecificOutput as Record; - expect(hookOutput.additionalContext).toContain("Redact the key before sharing."); + expect(result.stderr).toContain("Redact the key before sharing."); }); it("ignores non-string hint value", () => { @@ -337,8 +333,7 @@ describe("policyParams hint", () => { }); const result = runHook("PreToolUse", Payloads.preToolUse.bash("sudo rm -rf /", env.cwd), { homeDir: env.home }); assertPreToolUseDeny(result); - const output = result.parsed?.hookSpecificOutput as Record; - const reason = output.permissionDecisionReason as string; + const reason = result.stderr; // Should not have ". 42" appended expect(reason).not.toContain("42"); }); @@ -353,8 +348,7 @@ describe("policyParams hint", () => { const jwtOutput = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; const result = runHook("PostToolUse", Payloads.postToolUse.bash("cat token.txt", jwtOutput, env.cwd), { homeDir: env.home }); assertPostToolUseDeny(result); - const hookOutput = result.parsed?.hookSpecificOutput as Record; - expect(hookOutput.additionalContext).toContain("Redact the token before sharing."); + expect(result.stderr).toContain("Redact the token before sharing."); }); }); diff --git a/__tests__/hooks/block-read-outside-cwd.test.ts b/__tests__/hooks/block-read-outside-cwd.test.ts index ef20884c..421db1ba 100644 --- a/__tests__/hooks/block-read-outside-cwd.test.ts +++ b/__tests__/hooks/block-read-outside-cwd.test.ts @@ -34,7 +34,7 @@ describe("block-read-outside-cwd policy", () => { it("exists in BUILTIN_POLICIES", () => { expect(policy).toBeDefined(); expect(policy.defaultEnabled).toBe(false); - expect(policy.match.toolNames).toEqual(["Read", "Glob", "Grep", "Bash"]); + expect(policy.match.toolNames).toEqual(["Read", "Glob", "Grep", "Bash", "run_terminal_command", "Terminal", "Shell", "bash", "ReadFile"]); }); it("allows Read with file_path inside cwd", async () => { diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 10f03b2c..a7077c72 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { readFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { BUILTIN_POLICIES, registerBuiltinPolicies, clearGitBranchCache } from "../../src/hooks/builtin-policies"; import { getPoliciesForEvent, clearPolicies } from "../../src/hooks/policy-registry"; @@ -13,6 +14,10 @@ vi.mock("node:fs/promises", () => ({ open: vi.fn(), })); +vi.mock("node:fs", () => ({ + readFileSync: vi.fn().mockReturnValue(""), +})); + vi.mock("node:child_process", () => ({ execSync: vi.fn(), execFileSync: vi.fn(), @@ -485,6 +490,41 @@ describe("hooks/builtin-policies", () => { const ctx = makeCtx({ toolName: "Bash", toolInput: { command: "ls -la" } }); expect((await policy.fn(ctx)).decision).toBe("allow"); }); + + it("blocks nested command payloads (Gemini variants)", async () => { + const ctx = makeCtx({ + toolName: "Shell", + toolInput: { + tool: { + args: { + command: "sudo apt-get update", + }, + }, + }, + }); + expect((await policy.fn(ctx)).decision).toBe("deny"); + }); + + it("blocks stringified Gemini command payloads", async () => { + const ctx = makeCtx({ + toolName: "Shell", + toolInput: "{\"tool\":{\"args\":\"{\\\"command\\\":\\\"sudo apt-get update\\\"}\"}}", + }); + expect((await policy.fn(ctx)).decision).toBe("deny"); + }); + + it("prefers nested command over generic input text", async () => { + const ctx = makeCtx({ + toolName: "Shell", + toolInput: { + input: "metadata only", + tool: { + args: "{\"command\":\"sudo apt-get update\"}", + }, + }, + }); + expect((await policy.fn(ctx)).decision).toBe("deny"); + }); }); describe("block-curl-pipe-sh", () => { @@ -545,6 +585,22 @@ describe("hooks/builtin-policies", () => { }); expect((await policy.fn(ctx)).decision).toBe("deny"); }); + + it("blocks direct wget remote .sh download", async () => { + const ctx = makeCtx({ + toolName: "Bash", + toolInput: { command: "wget -O setup_external.sh https://example.com/setup.sh" }, + }); + expect((await policy.fn(ctx)).decision).toBe("deny"); + }); + + it("blocks direct curl remote .sh download", async () => { + const ctx = makeCtx({ + toolName: "Bash", + toolInput: { command: "curl -fsSL https://example.com/install.sh -o install.sh" }, + }); + expect((await policy.fn(ctx)).decision).toBe("deny"); + }); }); describe("block-push-master", () => { @@ -1971,7 +2027,7 @@ describe("hooks/builtin-policies", () => { vi.mocked(execSync).mockReset(); }); - it("denies when there are modified files", async () => { + it("warns (non-blocking) when there are modified files", async () => { vi.mocked(execSync).mockReturnValue("M src/index.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -1979,35 +2035,39 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("uncommitted changes"); }); - it("denies when there are untracked files", async () => { + it("warns (non-blocking) when there are untracked files", async () => { vi.mocked(execSync).mockReturnValue("?? newfile.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); expect(result.decision).toBe("deny"); + expect(result.reason).toContain("uncommitted changes"); }); - it("denies when there are staged but uncommitted files", async () => { + it("warns (non-blocking) when there are staged but uncommitted files", async () => { vi.mocked(execSync).mockReturnValue("A staged-file.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); expect(result.decision).toBe("deny"); + expect(result.reason).toContain("uncommitted changes"); }); - it("denies when there are deleted files", async () => { + it("warns (non-blocking) when there are deleted files", async () => { vi.mocked(execSync).mockReturnValue("D removed.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); expect(result.decision).toBe("deny"); + expect(result.reason).toContain("uncommitted changes"); }); - it("denies when there are renamed files", async () => { + it("warns (non-blocking) when there are renamed files", async () => { vi.mocked(execSync).mockReturnValue("R old.ts -> new.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); expect(result.decision).toBe("deny"); + expect(result.reason).toContain("uncommitted changes"); }); - it("denies with mixed status output (modified + untracked)", async () => { + it("warns (non-blocking) with mixed status output (modified + untracked)", async () => { vi.mocked(execSync).mockReturnValue("M src/index.ts\n?? newfile.ts\n A staged.ts\n"); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2120,7 +2180,7 @@ describe("hooks/builtin-policies", () => { }); } - it("denies when there are unpushed commits (plural message)", async () => { + it("warns (non-blocking) when there are unpushed commits (plural message)", async () => { mockPushScenario({ unpushedOutput: "abc123 fix\ndef456 update\n" }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2129,7 +2189,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("git push"); }); - it("denies with singular message for 1 unpushed commit", async () => { + it("warns (non-blocking) with singular message for 1 unpushed commit", async () => { mockPushScenario({ unpushedOutput: "abc123 fix\n" }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2138,7 +2198,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).not.toContain("commits"); }); - it("denies when no tracking branch exists", async () => { + it("warns (non-blocking) when no tracking branch exists", async () => { mockPushScenario({ branch: "new-feature", hasTracking: false }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2147,7 +2207,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("new-feature"); }); - it("deny message includes branch name and remote", async () => { + it("warning message includes branch name and remote", async () => { mockPushScenario({ branch: "my-feature", hasTracking: false }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2354,7 +2414,7 @@ describe("hooks/builtin-policies", () => { }); } - it("denies when no PR exists", async () => { + it("warns (non-blocking) when no PR exists", async () => { mockPrScenario({ prResult: null }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2363,7 +2423,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("gh pr create"); }); - it("deny message includes the branch name", async () => { + it("warning message includes the branch name", async () => { mockPrScenario({ branch: "my-feature", prResult: null }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2380,7 +2440,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("https://github.com/org/repo/pull/42"); }); - it("denies when PR is closed", async () => { + it("warns (non-blocking) when PR is closed", async () => { mockPrScenario({ prResult: { number: 42, url: "https://github.com/org/repo/pull/42", state: "CLOSED" } }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2389,12 +2449,12 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("gh pr create"); }); - it("denies when PR is merged and file changes exist after fetch", async () => { + it("warns (non-blocking) when PR is merged and file changes exist after fetch", async () => { mockPrScenario({ prResult: { number: 42, url: "https://github.com/org/repo/pull/42", state: "MERGED" } }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); expect(result.decision).toBe("deny"); - expect(result.reason).toContain("merged"); + expect(result.reason).toContain("gh pr create"); }); it("allows when PR is merged and branch is up to date after fetch (regular merge)", async () => { @@ -2453,7 +2513,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("no file changes"); }); - it("falls through to deny when fetch fails on merged PR", async () => { + it("warns (non-blocking) when fetch fails on merged PR", async () => { vi.mocked(execSync).mockImplementation((cmd: string) => { if (typeof cmd === "string" && cmd.includes("gh --version")) return "/usr/bin/gh\n"; if (typeof cmd === "string" && cmd.includes("rev-parse --abbrev-ref")) return "feat/branch\n"; @@ -2582,7 +2642,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("PR #10"); }); - it("falls through to deny when origin/{baseBranch} ref missing and no PR exists", async () => { + it("falls through to warn when origin/{baseBranch} ref missing and no PR exists", async () => { mockPrScenario({ baseRefExists: false, prResult: null }); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); @@ -2613,6 +2673,7 @@ describe("hooks/builtin-policies", () => { afterEach(() => { vi.mocked(execSync).mockReset(); vi.mocked(execFileSync).mockReset(); + vi.mocked(readFileSync).mockReset(); clearGitBranchCache(); }); @@ -2653,19 +2714,19 @@ describe("hooks/builtin-policies", () => { }); } - it("denies when CI checks are failing", async () => { + it("warns (non-blocking) when CI checks are failing", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "completed", conclusion: "failure", name: "test" }, { status: "completed", conclusion: "success", name: "build" }, ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("failing"); expect(result.reason).toContain('"test"'); }); - it("denies listing multiple failed checks by name", async () => { + it("warns listing multiple failed checks by name", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "completed", conclusion: "failure", name: "test" }, { status: "completed", conclusion: "failure", name: "lint" }, @@ -2673,39 +2734,39 @@ describe("hooks/builtin-policies", () => { ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain('"test"'); expect(result.reason).toContain('"lint"'); }); - it("denies when CI checks are in progress", async () => { + it("warns (non-blocking) when CI checks are in progress", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "in_progress", conclusion: "", name: "test" }, ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("still running"); expect(result.reason).toContain('"test"'); }); - it("denies when CI checks are queued", async () => { + it("warns (non-blocking) when CI checks are queued", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "queued", conclusion: "", name: "deploy" }, ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("still running"); }); - it("denies when CI checks are waiting", async () => { + it("warns (non-blocking) when CI checks are waiting", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "waiting", conclusion: "", name: "approval-gate" }, ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("still running"); }); @@ -2718,14 +2779,14 @@ describe("hooks/builtin-policies", () => { expect(result.decision).toBe("allow"); }); - it("failing checks take priority over pending checks", async () => { + it("warns (non-blocking) when failing checks take priority over pending checks", async () => { mockCiScenario("feat/branch", JSON.stringify([ { status: "completed", conclusion: "failure", name: "test" }, { status: "in_progress", conclusion: "", name: "build" }, ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); // Failure check comes first in code, so message says "failing" not "running" expect(result.reason).toContain("failing"); }); @@ -2832,7 +2893,7 @@ describe("hooks/builtin-policies", () => { ])); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain('"my-feature"'); }); @@ -2852,7 +2913,7 @@ describe("hooks/builtin-policies", () => { // -- Third-party check run tests -- - it("denies when a third-party check is failing while Actions checks pass", async () => { + it("warns (non-blocking) when a third-party check is failing while Actions checks pass", async () => { mockCiScenario( "feat/branch", JSON.stringify([ @@ -2866,7 +2927,7 @@ describe("hooks/builtin-policies", () => { ); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("failing"); expect(result.reason).toContain('"CodeRabbit"'); }); @@ -2885,7 +2946,7 @@ describe("hooks/builtin-policies", () => { ); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("still running"); expect(result.reason).toContain('"SonarCloud"'); }); @@ -2908,7 +2969,7 @@ describe("hooks/builtin-policies", () => { expect(result.reason).toContain("All CI checks passed"); }); - it("deny message includes names from both workflow and third-party failures", async () => { + it("warns listing names from both workflow and third-party failures", async () => { mockCiScenario( "feat/branch", JSON.stringify([ @@ -2922,7 +2983,7 @@ describe("hooks/builtin-policies", () => { ); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain('"test"'); expect(result.reason).toContain('"CodeRabbit"'); }); @@ -2996,12 +3057,12 @@ describe("hooks/builtin-policies", () => { ); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("still running"); expect(result.reason).toContain('"CodeRabbit"'); }); - it("denies when a commit status is error", async () => { + it("warns (non-blocking) when a commit status is error", async () => { mockCiScenario( "feat/branch", JSON.stringify([ @@ -3015,12 +3076,12 @@ describe("hooks/builtin-policies", () => { ); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("failing"); expect(result.reason).toContain('"CodeRabbit"'); }); - it("denies when a commit status is failure", async () => { + it("warns (non-blocking) when a commit status is failure", async () => { mockCiScenario( "feat/branch", JSON.stringify([ @@ -3034,7 +3095,7 @@ describe("hooks/builtin-policies", () => { ); const ctx = makeCtx({ eventType: "Stop", session: { cwd: "/repo" } }); const result = await policy.fn(ctx); - expect(result.decision).toBe("deny"); + expect(result.decision).toBe("allow"); expect(result.reason).toContain("failing"); }); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 3a2937ce..89845754 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { handleHookEvent } from "../../src/hooks/handler"; +import { handleHookEvent, writeVirtualLogEntry, _resetDedupeCache } from "../../src/hooks/handler"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; vi.mock("../../src/hooks/hooks-config", () => ({ readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: ["block-sudo"] })), @@ -81,6 +84,7 @@ describe("hooks/handler", () => { stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); vi.clearAllMocks(); + _resetDedupeCache(); }); afterEach(() => { @@ -90,14 +94,16 @@ describe("hooks/handler", () => { it("returns exit code from policy evaluation", async () => { mockStdin(); - const exitCode = await handleHookEvent("PreToolUse"); - expect(exitCode).toBe(0); + const result = await handleHookEvent("PreToolUse"); + expect(result.exitCode).toBe(0); + expect(result.hardStop).toBe(false); }); - it("returns number (not void)", async () => { + it("returns an object with exitCode and hardStop", async () => { mockStdin(); const result = await handleHookEvent("SessionStart"); - expect(typeof result).toBe("number"); + expect(typeof result.exitCode).toBe("number"); + expect(typeof result.hardStop).toBe("boolean"); }); it("does not write raw stderr (logging is via hook-logger)", async () => { @@ -132,11 +138,37 @@ describe("hooks/handler", () => { ); }); + it("normalizes Gemini stringified tool args before policy evaluation", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + mockStdin(JSON.stringify({ + hook_event_name: "BeforeTool", + toolName: "Shell", + toolArgs: "{\"command\":\"sudo apt-get update\",\"cwd\":\"/repo/subdir\"}", + })); + + await handleHookEvent("BeforeTool", "gemini"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "Shell", + tool_input: { command: "sudo apt-get update", cwd: "/repo/subdir" }, + cwd: "/repo/subdir", + }), + expect.objectContaining({ + integration: "gemini", + hookEventName: "BeforeTool", + cwd: "/repo/subdir", + }), + expect.anything(), + ); + }); + it("persists hook activity for every evaluation", async () => { mockStdin(); const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); - await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); expect(persistHookActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -160,7 +192,7 @@ describe("hooks/handler", () => { mockStdin(JSON.stringify({ tool_name: "Bash" })); const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); - await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); expect(persistHookActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -342,9 +374,9 @@ describe("hooks/handler", () => { const { trackHookEvent } = await import("../../src/hooks/hook-telemetry"); vi.mocked(trackHookEvent).mockRejectedValueOnce(new Error("PostHog unavailable")); - const exitCode = await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); - expect(exitCode).toBe(0); + expect(result.exitCode).toBe(0); }); it("fires custom_hooks_loaded with count, names, and event types when custom hooks are present", async () => { @@ -472,7 +504,7 @@ describe("hooks/handler", () => { mockStdin(sessionPayload); const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); - await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); expect(persistHookActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -489,11 +521,11 @@ describe("hooks/handler", () => { mockStdin(); const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); - await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); expect(persistHookActivity).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: undefined, + sessionId: expect.stringContaining("session-claude-code"), transcriptPath: undefined, cwd: undefined, permissionMode: undefined, @@ -525,6 +557,202 @@ describe("hooks/handler", () => { ); }); + describe("copilot integration handling", () => { + it("silently aborts corrupted legacy Claude-labeled Copilot-only events", async () => { + mockStdin(JSON.stringify({ sessionId: "cop-legacy-1" })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + + const result = await handleHookEvent("sessionStart", "claude-code"); + + expect(result).toEqual({ exitCode: 0, hardStop: false }); + expect(persistHookActivity).not.toHaveBeenCalled(); + expect(evaluatePolicies).not.toHaveBeenCalled(); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("detects Copilot from native camelCase events and persists canonical dashboard fields", async () => { + mockStdin(JSON.stringify({ + sessionId: "cop-start-123", + cwd: "/repo/copilot-app", + hookEventName: "sessionStart", + })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + + await handleHookEvent("sessionStart"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "SessionStart", + expect.any(Object), + expect.objectContaining({ + sessionId: "cop-start-123", + cwd: "/repo/copilot-app", + integration: "copilot", + }), + expect.any(Object), + ); + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "SessionStart", + sessionId: "cop-start-123", + integration: "copilot", + hookEventName: "sessionStart", + transcriptPath: path.join(os.homedir(), ".copilot", "session-state", "cop-start-123", "events.jsonl"), + }), + ); + }); + + it("normalizes nested Copilot toolArgs payloads before policy evaluation", async () => { + mockStdin(JSON.stringify({ + data: { + sessionId: "cop-toolargs-1", + hookEventName: "preToolUse", + toolName: "bash", + toolArgs: "{\"command\":\"sudo ls\",\"cwd\":\"/repo/copilot-app/subdir\"}", + }, + })); + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("preToolUse"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + session_id: "cop-toolargs-1", + tool_name: "bash", + tool_input: { command: "sudo ls", cwd: "/repo/copilot-app/subdir" }, + }), + expect.objectContaining({ + sessionId: "cop-toolargs-1", + cwd: "/repo/copilot-app/subdir", + integration: "copilot", + }), + expect.any(Object), + ); + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "PreToolUse", + sessionId: "cop-toolargs-1", + integration: "copilot", + transcriptPath: path.join(os.homedir(), ".copilot", "session-state", "cop-toolargs-1", "events.jsonl"), + }), + ); + }); + + it("recovers a Copilot session id from env vars when payload is empty", async () => { + const oldSession = process.env.COPILOT_SESSION_ID; + process.env.COPILOT_SESSION_ID = "cop-env-session"; + mockStdin(); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + try { + await handleHookEvent("sessionStart"); + } finally { + if (oldSession === undefined) delete process.env.COPILOT_SESSION_ID; + else process.env.COPILOT_SESSION_ID = oldSession; + } + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "SessionStart", + sessionId: "cop-env-session", + integration: "copilot", + transcriptPath: path.join(os.homedir(), ".copilot", "session-state", "cop-env-session", "events.jsonl"), + }), + ); + }); + + it("uses FAILPROOFAI_COPILOT_TRANSCRIPTS_DIR when configured", async () => { + const oldDir = process.env.FAILPROOFAI_COPILOT_TRANSCRIPTS_DIR; + process.env.FAILPROOFAI_COPILOT_TRANSCRIPTS_DIR = "/tmp/copilot-transcripts"; + mockStdin(JSON.stringify({ + sessionId: "cop-custom-path-1", + hookEventName: "sessionStart", + })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + try { + await handleHookEvent("sessionStart"); + } finally { + if (oldDir === undefined) delete process.env.FAILPROOFAI_COPILOT_TRANSCRIPTS_DIR; + else process.env.FAILPROOFAI_COPILOT_TRANSCRIPTS_DIR = oldDir; + } + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "copilot", + transcriptPath: "/tmp/copilot-transcripts/cop-custom-path-1/events.jsonl", + }), + ); + }); + + it("supports COPILOT_SESSION_STATE_DIR as a transcript base fallback", async () => { + const oldDir = process.env.COPILOT_SESSION_STATE_DIR; + process.env.COPILOT_SESSION_STATE_DIR = "/tmp/copilot-session-state"; + mockStdin(JSON.stringify({ + sessionId: "cop-legacy-env-1", + hookEventName: "sessionStart", + })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + try { + await handleHookEvent("sessionStart"); + } finally { + if (oldDir === undefined) delete process.env.COPILOT_SESSION_STATE_DIR; + else process.env.COPILOT_SESSION_STATE_DIR = oldDir; + } + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "copilot", + transcriptPath: "/tmp/copilot-session-state/cop-legacy-env-1/events.jsonl", + }), + ); + }); + + it("synthesizes a stable Copilot fallback session id when the payload omits one", async () => { + mockStdin(JSON.stringify({ + cwd: "/home/user/work/copilot-app", + hookEventName: "sessionStart", + })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("sessionStart"); + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-copilot-copilot-app", + integration: "copilot", + transcriptPath: path.join(os.homedir(), ".copilot", "session-state", "session-copilot-copilot-app", "events.jsonl"), + }), + ); + }); + + it("lets an explicit integration flag beat a Copilot-shaped payload", async () => { + mockStdin(JSON.stringify({ + sessionId: "cop-looks-like-copilot", + hookEventName: "preToolUse", + toolName: "bash", + toolInput: { command: "ls" }, + })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("PreToolUse", "cursor"); + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "PreToolUse", + integration: "cursor", + }), + ); + }); + }); + it("writes stdout from evaluator result", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ @@ -557,7 +785,7 @@ describe("hooks/handler", () => { mockStdin(JSON.stringify({ tool_name: "Read" })); const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); - await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); expect(persistHookActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -603,10 +831,10 @@ describe("hooks/handler", () => { mockStdin(oversized); const { hookLogWarn } = await import("../../src/hooks/hook-logger"); - const exitCode = await handleHookEvent("PreToolUse"); + const result = await handleHookEvent("PreToolUse"); expect(hookLogWarn).toHaveBeenCalledWith(expect.stringContaining("exceeds 1 MB")); - expect(exitCode).toBe(0); + expect(result.exitCode).toBe(0); }); it("logs warning when activity persistence fails", async () => { @@ -621,4 +849,298 @@ describe("hooks/handler", () => { }); }); + describe("Mechanism-Level Deduplication", () => { + it("prevents duplicate log entries at the STORAGE level (Choke Point)", async () => { + const { persistHookActivity, _resetForTest } = await vi.importActual("../../src/hooks/hook-activity-store") as any; + + const testDir = path.join(os.homedir(), ".failproofai-test-dedup-storage"); + _resetForTest(testDir); + + // Simulation: First record call + const entry1 = { + timestamp: Date.now(), + eventType: "Stop", + sessionId: "sess-dedup", + decision: "allow", + policyName: "test-policy", + durationMs: 100 + } as any; + persistHookActivity(entry1); + + // Simulation: Second record call with slightly different duration/timestamp + // but same sessionId and eventType. + // Window is > 50ms to hit "Twin" detection. + const entry2 = { + timestamp: Date.now() + 200, + eventType: "Stop", + sessionId: "sess-dedup", + decision: "allow", + policyName: "test-policy", + durationMs: 95 + } as any; + persistHookActivity(entry2); + + // Verify that after the second call, the store logic should have dropped it + const fs = await import("node:fs"); + const logPath = path.join(testDir, "current.jsonl"); + const content = fs.readFileSync(logPath, "utf-8").trim(); + const lines = content.split("\n").filter(l => l.trim().length > 0); + + expect(lines.length).toBe(1); // ONLY ONE LINE recorded despite two persist calls + + // Cleanup + fs.rmSync(testDir, { recursive: true, force: true }); + }); + }); + + describe("integration payload data in activityEntry", () => { + it("includes toolInput and toolOutput in activityEntry from parsed payload", async () => { + mockStdin(JSON.stringify({ + tool_name: "Bash", + tool_input: { command: "ls -la" }, + tool_output: "file1.txt\nfile2.txt", + })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("PreToolUse"); + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "PreToolUse", + toolInput: { command: "ls -la" }, + toolOutput: "file1.txt\nfile2.txt", + }), + ); + }); + }); + + describe("writeVirtualLogEntry", () => { + let tempDir: string; + let logPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "failproofai-vlog-")); + logPath = path.join(tempDir, "session.jsonl"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("writes a UserEntry for UserPromptSubmit events", () => { + writeVirtualLogEntry(logPath, "UserPromptSubmit", { + tool_input: { user_prompt: "What does this code do?" }, + }); + + const lines = fs.readFileSync(logPath, "utf-8").trim().split("\n"); + const entry = JSON.parse(lines[0]); + + expect(entry.type).toBe("user"); + expect(entry.message.role).toBe("user"); + expect(entry.message.content).toBe("What does this code do?"); + expect(entry.uuid).toBeTruthy(); + expect(entry.timestamp).toBeTruthy(); + }); + + it("writes an AssistantEntry with tool_use for PreToolUse events", () => { + writeVirtualLogEntry(logPath, "PreToolUse", { + tool_name: "Bash", + tool_input: { command: "echo hello" }, + }); + + const lines = fs.readFileSync(logPath, "utf-8").trim().split("\n"); + const entry = JSON.parse(lines[0]); + + expect(entry.type).toBe("assistant"); + const block = entry.message.content[0]; + expect(block.type).toBe("tool_use"); + expect(block.name).toBe("Bash"); + expect(block.input).toEqual({ command: "echo hello" }); + expect(block.id).toMatch(/^toolu_virt_/); + }); + + it("links PostToolUse tool_result to the PreToolUse tool_use id", () => { + writeVirtualLogEntry(logPath, "PreToolUse", { + tool_name: "Bash", + tool_input: { command: "echo hello" }, + }); + writeVirtualLogEntry(logPath, "PostToolUse", { + tool_name: "Bash", + tool_input: { command: "echo hello" }, + tool_response: "hello\n", + }); + + const lines = fs.readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + + const preEntry = JSON.parse(lines[0]); + const postEntry = JSON.parse(lines[1]); + + const toolUseId = preEntry.message.content[0].id; + expect(toolUseId).toMatch(/^toolu_virt_/); + + expect(postEntry.type).toBe("user"); + expect(postEntry.message.content[0].type).toBe("tool_result"); + expect(postEntry.message.content[0].tool_use_id).toBe(toolUseId); + expect(postEntry.message.content[0].content).toBe("hello\n"); + }); + + it("preserves structured PostToolUse output payloads", () => { + writeVirtualLogEntry(logPath, "PreToolUse", { + tool_name: "Read", + tool_input: { file_path: "/tmp/a.txt" }, + }); + writeVirtualLogEntry(logPath, "PostToolUse", { + tool_name: "Read", + tool_input: { file_path: "/tmp/a.txt" }, + tool_response: { lines: ["a", "b"], count: 2 }, + }); + + const lines = fs.readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean); + const postEntry = JSON.parse(lines[1]); + expect(postEntry.message.content[0].content).toEqual({ lines: ["a", "b"], count: 2 }); + }); + + it("threads parentUuid from UserPromptSubmit through PreToolUse", () => { + writeVirtualLogEntry(logPath, "UserPromptSubmit", { + tool_input: { user_prompt: "Do something" }, + }); + writeVirtualLogEntry(logPath, "PreToolUse", { + tool_name: "Read", + tool_input: { file_path: "/foo.ts" }, + }); + + const lines = fs.readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean); + const userEntry = JSON.parse(lines[0]); + const assistantEntry = JSON.parse(lines[1]); + + expect(assistantEntry.parentUuid).toBe(userEntry.uuid); + }); + + it("skips Stop and other non-conversation events", () => { + writeVirtualLogEntry(logPath, "Stop", {}); + writeVirtualLogEntry(logPath, "SessionStart", {}); + + expect(fs.existsSync(logPath)).toBe(false); + }); + + it("skips PostToolUse with no matching PreToolUse", () => { + writeVirtualLogEntry(logPath, "PostToolUse", { + tool_name: "Bash", + tool_input: { command: "echo orphan" }, + tool_response: "orphan\n", + }); + + expect(fs.existsSync(logPath)).toBe(false); + }); + + it("skips UserPromptSubmit with empty prompt", () => { + writeVirtualLogEntry(logPath, "UserPromptSubmit", { tool_input: {} }); + expect(fs.existsSync(logPath)).toBe(false); + }); + + it("uses tool_output field for PostToolUse result content", () => { + writeVirtualLogEntry(logPath, "PreToolUse", { + tool_name: "Bash", + tool_input: { command: "echo hello" }, + }); + writeVirtualLogEntry(logPath, "PostToolUse", { + tool_name: "Bash", + tool_input: { command: "echo hello" }, + tool_output: "hello world", + }); + + const lines = fs.readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + + const postEntry = JSON.parse(lines[1]); + expect(postEntry.type).toBe("user"); + expect(postEntry.message.content[0].type).toBe("tool_result"); + expect(postEntry.message.content[0].content).toBe("hello world"); + }); + }); + + describe("dedup — integration isolation", () => { + // Import the internal helpers for targeted testing. + // We test via handleHookEvent to exercise the real lock path. + + const dedupDir = path.join(os.homedir(), ".failproofai", "cache", "dedup", "firing-locks"); + + beforeEach(() => { + _resetDedupeCache(); + }); + + afterEach(() => { + _resetDedupeCache(); + }); + + it("cursor and claude-code with same session+fingerprint each acquire distinct firing locks", async () => { + const sharedPayload = JSON.stringify({ + session_id: "shared-session-001", + tool_name: "Bash", + tool_input: { command: "ls" }, + cwd: "/tmp", + }); + + mockStdin(sharedPayload); + const cursorResult = await handleHookEvent("preToolUse", "cursor"); + // Cursor should proceed (not deduplicated) + expect(cursorResult.exitCode).toBe(0); + + // Reset stdin for the next call + mockStdin(sharedPayload); + const claudeResult = await handleHookEvent("PreToolUse", "claude-code"); + // Claude should also proceed (different integration = different lock key) + expect(claudeResult.exitCode).toBe(0); + + // Both lock files should exist (distinct keys) + const lockFiles = fs.existsSync(dedupDir) ? fs.readdirSync(dedupDir) : []; + expect(lockFiles.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe("session ID env-var isolation", () => { + afterEach(() => { + delete process.env.COPILOT_SESSION_ID; + delete process.env.CURSOR_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; + delete process.env.PI_SESSION_ID; + delete process.env.GEMINI_SESSION_ID; + _resetDedupeCache(); + }); + + it("does not use COPILOT_SESSION_ID for cursor events", async () => { + process.env.COPILOT_SESSION_ID = "copilot-env-session"; + const payload = JSON.stringify({ + hook_event_name: "preToolUse", + tool_name: "Bash", + tool_input: { command: "ls" }, + cwd: "/tmp", + integration: "cursor", + workspace_roots: ["/tmp"], + }); + mockStdin(payload); + // Should run without error — the Copilot session ID should not be used + const result = await handleHookEvent("preToolUse", "cursor"); + expect(result.exitCode).toBe(0); + }); + + it("uses CURSOR_SESSION_ID only for cursor integration", async () => { + process.env.CURSOR_SESSION_ID = "cursor-specific-session"; + process.env.COPILOT_SESSION_ID = "copilot-session-should-be-ignored"; + const payload = JSON.stringify({ + hook_event_name: "preToolUse", + tool_name: "Bash", + tool_input: { command: "ls" }, + cwd: "/tmp", + integration: "cursor", + workspace_roots: ["/tmp"], + }); + mockStdin(payload); + const result = await handleHookEvent("preToolUse", "cursor"); + expect(result.exitCode).toBe(0); + // No assertion on session ID value (it's internal), but verify no crash + }); + }); + }); diff --git a/__tests__/hooks/hook-activity-store.test.ts b/__tests__/hooks/hook-activity-store.test.ts index c38dd1cf..3135fb57 100644 --- a/__tests__/hooks/hook-activity-store.test.ts +++ b/__tests__/hooks/hook-activity-store.test.ts @@ -1,4 +1,5 @@ // @vitest-environment node +process.env.FAILPROOFAI_SKIP_DEDUP = "true"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; @@ -10,6 +11,7 @@ import { getAllHookActivityEntries, searchHookActivity, getHookActivityHistory, + migrateIntegrationField, _resetForTest, PAGE_SIZE, type HookActivityEntry, @@ -174,4 +176,32 @@ describe("hooks/hook-activity-store", () => { rmSync(newDir, { recursive: true, force: true }); }); }); + + describe("migrateIntegrationField", () => { + it("adds integration field to entries that lack it", () => { + const { writeFileSync } = require("node:fs"); + // Manually write old-format entries without integration field + const entry1 = { timestamp: 1000, eventType: "PreToolUse", decision: "allow", durationMs: 10 } as any; + const entry2 = { timestamp: 2000, eventType: "PostToolUse", decision: "allow", durationMs: 20 } as any; + const currentFile = join(testDir, "current.jsonl"); + writeFileSync(currentFile, `${JSON.stringify(entry1)}\n${JSON.stringify(entry2)}\n`, "utf-8"); + + migrateIntegrationField(); + + const entries = getHookActivityPage(1); + expect(entries).toHaveLength(2); + expect(entries[0]).toHaveProperty("integration"); + expect(entries[1]).toHaveProperty("integration"); + expect(entries[0].integration).toBe("claude-code"); + expect(entries[1].integration).toBe("claude-code"); + }); + + it("runs only once (marks with migration marker)", () => { + persistHookActivity(makeEntry()); + migrateIntegrationField(); + migrateIntegrationField(); // Should be no-op + const entries = getHookActivityPage(1); + expect(entries).toHaveLength(1); + }); + }); }); diff --git a/__tests__/hooks/integration-deduplication.test.ts b/__tests__/hooks/integration-deduplication.test.ts new file mode 100644 index 00000000..ad9f6c08 --- /dev/null +++ b/__tests__/hooks/integration-deduplication.test.ts @@ -0,0 +1,239 @@ +/** + * Regression tests for hook deduplication + * + * ISSUE: When reinstalling hooks, duplicate entries accumulate in config files, + * causing multiple hook processes to fire for the same event. This blocks prompts + * and creates confusing duplicate dashboard entries. + * + * TESTS: Verify that hook installation always maintains exactly 1 failproofai hook per event. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getIntegration, INTEGRATIONS } from "@/src/hooks/integrations"; +import { randomUUID } from "node:crypto"; + +const TEMP_DIR = join(tmpdir(), `failproofai-test-${randomUUID()}`); + +describe("Integration: Hook Deduplication", () => { + beforeEach(() => { + try { + mkdirSync(TEMP_DIR, { recursive: true }); + } catch { + // Already exists + } + }); + + afterEach(() => { + // Cleanup temp files + try { + const fs = require("node:fs"); + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("Copilot integration", () => { + it("should not create duplicate hooks on multiple writeHookEntries calls", () => { + const configPath = join(TEMP_DIR, "copilot-config.json"); + + // Initial config + const initialConfig = { + version: 1, + hooks: {} as Record, + }; + + writeFileSync(configPath, JSON.stringify(initialConfig)); + const copilot = getIntegration("copilot"); + + // Simulate multiple installations (common source of duplicates) + const settings1 = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings1, "/path/to/failproofai", "user"); + writeFileSync(configPath, JSON.stringify(settings1)); + + const settings2 = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings2, "/path/to/failproofai", "user"); + writeFileSync(configPath, JSON.stringify(settings2)); + + const settings3 = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings3, "/path/to/failproofai", "user"); + writeFileSync(configPath, JSON.stringify(settings3)); + + // Verify: exactly ONE failproofai hook per event type, no duplicates + const hooks = settings3.hooks as Record; + for (const [eventType, entries] of Object.entries(hooks)) { + const failproofaiHooks = entries.filter( + (h) => copilot.isFailproofaiHook(h) + ); + expect( + failproofaiHooks.length, + `Event ${eventType} should have exactly 1 failproofai hook, but has ${failproofaiHooks.length}` + ).toBe(1); + } + }); + + it("should replace old failproofai hooks when binary path changes", () => { + const configPath = join(TEMP_DIR, "copilot-binary-path.json"); + + const initialConfig = { + version: 1, + hooks: {} as Record, + }; + + writeFileSync(configPath, JSON.stringify(initialConfig)); + const copilot = getIntegration("copilot"); + + // Install with path 1 + const settings1 = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings1, "/old/path/failproofai", "user"); + writeFileSync(configPath, JSON.stringify(settings1)); + + // Install with path 2 (simulating reinstall with updated binary path) + const settings2 = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings2, "/new/path/failproofai", "user"); + + // Verify: hooks use NEW path, no OLD path hooks remain + const hooks = settings2.hooks as Record; + for (const entries of Object.values(hooks)) { + for (const hook of entries) { + if (copilot.isFailproofaiHook(hook)) { + expect(hook.bash).toContain("/new/path/failproofai"); + expect(hook.bash).not.toContain("/old/path/failproofai"); + } + } + } + }); + + it("should preserve non-failproofai hooks when updating", () => { + const configPath = join(TEMP_DIR, "copilot-preserve.json"); + + const initialConfig = { + version: 1, + hooks: { + userPromptSubmitted: [ + { type: "command", bash: "echo 'custom-hook'", timeoutSec: 30 }, + ], + }, + }; + + writeFileSync(configPath, JSON.stringify(initialConfig)); + const copilot = getIntegration("copilot"); + + // Install failproofai hooks + const settings = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings, "/path/to/failproofai", "user"); + + // Verify: custom hook preserved alongside failproofai hook + const hooks = settings.hooks.userPromptSubmitted as any[]; + expect(hooks.length).toBe(2); // custom + failproofai + + const customHook = hooks.find((h) => !copilot.isFailproofaiHook(h)); + expect(customHook?.bash).toBe("echo 'custom-hook'"); + }); + + it("should handle all Copilot event types without duplication", () => { + const configPath = join(TEMP_DIR, "copilot-all-events.json"); + + const initialConfig = { version: 1, hooks: {} as Record }; + writeFileSync(configPath, JSON.stringify(initialConfig)); + const copilot = getIntegration("copilot"); + + // Install multiple times + for (let i = 0; i < 3; i++) { + const settings = JSON.parse(readFileSync(configPath, "utf-8")); + copilot.writeHookEntries(settings, "/path/to/failproofai", "user"); + writeFileSync(configPath, JSON.stringify(settings)); + } + + const finalConfig = JSON.parse(readFileSync(configPath, "utf-8")); + const hooks = finalConfig.hooks as Record; + + // Verify all Copilot event types present with no duplicates + for (const eventType of INTEGRATIONS.copilot.eventTypes) { + expect(hooks[eventType], `${eventType} should be registered`).toBeDefined(); + + const failproofaiCount = hooks[eventType].filter( + (h) => copilot.isFailproofaiHook(h) + ).length; + expect( + failproofaiCount, + `${eventType} should have exactly 1 failproofai hook` + ).toBe(1); + } + }); + }); + + describe("Hook registration ordering", () => { + it("should fire exactly ONE hook per event even with scope duplicates", async () => { + // Simulate the scenario from the regression: + // User has both user-scope and project-scope hooks installed. + // Each scope gets its own config file, so they can't see each other's hooks. + // The handler should still work correctly. + + const userScopeConfig = { + version: 1, + hooks: {} as Record, + }; + + const projectScopeConfig = { + version: 1, + hooks: {} as Record, + }; + + const copilot = getIntegration("copilot"); + + copilot.writeHookEntries(userScopeConfig, "/path/to/user-failproofai", "user"); + copilot.writeHookEntries(projectScopeConfig, "/path/to/project-failproofai", "project"); + + // Each scope should have exactly 1 failproofai hook per event (not duplicate across scopes) + for (const eventType of INTEGRATIONS.copilot.eventTypes) { + const userHooks = userScopeConfig.hooks[eventType]?.filter( + (h) => copilot.isFailproofaiHook(h) + ) ?? []; + const projectHooks = projectScopeConfig.hooks[eventType]?.filter( + (h) => copilot.isFailproofaiHook(h) + ) ?? []; + + expect(userHooks.length).toBe(1); + expect(projectHooks.length).toBe(1); + + // Both should be present (expected for scope duplication warning) + // but that's a separate concern handled by the manager's deduplication warning + } + }); + }); + + describe("Hook execution idempotency", () => { + it("should handle identical event firings without side effects", async () => { + // Even if duplicate hooks fire (before fix), the handler should be resilient. + // This test ensures handler processes can deduplicate at runtime. + + // Create test payloads for the same event + const payload1: Record = { + integration: "copilot", + sessionId: "test-session-1", + hook_event_name: "userPromptSubmitted", + }; + + const payload2: Record = { + integration: "copilot", + sessionId: "test-session-1", + hook_event_name: "userPromptSubmitted", + }; + + // Both should be detected as Copilot + const copilot = getIntegration("copilot"); + expect(copilot.detect(payload1)).toBe(true); + expect(copilot.detect(payload2)).toBe(true); + + // Both should be normalized identically + copilot.normalizePayload(payload1); + copilot.normalizePayload(payload2); + + expect(payload1.session_id).toBe(payload2.session_id); + }); + }); +}); diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts new file mode 100644 index 00000000..a0b8a992 --- /dev/null +++ b/__tests__/hooks/integrations.test.ts @@ -0,0 +1,787 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync, writeFileSync, existsSync, readlinkSync, symlinkSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import { execSync } from "node:child_process"; +import { + getIntegration, + INTEGRATIONS, + listIntegrationIds, + appendCopilotSyncToBashrc, + ensureCopilotRevisionSymlink, + removeCopilotSyncFromRcFiles, + synchronizeCopilotProjectHooks, +} from "../../src/hooks/integrations"; +import { + CURSOR_HOOK_EVENT_TYPES, + GEMINI_HOOK_EVENT_TYPES, + COPILOT_HOOK_EVENT_TYPES, + CODEX_HOOK_EVENT_TYPES, + OPENCODE_HOOK_EVENT_TYPES, +} from "../../src/hooks/types"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readlinkSync: vi.fn(), + unlinkSync: vi.fn(), + rmSync: vi.fn(), + symlinkSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(), +})); + +describe("hooks/integrations", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("listIntegrationIds", () => { + it("returns supported integration IDs", () => { + const ids = listIntegrationIds(); + expect(ids).toContain("claude-code"); + expect(ids).toContain("cursor"); + expect(ids).toContain("gemini"); + expect(ids).toContain("codex"); + expect(ids).toContain("opencode"); + expect(ids).toContain("pi"); + expect(ids.length).toBe(7); + }); + }); + + describe("claude-code", () => { + const claude = getIntegration("claude-code"); + + it("has correct properties", () => { + expect(claude.id).toBe("claude-code"); + expect(claude.displayName).toBe("Claude Code"); + expect(claude.scopes).toEqual(["user", "project", "local"]); + }); + + it("detects claude-specific events", () => { + expect(claude.detect({ hook_event_name: "beforeSubmitPrompt" })).toBe(true); + expect(claude.detect({})).toBe(false); // No longer fallback + }); + + it("getCanonicalEventName mirrors input", () => { + expect(claude.getCanonicalEventName({}, "PreToolUse")).toBe("PreToolUse"); + }); + }); + + describe("cursor", () => { + const cursor = getIntegration("cursor"); + + it("has correct properties", () => { + expect(cursor.id).toBe("cursor"); + expect(cursor.displayName).toBe("Cursor"); + expect(cursor.scopes).toEqual(["user", "project"]); + expect(cursor.eventTypes).toHaveLength(CURSOR_HOOK_EVENT_TYPES.length); + }); + + it("detects cursor payloads", () => { + expect(cursor.detect({ workspace_roots: ["/a"] })).toBe(true); + expect(cursor.detect({ hook_event_name: "preToolUse" })).toBe(true); + expect(cursor.detect({ hook_event_name: "beforeShellExecution" })).toBe(true); + expect(cursor.detect({ something: "else" })).toBe(false); + }); + + it("normalizes workspace_roots to cwd", () => { + const payload: any = { workspace_roots: ["/root/a"] }; + cursor.normalizePayload(payload); + expect(payload.cwd).toBe("/root/a"); + }); + + it("builds hook entry with mapped event name", () => { + const entry = cursor.buildHookEntry("/bin/fp", "beforeShellExecution") as any; + expect(entry.command).toContain("--hook PreToolUse"); + }); + }); + + describe("gemini", () => { + const gemini = getIntegration("gemini"); + + it("has correct properties", () => { + expect(gemini.id).toBe("gemini"); + expect(gemini.displayName).toBe("Gemini CLI"); + expect(gemini.scopes).toEqual(["user", "project"]); + expect(gemini.eventTypes).toHaveLength(GEMINI_HOOK_EVENT_TYPES.length); + }); + + it("detects gemini payloads exclusively", () => { + expect(gemini.detect({ hook_event_name: "BeforeTool" })).toBe(true); + expect(gemini.detect({ hook_event_name: "SessionStart" })).toBe(false); // Collision guard + }); + + it("maps events to canonical names", () => { + expect(gemini.getCanonicalEventName({ hook_event_name: "BeforeTool" }, "BeforeTool")).toBe("PreToolUse"); + expect(gemini.getCanonicalEventName({ hook_event_name: "AfterAgent" }, "AfterAgent")).toBe("Stop"); + }); + + it("resolves settings path correctly", () => { + expect(gemini.getSettingsPath("user")).toBe(resolve(homedir(), ".gemini", "settings.json")); + }); + + it("parses stringified Gemini tool args into structured tool_input", () => { + const payload: any = { + hook_event_name: "BeforeTool", + toolName: "Shell", + toolArgs: "{\"command\":\"sudo apt-get update\",\"cwd\":\"/repo/subdir\"}", + }; + + gemini.normalizePayload(payload); + + expect(payload.tool_name).toBe("Shell"); + expect(payload.tool_input).toEqual({ command: "sudo apt-get update", cwd: "/repo/subdir" }); + expect(payload.cwd).toBe("/repo/subdir"); + }); + + it("parses nested stringified Gemini args discovered through deep extraction", () => { + const payload: any = { + hook_event_name: "BeforeTool", + tool: { + args: "{\"command\":\"sudo apt-get update\",\"cwd\":\"/repo/nested\"}", + }, + }; + + gemini.normalizePayload(payload); + + expect(payload.tool_input).toEqual({ command: "sudo apt-get update", cwd: "/repo/nested" }); + expect(payload.cwd).toBe("/repo/nested"); + }); + + it("prefers toolArgs when toolInput exists but only toolArgs contains command", () => { + const payload: any = { + hook_event_name: "BeforeTool", + toolName: "Shell", + toolInput: { note: "metadata only" }, + toolArgs: "{\"command\":\"sudo apt-get update\",\"cwd\":\"/repo/real\"}", + }; + + gemini.normalizePayload(payload); + + expect(payload.tool_input).toEqual({ command: "sudo apt-get update", cwd: "/repo/real" }); + expect(payload.cwd).toBe("/repo/real"); + }); + + it("builds identical local-binary hook commands for user and project scopes", () => { + const userEntry = gemini.buildHookEntry("/bin/fp", "BeforeTool", "user") as any; + const projectEntry = gemini.buildHookEntry("/bin/fp", "BeforeTool", "project") as any; + + expect(userEntry.command).toContain('"'); + expect(userEntry.command).toContain("--hook BeforeTool --cli gemini --stdin"); + expect(projectEntry.command).toContain("--hook BeforeTool --cli gemini --stdin"); + expect(projectEntry.command).toBe(userEntry.command); + expect(projectEntry.command).not.toContain("npx -y failproofai"); + }); + + it("rewrites malformed/stale marked Gemini hooks to one canonical entry per event", () => { + const settings: any = { + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: "command", + command: "\"/node\" \"/repo/dist/cli.mj\" --hook BeforeTool --cli gemini --stdin", + __failproofai_hook__: true, + }, + { + type: "command", + command: "echo keep-non-failproof", + }, + ], + }, + { + hooks: [ + { + type: "command", + command: "\"/node\" \"/repo/dist/old-cli.mjs\" --hook BeforeTool --cli gemini --stdin", + __failproofai_hook__: true, + }, + ], + }, + ], + }, + }; + + gemini.writeHookEntries(settings, "/home/yashu/fp/failproofai/dist/cli.mjs", "project"); + const beforeTool = settings.hooks.BeforeTool; + expect(beforeTool).toBeDefined(); + + // Exactly one marked hook should remain for the event. + const marked = beforeTool.flatMap((m: any) => m.hooks || []).filter((h: any) => h.__failproofai_hook__ === true); + expect(marked).toHaveLength(1); + expect(marked[0].command).toContain("/home/yashu/fp/failproofai/dist/cli.mjs"); + expect(marked[0].command).not.toContain("cli.mj\""); + + // Non-marked hooks are preserved. + const nonMarked = beforeTool.flatMap((m: any) => m.hooks || []).filter((h: any) => !h.__failproofai_hook__); + expect(nonMarked.some((h: any) => h.command === "echo keep-non-failproof")).toBe(true); + }); + }); + + describe("copilot", () => { + const copilot = getIntegration("copilot"); + + it("has correct properties", () => { + expect(copilot.id).toBe("copilot"); + expect(copilot.displayName).toBe("GitHub Copilot"); + expect(copilot.scopes).toEqual(["user", "project"]); + expect(copilot.eventTypes).toHaveLength(COPILOT_HOOK_EVENT_TYPES.length); + }); + + it("detects copilot payloads via camelCase fields", () => { + expect(copilot.detect({ sessionId: "123" })).toBe(true); + expect(copilot.detect({ toolName: "ls" })).toBe(true); + expect(copilot.detect({ hook_event_name: "preToolUse" })).toBe(true); + }); + + it("detects copilot payloads from nested data without confusing PascalCase Claude events", () => { + expect(copilot.detect({ data: { sessionId: "cop-123" } })).toBe(true); + expect(copilot.detect({ data: { toolName: "bash" } })).toBe(true); + expect(copilot.detect({ data: { hookEventName: "preToolUse" } })).toBe(true); + expect(copilot.detect({ hook_event_name: "SessionStart" })).toBe(false); + }); + + it("normalizes camelCase to snake_case", () => { + const payload: any = { sessionId: "s1", toolName: "t1", toolInput: { a: 1 } }; + copilot.normalizePayload(payload); + expect(payload.session_id).toBe("s1"); + expect(payload.tool_name).toBe("t1"); + expect(payload.tool_input).toEqual({ a: 1 }); + }); + + it("parses stringified toolArgs JSON into tool_input", () => { + const payload: any = { + sessionId: "s1", + toolName: "bash", + toolArgs: "{\"command\":\"sudo ls\",\"cwd\":\"/repo/subdir\"}", + }; + copilot.normalizePayload(payload); + expect(payload.tool_input).toEqual({ command: "sudo ls", cwd: "/repo/subdir" }); + expect(payload.cwd).toBe("/repo/subdir"); + }); + + it("falls back to raw toolArgs string when JSON is malformed", () => { + const payload: any = { + sessionId: "s1", + toolName: "bash", + toolArgs: "{not valid json", + }; + copilot.normalizePayload(payload); + expect(payload.tool_input).toBe("{not valid json"); + }); + + it("uses the documented Copilot input waterfall", () => { + const fromParams: any = { data: { params: { command: "ls" } } }; + copilot.normalizePayload(fromParams); + expect(fromParams.tool_input).toEqual({ command: "ls" }); + + const fromMessage: any = { data: { message: "hello from data" } }; + copilot.normalizePayload(fromMessage); + expect(fromMessage.tool_input).toBe("hello from data"); + + const fromPrompt: any = { prompt: "plain prompt" }; + copilot.normalizePayload(fromPrompt); + expect(fromPrompt.tool_input).toBe("plain prompt"); + }); + + it("maps events to canonical names", () => { + expect(copilot.getCanonicalEventName({ hook_event_name: "preToolUse" }, "preToolUse")).toBe("PreToolUse"); + expect(copilot.getCanonicalEventName({ hook_event_name: "userPromptSubmitted" }, "userPromptSubmitted")).toBe("UserPromptSubmit"); + expect(copilot.getCanonicalEventName({}, "UserPromptSubmitted")).toBe("UserPromptSubmit"); + expect(copilot.getCanonicalEventName({}, "SessionEnd")).toBe("SessionEnd"); + expect(copilot.getCanonicalEventName({ hook_event_name: "errorOccurred" }, "errorOccurred")).toBe("Stop"); + }); + + it("resolves user settings path via COPILOT_HOME or ~/.copilot", () => { + const oldHome = process.env.COPILOT_HOME; + delete process.env.COPILOT_HOME; + expect(copilot.getSettingsPath("user")).toBe(resolve(homedir(), ".copilot", "config.json")); + + process.env.COPILOT_HOME = "/tmp/copilot-home"; + expect(copilot.getSettingsPath("user")).toBe(resolve("/tmp/copilot-home", "config.json")); + + if (oldHome) process.env.COPILOT_HOME = oldHome; + else delete process.env.COPILOT_HOME; + }); + + it("builds hook entries with Copilot native camelCase event names", () => { + const projectEntry = copilot.buildHookEntry("/bin/fp", "sessionStart", "project") as any; + const userEntry = copilot.buildHookEntry("/bin/fp", "preToolUse", "user") as any; + + expect(projectEntry.bash).toContain("--hook sessionStart --cli copilot"); + expect(userEntry.bash).toContain(`"${process.execPath}" "/bin/fp" --hook preToolUse --cli copilot`); + expect(userEntry.timeoutSec).toBe(60); + }); + + it("detects installation via gh rather than a standalone copilot binary", () => { + vi.mocked(execSync).mockImplementation((cmd) => { + expect(String(cmd)).toContain("gh"); + return "" as any; + }); + expect(copilot.detectInstalled()).toBe(true); + }); + + it("preserves unrelated user settings when writing hooks", () => { + const settings: any = { + version: 1, + copilotTokens: ["keep-me"], + loggedInUsers: [{ login: "octocat" }], + hooks: { + sessionStart: [{ bash: "echo existing" }], + }, + }; + + copilot.writeHookEntries(settings, "/bin/failproofai", "user"); + + expect(settings.copilotTokens).toEqual(["keep-me"]); + expect(settings.loggedInUsers).toEqual([{ login: "octocat" }]); + expect(settings.hooks.sessionStart.some((h: any) => String(h.bash).includes("failproofai"))).toBe(true); + }); + }); + + describe("codex", () => { + const codex = getIntegration("codex"); + + it("has correct properties", () => { + expect(codex.id).toBe("codex"); + expect(codex.displayName).toBe("OpenAI Codex"); + expect(codex.scopes).toEqual(["user", "project"]); + expect(codex.eventTypes).toHaveLength(CODEX_HOOK_EVENT_TYPES.length); + }); + + it("detects codex payloads", () => { + expect(codex.detect({ hook_event_name: "pre_tool_use" })).toBe(true); + expect(codex.detect({ integration: "codex" })).toBe(true); + expect(codex.detect({ hook_event_name: "preToolUse" })).toBe(false); + }); + + it("maps codex event names to canonical names", () => { + expect(codex.getCanonicalEventName({ hook_event_name: "pre_tool_use" }, "pre_tool_use")).toBe("PreToolUse"); + expect(codex.getCanonicalEventName({ hook_event_name: "user_prompt_submit" }, "user_prompt_submit")).toBe("UserPromptSubmit"); + }); + + it("resolves user settings path", () => { + expect(codex.getSettingsPath("user")).toBe(resolve(homedir(), ".codex", "hooks.json")); + }); + }); + + describe("opencode", () => { + const opencode = getIntegration("opencode"); + + it("has correct properties", () => { + expect(opencode.id).toBe("opencode"); + expect(opencode.displayName).toBe("OpenCode"); + expect(opencode.scopes).toEqual(["user", "project"]); + expect(opencode.eventTypes).toHaveLength(OPENCODE_HOOK_EVENT_TYPES.length); + }); + + it("detects opencode payloads", () => { + expect(opencode.detect({ integration: "opencode" })).toBe(true); + expect(opencode.detect({ slug: "gentle-wizard" })).toBe(true); + expect(opencode.detect({ session_id: "ses_123" })).toBe(true); + expect(opencode.detect({ something: "else" })).toBe(false); + }); + + it("maps events to canonical names", () => { + expect(opencode.getCanonicalEventName({ data: { type: "session.created" } }, "")).toBe("SessionStart"); + expect(opencode.getCanonicalEventName({ hook_event_name: "tool.execute.before" }, "")).toBe("PreToolUse"); + }); + + it("resolves user settings path including filename", () => { + expect(opencode.getSettingsPath("user")).toBe(resolve(homedir(), ".config", "opencode", "plugins", "failproofai.ts")); + }); + + it("returns correct count when removing hooks", () => { + const settingsPath = "/mock/path/failproofai.ts"; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("failproofai logic here"); + + const count = opencode.removeHooksFromFile(settingsPath); + expect(count).toBe(OPENCODE_HOOK_EVENT_TYPES.length); + }); + + it("removeHooksFromFile deletes only the failproofai.ts file, not the parent directory (B1 fix)", async () => { + const { unlinkSync, rmSync } = await import("node:fs"); + const settingsPath = "/home/user/.opencode/plugins/failproofai.ts"; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("failproofai logic here"); + + opencode.removeHooksFromFile(settingsPath); + + expect(vi.mocked(unlinkSync)).toHaveBeenCalledWith(settingsPath); + expect(vi.mocked(rmSync)).not.toHaveBeenCalled(); + }); + }); + + describe("appendCopilotSyncToBashrc", () => { + it("writes env-prefixed command on first install", () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith(".bashrc")); + vi.mocked(readFileSync).mockReturnValue("# existing content\n"); + + appendCopilotSyncToBashrc(); + + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(written).toContain("env failproofai copilot-sync 2>/dev/null"); + expect(written).not.toContain("\nfailproofai copilot-sync"); // no bare command + }); + + it("upgrades old bare command to env-prefixed on reinstall", () => { + const oldContent = "# existing\n# failproofai copilot-sync\nfailproofai copilot-sync 2>/dev/null\n"; + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith(".bashrc")); + vi.mocked(readFileSync).mockReturnValue(oldContent); + + appendCopilotSyncToBashrc(); + + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(written).toContain("env failproofai copilot-sync 2>/dev/null"); + // bare command line (at start of line) must be gone + expect(written).not.toMatch(/\nfailproofai copilot-sync 2>\/dev\/null/); + }); + + it("skips write when env-prefixed command already present", () => { + // NEW_CMD contains OLD_CMD as substring — regex must not match env-prefixed line + const newContent = "# failproofai copilot-sync\nenv failproofai copilot-sync 2>/dev/null\n"; + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith(".bashrc")); + vi.mocked(readFileSync).mockReturnValue(newContent); + + appendCopilotSyncToBashrc(); + + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + }); + + describe("removeCopilotSyncFromRcFiles", () => { + it("removes marker and command line from bashrc", () => { + const content = "# other stuff\n# failproofai copilot-sync\nenv failproofai copilot-sync 2>/dev/null\n# more stuff\n"; + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith(".bashrc")); + vi.mocked(readFileSync).mockReturnValue(content); + + removeCopilotSyncFromRcFiles(); + + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(written).not.toContain("failproofai copilot-sync"); + expect(written).toContain("# other stuff"); + expect(written).toContain("# more stuff"); + }); + + it("does nothing when marker is absent", () => { + vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith(".bashrc")); + vi.mocked(readFileSync).mockReturnValue("# unrelated content\n"); + + removeCopilotSyncFromRcFiles(); + + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + }); + + describe("synchronizeCopilotProjectHooks", () => { + it("preserves user-scope hooks byte-for-byte when no project file exists", () => { + const globalPath = resolve(homedir(), ".copilot", "config.json"); + const globalContent = JSON.stringify({ + hooks: { + sessionStart: [{ bash: "\"/usr/local/bin/failproofai\" --hook sessionStart --cli copilot" }], + }, + copilotTokens: ["keep-me"], + }, null, 2) + "\n"; + + vi.mocked(existsSync).mockImplementation((p) => String(p) === globalPath); + vi.mocked(readFileSync).mockImplementation((p) => { + if (String(p) === globalPath) return globalContent; + return ""; + }); + + synchronizeCopilotProjectHooks(); + + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it("merges project hooks into global config", () => { + const globalPath = resolve(homedir(), ".copilot", "config.json"); + const projectPath = resolve(process.cwd(), ".github", "hooks", "failproofai.json"); + + vi.mocked(existsSync).mockImplementation((p) => { + const path = String(p); + return path === globalPath || path === projectPath; + }); + + vi.mocked(readFileSync).mockImplementation((p) => { + const path = String(p); + if (path === globalPath) return JSON.stringify({ hooks: { preToolUse: [] } }); + if (path === projectPath) return JSON.stringify({ hooks: { preToolUse: [{ bash: "npx failproofai" }] } }); + return ""; + }); + + synchronizeCopilotProjectHooks(); + + const lastWrite = vi.mocked(writeFileSync).mock.calls.find(c => String(c[0]) === globalPath); + expect(lastWrite).toBeDefined(); + const data = JSON.parse(lastWrite![1] as string); + expect(data.hooks.preToolUse).toHaveLength(1); + expect(data.hooks.preToolUse[0].bash).toBe("npx failproofai"); + }); + + it("clears old project hooks before adding new ones", () => { + const globalPath = resolve(homedir(), ".copilot", "config.json"); + const projectPath = resolve(process.cwd(), ".github", "hooks", "failproofai.json"); + + vi.mocked(existsSync).mockImplementation((p) => String(p) === globalPath || String(p) === projectPath); + vi.mocked(readFileSync).mockImplementation((p) => { + const path = String(p); + if (path === globalPath) { + return JSON.stringify({ + hooks: { + preToolUse: [{ bash: "npx -y failproofai --hook PreToolUse --cli copilot" }] + } + }); + } + if (path === projectPath) { + return JSON.stringify({ + hooks: { + preToolUse: [{ bash: "npx -y failproofai --hook PreToolUse --cli copilot --NEW" }] + } + }); + } + return ""; + }); + + synchronizeCopilotProjectHooks(); + + const lastWrite = vi.mocked(writeFileSync).mock.calls.find(c => String(c[0]) === globalPath); + const data = JSON.parse(lastWrite![1] as string); + expect(data.hooks.preToolUse).toHaveLength(1); + expect(data.hooks.preToolUse[0].bash).toContain("--NEW"); + }); + + it("keeps user-scope local-binary hooks while refreshing project npx hooks", () => { + const globalPath = resolve(homedir(), ".copilot", "config.json"); + const projectPath = resolve(process.cwd(), ".github", "hooks", "failproofai.json"); + + vi.mocked(existsSync).mockImplementation((p) => String(p) === globalPath || String(p) === projectPath); + vi.mocked(readFileSync).mockImplementation((p) => { + const path = String(p); + if (path === globalPath) { + return JSON.stringify({ + hooks: { + preToolUse: [ + { bash: "\"/usr/local/bin/failproofai\" --hook preToolUse --cli copilot" }, + { bash: "npx -y failproofai --hook preToolUse --cli copilot --OLD" }, + ], + }, + }); + } + if (path === projectPath) { + return JSON.stringify({ + hooks: { + preToolUse: [ + { bash: "npx -y failproofai --hook preToolUse --cli copilot --NEW" }, + ], + }, + }); + } + return ""; + }); + + synchronizeCopilotProjectHooks(); + + const lastWrite = vi.mocked(writeFileSync).mock.calls.find(c => String(c[0]) === globalPath); + const data = JSON.parse(lastWrite![1] as string); + expect(data.hooks.preToolUse).toEqual([ + { bash: "\"/usr/local/bin/failproofai\" --hook preToolUse --cli copilot" }, + { bash: "npx -y failproofai --hook preToolUse --cli copilot --NEW" }, + ]); + }); + }); + + describe("ensureCopilotRevisionSymlink", () => { + it("creates the snap revision hook symlink when common hooks exist", () => { + const snapBase = resolve(homedir(), "snap", "copilot-cli"); + const currentLink = resolve(snapBase, "current"); + const commonHooks = resolve(snapBase, "common", ".config", "github-copilot", "hooks"); + const revHooks = resolve(snapBase, "1337", ".config", "github-copilot", "hooks"); + const globalPath = resolve(homedir(), ".copilot", "config.json"); + + vi.mocked(existsSync).mockImplementation((p) => { + const path = String(p); + return path === currentLink || path === commonHooks || path === globalPath; + }); + vi.mocked(readlinkSync).mockReturnValue("1337" as any); + vi.mocked(readFileSync).mockImplementation((p) => { + if (String(p) === globalPath) return JSON.stringify({ hooks: {} }); + return ""; + }); + + ensureCopilotRevisionSymlink(); + + expect(vi.mocked(symlinkSync)).toHaveBeenCalledWith(commonHooks, revHooks); + }); + }); + + describe("pi", () => { + const pi = getIntegration("pi"); + + it("detects pi payloads by explicit integration field", () => { + expect(pi.detect({ integration: "pi" })).toBe(true); + expect(pi.detect({ integration: "codex" })).toBe(false); + expect(pi.detect({ integration: "cursor" })).toBe(false); + expect(pi.detect({})).toBe(false); + }); + + it("extracts session_id from pi payload", () => { + const payload: Record = { + integration: "pi", + session_id: "pi-real-session-123", + tool_name: "bash", + tool_input: "ls -la", + }; + pi.normalizePayload(payload); + expect(payload.sessionId).toBe("pi-real-session-123"); + }); + + it("does not confuse pi with codex payloads", () => { + // Even if somehow a codex payload reaches pi.detect, it should return false + expect(pi.detect({ + integration: "codex", + codex_session_id: "codex-123" + })).toBe(false); + }); + + it("includes getSessionIdFromFile in the generated pi extension", () => { + // This test verifies that the pi integration's writeHookEntries + // generates a TypeScript file that includes the getSessionIdFromFile helper + // We can verify this by checking the integration object's structure + expect(pi.id).toBe("pi"); + expect(pi.displayName).toBe("Pi Coding Agent"); + expect(pi.scopes).toEqual(["user", "project"]); + + // The presence of writeHookEntries means it will generate the extension file + expect(typeof pi.writeHookEntries).toBe("function"); + }); + + it("getSessionIdFromFile correctly extracts UUID from Pi session filenames", () => { + // Test the extraction logic using string methods (not regex) + // Format: TIMESTAMP_UUID.jsonl + const extractUuid = (filename: string): string | undefined => { + const underscore = filename.lastIndexOf('_'); + const dot = filename.lastIndexOf('.'); + if (underscore > 0 && dot > underscore) { + return filename.slice(underscore + 1, dot); + } + return undefined; + }; + + // Valid Pi session filename with timestamp and UUID + const validFilename = "2026-04-21T12-35-19-493Z_019db009-b545-7792-bad7-6ea5116cd432.jsonl"; + expect(extractUuid(validFilename)).toBe("019db009-b545-7792-bad7-6ea5116cd432"); + + // Another valid format with different timestamp + const valid2 = "2026-01-01T00-00-00-000Z_aaaabbbb-cccc-dddd-eeee-ffff00001111.jsonl"; + expect(extractUuid(valid2)).toBe("aaaabbbb-cccc-dddd-eeee-ffff00001111"); + + // Valid: UUID can contain dashes and hex chars + const valid3 = "2026-01-01T00-00-00-000Z_f0f0f0f0-a1a1-b2b2-c3c3-d4d4d4d4d4d4.jsonl"; + expect(extractUuid(valid3)).toBe("f0f0f0f0-a1a1-b2b2-c3c3-d4d4d4d4d4d4"); + + // Invalid: no underscore + expect(extractUuid("2026-04-21T12-35-19-493Z.jsonl")).toBeUndefined(); + + // Invalid: no dot + expect(extractUuid("2026-04-21T12-35-19-493Z_019db009-b545-7792-bad7-6ea5116cd432")).toBeUndefined(); + + // Edge case: wrong extension - still extracts UUID (finds last dot) + expect(extractUuid("2026-04-21T12-35-19-493Z_019db009-b545-7792-bad7-6ea5116cd432.txt")).toBe("019db009-b545-7792-bad7-6ea5116cd432"); + }); + + it("getSessionIdFromFile pattern matches Pi session filename format", () => { + // Test the regex pattern that's embedded in the generated extension + const regex = /^[^_]+_([^.]+)\.jsonl$/; + + // Valid Pi session filename + const validFilename = "2026-04-21T12-35-19-493Z_019db009-b545-7792-bad7-6ea5116cd432.jsonl"; + const match = validFilename.match(regex); + expect(match).not.toBeNull(); + expect(match?.[1]).toBe("019db009-b545-7792-bad7-6ea5116cd432"); + + // Invalid filenames should not match + expect("no-uuid-in-filename.jsonl".match(regex)).toBeNull(); + expect("019db009-b545-7792-bad7-6ea5116cd432.jsonl".match(regex)).toBeNull(); // missing timestamp + expect("2026-04-21T12-35-19-493Z_invalid.txt".match(regex)).toBeNull(); // wrong extension + }); + }); + + describe("cursor normalizePayload — tool name canonicalization", () => { + const cursor = getIntegration("cursor"); + + it("maps beforeTabFileRead to Read", () => { + const payload: any = { hook_event_name: "beforeTabFileRead", file_path: "/tmp/foo.ts" }; + cursor.normalizePayload(payload); + expect(payload.tool_name).toBe("Read"); + }); + + it("maps beforeReadFile to Read", () => { + const payload: any = { hook_event_name: "beforeReadFile", file_path: "/tmp/foo.ts" }; + cursor.normalizePayload(payload); + expect(payload.tool_name).toBe("Read"); + }); + + it("maps afterTabFileEdit to Write", () => { + const payload: any = { hook_event_name: "afterTabFileEdit", file_path: "/tmp/foo.ts", tool_input: "content" }; + cursor.normalizePayload(payload); + expect(payload.tool_name).toBe("Write"); + }); + + it("maps afterFileEdit to Write", () => { + const payload: any = { hook_event_name: "afterFileEdit", file_path: "/tmp/foo.ts" }; + cursor.normalizePayload(payload); + expect(payload.tool_name).toBe("Write"); + }); + + it("does not override an already-set tool_name", () => { + const payload: any = { hook_event_name: "beforeTabFileRead", tool_name: "CustomTool", file_path: "/tmp/foo.ts" }; + cursor.normalizePayload(payload); + expect(payload.tool_name).toBe("CustomTool"); + }); + }); + + describe("gemini normalizePayload — session_id extraction", () => { + const gemini = getIntegration("gemini"); + + it("preserves existing session_id", () => { + const payload: any = { hook_event_name: "BeforeTool", session_id: "existing-id" }; + gemini.normalizePayload(payload); + expect(payload.session_id).toBe("existing-id"); + }); + + it("lifts session_id from sessionId field", () => { + const payload: any = { hook_event_name: "BeforeTool", sessionId: "lifted-id" }; + gemini.normalizePayload(payload); + expect(payload.session_id).toBe("lifted-id"); + }); + + it("lifts session_id from data.sessionId", () => { + const payload: any = { hook_event_name: "BeforeTool", data: { sessionId: "nested-id" } }; + gemini.normalizePayload(payload); + expect(payload.session_id).toBe("nested-id"); + }); + + it("lifts session_id from data.session_id", () => { + const payload: any = { hook_event_name: "BeforeTool", data: { session_id: "nested-snake-id" } }; + gemini.normalizePayload(payload); + expect(payload.session_id).toBe("nested-snake-id"); + }); + + it("leaves session_id undefined when no source available (env var not set)", () => { + delete process.env.GEMINI_SESSION_ID; + const payload: any = { hook_event_name: "BeforeTool" }; + gemini.normalizePayload(payload); + // session_id may remain undefined — no assertion on value, just no throw + expect(() => payload.session_id).not.toThrow(); + }); + }); +}); diff --git a/__tests__/hooks/loader-utils.test.ts b/__tests__/hooks/loader-utils.test.ts index b6f3b2da..477d2662 100644 --- a/__tests__/hooks/loader-utils.test.ts +++ b/__tests__/hooks/loader-utils.test.ts @@ -9,6 +9,10 @@ vi.mock("fs/promises", () => ({ unlink: vi.fn(), })); +vi.mock("child_process", () => ({ + spawnSync: vi.fn(), +})); + describe("hooks/loader-utils - findDistIndex", () => { const originalExecPath = process.execPath; @@ -74,3 +78,166 @@ describe("hooks/loader-utils - findDistIndex", () => { expect(result).toBeNull(); }); }); + +describe("hooks/loader-utils - createEsmShim", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("writes shim that exports customPolicies, allow, deny, instruct", async () => { + const { writeFile } = await import("fs/promises"); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const { createEsmShim } = await import("../../src/hooks/loader-utils"); + const distIndex = "/dist/index.js"; + const distUrl = "file:///dist/index.js"; + + const { shimPath } = await createEsmShim(distIndex, distUrl); + + expect(shimPath).toBe(`${distIndex}.__failproofai_esm_shim__.mjs`); + + expect(writeFile).toHaveBeenCalledOnce(); + const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string; + expect(writtenContent).toContain("export const customPolicies"); + expect(writtenContent).toContain("export const allow"); + expect(writtenContent).toContain("export const deny"); + expect(writtenContent).toContain("export const instruct"); + expect(writtenContent).toContain(`from '${distUrl}'`); + }); +}); + +describe("hooks/loader-utils - rewriteFileTree", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("rewrites 'from failproofai' to the ESM shim URL", async () => { + const { readFile, writeFile, access } = await import("fs/promises"); + vi.mocked(access).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const entryCode = `import { customPolicies } from 'failproofai';\ncustomPolicies.add({});`; + vi.mocked(readFile).mockImplementation(async (path: any) => { + if (String(path).endsWith(".mjs")) return ""; // shim out file reads + return entryCode; + }); + + const { rewriteFileTree } = await import("../../src/hooks/loader-utils"); + const entryPath = "/home/user/hooks/my-policy.js"; + const distIndex = "/dist/index.js"; + const distUrl = "file:///dist/index.js"; + + await rewriteFileTree(entryPath, distUrl, distIndex); + + // The temp file for the entry should be written with the failproofai import replaced + const writeCalls = vi.mocked(writeFile).mock.calls; + const entryTmpWrite = writeCalls.find((c) => String(c[0]).includes("my-policy.js")); + expect(entryTmpWrite).toBeDefined(); + const writtenCode = entryTmpWrite![1] as string; + expect(writtenCode).not.toContain("from 'failproofai'"); + expect(writtenCode).toContain("__failproofai_esm_shim__.mjs"); + }); + + it("rewrites require('failproofai') to the CJS dist path", async () => { + const { readFile, writeFile, access } = await import("fs/promises"); + vi.mocked(access).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const entryCode = `const { customPolicies } = require('failproofai');\ncustomPolicies.add({});`; + vi.mocked(readFile).mockImplementation(async (path: any) => { + if (String(path).endsWith(".mjs")) return ""; + return entryCode; + }); + + const { rewriteFileTree } = await import("../../src/hooks/loader-utils"); + const entryPath = "/home/user/hooks/my-policy.js"; + const distIndex = "/dist/index.js"; + const distUrl = "file:///dist/index.js"; + + await rewriteFileTree(entryPath, distUrl, distIndex); + + const writeCalls = vi.mocked(writeFile).mock.calls; + const entryTmpWrite = writeCalls.find((c) => String(c[0]).includes("my-policy.js")); + expect(entryTmpWrite).toBeDefined(); + const writtenCode = entryTmpWrite![1] as string; + expect(writtenCode).not.toContain("require('failproofai')"); + expect(writtenCode).toContain("/dist/index.js"); + }); + + it("handles circular imports A→B→A without infinite loop", async () => { + const { readFile, writeFile, access } = await import("fs/promises"); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const fileA = "/home/user/hooks/a.js"; + const fileB = "/home/user/hooks/b.js"; + + vi.mocked(access).mockImplementation(async (path: any) => { + const p = String(path); + if (p === fileA || p === fileB) return; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + vi.mocked(readFile).mockImplementation(async (path: any) => { + const p = String(path); + if (p === fileA) return `import x from './b.js';`; + if (p === fileB) return `import y from './a.js';`; + if (String(p).endsWith(".mjs")) return ""; + return ""; + }); + + const { rewriteFileTree } = await import("../../src/hooks/loader-utils"); + + // Should resolve without error or infinite loop + const tmpFiles = await rewriteFileTree(fileA, null, null); + expect(tmpFiles.length).toBeGreaterThanOrEqual(2); // both a.js and b.js get temp files + }); +}); + +describe("hooks/loader-utils - maybeTranspileTypeScript", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("returns code unchanged for non-.ts files", async () => { + const { maybeTranspileTypeScript } = await import("../../src/hooks/loader-utils"); + const code = "const x = 1;"; + const result = await maybeTranspileTypeScript(code, "/path/to/file.js"); + expect(result).toBe(code); + }); + + it("calls bun build to transpile .ts files", async () => { + const { writeFile, readFile, unlink } = await import("fs/promises"); + const { spawnSync } = await import("child_process"); + + vi.mocked(writeFile).mockResolvedValue(undefined); + vi.mocked(unlink).mockResolvedValue(undefined); + const transpiledCode = "const x: number = 1; // transpiled"; + vi.mocked(readFile).mockResolvedValue(transpiledCode as any); + vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: "", stderr: "", pid: 1, output: [], signal: null, error: undefined } as any); + + const { maybeTranspileTypeScript } = await import("../../src/hooks/loader-utils"); + const tsCode = "const x: number = 1;"; + const result = await maybeTranspileTypeScript(tsCode, "/path/to/file.ts"); + + expect(vi.mocked(spawnSync)).toHaveBeenCalledWith( + "bun", + expect.arrayContaining(["build", expect.stringContaining("__failproofai_ts_src__.ts")]), + expect.objectContaining({ encoding: "utf-8" }), + ); + expect(result).toBe(transpiledCode); + }); + + it("throws when bun build fails for .ts files", async () => { + const { writeFile, unlink } = await import("fs/promises"); + const { spawnSync } = await import("child_process"); + + vi.mocked(writeFile).mockResolvedValue(undefined); + vi.mocked(unlink).mockResolvedValue(undefined); + vi.mocked(spawnSync).mockReturnValue({ status: 1, stdout: "", stderr: "syntax error", pid: 1, output: [], signal: null, error: undefined } as any); + + const { maybeTranspileTypeScript } = await import("../../src/hooks/loader-utils"); + await expect(maybeTranspileTypeScript("const x: number = 1;", "/path/to/file.ts")).rejects.toThrow( + "TypeScript transpilation failed", + ); + }); +}); diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index bd64f932..c7c1a488 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -16,10 +16,18 @@ vi.mock("node:child_process", () => ({ execSync: vi.fn(), })); +// resolveFailproofaiBinary() uses FAILPROOFAI_DIST_PATH or relative paths +// Set a dist path so it finds a predictable binary path +const MOCK_DIST_PATH = "/mock/dist"; +const MOCK_BINARY_PATH = "/mock/dist/bin/failproofai.mjs"; + vi.mock("../../src/hooks/install-prompt", () => ({ promptPolicySelection: vi.fn(() => Promise.resolve(["block-sudo", "block-env-files", "sanitize-jwt"]), ), + promptIntegrationSelection: vi.fn(() => + Promise.resolve(["claude-code"]), + ), })); vi.mock("../../src/hooks/hooks-config", () => ({ @@ -56,11 +64,13 @@ const LOCAL_SETTINGS_PATH = resolve(process.cwd(), ".claude", "settings.local.js describe("hooks/manager", () => { beforeEach(() => { vi.resetAllMocks(); + process.env.FAILPROOFAI_DIST_PATH = MOCK_DIST_PATH; vi.mocked(execSync).mockReturnValue("/usr/local/bin/failproofai\n"); vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { + delete process.env.FAILPROOFAI_DIST_PATH; vi.restoreAllMocks(); }); @@ -85,7 +95,9 @@ describe("hooks/manager", () => { expect(hook.__failproofai_hook__).toBe(true); expect(hook.type).toBe("command"); expect(hook.timeout).toBe(60_000); - expect(hook.command).toBe(`"/usr/local/bin/failproofai" --hook ${eventType}`); + expect(hook.command).toBe( + `"${MOCK_BINARY_PATH}" --hook ${eventType} --cli claude-code`, + ); } }); @@ -218,7 +230,7 @@ describe("hooks/manager", () => { expect(written.hooks.PreToolUse).toHaveLength(1); expect(written.hooks.PreToolUse[0].hooks[0].command).toBe( - '"/usr/local/bin/failproofai" --hook PreToolUse', + `"${MOCK_BINARY_PATH}" --hook PreToolUse --cli claude-code`, ); }); @@ -234,33 +246,17 @@ describe("hooks/manager", () => { expect(Object.keys(written.hooks)).toHaveLength(26); }); - it("uses 'where' on Windows and handles multi-line output", async () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - vi.mocked(execSync).mockReturnValue("C:\\Program Files\\failproofai\\failproofai.exe\nC:\\other\\failproofai.exe\n"); + it("resolves binary from FAILPROOFAI_DIST_PATH", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue("{}"); const { installHooks } = await import("../../src/hooks/manager"); await installHooks(); - expect(execSync).toHaveBeenCalledWith("where failproofai", { encoding: "utf8" }); - const [, content] = vi.mocked(writeFileSync).mock.calls[0]; const written = JSON.parse(content as string); const hook = written.hooks.PreToolUse[0].hooks[0]; - expect(hook.command).toBe('"C:\\Program Files\\failproofai\\failproofai.exe" --hook PreToolUse'); - - Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); - }); - - it("throws when failproofai binary is not found", async () => { - vi.mocked(execSync).mockImplementation(() => { - throw new Error("not found"); - }); - - const { installHooks } = await import("../../src/hooks/manager"); - await expect(installHooks()).rejects.toThrow("failproofai binary not found"); + expect(hook.command).toContain(MOCK_BINARY_PATH); }); it("default scope is user", async () => { @@ -297,7 +293,7 @@ describe("hooks/manager", () => { for (const [eventType, matchers] of Object.entries(written.hooks)) { const hook = (matchers as Array<{ hooks: Array> }>)[0].hooks[0]; - expect(hook.command).toBe(`npx -y failproofai --hook ${eventType}`); + expect(hook.command).toBe(`npx -y failproofai --hook ${eventType} --cli claude-code`); } }); @@ -312,7 +308,7 @@ describe("hooks/manager", () => { const written = JSON.parse(content as string); const hook = written.hooks.PreToolUse[0].hooks[0]; - expect(hook.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse'); + expect(hook.command).toBe(`"${MOCK_BINARY_PATH}" --hook PreToolUse --cli claude-code`); }); it("local scope uses absolute binary path, not npx", async () => { @@ -326,7 +322,7 @@ describe("hooks/manager", () => { const written = JSON.parse(content as string); const hook = written.hooks.PreToolUse[0].hooks[0]; - expect(hook.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse'); + expect(hook.command).toBe(`"${MOCK_BINARY_PATH}" --hook PreToolUse --cli claude-code`); }); it("re-install on project scope migrates absolute-path hooks to npx format", async () => { @@ -356,7 +352,7 @@ describe("hooks/manager", () => { const written = JSON.parse(content as string); expect(written.hooks.PreToolUse[0].hooks[0].command).toBe( - "npx -y failproofai --hook PreToolUse", + "npx -y failproofai --hook PreToolUse --cli claude-code", ); }); @@ -367,7 +363,7 @@ describe("hooks/manager", () => { PreToolUse: [{ hooks: [{ type: "command", - command: "npx -y failproofai --hook PreToolUse", + command: "npx -y failproofai --hook PreToolUse --cli claude-code", timeout: 60000, }], }], @@ -506,8 +502,6 @@ describe("hooks/manager", () => { "user", undefined, ); - const logs = vi.mocked(console.log).mock.calls.map((c) => c[0]); - expect(logs.some((l: unknown) => typeof l === "string" && l.includes(resolve("/tmp/my-hooks.js")))).toBe(true); }); it("clears customPoliciesPath when removeCustomHooks is true", async () => { @@ -525,8 +519,117 @@ describe("hooks/manager", () => { const [[written]] = vi.mocked(writeScopedHooksConfig).mock.calls; expect((written as unknown as Record).customPoliciesPath).toBeUndefined(); - const logs = vi.mocked(console.log).mock.calls.map((c) => c[0]); - expect(logs.some((l: unknown) => typeof l === "string" && l.includes("Custom hooks path cleared"))).toBe(true); + }); + it("installs hooks for ALL available integrations when provided an array of all INTEGRATION_TYPES", async () => { + const { INTEGRATION_TYPES } = await import("../../src/hooks/types"); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + + const { installHooks } = await import("../../src/hooks/manager"); + + // Pass all available integrations explicitly + await installHooks(["block-sudo"], "user", undefined, false, undefined, undefined, false, [...INTEGRATION_TYPES]); + + const writeCalls = vi.mocked(writeFileSync).mock.calls; + expect(writeCalls.length).toBeGreaterThanOrEqual(INTEGRATION_TYPES.length); + + const combinedContentBytes = writeCalls.map(c => c[1] as string).join(" "); + + // We expect the failproofai hook command string injected into these settings + // to correctly contain the specific `--cli ` flag for every CLI. + for (const integ of INTEGRATION_TYPES) { + expect(combinedContentBytes).toContain(`--cli ${integ}`); + } + }); + + it("prompts for integrations and installs hooks for ALL available CLIs when selected in interactive mode", async () => { + const { INTEGRATION_TYPES } = await import("../../src/hooks/types"); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + + // Update mock specially for this test to select ALL CLIs + const { promptIntegrationSelection } = await import("../../src/hooks/install-prompt"); + vi.mocked(promptIntegrationSelection).mockResolvedValueOnce([...INTEGRATION_TYPES]); + + const { installHooks } = await import("../../src/hooks/manager"); + + // undefined integrationArg triggers interactive prompt + await installHooks(["block-sudo"], "user", undefined, false, undefined, undefined, false, undefined); + + expect(promptIntegrationSelection).toHaveBeenCalled(); + + const writeCalls = vi.mocked(writeFileSync).mock.calls; + expect(writeCalls.length).toBeGreaterThanOrEqual(INTEGRATION_TYPES.length); + + const combinedContentBytes = writeCalls.map(c => c[1] as string).join(" "); + + // Verify every CLI got its respective configuration applied + for (const integ of INTEGRATION_TYPES) { + expect(combinedContentBytes).toContain(`--cli ${integ}`); + } + }); + + it("warns when Stop-event policy installed for opencode (no Stop event support)", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks( + ["require-commit-before-stop"], + "user", + undefined, + false, + undefined, + undefined, + false, + ["opencode"], + ); + + const warnCalls = vi.mocked(console.warn).mock.calls.map((c) => String(c[0])); + expect(warnCalls.some((msg) => msg.includes("Stop") && msg.includes("require-commit-before-stop"))).toBe(true); + }); + + it("does not warn about Stop events when installing for claude-code (Stop is supported)", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks( + ["require-commit-before-stop"], + "user", + undefined, + false, + undefined, + undefined, + false, + ["claude-code"], + ); + + const warnCalls = vi.mocked(console.warn).mock.calls.map((c) => String(c[0])); + expect(warnCalls.some((msg) => msg.includes("does not support a Stop event"))).toBe(false); + }); + + it("does not warn about Stop events when installing for pi (no Stop event) with non-stop policy", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks( + ["block-sudo"], + "user", + undefined, + false, + undefined, + undefined, + false, + ["pi"], + ); + + const warnCalls = vi.mocked(console.warn).mock.calls.map((c) => String(c[0])); + expect(warnCalls.some((msg) => msg.includes("does not support a Stop event"))).toBe(false); }); }); @@ -658,9 +761,7 @@ describe("hooks/manager", () => { const { removeHooks } = await import("../../src/hooks/manager"); await removeHooks(); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("No settings file found"), - ); + // No settings file means no writes (integration.removeHooksFromFile skips missing files) expect(writeFileSync).not.toHaveBeenCalled(); }); @@ -671,9 +772,7 @@ describe("hooks/manager", () => { const { removeHooks } = await import("../../src/hooks/manager"); await removeHooks(); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("No hooks found"), - ); + // Settings file exists but has no hooks — should NOT write it back (nothing changed) expect(writeFileSync).not.toHaveBeenCalled(); }); @@ -859,8 +958,8 @@ describe("hooks/manager", () => { const calls = vi.mocked(console.log).mock.calls.map((c) => c[0]); const output = calls.join("\n"); - // Should show "not installed" title - expect(output).toContain("not installed"); + // Should still render the policy table + expect(output).toContain("Status"); // Policy names as comma-separated text expect(output).toContain("sanitize-jwt"); expect(output).toContain("block-sudo"); @@ -885,7 +984,7 @@ describe("hooks/manager", () => { const calls = vi.mocked(console.log).mock.calls.map((c) => c[0]); const output = calls.join("\n"); - expect(output).toContain("Policies — not installed"); + expect(output).toContain("Status"); expect(output).toContain("policies --install"); }); @@ -959,13 +1058,10 @@ describe("hooks/manager", () => { const calls = vi.mocked(console.log).mock.calls.map((c) => c[0]); const output = calls.join("\n"); - // Multi-scope warning present - expect(output).toContain("multiple scopes"); - // Scope columns should appear - const headerLine = calls.find( - (c: unknown) => typeof c === "string" && c.includes("User") && c.includes("Project"), - ); - expect(headerLine).toBeDefined(); + // Multi-scope layout present (integration display name in title) + expect(output).toContain("Claude Code"); + // No scope columns; scopes shown as a simple summary line + expect(output).toContain("Hooks active in scopes: user, project"); }); it("multi-scope shows only installed scope columns", async () => { @@ -997,12 +1093,9 @@ describe("hooks/manager", () => { await listHooks(); const calls = vi.mocked(console.log).mock.calls.map((c) => c[0]); - const headerLine = calls.find( - (c: unknown) => typeof c === "string" && c.includes("User") && c.includes("Project"), - ); - expect(headerLine).toBeDefined(); - // Local column should NOT appear - expect(headerLine).not.toContain("Local"); + const output = calls.join("\n"); + expect(output).toContain("Hooks active in scopes: user, project"); + expect(output).not.toContain("local"); }); it("listHooks with cwd reads from that directory", async () => { diff --git a/__tests__/hooks/policy-evaluator.test.ts b/__tests__/hooks/policy-evaluator.test.ts index 1da82ae2..cce5f471 100644 --- a/__tests__/hooks/policy-evaluator.test.ts +++ b/__tests__/hooks/policy-evaluator.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { evaluatePolicies } from "../../src/hooks/policy-evaluator"; import { registerPolicy, clearPolicies } from "../../src/hooks/policy-registry"; @@ -33,14 +33,10 @@ describe("hooks/policy-evaluator", () => { registerPolicy("allow", "desc", () => ({ decision: "allow" }), { events: ["PreToolUse"] }); const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash", tool_input: { command: "ls" } }); - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny"); - expect(parsed.hookSpecificOutput.permissionDecisionReason).toBe( - "Blocked Bash by failproofai because: blocked, as per the policy configured by the user", - ); - expect(parsed.hookSpecificOutput.hookEventName).toBe("PreToolUse"); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); + expect(result.stderr).toContain("Blocked"); expect(result.policyName).toBe("blocker"); expect(result.reason).toBe("blocked"); }); @@ -53,13 +49,10 @@ describe("hooks/policy-evaluator", () => { }), { events: ["PostToolUse"] }); const result = await evaluatePolicies("PostToolUse", { tool_name: "Read" }); - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.hookEventName).toBe("PostToolUse"); - expect(parsed.hookSpecificOutput.additionalContext).toBe( - "Blocked Read by failproofai because: JWT found, as per the policy configured by the user", - ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); + expect(result.stderr).toContain("JWT found"); expect(result.policyName).toBe("jwt-scrub"); expect(result.reason).toBe("JWT found"); }); @@ -72,7 +65,8 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("SessionStart", {}); expect(result.exitCode).toBe(2); expect(result.stdout).toBe(""); - expect(result.stderr).toBe("nope"); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); + expect(result.stderr).toContain("Nope"); expect(result.reason).toBe("nope"); }); @@ -99,8 +93,8 @@ describe("hooks/policy-evaluator", () => { }, { events: ["PreToolUse"] }); const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); }); it("instruct produces additionalContext in stdout with exit code 0", async () => { @@ -112,8 +106,8 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("PreToolUse", { tool_name: "Read" }); expect(result.exitCode).toBe(0); expect(result.decision).toBe("instruct"); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.additionalContext).toContain("You should try something else"); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("You should try something else"); expect(result.policyName).toBe("advisor"); expect(result.policyNames).toEqual(["advisor"]); expect(result.reason).toBe("You should try something else"); @@ -132,8 +126,8 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }); expect(result.decision).toBe("deny"); expect(result.policyName).toBe("blocker"); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); }); it("EvaluationResult.decision is 'allow' when all allow", async () => { @@ -170,7 +164,7 @@ describe("hooks/policy-evaluator", () => { expect(result.exitCode).toBe(2); expect(result.decision).toBe("instruct"); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("MANDATORY ACTION REQUIRED"); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); expect(result.stderr).toContain("Unsatisfied intents remain"); expect(result.policyName).toBe("verify"); }); @@ -190,9 +184,9 @@ describe("hooks/policy-evaluator", () => { expect(result.policyName).toBe("first"); expect(result.policyNames).toEqual(["first", "second"]); expect(result.reason).toBe("first warning\nsecond warning"); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.additionalContext).toContain("first warning"); - expect(parsed.hookSpecificOutput.additionalContext).toContain("second warning"); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("first warning"); + expect(result.stderr).toContain("second warning"); }); describe("allow with message", () => { @@ -208,9 +202,8 @@ describe("hooks/policy-evaluator", () => { expect(result.reason).toBe("All checks passed"); expect(result.policyName).toBe("info"); expect(result.policyNames).toEqual(["info"]); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.additionalContext).toBe("Note from failproofai: All checks passed"); - expect(result.stderr).toContain("[failproofai] info: All checks passed"); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("[FailproofAI] info: All checks passed"); }); it("combines multiple allow messages with newline", async () => { @@ -228,10 +221,10 @@ describe("hooks/policy-evaluator", () => { expect(result.decision).toBe("allow"); expect(result.policyName).toBe("info1"); expect(result.policyNames).toEqual(["info1", "info2"]); - const parsed = JSON.parse(result.stdout); - expect(parsed.reason).toBe("Commit check passed\nPush check passed"); - expect(result.stderr).toContain("[failproofai] info1: Commit check passed"); - expect(result.stderr).toContain("[failproofai] info2: Push check passed"); + expect(result.stdout).toBe(""); + expect(result.reason).toBe("Commit check passed\nPush check passed"); + expect(result.stderr).toContain("[FailproofAI] info1: Commit check passed"); + expect(result.stderr).toContain("[FailproofAI] info2: Push check passed"); }); it("returns empty stdout when allow has no reason (backward-compatible)", async () => { @@ -322,9 +315,9 @@ describe("hooks/policy-evaluator", () => { enabledPolicies: ["allow-all"], policyParams: { "nonexistent-policy": { someParam: 42 } }, }; - await expect( - evaluatePolicies("PreToolUse", { tool_name: "Bash" }, undefined, config), - ).resolves.not.toThrow(); + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, undefined, config); + expect(result).toBeDefined(); + expect(result.decision).toBe("allow"); }); it("custom hooks registered with custom/ prefix receive empty params", async () => { @@ -351,8 +344,8 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("Stop", {}); expect(result.exitCode).toBe(2); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("MANDATORY ACTION REQUIRED"); - expect(result.stderr).toContain("changes not committed"); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); + expect(result.stderr).toContain("Changes not committed"); expect(result.decision).toBe("deny"); expect(result.reason).toBe("changes not committed"); }); @@ -418,11 +411,11 @@ describe("hooks/policy-evaluator", () => { expect(result.exitCode).toBe(0); expect(result.decision).toBe("allow"); expect(result.policyNames).toEqual(["wf-commit", "wf-push", "wf-pr", "wf-ci"]); - const parsed = JSON.parse(result.stdout); - expect(parsed.reason).toContain("All changes committed"); - expect(parsed.reason).toContain("All commits pushed"); - expect(parsed.reason).toContain("PR #42 exists"); - expect(parsed.reason).toContain("All CI checks passed"); + expect(result.stdout).toBe(""); + expect(result.reason).toContain("All changes committed"); + expect(result.reason).toContain("All commits pushed"); + expect(result.reason).toContain("PR #42 exists"); + expect(result.reason).toContain("All CI checks passed"); }); it("allow messages from early policies are discarded when a later policy denies", async () => { @@ -454,7 +447,7 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("Stop", {}); expect(result.decision).toBe("instruct"); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("MANDATORY ACTION REQUIRED"); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); expect(result.stderr).toContain("Please verify tests"); }); @@ -472,8 +465,8 @@ describe("hooks/policy-evaluator", () => { expect(result.decision).toBe("allow"); expect(result.policyName).toBe("informative"); expect(result.policyNames).toEqual(["informative"]); - const parsed = JSON.parse(result.stdout); - expect(parsed.reason).toBe("CI is green"); + expect(result.stdout).toBe(""); + expect(result.reason).toBe("CI is green"); }); it("policy that throws is skipped — subsequent policies still run", async () => { @@ -505,8 +498,9 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash", tool_input: { command: "git push --force" } }, undefined, config); expect(result.decision).toBe("deny"); expect(result.reason).toBe("Force-pushing is blocked. Try creating a fresh branch instead."); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain("Try creating a fresh branch instead."); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Try creating a fresh branch instead."); }); it("appends hint to deny reason for PostToolUse", async () => { @@ -522,8 +516,9 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("PostToolUse", { tool_name: "Bash" }, undefined, config); expect(result.decision).toBe("deny"); expect(result.reason).toBe("Secret detected. Remove the secret before retrying."); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.additionalContext).toContain("Remove the secret before retrying."); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Remove the secret before retrying."); }); it("appends hint to deny reason for other event types (exit 2)", async () => { @@ -539,7 +534,8 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("SessionStart", {}, undefined, config); expect(result.exitCode).toBe(2); expect(result.reason).toBe("nope. Ask admin for access."); - expect(result.stderr).toBe("nope. Ask admin for access."); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); + expect(result.stderr).toContain("Nope. Ask admin for access."); }); it("appends hint to instruct reason", async () => { @@ -555,8 +551,8 @@ describe("hooks/policy-evaluator", () => { const result = await evaluatePolicies("PreToolUse", { tool_name: "Write" }, undefined, config); expect(result.decision).toBe("instruct"); expect(result.reason).toBe("Large file detected. Consider splitting into smaller files."); - const parsed = JSON.parse(result.stdout); - expect(parsed.hookSpecificOutput.additionalContext).toContain("Consider splitting into smaller files."); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Consider splitting into smaller files."); }); it("appends hint to instruct reason on Stop event", async () => { @@ -573,7 +569,7 @@ describe("hooks/policy-evaluator", () => { expect(result.exitCode).toBe(2); expect(result.decision).toBe("instruct"); expect(result.reason).toBe("Unsatisfied intents. Run the test suite first."); - expect(result.stderr).toContain("MANDATORY ACTION REQUIRED"); + expect(result.stderr).toContain("[FailproofAI Security Stop]"); expect(result.stderr).toContain("Unsatisfied intents. Run the test suite first."); }); @@ -681,4 +677,165 @@ describe("hooks/policy-evaluator", () => { expect(result.reason).toBe("hard block. deny hint"); }); }); + + describe("integration-specific specialized paths", () => { + beforeEach(() => { + clearPolicies(); + registerPolicy("blocker", "desc", () => ({ decision: "deny", reason: "forbidden" }), { events: ["PreToolUse"] }); + }); + + it("uses original Claude style for claude-code integration", async () => { + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { integration: "claude-code" }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe("[FailproofAI] blocker: Forbidden"); + }); + + it("returns prohibited action messaging for Gemini BeforeTool", async () => { + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { integration: "gemini", hookEventName: "BeforeTool" }); + + // Gemini expects Exit 0 for clean JSON denial parsing + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("MANDATORY ACTION REQUIRED from FailproofAI"); + expect(result.hardStop).toBe(false); // Turn-level stop is non-destructive + + // Verify the structured deny mirrors the same message on stdout and stderr. + const parsed = JSON.parse(result.stdout); + expect(parsed.decision).toBe("deny"); + expect(parsed.continue).toBeUndefined(); // continue: false removed — agent explains block to user + expect(parsed.systemMessage).toContain("Action prohibited by FailproofAI"); + expect(parsed.reason).toBe(parsed.systemMessage); + expect(parsed.reason).toContain("policy: blocker"); + expect(parsed.reason).toContain("Forbidden"); + expect(result.stderr).not.toBe(parsed.systemMessage); + /* + expect(parsed.reason).not.toContain("MANDATORY ACTION REQUIRED"); // reason ≠ systemMessage + expect(parsed.reason).toContain("Forbidden"); + expect(result.stderr).toBe(parsed.systemMessage); + */ + }); + + it("Gemini AfterAgent (Stop) includes continue: false; BeforeTool does not", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + const { registerPolicy, clearPolicies } = await import("../../src/hooks/policy-registry"); + + clearPolicies(); + registerPolicy("gate", "desc", () => ({ decision: "deny", reason: "not ready" }), { + events: ["Stop", "PreToolUse"], + }); + + // AfterAgent → Stop: continue: false IS expected (spec: triggers retry with reason as new prompt) + const stopResult = await evaluatePolicies("Stop", {}, { integration: "gemini", hookEventName: "AfterAgent" }); + expect(stopResult.exitCode).toBe(0); + const stopJson = JSON.parse(stopResult.stdout); + expect(stopJson.continue).toBe(false); + expect(stopJson.decision).toBe("deny"); + + // BeforeTool → PreToolUse: continue: false must NOT be present (turn continues, agent explains) + const toolResult = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { integration: "gemini", hookEventName: "BeforeTool" }); + expect(toolResult.exitCode).toBe(0); + const toolJson = JSON.parse(toolResult.stdout); + expect(toolJson.continue).toBeUndefined(); + expect(toolJson.decision).toBe("deny"); + }); + + it("Gemini BeforeToolSelection falls back to exit code 2 (spec: no decision field)", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + const { registerPolicy, clearPolicies } = await import("../../src/hooks/policy-registry"); + + clearPolicies(); + registerPolicy("gate", "desc", () => ({ decision: "deny", reason: "blocked" }), { + events: ["PreToolUse"], + }); + + const result = await evaluatePolicies( + "PreToolUse", + { tool_name: "Bash" }, + { integration: "gemini", hookEventName: "BeforeToolSelection" }, + ); + expect(result.exitCode).toBe(2); // stdout empty → exit code 2 + expect(result.stdout).toBe(""); // no JSON: spec says decision field is unsupported + expect(result.stderr).toContain("MANDATORY ACTION REQUIRED from FailproofAI"); + }); + + it("Gemini BeforeTool and AfterTool use different deny messaging by phase", async () => { + clearPolicies(); + registerPolicy("blocker", "desc", () => ({ decision: "deny", reason: "forbidden" }), { + events: ["PreToolUse", "PostToolUse"], + }); + + const before = await evaluatePolicies( + "PreToolUse", + { tool_name: "Bash" }, + { integration: "gemini", hookEventName: "BeforeTool" }, + ); + const beforeParsed = JSON.parse(before.stdout); + expect(beforeParsed.systemMessage).toContain("Action prohibited by FailproofAI"); + expect(beforeParsed.reason).toBe(beforeParsed.systemMessage); + expect(beforeParsed.continue).toBeUndefined(); + + const after = await evaluatePolicies( + "PostToolUse", + { tool_name: "Bash" }, + { integration: "gemini", hookEventName: "AfterTool" }, + ); + const afterParsed = JSON.parse(after.stdout); + + expect(afterParsed.systemMessage).toContain("MANDATORY ACTION REQUIRED from FailproofAI"); + expect(afterParsed.reason).toBe(afterParsed.systemMessage); + expect(afterParsed.continue).toBeUndefined(); + expect(afterParsed.reason).toContain("Forbidden"); + }); + + it("uses IDE specialized style and flags hard stop for cursor integration", async () => { + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { integration: "cursor" }); + + // Cursor expects Exit 0 for clean JSON denial parsing + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(result.hardStop).toBe(false); // Turn-level stop is non-destructive + + // Verify Real Deny JSON for Cursor + const parsed = JSON.parse(result.stdout); + expect(parsed.continue).toBe(false); + expect(parsed.permission).toBe("deny"); + }); + + it("flags hard stop for gemini/cursor on terminal events (Stop)", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + const { registerPolicy, clearPolicies } = await import("../../src/hooks/policy-registry"); + + clearPolicies(); + registerPolicy("block-sudo", "deny sudo", async () => ({ decision: "deny", reason: "no sudo" }), { events: ["Stop", "PostToolUse"] }); + + // Gemini Stop hook -> Terminal (Kill) + const geminiStop = await evaluatePolicies("Stop", {}, { integration: "gemini" }); + expect(geminiStop.hardStop).toBe(true); + + // Gemini PostToolUse hook -> Turn-level (Stay) + const geminiPost = await evaluatePolicies("PostToolUse", {}, { integration: "gemini" }); + expect(geminiPost.hardStop).toBe(false); + + // Cursor Stop hook -> Terminal (Kill) + const cursorStop = await evaluatePolicies("Stop", {}, { integration: "cursor" }); + expect(cursorStop.hardStop).toBe(true); + + // Cursor PostToolUse hook -> Safety-level (Kill) + const cursorPost = await evaluatePolicies("PostToolUse", {}, { integration: "cursor" }); + expect(cursorPost.hardStop).toBe(true); + }); + + it("uses IDE specialized style for copilot integration", async () => { + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { integration: "copilot" }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).permissionDecision).toBe("deny"); + expect(result.stderr).toContain("ACTION BLOCKED BY FAILPROOFAI"); + expect(result.stderr).toContain("Forbidden"); + }); + + it("uses default specialized style for unknown integration", async () => { + const result = await evaluatePolicies("PreToolUse", { tool_name: "Bash" }, { integration: "pi" as any }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe("[FailproofAI Security Stop] Policy: blocker - Forbidden"); + }); + }); }); diff --git a/__tests__/lib/log-entries.test.ts b/__tests__/lib/log-entries.test.ts index de6fff51..8241897c 100644 --- a/__tests__/lib/log-entries.test.ts +++ b/__tests__/lib/log-entries.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from "vitest"; -import { parseLogContent, parseRawLines } from "@/lib/log-entries"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parseLogContent, parseRawLines, parseSessionLog } from "@/lib/log-entries"; +import { _resetForTest as resetHookStoreForTest, persistHookActivity } from "@/src/hooks/hook-activity-store"; import type { UserEntry, AssistantEntry, GenericEntry, QueueOperationEntry } from "@/lib/log-entries"; // Helper to create a JSONL line @@ -7,6 +11,20 @@ function line(obj: Record): string { return JSON.stringify(obj); } +let tempRoot = ""; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "failproofai-log-entries-")); +}); + +afterEach(() => { + delete process.env.CLAUDE_PROJECTS_PATH; + delete process.env.COPILOT_SESSION_STATE_PATH; + resetHookStoreForTest(); + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = ""; +}); + describe("parseLogContent", () => { describe("basic parsing", () => { it("parses a single user entry", async () => { @@ -580,6 +598,387 @@ describe("parseLogContent", () => { } }); }); + + describe("Copilot dashboard visibility", () => { + it("maps Copilot user prompt activity entries into user-visible log lines", async () => { + const content = line({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "UserPromptSubmit", + sessionId: "copilot-session-1", + integration: "copilot", + toolInput: { prompt: "Explain this repo" }, + }); + + const entries = await parseLogContent(content); + expect(entries).toHaveLength(1); + const entry = entries[0] as UserEntry; + expect(entry.type).toBe("user"); + expect(entry.message.content).toBe("Explain this repo"); + }); + + it("maps Copilot lifecycle activity entries into assistant text for the session view", async () => { + const content = line({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "SessionStart", + sessionId: "copilot-session-2", + integration: "copilot", + }); + + const entries = await parseLogContent(content); + expect(entries).toHaveLength(1); + const entry = entries[0] as AssistantEntry; + expect(entry.type).toBe("assistant"); + expect(entry.message.content[0].type).toBe("text"); + if (entry.message.content[0].type === "text") { + expect(entry.message.content[0].text).toContain("copilot"); + } + }); + + it("loads Copilot session logs from the session-state UUID folder", async () => { + const sessionId = "11111111-2222-3333-4444-555555555555"; + const claudeRoot = join(tempRoot, ".claude", "projects"); + const copilotRoot = join(tempRoot, ".copilot", "session-state"); + const sessionDir = join(copilotRoot, sessionId); + + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, "events.jsonl"), + [ + line({ + type: "user", + uuid: "u1", + parentUuid: null, + timestamp: "2024-06-15T12:00:00.000Z", + message: { role: "user", content: "hello from copilot" }, + }), + line({ + type: "assistant", + uuid: "a1", + parentUuid: "u1", + timestamp: "2024-06-15T12:00:01.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "copilot reply" }], + }, + }), + ].join("\n"), + "utf8", + ); + + process.env.CLAUDE_PROJECTS_PATH = claudeRoot; + process.env.COPILOT_SESSION_STATE_PATH = copilotRoot; + + const result = await parseSessionLog(sessionId, sessionId); + + expect(result.entries).toHaveLength(2); + expect(result.sourceMode).toBe("native"); + expect(result.entries[0].type).toBe("user"); + expect(result.entries[1].type).toBe("assistant"); + }); + + it("serializes structured toolOutput from activity entries for dashboard rendering", async () => { + const content = line({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "PostToolUse", + sessionId: "cursor-session-structured-output", + integration: "cursor", + toolName: "Read", + toolInput: { file_path: "/tmp/a.txt" }, + toolOutput: { lines: ["a", "b"], count: 2 }, + }); + + const entries = await parseLogContent(content); + expect(entries).toHaveLength(1); + const entry = entries[0] as AssistantEntry; + expect(entry.type).toBe("assistant"); + expect(entry.message.content[0].type).toBe("tool_use"); + if (entry.message.content[0].type === "tool_use") { + expect(entry.message.content[0].result?.content).toContain("\"count\": 2"); + } + }); + + it("falls back to activity-store entries when native transcript is unavailable", async () => { + const hookStoreDir = join(tempRoot, ".failproofai", "cache", "hook-activity"); + resetHookStoreForTest(hookStoreDir); + process.env.CLAUDE_PROJECTS_PATH = join(tempRoot, ".claude", "projects"); + mkdirSync(process.env.CLAUDE_PROJECTS_PATH, { recursive: true }); + + persistHookActivity({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "UserPromptSubmit", + toolName: null, + policyName: null, + decision: "allow", + reason: null, + durationMs: 1, + sessionId: "cursor-s1", + integration: "cursor", + cwd: "/tmp/workspace", + toolInput: { prompt: "Hello from fallback" }, + }); + + const result = await parseSessionLog("-tmp-workspace", "cursor-s1"); + expect(result.sourceMode).toBe("fallback"); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].type).toBe("user"); + }); + + it("prefers transcriptPath native source over mirrored .claude session file for virtual integrations", async () => { + const hookStoreDir = join(tempRoot, ".failproofai", "cache", "hook-activity"); + resetHookStoreForTest(hookStoreDir); + const claudeRoot = join(tempRoot, ".claude", "projects"); + process.env.CLAUDE_PROJECTS_PATH = claudeRoot; + const projectName = "-tmp-workspace"; + const projectDir = join(claudeRoot, projectName); + mkdirSync(projectDir, { recursive: true }); + + // Mirrored file exists but is incomplete. + writeFileSync( + join(projectDir, "cursor-s4.jsonl"), + line({ + type: "assistant", + uuid: "a-mirror", + parentUuid: null, + timestamp: "2024-06-15T12:00:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "mirror content" }] }, + }), + "utf8", + ); + + const nativeTranscriptPath = join(tempRoot, "cursor-native.json"); + writeFileSync( + nativeTranscriptPath, + JSON.stringify([ + { role: "user", timestamp: "2024-06-15T12:00:01.000Z", content: "from native transcript" }, + ]), + "utf8", + ); + + persistHookActivity({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "SessionStart", + toolName: null, + policyName: null, + decision: "allow", + reason: null, + durationMs: 1, + sessionId: "cursor-s4", + integration: "cursor", + cwd: "/tmp/workspace", + transcriptPath: nativeTranscriptPath, + }); + + const result = await parseSessionLog(projectName, "cursor-s4"); + expect(result.sourceMode).toBe("native"); + expect(result.sourceDetail).toBe(nativeTranscriptPath); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].type).toBe("user"); + }); + + it("falls back to activity-store when transcriptPath exists but native parse fails", async () => { + const hookStoreDir = join(tempRoot, ".failproofai", "cache", "hook-activity"); + resetHookStoreForTest(hookStoreDir); + process.env.CLAUDE_PROJECTS_PATH = join(tempRoot, ".claude", "projects"); + mkdirSync(process.env.CLAUDE_PROJECTS_PATH, { recursive: true }); + + const badTranscriptPath = join(tempRoot, "bad-transcript.json"); + writeFileSync(badTranscriptPath, "{not valid json", "utf8"); + + persistHookActivity({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "UserPromptSubmit", + toolName: null, + policyName: null, + decision: "allow", + reason: null, + durationMs: 1, + sessionId: "cursor-s2", + integration: "cursor", + cwd: "/tmp/workspace", + transcriptPath: badTranscriptPath, + toolInput: { prompt: "Fallback after parse fail" }, + }); + + const result = await parseSessionLog("-tmp-workspace", "cursor-s2"); + expect(result.sourceMode).toBe("fallback"); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].type).toBe("user"); + }); + + it("parses native JSON transcript arrays and ignores malformed role/timestamp records", async () => { + const hookStoreDir = join(tempRoot, ".failproofai", "cache", "hook-activity"); + resetHookStoreForTest(hookStoreDir); + process.env.CLAUDE_PROJECTS_PATH = join(tempRoot, ".claude", "projects"); + mkdirSync(process.env.CLAUDE_PROJECTS_PATH, { recursive: true }); + + const jsonTranscriptPath = join(tempRoot, "native-transcript.json"); + writeFileSync( + jsonTranscriptPath, + JSON.stringify([ + { role: "user", timestamp: "1718452800000", content: "hello" }, + { role: "assistant", timestamp: "2024-06-15T12:00:01.000Z", content: { ok: true } }, + { role: "system", timestamp: "2024-06-15T12:00:02.000Z", content: "ignore me" }, + { role: "assistant", content: "missing timestamp ignored" }, + ]), + "utf8", + ); + + persistHookActivity({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "SessionStart", + toolName: null, + policyName: null, + decision: "allow", + reason: null, + durationMs: 1, + sessionId: "cursor-s3", + integration: "cursor", + cwd: "/tmp/workspace", + transcriptPath: jsonTranscriptPath, + }); + + const result = await parseSessionLog("-tmp-workspace", "cursor-s3"); + expect(result.sourceMode).toBe("native"); + expect(result.entries).toHaveLength(2); + expect(result.entries[0].type).toBe("user"); + expect(result.entries[1].type).toBe("assistant"); + if (result.entries[1].type === "assistant") { + expect(result.entries[1].message.content[0].type).toBe("text"); + } + }); + + it("uses known Cursor transcript location when transcriptPath metadata is missing", async () => { + const hookStoreDir = join(tempRoot, ".failproofai", "cache", "hook-activity"); + resetHookStoreForTest(hookStoreDir); + const homeDir = join(tempRoot, "home"); + process.env.HOME = homeDir; + process.env.CLAUDE_PROJECTS_PATH = join(tempRoot, ".claude", "projects"); + + const projectName = "-tmp-workspace"; + const sessionId = "cursor-s5"; + const cursorTranscriptDir = join( + homeDir, + ".cursor", + "projects", + "tmp-workspace", + "agent-transcripts", + sessionId, + ); + mkdirSync(cursorTranscriptDir, { recursive: true }); + writeFileSync( + join(cursorTranscriptDir, `${sessionId}.jsonl`), + line({ + type: "user", + uuid: "u-native", + parentUuid: null, + timestamp: "2024-06-15T12:00:00.000Z", + message: { role: "user", content: "cursor native transcript" }, + }), + "utf8", + ); + + persistHookActivity({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "SessionStart", + toolName: null, + policyName: null, + decision: "allow", + reason: null, + durationMs: 1, + sessionId, + integration: "cursor", + cwd: "/tmp/workspace", + }); + + const result = await parseSessionLog(projectName, sessionId); + expect(result.sourceMode).toBe("native"); + expect(result.sourceDetail).toContain(`/${sessionId}.jsonl`); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].type).toBe("user"); + }); + + it("prefers mirrored JSONL with tool pairing over activity-store fallback for virtual integrations", async () => { + const hookStoreDir = join(tempRoot, ".failproofai", "cache", "hook-activity"); + resetHookStoreForTest(hookStoreDir); + process.env.CLAUDE_PROJECTS_PATH = join(tempRoot, ".claude", "projects"); + + const projectName = "-tmp-workspace"; + const sessionId = "cursor-with-tool"; + const projectDir = join(process.env.CLAUDE_PROJECTS_PATH, projectName); + mkdirSync(projectDir, { recursive: true }); + + // Write mirrored JSONL with proper tool pre/post pairing (as writeVirtualLogEntry does) + const mirroredJsonl = [ + line({ + type: "assistant", + uuid: "tool-1", + parentUuid: null, + timestamp: "2024-06-15T12:00:00.000Z", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_virt_123", + name: "Read", + input: { file_path: "/test.txt" }, + }, + ], + }, + }), + line({ + type: "user", + uuid: "result-1", + parentUuid: null, + timestamp: "2024-06-15T12:00:01.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_virt_123", + content: "file contents", + }, + ], + }, + }), + ].join("\n"); + + writeFileSync(join(projectDir, `${sessionId}.jsonl`), mirroredJsonl, "utf8"); + + // Also add a matching activity entry (without transcriptPath to trigger fallback path) + persistHookActivity({ + timestamp: Date.parse("2024-06-15T12:00:00.000Z"), + eventType: "PreToolUse", + toolName: "Read", + toolInput: { file_path: "/test.txt" }, + policyName: null, + decision: "allow", + reason: null, + durationMs: 10, + sessionId, + integration: "cursor", + cwd: "/tmp/workspace", + }); + + const result = await parseSessionLog(projectName, sessionId); + + // Should use mirrored JSONL (native source), not activity-store fallback + expect(result.sourceMode).toBe("native"); + expect(result.entries).toHaveLength(1); + + // First entry: assistant with tool_use + const toolEntry = result.entries[0]; + expect(toolEntry.type).toBe("assistant"); + if (toolEntry.type === "assistant" && toolEntry.message.content[0]?.type === "tool_use") { + const toolBlock = toolEntry.message.content[0]; + expect(toolBlock.name).toBe("Read"); + // The key assertion: result should be populated (not undefined) + expect(toolBlock.result).toBeDefined(); + expect(toolBlock.result?.content).toContain("file contents"); + } + }); + }); }); describe("parseRawLines", () => { diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index a713821f..60d0543a 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -9,19 +9,27 @@ vi.mock("fs/promises", () => ({ vi.mock("@/lib/paths", () => ({ getClaudeProjectsPath: vi.fn(() => "/mock/.claude/projects"), + getCopilotSessionStatePath: vi.fn(() => "/mock/.copilot/session-state"), + getOpencodeStoragePath: vi.fn(() => "/mock/.local/share/opencode/storage"), + decodeFolderName: vi.fn((name: string) => name.replace(/-/g, "/").replace(/^C\//, "C:/")), + encodeCwd: vi.fn((cwd: string) => cwd.replace(/\//g, "-")), })); vi.mock("@/lib/utils", () => ({ formatDate: vi.fn((d: Date) => d.toISOString()), })); -vi.mock("@/lib/runtime-cache", () => ({ - runtimeCache: vi.fn((fn: (...args: unknown[]) => unknown) => fn), +vi.mock("../../src/hooks/hook-activity-store", () => ({ + getAllHookActivityEntries: vi.fn(() => []), + persistHookActivity: vi.fn(), + trackHookEvent: vi.fn(), })); import { readdir, stat } from "fs/promises"; -import { extractSessionId, getProjectFolders, getSessionFiles } from "@/lib/projects"; +import { extractSessionId, getProjectFolders, getSessionFiles, resolveAnyProjectPath } from "@/lib/projects"; +import { getAllHookActivityEntries } from "../../src/hooks/hook-activity-store"; +const mockGetAllActivity = vi.mocked(getAllHookActivityEntries); const mockReaddir = vi.mocked(readdir); const mockStat = vi.mocked(stat); @@ -49,21 +57,26 @@ describe("getProjectFolders", () => { }); it("returns empty array when directory doesn't exist", async () => { - mockStat.mockRejectedValueOnce(new Error("ENOENT")); + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // Claude + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // Copilot + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // opencode const result = await getProjectFolders(); expect(result).toEqual([]); }); it("returns empty array when path is not a directory", async () => { - mockStat.mockResolvedValueOnce({ - isDirectory: () => false, - } as any); + mockStat.mockResolvedValueOnce({ isDirectory: () => false } as any); // Claude + mockStat.mockResolvedValueOnce({ isDirectory: () => false } as any); // Copilot + mockStat.mockResolvedValueOnce({ isDirectory: () => false } as any); // opencode const result = await getProjectFolders(); expect(result).toEqual([]); }); it("returns only directories (not files)", async () => { + // Claude root stat, Copilot root stat, opencode root stat mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // Copilot root not found + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // opencode root not found mockReaddir.mockResolvedValueOnce([ { name: "project-a", isDirectory: () => true, isFile: () => false } as any, { name: "file.txt", isDirectory: () => false, isFile: () => true } as any, @@ -81,7 +94,10 @@ describe("getProjectFolders", () => { }); it("sorts newest-first by mtime", async () => { + // Claude root stat, Copilot root stat, opencode root stat mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // Copilot root not found + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // opencode root not found mockReaddir.mockResolvedValueOnce([ { name: "old", isDirectory: () => true, isFile: () => false } as any, { name: "new", isDirectory: () => true, isFile: () => false } as any, @@ -96,7 +112,10 @@ describe("getProjectFolders", () => { }); it("uses fallback Date(0) when individual stat fails", async () => { + // Claude root stat, Copilot root stat, opencode root stat mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // Copilot root not found + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // opencode root not found mockReaddir.mockResolvedValueOnce([ { name: "broken", isDirectory: () => true, isFile: () => false } as any, ] as any); @@ -106,6 +125,26 @@ describe("getProjectFolders", () => { expect(result).toHaveLength(1); expect(result[0].lastModified.getTime()).toBe(0); }); + + it("includes Copilot UUID session folders as projects", async () => { + const sessionId = "11111111-2222-3333-4444-555555555555"; + + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // Claude root missing + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); // Copilot root exists + mockStat.mockRejectedValueOnce(new Error("ENOENT")); // opencode root missing + mockReaddir.mockResolvedValueOnce([ + { name: sessionId, isDirectory: () => true, isFile: () => false } as any, + { name: "not-a-session", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: new Date("2024-06-20T00:00:00Z") } as any); + + const result = await getProjectFolders(); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe(sessionId); + expect(result[0].source).toBe("copilot"); + expect(result[0].sources).toEqual(["copilot"]); + }); }); describe("getSessionFiles", () => { @@ -173,7 +212,64 @@ describe("getSessionFiles", () => { it("returns empty array for missing directory", async () => { mockStat.mockRejectedValueOnce(new Error("ENOENT")); + mockGetAllActivity.mockReturnValueOnce([]); const result = await getSessionFiles("/nonexistent"); expect(result).toEqual([]); }); + + it("returns Copilot events.jsonl as a session file when the project path is a UUID directory", async () => { + const sessionId = "11111111-2222-3333-4444-555555555555"; + const projectPath = `/mock/.copilot/session-state/${sessionId}`; + + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "events.jsonl", isFile: () => true, isDirectory: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: new Date("2024-06-21T00:00:00Z") } as any); + mockGetAllActivity.mockReturnValueOnce([]); + + const result = await getSessionFiles(projectPath); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + name: "events.jsonl", + sessionId, + }), + ); + }); +}); + +describe("resolveAnyProjectPath", () => { + it("routes UUID-shaped names to Copilot, not Claude projects", () => { + const uuid = "86a5848b-fa06-45d2-8932-5a228ac59567"; + const result = resolveAnyProjectPath(uuid); + + expect(result.source).toBe("copilot"); + expect(result.path).toContain(".copilot/session-state"); + expect(result.path).toContain(uuid); + }); + + it("routes ses_-prefixed names to opencode", () => { + const sessionId = "ses_abc123"; + const result = resolveAnyProjectPath(sessionId); + + expect(result.source).toBe("opencode"); + expect(result.path).toContain("opencode/storage/session_diff"); + expect(result.path).toContain(sessionId); + }); + + it("routes encoded CWD names (starting with -) to Claude projects", () => { + const projectName = "-home-user-myproject"; + const result = resolveAnyProjectPath(projectName); + + expect(result.source).toBe("claude-code"); + expect(result.path).toContain(".claude/projects"); + expect(result.path).toContain(projectName); + }); + + it("throws RangeError for invalid project names", () => { + expect(() => resolveAnyProjectPath("")).toThrow(RangeError); + expect(() => resolveAnyProjectPath("../../etc/passwd")).toThrow(RangeError); + }); }); diff --git a/app/actions/get-hook-activity.ts b/app/actions/get-hook-activity.ts index b4925b5a..ed777ef1 100644 --- a/app/actions/get-hook-activity.ts +++ b/app/actions/get-hook-activity.ts @@ -3,6 +3,7 @@ import { getHookActivityHistory, searchHookActivity, + migrateIntegrationField, type HookActivityEntry, type HookActivityFilters, type HookActivityStats, @@ -16,6 +17,7 @@ export interface HookActivityPayload { } export async function getHookActivityAction(page: number): Promise { + migrateIntegrationField(); return getHookActivityHistory(page); } @@ -23,5 +25,6 @@ export async function searchHookActivityAction( filters: HookActivityFilters, page: number, ): Promise { + migrateIntegrationField(); return searchHookActivity(filters, page); } diff --git a/app/components/project-list.tsx b/app/components/project-list.tsx index 6a7ed4ed..d3a7484c 100644 --- a/app/components/project-list.tsx +++ b/app/components/project-list.tsx @@ -22,8 +22,9 @@ import { keywordsToParam, paramToKeywords, pageToParam, paramToPage, } from "@/lib/url-filter-serializers"; -import { Folder, Search, X } from "lucide-react"; +import { Calendar, Folder, Search, X, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; import Link from "next/link"; +import { IntegrationBadge } from "@/components/integration-badge"; import PaginationControls from "./pagination-controls"; import DatePickerInput from "./date-picker-input"; @@ -32,12 +33,12 @@ interface ProjectListProps { folders: ProjectFolder[]; } +// Replace `/` with `-` so users can search by filesystem path (e.g. "/home/user") +// and still match the encoded folder name (e.g. "-home-user"). function DateDisplay({ date, formatted }: { date: Date; formatted?: string }) { return {formatted || formatDate(date)}; } -// Replace `/` with `-` so users can search by filesystem path (e.g. "/home/user") -// and still match the encoded folder name (e.g. "-home-user"). function normalizeKeywordForSearch(keyword: string): string { return keyword.trim().toLowerCase().replace(/\//g, "-"); } @@ -274,6 +275,9 @@ export default function ProjectList({ folders }: ProjectListProps) { Path + + Integration + Last Modified @@ -306,6 +310,13 @@ export default function ProjectList({ folders }: ProjectListProps) { {folder.path} + +
+ {(folder.sources || []).map((s) => ( + + ))} +
+