@agent-flow-board/bridge 0.1.0 → 0.3.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 (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @agent-flow-board/bridge
2
2
 
3
- The local bridge to an [Agent Flow Board](https://afb.arbenger.com) server. It forwards the board's MCP over stdio, runs the hooks of your coding tool (Claude Code, Antigravity, Gemini CLI) so the board stays true, and writes the status file that the editor status line reads. It holds no tool code: every tool lives on the server.
3
+ The local bridge to an [Agent Flow Board](https://afb.arbenger.com) server. It forwards the board's MCP over stdio, runs the hooks of your coding tool (Claude Code, Antigravity, Gemini CLI) so the board stays true, and writes the status file that the editor status line reads. It holds no board tool code: every MCP tool lives on the server. It also runs browser checks on this machine (see Browser checks below).
4
4
 
5
5
  ## Install
6
6
 
@@ -22,6 +22,34 @@ Needs Node 20 or later. Run it with no arguments to see its usage; the list shou
22
22
  | `agent-flow-board mcp --server <url> --org <slug>` | Forwards MCP over stdio, for tools that only speak stdio. |
23
23
  | `agent-flow-board queue list` | Shows writes held while the board could not be reached. |
24
24
 
25
+ ### Browser checks
26
+
27
+ | Command | What it does |
28
+ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `agent-flow-board check connect --server <url> --org <slug> --json` | Makes this machine a check runner of the organisation. An agent approves the printed code with the board's MCP. Only the runner token is kept. |
30
+ | `agent-flow-board check run <item> <key> --server <url> --org <slug> [--step 1.2]` | Asks for the run, claims that run only, builds a private copy of the repo in the current folder, previews it, runs the check in a sandbox and reports. Exit 0 pass, 1 fail, 2 could not run. |
31
+ | `agent-flow-board check pull <item> <key> --server <url> --org <slug>` | Shows the board's copy of a check script as data. `--accept-hash <sha256>` writes exactly that text. |
32
+ | `agent-flow-board check watch [--parallel N] [--once]` | Takes runs started from the board's Run button, for folders where `check run` was used before. |
33
+ | `agent-flow-board check status`, `check doctor --fix`, `check clear`, `check setup` | Reads and repairs the state, removes old runs, installs the browser. All take `--json`. |
34
+
35
+ More check commands:
36
+
37
+ | Command | What it does |
38
+ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `agent-flow-board check project <NAME> --org <slug> --repo <dir> [--app-dir web]` | Registers a repo folder (and an optional app folder) for a board project, or a live site with `--url`. A repo must be inside a folder the owner approved. |
40
+ | `agent-flow-board check approve --action <roots\|register\|secrets\|adopt-env\|pictures>` | The owner, on a terminal, gets a single-use code (10 minutes). Agents cannot make one. |
41
+ | `agent-flow-board check roots add\|list\|remove` | The owner-approved parent folders a registration may live in (at most 5). |
42
+ | `agent-flow-board check secrets import\|list\|forget` | Seals test Supabase and mail values per registration (owner's code needed). Names only are ever printed. |
43
+ | `agent-flow-board check login <NAME> --org <slug> --seeded` | Saves a sign in for a registration from `AFB_CHECKS_<NAME>_EMAIL` and `_PASSWORD`. |
44
+ | `agent-flow-board check service install\|status\|uninstall` | Windows: starts the worker when you sign in. |
45
+ | `agent-flow-board check import-legacy --from <afb-checks folder> --org <slug>` | Read only: re-registers the old tool's projects and copies its check scripts. |
46
+
47
+ Runs share the machine: a slot set, one browser per host set, one database container with a clone per run, and kept dependencies and builds.
48
+
49
+ Check scripts and run state live in `~/.agent-flow-board/checks/`, never in a repo. Playwright is not installed with the bridge: the first run installs it there.
50
+
51
+ The sandboxed check can write its own run folder only. What the tool must be able to trust lives outside it: the live and end markers of a run (`checks/markers/`, checked against the names the tool derives for that run before any sweep acts on them) and the install, build, preview and mail-guard logs (`checks/logs/<run id>/`, which can hold values the app printed). Pictures and `result.json` are read from the run folder only when they are plain files (never a link), small enough, and the right kind of file. Changing the folder, the app folder, the site or the hosts of a registration that keeps an adopted `.env` or saved state needs the owner's `register` code (for a live site the code names `NAME@host`), and adopting a `.env` for another folder needs its own `adopt-env` code. A run started by hand stops after 40 minutes (`AFB_CHECKS_RUN_LIMIT_MS` changes it). Warm previews are not enabled in this version: `"warm": true` in a recipe is refused.
52
+
25
53
  The easy way to set everything up is the **Editor status** tab on the board's **Connect a tool** page: copy its prompt into Claude Code and the agent does the steps, asking you once before it writes anything.
26
54
 
27
55
  ## What it writes
@@ -0,0 +1,105 @@
1
+ // THE APP FOLDER OF A PROJECT. Some repos keep the app in a subfolder (the git root has no package.json, the app and its lockfile live in web/).
2
+ // A registration may name that folder with `appDir`; the private copy is still made from the git root, but install, build and start run in the
3
+ // app folder, and the files that decide what runs (package.json, lockfile, recipe) are read from there. Without appDir the app folder is the repo root.
4
+ // -- DEP-MODULES -- //
5
+ import { existsSync, lstatSync } from 'node:fs';
6
+ import { isAbsolute, join } from 'node:path';
7
+ // -- MODULES -- //
8
+ import { isInside } from './paths.js';
9
+ // -- CONSTANTS -- //
10
+ const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,60}$/;
11
+ const BARRED = new Set(['.git', 'node_modules']);
12
+ /** Names Windows keeps for devices, with or without an extension. */
13
+ const DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
14
+ // -- FUNCTIONS -- //
15
+ /** The cosmetic forms (web\, ./web, web/) become one value: web. Anything else is returned as it is, for appDirText to refuse. */
16
+ export function normalizeAppDir(raw) {
17
+ let s = String(raw ?? '')
18
+ .trim()
19
+ .replace(/\\/g, '/');
20
+ while (s.startsWith('./'))
21
+ s = s.slice(2);
22
+ s = s.replace(/\/+$/, '');
23
+ return s;
24
+ }
25
+ /** One plain sentence when `appDir` is not a plain relative folder name (or a few of them), else null. Looks at the text only. */
26
+ export function appDirText(appDir) {
27
+ if (typeof appDir !== 'string' || !appDir)
28
+ return 'appDir must be a folder name inside the repo, for example web.';
29
+ if (normalizeAppDir(appDir) !== appDir)
30
+ return `appDir "${appDir}" must be written as plain folder names with forward slashes and no trailing slash, for example web.`;
31
+ if (isAbsolute(appDir) || /^[A-Za-z]:/.test(appDir) || appDir.startsWith('/'))
32
+ return 'appDir must be inside the repo, not an absolute path.';
33
+ const segments = appDir.split('/');
34
+ if (segments.length > 4)
35
+ return 'appDir may name at most 4 nested folders.';
36
+ for (const s of segments) {
37
+ if (s === '.' || s === '..')
38
+ return 'appDir may not contain . or .. .';
39
+ if (!SEGMENT.test(s))
40
+ return `appDir has a folder name (${s.slice(0, 40)}) with characters a plain folder name does not use.`;
41
+ if (BARRED.has(s.toLowerCase()))
42
+ return `appDir may not go through ${s}.`;
43
+ // Windows drops a trailing dot, so web. would be read as web while a git path web./src matches nothing
44
+ if (s.endsWith('.') || DEVICE.test(s))
45
+ return `appDir has a folder name (${s.slice(0, 40)}) that ends with a dot or is a device name.`;
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * Walks appDir folder by folder from `base`, without following a link: a link, a junction, a file in its place or a folder that leaves `base` is a
51
+ * problem. Used on the live repo when a registration is made and again on the private copy of every run.
52
+ */
53
+ export function walkAppDir(base, appDir, what = 'the repo') {
54
+ const text = appDirText(appDir);
55
+ if (text)
56
+ return { problem: text };
57
+ let cur = base;
58
+ for (const seg of appDir.split('/')) {
59
+ cur = join(cur, seg);
60
+ let st;
61
+ try {
62
+ st = lstatSync(cur);
63
+ }
64
+ catch {
65
+ return {
66
+ problem: `The app folder ${appDir} is not in ${what}${what === 'the repo' ? '' : ' (is it committed?)'}.`
67
+ };
68
+ }
69
+ if (st.isSymbolicLink())
70
+ return {
71
+ problem: `The app folder ${appDir} goes through a link (${seg}), which an app folder may not do.`
72
+ };
73
+ if (!st.isDirectory())
74
+ return { problem: `${appDir} is not a folder in ${what}.` };
75
+ }
76
+ if (!isInside(base, cur))
77
+ return { problem: `The app folder ${appDir} leaves ${what}.` };
78
+ return { dir: cur };
79
+ }
80
+ /** The app folder of a config in the live repo: repo/appDir, or the repo itself. The text is not checked here. */
81
+ export function appRoot(cfg) {
82
+ return cfg.appDir ? join(cfg.repo, ...cfg.appDir.split('/')) : cfg.repo;
83
+ }
84
+ /** The app folder inside a run's private copy (`root` is the copy of the folder that holds the app folder), or an error that stops the run. */
85
+ export function sandboxAppRoot(root, appDir) {
86
+ if (!appDir)
87
+ return root;
88
+ const r = walkAppDir(root, appDir, 'the private copy of the repo');
89
+ if (r.problem !== undefined)
90
+ throw new Error(r.problem);
91
+ if (!existsSync(join(r.dir, 'package.json')))
92
+ throw new Error(`The app folder ${appDir} has no package.json in the private copy of the repo (a submodule, or not committed?).`);
93
+ return r.dir;
94
+ }
95
+ /** A problem with an app folder of a live repo: the text, the walk and the package.json. Null when it is fine. */
96
+ export function appDirProblem(repo, appDir) {
97
+ const r = walkAppDir(repo, appDir);
98
+ if (r.problem !== undefined)
99
+ return r.problem;
100
+ if (!existsSync(join(r.dir, 'package.json')))
101
+ return `The app folder ${appDir} has no package.json.`;
102
+ return null;
103
+ }
104
+ /** The pathspecs of the app files relative to the git root: appPaths are relative to the app folder, so they are prefixed with it. */
105
+ export const gitAppPaths = (appPaths, appDir) => appDir ? appPaths.map((p) => (p === '.' ? appDir : `${appDir}/${p}`)) : appPaths;
@@ -0,0 +1,213 @@
1
+ // THE OWNER APPROVAL CHANNEL. An agent can ask for things the owner must decide (register a live site, adopt a repo .env, widen the roots, upload pictures of a
2
+ // signed-in run, import secrets). The owner runs `check approve --action <x> --target <name>` on an interactive terminal and gets a single-use code that is valid for
3
+ // 10 minutes for that one action and target. The code is stored hashed (HMAC with a key in the bridge home), so the file never holds a usable code. An agent cannot
4
+ // make a code: minting needs a terminal. This is tamper-evident, not tamper-proof: a process that runs as the same user can read the key.
5
+ // -- DEP-MODULES -- //
6
+ import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
7
+ import { existsSync, linkSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { appendAudit } from './audit.js';
11
+ import { withLockSync } from './lock.js';
12
+ import { approvalsFile, checksHome, ensureDir } from './paths.js';
13
+ import { renameRetry } from './fsretry.js';
14
+ // -- CONSTANTS -- //
15
+ export const APPROVAL_ACTIONS = [
16
+ 'register',
17
+ 'secrets',
18
+ 'adopt-env',
19
+ 'roots',
20
+ 'pictures'
21
+ ];
22
+ /** A code is valid for ten minutes. */
23
+ export const APPROVAL_TTL_MS = 10 * 60 * 1000;
24
+ const MAX_CODES = 20;
25
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
26
+ const CODE_LENGTH = 12;
27
+ // -- FUNCTIONS -- //
28
+ /** A refusal the owner or the agent can read: one plain sentence. The command handlers turn it into exit code 1. */
29
+ export class Refusal extends Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = 'Refusal';
33
+ }
34
+ }
35
+ /** A mistake in how a command was typed (exit code 2). */
36
+ export class UsageError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = 'UsageError';
40
+ }
41
+ }
42
+ export const isApprovalAction = (v) => APPROVAL_ACTIONS.includes(v);
43
+ /** Writes a temporary file in the same folder and renames it over the real one, so a reader never sees half a file. */
44
+ export function writeFileAtomic(file, text) {
45
+ ensureDir(dirname(file));
46
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
47
+ try {
48
+ writeFileSync(temp, text, { mode: 0o600 });
49
+ renameRetry(temp, file);
50
+ }
51
+ catch (e) {
52
+ rmSync(temp, { force: true });
53
+ throw e;
54
+ }
55
+ }
56
+ const keyFile = () => join(checksHome(), 'approval.key');
57
+ /** The HMAC key of the approvals, the sealed roots and the registration files. Created once, whole, with mode 0600 (linked into place so nobody reads half of it). */
58
+ export function approvalKey() {
59
+ ensureDir(checksHome());
60
+ const file = keyFile();
61
+ if (!existsSync(file)) {
62
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
63
+ writeFileSync(tmp, randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
64
+ try {
65
+ linkSync(tmp, file);
66
+ }
67
+ catch (e) {
68
+ if (e.code !== 'EEXIST')
69
+ throw e;
70
+ }
71
+ finally {
72
+ rmSync(tmp, { force: true });
73
+ }
74
+ }
75
+ const key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex');
76
+ if (key.length !== 32)
77
+ throw new Error('The approval key file is damaged. Remove it and approve again.');
78
+ return key;
79
+ }
80
+ /** HMAC-SHA256 hex of a text under the approval key, with a purpose word so one seal can never stand for another. */
81
+ export function seal(purpose, text) {
82
+ return createHmac('sha256', approvalKey()).update(`${purpose}\0${text}`).digest('hex');
83
+ }
84
+ /** True when two hex strings are equal, compared in constant time. */
85
+ export function sameMac(a, b) {
86
+ return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
87
+ }
88
+ /**
89
+ * The target as it is compared: a name is lower cased, a folder (the `roots` action) is its real path when it exists, else its resolved path, lower cased on Windows.
90
+ * The owner and the agent may therefore write the same target in different spellings.
91
+ */
92
+ export function normaliseTarget(action, target) {
93
+ const text = String(target ?? '').trim();
94
+ if (!text || text.length > 400)
95
+ throw new Refusal('An approval needs a target (a name or a folder).');
96
+ if (action !== 'roots')
97
+ return text.toLowerCase();
98
+ let full = resolve(text);
99
+ try {
100
+ full = realpathSync.native(full);
101
+ }
102
+ catch {
103
+ /* A folder that is gone is compared by the path as written */
104
+ }
105
+ return process.platform === 'win32' ? full.toLowerCase() : full;
106
+ }
107
+ function normaliseCode(code) {
108
+ const text = String(code ?? '')
109
+ .toUpperCase()
110
+ .replace(/[\s-]/g, '');
111
+ return new RegExp(`^[${ALPHABET}]{${CODE_LENGTH}}$`).test(text) ? text : null;
112
+ }
113
+ const macOf = (action, target, code) => seal('approval', `${action}\0${target}\0${code}`);
114
+ function readEntries() {
115
+ try {
116
+ const v = JSON.parse(readFileSync(approvalsFile(), 'utf8'));
117
+ const list = v && typeof v === 'object' ? v.codes : null;
118
+ if (!Array.isArray(list))
119
+ return [];
120
+ const out = [];
121
+ for (const e of list) {
122
+ if (!e || typeof e !== 'object')
123
+ continue;
124
+ const { h, action, target, exp } = e;
125
+ if (typeof h === 'string' &&
126
+ typeof action === 'string' &&
127
+ isApprovalAction(action) &&
128
+ typeof target === 'string' &&
129
+ typeof exp === 'number')
130
+ out.push({ h, action, target, exp });
131
+ }
132
+ return out;
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
138
+ const writeEntries = (codes) => writeFileAtomic(approvalsFile(), JSON.stringify({ version: 1, codes }, null, 2) + '\n');
139
+ /**
140
+ * Makes a single-use code for one action and target. Needs an interactive terminal: `isTTY` is passed in by the caller (the command passes the real flag, a test
141
+ * passes true or false), and without it nothing is made. An older code for the same action and target is replaced.
142
+ */
143
+ export function mintApproval(action, target, opts) {
144
+ if (!opts.isTTY)
145
+ throw new Refusal('An approval code is made by the owner on an interactive terminal. This is not one, so no code was made.');
146
+ if (!isApprovalAction(action))
147
+ throw new Refusal(`"${action.slice(0, 30)}" is not an action (${APPROVAL_ACTIONS.join(', ')}).`);
148
+ const now = opts.now ?? Date.now();
149
+ const norm = normaliseTarget(action, target);
150
+ let code = '';
151
+ for (let i = 0; i < CODE_LENGTH; i++)
152
+ code += ALPHABET[randomInt(ALPHABET.length)];
153
+ const exp = now + APPROVAL_TTL_MS;
154
+ withLockSync('approvals', () => {
155
+ const kept = readEntries().filter((e) => e.exp > now && !(e.action === action && e.target === norm));
156
+ kept.push({ h: macOf(action, norm, code), action, target: norm, exp });
157
+ writeEntries(kept.slice(-MAX_CODES));
158
+ });
159
+ appendAudit('approval minted', { action, target: norm, minutes: APPROVAL_TTL_MS / 60000 });
160
+ return {
161
+ code: `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`,
162
+ expiresAt: new Date(exp).toISOString()
163
+ };
164
+ }
165
+ /**
166
+ * True when the code is valid for this action and target right now, without using it. A command with several codes checks all of them with this first and uses them
167
+ * (consumeApproval) last, so a wrong code never burns the right ones.
168
+ */
169
+ export function peekApproval(action, target, code, now = Date.now()) {
170
+ if (!isApprovalAction(action))
171
+ return false;
172
+ const plain = normaliseCode(code);
173
+ if (!plain)
174
+ return false;
175
+ let norm;
176
+ try {
177
+ norm = normaliseTarget(action, target);
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ const want = macOf(action, norm, plain);
183
+ return readEntries().some((e) => e.exp > now && e.action === action && sameMac(e.h, want));
184
+ }
185
+ /** True once for a valid code of this action and target, and false for a wrong action, a wrong target, an expired, a used or an unknown code. */
186
+ export function consumeApproval(action, target, code, now = Date.now()) {
187
+ if (!isApprovalAction(action))
188
+ return false;
189
+ const plain = normaliseCode(code);
190
+ if (!plain)
191
+ return false;
192
+ let norm;
193
+ try {
194
+ norm = normaliseTarget(action, target);
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ const want = macOf(action, norm, plain);
200
+ let hit = false;
201
+ withLockSync('approvals', () => {
202
+ const all = readEntries();
203
+ const live = all.filter((e) => e.exp > now);
204
+ const found = live.find((e) => e.action === action && sameMac(e.h, want));
205
+ hit = found !== undefined;
206
+ const rest = found ? live.filter((e) => e !== found) : live;
207
+ if (hit || rest.length !== all.length)
208
+ writeEntries(rest);
209
+ });
210
+ if (hit)
211
+ appendAudit('approved with code', { action, target: norm });
212
+ return hit;
213
+ }
@@ -0,0 +1,126 @@
1
+ // A HASH-CHAINED AUDIT LOG OF WHAT THE CHECK COMMANDS DID. Each line carries the MAC of the line before it (HMAC-SHA256 with a key that only this machine has),
2
+ // so an edited line or a cut shows. It is evidence, not prevention. Only short action words and plain numbers are logged: never a token, a password, a secret's
3
+ // path or a command text.
4
+ // -- DEP-MODULES -- //
5
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
6
+ import { appendFileSync, existsSync, linkSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ // -- MODULES -- //
9
+ import { withLockSync } from './lock.js';
10
+ import { auditFile, checksHome, ensureDir } from './paths.js';
11
+ import { renameRetry } from './fsretry.js';
12
+ // -- CONSTANTS -- //
13
+ const MAX_ACTION = 40;
14
+ const MAX_VALUE = 200;
15
+ // -- FUNCTIONS -- //
16
+ const keyFile = () => join(checksHome(), 'audit.key');
17
+ /** The head file keeps the MAC and the count of the last line, which is what shows a cut tail (the chain of the lines left is still true). */
18
+ const headFile = () => `${auditFile()}.head`;
19
+ /** The key, created once with mode 0600. It is written whole into a temporary file and linked into place, so another process never reads half of it. */
20
+ function homeKey() {
21
+ ensureDir(checksHome());
22
+ const file = keyFile();
23
+ if (!existsSync(file)) {
24
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
25
+ writeFileSync(tmp, randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
26
+ try {
27
+ linkSync(tmp, file);
28
+ }
29
+ catch (e) {
30
+ if (e.code !== 'EEXIST')
31
+ throw e;
32
+ }
33
+ finally {
34
+ rmSync(tmp, { force: true });
35
+ }
36
+ }
37
+ return Buffer.from(readFileSync(file, 'utf8').trim(), 'hex');
38
+ }
39
+ const lineMac = (key, prev, text) => createHmac('sha256', Buffer.concat([Buffer.from('afb check audit:'), key]))
40
+ .update(`${prev}\n${text}`)
41
+ .digest('hex');
42
+ const readLines = () => existsSync(auditFile()) ? readFileSync(auditFile(), 'utf8').split(/\r?\n/).filter(Boolean) : [];
43
+ function tail(lines) {
44
+ for (let i = lines.length - 1; i >= 0; i--) {
45
+ try {
46
+ const e = JSON.parse(lines[i] ?? '');
47
+ if (e && typeof e === 'object' && typeof e.mac === 'string')
48
+ return e.mac;
49
+ }
50
+ catch {
51
+ /* A damaged line is skipped, and verifyAudit reports it */
52
+ }
53
+ }
54
+ return '';
55
+ }
56
+ function clean(detail) {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(detail)) {
59
+ const name = k.replace(/[^A-Za-z0-9_]/g, '').slice(0, 40);
60
+ if (name)
61
+ out[name] = typeof v === 'string' ? v.slice(0, MAX_VALUE) : v;
62
+ }
63
+ return out;
64
+ }
65
+ /** Adds one line to the chain. Reading the last line and appending the next one is one step under a lock, so two writers never fork the chain. */
66
+ export function appendAudit(action, detail = {}) {
67
+ const key = homeKey();
68
+ const word = action.replace(/[^A-Za-z0-9 _.:-]/g, '').slice(0, MAX_ACTION) || 'action';
69
+ withLockSync('audit', () => {
70
+ const lines = readLines();
71
+ const prev = tail(lines);
72
+ const text = JSON.stringify({
73
+ at: new Date().toISOString(),
74
+ action: word,
75
+ detail: clean(detail),
76
+ prev
77
+ });
78
+ const mac = lineMac(key, prev, text);
79
+ appendFileSync(auditFile(), JSON.stringify({ ...JSON.parse(text), mac }) + '\n', {
80
+ mode: 0o600
81
+ });
82
+ const head = { lines: lines.length + 1, head: mac };
83
+ const tmp = `${headFile()}.${process.pid}.tmp`;
84
+ writeFileSync(tmp, JSON.stringify(head), { mode: 0o600 });
85
+ renameRetry(tmp, headFile());
86
+ });
87
+ }
88
+ function readHead() {
89
+ try {
90
+ const v = JSON.parse(readFileSync(headFile(), 'utf8'));
91
+ return typeof v.lines === 'number' && typeof v.head === 'string'
92
+ ? { lines: v.lines, head: v.head }
93
+ : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
100
+ /** True when every line holds the MAC of the line before it and the last line is the one the head file names (a cut tail shows there). */
101
+ export function verifyAudit() {
102
+ const lines = readLines();
103
+ const head = readHead();
104
+ if (!lines.length)
105
+ return { ok: head === null || head.lines === 0, lines: 0, head: '' };
106
+ const key = homeKey();
107
+ let prev = '';
108
+ for (let i = 0; i < lines.length; i++) {
109
+ let e;
110
+ try {
111
+ e = JSON.parse(lines[i] ?? '');
112
+ }
113
+ catch {
114
+ return { ok: false, lines: lines.length, head: prev };
115
+ }
116
+ const { mac, ...rest } = e;
117
+ if (typeof mac !== 'string' || e.prev !== prev)
118
+ return { ok: false, lines: lines.length, head: prev };
119
+ if (!same(lineMac(key, prev, JSON.stringify(rest)), mac))
120
+ return { ok: false, lines: lines.length, head: prev };
121
+ prev = mac;
122
+ }
123
+ // A cut tail, or a line added by hand after the last one, is not what the head file recorded
124
+ const matches = head !== null && head.lines === lines.length && head.head === prev;
125
+ return { ok: matches, lines: lines.length, head: prev };
126
+ }
@@ -0,0 +1,100 @@
1
+ // THE FOLDER BINDING OF `check watch`: WHICH REAL FOLDER BUILDS THE PROJECT <org>/<KEY>, AND THE PIN OF THE FILES THAT DECIDED WHAT RUNS.
2
+ // When the pin changes (a new script, a changed recipe, a new lockfile) the watcher stops and tells the person to run `check run` in that
3
+ // folder, which looks at the new setup and pins it again. Nothing in the repo can re-pin by itself.
4
+ // -- DEP-MODULES -- //
5
+ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ // -- MODULES -- //
8
+ import { bindingsFile, ensureDir, ORG_SLUG } from './paths.js';
9
+ import { renameRetry } from './fsretry.js';
10
+ import { checkRepoFolder } from './guardrails.js';
11
+ import { pinFiles } from './recipe.js';
12
+ // -- CONSTANTS -- //
13
+ const PROJECT_KEY = /^[A-Z][A-Z0-9]{1,9}$/;
14
+ // -- FUNCTIONS -- //
15
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
+ function idOf(org, projectKey) {
17
+ if (!ORG_SLUG.test(org))
18
+ throw new Error('The organisation is not a slug.');
19
+ if (!PROJECT_KEY.test(projectKey))
20
+ throw new Error('The project key must be 2 to 10 capital letters or digits, starting with a letter.');
21
+ return `${org}/${projectKey}`;
22
+ }
23
+ function readAll() {
24
+ const file = bindingsFile();
25
+ if (!existsSync(file))
26
+ return {};
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
30
+ }
31
+ catch {
32
+ throw new Error(`${file} is not valid JSON. Fix or remove it.`);
33
+ }
34
+ const out = {};
35
+ if (isRecord(parsed) && isRecord(parsed.bindings))
36
+ for (const [id, v] of Object.entries(parsed.bindings))
37
+ if (isRecord(v) && typeof v.folder === 'string' && typeof v.pin === 'string')
38
+ out[id] = { folder: v.folder, pin: v.pin };
39
+ return out;
40
+ }
41
+ /** Writes a temp file in the same folder, then renames it over the real one, so a reader never sees half a file. */
42
+ function writeAll(bindings) {
43
+ const file = bindingsFile();
44
+ ensureDir(dirname(file));
45
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
46
+ try {
47
+ writeFileSync(temp, JSON.stringify({ version: 1, bindings }, null, 2) + '\n', { mode: 0o600 });
48
+ renameRetry(temp, file);
49
+ }
50
+ catch (e) {
51
+ rmSync(temp, { force: true });
52
+ throw e;
53
+ }
54
+ }
55
+ /** Binds a project to a folder. Refuses a folder the guardrails refuse. Stores the real path and the given pin. */
56
+ export function bindFolder(org, projectKey, folder, pin) {
57
+ const id = idOf(org, projectKey);
58
+ const problem = checkRepoFolder(folder);
59
+ if (problem)
60
+ throw new Error(problem);
61
+ const all = readAll();
62
+ all[id] = { folder: realpathSync.native(folder), pin };
63
+ writeAll(all);
64
+ }
65
+ /** The folder and pin of a project, or null when it has none. */
66
+ export function boundFolder(org, projectKey) {
67
+ const found = readAll()[idOf(org, projectKey)];
68
+ return found ? { folder: found.folder, pin: found.pin } : null;
69
+ }
70
+ /** Every binding, sorted by org and key. */
71
+ export function listBindings() {
72
+ return Object.entries(readAll())
73
+ .sort(([a], [b]) => a.localeCompare(b))
74
+ .map(([id, v]) => {
75
+ const [org = '', projectKey = ''] = id.split('/');
76
+ return { org, projectKey, folder: v.folder, pin: v.pin };
77
+ });
78
+ }
79
+ /** Removes a binding. True when there was one. */
80
+ export function unbind(org, projectKey) {
81
+ const id = idOf(org, projectKey);
82
+ const all = readAll();
83
+ if (!(id in all))
84
+ return false;
85
+ delete all[id];
86
+ writeAll(all);
87
+ return true;
88
+ }
89
+ /**
90
+ * Whether the files that decide what runs are still the ones the person approved. `changed` also covers a folder that is gone or no longer
91
+ * allowed: the caller tells the person to run `check run` in that folder, which looks again and re-pins.
92
+ */
93
+ export function pinStatus(org, projectKey) {
94
+ const found = boundFolder(org, projectKey);
95
+ if (!found)
96
+ return 'unbound';
97
+ if (checkRepoFolder(found.folder))
98
+ return 'changed';
99
+ return pinFiles(join(found.folder)) === found.pin ? 'ok' : 'changed';
100
+ }