crewly 1.20.279 → 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 (117) hide show
  1. package/config/hooks/live-checkout-guard/guard.mjs +452 -0
  2. package/config/skills/agent/core/reply/SKILL.md +2 -1
  3. package/config/skills/agent/core/reply/execute.sh +11 -3
  4. package/config/skills/agent/core/reply/execute.test.sh +4 -0
  5. package/dist/backend/backend/src/constants.d.ts +81 -1
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +89 -0
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +1 -0
  10. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  11. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +3 -1
  12. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  13. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts +5 -0
  14. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js +42 -1
  16. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.d.ts +72 -0
  18. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.d.ts.map +1 -0
  19. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.js +113 -0
  20. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.js.map +1 -0
  21. package/dist/backend/backend/src/index.d.ts.map +1 -1
  22. package/dist/backend/backend/src/index.js +111 -1
  23. package/dist/backend/backend/src/index.js.map +1 -1
  24. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -2
  26. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts +4 -1
  28. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js +11 -2
  30. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  31. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts +3 -2
  32. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts.map +1 -1
  33. package/dist/backend/backend/src/services/agent/credential-guard.service.js +12 -4
  34. package/dist/backend/backend/src/services/agent/credential-guard.service.js.map +1 -1
  35. package/dist/backend/backend/src/services/agent/idle-detection.service.d.ts.map +1 -1
  36. package/dist/backend/backend/src/services/agent/idle-detection.service.js +7 -0
  37. package/dist/backend/backend/src/services/agent/idle-detection.service.js.map +1 -1
  38. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.d.ts +45 -0
  39. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.d.ts.map +1 -0
  40. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.js +109 -0
  41. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.js.map +1 -0
  42. package/dist/backend/backend/src/services/agent/resource-mode.service.d.ts.map +1 -1
  43. package/dist/backend/backend/src/services/agent/resource-mode.service.js +4 -1
  44. package/dist/backend/backend/src/services/agent/resource-mode.service.js.map +1 -1
  45. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js +13 -7
  47. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  48. package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts +16 -0
  49. package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts.map +1 -1
  50. package/dist/backend/backend/src/services/drive/drive-agent.service.js +37 -5
  51. package/dist/backend/backend/src/services/drive/drive-agent.service.js.map +1 -1
  52. package/dist/backend/backend/src/services/drive/drive-briefing-snapshot.d.ts +172 -0
  53. package/dist/backend/backend/src/services/drive/drive-briefing-snapshot.d.ts.map +1 -0
  54. package/dist/backend/backend/src/services/drive/drive-briefing-snapshot.js +301 -0
  55. package/dist/backend/backend/src/services/drive/drive-briefing-snapshot.js.map +1 -0
  56. package/dist/backend/backend/src/services/drive/drive-briefing-sync.service.d.ts +112 -0
  57. package/dist/backend/backend/src/services/drive/drive-briefing-sync.service.d.ts.map +1 -0
  58. package/dist/backend/backend/src/services/drive/drive-briefing-sync.service.js +247 -0
  59. package/dist/backend/backend/src/services/drive/drive-briefing-sync.service.js.map +1 -0
  60. package/dist/backend/backend/src/services/drive/drive-briefing.contract.d.ts +88 -0
  61. package/dist/backend/backend/src/services/drive/drive-briefing.contract.d.ts.map +1 -0
  62. package/dist/backend/backend/src/services/drive/drive-briefing.contract.js +18 -0
  63. package/dist/backend/backend/src/services/drive/drive-briefing.contract.js.map +1 -0
  64. package/dist/backend/backend/src/services/drive/drive-briefing.wiring.d.ts +73 -0
  65. package/dist/backend/backend/src/services/drive/drive-briefing.wiring.d.ts.map +1 -0
  66. package/dist/backend/backend/src/services/drive/drive-briefing.wiring.js +131 -0
  67. package/dist/backend/backend/src/services/drive/drive-briefing.wiring.js.map +1 -0
  68. package/dist/backend/backend/src/services/drive/drive-cloud.contract.d.ts +9 -2
  69. package/dist/backend/backend/src/services/drive/drive-cloud.contract.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/drive/drive-cloud.contract.js +9 -4
  71. package/dist/backend/backend/src/services/drive/drive-cloud.contract.js.map +1 -1
  72. package/dist/backend/backend/src/services/drive/drive-keep-warm.d.ts +72 -0
  73. package/dist/backend/backend/src/services/drive/drive-keep-warm.d.ts.map +1 -0
  74. package/dist/backend/backend/src/services/drive/drive-keep-warm.js +109 -0
  75. package/dist/backend/backend/src/services/drive/drive-keep-warm.js.map +1 -0
  76. package/dist/backend/backend/src/services/messaging/owner-hook-message.d.ts.map +1 -1
  77. package/dist/backend/backend/src/services/messaging/owner-hook-message.js +11 -5
  78. package/dist/backend/backend/src/services/messaging/owner-hook-message.js.map +1 -1
  79. package/dist/backend/backend/src/utils/boot-owner-alert.d.ts +33 -0
  80. package/dist/backend/backend/src/utils/boot-owner-alert.d.ts.map +1 -0
  81. package/dist/backend/backend/src/utils/boot-owner-alert.js +60 -0
  82. package/dist/backend/backend/src/utils/boot-owner-alert.js.map +1 -0
  83. package/dist/backend/backend/src/utils/build-provenance.d.ts +38 -0
  84. package/dist/backend/backend/src/utils/build-provenance.d.ts.map +1 -1
  85. package/dist/backend/backend/src/utils/build-provenance.js +63 -1
  86. package/dist/backend/backend/src/utils/build-provenance.js.map +1 -1
  87. package/dist/backend/build-info.json +2 -2
  88. package/dist/cli/backend/src/constants.d.ts +81 -1
  89. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  90. package/dist/cli/backend/src/constants.js +89 -0
  91. package/dist/cli/backend/src/constants.js.map +1 -1
  92. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  93. package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -2
  94. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  95. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts +4 -1
  96. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  97. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js +11 -2
  98. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  99. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts +3 -2
  100. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts.map +1 -1
  101. package/dist/cli/backend/src/services/agent/credential-guard.service.js +12 -4
  102. package/dist/cli/backend/src/services/agent/credential-guard.service.js.map +1 -1
  103. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.d.ts +45 -0
  104. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.d.ts.map +1 -0
  105. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.js +109 -0
  106. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.js.map +1 -0
  107. package/dist/cli/backend/src/services/agent/resource-mode.service.d.ts.map +1 -1
  108. package/dist/cli/backend/src/services/agent/resource-mode.service.js +4 -1
  109. package/dist/cli/backend/src/services/agent/resource-mode.service.js.map +1 -1
  110. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  111. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js +13 -7
  112. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  113. package/dist/cli/backend/src/services/drive/drive-keep-warm.d.ts +72 -0
  114. package/dist/cli/backend/src/services/drive/drive-keep-warm.d.ts.map +1 -0
  115. package/dist/cli/backend/src/services/drive/drive-keep-warm.js +109 -0
  116. package/dist/cli/backend/src/services/drive/drive-keep-warm.js.map +1 -0
  117. 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
