@cruxy/cli 0.14.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/agent/loop.d.ts +26 -0
  2. package/dist/agent/loop.js +59 -3
  3. package/dist/agent/session.d.ts +17 -1
  4. package/dist/agent/session.js +23 -2
  5. package/dist/brand/index.d.ts +1 -0
  6. package/dist/brand/index.js +1 -0
  7. package/dist/brand/voice.d.ts +94 -0
  8. package/dist/brand/voice.js +127 -0
  9. package/dist/cli/commands/checkpoint.js +1 -1
  10. package/dist/cli/commands/hooks.d.ts +8 -0
  11. package/dist/cli/commands/hooks.js +83 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/pr.js +10 -2
  14. package/dist/cli/commands/rollback.js +1 -1
  15. package/dist/cli/commands/run.js +13 -3
  16. package/dist/cli/commands/skills.js +2 -2
  17. package/dist/cli/program.js +5 -2
  18. package/dist/cli/repl.d.ts +2 -1
  19. package/dist/cli/repl.js +54 -3
  20. package/dist/cli/session-factory.d.ts +2 -2
  21. package/dist/cli/session-factory.js +13 -2
  22. package/dist/config/schema.d.ts +139 -46
  23. package/dist/config/schema.js +42 -0
  24. package/dist/constants.d.ts +9 -0
  25. package/dist/constants.js +9 -0
  26. package/dist/errors/constructors.d.ts +25 -0
  27. package/dist/errors/constructors.js +98 -6
  28. package/dist/errors/types.d.ts +14 -0
  29. package/dist/errors/types.js +23 -0
  30. package/dist/hooks/config.d.ts +21 -0
  31. package/dist/hooks/config.js +253 -0
  32. package/dist/hooks/index.d.ts +6 -0
  33. package/dist/hooks/index.js +6 -0
  34. package/dist/hooks/runner.d.ts +76 -0
  35. package/dist/hooks/runner.js +114 -0
  36. package/dist/hooks/service.d.ts +38 -0
  37. package/dist/hooks/service.js +49 -0
  38. package/dist/hooks/slash.d.ts +48 -0
  39. package/dist/hooks/slash.js +58 -0
  40. package/dist/hooks/trust.d.ts +46 -0
  41. package/dist/hooks/trust.js +106 -0
  42. package/dist/hooks/types.d.ts +147 -0
  43. package/dist/hooks/types.js +61 -0
  44. package/dist/onboarding/steps.js +1 -1
  45. package/dist/plan/service.d.ts +6 -0
  46. package/dist/plan/service.js +4 -0
  47. package/dist/render/state.js +4 -1
  48. package/dist/render/types.d.ts +7 -1
  49. package/dist/routing/index.d.ts +2 -0
  50. package/dist/routing/index.js +5 -0
  51. package/dist/routing/resolve.d.ts +17 -0
  52. package/dist/routing/resolve.js +18 -0
  53. package/dist/routing/router.d.ts +47 -0
  54. package/dist/routing/router.js +84 -0
  55. package/dist/routing/types.d.ts +42 -0
  56. package/dist/routing/types.js +27 -0
  57. package/dist/subagent/orchestrator.d.ts +6 -0
  58. package/dist/subagent/orchestrator.js +2 -0
  59. package/dist/subagent/types.d.ts +6 -0
  60. package/dist/tools/shell/exec.d.ts +53 -0
  61. package/dist/tools/shell/exec.js +128 -0
  62. package/dist/tools/shell/run-command.d.ts +4 -0
  63. package/dist/tools/shell/run-command.js +26 -116
  64. package/dist/vcs/generate.d.ts +3 -1
  65. package/dist/vcs/generate.js +4 -1
  66. package/package.json +2 -2
