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
65 changes: 65 additions & 0 deletions __tests__/bin/valid-clis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @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 thread
coderabbitai[bot] marked this conversation as resolved.

// Also assert the reverse direction: VALID_CLIS must not contain any
// entries beyond INTEGRATION_TYPES, so the two sets are exactly equal
// (no missing, no extra) rather than just a superset check.
const declaredClis = new Set([...block.matchAll(/"([^"]+)"/g)].map((m) => m[1]));
const expectedClis: Set<string> = new Set(INTEGRATION_TYPES);
for (const cli of declaredClis) {
expect(expectedClis.has(cli), `VALID_CLIS has unexpected extra entry: ${cli}`).toBe(true);
}
expect(declaredClis.size).toBe(expectedClis.size);
});

it("is consulted by every subcommand parser that accepts --cli", () => {
// install, uninstall, and policy add/remove each guard with VALID_CLIS.has.
// Slice the source by each parser's unique surrounding context so a
// missing guard in one parser can't be masked by extra guards elsewhere
// (a global count could pass even if one parser lost its check).
const installBlock = BIN_SOURCE.split("if (isInstall) {")[1]?.split("if (isUninstall) {")[0] ?? "";
const uninstallBlock = BIN_SOURCE.split("if (isUninstall) {")[1]?.split("// Default: list policies")[0] ?? "";
const policyBlock = BIN_SOURCE.split('if (args[0] === "policy") {')[1]?.split("// config — the interactive setup launcher")[0] ?? "";

const parserBlocks: Array<[string, string]> = [
["install", installBlock],
["uninstall", uninstallBlock],
["policy add/remove", policyBlock],
];
for (const [label, block] of parserBlocks) {
expect(block.length, `${label} parser slice not found`).toBeGreaterThan(0);
expect(block, `${label} parser is missing its own VALID_CLIS.has(...) guard`).toContain("VALID_CLIS.has(");
}
});

it("accepts adal in the --hook parser as well", () => {
expect(BIN_SOURCE).toContain('cliArg === "adal"');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
1 change: 1 addition & 0 deletions __tests__/components/project-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ describe("ProjectList", () => {
"Devin CLI",
"Antigravity CLI",
"Goose",
"AdaL",
]);
});

Expand Down
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