@foldrun/core 0.7.0 → 1.1.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 (49) hide show
  1. package/README.md +14 -1
  2. package/dist/src/completions.js +1 -0
  3. package/dist/src/deploy.js +5 -0
  4. package/dist/src/egress.d.ts +21 -0
  5. package/dist/src/egress.js +8 -0
  6. package/dist/src/hooks.d.ts +114 -0
  7. package/dist/src/hooks.js +370 -0
  8. package/dist/src/kinds.d.ts +1 -1
  9. package/dist/src/kinds.js +26 -0
  10. package/dist/src/library.d.ts +1 -1
  11. package/dist/src/library.js +17 -1
  12. package/dist/src/mail.js +1 -1
  13. package/dist/src/network.d.ts +120 -0
  14. package/dist/src/network.js +360 -0
  15. package/dist/src/notify.d.ts +8 -0
  16. package/dist/src/notify.js +10 -1
  17. package/dist/src/providers.d.ts +5 -0
  18. package/dist/src/providers.js +21 -1
  19. package/dist/src/run-container.d.ts +13 -0
  20. package/dist/src/run-container.js +8 -1
  21. package/dist/src/runner.d.ts +1 -0
  22. package/dist/src/runner.js +144 -16
  23. package/dist/src/secrets.d.ts +20 -0
  24. package/dist/src/secrets.js +65 -1
  25. package/dist/src/starter.js +9 -1
  26. package/dist/src/step-exec.d.ts +10 -0
  27. package/dist/src/step-exec.js +113 -2
  28. package/dist/src/store.d.ts +23 -1
  29. package/dist/src/store.js +33 -1
  30. package/dist/src/tool-loop.d.ts +45 -0
  31. package/dist/src/tool-loop.js +133 -0
  32. package/package.json +19 -1
  33. package/src/completions.ts +1 -0
  34. package/src/deploy.ts +4 -0
  35. package/src/egress.ts +26 -0
  36. package/src/hooks.ts +406 -0
  37. package/src/kinds.ts +28 -0
  38. package/src/library.ts +18 -2
  39. package/src/mail.ts +1 -1
  40. package/src/network.ts +390 -0
  41. package/src/notify.ts +11 -1
  42. package/src/providers.ts +27 -1
  43. package/src/run-container.ts +16 -2
  44. package/src/runner.ts +145 -14
  45. package/src/secrets.ts +69 -1
  46. package/src/starter.ts +9 -1
  47. package/src/step-exec.ts +115 -3
  48. package/src/store.ts +39 -1
  49. package/src/tool-loop.ts +176 -0
package/README.md CHANGED
@@ -222,9 +222,22 @@ allow products built on its Agent SDK to offer claude.ai login unless
222
222
  Anthropic has approved them, so check that the terms cover your use before
223
223
  you rely on a login token.
224
224
 
