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.
- package/README.ja.md +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -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 +6573 -5978
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- 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,
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
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'
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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',
|
|
342
|
-
'.
|
|
343
|
-
'.
|
|
344
|
-
'.
|
|
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([
|
|
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',
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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
|
|
444
|
-
'directory and is not told where your repository is
|
|
445
|
-
'directories
|
|
446
|
-
'
|
|
447
|
-
'
|
|
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([
|
|
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 (
|
|
12
|
-
|
|
13
|
-
|
|
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_
|
|
20
|
-
[/\bsk-proj-[A-Za-z0-9_
|
|
21
|
-
[/\bsk-[A-Za-z0-9_
|
|
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_
|
|
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_
|
|
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_
|
|
36
|
-
[/\bBearer\s+[A-Za-z0-9._
|
|
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
|
-
[
|
|
43
|
-
(
|
|
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
|
-
[
|
|
48
|
-
(
|
|
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
|
-
|
|
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;
|