crewly 1.20.280 → 1.20.282

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 (80) hide show
  1. package/config/hooks/live-checkout-guard/guard.mjs +452 -0
  2. package/config/skills/agent/core/receive-file/SKILL.md +53 -0
  3. package/config/skills/agent/core/receive-file/execute.sh +125 -0
  4. package/config/skills/agent/core/receive-file/execute.test.sh +80 -0
  5. package/config/skills/agent/core/send-file/SKILL.md +62 -0
  6. package/config/skills/agent/core/send-file/execute.sh +145 -0
  7. package/config/skills/agent/core/send-file/execute.test.sh +81 -0
  8. package/dist/backend/backend/src/constants.d.ts +30 -1
  9. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  10. package/dist/backend/backend/src/constants.js +38 -0
  11. package/dist/backend/backend/src/constants.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +12 -0
  13. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -1
  14. package/dist/backend/backend/src/controllers/apps/apps.controller.js +41 -0
  15. package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -1
  16. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +4 -0
  17. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -1
  18. package/dist/backend/backend/src/controllers/apps/apps.routes.js +12 -1
  19. package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -1
  20. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts +5 -0
  21. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.d.ts.map +1 -1
  22. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js +42 -1
  23. package/dist/backend/backend/src/controllers/connector/remote-mcp-proxy.controller.js.map +1 -1
  24. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.d.ts +72 -0
  25. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.d.ts.map +1 -0
  26. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.js +113 -0
  27. package/dist/backend/backend/src/controllers/connector/remote-mcp-tool-policy.js.map +1 -0
  28. package/dist/backend/backend/src/index.d.ts.map +1 -1
  29. package/dist/backend/backend/src/index.js +25 -1
  30. package/dist/backend/backend/src/index.js.map +1 -1
  31. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts +4 -1
  32. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  33. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js +11 -2
  34. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  35. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts +3 -2
  36. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts.map +1 -1
  37. package/dist/backend/backend/src/services/agent/credential-guard.service.js +12 -4
  38. package/dist/backend/backend/src/services/agent/credential-guard.service.js.map +1 -1
  39. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.d.ts +45 -0
  40. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.d.ts.map +1 -0
  41. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.js +109 -0
  42. package/dist/backend/backend/src/services/agent/live-checkout-guard.service.js.map +1 -0
  43. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  44. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js +13 -7
  45. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  46. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  47. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  48. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  49. package/dist/backend/backend/src/utils/boot-owner-alert.d.ts +33 -0
  50. package/dist/backend/backend/src/utils/boot-owner-alert.d.ts.map +1 -0
  51. package/dist/backend/backend/src/utils/boot-owner-alert.js +60 -0
  52. package/dist/backend/backend/src/utils/boot-owner-alert.js.map +1 -0
  53. package/dist/backend/backend/src/utils/build-provenance.d.ts +38 -0
  54. package/dist/backend/backend/src/utils/build-provenance.d.ts.map +1 -1
  55. package/dist/backend/backend/src/utils/build-provenance.js +63 -1
  56. package/dist/backend/backend/src/utils/build-provenance.js.map +1 -1
  57. package/dist/backend/build-info.json +2 -2
  58. package/dist/cli/backend/src/constants.d.ts +30 -1
  59. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  60. package/dist/cli/backend/src/constants.js +38 -0
  61. package/dist/cli/backend/src/constants.js.map +1 -1
  62. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts +4 -1
  63. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  64. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js +11 -2
  65. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  66. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts +3 -2
  67. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts.map +1 -1
  68. package/dist/cli/backend/src/services/agent/credential-guard.service.js +12 -4
  69. package/dist/cli/backend/src/services/agent/credential-guard.service.js.map +1 -1
  70. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.d.ts +45 -0
  71. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.d.ts.map +1 -0
  72. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.js +109 -0
  73. package/dist/cli/backend/src/services/agent/live-checkout-guard.service.js.map +1 -0
  74. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  75. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js +13 -7
  76. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  77. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  78. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  79. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  80. 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
