severally-mcp 2.3.1 → 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.
@@ -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 = (() => {
@@ -75,9 +75,12 @@ const usable = configError ? {} : CONFIG;
75
75
  // A CLI whose own section has a mistake is off, whatever else the section says.
76
76
  const cfgCli = (id) => {
77
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('; ')}` };
78
+ return {
79
+ enabled: false,
80
+ note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}`,
81
+ };
79
82
  }
80
- return usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {};
83
+ return usable.clis && typeof usable.clis === 'object' ? (usable.clis[id] ?? {}) : {};
81
84
  };
82
85
  const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
83
86
 
@@ -117,12 +120,23 @@ const str = (name, dflt) => {
117
120
  // The CLI a lead runs in, by any everyday name. Only `caller` uses this:
118
121
  // which consultant a name reaches is consultants.mjs's resolveConsultant.
119
122
  // 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
- }));
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
+ );
126
140
 
127
141
  export const CALLER_INPUTS = Object.freeze(Object.keys(CALLER_ALIASES));
128
142
 
@@ -205,18 +219,34 @@ export const POLICY = Object.freeze({
205
219
  // a consultant's business.
206
220
  clis: Object.freeze({
207
221
  codex: Object.freeze({
208
- 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'),
209
227
  }),
210
228
  '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'),
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'),
212
234
  }),
213
235
  // Where the real Antigravity credentials live is credentialsHome() below,
214
236
  // not a field here: the sandbox reads it per job (see timeoutMs()).
215
237
  antigravity: Object.freeze({
216
- 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'),
217
243
  }),
218
244
  opencode: Object.freeze({
219
- 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'),
220
250
  // opencode.json `provider` definitions copied into the sandbox config:
221
251
  // the only way to reach a provider the sandbox's empty HOME would not
222
252
  // know, such as a local OpenAI-compatible server.
@@ -338,18 +368,53 @@ export function opencodeCredentialsHome() {
338
368
  // and never copied by expose_paths; Codex's read-only sandbox cannot express
339
369
  // path rules, which the boundary record says.
340
370
  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',
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',
345
395
  ]);
346
396
  // Under POLICY.home, wherever the operator moved it.
347
- 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',
348
403
  // Where homes lived before 1.7: one left by an interrupted run can still hold an auth.json copy.
349
- 'jobs/*/home']);
404
+ 'jobs/*/home',
405
+ ]);
350
406
  // File names protected wherever they are.
351
407
  export const PROTECTED_FILE_NAMES = Object.freeze([
352
- '.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',
353
418
  ]);
354
419
 
355
420
  /** Absolute protected roots, with forward slashes; `*` stands for one path segment. */
@@ -366,8 +431,9 @@ const escapeRe = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
366
431
  export const PATHS_CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
367
432
  /** Regex sources: one per protected root, matching it and anything under it. */
368
433
  export function protectedRootPatterns(extraRoots = []) {
369
- return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))]
370
- .map((r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`);
434
+ return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))].map(
435
+ (r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`,
436
+ );
371
437
  }
372
438
  /** Regex sources for PROTECTED_FILE_NAMES, matched against a base name. */
