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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,20 @@ warn / inject; defects fail OPEN. `GitEnv` service makes guards layer-testable.
in-process -> `mergeVerdicts` -> encode once), replacing N fat per-guard
bundles. Standalone bundle ~0.9 MB (one, vs ~1.5 MB across four), embedded +
scaffolded like the per-guard bundles. Runtime is live (`bun dispatch.ts`);
`ax hooks install --all` now registers the single dispatcher (see below). A
daemon `/hooks/eval` fast-path (the latency win) is the remaining step.
`ax hooks install --all` now registers the single dispatcher (see below).
- **Daemon fast-path** `POST /hooks/eval` (`ax serve`, `routes/hooks.ts`,
capability `hooks_eval`): warm-evaluates the dispatcher via `dispatchEvent`,
skipping the cold `bun` spawn + bundle parse. DB-free (only GitEnvLive),
fail-open (unreadable body / any error -> allow). **Env-forwarding:** a
daemon-evaluated guard sees the DAEMON's `process.env`, not the agent's, so
the payload carries an `_ax_env` allowlist (decode -> `event.env`) and guards
read `readEnv(event, NAME) = event.env?.[NAME] ?? process.env[NAME]` for
bypass flags (`ALLOW_MAIN_WRITE`/`ALLOW_BRANCH_CHECKOUT`/
`ALLOW_DIRTY_MAIN_MUTATION`) + `AX_SPEND_MODE` - additive, so the spawned path
is unchanged. The endpoint is INERT until the shim ships: stage 3b adds the
effect-free bun shim (POST daemon-first, lazy-import the bundle on fallback) +
an opt-in install that swaps `bun dispatch.js` for the shim. Verified via
`handleDashboardRequest` (no live serve needed).
- `ax hooks install <abs-file> --providers=claude,codex` - idempotent fan-out
into provider configs via the existing codecs (ax ownership markers)
- `ax hooks install --all [--providers=claude,codex] [--dir=~/.ax/hooks]` -
Expand Down
34 changes: 34 additions & 0 deletions apps/axctl/src/dashboard/router/routes/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { handleDashboardRequest } from "../../server.ts";

const post = (body: string): Request =>
new Request("http://127.0.0.1:1738/hooks/eval", { method: "POST", body });

describe("POST /hooks/eval", () => {
test("a no-guard-match event returns an allow outcome (exit 0)", async () => {
// Read is not matched by any guard -> merged allow -> { exitCode: 0 }.
const res = await handleDashboardRequest(
post(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Read", cwd: "/tmp" })),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ exitCode: 0 });
});

test("fail-open: a garbage body still returns allow (never wedges the agent)", async () => {
const res = await handleDashboardRequest(post("} not json {"));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ exitCode: 0 });
});

test("an empty body is allow", async () => {
const res = await handleDashboardRequest(post(""));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ exitCode: 0 });
});

test("GET /api/version advertises hooks_eval", async () => {
const res = await handleDashboardRequest(new Request("http://127.0.0.1:1738/api/version"));
const body = (await res.json()) as { hooks_eval?: boolean };
expect(body.hooks_eval).toBe(true);
});
});
41 changes: 41 additions & 0 deletions apps/axctl/src/dashboard/router/routes/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Hooks family: POST /hooks/eval - the daemon fast-path for SDK hooks.
*
* A hook shim POSTs the raw harness event JSON (the same payload it received on
* stdin, plus a forwarded `_ax_env` allowlist) and gets back the merged
* ProcessOutcome ({ exitCode, stdout?, stderr? }) - evaluated WARM in the
* already-running daemon via `dispatchEvent`, skipping the cold `bun` spawn +
* effect-bundle parse the spawned path pays per fire. DB-free (the dispatcher
* only needs GitEnvLive), so it works on both source and the compiled binary,
* and answers even when SurrealDB is down.
*
* Fail-open by construction: a body read error -> allow; `dispatchEvent` never
* throws (per-guard defects already fail open). A daemon hiccup must never wedge
* the agent, so the shim also treats any non-2xx / unreachable daemon as
* "fall back to the local bundle".
*/
import { Effect } from "effect";
import { dispatchEvent } from "@ax/hooks-sdk/dispatch";
import { GitEnvLive } from "@ax/hooks-sdk/git-env";
import { jsonResponse, rawRoute, type AnyRoute } from "../router.ts";