+ });
@@ -102,7 +102,8 @@ tells the system that you are the one replying.
102
102
  | `--to <messageId>` | the message you answer (from your prompt) — goes to its conversation and thread |
103
103
  | `--work-item <id>` | the work item you answer about — goes where that work came from |
104
104
  | `--decision <id>` | the owner decision you follow up (`D-12`) — goes to the card's thread |
105
- | `--drive <session>` | the owner is in Drive mode (listening on the phone, your prompt says `[Drive mode · session …]`): the answer goes to their phone, not Slack — 1–3 short spoken sentences, no URLs, tables or code; `--interim` first when it needs work |
105
+ | `--drive <session> --ack "<text>"` | Drive mode, FIRST, within seconds of the message: a short acknowledgement or the direct answer ("On it — about five minutes.") |
106
+ | `--drive <session>` | the owner is in Drive mode (listening on the phone, your prompt says `[Drive mode · session …]`): the full result goes to their phone, not Slack — conclusion first, at most 3 spoken sentences; a decision → 2–3 options; no URLs, tables or code; details go in your end-of-session recap |
106
107
  | `--drive <session> --recap "<text>"` | after the Drive mode session ended: your ONE recap ("Drive mode recap — you said …; I did …; next: …"), posted in your DM with the owner (or the channel); it closes the conversation |
