@kaydash9999/banker-plugins 0.15.0 → 0.15.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -1
- package/package.json +1 -1
- package/skills/lineage/SKILL.md +28 -18
- package/skills/lineage/lineage.py +345 -96
- package/skills/ralph-qa/SKILL.md +91 -50
- package/skills/ralph-qa/references/gemini-seat.mjs +49 -17
- package/skills/ralph-qa/references/payload-scan.mjs +51 -27
- package/skills/ralph-qa/references/verifier-probe.mjs +40 -31
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
* node payload-scan.mjs <file>
|
|
6
6
|
*
|
|
7
7
|
* Prints {"file", "count", "hits": [{"line", "kind"}]} and exits 0 when nothing was found,
|
|
8
|
-
* 1 when something was, 2 when the file cannot be read or the arguments are not one
|
|
9
|
-
* private key hit also carries `end`, the line of its END marker (null when there is
|
|
10
|
-
* the whole block is the secret, not just the BEGIN line. The matched text itself is never
|
|
8
|
+
* 1 when something was, 2 when the file cannot be read or scanned or the arguments are not one
|
|
9
|
+
* file. A private key hit also carries `end`, the line of its END marker (null when there is
|
|
10
|
+
* none): the whole block is the secret, not just the BEGIN line. The matched text itself is never
|
|
11
11
|
* printed, so the report cannot become the leak. The patterns cover common key and token
|
|
12
12
|
* formats, private key blocks (PGP too), secret assignments (quoted, or unquoted with letters
|
|
13
13
|
* and digits), passwords (quoted, with a symbol, alone on a config or env-file line, or after a
|
|
14
14
|
* Dockerfile `ENV KEY value`), secrets given as command-line flags, curl's user and password flag,
|
|
15
15
|
* hex keys, Basic and Bearer credentials, credentials in URLs and
|
|
16
|
-
* long high-entropy strings (a lockfile's sha512- integrity hash
|
|
17
|
-
* payload clean.
|
|
18
|
-
*
|
|
16
|
+
* long high-entropy strings (a lockfile's sha512- integrity hash and a Go or Terraform h1: hash
|
|
17
|
+
* aside); they do not prove a payload clean. A Docker secret file's path after a *_FILE key is
|
|
18
|
+
* not read as the password it points to. Every repeat and lookahead is bounded (a run longer than
|
|
19
|
+
* 65536 characters is read in pieces) or anchored, so a long line takes linear time and cannot
|
|
20
|
+
* overflow the regex stack.
|
|
19
21
|
*/
|
|
20
22
|
import { readFileSync, realpathSync } from "node:fs";
|
|
21
23
|
import { fileURLToPath } from "node:url";
|
|
@@ -27,24 +29,26 @@ const PASSWORD_KEY = String.raw`(?:${PASS_WORD}|비밀번호|패스워드)[\w-]{
|
|
|
27
29
|
// An unquoted value that is not code: none of the characters an expression or a reference
|
|
28
30
|
// holds, and at least one symbol (an identifier has none).
|
|
29
31
|
const BARE = String.raw`[^\s;,"'().\[\]{}$<>]`;
|
|
30
|
-
|
|
32
|
+
// at most 8 words before PRIVATE KEY (RSA, EC, OPENSSH, ENCRYPTED, PGP): an open nested repeat
|
|
33
|
+
// overflows V8's regex stack on a key marker before millions of words
|
|
34
|
+
const PRIVATE_KEY = String.raw`(?:[A-Z0-9]{1,32} ){0,8}PRIVATE KEY(?: BLOCK)?-----`;
|
|
31
35
|
|
|
32
36
|
export const PATTERNS = [
|
|
33
37
|
["aws-access-key", /(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/],
|
|
34
|
-
["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/],
|
|
35
|
-
["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}/],
|
|
36
|
-
["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}/],
|
|
37
|
-
["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/],
|
|
38
|
-
["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/],
|
|
38
|
+
["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,65536}|github_pat_[A-Za-z0-9_]{22,65536})/],
|
|
39
|
+
["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,65536}/],
|
|
40
|
+
["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,65536}/],
|
|
41
|
+
["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,65536}/],
|
|
42
|
+
["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,65536}/],
|
|
39
43
|
["npm-token", /(?<![A-Za-z0-9])npm_[A-Za-z0-9]{36}/],
|
|
40
|
-
["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,}/],
|
|
44
|
+
["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,65536}/],
|
|
41
45
|
["google-api-key", /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/],
|
|
42
|
-
["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
46
|
+
["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,65536}/],
|
|
43
47
|
["slack-webhook", /hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/],
|
|
44
48
|
["private-key", new RegExp(`-----BEGIN ${PRIVATE_KEY}`)],
|
|
45
|
-
["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
|
|
46
|
-
["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,}/i],
|
|
47
|
-
["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,}/i],
|
|
49
|
+
["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,65536}\.eyJ[A-Za-z0-9_-]{10,65536}\.[A-Za-z0-9_-]{10,65536}/],
|
|
50
|
+
["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,65536}/i],
|
|
51
|
+
["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,65536}/i],
|
|
48
52
|
["url-credentials", /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]{0,256}:[^\s@/]{3,256}@/i],
|
|
49
53
|
// quoted: 12 characters or more, or 8 to 11 holding a digit (a word like "development" is not one)
|
|
50
54
|
["assigned-secret", new RegExp(SECRET_KEY + String.raw`["'](?:[^"'\s]{12,256}|(?=[^"'\s]{0,10}\d)[^"'\s]{8,11})["']`, "i")],
|
|
@@ -56,15 +60,17 @@ export const PATTERNS = [
|
|
|
56
60
|
// a Dockerfile's legacy `ENV KEY value` form, the value alone after the space
|
|
57
61
|
["dockerfile-env-password", new RegExp(String.raw`^[+\- ]?\s*ENV\s+[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}\s+["']?(?![-$<{%])[^\s"',;()\[\]{}=]{6,256}["']?\s*(?:#.*)?$`, "i")],
|
|
58
62
|
["flag-secret", /--(?:password|passwd|pass|token|api-key|apikey|secret|client-secret)(?:=|\s+)(?![-$])[^\s"']{6,256}/i],
|
|
59
|
-
// curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker)
|
|
60
|
-
// host:/path (rsync)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
// curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker) that is
|
|
64
|
+
// the whole value, quoted or not, a host:/path (rsync), a date format (date -u +%H:%M) or an id
|
|
65
|
+
// substitution ($(id -u):$(id -g)) is no credential. A user:group of names is not told apart:
|
|
66
|
+
// that would let admin:admin through.
|
|
67
|
+
["curl-user", /(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!(["']?)\d{1,10}:\d{1,10}\1(?=["'`\s;|&)]|$))(?!["']?\+%|\))[^\s:=]{1,256}:(?!\/)\S{3,256}/],
|
|
68
|
+
["assigned-hex", /(?:key|auth|token|secret)[\w-]{0,64}["']?\s*[:=]\s*["']?[0-9a-f]{32,65536}(?![0-9a-z])/i],
|
|
63
69
|
];
|
|
64
70
|
|
|
65
71
|
// Long runs of base64-like characters whose Shannon entropy is that of random data, as
|
|
66
72
|
// lineage's redaction measures them (32 characters or more, 4.5 bits per character or more).
|
|
67
|
-
const LONG_RUN = /[A-Za-z0-9+/=]{32,}/g;
|
|
73
|
+
const LONG_RUN = /[A-Za-z0-9+/=]{32,65536}/g;
|
|
68
74
|
const ENTROPY_FLOOR = 4.5;
|
|
69
75
|
|
|
70
76
|
export function entropy(s) {
|
|
@@ -75,10 +81,21 @@ export function entropy(s) {
|
|
|
75
81
|
return bits;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
// A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold)
|
|
84
|
+
// A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold) and a Go module or
|
|
85
|
+
// Terraform lock hash (`h1:` and 43 base64 characters and `=`, as go.sum and .terraform.lock.hcl
|
|
86
|
+
// hold) are public checksums.
|
|
79
87
|
const SRI_PREFIX = /sha(?:256|384|512)-$/;
|
|
80
|
-
const
|
|
81
|
-
|
|
88
|
+
const H1_PREFIX = /(?:^|[^A-Za-z0-9+/=])h1:$/;
|
|
89
|
+
const publicHash = (line, m) => SRI_PREFIX.test(line.slice(Math.max(0, m.index - 7), m.index))
|
|
90
|
+
|| (/^[A-Za-z0-9+/]{43}=$/.test(m[0]) && H1_PREFIX.test(line.slice(Math.max(0, m.index - 4), m.index)));
|
|
91
|
+
const highEntropy = (line) => [...line.matchAll(LONG_RUN)].some((m) => !publicHash(line, m) && entropy(m[0]) >= ENTROPY_FLOOR);
|
|
92
|
+
|
|
93
|
+
// A *_FILE key's absolute path, the Docker secret file official images take a password from, is
|
|
94
|
+
// not that password: the kinds that read a value after a key name see a `-` there instead. The
|
|
95
|
+
// other kinds still read the line as it is, so a key's own format shows wherever it sits.
|
|
96
|
+
const SECRET_FILE_PATH = /(?<![\w.-])([\w.-]{0,128}_FILE["']?(?:[ \t]*[:=][ \t]*|[ \t]+)["']?)\/[\w.@-]{1,128}(?:\/[\w.@-]{1,128}){0,32}(?=["']?(?:[\s,;#]|$))/gi;
|
|
97
|
+
const KEYED = new Set(["assigned-secret", "assigned-secret-bare", "assigned-password", "env-password", "config-password",
|
|
98
|
+
"dockerfile-env-password"]);
|
|
82
99
|
|
|
83
100
|
const KEY_END = new RegExp(`-----END ${PRIVATE_KEY}`);
|
|
84
101
|
|
|
@@ -92,8 +109,9 @@ export function scan(text) {
|
|
|
92
109
|
let next = 0;
|
|
93
110
|
lines.forEach((line, i) => {
|
|
94
111
|
while (next < ends.length && ends[next] < i + 1) next += 1;
|
|
112
|
+
const keyed = line.replace(SECRET_FILE_PATH, "$1-");
|
|
95
113
|
for (const [kind, re] of PATTERNS) {
|
|
96
|
-
if (!re.test(line)) continue;
|
|
114
|
+
if (!re.test(KEYED.has(kind) ? keyed : line)) continue;
|
|
97
115
|
hits.push(kind === "private-key" ? { line: i + 1, kind, end: ends[next] ?? null } : { line: i + 1, kind });
|
|
98
116
|
}
|
|
99
117
|
if (highEntropy(line)) hits.push({ line: i + 1, kind: "high-entropy" });
|
|
@@ -114,7 +132,13 @@ function main(argv) {
|
|
|
114
132
|
process.stderr.write(`payload-scan: cannot read ${file}: ${e.code ?? e.message}\n`);
|
|
115
133
|
return 2;
|
|
116
134
|
}
|
|
117
|
-
|
|
135
|
+
let result;
|
|
136
|
+
try {
|
|
137
|
+
result = scan(text);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
process.stderr.write(`payload-scan: cannot scan ${file}: ${e.message}\n`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
118
142
|
process.stdout.write(JSON.stringify({ file, ...result }, null, 2) + "\n");
|
|
119
143
|
return result.count === 0 ? 0 : 1;
|
|
120
144
|
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* verifier-probe: which external CLI seats ralph-qa can seat, and with which model,
|
|
4
4
|
* worked out from this machine alone. It sends no HTTP request and no prompt itself. The
|
|
5
|
-
* only child processes are `codex --version
|
|
6
|
-
*
|
|
5
|
+
* only child processes are `codex --version` and `codex debug models --bundled`; it starts no
|
|
6
|
+
* gemini and no opencode (opencode 1.3.10 empties its cache folder, the models.dev copy the probe
|
|
7
|
+
* reads too, on any start that finds the folder's version file missing or old).
|
|
7
8
|
*
|
|
8
9
|
* node verifier-probe.mjs --runtime=claude|codex [--codex[=<model>]] [--gemini[=<model>]] [--opencode[=<provider/model>]]
|
|
9
10
|
*
|
|
@@ -187,21 +188,20 @@ export const DECISION_REASONS = [
|
|
|
187
188
|
|
|
188
189
|
export const CLI_DEFAULT = "@cli-default";
|
|
189
190
|
|
|
190
|
-
// opencode refreshes its models.dev list on every start, `--version` included, unless told not to.
|
|
191
|
-
const OPENCODE_QUIET = { OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1" };
|
|
192
|
-
|
|
193
191
|
const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
|
|
194
192
|
const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
|
|
195
193
|
const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
|
|
196
194
|
const withNotes = (seat, notes) => (notes.length ? { ...seat, notes: [...new Set([...(seat.notes ?? []), ...notes])] } : seat);
|
|
197
195
|
|
|
198
|
-
// TOML keys: bare, "basic" or 'literal', dotted with optional spaces (`a."b.c" . d`).
|
|
196
|
+
// TOML keys: bare, "basic" or 'literal', dotted with optional spaces (`a."b.c" . d`). TOML ends a
|
|
197
|
+
// line only at \n or \r\n, so `.` runs over U+2028 and U+2029 too (the s flag): without it a
|
|
198
|
+
// comment holding one hid a header or a key that codex reads.
|
|
199
199
|
const KEY_SEG = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)`;
|
|
200
200
|
const KEY_PATH = String.raw`${KEY_SEG}(?:\s*\.\s*${KEY_SEG})*`;
|
|
201
|
-
const HEADER_RE = new RegExp(String.raw`^\s*\[(\[?)\s*(${KEY_PATH})\s*\]\]?\s*(?:#.*)
|
|
202
|
-
const ASSIGN_RE = new RegExp(String.raw`^\s*(${KEY_PATH})\s*=\s*(.*)
|
|
203
|
-
const SEG_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/
|
|
204
|
-
const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)
|
|
201
|
+
const HEADER_RE = new RegExp(String.raw`^\s*\[(\[?)\s*(${KEY_PATH})\s*\]\]?\s*(?:#.*)?$`, "s");
|
|
202
|
+
const ASSIGN_RE = new RegExp(String.raw`^\s*(${KEY_PATH})\s*=\s*(.*)$`, "s");
|
|
203
|
+
const SEG_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/gs;
|
|
204
|
+
const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/s;
|
|
205
205
|
|
|
206
206
|
// A dotted key's segments, quotes taken off (escapes kept as written).
|
|
207
207
|
const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
@@ -209,7 +209,7 @@ const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ??
|
|
|
209
209
|
// The walk's state: where it is (open string, nesting depth, table, array being gathered) and
|
|
210
210
|
// what it found so far.
|
|
211
211
|
const tomlState = () => ({ values: {}, arrays: {}, mcpServers: new Set(), mcpUnread: false, modelKeys: false,
|
|
212
|
-
open: null, depth: 0, table: [], roots: new Set(), array: null });
|
|
212
|
+
open: null, depth: 0, table: [], roots: new Set(), array: null, unplaced: false });
|
|
213
213
|
|
|
214
214
|
// The index just past the one-line string that opens at `i` (the line's end when it never closes).
|
|
215
215
|
function stringEnd(text, i) {
|
|
@@ -279,8 +279,9 @@ function noteMcp(state, full) {
|
|
|
279
279
|
// True when a key path ends in `model`, or its inline-table value sets one.
|
|
280
280
|
const setsModel = (full, value) => full[full.length - 1] === "model" || (value.startsWith("{") && /[{,]\s*model\s*=/.test(value));
|
|
281
281
|
|
|
282
|
-
// The strings of a top-level array, gathered line by line until it closes; comments aside.
|
|
283
|
-
|
|
282
|
+
// The strings of a top-level array, gathered line by line until it closes; comments aside. The
|
|
283
|
+
// gathered text spans lines, so a comment ends at the line's end, U+2028 and U+2029 inside it.
|
|
284
|
+
const ARRAY_ITEM = /"((?:[^"\\]|\\.)*)"|'([^']*)'|#[^\r\n]*/gs;
|
|
284
285
|
function gatherArray(state, text) {
|
|
285
286
|
if (!state.array) return;
|
|
286
287
|
state.array.text += text + "\n";
|
|
@@ -312,7 +313,9 @@ function enterTable(state, header) {
|
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
// One line of readToml into `state`: walked inside a multi-line string, array or inline table
|
|
315
|
-
// (a `model =` there still counts), else a table header or a `key = value`.
|
|
316
|
+
// (a `model =` there still counts), else a table header or a `key = value`. Any other line but
|
|
317
|
+
// a comment or a blank one is a line the probe could not place (a key it cannot spell, such as a
|
|
318
|
+
// non-ASCII bare key), and the read is not whole.
|
|
316
319
|
function tomlLine(state, line) {
|
|
317
320
|
if (state.open || state.depth > 0) {
|
|
318
321
|
if (!state.open && /(?:^|[{,])\s*model\s*=/.test(line)) state.modelKeys = true;
|
|
@@ -323,7 +326,8 @@ function tomlLine(state, line) {
|
|
|
323
326
|
const header = HEADER_RE.exec(line);
|
|
324
327
|
if (header) return enterTable(state, header);
|
|
325
328
|
const assign = ASSIGN_RE.exec(line);
|
|
326
|
-
if (assign) assignment(state, keyPath(assign[1]), assign[2]);
|
|
329
|
+
if (assign) return assignment(state, keyPath(assign[1]), assign[2]);
|
|
330
|
+
if (!/^\s*(?:#|$)/.test(line)) state.unplaced = true;
|
|
327
331
|
}
|
|
328
332
|
|
|
329
333
|
// The servers each [mcp_servers] header in the text names when the lines after it are walked
|
|
@@ -356,9 +360,9 @@ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
|
|
|
356
360
|
// the first segment of every key and table (`roots`), the MCP server names wherever they are
|
|
357
361
|
// defined (table headers, under [mcp_servers], dotted keys), whether servers were written in a
|
|
358
362
|
// form it does not list or that the text names beyond what it listed (`mcpUnread`), whether any
|
|
359
|
-
// table sets a model (`modelKeys`)
|
|
360
|
-
//
|
|
361
|
-
// TOML parser.
|
|
363
|
+
// table sets a model (`modelKeys`), whether a multi-line string, array or inline table was left
|
|
364
|
+
// open or a line could not be placed (`broken`), and the strings of each top-level array
|
|
365
|
+
// (`arrays`). Enough for those keys; not a TOML parser.
|
|
362
366
|
export function readToml(text) {
|
|
363
367
|
const state = tomlState();
|
|
364
368
|
const lines = String(text ?? "").split(/\r?\n/);
|
|
@@ -371,7 +375,7 @@ export function readToml(text) {
|
|
|
371
375
|
mcpServers: [...state.mcpServers],
|
|
372
376
|
mcpUnread: state.mcpUnread || unlisted.length > 0,
|
|
373
377
|
modelKeys: state.modelKeys,
|
|
374
|
-
broken: state.open !== null || state.depth > 0,
|
|
378
|
+
broken: state.open !== null || state.depth > 0 || state.unplaced,
|
|
375
379
|
};
|
|
376
380
|
}
|
|
377
381
|
|
|
@@ -509,7 +513,7 @@ function mcpBlockedSeat(seat, conf, notes) {
|
|
|
509
513
|
if (!bad && !conf.mcpUnread && !conf.broken) return null;
|
|
510
514
|
if (bad) notes.push(`${bad} MCP server name(s) hold characters \`-c mcp_servers.<name>.enabled=false\` cannot address: rename them to letters, digits, _ and -`);
|
|
511
515
|
if (conf.mcpUnread) notes.push("the probe could not list every MCP server the codex config defines (one inline mcp_servers table, or a name its walk did not place): write each server as its own [mcp_servers.<name>] table");
|
|
512
|
-
if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open), so its list of MCP servers may be short");
|
|
516
|
+
if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open, or a line it could not place), so its list of MCP servers may be short");
|
|
513
517
|
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
514
518
|
}
|
|
515
519
|
|
|
@@ -519,12 +523,14 @@ function mcpBlockedSeat(seat, conf, notes) {
|
|
|
519
523
|
const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
|
|
520
524
|
"sandbox_mode", "sandbox_workspace_write", "approval_policy"];
|
|
521
525
|
|
|
522
|
-
// The unseated seat when the managed file sets one of those keys
|
|
526
|
+
// The unseated seat when the managed file sets one of those keys, or could not be read to its
|
|
527
|
+
// end (a line read short may set one); null otherwise.
|
|
523
528
|
function managedBlockedSeat(seat, world, notes) {
|
|
524
529
|
if (world.platform === "win32") return null;
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
530
|
+
const read = readToml(world.readFile(CODEX_MANAGED));
|
|
531
|
+
const keys = read.roots.filter((k) => SEAT_KEYS.includes(k));
|
|
532
|
+
if (!keys.length && !read.broken) return null;
|
|
533
|
+
notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : "could not be read to its end"}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
|
|
528
534
|
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
529
535
|
}
|
|
530
536
|
|
|
@@ -696,17 +702,21 @@ function geminiStatsNotes(settings) {
|
|
|
696
702
|
return set === false ? [] : ["gemini sends usage statistics (metadata, not the payload) to play.googleapis.com; set privacy.usageStatisticsEnabled to false in its settings to stop it"];
|
|
697
703
|
}
|
|
698
704
|
|
|
699
|
-
// gemini
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
705
|
+
// gemini's own sandbox (tools.sandbox, or GEMINI_SANDBOX, which wins) restarts gemini inside it: a
|
|
706
|
+
// docker or podman container, or sandbox-exec on macOS when the value is true. The admin policy
|
|
707
|
+
// file does not reach a container, so every tool opens (0.62.0). The seat command turns it off
|
|
708
|
+
// with GEMINI_SANDBOX=false; this says so when it is on. gemini reads "0" and "false" as off, the
|
|
709
|
+
// env lower-cased and trimmed, the setting as written: "FALSE" there is no off value but a sandbox
|
|
710
|
+
// command gemini does not know, and it stops (Invalid sandbox command). The seat's env goes first.
|
|
703
711
|
function geminiSandboxNotes(settings, env) {
|
|
712
|
+
const off = (v) => v === "0" || v === "false";
|
|
704
713
|
const fromEnv = String(env.GEMINI_SANDBOX ?? "").trim().toLowerCase();
|
|
705
714
|
const set = settings.map((r) => r.value?.tools?.sandbox).find((v) => v !== undefined);
|
|
706
|
-
const on = fromEnv ? !
|
|
715
|
+
const on = fromEnv ? !off(fromEnv) : Boolean(set && !off(set) && (typeof set !== "object" || set.enabled));
|
|
707
716
|
return on ? ["gemini's own sandbox is on (tools.sandbox or GEMINI_SANDBOX): the seat command turns it off with GEMINI_SANDBOX=false, as the admin policy does not reach its container"] : [];
|
|
708
717
|
}
|
|
709
718
|
|
|
719
|
+
// gemini runs the hooks its settings define in every session, this seat too; no flag turns them off.
|
|
710
720
|
function geminiHookNotes(settings) {
|
|
711
721
|
const off = settings.some((r) => r.value?.hooksConfig?.enabled === false);
|
|
712
722
|
const hooks = settings.some((r) => Object.keys(r.value?.hooks ?? {}).length > 0);
|
|
@@ -816,13 +826,12 @@ function opencodeDecision(seat, { configured, unsure, closed, catalog }) {
|
|
|
816
826
|
function opencodeSeat(flag, { world, authorFamilies }) {
|
|
817
827
|
if (!world.which("opencode")) return absent("opencode");
|
|
818
828
|
const notes = [];
|
|
819
|
-
const version = firstLine(world.run("opencode", ["--version"], OPENCODE_QUIET));
|
|
820
829
|
const cfg = opencodeConfig(world);
|
|
821
830
|
const { configured: raw, models } = opencodeModels(cfg);
|
|
822
831
|
const usable = safeOnly(models, "opencode config", notes);
|
|
823
832
|
const configured = vetConfigured(usable.includes(raw) ? raw : null, authorFamilies, notes);
|
|
824
833
|
const sorted = sortModels(usable, authorFamilies);
|
|
825
|
-
const seat = { cli: "opencode", present: true,
|
|
834
|
+
const seat = { cli: "opencode", present: true, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
|
|
826
835
|
notes.push(...cfg.unreadable.map((f) => `config not readable: ${f}`));
|
|
827
836
|
if (world.env.OPENCODE_CONFIG_CONTENT) notes.push("$OPENCODE_CONFIG_CONTENT is not read: the seat command replaces it with its own reviewer agent");
|
|
828
837
|
const catalog = catalogProviders(world);
|