export const hooksRoutes: ReadonlyArray<AnyRoute> = [
rawRoute({
method: "POST",
path: "/hooks/eval",
handler: async (input) => {
let bodyText = "";
try {
bodyText = await input.req.text();
} catch {
// Unreadable body -> empty event -> no guard matches -> allow.
}
const outcome = await Effect.runPromise(
dispatchEvent(bodyText, process.env as Record<string, string | undefined>).pipe(
Effect.provide(GitEnvLive),
),
);
return jsonResponse(outcome);
},
}),
];
3 changes: 3 additions & 0 deletions apps/axctl/src/dashboard/router/routes/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const systemRoutes: ReadonlyArray<AnyRoute> = [
// OTLP receiver is pure HTTP+JSON+SurrealDB (no native dep),
// so it works in both source and compiled binary - always true.
otlp_receiver: true,
// POST /hooks/eval warm-evaluates SDK hooks (DB-free); the hook
// shim probes this to decide daemon-first vs spawn fallback.
hooks_eval: true,
}),
}),
];
3 changes: 3 additions & 0 deletions apps/axctl/src/dashboard/router/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* - GET /api/version (DB-free identity probe; see routes/system.ts)
* - GET /api/graph-explorer (env-gated experiment; see routes/insights.ts)
* - GET /api/events + GET /api/image (SSE/binary; see routes/live.ts)
* - POST /hooks/eval (DB-free hook dispatcher fast-path; see routes/hooks.ts)
* Every other endpoint is served by the contract router
* (../contract/web-handler.ts) before dispatch reaches this table.
*/
import type { AnyRoute } from "./router.ts";
import { hooksRoutes } from "./routes/hooks.ts";
import { insightRoutes } from "./routes/insights.ts";
import { liveRoutes } from "./routes/live.ts";
import { systemRoutes } from "./routes/system.ts";
Expand All @@ -16,4 +18,5 @@ export const routeTable: ReadonlyArray<AnyRoute> = [
...systemRoutes,
...insightRoutes,
...liveRoutes,
...hooksRoutes,
];
10 changes: 10 additions & 0 deletions packages/hooks-sdk/src/adapters/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,17 @@ export const decodeHookInput = (
sessionId: str(raw.session_id),
cwd: str(raw.cwd) ?? process.cwd(),
tool: toolName ? { name: toolName, input: asRecord(raw.tool_input) } : null,
env: forwardedEnv(raw["_ax_env"]),
raw,
parseError,
};
};

/** Extract the daemon-forwarded env allowlist: a flat object of string values.
* Non-string values are dropped; a non-object (or absent) yields undefined. */
const forwardedEnv = (v: unknown): Record<string, string> | undefined => {
if (!isRecord(v)) return undefined;
const out: Record<string, string> = {};
for (const [k, val] of Object.entries(v)) if (typeof val === "string") out[k] = val;
return Object.keys(out).length > 0 ? out : undefined;
};
17 changes: 17 additions & 0 deletions packages/hooks-sdk/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,27 @@ export interface HookEvent {
} | null;
/** untouched raw payload for escape hatches. */
readonly raw: Record<string, unknown>;
/**
* Forwarded environment allowlist (bypass flags, spend mode). Populated from
* an `_ax_env` field on the payload - the daemon shim injects the agent's env
* here because a daemon-evaluated guard sees the DAEMON's process.env, not the
* agent's. Guards read `event.env?.[NAME] ?? process.env[NAME]`, so the
* spawned path (where process.env IS the agent's) is unaffected.
*/
readonly env?: Record<string, string> | undefined;
/**
* Set when stdin was non-empty but did not parse to a JSON object
* (malformed JSON or a non-object value). Decode never throws; this is
* how callers distinguish garbage input from a genuinely empty payload.
*/
readonly parseError?: string | undefined;
}