107
108
  | `--conversation <id>` / `--thread <key>` | hints only: used when they name a conversation you are in (a thread key must be that conversation's channel); otherwise ignored |
108
109
 
@@ -20,7 +20,8 @@ Usage:
20
20
  bash execute.sh --new-thread "Wiki link audit" "Found 3 broken links: …" # a new topic
21
21
  bash execute.sh --ticket TKT-187 "Here is the preview: https://…" # answer about a ticket
22
22
  bash execute.sh --to <messageId> "Yes — done." # answer a specific message
23
- bash execute.sh --drive <session> "好的,下午三点前发你。" # the owner is in Drive mode (voice)
23
+ bash execute.sh --drive <session> --ack "On it — about five minutes." # Drive mode: acknowledge within seconds
24
+ bash execute.sh --drive <session> "Done: published to 1,200 readers." # Drive mode: the full result (voice)
24
25
  bash execute.sh --drive <session> --recap "Drive mode recap — …" # after the Drive mode session ended
25
26
 
26
27
  Options:
@@ -36,7 +37,10 @@ Options:
36
37
  --work-item The work item you are answering about
37
38
  --decision The owner decision you are following up (D-12)
38
39
  --drive <session> The owner is in Drive mode (listening on the phone): your answer
39
- goes to their phone, not Slack. Short, spoken, no URLs/tables.
40
+ goes to their phone, not Slack. Conclusion first, at most 3
41
+ spoken sentences; a decision → 2–3 options; no URLs/tables.
42
+ --ack With --drive: a quick acknowledgement or direct answer within
43
+ seconds ("On it — about five minutes."); the full result follows
40
44
  --recap With --drive, after the session ended: your one recap, posted
41
45
  in your DM with the owner (or the channel); closes the conversation
42
46
  --conversation|-C Only if your prompt tells you to answer somewhere specific
@@ -58,6 +62,7 @@ WORK_ITEM=""
58
62
  DECISION=""
59
63
  DRIVE=""
60
64
  RECAP=""
65
+ ACK=""
61
66
 
62
67
  while [[ $# -gt 0 ]]; do
63
68
  case "$1" in
@@ -76,6 +81,7 @@ while [[ $# -gt 0 ]]; do
76
81
  --decision) DECISION="$2"; shift 2 ;;
77
82
  --drive) DRIVE="$2"; shift 2 ;;
78
83
  --recap) RECAP="1"; shift ;;
84
+ --ack) ACK="1"; shift ;;
79
85
  --help|-h) print_usage; exit 0 ;;
