@assuredloop/orca-bot-kit 0.30.0 → 0.32.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.md +18 -86
- package/SETUP.md +3 -3
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +5 -8
- package/src/bot.js +0 -20
- package/src/clear.js +50 -14
- package/src/cli.js +22 -23
- package/src/commands.js +5 -8
- package/src/health.js +75 -30
- package/src/launch.js +23 -0
- package/src/orca.js +104 -18
- package/src/permissions.js +35 -518
- package/src/pi-record.js +96 -0
- package/src/pi.js +55 -17
- package/src/processes.js +8 -9
- package/src/rules.js +9 -1
- package/src/settings.js +109 -19
- package/src/skills.js +22 -9
- package/src/temp.js +69 -10
- package/src/up.js +2 -2
- package/src/run.js +0 -155
package/src/permissions.js
CHANGED
|
@@ -21,10 +21,9 @@
|
|
|
21
21
|
// `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
|
|
22
22
|
|
|
23
23
|
import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
-
import os from 'node:os';
|
|
25
24
|
import path from 'node:path';
|
|
26
25
|
|
|
27
|
-
import { allowRules,
|
|
26
|
+
import { allowRules, leadsOutside } from './bot.js';
|
|
28
27
|
import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT, REMOVED } from './commands.js';
|
|
29
28
|
import { readSettings } from './hooks.js';
|
|
30
29
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
@@ -60,18 +59,34 @@ export function defaultRules(bots, cli = ownCli()) {
|
|
|
60
59
|
*/
|
|
61
60
|
const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
|
|
62
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether a rule is one for `obk run` (#532), which #652 took out of the kit:
|
|
64
|
+
* the kit, then `run`. The kit is its CLI as `permission allow` recorded it,
|
|
65
|
+
* the bare `obk` that `obk run`'s own help spelled, or any path to an `obk`,
|
|
66
|
+
* quoted or not. A rule for it named the command after `--`, so it is known by
|
|
67
|
+
* its first two shell words.
|
|
68
|
+
*/
|
|
69
|
+
export function isRunRule(rule, cli = ownCli()) {
|
|
70
|
+
const text = /^Bash\((.*)\)$/s.exec(rule)?.[1];
|
|
71
|
+
const [kit, word] = (text === undefined ? undefined : shellWords(text).words) ?? [];
|
|
72
|
+
if (kit === undefined || word === undefined) return false;
|
|
73
|
+
return (kit === cli || path.basename(kit) === 'obk') && (word === 'run' || word.startsWith('run:'));
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* The rules that were defaults once and are not now, that the bot's `allow`
|
|
65
78
|
* holds: those of the commands kept back (#548, #615) or gone from the kit (#555),
|
|
66
|
-
* spelled as `defaultRules` spelled them, and Orca's check (#564)
|
|
67
|
-
*
|
|
79
|
+
* spelled as `defaultRules` spelled them, and Orca's check (#564); and any rule
|
|
80
|
+
* for `obk run`, which the user allowed and the kit no longer has (#652). The
|
|
81
|
+
* kit cannot tell its own old entry from a user's yes to the same rule, so it
|
|
68
82
|
* names them and leaves them (ADR 0051, ADR 0050).
|
|
69
83
|
*/
|
|
70
84
|
export function heldRules(bots, home, bot, cli = ownCli()) {
|
|
71
85
|
const allowed = allowOf(home, bot);
|
|
72
|
-
|
|
86
|
+
const spelled = [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
|
|
73
87
|
.concat(ORCA_CHECK)
|
|
74
88
|
.filter((rule) => allowed.includes(rule));
|
|
89
|
+
return [...new Set([...spelled, ...allowed.filter((rule) => isRunRule(rule, cli))])];
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
/** Command wrappers: programs that run the command their arguments name. */
|
|
@@ -166,14 +181,12 @@ function commandBroadness(all) {
|
|
|
166
181
|
if (star === -1) return undefined;
|
|
167
182
|
|
|
168
183
|
const program = path.basename(words[0].replace(/:?\*.*$/, ''));
|
|
169
|
-
if (isKit(words[0]) && mayRun(words[1] ?? '')) return runBroadness(words);
|
|
170
184
|
if (runnerOf(program) !== undefined) {
|
|
171
185
|
// Only what runs, fixed by its absolute path, narrows it. A wrapper's
|
|
172
|
-
// program is then judged by itself; an interpreter's script is the end
|
|
173
|
-
// unless it is the kit, which is judged as the kit.
|
|
186
|
+
// program is then judged by itself; an interpreter's script is the end.
|
|
174
187
|
const next = (words[1] ?? '').replace(/:\*$/, '');
|
|
175
188
|
const fixed = next.startsWith('/') && !next.includes('*');
|
|
176
|
-
if (fixed &&
|
|
189
|
+
if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
|
|
177
190
|
if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
|
|
178
191
|
return `${program} runs whatever its arguments say, so ${ANY}`;
|
|
179
192
|
}
|
|
@@ -186,349 +199,6 @@ function commandBroadness(all) {
|
|
|
186
199
|
return undefined;
|
|
187
200
|
}
|
|
188
201
|
|
|
189
|
-
/** Whether a word is the kit's CLI: `obk` by its name, or the path the kit runs as. */
|
|
190
|
-
const isKit = (word) => path.basename(word) === 'obk' || word === ownCli();
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Whether a rule's word can match `run`: Claude's `*` matches any characters,
|
|
194
|
-
* spaces too, so `ru*` matches `run --timeout 1 -- sh`, while `:*` matches
|
|
195
|
-
* only at the end of a word.
|
|
196
|
-
*/
|
|
197
|
-
function mayRun(word) {
|
|
198
|
-
const star = word.indexOf('*');
|
|
199
|
-
if (star === -1) return word === 'run';
|
|
200
|
-
const before = word.slice(0, star);
|
|
201
|
-
return before.endsWith(':') ? before === 'run:' : 'run'.startsWith(before);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Why a rule for `obk run`, as shell words, is broad, or undefined (#532). It
|
|
206
|
-
* runs whatever its arguments say, so only a fixed time limit, `--`, and a
|
|
207
|
-
* command after it that is narrow as a rule of its own narrow it.
|
|
208
|
-
*/
|
|
209
|
-
function runBroadness(words) {
|
|
210
|
-
const [, , ...rest] = words;
|
|
211
|
-
let command;
|
|
212
|
-
if (rest[0] === '--timeout' && /^\d+$/.test(rest[1] ?? '') && rest[2] === '--') command = rest.slice(3);
|
|
213
|
-
if (/^--timeout=\d+$/.test(rest[0] ?? '') && rest[1] === '--') command = rest.slice(2);
|
|
214
|
-
if (words[1] !== 'run' || command === undefined || command.length === 0) {
|
|
215
|
-
return `obk run runs whatever its arguments say, so ${ANY}. A rule for it names the command after --, such as Bash(obk run --timeout 300 -- go test:*)`;
|
|
216
|
-
}
|
|
217
|
-
return commandBroadness(command);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* A program no rule can name, standing for any command: a rule that matches
|
|
222
|
-
* the kit's run of it matches the run of any command (#635). Shell words
|
|
223
|
-
* cannot hold it.
|
|
224
|
-
*/
|
|
225
|
-
const ANY_COMMAND = '\u0007';
|
|
226
|
-
|
|
227
|
-
/** A wildcard in a rule, as `globOf` gives it: any text, spaces included. */
|
|
228
|
-
const STAR = Symbol('*');
|
|
229
|
-
|
|
230
|
-
/** A rule's text as a glob: each character itself, and each `*` a wildcard. */
|
|
231
|
-
const globOf = (text) => [...text].map((char) => (char === '*' ? STAR : char));
|
|
232
|
-
|
|
233
|
-
/*
|
|
234
|
-
* The commands a rule is held against, as a small automaton (#635): each state
|
|
235
|
-
* a list of edges, each a test of one character, or null for an edge that
|
|
236
|
-
* takes none. A part is `(machine, from) => to`.
|
|
237
|
-
*/
|
|
238
|
-
const text = (word) => (m, from) => [...word].reduce((at, char) => one((c) => c === char)(m, at), from);
|
|
239
|
-
const one = (test) => (m, from) => {
|
|
240
|
-
const to = m.state();
|
|
241
|
-
m.edge(from, test, to);
|
|
242
|
-
return to;
|
|
243
|
-
};
|
|
244
|
-
const many = (test) => (m, from) => {
|
|
245
|
-
const to = m.state();
|
|
246
|
-
m.edge(from, null, to);
|
|
247
|
-
m.edge(to, test, to);
|
|
248
|
-
return to;
|
|
249
|
-
};
|
|
250
|
-
const seq = (...parts) => (m, from) => parts.reduce((at, part) => part(m, at), from);
|
|
251
|
-
const alt = (...parts) => (m, from) => {
|
|
252
|
-
const to = m.state();
|
|
253
|
-
for (const part of parts) m.edge(part(m, from), null, to);
|
|
254
|
-
return to;
|
|
255
|
-
};
|
|
256
|
-
const maybe = (part) => alt(part, seq());
|
|
257
|
-
|
|
258
|
-
function automaton(part) {
|
|
259
|
-
const states = [];
|
|
260
|
-
const m = { state: () => states.push([]) - 1, edge: (from, test, to) => states[from].push([test, to]) };
|
|
261
|
-
const start = m.state();
|
|
262
|
-
return { states, start, end: part(m, start) };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const notSpace = (c) => c !== ' ' && c !== '\t';
|
|
266
|
-
const digit = (c) => c >= '0' && c <= '9';
|
|
267
|
-
const anything = () => true;
|
|
268
|
-
|
|
269
|
-
/** A program that runs whatever its arguments say, by its name or its absolute path, with a version on its name or not. */
|
|
270
|
-
const runner = seq(maybe(seq(text('/'), maybe(seq(many(notSpace), text('/'))))), alt(...[...RUNNERS].map(text)), many((c) => digit(c) || c === '.'));
|
|
271
|
-
|
|
272
|
-
/** A word that is a runner, and nothing more. */
|
|
273
|
-
let runnerWord;
|
|
274
|
-
|
|
275
|
-
/** The kit as a word: `obk`, any path to an `obk`, or the CLI the kit runs as. */
|
|
276
|
-
const kitAs = (kit) => alt(text('obk'), seq(many(notSpace), text('/obk')), text(kit));
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* The kit's run of any command, for the kit as it is spelled now, alone or
|
|
280
|
-
* behind a runner; then the run with a time limit; then after `--` any
|
|
281
|
-
* command, a runner with whatever it is told to run (a first argument that is
|
|
282
|
-
* not a fixed path, or the fixed path of another runner, as `commandBroadness`
|
|
283
|
-
* judges it), or another such run of the kit.
|
|
284
|
-
*/
|
|
285
|
-
const runsOfAny = new Map();
|
|
286
|
-
function runOfAny() {
|
|
287
|
-
const kit = shellWord(ownCli());
|
|
288
|
-
if (!runsOfAny.has(kit)) {
|
|
289
|
-
runsOfAny.set(kit, automaton((m, from) => {
|
|
290
|
-
const after = seq(
|
|
291
|
-
maybe(seq(runner, text(' '))), kitAs(kit),
|
|
292
|
-
text(' run --timeout'), alt(text(' '), text('=')), one(digit), many(digit), text(' -- '),
|
|
293
|
-
)(m, from);
|
|
294
|
-
m.edge(after, null, from);
|
|
295
|
-
m.edge(seq(runner, text(' '))(m, after), null, after);
|
|
296
|
-
return alt(seq(text(ANY_COMMAND), many(anything)), seq(runner, text(' '), one((c) => c !== '/'), many(anything)))(m, after);
|
|
297
|
-
}));
|
|
298
|
-
}
|
|
299
|
-
return runsOfAny.get(kit);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** The kit as a word, and nothing more, for the kit as it is spelled now. */
|
|
303
|
-
const kitWords = new Map();
|
|
304
|
-
function kitWord() {
|
|
305
|
-
const kit = shellWord(ownCli());
|
|
306
|
-
if (!kitWords.has(kit)) kitWords.set(kit, automaton(kitAs(kit)));
|
|
307
|
-
return kitWords.get(kit);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Whether a glob matches any command the automaton takes: a walk of the two
|
|
312
|
-
* together, where a wildcard takes any character, and every test takes one.
|
|
313
|
-
*/
|
|
314
|
-
function meets(glob, { states, start, end }) {
|
|
315
|
-
const seen = new Set();
|
|
316
|
-
const todo = [[0, start]];
|
|
317
|
-
while (todo.length > 0) {
|
|
318
|
-
const [at, state] = todo.pop();
|
|
319
|
-
const key = at * states.length + state;
|
|
320
|
-
if (seen.has(key)) continue;
|
|
321
|
-
seen.add(key);
|
|
322
|
-
if (at === glob.length && state === end) return true;
|
|
323
|
-
const here = glob[at];
|
|
324
|
-
if (here === STAR) todo.push([at + 1, state]);
|
|
325
|
-
for (const [test, to] of states[state]) {
|
|
326
|
-
if (test === null || here === STAR) todo.push([at, to]);
|
|
327
|
-
else if (here !== undefined && test(here)) todo.push([at + 1, to]);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Whether a Claude rule lets the kit's run run any command (#635), matched as
|
|
335
|
-
* Claude Code matches it: `x:*` at the end is `x *`, and any `*` is any text,
|
|
336
|
-
* spaces included. A rule with no wildcard is one exact command. One whose
|
|
337
|
-
* first word can be a runner lets the bot run any command without the kit,
|
|
338
|
-
* unless its next word can be only the kit, and is named as any other rule:
|
|
339
|
-
* `Bash`, `Bash(*)`, `Bash(env *)`.
|
|
340
|
-
*/
|
|
341
|
-
export function letsRunAnything(rule) {
|
|
342
|
-
const bash = /^Bash(?:\((.*)\))?$/s.exec(rule);
|
|
343
|
-
if (bash === null) return false;
|
|
344
|
-
const spec = bash[1] ?? '*';
|
|
345
|
-
if (!spec.includes('*')) return false;
|
|
346
|
-
const { words, odd } = shellWords(spec);
|
|
347
|
-
const [first = '', second = ''] = odd === undefined ? words : spec.split(' ');
|
|
348
|
-
runnerWord ??= automaton(runner);
|
|
349
|
-
const word = (one) => globOf(one.replace(/:\*$/, ''));
|
|
350
|
-
const kitNext = second !== '' && meets(word(second), kitWord()) && !meets(word(second), runnerWord);
|
|
351
|
-
if (meets(word(first), runnerWord) && !kitNext) return false;
|
|
352
|
-
return meets(globOf(spec.endsWith(':*') ? `${spec.slice(0, -2)} *` : spec), runOfAny());
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/*
|
|
356
|
-
* A Codex rules file, read as far as health reads one (#635): its comments,
|
|
357
|
-
* and calls of the three builtins whose arguments are literals. Anything else
|
|
358
|
-
* is Starlark the kit does not evaluate, and health says it cannot tell.
|
|
359
|
-
*/
|
|
360
|
-
const BUILTINS = new Set(['prefix_rule', 'host_executable', 'network_rule']);
|
|
361
|
-
|
|
362
|
-
/** The text with each comment blanked out, so a place in it is the same place in the file. */
|
|
363
|
-
function uncommented(source) {
|
|
364
|
-
let out = '';
|
|
365
|
-
let quote;
|
|
366
|
-
for (let at = 0; at < source.length; at += 1) {
|
|
367
|
-
const char = source[at];
|
|
368
|
-
if (quote !== undefined) {
|
|
369
|
-
out += char;
|
|
370
|
-
if (char === '\\' && at + 1 < source.length) out += source[++at];
|
|
371
|
-
else if (char === quote || char === '\n') quote = undefined;
|
|
372
|
-
} else if (char === '#') {
|
|
373
|
-
while (at + 1 < source.length && source[at + 1] !== '\n') {
|
|
374
|
-
out += ' ';
|
|
375
|
-
at += 1;
|
|
376
|
-
}
|
|
377
|
-
out += ' ';
|
|
378
|
-
} else {
|
|
379
|
-
if (char === '"' || char === "'") quote = char;
|
|
380
|
-
out += char;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return out;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const skipSpace = (source, at) => {
|
|
387
|
-
let i = at;
|
|
388
|
-
while (/\s/.test(source[i] ?? '')) i += 1;
|
|
389
|
-
return i;
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
/** A literal at `at`: `{ value, end }`, or undefined for anything the kit does not read. */
|
|
393
|
-
function literal(source, from) {
|
|
394
|
-
const at = skipSpace(source, from);
|
|
395
|
-
const quote = source[at];
|
|
396
|
-
if (quote === '"' || quote === "'") {
|
|
397
|
-
let value = '';
|
|
398
|
-
// An escape is not read: a string with one is something health cannot tell.
|
|
399
|
-
for (let i = at + 1; i < source.length && source[i] !== '\n' && source[i] !== '\\'; i += 1) {
|
|
400
|
-
if (source[i] === quote) return { value, end: i + 1 };
|
|
401
|
-
value += source[i];
|
|
402
|
-
}
|
|
403
|
-
return undefined;
|
|
404
|
-
}
|
|
405
|
-
if (quote === '[' || quote === '(') {
|
|
406
|
-
const close = quote === '[' ? ']' : ')';
|
|
407
|
-
const value = [];
|
|
408
|
-
let i = skipSpace(source, at + 1);
|
|
409
|
-
while (source[i] !== close) {
|
|
410
|
-
const item = literal(source, i);
|
|
411
|
-
if (item === undefined) return undefined;
|
|
412
|
-
value.push(item.value);
|
|
413
|
-
i = skipSpace(source, item.end);
|
|
414
|
-
if (source[i] === ',') i = skipSpace(source, i + 1);
|
|
415
|
-
else if (source[i] !== close) return undefined;
|
|
416
|
-
}
|
|
417
|
-
return { value, end: i + 1 };
|
|
418
|
-
}
|
|
419
|
-
const word = /^(-?\d+(?:\.\d+)?|True|False|None)\b/.exec(source.slice(at));
|
|
420
|
-
return word === null ? undefined : { value: word[1], end: at + word[0].length };
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/** A builtin's arguments from its `(`: `{ args, end }`, each `{ name, value }`, or undefined. */
|
|
424
|
-
function argumentsOf(source, open) {
|
|
425
|
-
const args = [];
|
|
426
|
-
let at = skipSpace(source, open + 1);
|
|
427
|
-
while (source[at] !== ')') {
|
|
428
|
-
const named = /^([A-Za-z_]\w*)\s*=(?!=)/.exec(source.slice(at));
|
|
429
|
-
if (named !== null) at += named[0].length;
|
|
430
|
-
const item = literal(source, at);
|
|
431
|
-
if (item === undefined) return undefined;
|
|
432
|
-
args.push({ name: named?.[1], value: item.value });
|
|
433
|
-
at = skipSpace(source, item.end);
|
|
434
|
-
if (source[at] === ',') at = skipSpace(source, at + 1);
|
|
435
|
-
else if (source[at] !== ')') return undefined;
|
|
436
|
-
}
|
|
437
|
-
return { args, end: at + 1 };
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* A rules file's `prefix_rule` calls and what health cannot read in it:
|
|
442
|
-
* `{ rules, unread }`, each rule `{ text, pattern, decision }` with its text as
|
|
443
|
-
* the file has it, and `unread` the first statement it cannot read, or
|
|
444
|
-
* undefined.
|
|
445
|
-
*/
|
|
446
|
-
function readRules(source) {
|
|
447
|
-
const bare = uncommented(source);
|
|
448
|
-
const rules = [];
|
|
449
|
-
let unread;
|
|
450
|
-
let at = skipSpace(bare, 0);
|
|
451
|
-
while (at < bare.length) {
|
|
452
|
-
const call = /^([A-Za-z_]\w*)\s*\(/.exec(bare.slice(at));
|
|
453
|
-
const read = call !== null && BUILTINS.has(call[1]) ? argumentsOf(bare, at + call[0].length - 1) : undefined;
|
|
454
|
-
if (read === undefined) {
|
|
455
|
-
// One line on: what health cannot read is said once, and what it can still comes out.
|
|
456
|
-
const line = bare.indexOf('\n', at);
|
|
457
|
-
const end = line === -1 ? bare.length : line;
|
|
458
|
-
unread ??= source.slice(at, end).trim();
|
|
459
|
-
at = skipSpace(bare, end);
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
if (call[1] === 'prefix_rule') {
|
|
463
|
-
// The pattern, then the decision, may come by place; anything more is not read.
|
|
464
|
-
const placed = read.args.filter((arg) => arg.name === undefined);
|
|
465
|
-
const named = (name) => read.args.find((arg) => arg.name === name);
|
|
466
|
-
if (placed.length > 2 || (placed.length > 0 && named('pattern')) || (placed.length > 1 && named('decision'))) {
|
|
467
|
-
unread ??= source.slice(at, read.end).trim();
|
|
468
|
-
} else {
|
|
469
|
-
const decision = (named('decision') ?? placed[1])?.value ?? 'allow';
|
|
470
|
-
rules.push({ text: source.slice(at, read.end), pattern: (named('pattern') ?? placed[0])?.value, decision });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
at = skipSpace(bare, read.end);
|
|
474
|
-
while (bare[at] === ';') at = skipSpace(bare, at + 1);
|
|
475
|
-
}
|
|
476
|
-
return { rules, unread };
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Whether a word is a program that runs whatever its arguments say, by its name or its absolute path. */
|
|
480
|
-
const isRunner = (word) => (!word.includes('/') || word.startsWith('/')) && runnerOf(path.basename(word)) !== undefined;
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Where the kit's run of any command can be after one more word of a
|
|
484
|
-
* pattern, from where it was, as `codexRunsAny` reads one; none when the word
|
|
485
|
-
* cannot be there.
|
|
486
|
-
*/
|
|
487
|
-
function afterWord(state, word) {
|
|
488
|
-
const kitOrRunner = (runner) => [...(isKit(word) ? ['kit'] : []), ...(isRunner(word) ? [runner] : [])];
|
|
489
|
-
switch (state) {
|
|
490
|
-
case 'start': return kitOrRunner('wrapper');
|
|
491
|
-
case 'wrapper': return isKit(word) ? ['kit'] : [];
|
|
492
|
-
case 'kit': return word === 'run' ? ['run'] : [];
|
|
493
|
-
case 'run': return word === '--timeout' ? ['timeout'] : (/^--timeout=\d+$/.test(word) ? ['limit'] : []);
|
|
494
|
-
case 'timeout': return /^\d+$/.test(word) ? ['limit'] : [];
|
|
495
|
-
case 'limit': return word === '--' ? ['command'] : [];
|
|
496
|
-
case 'command': return kitOrRunner('runner');
|
|
497
|
-
case 'runner': return [...(isKit(word) ? ['kit'] : []), ...(!word.startsWith('/') ? ['any'] : (isRunner(word) ? ['runner'] : []))];
|
|
498
|
-
default: return ['any'];
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Whether a `prefix_rule` pattern lets the kit's run run any command (#635),
|
|
504
|
-
* matched as Codex matches it: each element one word of the command, exactly,
|
|
505
|
-
* or any word of a list. It does when the pattern can be the start of the
|
|
506
|
-
* kit's run of any command, as `runOfAny` has it for Claude. A runner alone
|
|
507
|
-
* lets the bot run any command without the kit, and is not one.
|
|
508
|
-
*/
|
|
509
|
-
function codexRunsAny(pattern) {
|
|
510
|
-
if (!Array.isArray(pattern) || pattern.length === 0) return false;
|
|
511
|
-
let states = new Set(['start']);
|
|
512
|
-
for (const element of pattern) {
|
|
513
|
-
const words = Array.isArray(element) ? element : [element];
|
|
514
|
-
if (words.some((word) => typeof word !== 'string')) return false;
|
|
515
|
-
states = new Set([...states].flatMap((state) => words.flatMap((word) => afterWord(state, word))));
|
|
516
|
-
if (states.size === 0) return false;
|
|
517
|
-
}
|
|
518
|
-
return [...states].some((state) => state !== 'wrapper');
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* A rules file's `prefix_rule` calls that let the kit's run run any command,
|
|
523
|
-
* as their text, and the first statement health cannot read: `{ runs, unread }`.
|
|
524
|
-
* Codex matches a rule's words as the command's first words, each exactly.
|
|
525
|
-
*/
|
|
526
|
-
function codexRunRules(source) {
|
|
527
|
-
const { rules, unread } = readRules(source);
|
|
528
|
-
const runs = rules.filter(({ pattern, decision }) => decision === 'allow' && codexRunsAny(pattern));
|
|
529
|
-
return { runs: runs.map((rule) => rule.text), unread };
|
|
530
|
-
}
|
|
531
|
-
|
|
532
202
|
/**
|
|
533
203
|
* Refuse the rules `permission allow` was given when any is broad, before anything is
|
|
534
204
|
* written, naming the file the user can add it to themselves.
|
|
@@ -879,12 +549,11 @@ function planCodex(bots, home, bot) {
|
|
|
879
549
|
|
|
880
550
|
/**
|
|
881
551
|
* What health has to say about a bot's permission rules: `{ where, says }` for
|
|
882
|
-
* each entry in the file that `allow` does not hold, each rule `allow`
|
|
883
|
-
* holds that the file lacks
|
|
884
|
-
*
|
|
885
|
-
* an entry the kit did not write is named, never taken out.
|
|
552
|
+
* each entry in the file that `allow` does not hold, and each rule `allow`
|
|
553
|
+
* holds that the file lacks. Read only: an entry the kit did not write is named,
|
|
554
|
+
* never taken out.
|
|
886
555
|
*/
|
|
887
|
-
export function permissionsTrouble(
|
|
556
|
+
export function permissionsTrouble(home, bot) {
|
|
888
557
|
let allowed;
|
|
889
558
|
try {
|
|
890
559
|
allowed = allowOf(home, bot);
|
|
@@ -892,27 +561,7 @@ export function permissionsTrouble(bots, home, bot) {
|
|
|
892
561
|
if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
|
|
893
562
|
return [{ where: path.join(home, 'bot.yaml'), says: error.message }];
|
|
894
563
|
}
|
|
895
|
-
return [
|
|
896
|
-
...allowRunTrouble(bots, home, bot, allowed),
|
|
897
|
-
...claudeTrouble(home, bot, allowed),
|
|
898
|
-
...(runsOnClaude(bot) ? claudeRunTrouble(path.join(home, '.claude', 'settings.local.json'), bot.name) : []),
|
|
899
|
-
...codexTrouble(home, bot),
|
|
900
|
-
];
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Each rule in `allow` that lets the kit's run run any command. `permission
|
|
905
|
-
* allow` refuses one, so it came into bot.yaml by hand, and the kit writes
|
|
906
|
-
* what `allow` holds into the bot's harness files; the kit takes it back
|
|
907
|
-
* through `permission disallow`, after the user's yes.
|
|
908
|
-
*/
|
|
909
|
-
function allowRunTrouble(bots, home, bot, allowed) {
|
|
910
|
-
if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
|
|
911
|
-
const yaml = path.join(home, 'bot.yaml');
|
|
912
|
-
return [...new Set(allowed)].filter(letsRunAnything).map((rule) => ({
|
|
913
|
-
where: yaml,
|
|
914
|
-
says: `${yaml} allows ${rule}, and with obk run it lets ${bot.name} run any command as ${RUN_FORM}, with no judgment by auto mode or Codex's review: the kit writes what this allow list holds into ${bot.name}'s harness files. obk permission allow does not record such a rule, so it came into the file by hand. To close the gap, after the user's yes, run ${shellWord(ownCli())} permission disallow --bots ${shellWord(bots)} --bot ${shellWord(bot.name)} --rule ${shellWord(rule)}.`,
|
|
915
|
-
}));
|
|
564
|
+
return [...claudeTrouble(home, bot, allowed), ...codexTrouble(home, bot)];
|
|
916
565
|
}
|
|
917
566
|
|
|
918
567
|
/** Health's findings in the bot's Claude settings. */
|
|
@@ -940,7 +589,7 @@ function claudeTrouble(home, bot, allowed) {
|
|
|
940
589
|
where: file,
|
|
941
590
|
// One `permission allow` would refuse was added by the user by hand, which the
|
|
942
591
|
// boundary leaves to them: named, in neutral words, and left alone.
|
|
943
|
-
says:
|
|
592
|
+
says: broadness(rule) === undefined
|
|
944
593
|
? `${file} allows ${rule}, and ${bot.name}'s bot.yaml does not: the kit did not write it. It stays where it is. If the user wants it, record their yes with obk permission allow; if not, take it out of the file.`
|
|
945
594
|
: `${file} allows ${rule}, which the user added by hand, not the kit. It stays where it is.`,
|
|
946
595
|
})),
|
|
@@ -972,6 +621,9 @@ function codexTrouble(home, bot) {
|
|
|
972
621
|
}
|
|
973
622
|
}
|
|
974
623
|
|
|
624
|
+
// A file health cannot read is one finding of its own, and the rest of the
|
|
625
|
+
// fleet's health still comes out.
|
|
626
|
+
const unread = (where, error) => [{ where, says: `${where} could not be read (${error.code ?? error.message}), so health cannot say what it holds. It stays where it is.` }];
|
|
975
627
|
let names = [];
|
|
976
628
|
try {
|
|
977
629
|
names = existsSync(folder) ? readdirSync(folder) : [];
|
|
@@ -989,18 +641,10 @@ function codexTrouble(home, bot) {
|
|
|
989
641
|
} catch (error) {
|
|
990
642
|
return unread(where, error);
|
|
991
643
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
return [
|
|
997
|
-
...lines.map((line) => ({
|
|
998
|
-
where,
|
|
999
|
-
says: runs.includes(line) ? codexRunSays(where, line, bot.name) : `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
|
|
1000
|
-
})),
|
|
1001
|
-
...runs.filter((rule) => !lines.includes(rule)).map((rule) => ({ where, says: codexRunSays(where, rule, bot.name) })),
|
|
1002
|
-
...unclear(where, hidden, bot.name),
|
|
1003
|
-
];
|
|
644
|
+
return ruleLines(text).map((line) => ({
|
|
645
|
+
where,
|
|
646
|
+
says: `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
|
|
647
|
+
}));
|
|
1004
648
|
});
|
|
1005
649
|
|
|
1006
650
|
const wanted = [...lines.keys()];
|
|
@@ -1023,133 +667,6 @@ function codexTrouble(home, bot) {
|
|
|
1023
667
|
}];
|
|
1024
668
|
}
|
|
1025
669
|
|
|
1026
|
-
/** How the kit's run is called, as a finding names it. */
|
|
1027
|
-
const RUN_FORM = 'obk run --timeout <seconds> -- <command>';
|
|
1028
|
-
|
|
1029
|
-
/**
|
|
1030
|
-
* What the user does about a rule that lets the kit's run run any command, in
|
|
1031
|
-
* a file the kit did not write: the user's own, or the machine's policy.
|
|
1032
|
-
*/
|
|
1033
|
-
const takeOut = (file, policy = false) => `It stays where it is: ${file} is ${policy ? "this machine's policy" : "the user's"}, not the kit's. To close the gap, ${policy ? 'whoever keeps that policy takes' : 'the user takes'} the rule out of the file. The kit's default rules already allow the kit's own commands, narrowed to this bots folder, and a bounded run gets a narrow rule of its own, such as Bash(${shellWord(ownCli())} run --timeout 300 -- go test:*), through obk permission allow after the user's yes.`;
|
|
1034
|
-
|
|
1035
|
-
/** Health's words on a Claude rule the kit did not write that lets the kit's run run any command (#635). */
|
|
1036
|
-
const claudeRunSays = (file, rule, who, policy) => `${file} allows ${rule}, which the kit did not write. With obk run, it also lets ${who} run any command as ${RUN_FORM}, and auto mode does not judge that command. ${takeOut(file, policy)}`;
|
|
1037
|
-
|
|
1038
|
-
/** Health's words on such a Codex rule. An allowed command runs outside Codex's sandbox, with no review. */
|
|
1039
|
-
const codexRunSays = (file, rule, who, policy) => `${file} holds ${rule}, which the kit did not write. With obk run, it also lets ${who} run any command as ${RUN_FORM}, outside Codex's sandbox and with no review. ${takeOut(file, policy)}`;
|
|
1040
|
-
|
|
1041
|
-
/**
|
|
1042
|
-
* Health's words on a Codex rules file that holds what health does not read,
|
|
1043
|
-
* such as a pattern kept in a variable: it cannot tell what the file allows.
|
|
1044
|
-
*/
|
|
1045
|
-
const unclear = (where, part, who) => (part === undefined ? [] : [{
|
|
1046
|
-
where,
|
|
1047
|
-
says: `${where} holds ${part}, which health does not read: it reads only prefix_rule, host_executable and network_rule calls written out in full. So it cannot tell whether a rule there lets ${who} run whatever command obk run is given. It stays where it is. To check the file, run codex execpolicy check --rules ${shellWord(where)} with the kit's run, such as obk run --timeout 300 -- sh -c true, and read its decision.`,
|
|
1048
|
-
}]);
|
|
1049
|
-
|
|
1050
|
-
/** A file health cannot read is one finding of its own, and the rest of the fleet's health still comes out. */
|
|
1051
|
-
const unread = (where, error) => [{ where, says: `${where} could not be read (${error.code ?? error.message}), so health cannot say what it holds. It stays where it is.` }];
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* Health's findings on each rule in a Claude settings file the kit does not
|
|
1055
|
-
* write that lets the kit's run run any command.
|
|
1056
|
-
*/
|
|
1057
|
-
function claudeRunTrouble(file, who, policy) {
|
|
1058
|
-
if (!existsSync(file)) return [];
|
|
1059
|
-
let present;
|
|
1060
|
-
try {
|
|
1061
|
-
present = presentIn(readSettings(file, 'permission rules'), file);
|
|
1062
|
-
} catch (error) {
|
|
1063
|
-
return [{ where: file, says: error.message }];
|
|
1064
|
-
}
|
|
1065
|
-
return [...new Set(present)].filter((rule) => typeof rule === 'string' && letsRunAnything(rule))
|
|
1066
|
-
.map((rule) => ({ where: file, says: claudeRunSays(file, rule, who, policy) }));
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
/**
|
|
1070
|
-
* Health's findings on each rule in the `.rules` files of a Codex rules
|
|
1071
|
-
* folder the kit does not write that lets the kit's run run any command.
|
|
1072
|
-
*/
|
|
1073
|
-
function codexRunTrouble(folder, who, policy) {
|
|
1074
|
-
let names;
|
|
1075
|
-
try {
|
|
1076
|
-
names = existsSync(folder) ? readdirSync(folder) : [];
|
|
1077
|
-
} catch (error) {
|
|
1078
|
-
return unread(folder, error);
|
|
1079
|
-
}
|
|
1080
|
-
return names.filter((name) => name.endsWith('.rules')).sort().flatMap((name) => {
|
|
1081
|
-
const where = path.join(folder, name);
|
|
1082
|
-
try {
|
|
1083
|
-
if (!statSync(where).isFile()) return [];
|
|
1084
|
-
const { runs, unread: hidden } = codexRunRules(readFileSync(where, 'utf8'));
|
|
1085
|
-
return [...runs.map((rule) => ({ where, says: codexRunSays(where, rule, who, policy) })), ...unclear(where, hidden, who)];
|
|
1086
|
-
} catch (error) {
|
|
1087
|
-
return unread(where, error);
|
|
1088
|
-
}
|
|
1089
|
-
});
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
/** The nearest folder at or above `from` that holds `.git`: the repository both harnesses read project files up to. */
|
|
1093
|
-
function repoRoot(from) {
|
|
1094
|
-
for (let at = path.resolve(from); ; at = path.dirname(at)) {
|
|
1095
|
-
if (existsSync(path.join(at, '.git'))) return at;
|
|
1096
|
-
if (path.dirname(at) === at) return undefined;
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/**
|
|
1101
|
-
* What health has to say, for the whole fleet, about the allow lists every
|
|
1102
|
-
* bot's harness reads beside its own folder (#635): each rule there that lets
|
|
1103
|
-
* the kit's run run any command. Claude Code reads the user's settings, and,
|
|
1104
|
-
* for a session started below a git repository's root, the local settings at
|
|
1105
|
-
* that root; Codex reads the user's rules, and the rules of each folder from
|
|
1106
|
-
* the root down to the session's, once the project is trusted. Both read the
|
|
1107
|
-
* machine's policy too; Claude's can also come from MDM or the claude.ai
|
|
1108
|
-
* console, which no file shows. Read only: the kit names a rule there, never
|
|
1109
|
-
* takes it out.
|
|
1110
|
-
*/
|
|
1111
|
-
export function kitRunTrouble(bots, { claude, codex }) {
|
|
1112
|
-
const root = repoRoot(botsDir(bots));
|
|
1113
|
-
// OBK_POLICY_ROOT stands for `/` where the policy folders are, as in a test.
|
|
1114
|
-
const system = process.env.OBK_POLICY_ROOT || path.parse(process.cwd()).root;
|
|
1115
|
-
const found = [];
|
|
1116
|
-
if (claude) {
|
|
1117
|
-
const managed = path.join(system, process.platform === 'darwin' ? 'Library/Application Support/ClaudeCode' : 'etc/claude-code');
|
|
1118
|
-
const policy = 'every bot\'s Claude sessions';
|
|
1119
|
-
found.push(...claudeRunTrouble(path.join(managed, 'managed-settings.json'), policy, true));
|
|
1120
|
-
const parts = path.join(managed, 'managed-settings.d');
|
|
1121
|
-
let names = [];
|
|
1122
|
-
try {
|
|
1123
|
-
names = existsSync(parts) ? readdirSync(parts) : [];
|
|
1124
|
-
} catch (error) {
|
|
1125
|
-
found.push(...unread(parts, error));
|
|
1126
|
-
}
|
|
1127
|
-
for (const name of names.filter((one) => one.endsWith('.json') && !one.startsWith('.')).sort()) {
|
|
1128
|
-
found.push(...claudeRunTrouble(path.join(parts, name), policy, true));
|
|
1129
|
-
}
|
|
1130
|
-
const user = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
1131
|
-
found.push(...claudeRunTrouble(path.join(user, 'settings.json'), 'every bot\'s Claude sessions'));
|
|
1132
|
-
if (root !== undefined && root !== os.homedir()) {
|
|
1133
|
-
found.push(...claudeRunTrouble(path.join(root, '.claude', 'settings.local.json'), `the Claude sessions of every bot in ${root}`));
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
if (codex) {
|
|
1137
|
-
const user = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
1138
|
-
found.push(...codexRunTrouble(path.join(system, 'etc/codex/rules'), 'every bot\'s Codex sessions', true));
|
|
1139
|
-
found.push(...codexRunTrouble(path.join(user, 'rules'), 'every bot\'s Codex sessions'));
|
|
1140
|
-
const folders = [];
|
|
1141
|
-
for (let at = botsDir(bots); root !== undefined; at = path.dirname(at)) {
|
|
1142
|
-
folders.unshift(at);
|
|
1143
|
-
if (at === root || path.dirname(at) === at) break;
|
|
1144
|
-
}
|
|
1145
|
-
for (const folder of folders) {
|
|
1146
|
-
if (path.resolve(folder, '.codex') === path.resolve(user)) continue;
|
|
1147
|
-
found.push(...codexRunTrouble(path.join(folder, '.codex', 'rules'), `the Codex sessions of every bot in ${folder}`));
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
return found;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
670
|
/** One finding about `missing`, made by `finding`, or none when nothing is missing. */
|
|
1154
671
|
const missingIn = (missing, finding) => (missing.length === 0 ? [] : [finding([...new Set(missing)])]);
|
|
1155
672
|
|