/**
* Read an env var honoring the daemon-forwarded allowlist first, then the
* process env. In the spawned path `event.env` is absent and this is just
* `process.env[name]`; in the daemon path the agent's forwarded value wins, so
* bypass flags (ALLOW_MAIN_WRITE, ...) and spend mode reach the guard.
*/
export const readEnv = (event: HookEvent, name: string): string | undefined =>
event.env?.[name] ?? process.env[name];
73 changes: 73 additions & 0 deletions packages/hooks-sdk/src/forwarded-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import { Effect } from "effect";
import { dispatchEvent } from "./dispatch.ts";
import { decodeHookInput } from "./adapters/decode.ts";
import { readEnv } from "./event.ts";
import { GitEnvTest } from "./git-env.ts";
import enforceWorktreeWrite from "./hooks/enforce-worktree-write.ts";
import type { ProcessOutcome } from "./adapters/encode.ts";

const editOnMain = (axEnv?: Record<string, string>): string =>
JSON.stringify({
hook_event_name: "PreToolUse",
tool_name: "Edit",
tool_input: { file_path: "/repo/x.ts" },
cwd: "/repo",
...(axEnv ? { _ax_env: axEnv } : {}),
});

// /repo is a primary tree on `main` -> enforce-worktree-write blocks unless bypassed.
const gitMain = GitEnvTest({ roots: { "/repo": "/repo" }, branches: { "/repo": "main" } });

const run = (stdin: string): Promise<ProcessOutcome> =>
Effect.runPromise(
dispatchEvent(stdin, {}, [enforceWorktreeWrite]).pipe(Effect.provide(gitMain)),
);

describe("decode: _ax_env -> event.env", () => {
test("populates the forwarded env allowlist", () => {
const ev = decodeHookInput(editOnMain({ ALLOW_MAIN_WRITE: "1" }), {});
expect(ev.env).toEqual({ ALLOW_MAIN_WRITE: "1" });
});

test("drops non-string values and absent _ax_env is undefined", () => {
expect(decodeHookInput(editOnMain(), {}).env).toBeUndefined();
const ev = decodeHookInput(
JSON.stringify({ hook_event_name: "PreToolUse", _ax_env: { A: "1", B: 2 } }),
{},
);
expect(ev.env).toEqual({ A: "1" });
});
});

describe("readEnv precedence", () => {
test("event.env wins over process.env", () => {
const ev = decodeHookInput(editOnMain({ ALLOW_MAIN_WRITE: "1" }), {});
expect(readEnv(ev, "ALLOW_MAIN_WRITE")).toBe("1");
});

test("falls back to process.env when not forwarded", () => {
const ev = decodeHookInput(editOnMain(), {});
process.env.__AX_TEST_FWD = "yes";
try {
expect(readEnv(ev, "__AX_TEST_FWD")).toBe("yes");
} finally {
delete process.env.__AX_TEST_FWD;
}
});
});