80
86
  --) shift; if [[ $# -gt 0 && -z "$TEXT" ]]; then TEXT="$*"; fi; break ;;
81
87
  -*) echo "{\"success\":false,\"error\":\"Unknown option: $1\"}" >&2; print_usage >&2; exit 2 ;;
@@ -95,7 +101,7 @@ fi
95
101
  # Literal \n (from JSON-escaped text) → real newlines
96
102
  if [ -n "$TEXT" ]; then _NL=$'\n'; TEXT="${TEXT//\\n/$_NL}"; fi
97
103
 
98
- BODY=$(TEXT="$TEXT" INTERIM="$INTERIM" NONE="$NONE" ADDS_NEW="$ADDS_NEW" CONVERSATION_ID="$CONVERSATION_ID" THREAD="$THREAD" NEW_THREAD="$NEW_THREAD" TICKET="$TICKET" TO="$TO" WORK_ITEM="$WORK_ITEM" DECISION="$DECISION" DRIVE="$DRIVE" RECAP="$RECAP" python3 -c '
104
+ BODY=$(TEXT="$TEXT" INTERIM="$INTERIM" NONE="$NONE" ADDS_NEW="$ADDS_NEW" CONVERSATION_ID="$CONVERSATION_ID" THREAD="$THREAD" NEW_THREAD="$NEW_THREAD" TICKET="$TICKET" TO="$TO" WORK_ITEM="$WORK_ITEM" DECISION="$DECISION" DRIVE="$DRIVE" RECAP="$RECAP" ACK="$ACK" python3 -c '
99
105
  import os, json
100
106
  p = {}
101
107
  if os.environ.get("NONE"):
@@ -124,6 +130,8 @@ if os.environ.get("DRIVE"):
124
130
  p["drive"] = os.environ["DRIVE"]
125
131
  if os.environ.get("RECAP"):
126
132
  p["recap"] = True
133
+ if os.environ.get("ACK"):
134
+ p["ack"] = True
127
135
  print(json.dumps(p))
128
136
  ')
129
137
 
@@ -92,6 +92,10 @@ run_skill --drive drv_abc12345 --recap "Drive mode recap — you said X; next: n
92
92
  LOG="$(cat "$STUB_LOG")"
93
93
  check "drive session" "$LOG" 'drive\\": \\"drv_abc12345'
94
94
  check "recap flag" "$LOG" 'recap\\": true'
95
+ : > "$STUB_LOG"
96
+ run_skill --drive drv_abc12345 --ack "On it" </dev/null >/dev/null 2>&1 || true
97
+ LOG="$(cat "$STUB_LOG")"
98
+ check "ack flag" "$LOG" 'ack\\": true'
95
99
 
96
100
  echo "test 4c: references (--ticket / --to / --work-item / --decision)"
97
101
  : > "$STUB_LOG"
@@ -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.
@@ -7558,6 +7587,57 @@ export declare const DRIVE_CONSTANTS: {
7558
7587
  readonly FETCH_RETRIES: 3;
7559
7588
  /** Metadata `via` on every chat-v2 row Drive mode writes (the Slack mirrors skip them) */
7560
7589
  readonly VIA: "drive-mode";
7590
+ /** Keep-warm (v3): agents one session keeps warm, at most */
7591
+ readonly MAX_WARM: 20;
7592
+ };
7593
+ /**
7594
+ * Drive mode v3 status briefing (specs/2026-10-09-drive-mode-v3.md §1): the
7595
+ * compact per-team / per-agent snapshot this machine keeps up to date and
7596
+ * pushes to Crewly Cloud, so the voice answers status questions with no
7597
+ * agent round trip. Built from structured data only — no LLM calls.
7598
+ */
7599
+ export declare const DRIVE_BRIEFING_CONSTANTS: {
7600
+ /** Cloud route (`:instanceId` = this machine's device id) */
7601
+ readonly PUT_PATH: "/api/cloud/instances/:instanceId/briefing";
7602
+ /** `0` / `off` turns the snapshot push off */
7603
+ readonly KILL_SWITCH_ENV: "CREWLY_DRIVE_BRIEFING";
7604
+ /** Gather events this long before rebuilding (ms) */
7605
+ readonly DEBOUNCE_MS: 3000;
7606
+ /** At most one rebuild this often; events in between are coalesced (ms) */
7607
+ readonly MIN_INTERVAL_MS: 30000;
7608
+ /** A project's tickets are re-read after this even without a change event (ms) */
7609
+ readonly TICKET_CACHE_MS: number;
7610
+ /** The task pool is re-read after this even without an event (ms) */
7611
+ readonly POOL_CACHE_MS: 60000;
7612
+ /** Rebuild anyway this often; uploads only when it changed (ms) */
7613
+ readonly CHECK_INTERVAL_MS: 60000;
7614
+ /** Upload even when unchanged this often (Cloud sees the machine is alive) (ms) */
7615
+ readonly FULL_SYNC_INTERVAL_MS: number;
7616
+ /** Cloud call timeout (ms) */
7617
+ readonly REQUEST_TIMEOUT_MS: 10000;
7618
+ /** Backoff after a failed upload (ms) */
7619
+ readonly BACKOFF_INITIAL_MS: 15000;
7620
+ readonly BACKOFF_MAX_MS: number;
7621
+ /** Cloud does not take snapshots (not deployed / refused): check again after (ms) */
7622
+ readonly UNAVAILABLE_RETRY_MS: number;
7623
+ /** Largest snapshot sent (bytes of JSON); Cloud refuses above 64 KB */
7624
+ readonly MAX_BYTES: number;
7625
+ readonly MAX_TEAMS: 30;
7626
+ readonly MAX_AGENTS: 80;
7627
+ readonly MAX_ITEMS: 80;
7628
+ readonly MAX_WAITING: 30;
7629
+ /** An agent's recent messages to the owner, at most */
7630
+ readonly MAX_LAST_TO_OWNER: 3;
7631
+ /** How far back an agent's messages to the owner are read (ms) */
7632
+ readonly OWNER_FEED_WINDOW_MS: number;
7633
+ /** chat-v2 rows scanned for them, at most */
7634
+ readonly OWNER_FEED_SCAN_LIMIT: 1500;
7635
+ /** Done / cancelled work older than this is left out (ms) */
7636
+ readonly DONE_WINDOW_MS: number;
7637
+ /** Text lengths (characters) */
7638
+ readonly NAME_MAX: 60;
7639
+ readonly TITLE_MAX: 140;
7640
+ readonly TEXT_MAX: 240;
7561
7641
  };
7562
7642
  /**
7563
7643
  * Drive mode voice layer: Gemini Live ephemeral tokens minted on this