severally-mcp 2.1.0 → 2.4.0

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 (52) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +146 -69
  4. package/README.md +150 -73
  5. package/bin/severally-mcp.mjs +17 -2
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +59 -24
  10. package/docs/install.md +60 -24
  11. package/package.json +16 -4
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29220 -27764
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/build.mjs +31 -26
  26. package/scripts/doctor.mjs +52 -17
  27. package/scripts/init-config.mjs +8 -6
  28. package/scripts/install.mjs +342 -84
  29. package/scripts/live-check.mjs +52 -19
  30. package/scripts/live-mcp-check.mjs +42 -12
  31. package/scripts/test.mjs +10 -4
  32. package/src/adapters/antigravity-sandbox.mjs +39 -23
  33. package/src/adapters/antigravity.mjs +34 -16
  34. package/src/adapters/claude-code.mjs +73 -21
  35. package/src/adapters/codex.mjs +69 -27
  36. package/src/adapters/opencode-sandbox.mjs +40 -21
  37. package/src/adapters/opencode.mjs +31 -13
  38. package/src/brief.mjs +65 -41
  39. package/src/consultants.mjs +99 -30
  40. package/src/export.mjs +49 -12
  41. package/src/expose-paths.mjs +162 -44
  42. package/src/failures.mjs +23 -13
  43. package/src/jobs.mjs +420 -140
  44. package/src/parse-result.mjs +85 -30
  45. package/src/platform.mjs +5 -7
  46. package/src/policy.mjs +139 -32
  47. package/src/redact.mjs +38 -14
  48. package/src/run.mjs +104 -22
  49. package/src/schema.mjs +76 -22
  50. package/src/server.mjs +176 -166
  51. package/src/status.mjs +132 -0
  52. package/src/store.mjs +164 -19
@@ -60,14 +60,26 @@ export function extractJson(text) {
60
60
  let esc = false;
61
61
  for (let i = start; i < trimmed.length; i++) {
62
62
  const ch = trimmed[i];
63
- if (esc) { esc = false; continue; }
64
- if (ch === '\\') { esc = true; continue; }
65
- if (ch === '"') { inStr = !inStr; continue; }
63
+ if (esc) {
64
+ esc = false;
65
+ continue;
66
+ }
67
+ if (ch === '\\') {
68
+ esc = true;
69
+ continue;
70
+ }
71
+ if (ch === '"') {
72
+ inStr = !inStr;
73
+ continue;
74
+ }
66
75
  if (inStr) continue;
67
76
  if (ch === '{') depth++;
68
77
  else if (ch === '}') {
69
78
  depth--;
70
- if (depth === 0) { candidates.push(trimmed.slice(start, i + 1)); break; }
79
+ if (depth === 0) {
80
+ candidates.push(trimmed.slice(start, i + 1));
81
+ break;
82
+ }
71
83
  }
72
84
  }
73
85
  }
@@ -75,7 +87,9 @@ export function extractJson(text) {
75
87
  try {
76
88
  const parsed = JSON.parse(c);
77
89
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
78
- } catch { /* try the next candidate */ }
90
+ } catch {
91
+ /* try the next candidate */
92
+ }
79
93
  }
80
94
  return null;
81
95
  }
