diff --git a/CLAUDE.md b/CLAUDE.md index 2e84b5f1..3e77ec68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -787,6 +787,67 @@ For production users the recommended Goose install is: failproofai policies --install --cli goose --scope project ``` +### AdaL hooks (`~/.adal/settings.json`) + +AdaL (`@sylphai/adal-cli`) is the **only integration that needs no event map and +no `canonicalizeEventType` branch**. Its hook contract is deliberately +Claude-shaped, so the `adal` Integration in `integrations.ts` mirrors +`claudeCode` rather than inventing a new writer. + +**Settings file paths:** + +| Scope | Path | +|---------|-------------------------------| +| user | `~/.adal/settings.json` | +| project | `/.adal/settings.json` | + +There is no `local` scope (AdaL has no `settings.local.json` equivalent), so +`adal.scopes` is `["user", "project"]`. + +**Events are already canonical.** AdaL fires exactly six lifecycle events and +every one is spelled identically to its `HOOK_EVENT_TYPES` entry, so AdaL falls +through `canonicalizeEventType`'s "already PascalCase, pass through" tail: + +| Event | Enforceable? | +|----------------------|--------------| +| `PreToolUse` | ✅ can block | +| `UserPromptSubmit` | ✅ can block | +| `PostToolUse` | ❌ observation only | +| `PostToolUseFailure` | ❌ observation only | +| `PermissionRequest` | ❌ observation only | +| `Stop` | ❌ observation only | + +**Only `PreToolUse` and `UserPromptSubmit` can prevent an action.** The other +four fire after the action has already run, so a `deny` there cannot reverse it +— policies matching those events are advisory (same shape as Factory/Hermes on +non-`Stop` events). Do not write a post-action policy expecting it to block. + +**Hook entry shape** — `timeout` is in SECONDS (like Claude, not milliseconds): + +```json +{ + "hooks": { + "PreToolUse": [ + { "hooks": [{ "type": "command", "command": "failproofai --hook PreToolUse --cli adal", "timeout": 60 }] } + ] + } +} +``` + +Install is idempotent and preserves both unrelated settings keys and +pre-existing user hooks; uninstall removes only marker-owned entries. + +**Tool names** are snake_case internally, so `ADAL_TOOL_MAP` maps them onto the +Claude vocabulary (`bash`→`Bash`, `read_file`→`Read`, `create_file`→`Write`, +`replace_by_string`→`Edit`, …) and `ADAL_TOOL_INPUT_MAP` maps `create_file`'s +`new_string` body onto `content`. Unknown names — including `mcp__*` — pass +through unchanged. + +**Audit pillar: none yet.** AdaL persists no replayable tool-event log +(`~/.adal/sessions//` holds only side-artifacts; `~/.adal/adal.db` has no +tables), so `src/audit/cli-adapters/adal.ts` is inert by design and returns `[]` +from both functions. See that module's header for the rationale. + ### Dogfood configs for Factory / Devin / Antigravity / Goose Like the Codex / Cursor / OpenCode / Pi setups above, this repo ships diff --git a/README.md b/README.md index 82a0e6a1..6bf8be0f 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ before they become incidents. Zero latency. Runs locally. ## Supported agent CLIs + so the grid stays 6-per-row at any window width (scrolling on very narrow + screens instead of collapsing into ragged orphan rows). Rows are filled to + six before a new row is started. --> + + +
@@ -117,6 +118,13 @@ before they become incidents. Zero latency. Runs locally.
+ + AdaL + +
## Install diff --git a/__tests__/bin/valid-clis.test.ts b/__tests__/bin/valid-clis.test.ts new file mode 100644 index 00000000..c122ff38 --- /dev/null +++ b/__tests__/bin/valid-clis.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +/** + * Guards the CLI allowlist in bin/failproofai.mjs against drift. + * + * The allowlist used to be duplicated once per subcommand parser (--hook, + * policies --install, policies --uninstall, policy add/remove). Adding a new CLI + * to only some copies made it installable but NOT removable, which is how the + * AdaL integration first shipped. It is now a single module-scope `VALID_CLIS` + * shared by every parser; these tests fail if anyone reintroduces a local copy + * or lets the list drift from INTEGRATION_TYPES. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { INTEGRATION_TYPES } from "../../src/hooks/types"; + +const BIN_SOURCE = readFileSync(resolve(__dirname, "../../bin/failproofai.mjs"), "utf-8"); + +describe("bin/failproofai.mjs CLI allowlist", () => { + it("declares exactly one VALID_CLIS set (no per-parser copies)", () => { + const declarations = BIN_SOURCE.match(/const VALID_CLIS = new Set\(/g) ?? []; + expect(declarations).toHaveLength(1); + }); + + it("covers every INTEGRATION_TYPES entry", () => { + const block = BIN_SOURCE.split("const VALID_CLIS = new Set(")[1]?.split("]);")[0] ?? ""; + for (const cli of INTEGRATION_TYPES) { + expect(block).toContain(`"${cli}"`); + } + }); + + it("is consulted by every subcommand parser that accepts --cli", () => { + // install, uninstall, and policy add/remove each guard with VALID_CLIS.has. + const uses = BIN_SOURCE.match(/VALID_CLIS\.has\(/g) ?? []; + expect(uses.length).toBeGreaterThanOrEqual(3); + }); + + it("accepts adal in the --hook parser as well", () => { + expect(BIN_SOURCE).toContain('cliArg === "adal"'); + }); +}); diff --git a/__tests__/hooks/adal-canonicalize.test.ts b/__tests__/hooks/adal-canonicalize.test.ts new file mode 100644 index 00000000..fba58286 --- /dev/null +++ b/__tests__/hooks/adal-canonicalize.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +/** + * AdaL payload canonicalization. + * + * AdaL is the only integration that needs NO event map: its lifecycle event + * names are already the canonical PascalCase forms, so they must survive + * canonicalization byte-for-byte. Tool names, however, are snake_case + * internally and must map onto the Claude vocabulary that builtin policies + * match on (Bash / Read / Write / Edit / …). + */ +import { describe, it, expect } from "vitest"; +import { canonicalizeToolName, canonicalizeToolInput } from "../../src/hooks/tool-name-canonicalize"; +import { ADAL_TOOL_MAP, HOOK_EVENT_TYPES } from "../../src/hooks/types"; +import { adal } from "../../src/hooks/integrations"; + +describe("AdaL event names need no mapping", () => { + it("every event AdaL fires is already a canonical HookEventType", () => { + for (const event of adal.eventTypes) { + expect(HOOK_EVENT_TYPES).toContain(event); + } + }); +}); + +describe("canonicalizeToolName(cli: adal)", () => { + it("maps AdaL shell and search tools onto the Claude vocabulary", () => { + expect(canonicalizeToolName("bash", "adal")).toBe("Bash"); + expect(canonicalizeToolName("grep", "adal")).toBe("Grep"); + expect(canonicalizeToolName("glob", "adal")).toBe("Glob"); + }); + + it("maps every AdaL file tool onto Read/Write/Edit", () => { + expect(canonicalizeToolName("read_file", "adal")).toBe("Read"); + expect(canonicalizeToolName("read_image", "adal")).toBe("Read"); + expect(canonicalizeToolName("write_file", "adal")).toBe("Write"); + expect(canonicalizeToolName("create_file", "adal")).toBe("Write"); + expect(canonicalizeToolName("rewrite_file", "adal")).toBe("Write"); + expect(canonicalizeToolName("replace_by_string", "adal")).toBe("Edit"); + expect(canonicalizeToolName("delete_lines", "adal")).toBe("Edit"); + }); + + it("maps AdaL web tools", () => { + expect(canonicalizeToolName("fetch_url", "adal")).toBe("WebFetch"); + expect(canonicalizeToolName("web_search", "adal")).toBe("WebSearch"); + }); + + it("passes unknown and MCP tool names through unchanged", () => { + expect(canonicalizeToolName("mcp__my-server__exec", "adal")).toBe("mcp__my-server__exec"); + expect(canonicalizeToolName("some_future_tool", "adal")).toBe("some_future_tool"); + }); + + it("leaves undefined alone", () => { + expect(canonicalizeToolName(undefined, "adal")).toBeUndefined(); + }); + + it("every ADAL_TOOL_MAP target is a name builtin policies recognise", () => { + for (const canonical of Object.values(ADAL_TOOL_MAP)) { + expect(canonical[0]).toBe(canonical[0].toUpperCase()); + } + }); +}); + +describe("canonicalizeToolInput(cli: adal)", () => { + it("maps create_file's new_string body onto content so write builtins fire", () => { + const out = canonicalizeToolInput("Write", { file_path: "/tmp/a.txt", new_string: "secret" }, "adal"); + expect(out).toEqual({ file_path: "/tmp/a.txt", content: "secret" }); + }); + + it("leaves already-canonical file paths untouched", () => { + const input = { file_path: "/tmp/a.txt" }; + expect(canonicalizeToolInput("Read", input, "adal")).toEqual(input); + }); + + it("leaves bash command input untouched", () => { + const input = { command: "ls -la" }; + expect(canonicalizeToolInput("Bash", input, "adal")).toEqual(input); + }); + + it("passes arrays and non-objects through verbatim", () => { + expect(canonicalizeToolInput("Write", ["a", "b"], "adal")).toEqual(["a", "b"]); + expect(canonicalizeToolInput("Write", undefined, "adal")).toBeUndefined(); + }); + + it("is idempotent when input is already canonical", () => { + const once = canonicalizeToolInput("Write", { content: "x" }, "adal"); + expect(canonicalizeToolInput("Write", once, "adal")).toEqual({ content: "x" }); + }); +}); diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index e6de55f8..36a163ac 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -141,9 +141,9 @@ describe("hooks/install-prompt", () => { "install", ); - // 1 aggregate "all" + 2 detected + 10 undetected - expect(options).toHaveLength(13); - expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + // 1 aggregate "all" + 2 detected + 11 undetected + expect(options).toHaveLength(14); + expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"]); expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); expect(options[0].label).toBe("Install for all 2 detected"); @@ -168,6 +168,7 @@ describe("hooks/install-prompt", () => { "Devin CLI", "Antigravity CLI", "Goose", + "AdaL", ]); }); @@ -184,16 +185,16 @@ describe("hooks/install-prompt", () => { expect(options.every((o) => o.detected)).toBe(true); }); - it("install with all 12 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { + it("install with all 13 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); const { options, undetected } = buildCliMenuOptions( - ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"], + ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"], "install", ); expect(undetected).toEqual([]); - expect(options).toHaveLength(13); // aggregate + 12 detected - expect(options[0].label).toBe("Install for all 12 detected"); + expect(options).toHaveLength(14); // aggregate + 13 detected + expect(options[0].label).toBe("Install for all 13 detected"); }); it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index 8f247a2b..b4496b06 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -26,6 +26,7 @@ import { devin, antigravity, goose, + adal, getIntegration, listIntegrations, } from "../../src/hooks/integrations"; @@ -79,7 +80,7 @@ afterEach(() => { describe("integrations registry", () => { it("listIntegrations returns claude, codex, copilot, cursor, opencode, pi, hermes, and openclaw in declared order", () => { const ids = listIntegrations().map((i) => i.id); - expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(ids).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"]); }); it("getIntegration('claude') returns claudeCode", () => { @@ -130,6 +131,10 @@ describe("integrations registry", () => { expect(getIntegration("devin")).toBe(devin); }); + it("getIntegration('adal') returns adal", () => { + expect(getIntegration("adal")).toBe(adal); + }); + it("getIntegration throws for unknown id", () => { // @ts-expect-error — testing error path expect(() => getIntegration("unknown-cli")).toThrow(); @@ -1644,3 +1649,108 @@ describe("Goose integration", () => { expect(goose.hooksInstalledInSettings("project", tempDir)).toBe(true); }); }); + +describe("AdaL integration", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "fpai-adal-")); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("exposes only the six lifecycle events AdaL actually fires", () => { + expect([...adal.eventTypes]).toEqual([ + "PreToolUse", + "UserPromptSubmit", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "Stop", + ]); + }); + + it("supports user and project scope only (no local scope)", () => { + expect([...adal.scopes]).toEqual(["user", "project"]); + }); + + it("resolves project scope to /.adal/settings.json", () => { + expect(adal.getSettingsPath("project", tmp)).toBe(resolve(tmp, ".adal", "settings.json")); + }); + + it("buildHookEntry emits only fields AdaL accepts, with timeout in seconds", () => { + const entry = adal.buildHookEntry("/usr/local/bin/failproofai", "PreToolUse", "user"); + expect(entry.type).toBe("command"); + expect(entry.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse --cli adal'); + // AdaL reads timeout in SECONDS (same as Claude) — 60, not 60000. + expect(entry.timeout).toBe(60); + expect(adal.isFailproofaiHook(entry)).toBe(true); + }); + + it("buildHookEntry uses npx for project scope", () => { + const entry = adal.buildHookEntry("/ignored", "Stop", "project"); + expect(entry.command).toBe("npx -y failproofai --hook Stop --cli adal"); + }); + + it("writeHookEntries registers every event and is idempotent", () => { + const settings: Record = {}; + adal.writeHookEntries(settings, "/bin/failproofai", "user"); + adal.writeHookEntries(settings, "/bin/failproofai", "user"); + + const hooks = settings.hooks as Record>; + expect(Object.keys(hooks).sort()).toEqual([...adal.eventTypes].sort()); + for (const event of adal.eventTypes) { + const marked = hooks[event].flatMap((m) => m.hooks).filter((h) => adal.isFailproofaiHook(h)); + expect(marked).toHaveLength(1); + } + }); + + it("preserves unrelated settings and pre-existing user hooks", () => { + const settings: Record = { + model: "claude-opus-5", + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "my-own-hook.sh" }] }], + }, + }; + adal.writeHookEntries(settings, "/bin/failproofai", "user"); + + expect(settings.model).toBe("claude-opus-5"); + const pre = (settings.hooks as Record> }>>).PreToolUse; + const userHook = pre.flatMap((m) => m.hooks).find((h) => h.command === "my-own-hook.sh"); + expect(userHook).toBeDefined(); + expect(pre.flatMap((m) => m.hooks).filter((h) => adal.isFailproofaiHook(h))).toHaveLength(1); + }); + + it("removeHooksFromFile removes only marked entries and leaves user hooks", () => { + const settingsPath = adal.getSettingsPath("project", tmp); + mkdirSync(resolve(tmp, ".adal"), { recursive: true }); + const settings: Record = { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "my-own-hook.sh" }] }], + }, + }; + adal.writeHookEntries(settings, "/bin/failproofai", "project"); + writeFileSync(settingsPath, JSON.stringify(settings), "utf-8"); + + expect(adal.hooksInstalledInSettings("project", tmp)).toBe(true); + const removed = adal.removeHooksFromFile(settingsPath); + expect(removed).toBe(adal.eventTypes.length); + expect(adal.hooksInstalledInSettings("project", tmp)).toBe(false); + + const after = JSON.parse(readFileSync(settingsPath, "utf-8")) as { + hooks?: Record> }>>; + }; + const survivors = after.hooks?.PreToolUse?.flatMap((m) => m.hooks) ?? []; + expect(survivors).toHaveLength(1); + expect(survivors[0].command).toBe("my-own-hook.sh"); + }); + + it("hooksInstalledInSettings is false for a missing or corrupt settings file", () => { + expect(adal.hooksInstalledInSettings("project", tmp)).toBe(false); + mkdirSync(resolve(tmp, ".adal"), { recursive: true }); + writeFileSync(adal.getSettingsPath("project", tmp), "{ not json", "utf-8"); + expect(adal.hooksInstalledInSettings("project", tmp)).toBe(false); + }); +}); diff --git a/__tests__/lib/cli-registry.test.ts b/__tests__/lib/cli-registry.test.ts index 238043dd..175594a2 100644 --- a/__tests__/lib/cli-registry.test.ts +++ b/__tests__/lib/cli-registry.test.ts @@ -12,7 +12,7 @@ import { describe("lib/cli-registry", () => { it("KNOWN_CLI_IDS lists all supported CLIs in stable order", () => { - expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"]); }); it("getCliEntry returns the entry for known ids and undefined for unknown", () => { @@ -24,6 +24,7 @@ describe("lib/cli-registry", () => { expect(getCliEntry("pi")?.label).toBe("Pi"); expect(getCliEntry("hermes")?.label).toBe("Hermes"); expect(getCliEntry("goose")?.label).toBe("Goose"); + expect(getCliEntry("adal")?.label).toBe("AdaL"); expect(getCliEntry("unknown")).toBeUndefined(); }); @@ -42,6 +43,7 @@ describe("lib/cli-registry", () => { expect(getCliBadgeClasses("pi")).toContain("pink"); expect(getCliBadgeClasses("hermes")).toContain("indigo"); expect(getCliBadgeClasses("goose")).toContain("lime"); + expect(getCliBadgeClasses("adal")).toContain("fuchsia"); expect(getCliBadgeClasses("unknown")).toContain("orange"); // falls back to claude }); @@ -73,7 +75,7 @@ describe("lib/cli-registry", () => { it("listExternalCliEntries excludes claude", () => { const ids = listExternalCliEntries().map((c) => c.id); - expect(ids).toEqual(["codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); + expect(ids).toEqual(["codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"]); }); it("each CLI has a unique badgeClasses string", () => { diff --git a/assets/logos/adal.svg b/assets/logos/adal.svg new file mode 100644 index 00000000..977088b3 --- /dev/null +++ b/assets/logos/adal.svg @@ -0,0 +1 @@ +AdaL diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 35f85279..86ffdc64 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -14,6 +14,26 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { version } from "../package.json"; +// Canonical CLI allowlist — mirrors INTEGRATION_TYPES in src/hooks/types.ts. +// Hoisted to module scope so the --hook, install, uninstall, and policy +// add/remove parsers cannot drift apart (a per-parser copy previously let a new +// CLI be installable but not removable). +const VALID_CLIS = new Set([ + "claude", + "codex", + "copilot", + "cursor", + "opencode", + "pi", + "hermes", + "openclaw", + "factory", + "devin", + "antigravity", + "goose", + "adal", +]); + // Resolve the real package root early (following any npm bin symlinks) so that // scripts/launch.ts can locate .next/standalone/server.js correctly regardless // of how bun resolves import.meta.url for dynamically-imported modules. @@ -68,7 +88,7 @@ const hookIdx = args.indexOf("--hook"); if (hookIdx >= 0) { if (!args[hookIdx + 1]) { console.error("Error: Missing event type after --hook"); - console.error("Usage: failproofai --hook [--cli ]"); + console.error("Usage: failproofai --hook [--cli ]"); process.exit(1); } const eventType = args[hookIdx + 1]; @@ -90,6 +110,7 @@ if (hookIdx >= 0) { || cliArg === "devin" || cliArg === "antigravity" || cliArg === "goose" + || cliArg === "adal" ) ? cliArg : "claude"; @@ -157,9 +178,9 @@ COMMANDS policies, p List all available policies and their status policies --install, -i Enable policies in agent CLI settings [names...] Specific policy names to enable - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|adal Agent CLI(s) to install for; space-separated - (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose) or repeated. + (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose adal) or repeated. Default: detect installed CLIs and prompt. --scope user|project|local Config scope to write to (default: user) (Codex / Copilot / Cursor / OpenCode / Pi support user|project only) @@ -168,7 +189,7 @@ COMMANDS policies --uninstall, -u Disable policies or remove hooks [names...] Specific policy names to disable - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|adal Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies @@ -205,7 +226,7 @@ EXAMPLES failproofai policies --install --cli pi --scope project failproofai policies --install --cli factory --scope project failproofai policies --install --cli devin --scope project - failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose + failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose adal failproofai policies --install --custom ./my-policies.js failproofai policies -i -c ./my-policies.js failproofai policies --uninstall block-sudo @@ -253,9 +274,9 @@ USAGE OPTIONS (install) [names...] Specific policy names to enable (omit for interactive) - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|adal Agent CLI(s) to install for; space-separated - (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose) or repeated. + (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose adal) or repeated. Omit to detect installed CLIs and prompt (or auto-pick if only one is found). --scope user|project|local Config scope to write to (default: user) @@ -266,7 +287,7 @@ OPTIONS (install) OPTIONS (uninstall) [names...] Specific policy names to disable (omit to remove hooks) - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|adal Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies @@ -283,7 +304,7 @@ EXAMPLES failproofai policies --install --cli pi --scope project failproofai policies --install --cli factory --scope project failproofai policies --install --cli devin --scope project - failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose + failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose adal failproofai policies --install --custom ./my-policies.js failproofai policies -i -c ./my-policies.js failproofai policies --uninstall block-sudo @@ -324,7 +345,6 @@ EXAMPLES // --cli claude codex copilot // --cli claude --cli codex // Values are consumed greedily until the next flag or end of argv. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -413,7 +433,6 @@ EXAMPLES } // --cli accepts one or more space-separated values; same parser as install. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); @@ -539,7 +558,7 @@ USAGE failproofai policy remove Disable one policy OPTIONS - --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose + --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose|adal Agent CLI(s) to apply to; space-separated or repeated. Omit to detect installed CLIs and prompt. --scope user|project|local Config scope (default: user) @@ -577,7 +596,6 @@ EXAMPLES } // --cli accepts one or more space-separated values, optionally repeated. - const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); const cliFlagValues = []; const cliConsumedIdxs = new Set(); const cliFlagIdxs = rest.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); diff --git a/lib/cli-registry.ts b/lib/cli-registry.ts index 031aea4c..940e4eb6 100644 --- a/lib/cli-registry.ts +++ b/lib/cli-registry.ts @@ -27,7 +27,7 @@ import type { IntegrationType } from "@/src/hooks/types"; /** Canonical CLI ids the registry knows about. Mirrors `INTEGRATION_TYPES`. */ -export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"] as const satisfies readonly IntegrationType[]; +export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"] as const satisfies readonly IntegrationType[]; export type CliId = (typeof KNOWN_CLI_IDS)[number]; /** Per-CLI metadata consumed by the dashboard. */ @@ -99,6 +99,11 @@ const CLI_ENTRIES: Record = { label: "Goose", badgeClasses: "bg-lime-500/10 text-lime-400 border-lime-500/20", }, + adal: { + id: "adal", + label: "AdaL", + badgeClasses: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20", + }, }; export function getCliEntry(id: string): CliEntry | undefined { diff --git a/lib/download-session.ts b/lib/download-session.ts index 1eea27f5..5a409d3a 100644 --- a/lib/download-session.ts +++ b/lib/download-session.ts @@ -152,6 +152,12 @@ export async function resolveDownloadSource( return { kind: "synthesized", body, contentType: "application/x-ndjson", extension: "jsonl" }; } + if (cli === "adal") { + // AdaL is live-hooks-only: it persists no replayable transcript, so there is + // nothing to download. See src/audit/cli-adapters/adal.ts for the rationale. + return null; + } + // Exhaustive — but TypeScript can't always see CliId is exhausted across the // if-chain above, so guard with a runtime fallback. const _exhaustive: never = cli; diff --git a/lib/projects.ts b/lib/projects.ts index fa1afeb4..ed991e37 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -16,7 +16,7 @@ import { formatDate } from "./format-date"; export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/; -export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose"; +export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose" | "adal"; export interface ProjectFolder { name: string; diff --git a/src/audit/cli-adapters/adal.ts b/src/audit/cli-adapters/adal.ts new file mode 100644 index 00000000..3abf574b --- /dev/null +++ b/src/audit/cli-adapters/adal.ts @@ -0,0 +1,48 @@ +/** + * AdaL (@sylphai/adal-cli) transcript adapter — AUDIT (Pillar 2). + * + * ⚠️ INERT BY DESIGN — AdaL is a live-hooks-only integration today. + * + * Unlike every sibling adapter in this directory, this one has no data source + * to read. AdaL does not currently persist a replayable per-turn tool-event log: + * + * • `~/.adal/sessions//` holds only side-artifacts (documents/, images/, + * scratch/, system_prompt.txt) — no transcript. + * • `~/.adal/sessions/_metadata.json` has session identity (conversation_id, + * created_at, last_accessed, project_path, total_turns) but no tool calls. + * • `~/.adal/adal.db` exists but contains no tables. + * + * So there is no equivalent of Claude's JSONL transcript, Goose's SQLite + * `messages` rows, or Antigravity's `transcript_full.jsonl`. + * + * `listTranscripts` deliberately returns [] rather than enumerating sessions + * from the metadata files. Listing sessions whose events can never be streamed + * would populate the dashboard with rows that always open empty, which reads as + * broken rather than unsupported. Empty and honest is the better failure mode. + * + * This module exists because `ADAPTERS` in ./index.ts is an exhaustive + * `Record`, so a live-hooks-only CLI cannot be + * registered without it. `INTEGRATIONS` (live hooks) is `Partial` for the mirror + * reason — see the PR discussion about making `ADAPTERS` `Partial` too. + * + * When AdaL gains a persisted tool-event log, replace both functions with a + * real reader (lib/adal-sessions.ts) following the antigravity.ts pattern; the + * ADAL_TOOL_MAP / ADAL_TOOL_INPUT_MAP canonicalization this needs already + * exists in src/hooks/types.ts. + */ +import type { NormalizedToolEvent, TranscriptMetadata } from "../types"; +import type { ListOpts } from "./claude"; + +export async function listAdalTranscriptMetadata( + _opts: ListOpts = {}, +): Promise { + // No replayable transcript source — see the module header. + return []; +} + +export async function streamAdalEvents( + _meta: TranscriptMetadata, +): Promise { + // No replayable transcript source — see the module header. + return []; +} diff --git a/src/audit/cli-adapters/index.ts b/src/audit/cli-adapters/index.ts index 52fbe950..39526d7a 100644 --- a/src/audit/cli-adapters/index.ts +++ b/src/audit/cli-adapters/index.ts @@ -23,6 +23,7 @@ import { listFactoryTranscriptMetadata, streamFactoryEvents } from "./factory"; import { listAntigravityTranscriptMetadata, streamAntigravityEvents } from "./antigravity"; import { listDevinTranscriptMetadata, streamDevinEvents } from "./devin"; import { listGooseTranscriptMetadata, streamGooseEvents } from "./goose"; +import { listAdalTranscriptMetadata, streamAdalEvents } from "./adal"; export type { ListOpts }; @@ -93,6 +94,13 @@ export const ADAPTERS: Record = { listTranscripts: listGooseTranscriptMetadata, streamEvents: streamGooseEvents, }, + // AdaL is live-hooks-only: this adapter is inert because AdaL persists no + // replayable tool-event log yet. See ./adal.ts for the full rationale. + adal: { + cli: "adal", + listTranscripts: listAdalTranscriptMetadata, + streamEvents: streamAdalEvents, + }, }; export function getAdapter(cli: IntegrationType): CliAdapter { diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 011b93bf..300c5c03 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -38,6 +38,7 @@ import { ANTIGRAVITY_HOOK_SCOPES, GOOSE_HOOK_EVENT_TYPES, GOOSE_HOOK_SCOPES, + ADAL_HOOK_SCOPES, FAILPROOFAI_HOOK_MARKER, INTEGRATION_TYPES, type IntegrationType, @@ -2172,6 +2173,143 @@ export const goose: Integration = { }, }; +// ── AdaL integration ──────────────────────────────────────────────────────── +// +// AdaL's hook protocol is Claude-compatible by design (see the ADAL_* notes in +// types.ts). Material differences from claudeCode: +// • Settings paths: ~/.adal/settings.json (user) and /.adal/settings.json (project) +// • No "local" scope (AdaL has no settings.local.json equivalent) +// • Only PreToolUse / UserPromptSubmit can enforce; the other four events are +// observational, so a deny there is advisory (documented in CLAUDE.md) +// Stdin event names are already canonical PascalCase, so — unlike codex — there +// is NO event map and NO canonicalizeEventType branch. + +interface AdalSettingsFile { + hooks?: Record; + [key: string]: unknown; +} + +/** Events AdaL actually fires. A subset of HOOK_EVENT_TYPES, spelled identically. */ +const ADAL_EVENT_TYPES = [ + "PreToolUse", + "UserPromptSubmit", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "Stop", +] as const; + +export const adal: Integration = { + id: "adal", + displayName: "AdaL", + scopes: ADAL_HOOK_SCOPES, + eventTypes: ADAL_EVENT_TYPES, + + getSettingsPath(scope, cwd) { + const base = cwd ? resolve(cwd) : process.cwd(); + switch (scope) { + case "project": + return resolve(base, ".adal", "settings.json"); + default: + return resolve(homedir(), ".adal", "settings.json"); + } + }, + + readSettings(settingsPath) { + return readJsonFile(settingsPath); + }, + + writeSettings(settingsPath, settings) { + writeJsonFile(settingsPath, settings); + }, + + buildHookEntry(binaryPath, eventType, scope) { + const command = + scope === "project" + ? `npx -y failproofai --hook ${eventType} --cli adal` + : `"${binaryPath}" --hook ${eventType} --cli adal`; + return { + type: "command", + command, + // AdaL reads `timeout` in SECONDS, same as Claude. 60 = 60s. + timeout: 60, + [FAILPROOFAI_HOOK_MARKER]: true, + }; + }, + + isFailproofaiHook: isMarkedHook, + + writeHookEntries(settings, binaryPath, scope) { + const s = settings as AdalSettingsFile; + if (!s.hooks) s.hooks = {}; + + for (const eventType of ADAL_EVENT_TYPES) { + const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry; + if (!s.hooks[eventType]) s.hooks[eventType] = []; + const matchers: ClaudeHookMatcher[] = s.hooks[eventType]; + + let found = false; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record)); + if (idx >= 0) { + matcher.hooks[idx] = hookEntry; + found = true; + break; + } + } + if (!found) matchers.push({ hooks: [hookEntry] }); + } + }, + + removeHooksFromFile(settingsPath) { + const settings = this.readSettings(settingsPath) as AdalSettingsFile; + if (!settings.hooks) return 0; + + let removed = 0; + for (const eventType of Object.keys(settings.hooks)) { + const matchers = settings.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + for (let i = matchers.length - 1; i >= 0; i--) { + const matcher = matchers[i]; + if (!matcher.hooks) continue; + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record)); + removed += before - matcher.hooks.length; + if (matcher.hooks.length === 0) matchers.splice(i, 1); + } + if (matchers.length === 0) delete settings.hooks[eventType]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + this.writeSettings(settingsPath, settings as Record); + return removed; + }, + + hooksInstalledInSettings(scope, cwd) { + const settingsPath = this.getSettingsPath(scope, cwd); + if (!existsSync(settingsPath)) return false; + try { + const settings = this.readSettings(settingsPath) as AdalSettingsFile; + if (!settings.hooks) return false; + for (const matchers of Object.values(settings.hooks)) { + if (!Array.isArray(matchers)) continue; + for (const matcher of matchers) { + if (!matcher.hooks) continue; + if (matcher.hooks.some((h) => isMarkedHook(h as Record))) return true; + } + } + } catch { + // Corrupt settings — treat as not installed + } + return false; + }, + + detectInstalled() { + return binaryExists("adal") || binaryExists("adal-cli"); + }, +}; + // ── Registry ──────────────────────────────────────────────────────────────── // `Partial` is kept (not every IntegrationType is guaranteed installable for @@ -2192,6 +2330,7 @@ const INTEGRATIONS: Partial> = { devin, antigravity, goose, + adal, }; export function getIntegration(id: IntegrationType): Integration { diff --git a/src/hooks/tool-name-canonicalize.ts b/src/hooks/tool-name-canonicalize.ts index b784d5d9..e3dcea0b 100644 --- a/src/hooks/tool-name-canonicalize.ts +++ b/src/hooks/tool-name-canonicalize.ts @@ -25,6 +25,8 @@ import { ANTIGRAVITY_TOOL_INPUT_MAP, GOOSE_TOOL_MAP, GOOSE_TOOL_INPUT_MAP, + ADAL_TOOL_MAP, + ADAL_TOOL_INPUT_MAP, } from "./types"; /** @@ -56,6 +58,9 @@ export function canonicalizeToolName( // Goose: shell→Bash, write/edit/view→file ops, todo__todo_write→TodoWrite, … // (verified live against goose v1.43.0). Handles bare + `__` names. if (cli === "goose") return GOOSE_TOOL_MAP[raw] ?? raw; + // AdaL: bash→Bash, read_file→Read, create_file/rewrite_file→Write, + // replace_by_string/delete_lines→Edit. Unknown names (incl. mcp__*) pass through. + if (cli === "adal") return ADAL_TOOL_MAP[raw] ?? raw; return raw; } @@ -95,6 +100,9 @@ export function canonicalizeToolInput( // Goose file tools (write/edit/view) deliver the path as `path`, read_image as // `source`; map to `file_path` so path builtins fire (verified goose v1.43.0). else if (cli === "goose") perToolMap = GOOSE_TOOL_INPUT_MAP[toolName]; + // AdaL file tools already deliver `file_path`; only create_file/rewrite_file's + // body key differs (`new_string` → `content`) so content builtins fire. + else if (cli === "adal") perToolMap = ADAL_TOOL_INPUT_MAP[toolName]; if (!perToolMap) return rawInput; const out: Record = {}; for (const [k, v] of Object.entries(rawInput as Record)) { diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 9a851469..28dd24fb 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -5,7 +5,7 @@ export const HOOK_SCOPES = ["user", "project", "local"] as const; export type HookScope = (typeof HOOK_SCOPES)[number]; -export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"] as const; +export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose", "adal"] as const; export type IntegrationType = (typeof INTEGRATION_TYPES)[number]; export const CODEX_HOOK_SCOPES = ["user", "project"] as const; @@ -959,6 +959,69 @@ export const GOOSE_TOOL_INPUT_MAP: Record> = { LS: { path: "file_path" }, }; +// ── AdaL (@sylphai/adal-cli) ──────────────────────────────────────────────── +// +// AdaL is the only integration so far that needs NO event map and NO +// canonicalizeEventType branch in handler.ts: +// +// 1. **Events are already canonical PascalCase.** AdaL fires exactly six +// lifecycle events — PreToolUse, UserPromptSubmit, PostToolUse, +// PostToolUseFailure, PermissionRequest, Stop — and every one already +// exists in HOOK_EVENT_TYPES below, spelled identically. So AdaL falls +// through canonicalizeEventType's "claude / copilot / unknown — already +// PascalCase, pass through" tail, exactly like claude does. Adding an +// ADAL_EVENT_MAP would be an identity map. +// +// 2. **Only two events can enforce.** PreToolUse and UserPromptSubmit run +// before the action and can deny it. PostToolUse, PostToolUseFailure, +// PermissionRequest, and Stop fire after the fact — a deny there cannot +// reverse what already ran, so policies matching those events are +// observational (same shape as Factory/Hermes on non-Stop events). +// +// 3. **Settings shape is Claude-shaped.** ~/.adal/settings.json uses the same +// `hooks: { : [{ matcher?, hooks: [{ type, command, timeout }] }] }` +// structure Claude uses, and `timeout` is in SECONDS — so the adal +// Integration in integrations.ts mirrors claudeCode rather than inventing +// a new writer. +// +// 4. **Tool names are snake_case internally**, so ADAL_TOOL_MAP is required to +// let existing builtin policies (which match Bash/Read/Write/Edit) apply +// unchanged. Unknown tools pass through via the `?? raw` fallback, which +// preserves `mcp__server__tool` identity. +// +// Settings paths: +// user → ~/.adal/settings.json +// project → /.adal/settings.json +export const ADAL_HOOK_SCOPES = ["user", "project"] as const; + +export const ADAL_TOOL_MAP: Record = { + bash: "Bash", + read_file: "Read", + read_image: "Read", + write_file: "Write", + create_file: "Write", + rewrite_file: "Write", + replace_by_string: "Edit", + delete_lines: "Edit", + grep: "Grep", + glob: "Glob", + fetch_url: "WebFetch", + web_search: "WebSearch", +}; + +/** + * Per-tool input-key translation, keyed by the *canonical* tool name (the + * handler canonicalizes the name before calling canonicalizeToolInput). + * AdaL's file tools already deliver `file_path`, so Read/Edit need no mapping. + * Its create/rewrite tools carry the body as `new_string` while Claude builtins + * (block-secrets-write) read `content`, so map that one key — mirroring how + * GOOSE_TOOL_INPUT_MAP maps only the keys a builtin actually inspects. `bash` + * already delivers `command` (canonical), so Bash needs no entry. + */ +export const ADAL_TOOL_INPUT_MAP: Record> = { + Write: { new_string: "content" }, +}; + export const HOOK_EVENT_TYPES = [ "SessionStart", "SessionEnd",