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.
- package/README.ja.md +17 -7
- package/README.md +17 -7
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +13 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +10669 -10404
- package/scripts/build.mjs +31 -26
- package/scripts/doctor.mjs +52 -17
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +259 -86
- package/scripts/live-check.mjs +52 -19
- package/scripts/live-mcp-check.mjs +42 -12
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +39 -23
- package/src/adapters/antigravity.mjs +34 -16
- package/src/adapters/claude-code.mjs +73 -21
- package/src/adapters/codex.mjs +69 -27
- package/src/adapters/opencode-sandbox.mjs +40 -21
- package/src/adapters/opencode.mjs +31 -13
- package/src/brief.mjs +65 -41
- package/src/consultants.mjs +72 -24
- package/src/export.mjs +49 -12
- package/src/expose-paths.mjs +162 -44
- package/src/failures.mjs +23 -13
- package/src/jobs.mjs +320 -152
- package/src/parse-result.mjs +85 -30
- package/src/platform.mjs +5 -7
- package/src/policy.mjs +97 -27
- package/src/redact.mjs +38 -14
- package/src/run.mjs +67 -18
- package/src/schema.mjs +50 -18
- package/src/server.mjs +134 -72
- package/src/status.mjs +81 -12
- package/src/store.mjs +131 -29
package/src/parse-result.mjs
CHANGED
|
@@ -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) {
|
|
64
|
-
|
|
65
|
-
|
|
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) {
|
|
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 {
|
|
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: {
|
|
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(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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) =>
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
152
|
-
|
|
153
|
-
|
|
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 {
|
|
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,
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 {
|
|
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(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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',
|
|
342
|
-
'.
|
|
343
|
-
'.
|
|
344
|
-
'.
|
|
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([
|
|
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',
|
|
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
|
-
|
|
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 {
|
|
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([
|
|
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 (
|
|
12
|
-
|
|
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_
|
|
20
|
-
[/\bsk-proj-[A-Za-z0-9_
|
|
21
|
-
[/\bsk-[A-Za-z0-9_
|
|
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_
|
|
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_
|
|
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_
|
|
36
|
-
[/\bBearer\s+[A-Za-z0-9._
|
|
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
|
-
[
|
|
43
|
-
(
|
|
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
|
-
[
|
|
48
|
-
(
|
|
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(
|
|
183
|
-
|
|
184
|
-
|
|
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) => {
|
|
192
|
+
killer.on('exit', (code) => {
|
|
193
|
+
if (code !== 0) fallback();
|
|
194
|
+
});
|
|
187
195
|
return;
|
|
188
196
|
}
|
|
189
|
-
try {
|
|
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 {
|
|
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({
|
|
217
|
-
|
|
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(() => {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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 = () => {
|
|
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', () => {
|
|
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();
|