crewly 1.20.205 → 1.20.207

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 (124) hide show
  1. package/config/hooks/credential-guard/guard.sh +243 -0
  2. package/config/skills/agent/core/docs-comment/SKILL.md +121 -0
  3. package/config/skills/agent/core/docs-comment/execute.sh +131 -0
  4. package/config/skills/agent/core/docs-comment/execute.test.sh +105 -0
  5. package/dist/backend/backend/src/constants.d.ts +67 -2
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +71 -1
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.js +17 -1
  11. package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +19 -5
  13. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  14. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +71 -5
  15. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  16. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  17. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +4 -1
  18. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  19. package/dist/backend/backend/src/controllers/google/google.controller.d.ts +30 -0
  20. package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
  21. package/dist/backend/backend/src/controllers/google/google.controller.js +75 -0
  22. package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
  23. package/dist/backend/backend/src/controllers/google/google.routes.d.ts +4 -0
  24. package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
  25. package/dist/backend/backend/src/controllers/google/google.routes.js +9 -1
  26. package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
  27. package/dist/backend/backend/src/controllers/workspace/workspace.routes.d.ts.map +1 -1
  28. package/dist/backend/backend/src/controllers/workspace/workspace.routes.js +10 -2
  29. package/dist/backend/backend/src/controllers/workspace/workspace.routes.js.map +1 -1
  30. package/dist/backend/backend/src/index.d.ts +8 -0
  31. package/dist/backend/backend/src/index.d.ts.map +1 -1
  32. package/dist/backend/backend/src/index.js +39 -1
  33. package/dist/backend/backend/src/index.js.map +1 -1
  34. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  35. package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -0
  36. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  37. package/dist/backend/backend/src/services/agent/codex-daemon.utils.d.ts +16 -0
  38. package/dist/backend/backend/src/services/agent/codex-daemon.utils.d.ts.map +1 -1
  39. package/dist/backend/backend/src/services/agent/codex-daemon.utils.js +44 -0
  40. package/dist/backend/backend/src/services/agent/codex-daemon.utils.js.map +1 -1
  41. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts +14 -2
  42. package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  43. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js +18 -3
  44. package/dist/backend/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  45. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts +153 -0
  46. package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts.map +1 -0
  47. package/dist/backend/backend/src/services/agent/credential-guard.service.js +314 -0
  48. package/dist/backend/backend/src/services/agent/credential-guard.service.js.map +1 -0
  49. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  50. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +5 -0
  51. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  52. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts +39 -0
  53. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  54. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js +132 -3
  55. package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  56. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  57. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  58. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  59. package/dist/backend/backend/src/services/core/credential-files.d.ts +26 -0
  60. package/dist/backend/backend/src/services/core/credential-files.d.ts.map +1 -0
  61. package/dist/backend/backend/src/services/core/credential-files.js +60 -0
  62. package/dist/backend/backend/src/services/core/credential-files.js.map +1 -0
  63. package/dist/backend/backend/src/services/google/docs-comments.service.d.ts +171 -0
  64. package/dist/backend/backend/src/services/google/docs-comments.service.d.ts.map +1 -0
  65. package/dist/backend/backend/src/services/google/docs-comments.service.js +218 -0
  66. package/dist/backend/backend/src/services/google/docs-comments.service.js.map +1 -0
  67. package/dist/backend/backend/src/services/google/docs.service.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/google/docs.service.js +14 -1
  69. package/dist/backend/backend/src/services/google/docs.service.js.map +1 -1
  70. package/dist/backend/backend/src/services/google/drive.service.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/google/drive.service.js +6 -4
  72. package/dist/backend/backend/src/services/google/drive.service.js.map +1 -1
  73. package/dist/backend/backend/src/services/google/google-api.client.d.ts +16 -3
  74. package/dist/backend/backend/src/services/google/google-api.client.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/google/google-api.client.js +5 -2
  76. package/dist/backend/backend/src/services/google/google-api.client.js.map +1 -1
  77. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +10 -0
  78. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/google/google-workspace-token.service.js +10 -0
  80. package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
  81. package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.d.ts +68 -0
  82. package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.d.ts.map +1 -0
  83. package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.js +95 -0
  84. package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.js.map +1 -0
  85. package/dist/backend/build-info.json +2 -2
  86. package/dist/cli/backend/src/constants.d.ts +67 -2
  87. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  88. package/dist/cli/backend/src/constants.js +71 -1
  89. package/dist/cli/backend/src/constants.js.map +1 -1
  90. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  91. package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -0
  92. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  93. package/dist/cli/backend/src/services/agent/codex-daemon.utils.d.ts +16 -0
  94. package/dist/cli/backend/src/services/agent/codex-daemon.utils.d.ts.map +1 -1
  95. package/dist/cli/backend/src/services/agent/codex-daemon.utils.js +44 -0
  96. package/dist/cli/backend/src/services/agent/codex-daemon.utils.js.map +1 -1
  97. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts +14 -2
  98. package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
  99. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js +18 -3
  100. package/dist/cli/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
  101. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts +153 -0
  102. package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts.map +1 -0
  103. package/dist/cli/backend/src/services/agent/credential-guard.service.js +314 -0
  104. package/dist/cli/backend/src/services/agent/credential-guard.service.js.map +1 -0
  105. package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  106. package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +5 -0
  107. package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  108. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts +39 -0
  109. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
  110. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js +132 -3
  111. package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
  112. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  113. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  114. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  115. package/dist/cli/backend/src/services/core/credential-files.d.ts +26 -0
  116. package/dist/cli/backend/src/services/core/credential-files.d.ts.map +1 -0
  117. package/dist/cli/backend/src/services/core/credential-files.js +60 -0
  118. package/dist/cli/backend/src/services/core/credential-files.js.map +1 -0
  119. package/package.json +1 -1
  120. package/packages/crewly-agent/src/runtime/credential-guard.test.ts +48 -0
  121. package/packages/crewly-agent/src/runtime/credential-guard.ts +56 -0
  122. package/packages/crewly-agent/src/runtime/tool-registry.ts +15 -0
  123. package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +18 -0
  124. package/packages/crewly-agent/src/runtime/web-search.tool.ts +36 -14
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env bash
2
+ # Crewly credential guard — a pre-tool hook that refuses an agent's tool call
3
+ # when it reads Crewly's own credentials.
4
+ #
5
+ # Spec: specs/2026-10-04-agent-credential-isolation.md (layer 2).
6
+ #
7
+ # Usage (wired by the backend through a generated wrapper; hook JSON on stdin):
8
+ # bash guard.sh <format> [<paths-file>]
9
+ # format: claude | codex | gemini | antigravity
10
+ # paths-file: written by the backend; defaults to $CREWLY_CREDENTIAL_GUARD_PATHS
11
+ #
12
+ # Paths-file lines (tab-separated):
13
+ # home - <CREWLY_HOME>
14
+ # abs <id> an absolute credential path (a file, or a directory = its subtree)
15
+ #
16
+ # What it judges — only REAL PATHS, resolved the way the call would resolve them:
17
+ # - shell commands (Bash, run_shell_command, agy run_command) are tokenised
18
+ # like a shell: quotes, escapes, `$(…)` and backticks (recursed into),
19
+ # ~ / $HOME / ${HOME} / $CREWLY_HOME expansion (not inside single quotes;
20
+ # ~ not inside double quotes). A word is a candidate path only when it
21
+ # starts with `/`, or contains `/` and is resolved against the call's cwd,
22
+ # and only when the whole word (or the value after `=`) is that path. A
23
+ # bare word (`cloud`, `credentials`, `api-token`) never matches, and a
24
+ # quoted sentence that mentions a path (a commit message) is one word
25
+ # that is not a path.
26
+ # - file tools: only argument fields whose NAME says path/file/dir
27
+ # (file_path, path, AbsolutePath, …), resolved against the cwd. Content
28
+ # fields (write_file / write_to_file bodies) are never read.
29
+ # - `security find-generic-password|find-internet-password|export|-i`
30
+ # in a command that names `crewly`, and any `security dump-keychain`.
31
+ #
32
+ # Deny contract:
33
+ # claude, codex, gemini exit 2, reason on stderr (fed back to the model)
34
+ # antigravity stdout {"decision":"deny","reason":"..."}, exit 0;
35
+ # an allowed call prints NOTHING (agy reads `{}` and
36
+ # a non-zero exit as deny)
37
+ #
38
+ # Fails open: no CREWLY_SESSION_NAME (the owner's own runtime), no node, no
39
+ # paths file, unparsable input -> allowed (agy) / non-blocking error (others).
40
+ #
41
+ # Coverage limits: paths built at runtime ($(echo …), base64, globs, other
42
+ # variables), a script the agent writes and then runs, an interpreter reading
43
+ # a computed path. It is a speed bump, not a boundary: same OS user.
44
+
45
+ set -u
46
+
47
+ FORMAT="${1:-claude}"
48
+ PATHS_FILE="${2:-${CREWLY_CREDENTIAL_GUARD_PATHS:-}}"
49
+ PREFIX="credential-guard:"
50
+ REASON_TAIL="Crewly credentials are not available to agents. Use the connector skills instead (docs-read, docs-comment, drive-read, sheets-read, gmail-search, ...) — they act for you without handing out a token. If a skill cannot do what you need, tell the owner instead of working around it."
51
+
52
+ INPUT="$(cat)"
53
+
54
+ # Owner's own runtime (no agent session): never judged.
55
+ SESSION="${CREWLY_SESSION_NAME:-}"
56
+ [ -z "$SESSION" ] && exit 0
57
+
58
+ if [ -z "$PATHS_FILE" ] || [ ! -f "$PATHS_FILE" ]; then
59
+ echo "$PREFIX NO PATHS CHECKED — paths file missing (${PATHS_FILE:-<none>})" >&2
60
+ [ "$FORMAT" = "antigravity" ] && exit 0
61
+ exit 1
62
+ fi
63
+
64
+ if ! command -v node >/dev/null 2>&1; then
65
+ echo "$PREFIX node not found — nothing checked" >&2
66
+ [ "$FORMAT" = "antigravity" ] && exit 0
67
+ exit 1
68
+ fi
69
+
70
+ # The matcher prints the rule id of the first guarded path the call reads, or nothing.
71
+ read -r -d '' MATCHER <<'JS'
72
+ const fs = require('fs');
73
+ const path = require('path');
74
+ const [pathsFile] = process.argv.slice(1);
75
+ let input = '';
76
+ process.stdin.on('data', (c) => { input += c; });
77
+ process.stdin.on('end', () => {
78
+ let o;
79
+ try { o = JSON.parse(input); } catch { return; }
80
+ let home = '';
81
+ const guarded = [];
82
+ for (const line of fs.readFileSync(pathsFile, 'utf8').split('\n')) {
83
+ const [kind, id, value] = line.split('\t');
84
+ if (kind === 'home') home = value;
85
+ else if (kind === 'abs' && id && value) guarded.push({ id, p: path.resolve(value) });
86
+ }
87
+ const HOME = process.env.HOME || '';
88
+ const CH = home || process.env.CREWLY_HOME || '';
89
+
90
+ const tool = String(o.tool_name || (o.toolCall && o.toolCall.name) || '');
91
+ const args = (o.tool_input || (o.toolCall && o.toolCall.args) || {});
92
+ const cwd = String(o.cwd || (args && args.Cwd) || (Array.isArray(o.workspacePaths) && o.workspacePaths[0]) || process.cwd());
93
+
94
+ const hit = (abs) => {
95
+ const p = path.resolve(abs);
96
+ for (const g of guarded) if (p === g.p || p.startsWith(g.p + '/')) return g.id;
97
+ return null;
98
+ };
99
+ const expandVars = (s) => s
100
+ .replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, HOME)
101
+ .replace(/\$\{CREWLY_HOME\}|\$CREWLY_HOME(?![A-Za-z0-9_])/g, CH);
102
+ // bareOk: a path FIELD (file tools) — a bare name is a path, and ~ is
103
+ // expanded here. Shell words arrive already expanded by the tokeniser
104
+ // (a quoted ~ stays literal, as in a shell).
105
+ const asPath = (s, bareOk) => {
106
+ if (!s) return null;
107
+ if (bareOk && s.startsWith('~/')) s = HOME + s.slice(1);
108
+ if (s.startsWith('/')) return s;
109
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return null; // URL
110
+ if (s.includes('/') || bareOk) return path.resolve(cwd, s);
111
+ return null;
112
+ };
113
+
114
+ // ---- shell tokeniser ----
115
+ const OPS = new Set([';', '&', '|', '<', '>', '(', ')', '\n']);
116
+ function tokenize(cmd) {
117
+ const out = []; // { w: string|null (null = operator), q: bool quoted }
118
+ const subs = [];
119
+ let w = '', started = false, quotedAll = true, i = 0;
120
+ const push = () => { if (started) out.push({ w, q: quotedAll }); w = ''; started = false; quotedAll = true; };
121
+ const readSub = (openIdx) => { // $( ... ) with nesting
122
+ let depth = 1, j = openIdx;
123
+ while (j < cmd.length && depth > 0) { if (cmd[j] === '(') depth++; else if (cmd[j] === ')') depth--; j++; }
124
+ subs.push(cmd.slice(openIdx, j - 1));
125
+ return j;
126
+ };
127
+ while (i < cmd.length) {
128
+ const c = cmd[i];
129
+ if (c === '\\' && i + 1 < cmd.length) { w += cmd[i + 1]; started = true; quotedAll = false; i += 2; continue; }
130
+ if (c === "'") { const j = cmd.indexOf("'", i + 1); const end = j < 0 ? cmd.length : j; w += cmd.slice(i + 1, end); started = true; i = end + 1; continue; }
131
+ if (c === '"') {
132
+ let j = i + 1, seg = '';
133
+ while (j < cmd.length && cmd[j] !== '"') {
134
+ if (cmd[j] === '\\' && j + 1 < cmd.length) { seg += cmd[j + 1]; j += 2; continue; }
135
+ if (cmd[j] === '$' && cmd[j + 1] === '(') { j = readSub(j + 2); seg += '\u0000'; continue; }
136
+ if (cmd[j] === '`') { const k = cmd.indexOf('`', j + 1); const e = k < 0 ? cmd.length : k; subs.push(cmd.slice(j + 1, e)); seg += '\u0000'; j = e + 1; continue; }
137
+ seg += cmd[j]; j++;
138
+ }
139
+ w += expandVars(seg); started = true; i = j + 1; continue;
140
+ }
141
+ if (c === '$' && cmd[i + 1] === '(') { i = readSub(i + 2); w += '\u0000'; started = true; quotedAll = false; continue; }
142
+ if (c === '`') { const k = cmd.indexOf('`', i + 1); const e = k < 0 ? cmd.length : k; subs.push(cmd.slice(i + 1, e)); w += '\u0000'; started = true; quotedAll = false; i = e + 1; continue; }
143
+ if (/\s/.test(c) && c !== '\n') { push(); i++; continue; }
144
+ if (OPS.has(c)) { push(); out.push({ w: null }); i++; continue; }
145
+ // unquoted text: ~ at the start of a word, and variables
146
+ // ~ at the start of a word, or right after `=` / `:` (bash expands --opt=~/x too)
147
+ if (c === '~' && (!started || /[=:]$/.test(w)) && (cmd[i + 1] === '/' || i + 1 === cmd.length)) { w += HOME; started = true; quotedAll = false; i++; continue; }
148
+ if (c === '$') {
149
+ const m = /^\$\{(HOME|CREWLY_HOME)\}|^\$(HOME|CREWLY_HOME)(?![A-Za-z0-9_])/.exec(cmd.slice(i));
150
+ if (m) { w += (m[1] || m[2]) === 'HOME' ? HOME : CH; started = true; quotedAll = false; i += m[0].length; continue; }
151
+ }
152
+ w += c; started = true; quotedAll = false; i++;
153
+ }
154
+ push();
155
+ return { tokens: out, subs };
156
+ }
157
+
158
+ const SKIP = new Set(['sudo', 'command', 'env', 'nohup', 'time', 'xargs', 'exec', 'builtin']);
159
+ function checkCommand(cmd, depth) {
160
+ if (depth > 4) return null;
161
+ const { tokens, subs } = tokenize(cmd);
162
+ for (const s of subs) { const r = checkCommand(s, depth + 1); if (r) return r; }
163
+ // simple commands
164
+ let simple = [];
165
+ const cmds = [];
166
+ for (const t of tokens) { if (t.w === null) { if (simple.length) cmds.push(simple); simple = []; } else simple.push(t.w); }
167
+ if (simple.length) cmds.push(simple);
168
+ const lowerAll = cmd.toLowerCase();
169
+ for (const words of cmds) {
170
+ let k = 0;
171
+ while (k < words.length && (SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k]))) k++;
172
+ const verb = (words[k] || '').split('/').pop();
173
+ const rest = words.slice(k + 1).map((x) => x.toLowerCase());
174
+ if (verb === 'security') {
175
+ if (rest.includes('dump-keychain')) return 'keychain';
176
+ if (rest.some((x) => ['find-generic-password', 'find-internet-password', 'export', '-i'].includes(x)) && lowerAll.includes('crewly')) return 'keychain';
177
+ }
178
+ for (const word of words) {
179
+ if (word.includes('\u0000')) continue; // contains a substitution: judged through the substitution
180
+ const cands = [word];
181
+ const eq = word.indexOf('=');
182
+ if (eq > 0) cands.push(word.slice(eq + 1));
183
+ for (const c of cands) {
184
+ if (/\s/.test(c)) continue; // a sentence, not a path
185
+ const p = asPath(c, false);
186
+ if (p) { const id = hit(p); if (id) return id; }
187
+ }
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function checkPathFields(obj) {
194
+ if (!obj || typeof obj !== 'object') return null;
195
+ for (const [k, v] of Object.entries(obj)) {
196
+ if (!/path|file|dir/i.test(k)) continue; // never content fields
197
+ const vals = Array.isArray(v) ? v : [v];
198
+ for (const x of vals) {
199
+ if (typeof x !== 'string') continue;
200
+ const p = asPath(expandVars(x), true);
201
+ if (p) { const id = hit(p); if (id) return id; }
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ const shellCmd = typeof args.command === 'string' ? args.command : typeof args.CommandLine === 'string' ? args.CommandLine : null;
208
+ const isShell = /^(bash|run_shell_command|run_command|shell|exec_command)$/i.test(tool) || (shellCmd !== null && !/write|replace|edit/i.test(tool));
209
+ let id = null;
210
+ if (isShell && shellCmd !== null) id = checkCommand(shellCmd, 0);
211
+ if (!id && !/write|replace|edit|create/i.test(tool)) id = checkPathFields(args);
212
+ if (id) process.stdout.write(id);
213
+ });
214
+ JS
215
+
216
+ MATCH_ID="$(printf '%s' "$INPUT" | node -e "$MATCHER" "$PATHS_FILE" 2>/dev/null)"
217
+ MATCH_ID="$(printf '%s' "$MATCH_ID" | tr -cd 'A-Za-z0-9_-' | cut -c1-64)"
218
+
219
+ [ -z "$MATCH_ID" ] && exit 0
220
+
221
+ REASON="Blocked: this reads Crewly's own credentials (${MATCH_ID}). ${REASON_TAIL}"
222
+
223
+ # ---- tell the backend (warning in its log; the owner hears once a day) ------
224
+ API_URL="${CREWLY_API_URL:-http://localhost:${WEB_PORT:-8787}}"
225
+ if command -v curl >/dev/null 2>&1; then
226
+ CURL_ARGS=(-s -o /dev/null --max-time 2 -X POST "$API_URL/api/agent-hooks"
227
+ -H "Content-Type: application/json"
228
+ -H "User-Agent: crewly-credential-guard/1"
229
+ -H "X-Agent-Session: $SESSION")
230
+ if [ -n "${CREWLY_AGENT_BADGE:-}" ]; then
231
+ CURL_ARGS+=(-H "X-Agent-Badge: $CREWLY_AGENT_BADGE")
232
+ fi
233
+ curl "${CURL_ARGS[@]}" --data "{\"event\":\"CredentialAccessBlocked\",\"rule\":\"$MATCH_ID\",\"runtime\":\"$(printf '%s' "$FORMAT" | tr -cd 'a-z')\"}" >/dev/null 2>&1 || true
234
+ fi
235
+
236
+ if [ "$FORMAT" = "antigravity" ]; then
237
+ ESCAPED="$(printf '%s' "$REASON" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
238
+ printf '{"decision":"deny","reason":"%s"}\n' "$ESCAPED"
239
+ exit 0
240
+ fi
241
+
242
+ echo "$REASON" >&2
243
+ exit 2
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: Docs Comment
3
+ description: Handle feedback left as comments in a Google Doc — list comments and replies, reply, resolve, or add a comment (via the Google Workspace grant held by Crewly Cloud). Writes as the owner's Google account — say what you posted.
4
+ version: 1.0.0
5
+ category: productivity
6
+ skillType: claude-skill
7
+ assignableRoles:
8
+ - orchestrator
9
+ - team-leader
10
+ - developer
11
+ - operations
12
+ - ops
13
+ - sales
14
+ - support
15
+ - generalist
16
+ triggers:
17
+ - comments in the doc
18
+ - google doc comments
19
+ - reply to the comment
20
+ - resolve the comment
21
+ - feedback in the doc
22
+ - add a comment to the doc
23
+ tags:
24
+ - google
25
+ - docs
26
+ - comments
27
+ execution:
28
+ type: script
29
+ script:
30
+ file: execute.sh
31
+ interpreter: bash
32
+ timeoutMs: 60000
33
+ ---
34
+
35
+ # Docs Comment
36
+
37
+ ```bash
38
+ bash execute.sh list --doc "https://docs.google.com/document/d/1AbC…/edit"
39
+ bash execute.sh list --doc 1AbC… --include-resolved
40
+ bash execute.sh reply --doc 1AbC… --comment AAAB… --text "Updated the intro, see paragraph 2."
41
+ bash execute.sh resolve --doc 1AbC… --comment AAAB… --text "Done in v2."
42
+ bash execute.sh resolve --doc 1AbC… --comment AAAB…
43
+ bash execute.sh add --doc 1AbC… --text "Is this number from Q2?" --quote "grew 40%"
44
+ ```
45
+
46
+ `--doc` takes a document id or its docs.google.com URL. `--account` picks a
47
+ connected Google account (see below).
48
+
49
+ ## Working through feedback
50
+
51
+ 1. `list` the open comments. Each one has the text it is about (`quote`),
52
+ who wrote it and when, and any replies.
53
+ 2. Make the change the comment asks for (for example with `docs-write`), or
54
+ decide it needs the owner.
55
+ 3. `resolve` the comment with a one-line note of what changed, or `reply`
56
+ with a question if it is unclear. Do not resolve a comment you did not
57
+ address.
58
+
59
+ Everything you post appears under the owner's Google name. Tell the owner
60
+ what you replied to or resolved.
61
+
62
+ ## Output
63
+
64
+ `list`:
65
+
66
+ ```json
67
+ {"success":true,"docId":"1AbC…","count":1,"truncated":false,"comments":[{"id":"AAAB…","author":"Steve","createdTime":"2026-10-03T09:12:00.000Z","content":"Too long","quote":"In this document we…","anchor":"kix.x1","resolved":false,"replies":[{"id":"AAAC…","author":"Ella","createdTime":"…","content":"Trimmed."}]}]}
68
+ ```
69
+
70
+ Resolved comments are left out unless you pass `--include-resolved`.
71
+ `truncated: true` means the document has more than 500 comments and only the
72
+ first 500 were read.
73
+
74
+ `reply` / `resolve`:
75
+
76
+ ```json
77
+ {"success":true,"action":"reply","docId":"1AbC…","commentId":"AAAB…","replyId":"AAAD…","content":"Updated the intro."}
78
+ ```
79
+
80
+ `add`:
81
+
82
+ ```json
83
+ {"success":true,"action":"add","docId":"1AbC…","commentId":"AAAE…","content":"Is this number from Q2?","quote":"grew 40%"}
84
+ ```
85
+
86
+ ## Comments you add are not anchored
87
+
88
+ Google Docs does not show a comment created through the API as attached to
89
+ text. `--quote` is sent to Google as the quoted text, but in the Docs UI the
90
+ comment **may appear as a general (unanchored) comment** on the whole
91
+ document. When the location matters, quote the passage in the comment text
92
+ too, for example `--text "On 'grew 40%': is this Q2?"`.
93
+
94
+ ## Failures
95
+
96
+ `{"success":false,"reason":"reauth_required",…}` (exit 1): the Google grant
97
+ can read comments but not write them on this document. Reply, resolve and add
98
+ on a document Crewly did not create need Google Drive edit access, which
99
+ older connections do not include. Run `google-connect --product drive --channel <chat-channel-id>` to
100
+ post a one-tap card for the owner, and tell them in one line what you will do
101
+ once it is granted. Do not paste a link yourself.
102
+
103
+ `{"success":false,"reason":"not_connected","hint":"…"}` (exit 1): Google
104
+ Drive is not connected. Use `google-connect --product drive` the same way.
105
+
106
+ `{"success":false,"reason":"not_permitted",…}` (exit 1): this Google
107
+ connection belongs to someone else and is not shared with the person you are
108
+ working for. Say so; do not try another account.
109
+
110
+ `reason: "google_error"` with a 404 means the document id or comment id is
111
+ wrong, or the account cannot see the document.
112
+
113
+ ## Choosing a Google account
114
+
115
+ Several Google accounts can be connected at once. Without `--account` the call uses the default one (the first you connected, or whichever you marked default on the Connections page). Name one explicitly when it matters:
116
+
117
+ ```bash
118
+ bash execute.sh list --doc 1AbC… --account work@company.com
119
+ ```
120
+
121
+ The account must be connected *for this product* — Google consent is per product (Gmail / Calendar / Drive), so an account connected only for Calendar cannot read Drive.
@@ -0,0 +1,131 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # docs-comment — List, reply to, resolve or add comments in a Google Doc
4
+ #
5
+ # Backed by:
6
+ # GET /api/google/docs/:id/comments[?includeResolved=1]
7
+ # POST /api/google/docs/:id/comments { text, quote? }
8
+ # POST /api/google/docs/:id/comments/:commentId/replies { text }
9
+ # POST /api/google/docs/:id/comments/:commentId/resolve { text? }
10
+ #
11
+ # Usage:
12
+ # bash execute.sh list --doc <id|url> [--include-resolved]
13
+ # bash execute.sh reply --doc <id|url> --comment <id> --text "…"
14
+ # bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
15
+ # bash execute.sh add --doc <id|url> --text "…" [--quote "exact text"]
16
+ # bash execute.sh '{"command":"list","doc":"<id>"}'
17
+ # =============================================================================
18
+ set -euo pipefail
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ source "${SCRIPT_DIR}/../../_common/lib.sh"
21
+
22
+ print_usage() {
23
+ cat <<'EOF_USAGE'
24
+ Usage:
25
+ bash execute.sh list --doc <id|url> [--include-resolved]
26
+ bash execute.sh reply --doc <id|url> --comment <id> --text "…"
27
+ bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
28
+ bash execute.sh add --doc <id|url> --text "…" [--quote "exact text in the doc"]
29
+ bash execute.sh '{"command":"reply","doc":"<id>","comment":"<id>","text":"…"}'
30
+
31
+ Options:
32
+ --doc Document id or docs.google.com URL
33
+ --comment Comment id (from list)
34
+ --text Reply / comment text (optional for resolve)
35
+ --quote Exact document text the new comment is about (add only;
36
+ Google Docs may still show it as an unanchored comment)
37
+ --include-resolved list: include resolved comments too
38
+ --account Which connected Google account to act as (default: your primary)
39
+ --help | -h Show this help
40
+ EOF_USAGE
41
+ }
42
+
43
+ fail_from() {
44
+ printf '%s' "$1" | jq -c '{success: false, reason: (.details.error // .details // .error // "unknown"), hint: (.details.hint // ""), message: (.details.message // "")}' 2>/dev/null \
45
+ || jq -n --arg r "$1" '{success: false, reason: $r}'
46
+ exit 1
47
+ }
48
+
49
+ uri() { jq -rn --arg v "$1" '$v|@uri'; }
50
+
51
+ # api_call may print a one-line warning to stderr (no CREWLY_SESSION_NAME);
52
+ # the backend answer is always the last line. On failure print the mapped
53
+ # failure JSON (fail_from) and return 1.
54
+ call() {
55
+ local out
56
+ out=$(api_call "$@" 2>&1) || { fail_from "$(printf '%s\n' "$out" | tail -n 1)"; }
57
+ printf '%s\n' "$out" | tail -n 1
58
+ }
59
+
60
+ INPUT_JSON=""
61
+ COMMAND=""
62
+ if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
63
+ INPUT_JSON="$1"
64
+ shift || true
65
+ elif [[ $# -gt 0 && ${1:0:1} != '-' ]]; then
66
+ COMMAND="$1"
67
+ shift
68
+ fi
69
+ DOC=""; COMMENT=""; TEXT=""; TEXT_SET=0; QUOTE=""; INCLUDE_RESOLVED=0; ACCOUNT=""
70
+ while [[ $# -gt 0 ]]; do
71
+ case "$1" in
72
+ --doc|--id) [ $# -ge 2 ] || error_exit "$1 requires a value"; DOC="$2"; shift 2 ;;
73
+ --comment) [ $# -ge 2 ] || error_exit "--comment requires a value"; COMMENT="$2"; shift 2 ;;
74
+ --text) [ $# -ge 2 ] || error_exit "--text requires a value"; TEXT="$2"; TEXT_SET=1; shift 2 ;;
75
+ --quote) [ $# -ge 2 ] || error_exit "--quote requires a value"; QUOTE="$2"; shift 2 ;;
76
+ --include-resolved) INCLUDE_RESOLVED=1; shift ;;
77
+ --account) [ $# -ge 2 ] || error_exit "--account requires a value"; ACCOUNT="$2"; shift 2 ;;
78
+ --help|-h) print_usage; exit 0 ;;
79
+ *) error_exit "Unknown option: $1" ;;
80
+ esac
81
+ done
82
+ if [ -n "$INPUT_JSON" ]; then
83
+ INPUT=$(read_json_input "$INPUT_JSON")
84
+ [ -z "$COMMAND" ] && COMMAND=$(printf '%s' "$INPUT" | jq -r '.command // .action // empty')
85
+ [ -z "$DOC" ] && DOC=$(printf '%s' "$INPUT" | jq -r '.doc // .id // .documentId // empty')
86
+ [ -z "$COMMENT" ] && COMMENT=$(printf '%s' "$INPUT" | jq -r '.comment // .commentId // empty')
87
+ [ "$TEXT_SET" -eq 0 ] && TEXT=$(printf '%s' "$INPUT" | jq -r '.text // empty')
88
+ [ -z "$QUOTE" ] && QUOTE=$(printf '%s' "$INPUT" | jq -r '.quote // empty')
89
+ [ "$INCLUDE_RESOLVED" -eq 0 ] && [ "$(printf '%s' "$INPUT" | jq -r '.includeResolved // false')" = "true" ] && INCLUDE_RESOLVED=1
90
+ [ -z "$ACCOUNT" ] && ACCOUNT=$(printf '%s' "$INPUT" | jq -r '.account // empty')
91
+ fi
92
+ # Route the call at one connected Google account; unset means the default.
93
+ [ -n "$ACCOUNT" ] && export CREWLY_GOOGLE_ACCOUNT="$ACCOUNT"
94
+ # Accept a full docs.google.com URL too.
95
+ DOC=$(printf '%s' "$DOC" | sed -E 's#.*/document/d/([^/?]+).*#\1#')
96
+
97
+ [ -n "$COMMAND" ] || error_exit "a command is required: list, reply, resolve or add (see --help)"
98
+ require_param "doc (--doc)" "$DOC"
99
+ BASE="/google/docs/$(uri "$DOC")/comments"
100
+
101
+ case "$COMMAND" in
102
+ list)
103
+ QS=""; [ "$INCLUDE_RESOLVED" -eq 1 ] && QS="?includeResolved=1"
104
+ RESPONSE=$(call GET "${BASE}${QS}") || { printf '%s\n' "$RESPONSE"; exit 1; }
105
+ # Oversized answers come back as the output-cap envelope; pass it through.
106
+ if printf '%s' "$RESPONSE" | jq -e '.truncated == true and has("file")' >/dev/null 2>&1; then printf '%s\n' "$RESPONSE"; exit 0; fi
107
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), docId: .data.docId, count: (.data.comments | length), truncated: (.data.truncated // false), comments: .data.comments}'
108
+ ;;
109
+ reply)
110
+ require_param "comment (--comment)" "$COMMENT"
111
+ require_param "text (--text)" "$TEXT"
112
+ BODY=$(jq -cn --arg text "$TEXT" '{text: $text}')
113
+ RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/replies" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
114
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "reply", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: .data.content}'
115
+ ;;
116
+ resolve)
117
+ require_param "comment (--comment)" "$COMMENT"
118
+ BODY=$(jq -cn --arg text "$TEXT" 'if $text != "" then {text: $text} else {} end')
119
+ RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/resolve" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
120
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "resolve", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: (.data.content // ""), resolved: true}'
121
+ ;;
122
+ add)
123
+ require_param "text (--text)" "$TEXT"
124
+ BODY=$(jq -cn --arg text "$TEXT" --arg quote "$QUOTE" '{text: $text} + (if $quote != "" then {quote: $quote} else {} end)')
125
+ RESPONSE=$(call POST "${BASE}" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
126
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "add", docId: .data.docId, commentId: .data.id, content: .data.content} + (if .data.quote then {quote: .data.quote} else {} end)'
127
+ ;;
128
+ *)
129
+ error_exit "Unknown command: $COMMAND (expected list, reply, resolve or add)"
130
+ ;;
131
+ esac
@@ -0,0 +1,105 @@
1
+ #!/bin/bash
2
+ # Tests for docs-comment — run with: bash execute.test.sh
3
+ # Python HTTP stub as the backend; asserts the request the skill sends and
4
+ # the JSON it prints. No Google call is made. Exit 0 on pass.
5
+ set -u
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ EXEC="$HERE/execute.sh"
8
+ PASS=0; FAIL=0
9
+ check() {
10
+ local name="$1"; local got="$2"; local want="$3"
11
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
12
+ }
13
+
14
+ PORT=18831
15
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
16
+ python3 -u <<'PY' >/dev/null 2>&1 &
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ import json, os
19
+ LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
20
+ COMMENT = {'id': 'c1', 'author': 'Steve', 'createdTime': 't0', 'content': 'Too long', 'quote': 'intro',
21
+ 'resolved': False, 'replies': [{'id': 'r0', 'author': 'Ella', 'createdTime': 't1', 'content': 'Trimmed'}]}
22
+ class H(BaseHTTPRequestHandler):
23
+ def _send(self, status, payload):
24
+ self.send_response(status); self.send_header('Content-Type', 'application/json'); self.end_headers()
25
+ self.wfile.write(json.dumps(payload).encode())
26
+ def _reply(self, method, data=None):
27
+ open(LOG, 'w').write(json.dumps({'method': method, 'path': self.path, 'body': data,
28
+ 'account': self.headers.get('X-Google-Account')}))
29
+ if '/docs/narrow/' in self.path:
30
+ return self._send(403, {'success': False, 'error': 'reauth_required',
31
+ 'message': 'Replying to or adding comments on this document needs Google Drive edit access, which this Google account has not granted yet.',
32
+ 'hint': 'Ask the owner to reconnect Google Drive: run the google-connect skill with --product drive.'})
33
+ if method == 'GET':
34
+ return self._send(200, {'success': True, 'data': {'docId': 'd1', 'comments': [COMMENT], 'truncated': False}})
35
+ if self.path.endswith('/replies'):
36
+ return self._send(200, {'success': True, 'data': {'docId': 'd1', 'commentId': 'c1', 'id': 'r9', 'content': data['text']}})
37
+ if self.path.endswith('/resolve'):
38
+ return self._send(200, {'success': True, 'data': {'docId': 'd1', 'commentId': 'c1', 'id': 'r10', 'action': 'resolve', 'content': data.get('text', ''), 'resolved': True}})
39
+ out = {'docId': 'd1', 'id': 'c7', 'content': data['text'], 'resolved': False, 'replies': []}
40
+ if 'quote' in data: out['quote'] = data['quote']
41
+ return self._send(200, {'success': True, 'data': out})
42
+ def do_GET(self): self._reply('GET')
43
+ def do_POST(self):
44
+ n = int(self.headers.get('content-length', '0')); body = self.rfile.read(n).decode() if n else ''
45
+ self._reply('POST', json.loads(body or '{}'))
46
+ def log_message(self, *a, **k): pass
47
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
48
+ PY
49
+ STUB_PID=$!
50
+ disown "$STUB_PID" 2>/dev/null || true
51
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG"' EXIT
52
+ for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
53
+
54
+ run() { CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$@" 2>/dev/null; }
55
+ run_err() { CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$@" 2>&1 >/dev/null; }
56
+ req() { jq -r '.method + " " + .path + " " + (.body|tojson)' "$STUB_LOG"; }
57
+
58
+ # list
59
+ OUT=$(run list --doc "https://docs.google.com/document/d/d1/edit?tab=t.0")
60
+ check "list: request" "$(req)" 'GET /api/google/docs/d1/comments null'
61
+ check "list: output" "$(printf '%s' "$OUT" | jq -c '{success, docId, count, truncated, first: .comments[0].id, quote: .comments[0].quote, replies: (.comments[0].replies|length)}')" \
62
+ '{"success":true,"docId":"d1","count":1,"truncated":false,"first":"c1","quote":"intro","replies":1}'
63
+ run list --doc d1 --include-resolved >/dev/null
64
+ check "list: --include-resolved" "$(req)" 'GET /api/google/docs/d1/comments?includeResolved=1 null'
65
+
66
+ # reply
67
+ OUT=$(run reply --doc d1 --comment c1 --text "Fixed, see para 2")
68
+ check "reply: request" "$(req)" 'POST /api/google/docs/d1/comments/c1/replies {"text":"Fixed, see para 2"}'
69
+ check "reply: output" "$OUT" '{"success":true,"action":"reply","docId":"d1","commentId":"c1","replyId":"r9","content":"Fixed, see para 2"}'
70
+
71
+ # resolve, with and without a message
72
+ OUT=$(run resolve --doc d1 --comment c1 --text "Done")
73
+ check "resolve: request" "$(req)" 'POST /api/google/docs/d1/comments/c1/resolve {"text":"Done"}'
74
+ check "resolve: output" "$OUT" '{"success":true,"action":"resolve","docId":"d1","commentId":"c1","replyId":"r10","content":"Done","resolved":true}'
75
+ run resolve --doc d1 --comment c1 >/dev/null
76
+ check "resolve: no text" "$(req)" 'POST /api/google/docs/d1/comments/c1/resolve {}'
77
+
78
+ # add, with and without a quote
79
+ OUT=$(run add --doc d1 --text "Source?" --quote "grew 40%")
80
+ check "add: request" "$(req)" 'POST /api/google/docs/d1/comments {"text":"Source?","quote":"grew 40%"}'
81
+ check "add: output" "$OUT" '{"success":true,"action":"add","docId":"d1","commentId":"c7","content":"Source?","quote":"grew 40%"}'
82
+ run add --doc d1 --text "General" >/dev/null
83
+ check "add: no quote" "$(req)" 'POST /api/google/docs/d1/comments {"text":"General"}'
84
+
85
+ # JSON input + --account
86
+ run '{"command":"reply","doc":"d1","comment":"c1","text":"via json","account":"work@x.com"}' >/dev/null
87
+ check "json input: request" "$(req)" 'POST /api/google/docs/d1/comments/c1/replies {"text":"via json"}'
88
+ check "json input: account header" "$(jq -r '.account' "$STUB_LOG")" 'work@x.com'
89
+
90
+ # reauth_required is passed through with its English hint, exit 1
91
+ OUT=$(run reply --doc narrow --comment c1 --text "x"); CODE=$?
92
+ check "reauth: exit code" "$CODE" "1"
93
+ check "reauth: reason" "$(printf '%s' "$OUT" | jq -r '.reason')" "reauth_required"
94
+ check "reauth: hint names the one-tap path" "$(printf '%s' "$OUT" | jq -r '.hint' | grep -c 'google-connect skill with --product drive')" "1"
95
+
96
+ # argument validation (no request sent)
97
+ check "no command" "$(run_err --doc d1 | grep -c 'command is required')" "1"
98
+ check "unknown command" "$(run_err delete --doc d1 | grep -c 'Unknown command')" "1"
99
+ check "reply needs --comment" "$(run_err reply --doc d1 --text x | grep -c 'comment')" "1"
100
+ check "reply needs --text" "$(run_err reply --doc d1 --comment c1 | grep -c 'text')" "1"
101
+ check "add needs --text" "$(run_err add --doc d1 | grep -c 'text')" "1"
102
+ check "needs --doc" "$(run_err list | grep -c 'doc')" "1"
103
+
104
+ echo "docs-comment: $PASS passed, $FAIL failed"
105
+ [ "$FAIL" -eq 0 ]