@foldrun/core 0.7.0 → 1.0.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/README.md +14 -1
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +5 -0
- package/dist/src/run-container.js +6 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +16 -0
- package/dist/src/starter.js +2 -0
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +11 -1
- package/dist/src/store.js +1 -0
- package/package.json +18 -1
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +10 -2
- package/src/runner.ts +15 -0
- package/src/starter.ts +2 -0
- package/src/step-exec.ts +115 -3
- package/src/store.ts +4 -0
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
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
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
34
|
+
import matter from "gray-matter";
|
|
35
|
+
|
|
36
|
+
export const HOOK_EVENTS = ["tool.call", "tool.result", "step.start", "step.end"] as const;
|
|
37
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
38
|
+
|
|
39
|
+
/** Events that decide something before it happens: a failure refuses. */
|
|
40
|
+
const GATES: ReadonlySet<HookEvent> = new Set(["tool.call", "step.start"]);
|
|
41
|
+
|
|
42
|
+
export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 } as const;
|
|
43
|
+
export const HOOK_TIMEOUT_MAX = 120;
|
|
44
|
+
export const HOOK_CODE_MAX = 100 * 1024;
|
|
45
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
46
|
+
|
|
47
|
+
export interface HookDef {
|
|
48
|
+
name: string;
|
|
49
|
+
/** The folder holding hook.md and run.mjs. */
|
|
50
|
+
dir: string;
|
|
51
|
+
scope: "workspace" | "account";
|
|
52
|
+
on: HookEvent;
|
|
53
|
+
/** `tool:` — which tools a tool.call / tool.result hook sees. Unset is all. */
|
|
54
|
+
tool?: string;
|
|
55
|
+
timeoutSec: number;
|
|
56
|
+
description?: string;
|
|
57
|
+
/** run.mjs as it was when the step started. The hook runs from this copy,
|
|
58
|
+
* so an agent that edits the file mid-step changes nothing that runs. */
|
|
59
|
+
code: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read hook.md, or say in one sentence why it is not a hook. */
|
|
63
|
+
export function readHookDef(dir: string, scope: HookDef["scope"]): HookDef | { error: string } {
|
|
64
|
+
const name = path.basename(dir);
|
|
65
|
+
if (!NAME.test(name)) return { error: `hooks/${name}: a hook's folder name is lowercase letters, digits and dashes` };
|
|
66
|
+
const md = path.join(dir, "hook.md");
|
|
67
|
+
if (!fs.existsSync(md)) return { error: `hooks/${name}: no hook.md` };
|
|
68
|
+
if (!fs.existsSync(path.join(dir, "run.mjs"))) return { error: `hooks/${name}: no run.mjs beside hook.md` };
|
|
69
|
+
let data: Record<string, unknown>;
|
|
70
|
+
try {
|
|
71
|
+
data = (matter(fs.readFileSync(md, "utf8")).data ?? {}) as Record<string, unknown>;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { error: `hooks/${name}/hook.md: frontmatter does not parse (${(err as Error).message.split("\n")[0]})` };
|
|
74
|
+
}
|
|
75
|
+
const on = data.on;
|
|
76
|
+
if (typeof on !== "string" || !(HOOK_EVENTS as readonly string[]).includes(on)) {
|
|
77
|
+
return { error: `hooks/${name}/hook.md: on: must be one of ${HOOK_EVENTS.join(", ")}${on === "run.end" ? " (run.end is not available yet)" : ""}` };
|
|
78
|
+
}
|
|
79
|
+
const event = on as HookEvent;
|
|
80
|
+
if (data.tool !== undefined && (typeof data.tool !== "string" || !data.tool.trim())) {
|
|
81
|
+
return { error: `hooks/${name}/hook.md: tool: is a tool name or pattern, like Bash, Write|Edit or mcp__*` };
|
|
82
|
+
}
|
|
83
|
+
if (data.tool !== undefined && event !== "tool.call" && event !== "tool.result") {
|
|
84
|
+
return { error: `hooks/${name}/hook.md: tool: only means something on tool.call and tool.result` };
|
|
85
|
+
}
|
|
86
|
+
// It runs through `node --eval`, one argument, which Linux caps at 128 KB.
|
|
87
|
+
const code = fs.readFileSync(path.join(dir, "run.mjs"), "utf8");
|
|
88
|
+
if (Buffer.byteLength(code) > HOOK_CODE_MAX) {
|
|
89
|
+
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` };
|
|
90
|
+
}
|
|
91
|
+
const deflt = GATES.has(event) ? HOOK_TIMEOUT_DEFAULT.gate : HOOK_TIMEOUT_DEFAULT.watch;
|
|
92
|
+
const timeout = data.timeout === undefined ? deflt : Number(data.timeout);
|
|
93
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > HOOK_TIMEOUT_MAX) {
|
|
94
|
+
return { error: `hooks/${name}/hook.md: timeout: is seconds, 1 to ${HOOK_TIMEOUT_MAX}` };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
dir,
|
|
99
|
+
scope,
|
|
100
|
+
on: event,
|
|
101
|
+
...(typeof data.tool === "string" ? { tool: data.tool.trim() } : {}),
|
|
102
|
+
timeoutSec: timeout,
|
|
103
|
+
...(typeof data.description === "string" ? { description: data.description } : {}),
|
|
104
|
+
code,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every hook folder in a hooks/ directory. */
|
|
109
|
+
export function listHookDirs(hooksDir: string): string[] {
|
|
110
|
+
if (!fs.existsSync(hooksDir)) return [];
|
|
111
|
+
return fs.readdirSync(hooksDir, { withFileTypes: true })
|
|
112
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
113
|
+
.map((d) => path.join(hooksDir, d.name))
|
|
114
|
+
.sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The names a frontmatter `hooks:` holds — a list, or one name. */
|
|
118
|
+
export function hookNames(raw: unknown): string[] {
|
|
119
|
+
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
|
|
120
|
+
return list.map((x) => String(x).trim()).filter(Boolean);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The hooks one agent runs, in order: what the account turned on, then the
|
|
125
|
+
* workspace, then the agent itself, each name once. A name resolves to the
|
|
126
|
+
* workspace's own hooks/ first, then the library's. Names that resolve to
|
|
127
|
+
* nothing come back in `missing` — `foldrun check` errors on them, and a run
|
|
128
|
+
* refuses to start the step rather than run unguarded.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveHooks(args: {
|
|
131
|
+
workspaceRoot: string;
|
|
132
|
+
libraryRoot: string;
|
|
133
|
+
names: string[];
|
|
134
|
+
}): { hooks: HookDef[]; missing: string[]; errors: string[] } {
|
|
135
|
+
const hooks: HookDef[] = [];
|
|
136
|
+
const missing: string[] = [];
|
|
137
|
+
const errors: string[] = [];
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
for (const name of args.names) {
|
|
140
|
+
if (seen.has(name)) continue;
|
|
141
|
+
seen.add(name);
|
|
142
|
+
const own = path.join(args.workspaceRoot, "hooks", name);
|
|
143
|
+
const shared = path.join(args.libraryRoot, "hooks", name);
|
|
144
|
+
const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
|
|
145
|
+
if (!dir) { missing.push(name); continue; }
|
|
146
|
+
const def = readHookDef(dir, dir === own ? "workspace" : "account");
|
|
147
|
+
if ("error" in def) errors.push(def.error);
|
|
148
|
+
else hooks.push(def);
|
|
149
|
+
}
|
|
150
|
+
return { hooks, missing, errors };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Does `pattern` name this tool? Alternatives split on `|`, `*` matches any
|
|
155
|
+
* run of characters. A pattern without `__` also matches a tool's short
|
|
156
|
+
* name, so `web` catches `mcp__foldrun_scripts__web` and `Bash` catches Bash.
|
|
157
|
+
*/
|
|
158
|
+
export function matchesTool(pattern: string | undefined, tool: string): boolean {
|
|
159
|
+
if (!pattern) return true;
|
|
160
|
+
const short = tool.includes("__") ? tool.slice(tool.lastIndexOf("__") + 2) : tool;
|
|
161
|
+
return pattern.split("|").map((p) => p.trim()).filter(Boolean).some((p) => {
|
|
162
|
+
const re = new RegExp(`^${p.replace(/[.+?^${}()[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
|
163
|
+
return re.test(tool) || (!p.includes("__") && re.test(short));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What one hook said, normalised. */
|
|
168
|
+
export interface HookAnswer {
|
|
169
|
+
ok: boolean;
|
|
170
|
+
/** tool.call */
|
|
171
|
+
decision?: "allow" | "deny";
|
|
172
|
+
reason?: string;
|
|
173
|
+
input?: Record<string, unknown>;
|
|
174
|
+
/** step.start */
|
|
175
|
+
context?: string;
|
|
176
|
+
/** step.end */
|
|
177
|
+
reply?: string;
|
|
178
|
+
/** tool.result / anything: a line for the trail */
|
|
179
|
+
note?: string;
|
|
180
|
+
/** Why it failed — threw, timed out, wrong shape. */
|
|
181
|
+
failure?: string;
|
|
182
|
+
ms: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Only these reach a hook's process: enough to run node, nothing to act with. */
|
|
186
|
+
const PASSED_ENV = ["PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_PATH"];
|
|
187
|
+
|
|
188
|
+
/** One empty home per hook name, made once: a hook reads no dotfile the
|
|
189
|
+
* step's own home holds (a CLI login, a cached token). */
|
|
190
|
+
function hookHome(hook: HookDef): string {
|
|
191
|
+
const dir = path.join(os.tmpdir(), "foldrun-hook-home", hook.name);
|
|
192
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
193
|
+
return dir;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function hookEnv(from: Record<string, string | undefined>, hook: HookDef): Record<string, string> {
|
|
197
|
+
const env: Record<string, string> = {};
|
|
198
|
+
for (const k of PASSED_ENV) if (from[k]) env[k] = from[k]!;
|
|
199
|
+
env.HOME = hookHome(hook);
|
|
200
|
+
env.FOLDRUN_HOOK = hook.name;
|
|
201
|
+
env.FOLDRUN_HOOK_EVENT = hook.on;
|
|
202
|
+
return env;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Run one hook on one event. Never throws: a failure is an answer. */
|
|
206
|
+
export async function runHook(
|
|
207
|
+
hook: HookDef,
|
|
208
|
+
event: Record<string, unknown>,
|
|
209
|
+
opts: { cwd: string; env: Record<string, string | undefined>; node?: string },
|
|
210
|
+
): Promise<HookAnswer> {
|
|
211
|
+
const started = Date.now();
|
|
212
|
+
const done = (a: Omit<HookAnswer, "ms">): HookAnswer => ({ ...a, ms: Date.now() - started });
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
let out = "";
|
|
215
|
+
let err = "";
|
|
216
|
+
let settled = false;
|
|
217
|
+
const finish = (a: Omit<HookAnswer, "ms">) => { if (!settled) { settled = true; resolve(done(a)); } };
|
|
218
|
+
// Typed: untyped, a stricter tsc (foldrun-web compiles this file) narrows
|
|
219
|
+
// it to never after the catch that returns.
|
|
220
|
+
let child: ChildProcessWithoutNullStreams;
|
|
221
|
+
try {
|
|
222
|
+
// From the copy taken at the start of the step, never the file: what
|
|
223
|
+
// guards the step cannot be edited by the step. One self-contained
|
|
224
|
+
// module — Node's built-ins only, no relative imports.
|
|
225
|
+
child = spawn(opts.node ?? process.execPath, ["--input-type=module", "--eval", hook.code], {
|
|
226
|
+
cwd: opts.cwd,
|
|
227
|
+
// Cast: Next's typings (foldrun-web compiles this file) make NODE_ENV
|
|
228
|
+
// required on ProcessEnv; a hook deliberately gets only hookEnv's keys.
|
|
229
|
+
env: hookEnv(opts.env, hook) as NodeJS.ProcessEnv,
|
|
230
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
231
|
+
// Its own process group, so a timeout can kill what the hook started
|
|
232
|
+
// too: SIGKILL to node alone left a `sleep` or a curl it spawned
|
|
233
|
+
// running in the sandbox after the call had been refused.
|
|
234
|
+
detached: process.platform !== "win32",
|
|
235
|
+
});
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return finish({ ok: false, failure: `could not start: ${(e as Error).message}` });
|
|
238
|
+
}
|
|
239
|
+
const killAll = () => {
|
|
240
|
+
try { if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGKILL"); else child.kill("SIGKILL"); }
|
|
241
|
+
catch { try { child.kill("SIGKILL"); } catch { /* already gone */ } }
|
|
242
|
+
};
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
killAll();
|
|
245
|
+
finish({ ok: false, failure: `timed out after ${hook.timeoutSec}s` });
|
|
246
|
+
}, hook.timeoutSec * 1000);
|
|
247
|
+
// Keep the TAIL of each stream, not the head: the answer is the last
|
|
248
|
+
// line of stdout and the reason the last lines of stderr, so a hook that
|
|
249
|
+
// logged a megabyte first lost its answer under a head cap and failed.
|
|
250
|
+
// Decoded by the stream, so a character split across two chunks is not
|
|
251
|
+
// turned into replacement characters inside the JSON answer.
|
|
252
|
+
child.stdout.setEncoding("utf8");
|
|
253
|
+
child.stderr.setEncoding("utf8");
|
|
254
|
+
child.stdout.on("data", (b: string) => { out += b; if (out.length > 1_000_000) out = out.slice(-1_000_000); });
|
|
255
|
+
child.stderr.on("data", (b: string) => { err += b; if (err.length > 20_000) err = err.slice(-20_000); });
|
|
256
|
+
child.on("error", (e: Error) => { clearTimeout(timer); finish({ ok: false, failure: `could not start: ${e.message}` }); });
|
|
257
|
+
child.on("close", (code: number | null) => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
// A hook is one shot: whatever it left running in the background goes
|
|
260
|
+
// with it, rather than outlive the call it was asked about.
|
|
261
|
+
if (!settled) killAll();
|
|
262
|
+
const stderr = err.trim().split("\n").slice(-3).join(" ").slice(0, 500);
|
|
263
|
+
if (code === 2) return finish({ ok: true, decision: "deny", reason: stderr || `${hook.name} refused it` });
|
|
264
|
+
if (code !== 0) {
|
|
265
|
+
// An uncaught throw ends stderr with stack frames and "Node.js vN":
|
|
266
|
+
// the last three lines named neither the error nor its message.
|
|
267
|
+
const lines = err.split("\n").map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l) && !/^at /.test(l));
|
|
268
|
+
const thrown = [...lines].reverse().find((l) => /^[A-Za-z]*Error\b.*:/.test(l));
|
|
269
|
+
const why = (thrown ?? lines.slice(-3).join(" ")).slice(0, 500);
|
|
270
|
+
return finish({ ok: false, failure: `exited ${code ?? "on a signal"}${why ? `: ${why}` : ""}` });
|
|
271
|
+
}
|
|
272
|
+
finish(parseAnswer(hook.on, out));
|
|
273
|
+
});
|
|
274
|
+
child.stdin.on("error", () => {});
|
|
275
|
+
child.stdin.end(JSON.stringify(event));
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The last JSON object a hook printed, checked against its event's shape. */
|
|
280
|
+
export function parseAnswer(on: HookEvent, stdout: string): Omit<HookAnswer, "ms"> {
|
|
281
|
+
const lines = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean);
|
|
282
|
+
if (!lines.length) return { ok: true, decision: on === "tool.call" ? "allow" : undefined };
|
|
283
|
+
let a: unknown;
|
|
284
|
+
try { a = JSON.parse(lines[lines.length - 1]); } catch { return { ok: false, failure: "its last line of output is not JSON" }; }
|
|
285
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) return { ok: false, failure: "it answered with something other than a JSON object" };
|
|
286
|
+
const o = a as Record<string, unknown>;
|
|
287
|
+
const str = (k: string) => (o[k] === undefined ? undefined : typeof o[k] === "string" ? (o[k] as string) : null);
|
|
288
|
+
const note = str("note");
|
|
289
|
+
if (note === null) return { ok: false, failure: "note must be text" };
|
|
290
|
+
if (on === "tool.call") {
|
|
291
|
+
const decision = o.decision ?? "allow";
|
|
292
|
+
if (decision !== "allow" && decision !== "deny") return { ok: false, failure: `decision must be allow or deny, not ${JSON.stringify(decision)}` };
|
|
293
|
+
const reason = str("reason");
|
|
294
|
+
if (reason === null) return { ok: false, failure: "reason must be text" };
|
|
295
|
+
if (decision === "deny" && !reason) return { ok: false, failure: "a deny needs a reason — the model reads it" };
|
|
296
|
+
if (o.input !== undefined && (!o.input || typeof o.input !== "object" || Array.isArray(o.input))) return { ok: false, failure: "input must be the tool's whole input, as an object" };
|
|
297
|
+
return { ok: true, decision, ...(reason ? { reason } : {}), ...(o.input ? { input: o.input as Record<string, unknown> } : {}), ...(note ? { note } : {}) };
|
|
298
|
+
}
|
|
299
|
+
if (on === "step.start") {
|
|
300
|
+
const context = str("context");
|
|
301
|
+
if (context === null) return { ok: false, failure: "context must be text" };
|
|
302
|
+
return { ok: true, ...(context ? { context } : {}), ...(note ? { note } : {}) };
|
|
303
|
+
}
|
|
304
|
+
if (on === "step.end") {
|
|
305
|
+
const reply = str("reply");
|
|
306
|
+
if (reply === null) return { ok: false, failure: "reply must be text" };
|
|
307
|
+
return { ok: true, ...(reply !== undefined ? { reply } : {}), ...(note ? { note } : {}) };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, ...(note ? { note } : {}) };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** What a run of tool.call hooks decided for one call. */
|
|
313
|
+
export interface ToolCallVerdict {
|
|
314
|
+
decision: "allow" | "deny";
|
|
315
|
+
reason?: string;
|
|
316
|
+
/** The input after every rewrite, when any hook rewrote it. */
|
|
317
|
+
input?: Record<string, unknown>;
|
|
318
|
+
/** One line per hook that did something, for the trail. */
|
|
319
|
+
records: HookRecord[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** One hook's effect, as the trail keeps it. */
|
|
323
|
+
export interface HookRecord {
|
|
324
|
+
hook: string;
|
|
325
|
+
event: HookEvent;
|
|
326
|
+
/** deny | rewrite | context | reply | note | error */
|
|
327
|
+
did: "deny" | "rewrite" | "context" | "reply" | "note" | "error";
|
|
328
|
+
tool?: string;
|
|
329
|
+
reason?: string;
|
|
330
|
+
ms: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** A hook record as it crosses from the sandbox: checked, never trusted. */
|
|
334
|
+
export function isHookRecord(x: unknown): x is HookRecord {
|
|
335
|
+
if (!x || typeof x !== "object") return false;
|
|
336
|
+
const r = x as Record<string, unknown>;
|
|
337
|
+
return typeof r.hook === "string" && NAME.test(r.hook)
|
|
338
|
+
&& typeof r.event === "string" && (HOOK_EVENTS as readonly string[]).includes(r.event)
|
|
339
|
+
&& typeof r.did === "string" && ["deny", "rewrite", "context", "reply", "note", "error"].includes(r.did)
|
|
340
|
+
&& typeof r.ms === "number"
|
|
341
|
+
&& (r.tool === undefined || typeof r.tool === "string")
|
|
342
|
+
&& (r.reason === undefined || typeof r.reason === "string");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function isGate(on: HookEvent) {
|
|
346
|
+
return GATES.has(on);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* tool.call: every matching hook in order. The first deny ends it — the most
|
|
351
|
+
* restrictive answer wins; a rewrite is handed on to the next hook, so they
|
|
352
|
+
* chain in the order they were turned on. A hook that fails denies.
|
|
353
|
+
*/
|
|
354
|
+
export async function runToolCallHooks(
|
|
355
|
+
hooks: HookDef[],
|
|
356
|
+
call: { tool: string; input: Record<string, unknown>; base: Record<string, unknown> },
|
|
357
|
+
run: (hook: HookDef, event: Record<string, unknown>) => Promise<HookAnswer>,
|
|
358
|
+
): Promise<ToolCallVerdict> {
|
|
359
|
+
const records: HookRecord[] = [];
|
|
360
|
+
let input = call.input;
|
|
361
|
+
let rewritten = false;
|
|
362
|
+
for (const hook of hooks) {
|
|
363
|
+
if (hook.on !== "tool.call" || !matchesTool(hook.tool, call.tool)) continue;
|
|
364
|
+
const a = await run(hook, { ...call.base, event: "tool.call", hook: hook.name, tool: { name: call.tool, input } });
|
|
365
|
+
if (!a.ok) {
|
|
366
|
+
const reason = `hook ${hook.name} failed (${a.failure}), so the call is refused`;
|
|
367
|
+
records.push({ hook: hook.name, event: "tool.call", did: "error", tool: call.tool, reason, ms: a.ms });
|
|
368
|
+
return { decision: "deny", reason, records };
|
|
369
|
+
}
|
|
370
|
+
if (a.decision === "deny") {
|
|
371
|
+
records.push({ hook: hook.name, event: "tool.call", did: "deny", tool: call.tool, reason: a.reason, ms: a.ms });
|
|
372
|
+
return { decision: "deny", reason: `hook ${hook.name}: ${a.reason}`, records };
|
|
373
|
+
}
|
|
374
|
+
if (a.input) {
|
|
375
|
+
input = a.input;
|
|
376
|
+
rewritten = true;
|
|
377
|
+
records.push({ hook: hook.name, event: "tool.call", did: "rewrite", tool: call.tool, ...(a.reason ? { reason: a.reason } : {}), ms: a.ms });
|
|
378
|
+
} else if (a.note) {
|
|
379
|
+
records.push({ hook: hook.name, event: "tool.call", did: "note", tool: call.tool, reason: a.note, ms: a.ms });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { decision: "allow", ...(rewritten ? { input } : {}), records };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The trail's line for a record. */
|
|
386
|
+
export function hookLine(r: HookRecord): string {
|
|
387
|
+
const what = r.tool ? ` ${r.tool}` : "";
|
|
388
|
+
switch (r.did) {
|
|
389
|
+
case "deny": return `hook ${r.hook} denied${what}: ${r.reason}`;
|
|
390
|
+
case "rewrite": return `hook ${r.hook} rewrote${what}${r.reason ? `: ${r.reason}` : ""}`;
|
|
391
|
+
case "context": return `hook ${r.hook} added context to the step`;
|
|
392
|
+
case "reply": return `hook ${r.hook} rewrote the step's reply`;
|
|
393
|
+
case "note": return `hook ${r.hook}${what}: ${r.reason}`;
|
|
394
|
+
case "error": return `hook ${r.hook} failed on ${r.event}${what}: ${r.reason}`;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** For `foldrun check`: every problem with the hooks/ folder of one root. */
|
|
399
|
+
export function checkHooksDir(hooksDir: string, scope: HookDef["scope"]): string[] {
|
|
400
|
+
const problems: string[] = [];
|
|
401
|
+
for (const dir of listHookDirs(hooksDir)) {
|
|
402
|
+
const def = readHookDef(dir, scope);
|
|
403
|
+
if ("error" in def) problems.push(def.error);
|
|
404
|
+
}
|
|
405
|
+
return problems;
|
|
406
|
+
}
|
package/src/kinds.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type Kind =
|
|
|
44
44
|
| "flows"
|
|
45
45
|
| "evals"
|
|
46
46
|
| "tools"
|
|
47
|
+
| "hooks"
|
|
47
48
|
| "skills"
|
|
48
49
|
| "memory"
|
|
49
50
|
| "knowledge"
|
|
@@ -192,6 +193,33 @@ Notes for whoever maintains this. Agents opt in with:
|
|
|
192
193
|
\`\`\`yaml
|
|
193
194
|
tools: [${name}]
|
|
194
195
|
\`\`\`
|
|
196
|
+
`,
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
hooks: {
|
|
200
|
+
kind: "hooks",
|
|
201
|
+
one: "hook",
|
|
202
|
+
docType: null,
|
|
203
|
+
docKey: null,
|
|
204
|
+
// Turned on per agent (`hooks:`), or for every agent from an AGENTS.md;
|
|
205
|
+
// shared ones live in the account library.
|
|
206
|
+
scopes: ["account", "workspace"],
|
|
207
|
+
placeholder: "no-force-push",
|
|
208
|
+
hint: "Your code at every tool call, step start or step end — on every model. Folder: hook.md and run.mjs.",
|
|
209
|
+
file: (name) => `hooks/${name}/hook.md`,
|
|
210
|
+
template: (name) => `---
|
|
211
|
+
name: ${name}
|
|
212
|
+
on: tool.call
|
|
213
|
+
tool: Bash
|
|
214
|
+
description: What this hook guards, in one line.
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
# ${name}
|
|
218
|
+
|
|
219
|
+
What it refuses or rewrites, and why. The code is run.mjs beside this file:
|
|
220
|
+
it reads the event as JSON on stdin and prints one JSON answer —
|
|
221
|
+
{"decision": "deny", "reason": "..."} to refuse, {"input": {...}} to rewrite,
|
|
222
|
+
{} to let the call through. A hook that fails refuses the call.
|
|
195
223
|
`,
|
|
196
224
|
},
|
|
197
225
|
// Last, and not navigable: a folder tool holds its own code, so scripts
|
package/src/library.ts
CHANGED
|
@@ -32,12 +32,12 @@ import {
|
|
|
32
32
|
} from "./store.ts";
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools";
|
|
35
|
+
export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools" | "hooks";
|
|
36
36
|
// Same order as the workspace nav and asset pages — one list, one order.
|
|
37
37
|
// Order matters and is asserted: it is the order every noun list in the
|
|
38
38
|
// product uses. Tools then scripts, because a script is material a tool
|
|
39
39
|
// points at — see the KINDS table, which is where the order is declared.
|
|
40
|
-
export const LIBRARY_KINDS: LibraryKind[] = ["tools", "knowledge", "memory", "skills", "scripts"];
|
|
40
|
+
export const LIBRARY_KINDS: LibraryKind[] = ["tools", "hooks", "knowledge", "memory", "skills", "scripts"];
|
|
41
41
|
|
|
42
42
|
export function libraryDir(tenant: string, kind?: LibraryKind) {
|
|
43
43
|
// Alongside the workspace in single-workspace mode: ./library, so a shared
|
|
@@ -159,6 +159,22 @@ export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
|
|
|
159
159
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// A hook is a folder: hook.md says when it runs, run.mjs is the code.
|
|
163
|
+
if (kind === "hooks") {
|
|
164
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
165
|
+
const full = path.join(dir, entry);
|
|
166
|
+
const hookMd = path.join(full, "hook.md");
|
|
167
|
+
if (!fs.statSync(full).isDirectory() || !fs.existsSync(hookMd)) continue;
|
|
168
|
+
out.push({
|
|
169
|
+
...describe(hookMd, entry),
|
|
170
|
+
path: `${entry}/hook.md`,
|
|
171
|
+
files: folderFiles(full, entry),
|
|
172
|
+
updatedAt: fs.statSync(hookMd).mtime.toISOString(),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
162
178
|
if (kind === "skills") {
|
|
163
179
|
for (const entry of fs.readdirSync(dir).sort()) {
|
|
164
180
|
const full = path.join(dir, entry);
|
package/src/mail.ts
CHANGED
|
@@ -81,7 +81,7 @@ export const MAIL_CATEGORIES: readonly MailCategoryInfo[] = [
|
|
|
81
81
|
label: "Account notices",
|
|
82
82
|
required: true,
|
|
83
83
|
perWorkspace: false,
|
|
84
|
-
description: "Something about the account stopped working on its own — a notification webhook switched off after days of failures, a close request.",
|
|
84
|
+
description: "Something about the account stopped working on its own, or is about to — a notification webhook switched off after days of failures, a close request, an API route it still calls being switched off.",
|
|
85
85
|
why: "each one is the only message saying a thing you rely on has stopped",
|
|
86
86
|
},
|
|
87
87
|
{
|
package/src/notify.ts
CHANGED
|
@@ -137,6 +137,15 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
|
|
|
137
137
|
// line per parked run would bury the failures around it.
|
|
138
138
|
let warnedNoPublicUrl = false;
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The version of the webhook body's shape, sent on every delivery as
|
|
142
|
+
* `x-foldrun-webhook-version` (and in the user agent). Within a version the
|
|
143
|
+
* body only gains fields — a receiver must ignore the ones it does not know.
|
|
144
|
+
* A field removed, renamed or retyped is a new version, announced in What's
|
|
145
|
+
* new at least six months before the old shape stops being sent.
|
|
146
|
+
*/
|
|
147
|
+
export const WEBHOOK_PAYLOAD_VERSION = 1;
|
|
148
|
+
|
|
140
149
|
/**
|
|
141
150
|
* The headers that prove a delivery came from this install.
|
|
142
151
|
*
|
|
@@ -252,7 +261,8 @@ export async function attemptWebhook(
|
|
|
252
261
|
method: "POST",
|
|
253
262
|
headers: {
|
|
254
263
|
"content-type": "application/json",
|
|
255
|
-
"user-agent":
|
|
264
|
+
"user-agent": `foldrun-webhooks/${WEBHOOK_PAYLOAD_VERSION}`,
|
|
265
|
+
"x-foldrun-webhook-version": String(WEBHOOK_PAYLOAD_VERSION),
|
|
256
266
|
"x-foldrun-delivery": d.id,
|
|
257
267
|
"x-foldrun-event": d.event,
|
|
258
268
|
"x-foldrun-attempt": String(d.attempt),
|
package/src/providers.ts
CHANGED
|
@@ -591,6 +591,11 @@ export interface BrowseSettings {
|
|
|
591
591
|
ignore_https_errors?: boolean;
|
|
592
592
|
/** The NAME of a vault secret whose value encrypts state= logins at rest. */
|
|
593
593
|
state_key?: string;
|
|
594
|
+
/** A saved login every call uses by default: loaded from
|
|
595
|
+
* workspace/state/browser/<name>.json and saved back when the call ends.
|
|
596
|
+
* Beside `cookies:` (with `state_key:`), the secret seeds it once and the
|
|
597
|
+
* session the site keeps rotating is what later runs load. */
|
|
598
|
+
state?: string;
|
|
594
599
|
/** true records every call, so the run page always has the recording to play. */
|
|
595
600
|
video?: boolean;
|
|
596
601
|
/** false stops the tool streaming its page to the run page while the step
|
|
@@ -685,7 +690,7 @@ export function browseEngineRefusal(engine: string, what: string): string | null
|
|
|
685
690
|
}
|
|
686
691
|
const BROWSE_SETTING_KEYS = [
|
|
687
692
|
"engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
|
|
688
|
-
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
|
|
693
|
+
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key", "state",
|
|
689
694
|
"video", "live_view", "vendor_session",
|
|
690
695
|
// Deprecated spelling of vendor_session (read, and warned about).
|
|
691
696
|
"session",
|
|
@@ -964,6 +969,11 @@ export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; re
|
|
|
964
969
|
if (list.length) settings[k] = list;
|
|
965
970
|
continue;
|
|
966
971
|
}
|
|
972
|
+
if (k === "state") {
|
|
973
|
+
if (typeof v !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state is a plain name (letters, digits, dot, dash, underscore), like youtube — the login is kept in workspace/state/browser/<name>.json." };
|
|
974
|
+
settings.state = v;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
967
977
|
if (k === "state_key") {
|
|
968
978
|
if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state_key must be the NAME of a vault secret (CAPITALS), never the key — store it with `foldrun secrets set NAME` and declare it under secrets:." };
|
|
969
979
|
settings.state_key = v;
|
|
@@ -1033,6 +1043,22 @@ export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; re
|
|
|
1033
1043
|
};
|
|
1034
1044
|
}
|
|
1035
1045
|
|
|
1046
|
+
// A secret's cookies seeding a saved login: the login then sits in state/,
|
|
1047
|
+
// a workspace file, so it must be encrypted with a key from the vault.
|
|
1048
|
+
if (settings.state && settings.cookies && !settings.state_key) {
|
|
1049
|
+
return {
|
|
1050
|
+
settings,
|
|
1051
|
+
rest: left(rest),
|
|
1052
|
+
error:
|
|
1053
|
+
`web.browse.state beside cookies needs web.browse.state_key — the secret's session would be saved ` +
|
|
1054
|
+
`to state/ where any step can read it. Store a key (\`foldrun secrets set BROWSER_STATE_KEY\`), ` +
|
|
1055
|
+
`write \`state_key: BROWSER_STATE_KEY\` and declare it under secrets:.`,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (settings.state && settings.storage) {
|
|
1059
|
+
return { settings, rest: left(rest), error: "web.browse.state cannot be combined with storage: a secret's tokens would be written to state/." };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1036
1062
|
// Storage is per origin, and seeding it into the wrong one would hand a
|
|
1037
1063
|
// site another site's signed-in state — the same mistake `cookie_domain`
|
|
1038
1064
|
// exists to prevent, one storage area over.
|
package/src/run-container.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// The runner image bundles @foldrun/core itself, so the driver runs the same
|
|
19
19
|
// executeStep the server would — one implementation, two homes.
|
|
20
20
|
|
|
21
|
+
import { isHookRecord, type HookRecord } from "./hooks.ts";
|
|
21
22
|
import fs from "node:fs";
|
|
22
23
|
import os from "node:os";
|
|
23
24
|
import path from "node:path";
|
|
@@ -77,6 +78,9 @@ export interface ContainerStepInput {
|
|
|
77
78
|
/** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
|
|
78
79
|
* count reads (limits.ts) — enforced in the pod by the same hook. */
|
|
79
80
|
limits?: Record<string, number>;
|
|
81
|
+
/** `hooks:` names, resolved inside the sandbox against its own roots. */
|
|
82
|
+
hookNames?: string[];
|
|
83
|
+
hookBase?: Record<string, unknown>;
|
|
80
84
|
toolOwners?: Record<string, string>;
|
|
81
85
|
/** tools: [search] — the directories to search, as paths INSIDE the
|
|
82
86
|
* container (/workspace/…, /library/…). */
|
|
@@ -172,7 +176,9 @@ const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
|
|
|
172
176
|
// there: everything platform-owned (store.ts#isPlatformPath — also never
|
|
173
177
|
// copied in), plus knowledge/, which goes in for reading and is dropped on
|
|
174
178
|
// the way out, and .git/.
|
|
175
|
-
|
|
179
|
+
// hooks/: a step must not rewrite the code that guards it — for this run (the
|
|
180
|
+
// code is read into memory before the model starts, hooks.ts) or the next.
|
|
181
|
+
const DENY_BACK = ["knowledge/", "hooks/", ".git/"];
|
|
176
182
|
// A .git ANYWHERE in the tree, not just at the root. A step that clones a
|
|
177
183
|
// repository into a shared area (files/site, say) was shipping the entire
|
|
178
184
|
// object store back to the workspace, where the harvest then filed thousands
|
|
@@ -659,6 +665,7 @@ try {
|
|
|
659
665
|
schema: input.schema,
|
|
660
666
|
maxTurns: input.maxTurns,
|
|
661
667
|
...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
|
|
668
|
+
...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
|
|
662
669
|
// Slim browsing: the platform put this step on the slim image to browse
|
|
663
670
|
// through the account's pod, and named the file the web tool logs its
|
|
664
671
|
// reconnects to. A lost pod stops the step; what it wrote decides what
|
|
@@ -1107,7 +1114,7 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
|
|
|
1107
1114
|
|
|
1108
1115
|
export function parseDriverLine(
|
|
1109
1116
|
line: string,
|
|
1110
|
-
): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean } | { e: "done" } & ContainerStepOutcome | null {
|
|
1117
|
+
): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord } | { e: "done" } & ContainerStepOutcome | null {
|
|
1111
1118
|
const trimmed = line.trim();
|
|
1112
1119
|
if (!trimmed.startsWith("{")) return null;
|
|
1113
1120
|
try {
|
|
@@ -1123,6 +1130,7 @@ export function parseDriverLine(
|
|
|
1123
1130
|
...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
|
|
1124
1131
|
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
1125
1132
|
...(parsed.check === true ? { check: true } : {}),
|
|
1133
|
+
...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
|
|
1126
1134
|
};
|
|
1127
1135
|
}
|
|
1128
1136
|
if (parsed.e === "done") {
|