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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `<cwd>/.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/<id>/` 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
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ before they become incidents. Zero latency. Runs locally.
## Supported agent CLIs

<!-- A 6-column table instead of inline <img> runs: table columns never re-wrap,
so the grid stays 2×6 at any window width (scrolling on very narrow screens
instead of collapsing into ragged orphan rows). -->
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. -->
<table align="center">
<tr>
<td align="center" width="96">
Expand Down Expand Up @@ -117,6 +118,13 @@ before they become incidents. Zero latency. Runs locally.
</a>
</td>
</tr>
<tr>
<td align="center" width="96">
<a href="https://docs.sylph.ai" title="AdaL (@sylphai/adal-cli)">
<img src="assets/logos/adal.svg" alt="AdaL" width="56" height="56" />
</a>
</td>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</tr>
</table>

## Install
Expand Down
41 changes: 41 additions & 0 deletions __tests__/bin/valid-clis.test.ts
Original file line number Diff line number Diff line change
@@ -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}"`);
}
Comment on lines +25 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert exact allowlist equality.

This only checks that VALID_CLIS contains every integration, so unsupported extra entries can remain accepted without failing the test. Since the allowlist is documented as mirroring INTEGRATION_TYPES, compare the two sets in both directions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/bin/valid-clis.test.ts` around lines 25 - 29, Update the test case
covering INTEGRATION_TYPES to assert exact equality between VALID_CLIS and
INTEGRATION_TYPES in both directions, ensuring no unsupported CLI entries are
accepted. Preserve the existing source extraction and integration coverage while
adding validation that every allowlist entry is defined in INTEGRATION_TYPES.

});

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);
Comment on lines +32 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate each parser independently.

uses.length >= 3 can pass when multiple checks are in one parser and another --cli parser has no allowlist guard. Assert VALID_CLIS.has(...) within each install, uninstall, and policy parser block, or exercise each parser with valid and invalid CLI values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/bin/valid-clis.test.ts` around lines 32 - 35, Update the test “is
consulted by every subcommand parser that accepts --cli” to validate each parser
independently rather than counting all VALID_CLIS.has occurrences globally.
Assert the allowlist check within the install, uninstall, and policy add/remove
parser blocks, or invoke each parser with both valid and invalid CLI values.

});

it("accepts adal in the --hook parser as well", () => {
expect(BIN_SOURCE).toContain('cliArg === "adal"');
});
Comment on lines +38 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover every integration in the --hook parser.

Checking only cliArg === "adal" will not catch a future CLI added to INTEGRATION_TYPES and VALID_CLIS but omitted from the hook parser’s explicit checks. Route this parser through VALID_CLIS.has(cliArg) or assert acceptance for every INTEGRATION_TYPES entry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/bin/valid-clis.test.ts` around lines 38 - 40, Update the --hook
parser test around “accepts adal in the --hook parser as well” to cover every
supported integration, rather than checking only the literal adal value. Prefer
validating the parser through VALID_CLIS.has(cliArg), or iterate over
INTEGRATION_TYPES and assert each entry is accepted, so additions to the
supported CLI sets cannot be omitted from explicit parser checks.

});
87 changes: 87 additions & 0 deletions __tests__/hooks/adal-canonicalize.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
15 changes: 8 additions & 7 deletions __tests__/hooks/install-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -168,6 +168,7 @@ describe("hooks/install-prompt", () => {
"Devin CLI",
"Antigravity CLI",
"Goose",
"AdaL",
]);
});

Expand All @@ -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 () => {
Expand Down
Loading
Loading