describe("forwarded bypass reaches the guard (daemon correctness)", () => {
test("Edit on main blocks without a forwarded bypass", async () => {
const out = await run(editOnMain());
expect(out.exitCode).toBe(2);
});

test("a forwarded ALLOW_MAIN_WRITE=1 unblocks it (env not in this process)", async () => {
// process.env.ALLOW_MAIN_WRITE is unset here; only the payload carries it,
// exactly as a daemon-evaluated hook would receive it.
expect(process.env.ALLOW_MAIN_WRITE).toBeUndefined();
const out = await run(editOnMain({ ALLOW_MAIN_WRITE: "1" }));
expect(out.exitCode).toBe(0);
});
});
3 changes: 2 additions & 1 deletion packages/hooks-sdk/src/hooks/enforce-worktree-write.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Effect } from "effect";
import { defineHook, runMain } from "../define.ts";
import { readEnv } from "../event.ts";
import { GitEnv } from "../git-env.ts";
import { Verdict } from "../verdict.ts";
import { extractPatchPaths } from "./patch-paths.ts";
Expand Down Expand Up @@ -37,7 +38,7 @@ const hook = defineHook({
matcher: { tools: ["Write", "Edit", "MultiEdit", "apply_patch"] },
run: (event) =>
Effect.gen(function* () {
if (process.env.ALLOW_MAIN_WRITE === "1") return Verdict.allow;
if (readEnv(event, "ALLOW_MAIN_WRITE") === "1") return Verdict.allow;
const git = yield* GitEnv;
const home = process.env.HOME ?? "";
const paths = targetPaths(event.tool?.name ?? "", event.tool?.input ?? {}, event.cwd);
Expand Down
11 changes: 6 additions & 5 deletions packages/hooks-sdk/src/hooks/enforce-worktree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Effect } from "effect";
import { defineHook, runMain } from "../define.ts";
import { readEnv, type HookEvent } from "../event.ts";
import { GitEnv } from "../git-env.ts";
import { Verdict } from "../verdict.ts";
import { findGitInvocations, type GitInvocation } from "./git-command.ts";
Expand Down Expand Up @@ -39,8 +40,8 @@ const isGuardedMutation = (inv: GitInvocation): boolean => {
return false;
};

const hasBypass = (name: string, inv: GitInvocation): boolean =>
process.env[name] === "1" || inv.env?.[name] === "1";
const hasBypass = (name: string, inv: GitInvocation, event: HookEvent): boolean =>
readEnv(event, name) === "1" || inv.env?.[name] === "1";

const blockDirtyMsg = (target: string, branch: string, command: string) =>
`BLOCKED: history-mutating git op against a DIRTY primary working tree.
Expand Down Expand Up @@ -96,7 +97,7 @@ const hook = defineHook({

// ---- Guard B: history mutation into a DIRTY primary tree ----
for (const inv of invocations) {
if (hasBypass("ALLOW_DIRTY_MAIN_MUTATION", inv)) continue;
if (hasBypass("ALLOW_DIRTY_MAIN_MUTATION", inv, event)) continue;
if (!isGuardedMutation(inv)) continue;
// Target tree: explicit `git -C <path>` wins, else the event cwd.
const target = inv.cPath ?? event.cwd;
Expand All @@ -114,9 +115,9 @@ const hook = defineHook({
// the default branch is allowed); block linked worktrees from TAKING
// the default branch - a branch lives in one worktree at a time, so a
// worktree holding it locks the primary tree off it.
if (process.env.ALLOW_BRANCH_CHECKOUT === "1") return Verdict.allow;
if (readEnv(event, "ALLOW_BRANCH_CHECKOUT") === "1") return Verdict.allow;
for (const inv of invocations) {
if (hasBypass("ALLOW_BRANCH_CHECKOUT", inv)) continue;
if (hasBypass("ALLOW_BRANCH_CHECKOUT", inv, event)) continue;
if (inv.verb !== "checkout" && inv.verb !== "switch") continue;
if (isRestore(inv.verb, inv.args)) continue;
// Target tree: explicit `git -C <path>` wins, else the event cwd.
Expand Down
3 changes: 2 additions & 1 deletion packages/hooks-sdk/src/hooks/route-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import { Effect } from "effect";
import { defineHook, runMain } from "../define.ts";
import { readEnv } from "../event.ts";
import { decideVerdict } from "../decide-verdict.ts";
import { loadRoutingTableOrDefault } from "../routing-table.ts";
import { resolveDispatchModel } from "../resolve-dispatch-model.ts";
Expand Down Expand Up @@ -88,7 +89,7 @@ const hook = defineHook({
const spendConfig = resolveSpendConfig(table.spendMode as Partial<SpendConfig> | undefined);

// mode (conserve unless a fresh cache says splurge). Env override wins.
const envMode = process.env.AX_SPEND_MODE;
const envMode = readEnv(event, "AX_SPEND_MODE");
const computed = computeSpendMode(
readQuotaCacheSync(defaultQuotaCachePath()),
Date.now(),
Expand Down
1 change: 1 addition & 0 deletions packages/hooks-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
type DispatchInstallEntry,
} from "./dispatch.ts";
export { Verdict } from "./verdict.ts";
export { readEnv } from "./event.ts";
export type { Harness, HookEvent, HookEventName } from "./event.ts";
export { GitEnv, GitEnvLive, GitEnvTest, type GitEnvService } from "./git-env.ts";
export {
Expand Down
Loading