@@ -0,0 +1,114 @@
1
+ import { hookFailed, hookUntrusted, messageOf } from "../errors/index.js";
2
+ import { runGatedShell } from "../tools/shell/exec.js";
3
+ import { fingerprintHooks, isTrusted } from "./trust.js";
4
+ export class HookRunner {
5
+ deps;
6
+ constructor(deps) {
7
+ this.deps = deps;
8
+ }
9
+ /** The project hooks — the trust-gated subset. */
10
+ get projectHooks() {
11
+ return this.deps.hooks.filter((h) => h.source === "project");
12
+ }
13
+ /**
14
+ * Fire every hook registered for `event`, in catalog order. Resolves normally
15
+ * when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
16
+ * when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
17
+ * hooks are not trusted. A no-op when hooks are disabled or none match.
18
+ */
19
+ async fire(event, ctx) {
20
+ if (!this.deps.enabled)
21
+ return;
22
+ const applicable = this.deps.hooks.filter((h) => h.event === event);
23
+ if (applicable.length === 0)
24
+ return;
25
+ // Trust gate FIRST — before any project hook can reach execution.
26
+ if (applicable.some((h) => h.source === "project")) {
27
+ await this.ensureProjectTrust();
28
+ }
29
+ for (const hook of applicable) {
30
+ this.deps.announce?.(`running hook: ${hook.name}`);
31
+ const verdict = await this.runOne(hook, ctx);
32
+ if (verdict.ok)
33
+ continue;
34
+ if (hook.blocking) {
35
+ // Fail-closed: stop, do not run the remaining hooks, abort the action.
36
+ throw hookFailed(hook.name, verdict.reason ?? "hook failed");
37
+ }
38
+ // Advisory: the action already happened (or proceeds) — report, continue.
39
+ this.deps.reportFailure?.(`hook "${hook.name}" failed (advisory): ${verdict.reason ?? "unknown"}`);
40
+ }
41
+ }
42
+ /**
43
+ * Ensure this repo's project hooks are trusted for their current fingerprint.
44
+ * Records trust on an interactive accept; throws `CRUXY_E_HOOK_UNTRUSTED` on
45
+ * decline, when `trustPrompt` is off, or when non-interactive — NEVER
46
+ * auto-trusts. The fingerprint covers ALL project hooks, so a change to any of
47
+ * them invalidates a prior decision (stale → re-prompt).
48
+ */
49
+ async ensureProjectTrust() {
50
+ const projectHooks = this.projectHooks;
51
+ const fingerprint = fingerprintHooks(projectHooks);
52
+ if (isTrusted(this.deps.trust, this.deps.cwd, fingerprint))
53
+ return;
54
+ // Never auto-trust: no prompt possible → fail loud.
55
+ if (!this.deps.interactive ||
56
+ !this.deps.trustPrompt ||
57
+ !this.deps.promptTrust) {
58
+ throw hookUntrusted(this.deps.cwd, projectHooks.length);
59
+ }
60
+ const trusted = await this.deps.promptTrust({
61
+ root: this.deps.cwd,
62
+ hooks: projectHooks,
63
+ });
64
+ if (!trusted) {
65
+ // Decline is not recorded — the next run asks again. Nothing runs now.
66
+ throw hookUntrusted(this.deps.cwd, projectHooks.length);
67
+ }
68
+ this.deps.trust.record({
69
+ root: this.deps.cwd,
70
+ fingerprint,
71
+ at: this.deps.now?.() ?? nowIso(),
72
+ });
73
+ }
74
+ /**
75
+ * Run one hook command through the shared gate + sandbox path and reduce it to
76
+ * a pass/fail verdict. A throw from {@link runGatedShell} (non-interactive
77
+ * approval, sandbox start failure) is a failure whose policy the caller
78
+ * applies — so an advisory hook can never abort the run on an infra error.
79
+ */
80
+ async runOne(hook, ctx) {
81
+ let outcome;
82
+ try {
83
+ outcome = await runGatedShell(hook.command, ctx);
84
+ }
85
+ catch (err) {
86
+ return { ok: false, reason: messageOf(err) ?? "hook could not run" };
87
+ }
88
+ return evaluate(outcome);
89
+ }
90
+ }
91
+ /** Reduce a gated-shell outcome to pass/fail. Success is EXACTLY exit code 0. */
92
+ function evaluate(outcome) {
93
+ if (!outcome.approved) {
94
+ return {
95
+ ok: false,
96
+ reason: outcome.rejection ?? "declined at the approval prompt",
97
+ };
98
+ }
99
+ const e = outcome.exec;
100
+ if (e.timedOut)
101
+ return { ok: false, reason: "timed out" };
102
+ if (e.spawnError !== undefined)
103
+ return { ok: false, reason: e.spawnError };
104
+ if (e.exitCode !== 0) {
105
+ return {
106
+ ok: false,
107
+ reason: `exited with code ${e.exitCode ?? e.signal ?? "unknown"}`,
108
+ };
109
+ }
110
+ return { ok: true };
111
+ }
112
+ function nowIso() {
113
+ return new Date().toISOString();
114
+ }
@@ -0,0 +1,38 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { logger as Logger } from "../utils/logger.js";
3
+ import { type HookSources } from "./config.js";
4
+ import { HookRunner, type TrustPromptInfo } from "./runner.js";
5
+ import { type TrustStore } from "./trust.js";
6
+ import type { HookCatalog, SlashCommandSpec } from "./types.js";
7
+ /**
8
+ * Construction/wiring for the hooks subsystem (C.19). Loads the layered catalog,
9
+ * builds the {@link HookRunner} with the real trust store + an interactive trust
10
+ * prompt, and exposes the resolved custom slash commands. Everything the runner
11
+ * needs is injectable so the security behavior is unit-testable without touching
12
+ * disk or a TTY.
13
+ */
14
+ export interface HooksService {
15
+ /** The lifecycle firing seam (threaded into the loop / session). */
16
+ runner: HookRunner;
17
+ /** Resolved custom slash commands (project > user). */
18
+ commands: SlashCommandSpec[];
19
+ /** The full catalog (for `cruxy hooks list` + surfacing load errors). */
20
+ catalog: HookCatalog;
21
+ }
22
+ export interface BuildHooksServiceOptions {
23
+ cwd: string;
24
+ config: CruxyConfig;
25
+ /** Whether cruxy can prompt (stdin is a TTY). */
26
+ interactive: boolean;
27
+ logger: typeof Logger;
28
+ sources?: HookSources;
29
+ trust?: TrustStore;
30
+ promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
31
+ now?: () => string;
32
+ }
33
+ /**
34
+ * Load the catalog and assemble the {@link HooksService}. Malformed definitions
35
+ * are surfaced (never eval'd, never silently dropped) through the logger; the
36
+ * valid ones proceed.
37
+ */
38
+ export declare function buildHooksService(opts: BuildHooksServiceOptions): Promise<HooksService>;
@@ -0,0 +1,49 @@
1
+ import { readSingleKey } from "../components/input.js";
2
+ import { shouldUseColor } from "../errors/index.js";
3
+ import { themeForColor } from "../theme/index.js";
4
+ import { defaultHookSources, loadHookCatalog, } from "./config.js";
5
+ import { HookRunner } from "./runner.js";
6
+ import { fileTrustStore } from "./trust.js";
7
+ /**
8
+ * Load the catalog and assemble the {@link HooksService}. Malformed definitions
9
+ * are surfaced (never eval'd, never silently dropped) through the logger; the
10
+ * valid ones proceed.
11
+ */
12
+ export async function buildHooksService(opts) {
13
+ const sources = opts.sources ?? defaultHookSources(opts.cwd);
14
+ const catalog = await loadHookCatalog(sources);
15
+ for (const err of catalog.errors) {
16
+ opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}": ${err.message}`);
17
+ }
18
+ const trust = opts.trust ?? fileTrustStore();
19
+ const runner = new HookRunner({
20
+ hooks: catalog.hooks,
21
+ trust,
22
+ enabled: opts.config.hooks.enabled,
23
+ trustPrompt: opts.config.hooks.trustPrompt,
24
+ interactive: opts.interactive,
25
+ cwd: opts.cwd,
26
+ promptTrust: opts.promptTrust ?? defaultTrustPrompt,
27
+ announce: (message) => opts.logger.info(message),
28
+ reportFailure: (message) => opts.logger.warn(message),
29
+ now: opts.now,
30
+ });
31
+ return { runner, commands: catalog.commands, catalog };
32
+ }
33
+ /**
34
+ * The real interactive trust prompt: list the project's hooks (name → event →
35
+ * command) and read a single y/N key. Default-deny — anything but `y` (including
36
+ * EOF / Ctrl-C) declines, so an untrusted repo never runs on a stray keypress.
37
+ */
38
+ async function defaultTrustPrompt(info) {
39
+ const t = themeForColor(shouldUseColor());
40
+ const out = process.stderr;
41
+ out.write(`\n${t.warning(t.strong("!"))} this project defines ${t.strong(String(info.hooks.length))} hook${info.hooks.length === 1 ? "" : "s"} (authored by the repo):\n`);
42
+ for (const h of info.hooks) {
43
+ out.write(` ${t.muted(`${h.event}`)} ${t.strong(h.name)} ${t.muted("→")} ${h.command}\n`);
44
+ }
45
+ out.write(`${t.muted("review these carefully.")} trust and run this project's hooks? ${t.muted("[y/N]")} `);
46
+ const key = (await readSingleKey()).toLowerCase();
47
+ out.write("\n");
48
+ return key === "y";
49
+ }
@@ -0,0 +1,48 @@
1
+ import type { SlashCommandSpec } from "./types.js";
2
+ /**
3
+ * Custom slash-command resolution (C.19). Builtins are reserved and always win —
4
+ * a custom command can never shadow `/help`, `/exit`, etc. A resolved custom
5
+ * command is either a `prompt` (expanded to safe text fed to the agent) or a
6
+ * `shell` binding (executed through the SAME gate + sandbox as everything else,
7
+ * by the caller). Nothing here executes anything; it only resolves + expands.
8
+ */
9
+ /** The builtin slash commands (C.13) — reserved, not overridable. */
10
+ export declare const BUILTIN_SLASH_COMMANDS: readonly ["help", "clear", "compact", "reload", "plan", "exit", "quit"];
11
+ /** Is `name` (no leading slash) a reserved builtin? */
12
+ export declare function isBuiltinSlash(name: string): boolean;
13
+ /** The outcome of resolving a `/…` line against the custom catalog. */
14
+ export type SlashResolution =
15
+ /** A builtin — the REPL's own dispatch handles it (custom never shadows it). */
16
+ {
17
+ kind: "builtin";
18
+ name: string;
19
+ }
20
+ /** A prompt-template command, already expanded to the text to send the agent. */
21
+ | {
22
+ kind: "prompt";
23
+ spec: SlashCommandSpec;
24
+ prompt: string;
25
+ }
26
+ /** A shell-bound command — the caller runs `spec.command` through the gate. */
27
+ | {
28
+ kind: "shell";
29
+ spec: SlashCommandSpec;
30
+ args: string;
31
+ }
32
+ /** Not a slash line, or an unknown command. */
33
+ | {
34
+ kind: "none";
35
+ };
36
+ /**
37
+ * Resolve one input line. Non-slash input and unknown names → `none`. Builtins
38
+ * short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
39
+ * command named after a builtin is inert (surfaced separately at load time).
40
+ */
41
+ export declare function resolveSlash(line: string, commands: readonly SlashCommandSpec[]): SlashResolution;
42
+ /**
43
+ * Expand a prompt template — substitute every `{{args}}` with the caller's args.
44
+ * Pure text in, pure text out: the result is fed to the agent as a user prompt,
45
+ * so a prompt command can never execute anything (that is the whole safety of
46
+ * the default `prompt` kind).
47
+ */
48
+ export declare function expandTemplate(template: string, args: string): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Custom slash-command resolution (C.19). Builtins are reserved and always win —
3
+ * a custom command can never shadow `/help`, `/exit`, etc. A resolved custom
4
+ * command is either a `prompt` (expanded to safe text fed to the agent) or a
5
+ * `shell` binding (executed through the SAME gate + sandbox as everything else,
6
+ * by the caller). Nothing here executes anything; it only resolves + expands.
7
+ */
8
+ /** The builtin slash commands (C.13) — reserved, not overridable. */
9
+ export const BUILTIN_SLASH_COMMANDS = [
10
+ "help",
11
+ "clear",
12
+ "compact",
13
+ "reload",
14
+ "plan",
15
+ "exit",
16
+ "quit",
17
+ ];
18
+ const BUILTINS = new Set(BUILTIN_SLASH_COMMANDS);
19
+ /** Is `name` (no leading slash) a reserved builtin? */
20
+ export function isBuiltinSlash(name) {
21
+ return BUILTINS.has(name);
22
+ }
23
+ /**
24
+ * Resolve one input line. Non-slash input and unknown names → `none`. Builtins
25
+ * short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
26
+ * command named after a builtin is inert (surfaced separately at load time).
27
+ */
28
+ export function resolveSlash(line, commands) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed.startsWith("/"))
31
+ return { kind: "none" };
32
+ const space = trimmed.indexOf(" ");
33
+ const name = (space === -1 ? trimmed.slice(1) : trimmed.slice(1, space)).trim();
34
+ const args = space === -1 ? "" : trimmed.slice(space + 1).trim();
35
+ if (name === "")
36
+ return { kind: "none" };
37
+ if (isBuiltinSlash(name))
38
+ return { kind: "builtin", name };
39
+ const spec = commands.find((c) => c.name === name);
40
+ if (!spec)
41
+ return { kind: "none" };
42
+ if (spec.kind === "shell")
43
+ return { kind: "shell", spec, args };
44
+ return {
45
+ kind: "prompt",
46
+ spec,
47
+ prompt: expandTemplate(spec.template ?? "", args),
48
+ };
49
+ }
50
+ /**
51
+ * Expand a prompt template — substitute every `{{args}}` with the caller's args.
52
+ * Pure text in, pure text out: the result is fed to the agent as a user prompt,
53
+ * so a prompt command can never execute anything (that is the whole safety of
54
+ * the default `prompt` kind).
55
+ */
56
+ export function expandTemplate(template, args) {
57
+ return template.replaceAll("{{args}}", args);
58
+ }
@@ -0,0 +1,46 @@
1
+ import type { HookDefinition, HookTrust } from "./types.js";
2
+ /**
3
+ * The project-hook trust model (C.19). Trust is recorded in the GLOBAL dir
4
+ * (`~/.cruxy/trust.json`) — in the user's home, NEVER inside a repo — so cloning
5
+ * a repo carries zero trust and an attacker cannot ship a pre-trusted marker.
6
+ *
7
+ * Trust is bound to a {@link fingerprintHooks fingerprint} of the exact project
8
+ * hook commands seen at trust time. It is re-checked on every run: if the
9
+ * project's hooks change (a command / event / blocking edit), the fingerprint no
10
+ * longer matches and trust is stale → the user is re-prompted. This is what
11
+ * defeats trust-then-swap.
12
+ */
13
+ /** ~/.cruxy/trust.json */
14
+ export declare function trustPath(): string;
15
+ /**
16
+ * A stable content fingerprint of a repo's PROJECT hook definitions. Canonical
17
+ * by construction so a benign reformat of `hooks.json` (reindent, reordered
18
+ * keys, extra whitespace inside a command) does NOT change it, while any real
19
+ * change to a command, event, or blocking flag DOES:
20
+ * - only the meaning-bearing fields are hashed (name, event, command, blocking);
21
+ * - the command string is whitespace-normalized (trim + collapse runs);
22
+ * - specs are sorted by name and serialized with a fixed key order.
23
+ */
24
+ export declare function fingerprintHooks(hooks: readonly HookDefinition[]): string;
25
+ /** The persisted trust seam — a file-backed store in production, injectable for
26
+ * tests. `get`/`record` are synchronous (the record is tiny). */
27
+ export interface TrustStore {
28
+ /** The recorded decision for a repo root, or undefined if never trusted. */
29
+ get(root: string): HookTrust | undefined;
30
+ /** Persist a trust decision (overwrites any prior one for the same root). */
31
+ record(trust: HookTrust): void;
32
+ }
33
+ /**
34
+ * Is this repo's current project-hook set trusted? True only when a decision
35
+ * exists AND its fingerprint matches the current one — a changed hook set is
36
+ * treated as untrusted (stale), forcing a fresh decision.
37
+ */
38
+ export declare function isTrusted(store: TrustStore, root: string, currentFingerprint: string): boolean;
39
+ /**
40
+ * The real store, persisting to `~/.cruxy/trust.json` as `{ [root]: HookTrust }`.
41
+ * Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
42
+ * (fail-closed — a broken trust file must never grant trust).
43
+ */
44
+ export declare function fileTrustStore(file?: string): TrustStore;
45
+ /** An in-memory store for tests (and any ephemeral run). */
46
+ export declare function memoryTrustStore(seed?: HookTrust[]): TrustStore;
@@ -0,0 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { globalDir } from "../config/paths.js";
5
+ import { TRUST_FILE_NAME } from "../constants.js";
6
+ /**
7
+ * The project-hook trust model (C.19). Trust is recorded in the GLOBAL dir
8
+ * (`~/.cruxy/trust.json`) — in the user's home, NEVER inside a repo — so cloning
9
+ * a repo carries zero trust and an attacker cannot ship a pre-trusted marker.
10
+ *
11
+ * Trust is bound to a {@link fingerprintHooks fingerprint} of the exact project
12
+ * hook commands seen at trust time. It is re-checked on every run: if the
13
+ * project's hooks change (a command / event / blocking edit), the fingerprint no
14
+ * longer matches and trust is stale → the user is re-prompted. This is what
15
+ * defeats trust-then-swap.
16
+ */
17
+ /** ~/.cruxy/trust.json */
18
+ export function trustPath() {
19
+ return path.join(globalDir(), TRUST_FILE_NAME);
20
+ }
21
+ /**
22
+ * A stable content fingerprint of a repo's PROJECT hook definitions. Canonical
23
+ * by construction so a benign reformat of `hooks.json` (reindent, reordered
24
+ * keys, extra whitespace inside a command) does NOT change it, while any real
25
+ * change to a command, event, or blocking flag DOES:
26
+ * - only the meaning-bearing fields are hashed (name, event, command, blocking);
27
+ * - the command string is whitespace-normalized (trim + collapse runs);
28
+ * - specs are sorted by name and serialized with a fixed key order.
29
+ */
30
+ export function fingerprintHooks(hooks) {
31
+ const canonical = hooks
32
+ .map((h) => ({
33
+ name: h.name,
34
+ event: h.event,
35
+ command: h.command.trim().replace(/\s+/g, " "),
36
+ blocking: h.blocking,
37
+ }))
38
+ .sort((a, b) => a.name.localeCompare(b.name))
39
+ // Fixed key order — JSON of an object literal preserves insertion order.
40
+ .map((h) => [h.name, h.event, h.command, h.blocking]);
41
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
42
+ }
43
+ /**
44
+ * Is this repo's current project-hook set trusted? True only when a decision
45
+ * exists AND its fingerprint matches the current one — a changed hook set is
46
+ * treated as untrusted (stale), forcing a fresh decision.
47
+ */
48
+ export function isTrusted(store, root, currentFingerprint) {
49
+ const record = store.get(path.resolve(root));
50
+ return record !== undefined && record.fingerprint === currentFingerprint;
51
+ }
52
+ // ── file-backed store ─────────────────────────────────────────────────────────
53
+ /**
54
+ * The real store, persisting to `~/.cruxy/trust.json` as `{ [root]: HookTrust }`.
55
+ * Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
56
+ * (fail-closed — a broken trust file must never grant trust).
57
+ */
58
+ export function fileTrustStore(file = trustPath()) {
59
+ let cache = null;
60
+ const load = () => {
61
+ if (cache)
62
+ return cache;
63
+ try {
64
+ const raw = JSON.parse(readFileSync(file, "utf8"));
65
+ cache =
66
+ raw && typeof raw === "object"
67
+ ? raw
68
+ : {};
69
+ }
70
+ catch {
71
+ // Missing or corrupt → no trust (fail-closed).
72
+ cache = {};
73
+ }
74
+ return cache;
75
+ };
76
+ return {
77
+ get(root) {
78
+ return load()[path.resolve(root)];
79
+ },
80
+ record(trust) {
81
+ const store = load();
82
+ store[path.resolve(trust.root)] = {
83
+ ...trust,
84
+ root: path.resolve(trust.root),
85
+ };
86
+ mkdirSync(path.dirname(file), { recursive: true });
87
+ // 0600: trust records name local paths; keep them owner-only.
88
+ writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
89
+ if (existsSync(file))
90
+ cache = store;
91
+ },
92
+ };
93
+ }
94
+ /** An in-memory store for tests (and any ephemeral run). */
95
+ export function memoryTrustStore(seed = []) {
96
+ const store = new Map();
97
+ for (const t of seed)
98
+ store.set(path.resolve(t.root), t);
99
+ return {
100
+ get: (root) => store.get(path.resolve(root)),
101
+ record: (trust) => void store.set(path.resolve(trust.root), {
102
+ ...trust,
103
+ root: path.resolve(trust.root),
104
+ }),
105
+ };
106
+ }
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Types for hooks + custom slash commands (C.19). Everything here is **data**:
4
+ * hook and command definitions are validated (fail-loud, malformed excluded) and
5
+ * NEVER eval'd — a hook's `command` string only ever reaches execution through
6
+ * the shared U.3-gated + C.16-sandboxed shell path (see `runner.ts`), exactly
7
+ * like an agent-run command.
8
+ */
9
+ /**
10
+ * The core hook lifecycle events. Deliberately a small, closed set for this
11
+ * build; the enum is the extension point for future events (out of scope).
12
+ * - `before-run` / `after-run` — around a whole user turn (the agent loop).
13
+ * - `before-tool` / `after-tool` — around each tool call.
14
+ * - `on-file-change` — after a file-mutating tool (write/edit/patch) succeeds.
15
+ */
16
+ export declare const HOOK_EVENTS: readonly ["before-run", "after-run", "before-tool", "after-tool", "on-file-change"];
17
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
18
+ /** `before-*` events default to blocking (fail-closed pre-checks); everything
19
+ * that fires *after* an action defaults to advisory. Overridable per hook. */
20
+ export declare function defaultBlocking(event: HookEvent): boolean;
21
+ /** Where a hook / command was discovered. `project` is the supply-chain-risky
22
+ * source (another author); `user` is your own `~/.cruxy`. */
23
+ export type HookSource = "project" | "user";
24
+ /** Precedence: a project definition overrides a user one of the same name. */
25
+ export declare const HOOK_SOURCE_PRECEDENCE: readonly HookSource[];
26
+ /** Strict schema for one hook definition in a `hooks.json`. Unknown keys are a
27
+ * loud error (`.strict()`); `blocking` is optional (resolved from the event). */
28
+ export declare const HookSpecSchema: z.ZodObject<{
29
+ name: z.ZodString;
30
+ event: z.ZodEnum<["before-run", "after-run", "before-tool", "after-tool", "on-file-change"]>;
31
+ command: z.ZodString;
32
+ blocking: z.ZodOptional<z.ZodBoolean>;
33
+ }, "strict", z.ZodTypeAny, {
34
+ name: string;
35
+ command: string;
36
+ event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
37
+ blocking?: boolean | undefined;
38
+ }, {
39
+ name: string;
40
+ command: string;
41
+ event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
42
+ blocking?: boolean | undefined;
43
+ }>;
44
+ export type HookSpec = z.infer<typeof HookSpecSchema>;
45
+ /** The whole `hooks.json` file shape. */
46
+ export declare const HooksFileSchema: z.ZodObject<{
47
+ hooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
48
+ name: z.ZodString;
49
+ event: z.ZodEnum<["before-run", "after-run", "before-tool", "after-tool", "on-file-change"]>;
50
+ command: z.ZodString;
51
+ blocking: z.ZodOptional<z.ZodBoolean>;
52
+ }, "strict", z.ZodTypeAny, {
53
+ name: string;
54
+ command: string;
55
+ event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
56
+ blocking?: boolean | undefined;
57
+ }, {
58
+ name: string;
59
+ command: string;
60
+ event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
61
+ blocking?: boolean | undefined;
62
+ }>, "many">>;
63
+ }, "strict", z.ZodTypeAny, {
64
+ hooks: {
65
+ name: string;
66
+ command: string;
67
+ event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
68
+ blocking?: boolean | undefined;
69
+ }[];
70
+ }, {
71
+ hooks?: {
72
+ name: string;
73
+ command: string;
74
+ event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
75
+ blocking?: boolean | undefined;
76
+ }[] | undefined;
77
+ }>;
78
+ /** A validated hook, tagged with its source and with `blocking` resolved. */
79
+ export interface HookDefinition {
80
+ name: string;
81
+ event: HookEvent;
82
+ command: string;
83
+ /** Resolved (event default applied) — the value the runner acts on. */
84
+ blocking: boolean;
85
+ source: HookSource;
86
+ }
87
+ /**
88
+ * A custom slash command. `prompt` (the safe default) expands a text template
89
+ * fed to the agent; `shell` binds a command that runs through the SAME gate +
90
+ * sandbox as everything else. Never arbitrary code by default.
91
+ */
92
+ export type SlashKind = "prompt" | "shell";
93
+ /** Strict frontmatter for a `commands/<name>.md`. The command NAME comes from
94
+ * the filename; the markdown BODY is the prompt template (kind `prompt`). */
95
+ export declare const SlashFrontmatterSchema: z.ZodObject<{
96
+ kind: z.ZodDefault<z.ZodEnum<["prompt", "shell"]>>;
97
+ description: z.ZodString;
98
+ /** Required (and only meaningful) when `kind: "shell"`. */
99
+ command: z.ZodOptional<z.ZodString>;
100
+ }, "strict", z.ZodTypeAny, {
101
+ kind: "shell" | "prompt";
102
+ description: string;
103
+ command?: string | undefined;
104
+ }, {
105
+ description: string;
106
+ command?: string | undefined;
107
+ kind?: "shell" | "prompt" | undefined;
108
+ }>;
109
+ export type SlashFrontmatter = z.infer<typeof SlashFrontmatterSchema>;
110
+ /** A validated custom slash command, tagged with source. */
111
+ export interface SlashCommandSpec {
112
+ name: string;
113
+ kind: SlashKind;
114
+ description: string;
115
+ /** Prompt template (kind `prompt`); `{{args}}` is substituted at expansion. */
116
+ template?: string;
117
+ /** Shell command (kind `shell`) — gated + sandboxed when run. */
118
+ command?: string;
119
+ source: HookSource;
120
+ }
121
+ /** A malformed hook / command, excluded from the catalog and surfaced loudly
122
+ * (never eval'd, never silently dropped). */
123
+ export interface HookConfigError {
124
+ source: HookSource;
125
+ /** Absolute path of the offending file. */
126
+ file: string;
127
+ /** The would-be name (filename / spec name), for display. */
128
+ name: string;
129
+ message: string;
130
+ }
131
+ /** The resolved hook catalog: valid definitions (deduped by precedence) + the
132
+ * malformed ones that were excluded. */
133
+ export interface HookCatalog {
134
+ hooks: HookDefinition[];
135
+ commands: SlashCommandSpec[];
136
+ errors: HookConfigError[];
137
+ }
138
+ /** One repo's recorded trust decision (persisted in `~/.cruxy/trust.json`). The
139
+ * `fingerprint` binds trust to the exact hook commands seen at trust time. */
140
+ export interface HookTrust {
141
+ /** Absolute project root. */
142
+ root: string;
143
+ /** sha256 of the canonicalized project hook specs (see `trust.ts`). */
144
+ fingerprint: string;
145
+ /** ISO timestamp the decision was recorded. */
146
+ at: string;
147
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Types for hooks + custom slash commands (C.19). Everything here is **data**:
4
+ * hook and command definitions are validated (fail-loud, malformed excluded) and
5
+ * NEVER eval'd — a hook's `command` string only ever reaches execution through
6
+ * the shared U.3-gated + C.16-sandboxed shell path (see `runner.ts`), exactly
7
+ * like an agent-run command.
8
+ */
9
+ // ── lifecycle events ──────────────────────────────────────────────────────────
10
+ /**
11
+ * The core hook lifecycle events. Deliberately a small, closed set for this
12
+ * build; the enum is the extension point for future events (out of scope).
13
+ * - `before-run` / `after-run` — around a whole user turn (the agent loop).
14
+ * - `before-tool` / `after-tool` — around each tool call.
15
+ * - `on-file-change` — after a file-mutating tool (write/edit/patch) succeeds.
16
+ */
17
+ export const HOOK_EVENTS = [
18
+ "before-run",
19
+ "after-run",
20
+ "before-tool",
21
+ "after-tool",
22
+ "on-file-change",
23
+ ];
24
+ /** `before-*` events default to blocking (fail-closed pre-checks); everything
25
+ * that fires *after* an action defaults to advisory. Overridable per hook. */
26
+ export function defaultBlocking(event) {
27
+ return event === "before-run" || event === "before-tool";
28
+ }
29
+ /** Precedence: a project definition overrides a user one of the same name. */
30
+ export const HOOK_SOURCE_PRECEDENCE = [
31
+ "project",
32
+ "user",
33
+ ];
34
+ // ── hook specs ────────────────────────────────────────────────────────────────
35
+ /** Strict schema for one hook definition in a `hooks.json`. Unknown keys are a
36
+ * loud error (`.strict()`); `blocking` is optional (resolved from the event). */
37
+ export const HookSpecSchema = z
38
+ .object({
39
+ name: z
40
+ .string()
41
+ .min(1)
42
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "hook name must be kebab-case (lowercase letters, digits, hyphens)"),
43
+ event: z.enum(HOOK_EVENTS),
44
+ command: z.string().min(1, "hook command must be a non-empty string"),
45
+ blocking: z.boolean().optional(),
46
+ })
47
+ .strict();
48
+ /** The whole `hooks.json` file shape. */
49
+ export const HooksFileSchema = z
50
+ .object({ hooks: z.array(HookSpecSchema).default([]) })
51
+ .strict();
52
+ /** Strict frontmatter for a `commands/<name>.md`. The command NAME comes from
53
+ * the filename; the markdown BODY is the prompt template (kind `prompt`). */
54
+ export const SlashFrontmatterSchema = z
55
+ .object({
56
+ kind: z.enum(["prompt", "shell"]).default("prompt"),
57
+ description: z.string().min(1),
58
+ /** Required (and only meaningful) when `kind: "shell"`. */
59
+ command: z.string().min(1).optional(),
60
+ })
61
+ .strict();