@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.
- package/dist/agent/loop.d.ts +26 -0
- package/dist/agent/loop.js +59 -3
- package/dist/agent/session.d.ts +17 -1
- package/dist/agent/session.js +23 -2
- package/dist/brand/index.d.ts +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/voice.d.ts +94 -0
- package/dist/brand/voice.js +127 -0
- package/dist/cli/commands/checkpoint.js +1 -1
- package/dist/cli/commands/hooks.d.ts +8 -0
- package/dist/cli/commands/hooks.js +83 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/pr.js +10 -2
- package/dist/cli/commands/rollback.js +1 -1
- package/dist/cli/commands/run.js +13 -3
- package/dist/cli/commands/skills.js +2 -2
- package/dist/cli/program.js +5 -2
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +54 -3
- package/dist/cli/session-factory.d.ts +2 -2
- package/dist/cli/session-factory.js +13 -2
- package/dist/config/schema.d.ts +139 -46
- package/dist/config/schema.js +42 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +9 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +98 -6
- package/dist/errors/types.d.ts +14 -0
- package/dist/errors/types.js +23 -0
- package/dist/hooks/config.d.ts +21 -0
- package/dist/hooks/config.js +253 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/runner.d.ts +76 -0
- package/dist/hooks/runner.js +114 -0
- package/dist/hooks/service.d.ts +38 -0
- package/dist/hooks/service.js +49 -0
- package/dist/hooks/slash.d.ts +48 -0
- package/dist/hooks/slash.js +58 -0
- package/dist/hooks/trust.d.ts +46 -0
- package/dist/hooks/trust.js +106 -0
- package/dist/hooks/types.d.ts +147 -0
- package/dist/hooks/types.js +61 -0
- package/dist/onboarding/steps.js +1 -1
- package/dist/plan/service.d.ts +6 -0
- package/dist/plan/service.js +4 -0
- package/dist/render/state.js +4 -1
- package/dist/render/types.d.ts +7 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +5 -0
- package/dist/routing/resolve.d.ts +17 -0
- package/dist/routing/resolve.js +18 -0
- package/dist/routing/router.d.ts +47 -0
- package/dist/routing/router.js +84 -0
- package/dist/routing/types.d.ts +42 -0
- package/dist/routing/types.js +27 -0
- package/dist/subagent/orchestrator.d.ts +6 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/subagent/types.d.ts +6 -0
- package/dist/tools/shell/exec.d.ts +53 -0
- package/dist/tools/shell/exec.js +128 -0
- package/dist/tools/shell/run-command.d.ts +4 -0
- package/dist/tools/shell/run-command.js +26 -116
- package/dist/vcs/generate.d.ts +3 -1
- package/dist/vcs/generate.js +4 -1
- 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();
|