373
439
  export function protectedNamePatterns() {
@@ -380,7 +446,11 @@ export function isProtectedPath(abs) {
380
446
  const roots = protectedRootPatterns().map((p) => new RegExp(p, flags));
381
447
  const names = protectedNamePatterns().map((p) => new RegExp(p, flags));
382
448
  let real = abs;
383
- 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
+ }
384
454
  return [abs, real].some((p) => {
385
455
  const fwd = p.replaceAll('\\', '/');
386
456
  return roots.some((re) => re.test(fwd)) || names.some((re) => re.test(path.basename(fwd)));
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]`],
package/src/run.mjs CHANGED
@@ -5,10 +5,7 @@ import { spawnCommand as spawn } from './platform.mjs';
5
5
  import path from 'node:path';
6
6
  import { POLICY } from './policy.mjs';
7
7
 
8
- const DROP_EXACT = new Set([
9
- 'CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'AI_AGENT',
10
- 'ANTHROPIC_LOG', 'NODE_OPTIONS',
11
- ]);
8
+ const DROP_EXACT = new Set(['CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'AI_AGENT', 'ANTHROPIC_LOG', 'NODE_OPTIONS']);
12
9
  const DROP_PREFIX = ['CLAUDE_CODE_', 'SEVERALLY_', 'MCP_'];
13
10
 
14
11
  // Each consultant sees only its own vendor's credentials.
@@ -179,14 +176,33 @@ export class ProcHandle {
179
176
  // before its parent exits and its descendants become unreachable. Once
180
177
  // it has exited, its pid may already be another process's: leave it.
181
178
  if (this.child.exitCode !== null || this.child.signalCode !== null) return;
182
- const killer = spawn(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
183
- ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
184
- const fallback = () => { try { this.child.kill('SIGKILL'); } catch { /* gone */ } };
179
+ const killer = spawn(
180
+ path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
181
+ ['/pid', String(pid), '/T', '/F'],
182
+ { windowsHide: true, stdio: 'ignore' },
183
+ );
184
+ const fallback = () => {
185
+ try {
186
+ this.child.kill('SIGKILL');
187
+ } catch {
188
+ /* gone */
189
+ }
190
+ };
185
191
  killer.on('error', fallback);
186
- killer.on('exit', (code) => { if (code !== 0) fallback(); });
192
+ killer.on('exit', (code) => {
193
+ if (code !== 0) fallback();
194
+ });
187
195
  return;
188
196
  }
189
- try { process.kill(-pid, signal); } catch { try { this.child.kill(signal); } catch { /* gone */ } }
197
+ try {
198
+ process.kill(-pid, signal);
199
+ } catch {
200
+ try {
201
+ this.child.kill(signal);
202
+ } catch {
203
+ /* gone */
204
+ }
205
+ }
190
206
  setTimeout(() => this.forceKill(), POLICY.killGraceMs).unref();
191
207
  }
192
208
 
@@ -200,7 +216,11 @@ export class ProcHandle {
200
216
  forceKill() {
201
217
  const pid = this.child?.pid;
202
218
  if (!pid || process.platform === 'win32') return;
203
- try { process.kill(-pid, 'SIGKILL'); } catch { /* gone */ }
219
+ try {
220
+ process.kill(-pid, 'SIGKILL');
221
+ } catch {
222
+ /* gone */
223
+ }
204
224
  }
205
225
  }
206
226
 
@@ -213,8 +233,18 @@ export class ProcHandle {
213
233
  * pipes closed from this side. A descendant outside the group (setsid, or on
214
234
  * Windows once the parent is gone) may still be running.
215
235
  */
216
- export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSignal, compactLine = null,
217
- settleAfter = null, stopSignal = 'SIGTERM' }) {
236
+ export function runChild({
237
+ command,
238
+ args,
239
+ cwd,
240
+ env,
241
+ input,
242
+ timeoutMs,
243
+ onCancelSignal,
244
+ compactLine = null,
245
+ settleAfter = null,
246
+ stopSignal = 'SIGTERM',
247
+ }) {
218
248
  const child = spawn(command, args, {
219
249
  cwd,
220
250
  // PWD is the parent's otherwise, and opencode takes its project root from
@@ -244,17 +274,26 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
244
274
  if (ms == null || Date.now() + ms >= settleAt) return;
245
275
  settleAt = Date.now() + ms;
246
276
  clearTimeout(settleTimer);
247
- settleTimer = setTimeout(() => { stalled = true; handle.stop(stopSignal); }, ms);
277
+ settleTimer = setTimeout(() => {
278
+ stalled = true;
279
+ handle.stop(stopSignal);
280
+ }, ms);
248
281
  };
249
282
 
250
283
  const append = (which, chunk) => {
251
284
  const text = chunk.toString('utf8');
252
285
  if (which === 'out') {
253
- if (stdout.length >= cap) { truncated = true; return; }
286
+ if (stdout.length >= cap) {
287
+ truncated = true;
288
+ return;
289
+ }
254
290
  stdout += text.slice(0, cap - stdout.length);
255
291
  if (stdout.length >= cap) truncated = true;
256
292
  } else {
257
- if (stderr.length >= cap) { truncated = true; return; }
293
+ if (stderr.length >= cap) {
294
+ truncated = true;
295
+ return;
296
+ }
258
297
  stderr += text.slice(0, cap - stderr.length);
259
298
  if (stderr.length >= cap) truncated = true;
260
299
  }
@@ -273,7 +312,10 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
273
312
  if (settleAfter) watch(l);
274
313
  append('out', `${compact(l)}\n`);
275
314
  }
276
- if (pending.length > cap) { append('out', pending); pending = ''; }
315
+ if (pending.length > cap) {
316
+ append('out', pending);
317
+ pending = '';
318
+ }
277
319
  };
278
320
  // Decode as a stream: a multibyte character split across two chunks would
279
321
  // otherwise turn into replacement characters inside the answer text.
@@ -310,7 +352,12 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
310
352
  clearTimeout(orphanTimer);
311
353
  resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, pipesForced, ...payload });
312
354
  };
313
- const flushPending = () => { if (byLine && pending) { append('out', compact(pending)); pending = ''; } };
355
+ const flushPending = () => {
356
+ if (byLine && pending) {
357
+ append('out', compact(pending));
358
+ pending = '';
359
+ }
360
+ };
314
361
  child.on('error', (err) => finish({ code: null, signal: null, spawnError: err.message }));
315
362
  // 'close' waits for the pipes as well as the child. Once the child has
316
363
  // exited, a grace later the rest of its group is stopped, and a grace
@@ -336,7 +383,9 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
336
383
  });
337
384
 
338
385
  if (input !== undefined && input !== null) {
339
- child.stdin.on('error', () => { /* child may exit before draining stdin */ });
386
+ child.stdin.on('error', () => {
387
+ /* child may exit before draining stdin */
388
+ });
340
389
  child.stdin.end(input);
341
390
  } else {
342
391
  child.stdin.end();