+ });
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: Receive File
3
+ description: Download a large file another agent of the same Crewly account sent with send-file (handoff id hf_…), save it in your workspace, verify its size, then tell Crewly Cloud to delete it. Use when a message gives you a handoff id or says "receive it with the receive-file skill".
4
+ version: 1.0.0
5
+ category: productivity
6
+ skillType: claude-skill
7
+ assignableRoles:
8
+ - "*"
9
+ triggers:
10
+ - receive file
11
+ - download handoff
12
+ - hf_
13
+ - get the file from
14
+ - fetch the video
15
+ tags:
16
+ - files
17
+ - handoff
18
+ - transfer
19
+ execution:
20
+ type: script
21
+ script:
22
+ file: execute.sh
23
+ interpreter: bash
24
+ timeoutMs: 3600000
25
+ ---
26
+
27
+ # Receive File
28
+
29
+ Downloads a file handed to you with `send-file` (by any agent of your own
30
+ Crewly account, on this or another machine). The file streams to disk, never
31
+ through memory; its size is checked against what the sender declared; then the
32
+ temporary copy on the server is deleted.
33
+
34
+ ```bash
35
+ bash execute.sh --id hf_AbCdEfGhIjKlMnOpQrStUv
36
+ bash execute.sh --id hf_… --dir ./inbox
37
+ ```
38
+
39
+ Prints:
40
+
41
+ ```json
42
+ {"success":true,"path":"/…/received/daily-2026-10-09.mp4","fileName":"daily-2026-10-09.mp4","sizeBytes":314572800,"note":"today's cut","acked":true}
43
+ ```
44
+
45
+ - `--dir`: where to save (default `received/` in your project directory).
46
+ An existing file is never overwritten; a number is added to the name.
47
+ - If the size does not match, nothing is acknowledged and the partial file is
48
+ removed: run it again (the server copy stays until it is acknowledged or
49
+ 24 hours pass).
50
+ - An interrupted download keeps its `.part` file and resumes on the next run.
51
+ - `--no-ack` keeps the server copy so another run can fetch it again.
52
+ - A handoff is gone once received, cancelled or expired (24 h): ask the sender
53
+ to send it again.
@@ -0,0 +1,125 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # receive-file — download a file handed over with send-file, verify its size,
4
+ # acknowledge it (Cloud then deletes it). crewly-services apps/SPEC.md section 18.
5
+ #
6
+ # The backend (GET /api/apps/handoffs/:id) answers metadata and a presigned
7
+ # download URL; curl streams it straight to disk (resumable via a .part file).
8
+ #
9
+ # Usage:
10
+ # bash execute.sh --id hf_… [--dir <folder>] [--no-ack]
11
+ # =============================================================================
12
+ set -euo pipefail
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "${SCRIPT_DIR}/../../_common/lib.sh"
15
+
16
+ print_usage() {
17
+ cat <<'EOF_USAGE'
18
+ Usage:
19
+ bash execute.sh --id hf_… [--dir <folder>] [--no-ack]
20
+
21
+ Options:
22
+ --id Handoff id from the sender (required)
23
+ --dir Folder to save into (default: received/ in your project directory)
24
+ --no-ack Do not delete the server copy afterwards
25
+ --help | -h Show this help
26
+ EOF_USAGE
27
+ }
28
+
29
+ fail_from() {
30
+ printf '%s' "$1" | jq -c '{success: false, status: (.status // 0), reason: (.details.error // .error // "unknown"), message: (.details.message // .details // "")}' 2>/dev/null \
31
+ || jq -cn --arg r "$1" '{success: false, reason: $r}'
32
+ exit 1
33
+ }
34
+
35
+ call() {
36
+ local out
37
+ out=$(api_call "$@" 2>&1) || { fail_from "$(printf '%s\n' "$out" | tail -n 1)"; }
38
+ printf '%s\n' "$out" | tail -n 1
39
+ }
40
+
41
+ ID=""; DIR=""; ACK=1
42
+ while [[ $# -gt 0 ]]; do
43
+ case "$1" in
44
+ --id) [ $# -ge 2 ] || error_exit "--id requires a value"; ID="$2"; shift 2 ;;
45
+ --dir) [ $# -ge 2 ] || error_exit "--dir requires a value"; DIR="$2"; shift 2 ;;
46
+ --no-ack) ACK=0; shift ;;
47
+ --help|-h) print_usage; exit 0 ;;
48
+ *) error_exit "Unknown option: $1" ;;
49
+ esac
50
+ done
51
+ [ -n "$ID" ] || error_exit "--id is required"
52
+ [[ "$ID" =~ ^hf_[A-Za-z0-9_-]{22}$ ]] || error_exit "--id must be a handoff id like hf_… (22 characters after hf_)"
53
+ [ -n "$DIR" ] || DIR="${CREWLY_PROJECT_PATH:-$PWD}/received"
54
+ command -v node >/dev/null 2>&1 || error_exit "node is required"
55
+
56
+ # The destination folder must not be inside the Crewly home directory.
57
+ check_dir() {
58
+ RF_DIR="$1" node <<'NODE'
59
+ const fs = require('fs'); const path = require('path'); const os = require('os');
60
+ const abs = path.resolve(process.env.RF_DIR);
61
+ fs.mkdirSync(abs, { recursive: true });
62
+ const r = fs.realpathSync(abs);
63
+ const homes = [path.join(os.homedir(), '.crewly'), process.env.CREWLY_HOME].filter(Boolean).map((p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } });
64
+ if (homes.some((h) => r === h || r.startsWith(h + path.sep))) { process.stderr.write(JSON.stringify({ error: 'the folder is inside the Crewly home directory' }) + '\n'); process.exit(1); }
65
+ process.stdout.write(r);
66
+ NODE
67
+ }
68
+ DIR=$(check_dir "$DIR") || exit 1
69
+
70
+ META=$(call GET "/apps/handoffs/${ID}") || { printf '%s\n' "$META"; exit 1; }
71
+ STATUS=$(printf '%s' "$META" | jq -r '.data.status // empty')
72
+ if [ "$STATUS" != "ready" ]; then
73
+ jq -cn '{success: false, reason: "not_ready", message: "The sender has not finished uploading. Ask them to confirm, then run this again."}'
74
+ exit 1
75
+ fi
76
+ URL=$(printf '%s' "$META" | jq -r '.data.download.url // empty')
77
+ SIZE=$(printf '%s' "$META" | jq -r '.data.sizeBytes // empty')
78
+ RAW_NAME=$(printf '%s' "$META" | jq -r '.data.fileName // "file"')
79
+ NOTE=$(printf '%s' "$META" | jq -r '.data.note // empty')
80
+ [ -n "$URL" ] && [[ "$SIZE" =~ ^[0-9]+$ ]] || error_exit "Cloud answered without a download URL"
81
+
82
+ # A safe local name: the last path segment, no leading dots.
83
+ NAME=$(printf '%s' "$RAW_NAME" | tr '\\' '/' | awk -F/ '{print $NF}' | sed 's/^[.[:space:]]*//')
84
+ [ -n "$NAME" ] || NAME="file"
85
+
86
+ # The .part file is named by handoff id so an interrupted run resumes.
87
+ PART="${DIR}/.${ID}.part"
88
+ if [ -f "$PART" ]; then
89
+ HAVE=$(wc -c < "$PART" | tr -d ' ')
90
+ [ "$HAVE" -le "$SIZE" ] || rm -f "$PART"
91
+ fi
92
+ CODE=$(curl -sS -o "$PART" -w '%{http_code}' --connect-timeout 30 --retry 3 --retry-delay 3 -C - "$URL" 2>/dev/null) || CODE="000"
93
+ # 200 = whole body, 206 = resumed, 416 = the .part was already complete.
94
+ case "$CODE" in 200|206|416) ;; *)
95
+ [ "$CODE" = "000" ] || rm -f "$PART"
96
+ jq -cn --arg c "$CODE" '{success: false, reason: "download_failed", message: ("The download failed (HTTP " + $c + "). Run the skill again; a partial file is resumed.")}'
97
+ exit 1 ;;
98
+ esac
99
+
100
+ GOT=$(wc -c < "$PART" | tr -d ' ')
101
+ if [ "$GOT" != "$SIZE" ]; then
102
+ rm -f "$PART"
103
+ jq -cn --argjson g "$GOT" --argjson s "$SIZE" '{success: false, reason: "size_mismatch", message: ("Downloaded " + ($g|tostring) + " bytes, expected " + ($s|tostring) + ". The partial file was removed and the server copy kept; run the skill again.")}'
104
+ exit 1
105
+ fi
106
+
107
+ # Final name: never overwrite.
108
+ DEST="${DIR}/${NAME}"
109
+ if [ -e "$DEST" ]; then
110
+ BASE="${NAME%.*}"; EXT=""
111
+ [ "$BASE" != "$NAME" ] && EXT=".${NAME##*.}"
112
+ N=1
113
+ while [ -e "${DIR}/${BASE}-${N}${EXT}" ]; do N=$((N+1)); done
114
+ DEST="${DIR}/${BASE}-${N}${EXT}"
115
+ fi
116
+ mv "$PART" "$DEST"
117
+
118
+ ACKED=false
119
+ if [ "$ACK" = "1" ]; then
120
+ if api_call POST "/apps/handoffs/${ID}/ack" >/dev/null 2>&1; then ACKED=true; fi
121
+ fi
122
+
123
+ jq -cn --arg p "$DEST" --arg n "$NAME" --argjson s "$SIZE" --arg note "$NOTE" --argjson a "$ACKED" \
124
+ '{success: true, path: $p, fileName: $n, sizeBytes: $s, acked: $a} + (if $note != "" then {note: $note} else {} end)
125
+ + (if $a then {} else {warning: "The server copy was not deleted (it is removed automatically after 24 hours)."} end)'