crewly 1.20.280 → 1.20.281

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 (60) hide show
  1. package/config/hooks/live-checkout-guard/guard.mjs +452 -0
  2. package/dist/backend/backend/src/constants.d.ts +30 -1
  3. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  4. package/dist/backend/backend/src/constants.js +38 -0
  5. package/dist/backend/backend/src/constants.js.map +1 -1
  6. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts +5 -0
  7. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts.map +1 -1
  8. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js +42 -1
  9. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.d.ts +72 -0
  11. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.d.ts.map +1 -0
  12. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.js +113 -0
  13. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.js.map +1 -0
  14. package/dist/backend/backend/src/index.d.ts.map +1 -1
  15. package/dist/backend/backend/src/index.js +25 -1
  16. package/dist/backend/backend/src/index.js.map +1 -1
  17. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts +4 -1
  18. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  19. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js +11 -2
  20. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  21. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts +3 -2
  22. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts.map +1 -1
  23. package/dist/backend/backend/src/services/agent/credential-guard.service.js +12 -4
  24. package/dist/backend/backend/src/services/agent/credential-guard.service.js.map +1 -1
  25. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.d.ts +45 -0
  26. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.d.ts.map +1 -0
  27. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.js +109 -0
  28. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.js.map +1 -0
  29. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js +13 -7
  31. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  32. package/dist/backend/backend/src/utils/boot-owner-alert.d.ts +33 -0
  33. package/dist/backend/backend/src/utils/boot-owner-alert.d.ts.map +1 -0
  34. package/dist/backend/backend/src/utils/boot-owner-alert.js +60 -0
  35. package/dist/backend/backend/src/utils/boot-owner-alert.js.map +1 -0
  36. package/dist/backend/backend/src/utils/build-provenance.d.ts +38 -0
  37. package/dist/backend/backend/src/utils/build-provenance.d.ts.map +1 -1
  38. package/dist/backend/backend/src/utils/build-provenance.js +63 -1
  39. package/dist/backend/backend/src/utils/build-provenance.js.map +1 -1
  40. package/dist/backend/build-info.json +2 -2
  41. package/dist/cli/backend/src/constants.d.ts +30 -1
  42. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  43. package/dist/cli/backend/src/constants.js +38 -0
  44. package/dist/cli/backend/src/constants.js.map +1 -1
  45. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts +4 -1
  46. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  47. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js +11 -2
  48. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  49. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts +3 -2
  50. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts.map +1 -1
  51. package/dist/cli/backend/src/services/agent/credential-guard.service.js +12 -4
  52. package/dist/cli/backend/src/services/agent/credential-guard.service.js.map +1 -1
  53. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.d.ts +45 -0
  54. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.d.ts.map +1 -0
  55. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.js +109 -0
  56. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.js.map +1 -0
  57. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  58. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js +13 -7
  59. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  60. package/package.json +1 -1
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+ // Crewly live-checkout guard — a pre-tool hook that stops an agent changing the
3
+ // LIVE Crewly checkout (the one the running Crewly was built and is served from).
4
+ //
5
+ // Incident (2026-10-08/09): an agent edited files in, then `git switch`ed and
6
+ // committed in, the live checkout. On the next restart Crewly refused to start
7
+ // ("STALE BUILD: ... HEAD is ...") and was down until an admin switched back.
8
+ //
9
+ // Usage (wired by the backend; PreToolUse JSON on stdin):
10
+ // node guard.mjs <live-root> [<live-root> ...]
11
+ //
12
+ // Judged:
13
+ // - Bash (also Codex "Bash"): git state-changing subcommands whose target is a
14
+ // live root (`cd <live> && git ...`, `git -C <live> ...`, `--git-dir`,
15
+ // `--work-tree`, or the call's cwd being the live root); writes to the live
16
+ // root by redirection, rm/mv/cp/touch/tee/mkdir/chmod/truncate/dd/sed -i/perl -i.
17
+ // - Edit / Write / MultiEdit / NotebookEdit: file_path / notebook_path under a live root.
18
+ // - Codex apply_patch: "*** Update/Add/Delete File:" paths in the patch text.
19
+ // Always allowed: read-only git (status, log, diff, show, fetch, ls-remote,
20
+ // branch --list, ...), reads, and anything under a `.crewly/` directory (agent
21
+ // data: tickets, findings, drafts).
22
+ //
23
+ // Exit: 0 allowed, 2 blocked (stderr is fed back to the agent), 1 could not check
24
+ // (non-blocking, never a silent pass).
25
+ //
26
+ // Coverage limits: an interpreter one-liner (python -c, node -e), a path built at
27
+ // runtime ($VAR other than HOME, globs, base64), a script the agent writes and
28
+ // runs, and `$(...)` inside double quotes are NOT seen. A speed bump, not a boundary.
29
+
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+
34
+ const PREFIX = 'live-checkout-guard:';
35
+
36
+ /** Git subcommands that never change HEAD, the index, the working tree or refs-for-checkout. */
37
+ const READ_ONLY_GIT = new Set([
38
+ 'status', 'log', 'diff', 'show', 'fetch', 'ls-remote', 'ls-files', 'ls-tree', 'rev-parse', 'rev-list',
39
+ 'describe', 'blame', 'grep', 'cat-file', 'shortlog', 'show-ref', 'show-branch', 'name-rev', 'merge-base',
40
+ 'for-each-ref', 'diff-tree', 'diff-files', 'diff-index', 'var', 'whatchanged', 'count-objects',
41
+ 'check-ignore', 'help', 'version', 'verify-commit', 'verify-tag', 'range-diff', 'cherry', 'show-index',
42
+ ]);
43
+
44
+ const BRANCH_MUTATING = new Set(['-d', '-D', '-m', '-M', '-c', '-C', '--delete', '--move', '--copy', '-u', '--set-upstream-to', '--unset-upstream', '--edit-description', '-f', '--force']);
45
+ const LIST_FLAGS = new Set(['-l', '--list', '-a', '--all', '-r', '--remotes', '--show-current', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at']);
46
+ const WRAPPERS = new Set(['sudo', 'command', 'nohup', 'time', 'exec', 'env', 'nice', 'builtin', 'xargs', 'stdbuf', 'timeout']);
47
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
48
+
49
+ // ---- helpers ---------------------------------------------------------------
50
+
51
+ /** realpath of the deepest existing ancestor + the not-yet-existing tail. */
52
+ function realish(p) {
53
+ const abs = path.resolve(p);
54
+ let cur = abs;
55
+ const tail = [];
56
+ for (let i = 0; i < 64; i += 1) {
57
+ try {
58
+ const real = fs.realpathSync(cur);
59
+ return path.join(real, ...tail.reverse());
60
+ } catch {
61
+ const parent = path.dirname(cur);
62
+ if (parent === cur) return abs;
63
+ tail.push(path.basename(cur));
64
+ cur = parent;
65
+ }
66
+ }
67
+ return abs;
68
+ }
69
+
70
+ /** Expand ~, $HOME, ${HOME}; return null when another variable/substitution remains. */
71
+ function expand(word) {
72
+ const home = process.env.HOME || os.homedir();
73
+ let w = word;
74
+ if (home) {
75
+ w = w.replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, home);
76
+ if (w === '~') w = home;
77
+ else if (w.startsWith('~/')) w = home + w.slice(1);
78
+ }
79
+ return /[$`]/.test(w) ? null : w;
80
+ }
81
+
82
+ const roots = process.argv.slice(2).filter(Boolean).map(realish);
83
+
84
+ /** The live root containing p, or null. Paths under a `.crewly` directory are not live. */
85
+ function liveRootOf(p, { allowDataDirs = true } = {}) {
86
+ const r = realish(p);
87
+ for (const root of roots) {
88
+ if (r === root || r.startsWith(root + path.sep)) {
89
+ if (allowDataDirs && r.slice(root.length).split(path.sep).includes('.crewly')) return null;
90
+ return root;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function resolveWord(word, cwd) {
97
+ const w = expand(word);
98
+ if (w === null || w === '') return null;
99
+ return path.resolve(cwd, w);
100
+ }
101
+
102
+ const DENY_TAIL =
103
+ 'This is the live Crewly install. Clone it into your own scratch dir (git clone <repo-url> <scratch>/crewly && ln -s <live>/node_modules <scratch>/crewly/node_modules) and work there. ' +
104
+ 'Read-only git (status, log, diff, show, fetch, ls-remote, branch --list) and files under .crewly/ stay allowed here.';
105
+
106
+ function deny(what, root) {
107
+ process.stderr.write(
108
+ `${PREFIX} BLOCKED — ${what} (live checkout: ${root}).\n` +
109
+ `${PREFIX} ${DENY_TAIL.replace(/<live>/g, root)}\n` +
110
+ 'Changing the live branch, HEAD or files makes Crewly refuse to start on its next restart.\n',
111
+ );
112
+ process.exit(2);
113
+ }
114
+
115
+ // ---- shell tokenising ------------------------------------------------------
116
+
117
+ /**
118
+ * Split a command line into simple commands: { words, redirs }.
119
+ * Separators: ; & | && || newline ( ) { } $( ` . Quotes and backslashes are honoured.
120
+ */
121
+ function parseCommands(src) {
122
+ const cmds = [];
123
+ let words = [];
124
+ let redirs = [];
125
+ let cur = '';
126
+ let has = false;
127
+ let pendingRedir = null;
128
+ const pushWord = () => {
129
+ if (!has) return;
130
+ if (pendingRedir) {
131
+ if (!cur.startsWith('&')) redirs.push(cur);
132
+ pendingRedir = null;
133
+ } else {
134
+ words.push(cur);
135
+ }
136
+ cur = '';
137
+ has = false;
138
+ };
139
+ const endCmd = () => {
140
+ pushWord();
141
+ pendingRedir = null;
142
+ if (words.length || redirs.length) cmds.push({ words, redirs });
143
+ words = [];
144
+ redirs = [];
145
+ };
146
+ for (let i = 0; i < src.length; i += 1) {
147
+ const c = src[i];
148
+ if (c === "'") {
149
+ const j = src.indexOf("'", i + 1);
150
+ const end = j === -1 ? src.length : j;
151
+ cur += src.slice(i + 1, end);
152
+ has = true;
153
+ i = end;
154
+ } else if (c === '"') {
155
+ let j = i + 1;
156
+ let s = '';
157
+ while (j < src.length && src[j] !== '"') {
158
+ if (src[j] === '\\' && j + 1 < src.length && '"\\$`'.includes(src[j + 1])) {
159
+ s += src[j + 1];
160
+ j += 2;
161
+ } else {
162
+ s += src[j];
163
+ j += 1;
164
+ }
165
+ }
166
+ cur += s;
167
+ has = true;
168
+ i = j;
169
+ } else if (c === '\\') {
170
+ if (i + 1 < src.length) {
171
+ if (src[i + 1] !== '\n') {
172
+ cur += src[i + 1];
173
+ has = true;
174
+ }
175
+ i += 1;
176
+ }
177
+ } else if (c === ';' || c === '\n' || c === '(' || c === ')' || c === '`' || c === '{' && !has && /\s/.test(src[i + 1] || ' ') || c === '}' && !has) {
178
+ endCmd();
179
+ } else if (c === '$' && src[i + 1] === '(') {
180
+ endCmd();
181
+ i += 1;
182
+ } else if (c === '&' || c === '|') {
183
+ // &> and >& and >| belong to redirections
184
+ if (c === '&' && src[i + 1] === '>') {
185
+ pushWord();
186
+ pendingRedir = '>';
187
+ i += src[i + 2] === '>' ? 2 : 1;
188
+ } else {
189
+ endCmd();
190
+ if (src[i + 1] === c) i += 1;
191
+ }
192
+ } else if (c === '>') {
193
+ // fd prefix like 2> was already read into cur as digits
194
+ if (has && /^\d+$/.test(cur)) {
195
+ cur = '';
196
+ has = false;
197
+ } else {
198
+ pushWord();
199
+ }
200
+ i += src[i + 1] === '>' || src[i + 1] === '|' ? 1 : 0;
201
+ pendingRedir = '>';
202
+ } else if (c === '<') {
203
+ if (has && /^\d+$/.test(cur)) {
204
+ cur = '';
205
+ has = false;
206
+ } else {
207
+ pushWord();
208
+ }
209
+ if (src[i + 1] === '<') i += 1; // heredoc marker: next word is the delimiter, not a path
210
+ pendingRedir = '<';
211
+ } else if (/\s/.test(c)) {
212
+ if (pendingRedir === '<' && has) {
213
+ cur = '';
214
+ has = false;
215
+ pendingRedir = null;
216
+ } else {
217
+ pushWord();
218
+ }
219
+ } else {
220
+ cur += c;
221
+ has = true;
222
+ }
223
+ }
224
+ endCmd();
225
+ return cmds;
226
+ }
227
+
228
+ // ---- analysis --------------------------------------------------------------
229
+
230
+ function stripWrappers(words) {
231
+ let i = 0;
232
+ const env = {};
233
+ for (;;) {
234
+ const w = words[i];
235
+ if (w === undefined) break;
236
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(w);
237
+ if (m) {
238
+ env[m[1]] = m[2];
239
+ i += 1;
240
+ } else if (WRAPPERS.has(path.basename(w))) {
241
+ const wrapper = path.basename(w);
242
+ i += 1;
243
+ while (words[i] !== undefined && words[i].startsWith('-')) {
244
+ const opt = words[i];
245
+ i += 1;
246
+ if (wrapper === 'nice' && opt === '-n') i += 1;
247
+ }
248
+ if (wrapper === 'timeout') i += 1; // duration
249
+ } else break;
250
+ }
251
+ return { env, words: words.slice(i) };
252
+ }
253
+
254
+ function gitSubcommand(args, state) {
255
+ let i = 0;
256
+ let dir = state.cwd;
257
+ let target = null; // explicit --git-dir / --work-tree
258
+ while (i < args.length && args[i].startsWith('-')) {
259
+ const a = args[i];
260
+ if (a === '-C') {
261
+ const p = resolveWord(args[i + 1] ?? '', dir);
262
+ if (p) dir = p;
263
+ i += 2;
264
+ } else if (a === '-c' || a === '--namespace' || a === '--exec-path') i += 2;
265
+ else if (a === '--git-dir' || a === '--work-tree') {
266
+ target = resolveWord(args[i + 1] ?? '', dir) ?? target;
267
+ i += 2;
268
+ } else if (a.startsWith('--git-dir=') || a.startsWith('--work-tree=')) {
269
+ target = resolveWord(a.slice(a.indexOf('=') + 1), dir) ?? target;
270
+ i += 1;
271
+ } else i += 1;
272
+ }
273
+ return { sub: args[i] ?? '', rest: args.slice(i + 1), dir, target };
274
+ }
275
+
276
+ function hasAny(args, set) {
277
+ return args.some((a) => set.has(a) || [...set].some((s) => s.startsWith('--') && a.startsWith(s + '=')));
278
+ }
279
+
280
+ /** Whether `git <sub> <rest>` is read-only. */
281
+ function gitIsReadOnly(sub, rest) {
282
+ if (READ_ONLY_GIT.has(sub)) return true;
283
+ const flags = rest.filter((a) => a.startsWith('-'));
284
+ const positional = rest.filter((a) => !a.startsWith('-'));
285
+ switch (sub) {
286
+ case 'branch':
287
+ if (hasAny(rest, BRANCH_MUTATING)) return false;
288
+ return hasAny(rest, LIST_FLAGS) || positional.length === 0;
289
+ case 'tag':
290
+ if (flags.some((f) => ['-d', '-a', '-s', '-f', '-m', '-u', '--delete', '--force', '--annotate', '--sign'].includes(f))) return false;
291
+ return flags.some((f) => ['-l', '--list', '-n', '--contains', '--points-at', '--merged', '--no-merged', '-v', '--verify'].includes(f)) || positional.length === 0;
292
+ case 'remote':
293
+ return positional.length === 0 || ['show', 'get-url'].includes(positional[0]);
294
+ case 'config':
295
+ return flags.some((f) => ['--get', '--get-all', '--get-regexp', '-l', '--list', '--show-origin'].includes(f));
296
+ case 'stash':
297
+ return ['list', 'show'].includes(positional[0] ?? '');
298
+ case 'worktree':
299
+ return positional[0] === 'list';
300
+ case 'submodule':
301
+ return positional.length === 0 || ['status', 'summary'].includes(positional[0]);
302
+ case 'reflog':
303
+ return !positional.some((p) => ['expire', 'delete'].includes(p));
304
+ case 'archive':
305
+ return true;
306
+ default:
307
+ return false;
308
+ }
309
+ }
310
+
311
+ const CLONE_VALUE_OPTS = new Set(['-b', '--branch', '--depth', '-o', '--origin', '--reference', '--template', '-c', '--config', '-j', '--jobs', '--filter', '--separate-git-dir', '--shallow-since', '--shallow-exclude', '-u', '--upload-pack']);
312
+
313
+ function cloneDestination(rest, cwd) {
314
+ const pos = [];
315
+ for (let i = 0; i < rest.length; i += 1) {
316
+ if (CLONE_VALUE_OPTS.has(rest[i])) i += 1;
317
+ else if (!rest[i].startsWith('-')) pos.push(rest[i]);
318
+ }
319
+ const dest = pos[1] ?? path.basename(pos[0] ?? '').replace(/\.git$/, '');
320
+ return dest ? resolveWord(dest, cwd) : null;
321
+ }
322
+
323
+ function analyzeSimple(cmd, state, depth) {
324
+ const { env, words } = stripWrappers(cmd.words);
325
+
326
+ // redirection targets
327
+ for (const r of cmd.redirs) {
328
+ const p = resolveWord(r, state.cwd);
329
+ const root = p && liveRootOf(p);
330
+ if (root) deny(`redirection writes into the live checkout (${r})`, root);
331
+ }
332
+ if (words.length === 0) return;
333
+ const verb = path.basename(words[0]);
334
+ const args = words.slice(1);
335
+
336
+ if (SHELLS.has(verb)) {
337
+ const ci = args.findIndex((a) => /^-[a-z]*c$/.test(a));
338
+ if (ci !== -1 && args[ci + 1] !== undefined && depth < 4) analyze(args[ci + 1], state, depth + 1);
339
+ return;
340
+ }
341
+ if (verb === 'cd' || verb === 'pushd') {
342
+ const t = args.find((a) => !a.startsWith('-')) ?? '~';
343
+ const p = resolveWord(t, state.cwd);
344
+ if (p) state.cwd = p;
345
+ return;
346
+ }
347
+
348
+ if (verb === 'git') {
349
+ const g = gitSubcommand(args, state);
350
+ const envTarget = env.GIT_DIR || env.GIT_WORK_TREE;
351
+ const explicit = g.target ?? (envTarget ? resolveWord(envTarget, state.cwd) : null);
352
+ if (g.sub === 'clone' || g.sub === 'init') {
353
+ const dest = g.sub === 'clone' ? cloneDestination(g.rest, g.dir) : resolveWord(g.rest.find((a) => !a.startsWith('-')) ?? '.', g.dir);
354
+ const root = dest && liveRootOf(dest);
355
+ if (root) deny(`git ${g.sub} would create a repository inside the live checkout`, root);
356
+ return;
357
+ }
358
+ const root = liveRootOf(g.dir) || (explicit ? liveRootOf(explicit) : null);
359
+ if (!root) return;
360
+ if (gitIsReadOnly(g.sub, g.rest)) return;
361
+ deny(`\`git ${g.sub || '(no subcommand)'}\` would change the live Crewly checkout`, root);
362
+ return;
363
+ }
364
+
365
+ // file-mutating verbs
366
+ const fileArgs = args.filter((a) => !a.startsWith('-'));
367
+ const check = (list, what) => {
368
+ for (const a of list) {
369
+ const p = resolveWord(a, state.cwd);
370
+ const root = p && liveRootOf(p);
371
+ if (root) deny(`\`${verb}\` ${what} (${a})`, root);
372
+ }
373
+ };
374
+ switch (verb) {
375
+ case 'rm': case 'rmdir': case 'unlink': case 'chmod': case 'chown': case 'chgrp': case 'truncate':
376
+ case 'touch': case 'tee': case 'mv': case 'shred': case 'mkdir':
377
+ check(fileArgs, 'would modify the live checkout');
378
+ break;
379
+ case 'sed': case 'gsed': case 'perl':
380
+ if (args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith('--in-place'))) check(fileArgs, 'edits a file in place in the live checkout');
381
+ break;
382
+ case 'cp': case 'ln': case 'install': case 'rsync': case 'scp': {
383
+ const ti = args.findIndex((a) => a === '-t' || a === '--target-directory');
384
+ const dest = ti !== -1 ? [args[ti + 1]] : fileArgs.slice(-1);
385
+ check(dest.filter(Boolean), 'would write into the live checkout');
386
+ break;
387
+ }
388
+ case 'dd':
389
+ check(args.filter((a) => a.startsWith('of=')).map((a) => a.slice(3)), 'writes into the live checkout');
390
+ break;
391
+ default:
392
+ }
393
+ }
394
+
395
+ function analyze(command, state, depth = 0) {
396
+ for (const cmd of parseCommands(command)) analyzeSimple(cmd, state, depth);
397
+ }
398
+
399
+ /** Paths a Codex apply_patch text touches. */
400
+ function patchPaths(text) {
401
+ const out = [];
402
+ for (const m of text.matchAll(/^\*\*\* (?:Update|Add|Delete) File:\s*(.+?)\s*$/gm)) out.push(m[1]);
403
+ for (const m of text.matchAll(/^\*\*\* Move to:\s*(.+?)\s*$/gm)) out.push(m[1]);
404
+ return out;
405
+ }
406
+
407
+ // ---- main ------------------------------------------------------------------
408
+
409
+ let raw = '';
410
+ process.stdin.setEncoding('utf8');
411
+ process.stdin.on('data', (d) => { raw += d; });
412
+ process.stdin.on('end', () => {
413
+ if (roots.length === 0) {
414
+ process.stderr.write(`${PREFIX} NO LIVE ROOT GIVEN — nothing checked; refusing to report this call as safe\n`);
415
+ process.exit(1);
416
+ }
417
+ let input;
418
+ try {
419
+ input = JSON.parse(raw);
420
+ } catch {
421
+ process.stderr.write(`${PREFIX} unparsable hook input — nothing checked\n`);
422
+ process.exit(1);
423
+ }
424
+ const tool = String(input.tool_name ?? '');
425
+ const ti = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
426
+ const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
427
+
428
+ const fileTargets = [];
429
+ for (const k of ['file_path', 'notebook_path', 'path', 'filePath']) if (typeof ti[k] === 'string') fileTargets.push(ti[k]);
430
+ const cmdText = typeof ti.command === 'string' ? ti.command : typeof ti.cmd === 'string' ? ti.cmd : '';
431
+ const isPatch = tool === 'apply_patch' || cmdText.includes('*** Begin Patch') || (typeof ti.input === 'string' && ti.input.includes('*** Begin Patch'));
432
+
433
+ if (isPatch) {
434
+ const text = cmdText || String(ti.input ?? '');
435
+ for (const f of patchPaths(text)) {
436
+ const p = resolveWord(f, cwd);
437
+ const root = p && liveRootOf(p);
438
+ if (root) deny(`apply_patch edits a file in the live checkout (${f})`, root);
439
+ }
440
+ }
441
+ if (/^(Edit|Write|MultiEdit|NotebookEdit|str_replace_editor)$/.test(tool) || (!cmdText && fileTargets.length && /edit|write|patch/i.test(tool))) {
442
+ for (const f of fileTargets) {
443
+ const p = resolveWord(f, cwd);
444
+ const root = p && liveRootOf(p);
445
+ if (root) deny(`${tool} would edit a file in the live checkout (${f})`, root);
446
+ }
447
+ }
448
+ if (cmdText && !isPatch) analyze(cmdText, { cwd });
449
+
450
+ process.stdout.write(`${PREFIX} allowed — ${roots.length} live root(s) checked\n`);
451
+ process.exit(0);
452
+ });
@@ -1016,12 +1016,29 @@ export declare const CONTROL_PLANE_GUARD_CONSTANTS: {
1016
1016
  /** Files under CREWLY_HOME that agents must not read with the built-in tools either. */
1017
1017
  readonly CREWLY_HOME_READ_DENIED_FILES: readonly ["api-token"];
1018
1018
  /** Write-protected directories under the install root (whole subtree). */
1019
- readonly INSTALL_DIRS: readonly ["config/skills/orchestrator/stop-agent", "config/skills/orchestrator/start-agent", "config/skills/orchestrator/terminate-agent", "config/skills/orchestrator/stop-team", "config/skills/orchestrator/start-team", "config/skills/orchestrator/restart-crewly", "config/hooks/control-plane-guard", "config/hooks/agent-status", "config/hooks/subagent-guard", "config/hooks/credential-guard", "dist"];
1019
+ readonly INSTALL_DIRS: readonly ["config/skills/orchestrator/stop-agent", "config/skills/orchestrator/start-agent", "config/skills/orchestrator/terminate-agent", "config/skills/orchestrator/stop-team", "config/skills/orchestrator/start-team", "config/skills/orchestrator/restart-crewly", "config/hooks/control-plane-guard", "config/hooks/agent-status", "config/hooks/subagent-guard", "config/hooks/credential-guard", "config/hooks/live-checkout-guard", "dist"];
1020
1020
  /** Write-protected files under the install root. */
1021
1021
  readonly INSTALL_FILES: readonly ["config/skills/_common/lib.sh"];
1022
1022
  /** Write-protected directories under the agent's project path (whole subtree). */
1023
1023
  readonly PROJECT_DIRS: readonly [".claude/agents", ".crewly/triggers"];
1024
1024
  };
1025
+ /**
1026
+ * Live-checkout guard: agents may not change the checkout the running Crewly
1027
+ * is built from (git state changes or file edits). Incident 2026-10-08/09: an
1028
+ * agent switched branches in it and the next restart refused to start.
1029
+ */
1030
+ export declare const LIVE_CHECKOUT_GUARD_CONSTANTS: {
1031
+ /** Backend env var; `0` turns the guard off for sessions launched by this backend. */
1032
+ readonly KILL_SWITCH_ENV: "CREWLY_LIVE_CHECKOUT_GUARD";
1033
+ /** Value of KILL_SWITCH_ENV that disables the guard. */
1034
+ readonly KILL_SWITCH_OFF_VALUE: "0";
1035
+ /** The hook script (node), relative to the install root. */
1036
+ readonly HOOK_SCRIPT: "config/hooks/live-checkout-guard/guard.mjs";
1037
+ /** Claude Code tools the hook is attached to. */
1038
+ readonly CLAUDE_MATCHER: "Bash|Edit|Write|MultiEdit|NotebookEdit";
1039
+ /** Codex tools the hook is attached to (shell, plus its patch tool). */
1040
+ readonly CODEX_MATCHER: "Bash|apply_patch|Edit|Write";
1041
+ };
1025
1042
  /**
1026
1043
  * Credential guard (specs/2026-10-04-agent-credential-isolation.md, layer 2):
1027
1044
  * a pre-tool hook, on every runtime that has one, that refuses an agent's
@@ -7357,6 +7374,18 @@ export declare const REMOTE_MCP_CONSTANTS: {
7357
7374
  readonly MAX_TOOL_NAMES: 50;
7358
7375
  /** Catalog presets the add form offers. */
7359
7376
  readonly PROVIDERS: readonly string[];
7377
+ /**
7378
+ * Tools an AGENT may never call through the proxy, per server kind (the
7379
+ * server's provider, or its id when it has none). Compared after
7380
+ * trim + NFKC + lower-casing, so case and width tricks do not bypass it.
7381
+ * Zoho has no draft tool: a draft is a mode of sendEmail, so one missing
7382
+ * field sends mail. Mail is only ever drafted through the backend's
7383
+ * draft-only path (CREW-400). resendVerification* mails an address a
7384
+ * verification link; denied too since an agent has no reason to trigger it.
7385
+ */
7386
+ readonly DENIED_AGENT_TOOLS: Record<string, readonly string[]>;
7387
+ /** Message an agent gets when it calls a denied tool. */
7388
+ readonly DENIED_TOOL_MESSAGE: "sending mail is not allowed for agents; save a draft instead";
7360
7389
  /**
7361
7390
  * OAuth state (clients, tokens, pending sign-ins) under CREWLY_HOME:
7362
7391
  * AES-256-GCM with the install's master key, mode 0600.