225
+ ## Versions
226
+
227
+ [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html): a patch is
228
+ a bug fix, a minor adds something and breaks nothing, a major breaks
229
+ something and says what. The promise covers the **public API**, the entry
230
+ points listed in `package.json` under `foldrun.publicApi`: `@foldrun/core`
231
+ itself and the subpaths the `foldrun` CLI imports. Every other module under
232
+ `@foldrun/core/*` can be reached, because the runner image imports its own
233
+ copy by name, but it is internal and may change in any release. Deprecated
234
+ names say so in their doc comment for at least one minor before a major
235
+ removes them. Pre-releases are on npm's `next` tag. How a release decides
236
+ its number: [CONTRIBUTING.md](CONTRIBUTING.md#versions).
237
+
225
238
  ## Status
226
239
 
227
- v0.1. Local and hosted runs work. Hosted runs go through a durable on-disk
240
+ Local and hosted runs work. Hosted runs go through a durable on-disk
228
241
  queue (survives restarts, parks at approval gates), the dashboard has
229
242
  accounts and sessions (scrypt + signed cookies; first signup is free, then
230
243
  `FOLDRUN_OPEN_SIGNUP=1`), spending lands in an append-only per-account
@@ -98,6 +98,7 @@ const FIELDS = {
98
98
  { label: "disallowedTools", insert: "disallowedTools:\n - " },
99
99
  { label: "subagents", insert: "subagents:\n - ", hint: "colleagues it may delegate a job to — own context, own tools, no wider than its own" },
100
100
  { label: "limits", insert: "limits:\n web.search: 40\n calls: 300", hint: "per step: a call past a limit is refused, never run" },
101
+ { label: "network", insert: "network:\n - api.example.com", hint: "sites the sandbox may reach: open | trusted | none, or a list added to the trusted list" },
101
102
  {
102
103
  label: "provider",
103
104
  insert: "provider:\n name: \n token: ${PROVIDER_TOKEN}",
@@ -28,6 +28,7 @@ import { conformanceIssues } from "./okf.js";
28
28
  import { timezoneProblem } from "./clock.js";
29
29
  import { webProblems, webWarnings, runnerEngines } from "./providers.js";
30
30
  import { readLimits } from "./limits.js";
31
+ import { networkProblems } from "./network.js";
31
32
  import { actionProblems } from "./web-actions.js";
32
33
  import { languageProblem } from "./language.js";
33
34
  import { localeProblems } from "./locale.js";
@@ -306,6 +307,10 @@ export function deployIssues(files) {
306
307
  // action> — would run uncapped where the author meant a cap.
307
308
  for (const w of readLimits(front.limits).problems)
308
309
  at(f.path, w);
310
+ // `network:` that cannot be read would leave the step on the level
311
+ // above it — wider or narrower than the author wrote.
312
+ for (const w of networkProblems(front.network))
313
+ at(f.path, w);
309
314
  const lang = languageProblem(front.language);
310
315
  if (lang)
311
316
  at(f.path, lang);
@@ -1,4 +1,5 @@
1
1
  import type { TestEffect } from "./test-mode.ts";
2
+ import type { NetworkLevel } from "./network.ts";
2
3
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
3
4
  export declare const PLACEHOLDER: RegExp;
4
5
  /** The env var the pod reads to know it should send through a proxy. */
@@ -41,10 +42,30 @@ export interface EgressGrant {
41
42
  * that has to put a rate-limit reset into words — and a reset time is
42
43
  * only useful in the calendar the step works to. See refusal.ts. */
43
44
  timezone?: string;
45
+ /** Which sites the step may reach through the proxy's forward door
46
+ * (network.ts). Absent on a grant from a runner that predates it: the
47
+ * forward door then allows only the hosts the secrets were granted to. */
48
+ network?: {
49
+ level: NetworkLevel;
50
+ hosts: string[];
51
+ };
52
+ /** Refuse a site off the list (true), or allow it and name it in the
53
+ * trace (false: report only). Set by the platform when it leases. */
54
+ enforce?: boolean;
44
55
  }
45
56
  export interface EgressLease {
46
57
  /** The base the pod sends through: `http://…/e/<token>`. */
47
58
  url: string;
59
+ /** The forward-proxy address for HTTP_PROXY / HTTPS_PROXY —
60
+ * `http://foldrun:<token>@host:port` — when the platform's proxy has the
61
+ * forward door. Absent on an older proxy: programs then go direct. */
62
+ proxy?: string;
63
+ /** The PEM of the CA the forward door signs with for a host a secret is
64
+ * bound to, so the sandbox can trust exactly those interceptions. */
65
+ ca?: string;
66
+ /** Whether a site off the step's list is refused (true) or only named
67
+ * in the trace (false). */
68
+ enforce?: boolean;
48
69
  /** The grant is finished being built — publish it. The runner keeps
49
70
  * adding grants after the lease exists (the model key's host needs the
50
71
  * lease URL first), so a store that is not this process's memory has
@@ -31,6 +31,14 @@
31
31
  // `FOLDRUN_EGRESS` instead, and sends through the proxy like everything
32
32
  // else.
33
33
  //
34
+ // A secret BOUND to sites in the vault (`foldrun secrets set NAME --site
35
+ // api.example.com`) is never materialised, whatever the step grants: the
36
+ // pod's programs all send through the proxy's forward door (HTTPS_PROXY),
37
+ // and for a bound site the door terminates TLS with the platform's egress CA
38
+ // — which the sandbox trusts — and fills `${NAME}` in the URL and headers on
39
+ // the way out, the way Anthropic's cloud sandboxes attach a network secret.
40
+ // Every other site is a tunnel the proxy does not read.
41
+ //
34
42
  // This file is the vocabulary both sides share. The server is the
35
43
  // platform's (it needs a long-lived process and a Service); the pod side is
36
44
  // here in core, because it is what the runner image runs.
@@ -0,0 +1,114 @@
1
+ export declare const HOOK_EVENTS: readonly ["tool.call", "tool.result", "step.start", "step.end"];
2
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
3
+ export declare const HOOK_TIMEOUT_DEFAULT: {
4
+ readonly gate: 30;
5
+ readonly watch: 60;
6
+ };
7
+ export declare const HOOK_TIMEOUT_MAX = 120;
8
+ export declare const HOOK_CODE_MAX: number;
9
+ export interface HookDef {
10
+ name: string;
11
+ /** The folder holding hook.md and run.mjs. */
12
+ dir: string;
13
+ scope: "workspace" | "account";
14
+ on: HookEvent;
15
+ /** `tool:` — which tools a tool.call / tool.result hook sees. Unset is all. */
16
+ tool?: string;
17
+ timeoutSec: number;
18
+ description?: string;
19
+ /** run.mjs as it was when the step started. The hook runs from this copy,
20
+ * so an agent that edits the file mid-step changes nothing that runs. */
21
+ code: string;
22
+ }
23
+ /** Read hook.md, or say in one sentence why it is not a hook. */
24
+ export declare function readHookDef(dir: string, scope: HookDef["scope"]): HookDef | {
25
+ error: string;
26
+ };
27
+ /** Every hook folder in a hooks/ directory. */
28
+ export declare function listHookDirs(hooksDir: string): string[];
29
+ /** The names a frontmatter `hooks:` holds — a list, or one name. */
30
+ export declare function hookNames(raw: unknown): string[];
31
+ /**
32
+ * The hooks one agent runs, in order: what the account turned on, then the
33
+ * workspace, then the agent itself, each name once. A name resolves to the
34
+ * workspace's own hooks/ first, then the library's. Names that resolve to
35
+ * nothing come back in `missing` — `foldrun check` errors on them, and a run
36
+ * refuses to start the step rather than run unguarded.
37
+ */
38
+ export declare function resolveHooks(args: {
39
+ workspaceRoot: string;
40
+ libraryRoot: string;
41
+ names: string[];
42
+ }): {
43
+ hooks: HookDef[];
44
+ missing: string[];
45
+ errors: string[];
46
+ };
47
+ /**
48
+ * Does `pattern` name this tool? Alternatives split on `|`, `*` matches any
49
+ * run of characters. A pattern without `__` also matches a tool's short
50
+ * name, so `web` catches `mcp__foldrun_scripts__web` and `Bash` catches Bash.
51
+ */
52
+ export declare function matchesTool(pattern: string | undefined, tool: string): boolean;
53
+ /** What one hook said, normalised. */
54
+ export interface HookAnswer {
55
+ ok: boolean;
56
+ /** tool.call */
57
+ decision?: "allow" | "deny";
58
+ reason?: string;
59
+ input?: Record<string, unknown>;
60
+ /** step.start */
61
+ context?: string;
62
+ /** step.end */
63
+ reply?: string;
64
+ /** tool.result / anything: a line for the trail */
65
+ note?: string;
66
+ /** Why it failed — threw, timed out, wrong shape. */
67
+ failure?: string;
68
+ ms: number;
69
+ }
70
+ export declare function hookEnv(from: Record<string, string | undefined>, hook: HookDef): Record<string, string>;
71
+ /** Run one hook on one event. Never throws: a failure is an answer. */
72
+ export declare function runHook(hook: HookDef, event: Record<string, unknown>, opts: {
73
+ cwd: string;
74
+ env: Record<string, string | undefined>;
75
+ node?: string;
76
+ }): Promise<HookAnswer>;
77
+ /** The last JSON object a hook printed, checked against its event's shape. */
78
+ export declare function parseAnswer(on: HookEvent, stdout: string): Omit<HookAnswer, "ms">;
79
+ /** What a run of tool.call hooks decided for one call. */
80
+ export interface ToolCallVerdict {
81
+ decision: "allow" | "deny";
82
+ reason?: string;
83
+ /** The input after every rewrite, when any hook rewrote it. */
84
+ input?: Record<string, unknown>;
85
+ /** One line per hook that did something, for the trail. */
86
+ records: HookRecord[];
87
+ }
88
+ /** One hook's effect, as the trail keeps it. */
89
+ export interface HookRecord {
90
+ hook: string;
91
+ event: HookEvent;
92
+ /** deny | rewrite | context | reply | note | error */
93
+ did: "deny" | "rewrite" | "context" | "reply" | "note" | "error";
94
+ tool?: string;
95
+ reason?: string;
96
+ ms: number;
97
+ }
98
+ /** A hook record as it crosses from the sandbox: checked, never trusted. */
99
+ export declare function isHookRecord(x: unknown): x is HookRecord;
100
+ export declare function isGate(on: HookEvent): boolean;
101
+ /**
102
+ * tool.call: every matching hook in order. The first deny ends it — the most
103
+ * restrictive answer wins; a rewrite is handed on to the next hook, so they
104
+ * chain in the order they were turned on. A hook that fails denies.
105
+ */
106
+ export declare function runToolCallHooks(hooks: HookDef[], call: {
107
+ tool: string;
108
+ input: Record<string, unknown>;
109
+ base: Record<string, unknown>;
110
+ }, run: (hook: HookDef, event: Record<string, unknown>) => Promise<HookAnswer>): Promise<ToolCallVerdict>;
111
+ /** The trail's line for a record. */
112
+ export declare function hookLine(r: HookRecord): string;
113
+ /** For `foldrun check`: every problem with the hooks/ folder of one root. */
114
+ export declare function checkHooksDir(hooksDir: string, scope: HookDef["scope"]): string[];
@@ -0,0 +1,370 @@
1
+ // Hooks: a person's own code at the points every model's step passes through.
2
+ //
3
+ // A hook is a folder — `hooks/<name>/hook.md` (what it listens for) beside
4
+ // `run.mjs` (what it does) — in a workspace, or in the account library for
5
+ // every workspace. An agent turns one on with `hooks: [name]`; a workspace's
6
+ // or the account's AGENTS.md turns one on for every agent under it.
7
+ //
8
+ // Where they run, and why there: at foldrun's own dispatch, the step's
9
+ // PreToolUse / PostToolUse callbacks in step-exec.ts. Every model goes
10
+ // through that one loop — another provider's model is the same loop with the
11
+ // translator changing the wire format underneath — so one hook decides the
12
+ // same way on every model. Nothing here is tied to one provider's features
13
+ // (thinking blocks, effort, server tools).
14
+ //
15
+ // The contract, kept to what the established agent tools agree on (Claude
16
+ // Code, the Agent SDK, Cursor, Copilot, Gemini CLI): the event goes in as
17
+ // one JSON object on stdin, an answer comes back as one JSON object on
18
+ // stdout. Exit 0 with an answer; exit 2 denies with stderr as the reason
19
+ // (the common convention); anything else is a failure.
20
+ //
21
+ // Gates fail closed. A tool.call or step.start hook that throws, times out or
22
+ // answers with the wrong shape refuses the call — a guard that crashes must
23
+ // not become an open door. A watcher (tool.result, step.end) that fails is
24
+ // recorded and the step goes on: what it watches has already happened.
25
+ //
26
+ // A hook has no more reach than the agent: it runs as a child of the step,
27
+ // inside the step's own sandbox, in the agent's directory, with an
28
+ // environment stripped to the basics — no secrets, no model key.
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import os from "node:os";
32
+ import { spawn } from "node:child_process";
33
+ import matter from "gray-matter";
34
+ export const HOOK_EVENTS = ["tool.call", "tool.result", "step.start", "step.end"];
35
+ /** Events that decide something before it happens: a failure refuses. */
36
+ const GATES = new Set(["tool.call", "step.start"]);
37
+ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 };
38
+ export const HOOK_TIMEOUT_MAX = 120;
39
+ export const HOOK_CODE_MAX = 100 * 1024;
40
+ const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
41
+ /** Read hook.md, or say in one sentence why it is not a hook. */
42
+ export function readHookDef(dir, scope) {
43
+ const name = path.basename(dir);
44
+ if (!NAME.test(name))
45
+ return { error: `hooks/${name}: a hook's folder name is lowercase letters, digits and dashes` };
46
+ const md = path.join(dir, "hook.md");
47
+ if (!fs.existsSync(md))
48
+ return { error: `hooks/${name}: no hook.md` };
49
+ if (!fs.existsSync(path.join(dir, "run.mjs")))
50
+ return { error: `hooks/${name}: no run.mjs beside hook.md` };
51
+ let data;
52
+ try {
53
+ data = (matter(fs.readFileSync(md, "utf8")).data ?? {});
54
+ }
55
+ catch (err) {
56
+ return { error: `hooks/${name}/hook.md: frontmatter does not parse (${err.message.split("\n")[0]})` };
57
+ }
58
+ const on = data.on;
59
+ if (typeof on !== "string" || !HOOK_EVENTS.includes(on)) {
60
+ return { error: `hooks/${name}/hook.md: on: must be one of ${HOOK_EVENTS.join(", ")}${on === "run.end" ? " (run.end is not available yet)" : ""}` };
61
+ }
62
+ const event = on;
63
+ if (data.tool !== undefined && (typeof data.tool !== "string" || !data.tool.trim())) {
64
+ return { error: `hooks/${name}/hook.md: tool: is a tool name or pattern, like Bash, Write|Edit or mcp__*` };
65
+ }
66
+ if (data.tool !== undefined && event !== "tool.call" && event !== "tool.result") {
67
+ return { error: `hooks/${name}/hook.md: tool: only means something on tool.call and tool.result` };
68
+ }
69
+ // It runs through `node --eval`, one argument, which Linux caps at 128 KB.
70
+ const code = fs.readFileSync(path.join(dir, "run.mjs"), "utf8");
71
+ if (Buffer.byteLength(code) > HOOK_CODE_MAX) {
72
+ return { error: `hooks/${name}/run.mjs is ${Math.round(Buffer.byteLength(code) / 1024)} KB — a hook is one small self-contained module, at most ${HOOK_CODE_MAX / 1024} KB` };
73
+ }
74
+ const deflt = GATES.has(event) ? HOOK_TIMEOUT_DEFAULT.gate : HOOK_TIMEOUT_DEFAULT.watch;
75
+ const timeout = data.timeout === undefined ? deflt : Number(data.timeout);
76
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > HOOK_TIMEOUT_MAX) {
77
+ return { error: `hooks/${name}/hook.md: timeout: is seconds, 1 to ${HOOK_TIMEOUT_MAX}` };
78
+ }
79
+ return {
80
+ name,
81
+ dir,
82
+ scope,
83
+ on: event,
84
+ ...(typeof data.tool === "string" ? { tool: data.tool.trim() } : {}),
85
+ timeoutSec: timeout,
86
+ ...(typeof data.description === "string" ? { description: data.description } : {}),
87
+ code,
88
+ };
89
+ }
90
+ /** Every hook folder in a hooks/ directory. */
91
+ export function listHookDirs(hooksDir) {
92
+ if (!fs.existsSync(hooksDir))
93
+ return [];
94
+ return fs.readdirSync(hooksDir, { withFileTypes: true })
95
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
96
+ .map((d) => path.join(hooksDir, d.name))
97
+ .sort();
98
+ }
99
+ /** The names a frontmatter `hooks:` holds — a list, or one name. */
100
+ export function hookNames(raw) {
101
+ const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
102
+ return list.map((x) => String(x).trim()).filter(Boolean);
103
+ }
104
+ /**
105
+ * The hooks one agent runs, in order: what the account turned on, then the
106
+ * workspace, then the agent itself, each name once. A name resolves to the
107
+ * workspace's own hooks/ first, then the library's. Names that resolve to
108
+ * nothing come back in `missing` — `foldrun check` errors on them, and a run
109
+ * refuses to start the step rather than run unguarded.
110
+ */
111
+ export function resolveHooks(args) {
112
+ const hooks = [];
113
+ const missing = [];
114
+ const errors = [];
115
+ const seen = new Set();
116
+ for (const name of args.names) {
117
+ if (seen.has(name))
118
+ continue;
119
+ seen.add(name);
120
+ const own = path.join(args.workspaceRoot, "hooks", name);
121
+ const shared = path.join(args.libraryRoot, "hooks", name);
122
+ const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
123
+ if (!dir) {
124
+ missing.push(name);
125
+ continue;
126
+ }
127
+ const def = readHookDef(dir, dir === own ? "workspace" : "account");
128
+ if ("error" in def)
129
+ errors.push(def.error);
130
+ else
131
+ hooks.push(def);
132
+ }
133
+ return { hooks, missing, errors };
134
+ }
135
+ /**
136
+ * Does `pattern` name this tool? Alternatives split on `|`, `*` matches any
137
+ * run of characters. A pattern without `__` also matches a tool's short
138
+ * name, so `web` catches `mcp__foldrun_scripts__web` and `Bash` catches Bash.
139
+ */
140
+ export function matchesTool(pattern, tool) {
141
+ if (!pattern)
142
+ return true;
143
+ const short = tool.includes("__") ? tool.slice(tool.lastIndexOf("__") + 2) : tool;
144
+ return pattern.split("|").map((p) => p.trim()).filter(Boolean).some((p) => {
145
+ const re = new RegExp(`^${p.replace(/[.+?^${}()[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
146
+ return re.test(tool) || (!p.includes("__") && re.test(short));
147
+ });
148
+ }
149
+ /** Only these reach a hook's process: enough to run node, nothing to act with. */
150
+ const PASSED_ENV = ["PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_PATH"];
151
+ /** One empty home per hook name, made once: a hook reads no dotfile the
152
+ * step's own home holds (a CLI login, a cached token). */
153
+ function hookHome(hook) {
154
+ const dir = path.join(os.tmpdir(), "foldrun-hook-home", hook.name);
155
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
156
+ return dir;
157
+ }
158
+ export function hookEnv(from, hook) {
159
+ const env = {};
160
+ for (const k of PASSED_ENV)
161
+ if (from[k])
162
+ env[k] = from[k];
163
+ env.HOME = hookHome(hook);
164
+ env.FOLDRUN_HOOK = hook.name;
165
+ env.FOLDRUN_HOOK_EVENT = hook.on;
166
+ return env;
167
+ }
168
+ /** Run one hook on one event. Never throws: a failure is an answer. */
169
+ export async function runHook(hook, event, opts) {
170
+ const started = Date.now();
171
+ const done = (a) => ({ ...a, ms: Date.now() - started });
172
+ return new Promise((resolve) => {
173
+ let out = "";
174
+ let err = "";
175
+ let settled = false;
176
+ const finish = (a) => { if (!settled) {
177
+ settled = true;
178
+ resolve(done(a));
179
+ } };
180
+ // Typed: untyped, a stricter tsc (foldrun-web compiles this file) narrows
181
+ // it to never after the catch that returns.
182
+ let child;
183
+ try {
184
+ // From the copy taken at the start of the step, never the file: what
185
+ // guards the step cannot be edited by the step. One self-contained
186
+ // module — Node's built-ins only, no relative imports.
187
+ child = spawn(opts.node ?? process.execPath, ["--input-type=module", "--eval", hook.code], {
188
+ cwd: opts.cwd,
189
+ // Cast: Next's typings (foldrun-web compiles this file) make NODE_ENV
190
+ // required on ProcessEnv; a hook deliberately gets only hookEnv's keys.
191
+ env: hookEnv(opts.env, hook),
192
+ stdio: ["pipe", "pipe", "pipe"],
193
+ // Its own process group, so a timeout can kill what the hook started
194
+ // too: SIGKILL to node alone left a `sleep` or a curl it spawned
195
+ // running in the sandbox after the call had been refused.
196
+ detached: process.platform !== "win32",
197
+ });
198
+ }
199
+ catch (e) {
200
+ return finish({ ok: false, failure: `could not start: ${e.message}` });
201
+ }
202
+ const killAll = () => {
203
+ try {
204
+ if (child.pid && process.platform !== "win32")
205
+ process.kill(-child.pid, "SIGKILL");
206
+ else
207
+ child.kill("SIGKILL");
208
+ }
209
+ catch {
210
+ try {
211
+ child.kill("SIGKILL");
212
+ }
213
+ catch { /* already gone */ }
214
+ }
215
+ };
216
+ const timer = setTimeout(() => {
217
+ killAll();
218
+ finish({ ok: false, failure: `timed out after ${hook.timeoutSec}s` });
219
+ }, hook.timeoutSec * 1000);
220
+ // Keep the TAIL of each stream, not the head: the answer is the last
221
+ // line of stdout and the reason the last lines of stderr, so a hook that
222
+ // logged a megabyte first lost its answer under a head cap and failed.
223
+ // Decoded by the stream, so a character split across two chunks is not
224
+ // turned into replacement characters inside the JSON answer.
225
+ child.stdout.setEncoding("utf8");
226
+ child.stderr.setEncoding("utf8");
227
+ child.stdout.on("data", (b) => { out += b; if (out.length > 1_000_000)
228
+ out = out.slice(-1_000_000); });
229
+ child.stderr.on("data", (b) => { err += b; if (err.length > 20_000)
230
+ err = err.slice(-20_000); });
231
+ child.on("error", (e) => { clearTimeout(timer); finish({ ok: false, failure: `could not start: ${e.message}` }); });
232
+ child.on("close", (code) => {
233
+ clearTimeout(timer);
234
+ // A hook is one shot: whatever it left running in the background goes
235
+ // with it, rather than outlive the call it was asked about.
236
+ if (!settled)
237
+ killAll();
238
+ const stderr = err.trim().split("\n").slice(-3).join(" ").slice(0, 500);
239
+ if (code === 2)
240
+ return finish({ ok: true, decision: "deny", reason: stderr || `${hook.name} refused it` });
241
+ if (code !== 0) {
242
+ // An uncaught throw ends stderr with stack frames and "Node.js vN":
243
+ // the last three lines named neither the error nor its message.
244
+ const lines = err.split("\n").map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l) && !/^at /.test(l));
245
+ const thrown = [...lines].reverse().find((l) => /^[A-Za-z]*Error\b.*:/.test(l));
246
+ const why = (thrown ?? lines.slice(-3).join(" ")).slice(0, 500);
247
+ return finish({ ok: false, failure: `exited ${code ?? "on a signal"}${why ? `: ${why}` : ""}` });
248
+ }
249
+ finish(parseAnswer(hook.on, out));
250
+ });
251
+ child.stdin.on("error", () => { });
252
+ child.stdin.end(JSON.stringify(event));
253
+ });
254
+ }
255
+ /** The last JSON object a hook printed, checked against its event's shape. */
256
+ export function parseAnswer(on, stdout) {
257
+ const lines = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean);
258
+ if (!lines.length)
259
+ return { ok: true, decision: on === "tool.call" ? "allow" : undefined };
260
+ let a;
261
+ try {
262
+ a = JSON.parse(lines[lines.length - 1]);
263
+ }
264
+ catch {
265
+ return { ok: false, failure: "its last line of output is not JSON" };
266
+ }
267
+ if (!a || typeof a !== "object" || Array.isArray(a))
268
+ return { ok: false, failure: "it answered with something other than a JSON object" };
269
+ const o = a;
270
+ const str = (k) => (o[k] === undefined ? undefined : typeof o[k] === "string" ? o[k] : null);
271
+ const note = str("note");
272
+ if (note === null)
273
+ return { ok: false, failure: "note must be text" };
274
+ if (on === "tool.call") {
275
+ const decision = o.decision ?? "allow";
276
+ if (decision !== "allow" && decision !== "deny")
277
+ return { ok: false, failure: `decision must be allow or deny, not ${JSON.stringify(decision)}` };
278
+ const reason = str("reason");
279
+ if (reason === null)
280
+ return { ok: false, failure: "reason must be text" };
281
+ if (decision === "deny" && !reason)
282
+ return { ok: false, failure: "a deny needs a reason — the model reads it" };
283
+ if (o.input !== undefined && (!o.input || typeof o.input !== "object" || Array.isArray(o.input)))
284
+ return { ok: false, failure: "input must be the tool's whole input, as an object" };
285
+ return { ok: true, decision, ...(reason ? { reason } : {}), ...(o.input ? { input: o.input } : {}), ...(note ? { note } : {}) };
286
+ }
287
+ if (on === "step.start") {
288
+ const context = str("context");
289
+ if (context === null)
290
+ return { ok: false, failure: "context must be text" };
291
+ return { ok: true, ...(context ? { context } : {}), ...(note ? { note } : {}) };
292
+ }
293
+ if (on === "step.end") {
294
+ const reply = str("reply");
295
+ if (reply === null)
296
+ return { ok: false, failure: "reply must be text" };
297
+ return { ok: true, ...(reply !== undefined ? { reply } : {}), ...(note ? { note } : {}) };
298
+ }
299
+ return { ok: true, ...(note ? { note } : {}) };
300
+ }
301
+ /** A hook record as it crosses from the sandbox: checked, never trusted. */
302
+ export function isHookRecord(x) {
303
+ if (!x || typeof x !== "object")
304
+ return false;
305
+ const r = x;
306
+ return typeof r.hook === "string" && NAME.test(r.hook)
307
+ && typeof r.event === "string" && HOOK_EVENTS.includes(r.event)
308
+ && typeof r.did === "string" && ["deny", "rewrite", "context", "reply", "note", "error"].includes(r.did)
309
+ && typeof r.ms === "number"
310
+ && (r.tool === undefined || typeof r.tool === "string")
311
+ && (r.reason === undefined || typeof r.reason === "string");
312
+ }
313
+ export function isGate(on) {
314
+ return GATES.has(on);
315
+ }
316
+ /**
317
+ * tool.call: every matching hook in order. The first deny ends it — the most
318
+ * restrictive answer wins; a rewrite is handed on to the next hook, so they
319
+ * chain in the order they were turned on. A hook that fails denies.
320
+ */
321
+ export async function runToolCallHooks(hooks, call, run) {
322
+ const records = [];
323
+ let input = call.input;
324
+ let rewritten = false;
325
+ for (const hook of hooks) {
326
+ if (hook.on !== "tool.call" || !matchesTool(hook.tool, call.tool))
327
+ continue;
328
+ const a = await run(hook, { ...call.base, event: "tool.call", hook: hook.name, tool: { name: call.tool, input } });
329
+ if (!a.ok) {
330
+ const reason = `hook ${hook.name} failed (${a.failure}), so the call is refused`;
331
+ records.push({ hook: hook.name, event: "tool.call", did: "error", tool: call.tool, reason, ms: a.ms });
332
+ return { decision: "deny", reason, records };
333
+ }
334
+ if (a.decision === "deny") {
335
+ records.push({ hook: hook.name, event: "tool.call", did: "deny", tool: call.tool, reason: a.reason, ms: a.ms });
336
+ return { decision: "deny", reason: `hook ${hook.name}: ${a.reason}`, records };
337
+ }
338
+ if (a.input) {
339
+ input = a.input;
340
+ rewritten = true;
341
+ records.push({ hook: hook.name, event: "tool.call", did: "rewrite", tool: call.tool, ...(a.reason ? { reason: a.reason } : {}), ms: a.ms });
342
+ }
343
+ else if (a.note) {
344
+ records.push({ hook: hook.name, event: "tool.call", did: "note", tool: call.tool, reason: a.note, ms: a.ms });
345
+ }
346
+ }
347
+ return { decision: "allow", ...(rewritten ? { input } : {}), records };
348
+ }
349
+ /** The trail's line for a record. */
350
+ export function hookLine(r) {
351
+ const what = r.tool ? ` ${r.tool}` : "";
352
+ switch (r.did) {
353
+ case "deny": return `hook ${r.hook} denied${what}: ${r.reason}`;
354
+ case "rewrite": return `hook ${r.hook} rewrote${what}${r.reason ? `: ${r.reason}` : ""}`;
355
+ case "context": return `hook ${r.hook} added context to the step`;
356
+ case "reply": return `hook ${r.hook} rewrote the step's reply`;
357
+ case "note": return `hook ${r.hook}${what}: ${r.reason}`;
358
+ case "error": return `hook ${r.hook} failed on ${r.event}${what}: ${r.reason}`;
359
+ }
360
+ }
361
+ /** For `foldrun check`: every problem with the hooks/ folder of one root. */
362
+ export function checkHooksDir(hooksDir, scope) {
363
+ const problems = [];
364
+ for (const dir of listHookDirs(hooksDir)) {
365
+ const def = readHookDef(dir, scope);
366
+ if ("error" in def)
367
+ problems.push(def.error);
368
+ }
369
+ return problems;
370
+ }
@@ -1,6 +1,6 @@
1
1
  /** Where a document can live. Nearest wins at read time. */
2
2
  export type Scope = "account" | "workspace" | "agent";
3
- export type Kind = "agents" | "flows" | "evals" | "tools" | "skills" | "memory" | "knowledge" | "scripts";
3
+ export type Kind = "agents" | "flows" | "evals" | "tools" | "hooks" | "skills" | "memory" | "knowledge" | "scripts";
4
4
  export interface KindMeta {
5
5
  kind: Kind;
6
6
  /** Singular noun, for buttons and prose. */
package/dist/src/kinds.js CHANGED
@@ -135,6 +135,32 @@ Notes for whoever maintains this. Agents opt in with:
135
135
  \`\`\`yaml
136
136
  tools: [${name}]
137
137
  \`\`\`
138
+ `,
139
+ },
140
+ hooks: {
141
+ kind: "hooks",
142
+ one: "hook",
143
+ docType: null,
144
+ docKey: null,
145
+ // Turned on per agent (`hooks:`), or for every agent from an AGENTS.md;
146
+ // shared ones live in the account library.
147
+ scopes: ["account", "workspace"],
148
+ placeholder: "no-force-push",
149
+ hint: "Your code at every tool call, step start or step end — on every model. Folder: hook.md and run.mjs.",
150
+ file: (name) => `hooks/${name}/hook.md`,
151
+ template: (name) => `---
152
+ name: ${name}
153
+ on: tool.call
154
+ tool: Bash
155
+ description: What this hook guards, in one line.
156
+ ---
157
+
158
+ # ${name}
159
+
160
+ What it refuses or rewrites, and why. The code is run.mjs beside this file:
161
+ it reads the event as JSON on stdin and prints one JSON answer —
162
+ {"decision": "deny", "reason": "..."} to refuse, {"input": {...}} to rewrite,
163
+ {} to let the call through. A hook that fails refuses the call.
138
164
  `,
139
165
  },
140
166
  // Last, and not navigable: a folder tool holds its own code, so scripts
@@ -1,5 +1,5 @@
1
1
  import { type ToolDef } from "./store.ts";
2
- export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools";
2
+ export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools" | "hooks";
3
3
  export declare const LIBRARY_KINDS: LibraryKind[];
4
4
  export declare function libraryDir(tenant: string, kind?: LibraryKind): string;
5
5
  export interface LibraryEntry {