severally-mcp 2.3.1 → 2.4.2

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 (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
package/src/policy.mjs CHANGED
@@ -7,7 +7,10 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { parseJsonc } from './jsonc.mjs';
9
9
  import { findCommand } from './platform.mjs';
10
- import { buildConsultants, CLI_IDS, normalizeName } from './consultants.mjs';
10
+ import { buildConsultants, isObject, normalizeName } from './consultants.mjs';
11
+
12
+ /** @typedef {import('./consultants.mjs').Consultant} Consultant */
13
+ /** @typedef {import('./consultants.mjs').CliId} CliId */
11
14
 
12
15
  // One file, read once at start, so an operator configures a machine in a
13
16
  // single place instead of editing each client's MCP registration. Precedence
@@ -17,12 +20,14 @@ import { buildConsultants, CLI_IDS, normalizeName } from './consultants.mjs';
17
20
  const CONFIG_HOME = process.env.SEVERALLY_HOME || path.join(os.homedir(), '.severally');
18
21
  // .jsonc first, for operators whose editor treats comments in a .json file as
19
22
  // an error; both are read the same way, comments and all.
20
- const CONFIG_PATH = process.env.SEVERALLY_CONFIG
21
- || [path.join(CONFIG_HOME, 'config.jsonc'), path.join(CONFIG_HOME, 'config.json')]
22
- .find((p) => fs.existsSync(p))
23
- || path.join(CONFIG_HOME, 'config.json');
23
+ const CONFIG_PATH =
24
+ process.env.SEVERALLY_CONFIG ||
25
+ [path.join(CONFIG_HOME, 'config.jsonc'), path.join(CONFIG_HOME, 'config.json')].find((p) => fs.existsSync(p)) ||
26
+ path.join(CONFIG_HOME, 'config.json');
24
27
 
28
+ /** @type {string | null} */
25
29
  let configError = null;
30
+ /** @type {unknown} the parsed config file, before validation */
26
31
  const CONFIG = (() => {
27
32
  try {
28
33
  return parseJsonc(fs.readFileSync(CONFIG_PATH, 'utf8'));
@@ -30,7 +35,8 @@ const CONFIG = (() => {
30
35
  // A missing file is the normal case. A malformed one is not: silently
31
36
  // ignoring it would run the machine on defaults the operator thinks they
32
37
  // overrode, so it is surfaced on stderr when the server starts.
33
- if (err.code !== 'ENOENT') configError = `${CONFIG_PATH}: ${err.message}`;
38
+ const code = err instanceof Error && 'code' in err ? err.code : undefined;
39
+ if (code !== 'ENOENT') configError = `${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`;
34
40
  return {};
35
41
  }
36
42
  })();
@@ -50,7 +56,10 @@ if (!configError && ![undefined, '', 'on', 'off'].includes(process.env.SEVERALLY
50
56
  const FROM = configError ? buildConsultants({}) : BUILT;
51
57
  const LOCAL = configError ? [] : BUILT.problems.filter((p) => !BUILT.global.includes(p));
52
58
 
53
- /** What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started. */
59
+ /**
60
+ * What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started.
61
+ * @returns {string | null}
62
+ */
54
63
  export function configProblem() {
55
64
  return configError;
56
65
  }
@@ -65,31 +74,66 @@ export function brokenConsultantNames() {
65
74
  return configError ? [] : Object.keys(BUILT.broken);
66
75
  }
67
76
 
68
- /** Why the consultant of this name is out of use because of a config mistake, or null. */
77
+ /**
78
+ * Why the consultant of this name is out of use because of a config mistake, or null.
79
+ * @param {string} name
80
+ */
69
81
  export function brokenConsultant(name) {
70
82
  const p = !configError && Object.hasOwn(BUILT.broken, name) ? BUILT.broken[name] : null;
71
83
  return p ? `the config file has a mistake in it: ${p.join('; ')}` : null;
72
84
  }
73
85
 
74
- const usable = configError ? {} : CONFIG;
86
+ /**
87
+ * One CLI's section of the config file. `args`, `env` and `provider` are read
88
+ * only after buildConsultants has checked them (a section with a mistake is
89
+ * replaced by a disabled one in cfgCli), so they have the types given here.
90
+ * @typedef {object} CliSection
91
+ * @property {unknown} [enabled]
92
+ * @property {unknown} [note]
93
+ * @property {unknown} [bin]
94
+ * @property {string[]} [args]
95
+ * @property {Record<string, string>} [env]
96
+ * @property {Record<string, Record<string, unknown>>} [provider]
97
+ */
98
+
99
+ /** @type {Record<string, unknown>} */
100
+ const usable = configError || !isObject(CONFIG) ? {} : CONFIG;
75
101
  // A CLI whose own section has a mistake is off, whatever else the section says.
102
+ /**
103
+ * @param {string} id
104
+ * @returns {CliSection}
105
+ */
76
106
  const cfgCli = (id) => {
77
- if (Object.hasOwn(BUILT.brokenClis, id) && !configError) {
78
- return { enabled: false, note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}` };
107
+ const broken = Object.hasOwn(BUILT.brokenClis, id) ? BUILT.brokenClis[id] : undefined;
108
+ if (broken && !configError) {
109
+ return {
110
+ enabled: false,
111
+ note: `turned off: the config file has a mistake in clis.${id}: ${broken.join('; ')}`,
112
+ };
79
113
  }
80
- return usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {};
114
+ const def = isObject(usable.clis) ? usable.clis[id] : undefined;
115
+ // A section reaches here only when buildConsultants found no mistake in it.
116
+ return isObject(def) ? /** @type {CliSection} */ (def) : {};
81
117
  };
82
- const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
118
+ const cfgServer = isObject(usable.server) ? usable.server : {};
83
119
 
84
120
  /**
85
121
  * Is this command runnable here? Absolute/relative paths are checked as given.
86
122
  * Exported so the config generator can report what a machine has without
87
123
  * going through POLICY, which already reflects any config file it found.
124
+ * @param {string} command
88
125
  */
89
126
  export function isInstalled(command) {
90
127
  return findCommand(command) !== null;
91
128
  }
92
129
 
130
+ /**
131
+ * @param {string} name
132
+ * @param {number | null} dflt
133
+ * @param {number} min
134
+ * @param {number} max
135
+ * @returns {number | null}
136
+ */
93
137
  const num = (name, dflt, min, max) => {
94
138
  const raw = process.env[name];
95
139
  if (raw === undefined || raw === '') return dflt;
@@ -102,6 +146,14 @@ const num = (name, dflt, min, max) => {
102
146
  // the same range whichever one supplied it. Codex and OpenCode do not pass the
103
147
  // user's shell environment to an MCP server, so for them the file is the only
104
148
  // place an operator can reach without editing the client's registration.
149
+ /**
150
+ * @param {string} name
151
+ * @param {unknown} fromCfg
152
+ * @param {number} dflt
153
+ * @param {number} min
154
+ * @param {number} max
155
+ * @returns {number}
156
+ */
105
157
  const limit = (name, fromCfg, dflt, min, max) => {
106
158
  const fromEnv = num(name, null, min, max);
107
159
  if (fromEnv !== null) return fromEnv;
@@ -109,6 +161,11 @@ const limit = (name, fromCfg, dflt, min, max) => {
109
161
  return dflt;
110
162
  };
111
163
 
164
+ /**
165
+ * @param {string} name
166
+ * @param {string} dflt
167
+ * @returns {string}
168
+ */
112
169
  const str = (name, dflt) => {
113
170
  const raw = process.env[name];
114
171
  return raw === undefined || raw === '' ? dflt : raw;
@@ -117,23 +174,43 @@ const str = (name, dflt) => {
117
174
  // The CLI a lead runs in, by any everyday name. Only `caller` uses this:
118
175
  // which consultant a name reaches is consultants.mjs's resolveConsultant.
119
176
  // Object.create(null) so 'constructor' is an unknown name, not a function.
120
- export const CALLER_ALIASES = Object.freeze(Object.assign(Object.create(null), {
121
- codex: 'codex', gpt: 'codex', chatgpt: 'codex', openai: 'codex',
122
- 'claude-code': 'claude-code', claude: 'claude-code', anthropic: 'claude-code',
123
- antigravity: 'antigravity', agy: 'antigravity', gemini: 'antigravity', google: 'antigravity',
124
- opencode: 'opencode', glm: 'opencode',
125
- }));
177
+ /** @type {Readonly<Record<string, CliId>>} */
178
+ export const CALLER_ALIASES = Object.freeze(
179
+ Object.assign(Object.create(null), {
180
+ codex: 'codex',
181
+ gpt: 'codex',
182
+ chatgpt: 'codex',
183
+ openai: 'codex',
184
+ 'claude-code': 'claude-code',
185
+ claude: 'claude-code',
186
+ anthropic: 'claude-code',
187
+ antigravity: 'antigravity',
188
+ agy: 'antigravity',
189
+ gemini: 'antigravity',
190
+ google: 'antigravity',
191
+ opencode: 'opencode',
192
+ glm: 'opencode',
193
+ }),
194
+ );
126
195
 
127
196
  export const CALLER_INPUTS = Object.freeze(Object.keys(CALLER_ALIASES));
128
197
 
129
- /** The CLI id for any accepted spelling of a caller, or null. */
198
+ /**
199
+ * The CLI id for any accepted spelling of a caller, or null.
200
+ * @param {unknown} raw
201
+ * @returns {CliId | null}
202
+ */
130
203
  export function resolveCaller(raw) {
131
204
  const key = normalizeName(raw);
132
- return typeof key === 'string' && Object.hasOwn(CALLER_ALIASES, key) ? CALLER_ALIASES[key] : null;
205
+ return typeof key === 'string' ? (CALLER_ALIASES[key] ?? null) : null;
133
206
  }
134
207
 
135
208
  // Which CLI is hosting this server. Used only to annotate a consultation that
136
209
  // goes to the caller's own vendor; it never affects permissions or limits.
210
+ /**
211
+ * @param {NodeJS.ProcessEnv} [env]
212
+ * @returns {CliId | null}
213
+ */
137
214
  export function detectCaller(env = process.env) {
138
215
  if (env.CLAUDECODE === '1' || env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
139
216
  if (env.CODEX_HOME || env.CODEX_SANDBOX || env.CODEX_SANDBOX_NETWORK_DISABLED) return 'codex';
@@ -153,12 +230,15 @@ export const VERDICTS = ['unverified', 'confirmed', 'not_applicable', 'unverifia
153
230
  // The consultant's own one-word bottom line. Also what the lead may predict
154
231
  // before asking -- the same vocabulary, so a prediction and an answer can be
155
232
  // read side by side without anyone translating between two scales.
156
- export const STANCES = ['proceed', 'do_not_proceed', 'alternative', 'undetermined'];
233
+ export const STANCES = /** @type {const} */ (['proceed', 'do_not_proceed', 'alternative', 'undetermined']);
157
234
 
158
235
  /**
159
236
  * A leading ~ is a shell courtesy, not a path: nothing expands it here, so
160
237
  * "~/.local/bin/claudex" would simply not exist and the consultant would drop
161
238
  * out of the available list without saying why. Expand it where a path is read.
239
+ * @template T
240
+ * @param {T} value
241
+ * @returns {T | string}
162
242
  */
163
243
  function expandHome(value) {
164
244
  if (typeof value !== 'string') return value;
@@ -167,6 +247,12 @@ function expandHome(value) {
167
247
  }
168
248
 
169
249
  // env > config file > built-in default, for a CLI's bin.
250
+ /**
251
+ * @param {string} id
252
+ * @param {string} envName
253
+ * @param {string} dflt
254
+ * @returns {string}
255
+ */
170
256
  const binFor = (id, envName, dflt) => {
171
257
  const fromEnv = process.env[envName];
172
258
  if (fromEnv !== undefined && fromEnv !== '') return expandHome(fromEnv);
@@ -177,11 +263,16 @@ const binFor = (id, envName, dflt) => {
177
263
  // Why a consultant is off, in the operator's words: "quota exhausted until
178
264
  // 15:00" is more use to a lead than a bare refusal, and it is the reason a CLI
179
265
  // that IS installed gets excluded.
266
+ /** @param {string} id */
180
267
  const disabledNote = (id) => {
181
268
  const n = cfgCli(id).note;
182
269
  return typeof n === 'string' && n.trim() !== '' ? n.trim() : null;
183
270
  };
184
271
 
272
+ /**
273
+ * @param {string} id
274
+ * @param {string} cli
275
+ */
185
276
  const isEnabled = (id, cli) => {
186
277
  const flag = cfgCli(id).enabled;
187
278
  if (typeof flag === 'boolean') return flag;
@@ -196,7 +287,14 @@ const OPENCODE_BIN = binFor('opencode', 'SEVERALLY_OPENCODE_BIN', 'opencode');
196
287
  // Operator-only extras for the child process (config: args, env). Config-only,
197
288
  // never from a request, and validated at launch (jobs.mjs) where the isolation
198
289
  // flags and variables they must not undo are known.
199
- const extras = (id) => ({ extraArgs: cfgCli(id).args ?? [], extraEnv: cfgCli(id).env ?? {} });
290
+ /**
291
+ * @param {string} id
292
+ * @returns {{ extraArgs: string[], extraEnv: Record<string, string> }}
293
+ */
294
+ const extras = (id) => ({
295
+ extraArgs: cfgCli(id).args ?? [],
296
+ extraEnv: cfgCli(id).env ?? {},
297
+ });
200
298
 
201
299
  export const POLICY = Object.freeze({
202
300
  home: str('SEVERALLY_HOME', path.join(os.homedir(), '.severally')),
@@ -205,18 +303,34 @@ export const POLICY = Object.freeze({
205
303
  // a consultant's business.
206
304
  clis: Object.freeze({
207
305
  codex: Object.freeze({
208
- cli: CODEX_BIN, available: isEnabled('codex', CODEX_BIN), note: disabledNote('codex'), label: 'Codex CLI', ...extras('codex'),
306
+ cli: CODEX_BIN,
307
+ available: isEnabled('codex', CODEX_BIN),
308
+ note: disabledNote('codex'),
309
+ label: 'Codex CLI',
310
+ ...extras('codex'),
209
311
  }),
210
312
  'claude-code': Object.freeze({
211
- cli: CLAUDE_BIN, available: isEnabled('claude-code', CLAUDE_BIN), note: disabledNote('claude-code'), label: 'Claude Code CLI', ...extras('claude-code'),
313
+ cli: CLAUDE_BIN,
314
+ available: isEnabled('claude-code', CLAUDE_BIN),
315
+ note: disabledNote('claude-code'),
316
+ label: 'Claude Code CLI',
317
+ ...extras('claude-code'),
212
318
  }),
213
319
  // Where the real Antigravity credentials live is credentialsHome() below,
214
320
  // not a field here: the sandbox reads it per job (see timeoutMs()).
215
321
  antigravity: Object.freeze({
216
- cli: AGY_BIN, available: isEnabled('antigravity', AGY_BIN), note: disabledNote('antigravity'), label: 'Antigravity CLI', ...extras('antigravity'),
322
+ cli: AGY_BIN,
323
+ available: isEnabled('antigravity', AGY_BIN),
324
+ note: disabledNote('antigravity'),
325
+ label: 'Antigravity CLI',
326
+ ...extras('antigravity'),
217
327
  }),
218
328
  opencode: Object.freeze({
219
- cli: OPENCODE_BIN, available: isEnabled('opencode', OPENCODE_BIN), note: disabledNote('opencode'), label: 'OpenCode CLI', ...extras('opencode'),
329
+ cli: OPENCODE_BIN,
330
+ available: isEnabled('opencode', OPENCODE_BIN),
331
+ note: disabledNote('opencode'),
332
+ label: 'OpenCode CLI',
333
+ ...extras('opencode'),
220
334
  // opencode.json `provider` definitions copied into the sandbox config:
221
335
  // the only way to reach a provider the sandbox's empty HOME would not
222
336
  // know, such as a local OpenAI-compatible server.
@@ -286,6 +400,8 @@ export const POLICY = Object.freeze({
286
400
  }),
287
401
  });
288
402
 
403
+ /** @typedef {typeof POLICY} Policy */
404
+
289
405
  /**
290
406
  * Wall-clock budget for one consultation of this consultant. Read at job
291
407
  * start (not frozen into the module) so an operator restart is not needed to
@@ -293,6 +409,7 @@ export const POLICY = Object.freeze({
293
409
  * same time: a long Gemini answer should not force the same patience on every
294
410
  * other consultant. The two hour ceiling stays -- past that the answer is a
295
411
  * different request, not a slower one.
412
+ * @param {Consultant | null} [consultant]
296
413
  */
297
414
  export function timeoutMs(consultant) {
298
415
  return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 1_800_000, 1_000, 7_200_000);
@@ -338,22 +455,61 @@ export function opencodeCredentialsHome() {
338
455
  // and never copied by expose_paths; Codex's read-only sandbox cannot express
339
456
  // path rules, which the boundary record says.
340
457
  export const PROTECTED_HOME_PATHS = Object.freeze([
341
- '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.docker', '.config/gcloud', '.config/gh', '.config/hub',
342
- '.netrc', '.npmrc', '.pypirc', '.git-credentials', '.cargo/credentials', '.cargo/credentials.toml',
343
- '.terraform.d', '.vault-token', 'Library/Keychains',
344
- '.codex', '.claude', '.claude.json', '.gemini', '.config/opencode', '.local/share/opencode',
458
+ '.ssh',
459
+ '.gnupg',
460
+ '.aws',
461
+ '.azure',
462
+ '.kube',
463
+ '.docker',
464
+ '.config/gcloud',
465
+ '.config/gh',
466
+ '.config/hub',
467
+ '.netrc',
468
+ '.npmrc',
469
+ '.pypirc',
470
+ '.git-credentials',
471
+ '.cargo/credentials',
472
+ '.cargo/credentials.toml',
473
+ '.terraform.d',
474
+ '.vault-token',
475
+ 'Library/Keychains',
476
+ '.codex',
477
+ '.claude',
478
+ '.claude.json',
479
+ '.gemini',
480
+ '.config/opencode',
481
+ '.local/share/opencode',
345
482
  ]);
346
483
  // Under POLICY.home, wherever the operator moved it.
347
- const PROTECTED_SEVERALLY_PATHS = Object.freeze(['history', 'backups', 'config.json', 'config.jsonc', 'homes',
484
+ const PROTECTED_SEVERALLY_PATHS = Object.freeze([
485
+ 'history',
486
+ 'backups',
487
+ 'config.json',
488
+ 'config.jsonc',
489
+ 'homes',
348
490
  // Where homes lived before 1.7: one left by an interrupted run can still hold an auth.json copy.
349
- 'jobs/*/home']);
491
+ 'jobs/*/home',
492
+ ]);
350
493
  // File names protected wherever they are.
351
494
  export const PROTECTED_FILE_NAMES = Object.freeze([
352
- '.env', '.env.*', '.npmrc', '.netrc', '.pypirc', '.git-credentials', 'id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa',
495
+ '.env',
496
+ '.env.*',
497
+ '.npmrc',
498
+ '.netrc',
499
+ '.pypirc',
500
+ '.git-credentials',
501
+ 'id_rsa',
502
+ 'id_ed25519',
503
+ 'id_ecdsa',
504
+ 'id_dsa',
353
505
  ]);
354
506
 
355
- /** Absolute protected roots, with forward slashes; `*` stands for one path segment. */
507
+ /**
508
+ * Absolute protected roots, with forward slashes; `*` stands for one path segment.
509
+ * @returns {string[]}
510
+ */
356
511
  export function protectedRoots() {
512
+ /** @param {string} p */
357
513
  const fwd = (p) => p.replaceAll('\\', '/');
358
514
  return [
359
515
  ...PROTECTED_HOME_PATHS.map((rel) => fwd(path.join(os.homedir(), rel))),
@@ -361,26 +517,38 @@ export function protectedRoots() {
361
517
  ];
362
518
  }
363
519
 
520
+ /** @param {string} s */
364
521
  const escapeRe = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
365
522
  // Case-insensitive where the default file system is: Windows and macOS.
366
523
  export const PATHS_CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
367
- /** Regex sources: one per protected root, matching it and anything under it. */
524
+ /**
525
+ * Regex sources: one per protected root, matching it and anything under it.
526
+ * @param {string[]} [extraRoots]
527
+ */
368
528
  export function protectedRootPatterns(extraRoots = []) {
369
- return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))]
370
- .map((r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`);
529
+ return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))].map(
530
+ (r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`,
531
+ );
371
532
  }
372
533
  /** Regex sources for PROTECTED_FILE_NAMES, matched against a base name. */
373
534
  export function protectedNamePatterns() {
374
535
  return PROTECTED_FILE_NAMES.map((n) => `^${escapeRe(n).replaceAll('*', '.*')}$`);
375
536
  }
376
537
 
377
- /** Whether an absolute path, or the file it links to, is protected. */
538
+ /**
539
+ * Whether an absolute path, or the file it links to, is protected.
540
+ * @param {string} abs
541
+ */
378
542
  export function isProtectedPath(abs) {
379
543
  const flags = PATHS_CASE_INSENSITIVE ? 'i' : '';
380
544
  const roots = protectedRootPatterns().map((p) => new RegExp(p, flags));
381
545
  const names = protectedNamePatterns().map((p) => new RegExp(p, flags));
382
546
  let real = abs;
383
- try { real = fs.realpathSync.native(abs); } catch { /* missing: check the name as given */ }
547
+ try {
548
+ real = fs.realpathSync.native(abs);
549
+ } catch {
550
+ /* missing: check the name as given */
551
+ }
384
552
  return [abs, real].some((p) => {
385
553
  const fwd = p.replaceAll('\\', '/');
386
554
  return roots.some((re) => re.test(fwd)) || names.some((re) => re.test(path.basename(fwd)));
@@ -389,12 +557,31 @@ export function isProtectedPath(abs) {
389
557
 
390
558
  export const artifactKinds = ['code', 'log', 'doc', 'data', 'diff', 'spec', 'test-output', 'config'];
391
559
 
392
- /** The consultant with this exact name, or undefined. */
560
+ /**
561
+ * The consultant with this exact name, or undefined.
562
+ * @param {string} name
563
+ */
393
564
  export function consultantByName(name) {
394
565
  return POLICY.consultants.find((c) => c.name === name);
395
566
  }
396
567
 
397
- /** Can this consultant run here: turned on itself, and its CLI usable. */
568
+ /**
569
+ * The consultant with this exact name, for a caller that holds a name it got
570
+ * from a consultant already (a started job's target). Throws when there is
571
+ * none; use consultantByName when the name may be unknown.
572
+ * @param {string} name
573
+ * @returns {Consultant}
574
+ */
575
+ export function consultantNamed(name) {
576
+ const c = consultantByName(name);
577
+ if (!c) throw new Error(`no consultant named "${name}"`);
578
+ return c;
579
+ }
580
+
581
+ /**
582
+ * Can this consultant run here: turned on itself, and its CLI usable.
583
+ * @param {Consultant} c
584
+ */
398
585
  export function consultantAvailable(c) {
399
586
  return c.enabled && POLICY.clis[c.cli].available;
400
587
  }
@@ -402,6 +589,8 @@ export function consultantAvailable(c) {
402
589
  /**
403
590
  * Why this consultant cannot be reached, or null when it can. The operator's
404
591
  * own note wins: "installed but rate-limited" is a case only they can state.
592
+ * @param {Consultant | null | undefined} c
593
+ * @returns {string | null}
405
594
  */
406
595
  export function unavailableReason(c) {
407
596
  if (!c || consultantAvailable(c)) return null;
@@ -419,6 +608,7 @@ export function availableConsultants() {
419
608
 
420
609
  export function limitsSummary() {
421
610
  const list = availableConsultants();
611
+ /** @param {(c: Consultant) => unknown} f */
422
612
  const by = (f) => Object.fromEntries(list.map((c) => [c.name, f(c)]));
423
613
  return {
424
614
  available_targets: list.map((c) => c.name),
@@ -434,16 +624,19 @@ export function limitsSummary() {
434
624
  max_concurrent_jobs: POLICY.maxConcurrent,
435
625
  max_wait_ms: POLICY.maxWaitMs,
436
626
  input_char_budget: POLICY.input.totalCharsMax,
437
- // Stated so it is true of all three consultants. Reading is allowed for
438
- // every one of them -- what keeps a consultation brief-only is that the
439
- // child starts in an empty working directory and is never told where the
440
- // repository is, not that it cannot read. Codex is additionally sandboxed
441
- // read-only rather than execution-free, so do not advertise "no execution".
627
+ // Stated so it is true of all four CLIs. Reading is allowed for every one
628
+ // of them -- what keeps a consultation brief-only is that the child starts
629
+ // in an empty working directory and is never told where the repository
630
+ // is, not that it cannot read. Only Claude Code drops its whole-disk read
631
+ // when files are exposed (claude-code.mjs). Codex is additionally
632
+ // sandboxed read-only rather than execution-free, so do not advertise
633
+ // "no execution".
442
634
  consultant_permissions:
443
- 'web search/browse and reading local files allowed, but the consultant starts in an empty working ' +
444
- 'directory and is not told where your repository is -- unless context.expose_paths names files or ' +
445
- 'directories, which are copied read-only into that working directory and are then the only part of ' +
446
- 'your repository it has; file edits, network access, MCP tools and further consultations denied; the ' +
447
- 'Codex consultant additionally has a read-only shell',
635
+ 'web search/browse and reading local files allowed; the consultant starts in an empty working ' +
636
+ 'directory and is not told where your repository is. context.expose_paths copies the files or ' +
637
+ 'directories it names read-only into that working directory, but that is not a read boundary: apart ' +
638
+ 'from the protected credential paths, Codex, Antigravity and OpenCode can read any file your account ' +
639
+ 'can, and so can Claude Code when nothing is exposed; file edits, network access, MCP tools and ' +
640
+ 'further consultations denied; the Codex consultant additionally has a read-only shell',
448
641
  };
449
642
  }
package/src/redact.mjs CHANGED
@@ -1,65 +1,107 @@
1
1
  // Credential scrubbing. Applied on the way out (brief sent to the consultant),
2
2
  // on the way in (consultant output), and to everything persisted to disk.
3
3
 
4
- const CODE_WORDS = new Set(['true', 'false', 'null', 'none', 'undefined', 'string', 'str', 'number', 'boolean', 'bool', 'int', 'bytes']);
4
+ const CODE_WORDS = new Set([
5
+ 'true',
6
+ 'false',
7
+ 'null',
8
+ 'none',
9
+ 'undefined',
10
+ 'string',
11
+ 'str',
12
+ 'number',
13
+ 'boolean',
14
+ 'bool',
15
+ 'int',
16
+ 'bytes',
17
+ ]);
5
18
 
6
19
  // A password-type value that is code, not a secret: a flag or a type, a
7
20
  // variable reference (${X}, $X, process.env.X, os.environ[...]), and -- unless
8
21
  // it is a long value under a full PASSWORD/PASSWD name, which stays masked
9
22
  // whatever it looks like -- a dotted name, a call or a borrow (&str).
23
+ /**
24
+ * @param {string} name
25
+ * @param {string} value
26
+ */
10
27
  function looksLikeCode(name, value) {
11
- if (CODE_WORDS.has(value.toLowerCase()) || value.startsWith('${') || /^\$[A-Z_][A-Z0-9_]*$/.test(value)
12
- || /^(?:process\.env\.|os\.environ\b)/.test(value)) return true;
13
- if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1))) return false;
28
+ if (
29
+ CODE_WORDS.has(value.toLowerCase()) ||
30
+ value.startsWith('${') ||
31
+ /^\$[A-Z_][A-Z0-9_]*$/.test(value) ||
32
+ /^(?:process\.env\.|os\.environ\b)/.test(value)
33
+ )
34
+ return true;
35
+ if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1) ?? '')) return false;
14
36
  return /^[A-Za-z_][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) || /^[A-Za-z_][\w$.]*\(/.test(value) || value.startsWith('&');
15
37
  }
16
38
 
39
+ // A replacement is a string or a function of the match.
40
+ /** @type {Array<[RegExp, string | ((...groups: string[]) => string)]>} */
17
41
  const PATTERNS = [
18
42
  // Provider API keys
19
- [/\bsk-ant-[A-Za-z0-9_\-]{12,}/g, '[REDACTED:anthropic-key]'],
20
- [/\bsk-proj-[A-Za-z0-9_\-]{12,}/g, '[REDACTED:openai-key]'],
21
- [/\bsk-[A-Za-z0-9_\-]{20,}/g, '[REDACTED:api-key]'],
43
+ [/\bsk-ant-[A-Za-z0-9_-]{12,}/g, '[REDACTED:anthropic-key]'],
44
+ [/\bsk-proj-[A-Za-z0-9_-]{12,}/g, '[REDACTED:openai-key]'],
45
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, '[REDACTED:api-key]'],
22
46
  [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[REDACTED:github-token]'],
23
47
  [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[REDACTED:github-token]'],
24
48
  [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack-token]'],
25
49
  [/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/g, '[REDACTED:slack-webhook]'],
26
50
  [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
27
51
  [/\bASIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
28
- [/\bAIza[0-9A-Za-z_\-]{30,}/g, '[REDACTED:google-api-key]'],
52
+ [/\bAIza[0-9A-Za-z_-]{30,}/g, '[REDACTED:google-api-key]'],
29
53
  // Stripe secret and restricted keys (pk_ is public, left alone), npm, GitLab, Hugging Face
30
54
  [/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, '[REDACTED:stripe-key]'],
31
55
  [/\bnpm_[A-Za-z0-9]{30,}/g, '[REDACTED:npm-token]'],
32
- [/\bglpat-[A-Za-z0-9_\-]{20,}/g, '[REDACTED:gitlab-token]'],
56
+ [/\bglpat-[A-Za-z0-9_-]{20,}/g, '[REDACTED:gitlab-token]'],
33
57
  [/\bhf_[A-Za-z0-9]{30,}/g, '[REDACTED:huggingface-token]'],
34
58
  // JWT / OAuth access tokens (3 base64url segments)
35
- [/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}/g, '[REDACTED:jwt]'],
36
- [/\bBearer\s+[A-Za-z0-9._\-]{20,}/gi, 'Bearer [REDACTED:token]'],
59
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED:jwt]'],
60
+ [/\bBearer\s+[A-Za-z0-9._-]{20,}/gi, 'Bearer [REDACTED:token]'],
37
61
  // PEM private keys
38
62
  [/-----BEGIN[^-]{0,40}PRIVATE KEY-----[\s\S]*?-----END[^-]{0,40}PRIVATE KEY-----/g, '[REDACTED:private-key]'],
39
63
  // The password in a URL's userinfo, also with an empty user (redis://:pass@host)
40
64
  [/(:\/\/[^\s:/@]*:)[^\s@/]+(@)/g, '$1[REDACTED]$2'],
41
65
  // KEY=value / "token": "value" style assignments
42
- [/\b((?:[A-Za-z0-9_]*_)?(?:API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN|ID_TOKEN|SECRET_KEY|CLIENT_SECRET|AUTH_TOKEN|CREDENTIALS|ACCOUNTKEY|SHAREDACCESSKEY|SECRET|TOKEN))(["']?\s*[:=]\s*["']?)([^\s"',;)]{8,})/gi,
43
- (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
66
+ [
67
+ /\b((?:[A-Za-z0-9_]*_)?(?:API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN|ID_TOKEN|SECRET_KEY|CLIENT_SECRET|AUTH_TOKEN|CREDENTIALS|ACCOUNTKEY|SHAREDACCESSKEY|SECRET|TOKEN))(["']?\s*[:=]\s*["']?)([^\s"',;)]{8,})/gi,
68
+ (_m, k, sep, _v) => `${k}${sep}[REDACTED]`,
69
+ ],
44
70
  // Password-type names, with values from 4 characters; PASS and PWD too (PWD
45
71
  // only after `_` or a letter: bare PWD is the working directory). What reads
46
72
  // as code rather than a password is left alone (looksLikeCode).
47
- [/\b((?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASS|(?<=[A-Za-z_])PWD))(["']?\s*[:=]\s*["']?)([^\s"',;)]{4,})/gi,
48
- (m, k, sep, v) => (looksLikeCode(k, v) ? m : `${k}${sep}[REDACTED]`)],
73
+ [
74
+ /\b((?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASS|(?<=[A-Za-z_])PWD))(["']?\s*[:=]\s*["']?)([^\s"',;)]{4,})/gi,
75
+ (m, k, sep, v) => (looksLikeCode(k, v) ? m : `${k}${sep}[REDACTED]`),
76
+ ],
49
77
  // Any environment variable named *_KEY. Upper case only: in code, primary_key,
50
78
  // sort_key and idempotency_key name things, not secrets.
51
79
  [/\b([A-Z0-9_]+_KEY)(\s*=\s*["']?)([^\s"',;)]{8,})/g, (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
52
80
  ];
53
81
 
82
+ /**
83
+ * Any JSON-like value in, the same shape out with its strings scrubbed.
84
+ * @template T
85
+ * @overload
86
+ * @param {T} value
87
+ * @returns {T}
88
+ */
89
+ /**
90
+ * @param {unknown} value
91
+ * @returns {unknown}
92
+ */
54
93
  export function redact(value) {
55
94
  if (value === null || value === undefined) return value;
56
95
  if (typeof value === 'string') {
96
+ /** @type {string} */
57
97
  let out = value;
58
- for (const [re, rep] of PATTERNS) out = out.replace(re, rep);
98
+ // The same call either way: replace() has one overload for a string and one for a function.
99
+ for (const [re, rep] of PATTERNS) out = typeof rep === 'string' ? out.replace(re, rep) : out.replace(re, rep);
59
100
  return out;
60
101
  }
61
102
  if (Array.isArray(value)) return value.map(redact);
62
103
  if (typeof value === 'object') {
104
+ /** @type {Record<string, unknown>} */
63
105
  const out = {};
64
106
  for (const [k, v] of Object.entries(value)) out[k] = redact(v);
65
107
  return out;
@@ -67,6 +109,7 @@ export function redact(value) {
67
109
  return value;
68
110
  }
69
111
 
112
+ /** @param {unknown} text */
70
113
  export function containsSecret(text) {
71
114
  if (typeof text !== 'string') return false;
72
115
  return redact(text) !== text;