@@ -101,7 +115,7 @@ function mapList(raw, fields, { requiredField, idPrefix = null }) {
101
115
 
102
116
  /**
103
117
  * @param {object|string} raw parsed JSON object, or raw text to mine one out of
104
- * @returns {{result: object, quality: {structured_output: boolean, missing_sections: string[]}}}
118
+ * @returns {{result: object, quality: {missing_sections: string[], findings_without_grounds: number, unsourced: boolean, list_truncated?: Record<string, number>}}}
105
119
  */
106
120
  export function normalizeResult(raw) {
107
121
  const objIn = typeof raw === 'string' ? extractJson(raw) : raw;
@@ -120,37 +134,78 @@ export function normalizeResult(raw) {
120
134
  confidence: level(src.confidence),
121
135
  evidence_basis: basis(src.evidence_basis),
122
136
  stance: stance(src.stance),
123
- findings: mapList(src.findings, {
124
- point: 'text', grounds: 'text', impact: 'text', severity: 'level', confidence: 'level',
125
- }, { requiredField: 'point', idPrefix: 'f' }),
126
- alternatives: mapList(src.alternatives, {
127
- option: 'text', tradeoffs: 'text', when_preferred: 'text',
128
- }, { requiredField: 'option' }),
129
- unknowns: mapList(src.unknowns, {
130
- item: 'text', why_it_matters: 'text', how_to_obtain: 'text',
131
- }, { requiredField: 'item', idPrefix: 'u' }),
132
- decision_changers: mapList(src.decision_changers, {
133
- condition: 'text', changes_to: 'text',
134
- }, { requiredField: 'condition' }),
135
- next_checks: mapList(src.next_checks, {
136
- check: 'text', method: 'text', expected_signal: 'text',
137
- }, { requiredField: 'check', idPrefix: 'c' }),
138
- remaining_disagreements: mapList(src.remaining_disagreements, {
139
- topic: 'text', your_position: 'text', why_unresolved: 'text',
140
- }, { requiredField: 'topic' }),
137
+ findings: mapList(
138
+ src.findings,
139
+ {
140
+ point: 'text',
141
+ grounds: 'text',
142
+ impact: 'text',
143
+ severity: 'level',
144
+ confidence: 'level',
145
+ },
146
+ { requiredField: 'point', idPrefix: 'f' },
147
+ ),
148
+ alternatives: mapList(
149
+ src.alternatives,
150
+ {
151
+ option: 'text',
152
+ tradeoffs: 'text',
153
+ when_preferred: 'text',
154
+ },
155
+ { requiredField: 'option' },
156
+ ),
157
+ unknowns: mapList(
158
+ src.unknowns,
159
+ {
160
+ item: 'text',
161
+ why_it_matters: 'text',
162
+ how_to_obtain: 'text',
163
+ },
164
+ { requiredField: 'item', idPrefix: 'u' },
165
+ ),
166
+ decision_changers: mapList(
167
+ src.decision_changers,
168
+ {
169
+ condition: 'text',
170
+ changes_to: 'text',
171
+ },
172
+ { requiredField: 'condition' },
173
+ ),
174
+ next_checks: mapList(
175
+ src.next_checks,
176
+ {
177
+ check: 'text',
178
+ method: 'text',
179
+ expected_signal: 'text',
180
+ },
181
+ { requiredField: 'check', idPrefix: 'c' },
182
+ ),
183
+ remaining_disagreements: mapList(
184
+ src.remaining_disagreements,
185
+ {
186
+ topic: 'text',
187
+ your_position: 'text',
188
+ why_unresolved: 'text',
189
+ },
190
+ { requiredField: 'topic' },
191
+ ),
141
192
  references: (Array.isArray(src.references) ? src.references : [])
142
193
  .slice(0, O.referencesMax)
143
- .map((r) => (r && typeof r === 'object'
144
- ? { title: clampText(r.title, 300), url: clampText(r.url, 1000), relevance: clampText(r.relevance, 600) }
145
- : { title: clampText(r, 300), url: null, relevance: null }))
194
+ .map((r) =>
195
+ r && typeof r === 'object'
196
+ ? { title: clampText(r.title, 300), url: clampText(r.url, 1000), relevance: clampText(r.relevance, 600) }
197
+ : { title: clampText(r, 300), url: null, relevance: null },
198
+ )
146
199
  .filter((r) => r.title || r.url),
147
200
  };
148
201
 
149
202
  // The lists are cut to O.listMax before anything else looks at them; say how
150
203
  // long the ones that were cut had been, so the cut is not silent.
151
- const listTruncated = Object.fromEntries(['findings', 'alternatives', 'unknowns', 'decision_changers', 'next_checks', 'remaining_disagreements']
152
- .filter((k) => Array.isArray(src[k]) && src[k].length > O.listMax)
153
- .map((k) => [k, src[k].length]));
204
+ const listTruncated = Object.fromEntries(
205
+ ['findings', 'alternatives', 'unknowns', 'decision_changers', 'next_checks', 'remaining_disagreements']
206
+ .filter((k) => Array.isArray(src[k]) && src[k].length > O.listMax)
207
+ .map((k) => [k, src[k].length]),
208
+ );
154
209
 
155
210
  const missing = RESULT_KEYS.filter((k) => {
156
211
  const v = result[k];
package/src/platform.mjs CHANGED
@@ -36,13 +36,9 @@ export function findCommand(command, { cwd = process.cwd(), env = process.env }
36
36
  if (typeof command !== 'string' || !command) return null;
37
37
  cwd = path.resolve(cwd);
38
38
  const windows = process.platform === 'win32';
39
- const value = (name) => env[windows
40
- ? Object.keys(env).find((key) => key.toUpperCase() === name) : name];
39
+ const value = (name) => env[windows ? Object.keys(env).find((key) => key.toUpperCase() === name) : name];
41
40
  const explicit = command.includes('/') || (windows && command.includes('\\'));
42
- const dirs = explicit ? [cwd] : [
43
- ...(windows ? [cwd] : []),
44
- ...(value('PATH') ?? '').split(path.delimiter).filter(Boolean),
45
- ];
41
+ const dirs = explicit ? [cwd] : [...(windows ? [cwd] : []), ...(value('PATH') ?? '').split(path.delimiter).filter(Boolean)];
46
42
  for (const dir of dirs) {
47
43
  // Absolute candidates prevent which's own cwd lookup from resolving a
48
44
  // wrapper in the server directory instead of the child's directory.
@@ -56,7 +52,9 @@ export function findCommand(command, { cwd = process.cwd(), env = process.env }
56
52
  if (!fs.statSync(candidate).isFile()) return null;
57
53
  fs.accessSync(candidate, fs.constants.X_OK);
58
54
  return candidate;
59
- } catch { /* not installed */ }
55
+ } catch {
56
+ /* not installed */
57
+ }
60
58
  }
61
59
  return null;
62
60
  }
package/src/policy.mjs CHANGED
@@ -7,7 +7,7 @@ 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, normalizeName } from './consultants.mjs';
11
11
 
12
12
  // One file, read once at start, so an operator configures a machine in a
13
13
  // single place instead of editing each client's MCP registration. Precedence
@@ -17,10 +17,10 @@ import { buildConsultants, CLI_IDS, normalizeName } from './consultants.mjs';
17
17
  const CONFIG_HOME = process.env.SEVERALLY_HOME || path.join(os.homedir(), '.severally');
18
18
  // .jsonc first, for operators whose editor treats comments in a .json file as
19
19
  // 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');
20
+ const CONFIG_PATH =
21
+ process.env.SEVERALLY_CONFIG ||
22
+ [path.join(CONFIG_HOME, 'config.jsonc'), path.join(CONFIG_HOME, 'config.json')].find((p) => fs.existsSync(p)) ||
23
+ path.join(CONFIG_HOME, 'config.json');
24
24
 
25
25
  let configError = null;
26
26
  const CONFIG = (() => {
@@ -35,21 +35,53 @@ const CONFIG = (() => {
35
35
  }
36
36
  })();
37
37
 
38
- // Every mistake in the file, not only a syntax error. A config with any of
39
- // them is not run on defaults: the server still starts, so the operator can
40
- // read why, but every consultation is refused (jobs.mjs) -- falling back would
41
- // quietly re-enable a CLI the operator turned off.
38
+ // A mistake that leaves the whole file untrustworthy (unreadable, an unknown
39
+ // top-level key, a section of the wrong shape) is not run on defaults: the
40
+ // server still starts, so the operator can read why, but every consultation is
41
+ // refused (jobs.mjs) -- falling back would quietly re-enable a CLI the
42
+ // operator turned off. A mistake inside one consultant or one CLI only takes
43
+ // that one out of use, with the reason.
42
44
  const BUILT = buildConsultants(CONFIG);
43
- if (!configError && BUILT.problems.length) configError = `${CONFIG_PATH}: ${BUILT.problems.join('; ')}`;
45
+ if (!configError && BUILT.global.length) configError = `${CONFIG_PATH}: ${BUILT.global.join('; ')}`;
46
+ // A value that is neither, from the environment, cannot be read as either: reading it as "on" would open the web.
47
+ if (!configError && ![undefined, '', 'on', 'off'].includes(process.env.SEVERALLY_NETWORK)) {
48
+ configError = `SEVERALLY_NETWORK must be "on" or "off", not ${JSON.stringify(process.env.SEVERALLY_NETWORK)}`;
49
+ }
44
50
  const FROM = configError ? buildConsultants({}) : BUILT;
51
+ const LOCAL = configError ? [] : BUILT.problems.filter((p) => !BUILT.global.includes(p));
45
52
 
46
- /** What is wrong with the config file, or null. Reported at startup; while set, nothing can be started. */
53
+ /** What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started. */
47
54
  export function configProblem() {
48
55
  return configError;
49
56
  }
50
57
 
58
+ /** The mistakes kept to one consultant or one CLI, each of which is out of use until fixed. */
59
+ export function configWarnings() {
60
+ return LOCAL.map((p) => `${CONFIG_PATH}: ${p}`);
61
+ }
62
+
63
+ /** The names of the consultants out of use because of a mistake in their own config section. */
64
+ export function brokenConsultantNames() {
65
+ return configError ? [] : Object.keys(BUILT.broken);
66
+ }
67
+
68
+ /** Why the consultant of this name is out of use because of a config mistake, or null. */
69
+ export function brokenConsultant(name) {
70
+ const p = !configError && Object.hasOwn(BUILT.broken, name) ? BUILT.broken[name] : null;
71
+ return p ? `the config file has a mistake in it: ${p.join('; ')}` : null;
72
+ }
73
+
51
74
  const usable = configError ? {} : CONFIG;
52
- const cfgCli = (id) => (usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {});
75
+ // A CLI whose own section has a mistake is off, whatever else the section says.
76
+ const cfgCli = (id) => {
77
+ if (Object.hasOwn(BUILT.brokenClis, id) && !configError) {
78
+ return {
79
+ enabled: false,
80
+ note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}`,
81
+ };
82
+ }
83
+ return usable.clis && typeof usable.clis === 'object' ? (usable.clis[id] ?? {}) : {};
84
+ };
53
85
  const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
54
86
 
55
87
  /**
@@ -88,12 +120,23 @@ const str = (name, dflt) => {
88
120
  // The CLI a lead runs in, by any everyday name. Only `caller` uses this:
89
121
  // which consultant a name reaches is consultants.mjs's resolveConsultant.
90
122
  // Object.create(null) so 'constructor' is an unknown name, not a function.
91
- export const CALLER_ALIASES = Object.freeze(Object.assign(Object.create(null), {
92
- codex: 'codex', gpt: 'codex', chatgpt: 'codex', openai: 'codex',
93
- 'claude-code': 'claude-code', claude: 'claude-code', anthropic: 'claude-code',
94
- antigravity: 'antigravity', agy: 'antigravity', gemini: 'antigravity', google: 'antigravity',
95
- opencode: 'opencode', glm: 'opencode',
96
- }));
123
+ export const CALLER_ALIASES = Object.freeze(
124
+ Object.assign(Object.create(null), {
125
+ codex: 'codex',
126
+ gpt: 'codex',
127
+ chatgpt: 'codex',
128
+ openai: 'codex',
129
+ 'claude-code': 'claude-code',
130
+ claude: 'claude-code',
131
+ anthropic: 'claude-code',
132
+ antigravity: 'antigravity',
133
+ agy: 'antigravity',
134
+ gemini: 'antigravity',
135
+ google: 'antigravity',
136
+ opencode: 'opencode',
137
+ glm: 'opencode',
138
+ }),
139
+ );
97
140
 
98
141
  export const CALLER_INPUTS = Object.freeze(Object.keys(CALLER_ALIASES));
99
142
 
@@ -176,18 +219,34 @@ export const POLICY = Object.freeze({
176
219
  // a consultant's business.
177
220
  clis: Object.freeze({
178
221
  codex: Object.freeze({
179
- cli: CODEX_BIN, available: isEnabled('codex', CODEX_BIN), note: disabledNote('codex'), label: 'Codex CLI', ...extras('codex'),
222
+ cli: CODEX_BIN,
223
+ available: isEnabled('codex', CODEX_BIN),
224
+ note: disabledNote('codex'),
225
+ label: 'Codex CLI',
226
+ ...extras('codex'),
180
227
  }),
181
228
  'claude-code': Object.freeze({
182
- cli: CLAUDE_BIN, available: isEnabled('claude-code', CLAUDE_BIN), note: disabledNote('claude-code'), label: 'Claude Code CLI', ...extras('claude-code'),
229
+ cli: CLAUDE_BIN,
230
+ available: isEnabled('claude-code', CLAUDE_BIN),
231
+ note: disabledNote('claude-code'),
232
+ label: 'Claude Code CLI',
233
+ ...extras('claude-code'),
183
234
  }),
184
235
  // Where the real Antigravity credentials live is credentialsHome() below,
185
236
  // not a field here: the sandbox reads it per job (see timeoutMs()).
186
237
  antigravity: Object.freeze({
187
- cli: AGY_BIN, available: isEnabled('antigravity', AGY_BIN), note: disabledNote('antigravity'), label: 'Antigravity CLI', ...extras('antigravity'),
238
+ cli: AGY_BIN,
239
+ available: isEnabled('antigravity', AGY_BIN),
240
+ note: disabledNote('antigravity'),
241
+ label: 'Antigravity CLI',
242
+ ...extras('antigravity'),
188
243
  }),
189
244
  opencode: Object.freeze({
190
- cli: OPENCODE_BIN, available: isEnabled('opencode', OPENCODE_BIN), note: disabledNote('opencode'), label: 'OpenCode CLI', ...extras('opencode'),
245
+ cli: OPENCODE_BIN,
246
+ available: isEnabled('opencode', OPENCODE_BIN),
247
+ note: disabledNote('opencode'),
248
+ label: 'OpenCode CLI',
249
+ ...extras('opencode'),
191
250
  // opencode.json `provider` definitions copied into the sandbox config:
192
251
  // the only way to reach a provider the sandbox's empty HOME would not
193
252
  // know, such as a local OpenAI-compatible server.
@@ -218,6 +277,10 @@ export const POLICY = Object.freeze({
218
277
  // clients allow a tool call, so a long wait does not blow up as a client-side
219
278
  // timeout while the consultation is still healthy; a client registration
220
279
  // that is known to wait longer raises it (scripts/install.mjs).
280
+ // "off" takes the web away from every consultation, whatever the request
281
+ // asks; "on" leaves it to each request's own network.
282
+ // Either source can close it; neither can reopen what the other closed.
283
+ network: [process.env.SEVERALLY_NETWORK, cfgServer.network].includes('off') ? 'off' : 'on',
221
284
  maxWaitMs: limit('SEVERALLY_MAX_WAIT_MS', cfgServer.max_wait_ms, 45_000, 0, 600_000),
222
285
 
223
286
  input: Object.freeze({
@@ -305,18 +368,53 @@ export function opencodeCredentialsHome() {
305
368
  // and never copied by expose_paths; Codex's read-only sandbox cannot express
306
369
  // path rules, which the boundary record says.
307
370
  export const PROTECTED_HOME_PATHS = Object.freeze([
308
- '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.docker', '.config/gcloud', '.config/gh', '.config/hub',
309
- '.netrc', '.npmrc', '.pypirc', '.git-credentials', '.cargo/credentials', '.cargo/credentials.toml',
310
- '.terraform.d', '.vault-token', 'Library/Keychains',
311
- '.codex', '.claude', '.claude.json', '.gemini', '.config/opencode', '.local/share/opencode',
371
+ '.ssh',
372
+ '.gnupg',
373
+ '.aws',
374
+ '.azure',
375
+ '.kube',
376
+ '.docker',
377
+ '.config/gcloud',
378
+ '.config/gh',
379
+ '.config/hub',
380
+ '.netrc',
381
+ '.npmrc',
382
+ '.pypirc',
383
+ '.git-credentials',
384
+ '.cargo/credentials',
385
+ '.cargo/credentials.toml',
386
+ '.terraform.d',
387
+ '.vault-token',
388
+ 'Library/Keychains',
389
+ '.codex',
390
+ '.claude',
391
+ '.claude.json',
392
+ '.gemini',
393
+ '.config/opencode',
394
+ '.local/share/opencode',
312
395
  ]);
313
396
  // Under POLICY.home, wherever the operator moved it.
314
- const PROTECTED_SEVERALLY_PATHS = Object.freeze(['history', 'backups', 'config.json', 'config.jsonc', 'homes',
397
+ const PROTECTED_SEVERALLY_PATHS = Object.freeze([
398
+ 'history',
399
+ 'backups',
400
+ 'config.json',
401
+ 'config.jsonc',
402
+ 'homes',
315
403
  // Where homes lived before 1.7: one left by an interrupted run can still hold an auth.json copy.
316
- 'jobs/*/home']);
404
+ 'jobs/*/home',
405
+ ]);
317
406
  // File names protected wherever they are.
318
407
  export const PROTECTED_FILE_NAMES = Object.freeze([
319
- '.env', '.env.*', '.npmrc', '.netrc', '.pypirc', '.git-credentials', 'id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa',
408
+ '.env',
409
+ '.env.*',
410
+ '.npmrc',
411
+ '.netrc',
412
+ '.pypirc',
413
+ '.git-credentials',
414
+ 'id_rsa',
415
+ 'id_ed25519',
416
+ 'id_ecdsa',
417
+ 'id_dsa',
320
418
  ]);
321
419
 
322
420
  /** Absolute protected roots, with forward slashes; `*` stands for one path segment. */
@@ -333,8 +431,9 @@ const escapeRe = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
333
431
  export const PATHS_CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
334
432
  /** Regex sources: one per protected root, matching it and anything under it. */
335
433
  export function protectedRootPatterns(extraRoots = []) {
336
- return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))]
337
- .map((r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`);
434
+ return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))].map(
435
+ (r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`,
436
+ );
338
437
  }
339
438
  /** Regex sources for PROTECTED_FILE_NAMES, matched against a base name. */
340
439
  export function protectedNamePatterns() {
@@ -347,7 +446,11 @@ export function isProtectedPath(abs) {
347
446
  const roots = protectedRootPatterns().map((p) => new RegExp(p, flags));
348
447
  const names = protectedNamePatterns().map((p) => new RegExp(p, flags));
349
448
  let real = abs;
350
- try { real = fs.realpathSync.native(abs); } catch { /* missing: check the name as given */ }
449
+ try {
450
+ real = fs.realpathSync.native(abs);
451
+ } catch {
452
+ /* missing: check the name as given */
453
+ }
351
454
  return [abs, real].some((p) => {
352
455
  const fwd = p.replaceAll('\\', '/');
353
456
  return roots.some((re) => re.test(fwd)) || names.some((re) => re.test(path.basename(fwd)));
@@ -389,11 +492,15 @@ export function limitsSummary() {
389
492
  const by = (f) => Object.fromEntries(list.map((c) => [c.name, f(c)]));
390
493
  return {
391
494
  available_targets: list.map((c) => c.name),
495
+ // A consultant or CLI out of use because of a mistake in its own section.
496
+ ...(LOCAL.length ? { config_mistakes: configWarnings() } : {}),
392
497
  clis: by((c) => c.cli),
393
498
  models: by((c) => c.model),
394
499
  // Per consultant, because they no longer share one budget.
395
500
  timeout_ms: by(timeoutMs),
396
501
  max_rounds_per_chain: POLICY.maxRounds,
502
+ // "off": the operator has taken the web away from every consultation.
503
+ network: POLICY.network,
397
504
  max_concurrent_jobs: POLICY.maxConcurrent,
398
505
  max_wait_ms: POLICY.maxWaitMs,
399
506
  input_char_budget: POLICY.input.totalCharsMax,
package/src/redact.mjs CHANGED
@@ -1,51 +1,75 @@
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).
10
23
  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;
24
+ if (
25
+ CODE_WORDS.has(value.toLowerCase()) ||
26
+ value.startsWith('${') ||
27
+ /^\$[A-Z_][A-Z0-9_]*$/.test(value) ||
28
+ /^(?:process\.env\.|os\.environ\b)/.test(value)
29
+ )
30
+ return true;
13
31
  if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1))) return false;
14
32
  return /^[A-Za-z_][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) || /^[A-Za-z_][\w$.]*\(/.test(value) || value.startsWith('&');
15
33
  }
16
34
 
35
+ // A replacement is a string or a function of the match.
36
+ /** @type {Array<[RegExp, any]>} */
17
37
  const PATTERNS = [
18
38
  // 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]'],
39
+ [/\bsk-ant-[A-Za-z0-9_-]{12,}/g, '[REDACTED:anthropic-key]'],
40
+ [/\bsk-proj-[A-Za-z0-9_-]{12,}/g, '[REDACTED:openai-key]'],
41
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, '[REDACTED:api-key]'],
22
42
  [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[REDACTED:github-token]'],
23
43
  [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[REDACTED:github-token]'],
24
44
  [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack-token]'],
25
45
  [/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/g, '[REDACTED:slack-webhook]'],
26
46
  [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
27
47
  [/\bASIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
28
- [/\bAIza[0-9A-Za-z_\-]{30,}/g, '[REDACTED:google-api-key]'],
48
+ [/\bAIza[0-9A-Za-z_-]{30,}/g, '[REDACTED:google-api-key]'],
29
49
  // Stripe secret and restricted keys (pk_ is public, left alone), npm, GitLab, Hugging Face
30
50
  [/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, '[REDACTED:stripe-key]'],
31
51
  [/\bnpm_[A-Za-z0-9]{30,}/g, '[REDACTED:npm-token]'],
32
- [/\bglpat-[A-Za-z0-9_\-]{20,}/g, '[REDACTED:gitlab-token]'],
52
+ [/\bglpat-[A-Za-z0-9_-]{20,}/g, '[REDACTED:gitlab-token]'],
33
53
  [/\bhf_[A-Za-z0-9]{30,}/g, '[REDACTED:huggingface-token]'],
34
54
  // 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]'],
55
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED:jwt]'],
56
+ [/\bBearer\s+[A-Za-z0-9._-]{20,}/gi, 'Bearer [REDACTED:token]'],
37
57
  // PEM private keys
38
58
  [/-----BEGIN[^-]{0,40}PRIVATE KEY-----[\s\S]*?-----END[^-]{0,40}PRIVATE KEY-----/g, '[REDACTED:private-key]'],
39
59
  // The password in a URL's userinfo, also with an empty user (redis://:pass@host)
40
60
  [/(:\/\/[^\s:/@]*:)[^\s@/]+(@)/g, '$1[REDACTED]$2'],
41
61
  // 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]`],
62
+ [
63
+ /\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,
64
+ (_m, k, sep, _v) => `${k}${sep}[REDACTED]`,
65
+ ],
44
66
  // Password-type names, with values from 4 characters; PASS and PWD too (PWD
45
67
  // only after `_` or a letter: bare PWD is the working directory). What reads
46
68
  // 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]`)],
69
+ [
70
+ /\b((?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASS|(?<=[A-Za-z_])PWD))(["']?\s*[:=]\s*["']?)([^\s"',;)]{4,})/gi,
71
+ (m, k, sep, v) => (looksLikeCode(k, v) ? m : `${k}${sep}[REDACTED]`),
72
+ ],
49
73
  // Any environment variable named *_KEY. Upper case only: in code, primary_key,
50
74
  // sort_key and idempotency_key name things, not secrets.
51
75
  [/\b([A-Z0-9_]+_KEY)(\s*=\s*["']?)([^\s"',;)]{8,})/g, (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],