@assuredloop/orca-bot-kit 0.9.0 → 0.11.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 +8 -0
- package/SETUP.md +22 -16
- package/package.json +1 -1
- package/rules/limits.md +4 -0
- package/rules/mail.md +1 -2
- package/skills/obk-bot-building/SKILL.md +54 -13
- package/src/book.js +60 -4
- package/src/bot.js +40 -1
- package/src/cli.js +161 -34
- package/src/health.js +2 -0
- package/src/hooks.js +2 -2
- package/src/message.js +83 -11
- package/src/orca-reload.applescript +45 -0
- package/src/orca-runtime.cjs +28 -0
- package/src/orca.js +123 -26
- package/src/permissions.js +485 -0
- package/src/retire.js +12 -25
- package/src/rules.js +16 -1
- package/src/up.js +59 -18
- package/src/orca-window.cjs +0 -21
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// The permission rules a bot may run with, written into its own Claude
|
|
2
|
+
// settings, and only the ones its user said yes to (#344).
|
|
3
|
+
//
|
|
4
|
+
// A bot in auto mode is stopped by the harness's check for things the kit's own
|
|
5
|
+
// rules tell every bot to do: read and send its mail, read a long message's
|
|
6
|
+
// body beside the bots folder, commit. A matching allow rule is settled before
|
|
7
|
+
// that check (Claude Code's permissions docs), so the kit offers every Claude
|
|
8
|
+
// bot a small default set, spelled with its real CLI and bots folder. The user
|
|
9
|
+
// sees the exact rules and says yes; `bot change --allow` keeps that yes in
|
|
10
|
+
// bot.yaml's `allow`, and the kit's code writes what `allow` holds into
|
|
11
|
+
// `.claude/settings.json` in the bot folder, beside its hook (ADR 0022). A model
|
|
12
|
+
// editing the file by hand may get the format wrong; code does not.
|
|
13
|
+
//
|
|
14
|
+
// The kit owns only the entries `allow` holds. Any other entry in the file is
|
|
15
|
+
// left where it is, and health names it.
|
|
16
|
+
//
|
|
17
|
+
// A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
|
|
18
|
+
// `Bash(<words>:*)` becomes a `prefix_rule` of those words in
|
|
19
|
+
// `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { leadsOutside } from './bot.js';
|
|
25
|
+
import { readSettings } from './hooks.js';
|
|
26
|
+
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
27
|
+
|
|
28
|
+
/** Where Claude Code reads a project's settings, inside the bot home. */
|
|
29
|
+
const FILE = '.claude/settings.json';
|
|
30
|
+
|
|
31
|
+
/** Where Codex reads a trusted project's rules, and the one file of them the kit owns. */
|
|
32
|
+
const CODEX_RULES = '.codex/rules';
|
|
33
|
+
const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The rules every Claude bot is offered, in this order. The mail commands are
|
|
37
|
+
* narrowed to this bots folder and spelled as the kit prints them in its
|
|
38
|
+
* nudge and in `message to`'s answer, which is what a bot runs. `//` is Claude's
|
|
39
|
+
* form for an absolute path.
|
|
40
|
+
*/
|
|
41
|
+
export function defaultRules(bots, cli = ownCli()) {
|
|
42
|
+
const kit = shellWord(cli);
|
|
43
|
+
const folder = shellWord(bots);
|
|
44
|
+
return [
|
|
45
|
+
`Bash(${kit} message check --bots ${folder}:*)`,
|
|
46
|
+
`Bash(${kit} message send --bots ${folder}:*)`,
|
|
47
|
+
`Bash(${kit} message to --bots ${folder}:*)`,
|
|
48
|
+
`Read(/${bots}.messages/**)`,
|
|
49
|
+
'Bash(git add:*)',
|
|
50
|
+
'Bash(git commit:*)',
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Command wrappers: programs that run the command their arguments name. */
|
|
55
|
+
const WRAPPERS = new Set(['env', 'xargs', 'sudo', 'eval', 'exec', 'nohup', 'timeout', 'nice', 'command', 'time', 'watch']);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Programs that run whatever their arguments tell them to: shells, interpreters
|
|
59
|
+
* and the wrappers above. With a wildcard after one of them, a rule lets the bot
|
|
60
|
+
* run any command, unless the next word fixes what runs by its absolute path.
|
|
61
|
+
*/
|
|
62
|
+
const RUNNERS = new Set([
|
|
63
|
+
'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh',
|
|
64
|
+
'python', 'python2', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'osascript',
|
|
65
|
+
'npx', 'pnpx', 'bunx', 'uvx',
|
|
66
|
+
...WRAPPERS,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const ANY = 'it lets the bot run any command';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Why a rule is broad, as the end of a sentence, or undefined for a narrow one
|
|
73
|
+
* (#353). A charter's grants are written as narrow, exact rules only; one that
|
|
74
|
+
* lets the bot run any command, a program with any arguments, or any file on
|
|
75
|
+
* the disk or in the home is the user's to add by hand (ADR 0027).
|
|
76
|
+
*/
|
|
77
|
+
export function broadness(rule) {
|
|
78
|
+
if (typeof rule !== 'string') return undefined;
|
|
79
|
+
const file = /^(Read|Edit|Write)(?:\((.*)\))?$/s.exec(rule);
|
|
80
|
+
if (file !== null) {
|
|
81
|
+
const spec = file[2] ?? '';
|
|
82
|
+
return spec === '' || /^(\/\/|~\/)[*/]*$/.test(spec) ? `it lets the bot ${file[1].toLowerCase()} any file` : undefined;
|
|
83
|
+
}
|
|
84
|
+
const bash = /^Bash(?:\((.*)\))?$/s.exec(rule);
|
|
85
|
+
if (bash === null) return undefined;
|
|
86
|
+
const { words, odd } = shellWords(bash[1] ?? '');
|
|
87
|
+
if (odd !== undefined) return `it holds ${odd}, which the shell reads specially, so the kit cannot tell what it runs`;
|
|
88
|
+
return commandBroadness(words);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What a word may hold outside quotes; anything else the shell reads specially. */
|
|
92
|
+
const PLAIN = /[A-Za-z0-9\-_./:=@%+,^*]/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A Bash rule's command as the shell words it is, quotes taken off, or the
|
|
96
|
+
* first thing in it the shell reads specially: `{ words }` or `{ odd }`. Only
|
|
97
|
+
* plain words are judged, so an escape, an expansion or a second command can
|
|
98
|
+
* never hide which program runs.
|
|
99
|
+
*/
|
|
100
|
+
function shellWords(text) {
|
|
101
|
+
const words = [];
|
|
102
|
+
let word;
|
|
103
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
104
|
+
const char = text[at];
|
|
105
|
+
if (char === ' ' || char === '\t') {
|
|
106
|
+
if (word !== undefined) words.push(word);
|
|
107
|
+
word = undefined;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
word ??= '';
|
|
111
|
+
if (char === "'" || char === '"') {
|
|
112
|
+
const end = text.indexOf(char, at + 1);
|
|
113
|
+
if (end === -1) return { odd: `a ${char} that does not close` };
|
|
114
|
+
const inside = text.slice(at + 1, end);
|
|
115
|
+
const special = char === '"' ? /[$`\\]/.exec(inside) : null;
|
|
116
|
+
if (special !== null) return { odd: `the character ${special[0]}` };
|
|
117
|
+
word += inside;
|
|
118
|
+
at = end;
|
|
119
|
+
} else if (char === '\\' && text[at + 1] === "'") {
|
|
120
|
+
// An apostrophe outside quotes, the form shellWord gives one in a path.
|
|
121
|
+
word += "'";
|
|
122
|
+
at += 1;
|
|
123
|
+
} else if (PLAIN.test(char) || (char === '~' && word === '')) {
|
|
124
|
+
word += char;
|
|
125
|
+
} else {
|
|
126
|
+
return { odd: char === '\n' ? 'a newline' : `the character ${char}` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (word !== undefined) words.push(word);
|
|
130
|
+
return { words };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The shell or interpreter a program name is, with a version on its name or not. */
|
|
134
|
+
const runnerOf = (name) => [name, name.replace(/[\d.]+$/, '')].find((one) => RUNNERS.has(one));
|
|
135
|
+
|
|
136
|
+
/** Why a Bash rule's command, as shell words, is broad, or undefined. */
|
|
137
|
+
function commandBroadness(all) {
|
|
138
|
+
// The program is the first word after any shell assignments in front of it.
|
|
139
|
+
let words = all;
|
|
140
|
+
while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1);
|
|
141
|
+
if (words.length === 0) return ANY;
|
|
142
|
+
const star = words.findIndex((word) => word.includes('*'));
|
|
143
|
+
if (star === -1) return undefined;
|
|
144
|
+
|
|
145
|
+
const program = path.basename(words[0].replace(/:?\*.*$/, ''));
|
|
146
|
+
if (runnerOf(program) !== undefined) {
|
|
147
|
+
// Only what runs, fixed by its absolute path, narrows it. A wrapper's
|
|
148
|
+
// program is then judged by itself; an interpreter's script is the end.
|
|
149
|
+
const next = (words[1] ?? '').replace(/:\*$/, '');
|
|
150
|
+
const fixed = next.startsWith('/') && !next.includes('*');
|
|
151
|
+
if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
|
|
152
|
+
if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
|
|
153
|
+
return `${program} runs whatever its arguments say, so ${ANY}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Two words at least before the first wildcard: a program and what it does.
|
|
157
|
+
const part = words[star].slice(0, words[star].indexOf('*')).replace(/:$/, '');
|
|
158
|
+
const before = star + (part === '' ? 0 : 1);
|
|
159
|
+
if (before === 0) return ANY;
|
|
160
|
+
if (before < 2) return `it lets the bot run ${words[0].replace(/:?\*.*$/, '')} with any arguments`;
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse the rules `--allow` was given when any is broad, before anything is
|
|
166
|
+
* written, naming the file the user can add it to themselves.
|
|
167
|
+
*/
|
|
168
|
+
export function refuseBroad(home, rules) {
|
|
169
|
+
for (const rule of rules) {
|
|
170
|
+
const why = broadness(rule);
|
|
171
|
+
if (why === undefined) continue;
|
|
172
|
+
throw new Error(`--allow ${rule} is broad: ${why}. The kit writes only narrow, exact rules, such as Bash(gh pr merge:*), so nothing was written. If the user wants this rule for ${path.basename(home)}, they add it to ${path.join(home, FILE)} themselves.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The rules the bot is allowed beyond the kit's defaults, in `allow`'s order. */
|
|
177
|
+
export const beyondDefaults = (bots, home, bot) => {
|
|
178
|
+
const defaults = defaultRules(bots);
|
|
179
|
+
return allowOf(home, bot).filter((rule) => !defaults.includes(rule));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Whether any of a bot's sessions, or the bot itself, runs on Claude Code. */
|
|
183
|
+
export const runsOnClaude = (bot) => bot.harness === 'claude'
|
|
184
|
+
|| bot.sessions.some((session) => harnessOf(session, bot.harness) === 'claude');
|
|
185
|
+
|
|
186
|
+
/** The rules the user allowed this bot, read from its bot.yaml, or a refusal naming the file. */
|
|
187
|
+
export function allowOf(home, bot) {
|
|
188
|
+
const value = bot.allow;
|
|
189
|
+
if (value === undefined || value === null) return [];
|
|
190
|
+
const file = path.join(home, 'bot.yaml');
|
|
191
|
+
if (!Array.isArray(value) || value.some((rule) => typeof rule !== 'string' || rule.trim() === '')) {
|
|
192
|
+
throw new Error(`the allow entry in ${file} is not a list of permission rules, such as Bash(git add:*), so none of it is written. Fix it, then run the command again.`);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether any of a bot's sessions, or the bot itself, runs on Codex. */
|
|
198
|
+
export const runsOnCodex = (bot) => bot.harness === 'codex'
|
|
199
|
+
|| bot.sessions.some((session) => harnessOf(session, bot.harness) === 'codex');
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The default rules this bot has not been allowed yet for one harness's file,
|
|
203
|
+
* in the default order. Codex is not offered the Read rule: its sandbox reads
|
|
204
|
+
* every file already.
|
|
205
|
+
*/
|
|
206
|
+
function waitingFor(bots, home, bot, harness) {
|
|
207
|
+
const allowed = allowOf(home, bot);
|
|
208
|
+
return defaultRules(bots)
|
|
209
|
+
.filter((rule) => harness === 'claude' || codexForm(rule).line !== undefined)
|
|
210
|
+
.filter((rule) => !allowed.includes(rule));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A rule's Codex form: `{ line }`, `{ needless: true }` for a Read rule, which
|
|
215
|
+
* Codex's sandbox does not need, or `{ why }` for a rule Codex has none for.
|
|
216
|
+
* Only a prefix of plain words has one; a prefix rule for one exact command
|
|
217
|
+
* would let the bot add any arguments the user did not say yes to.
|
|
218
|
+
*/
|
|
219
|
+
export function codexForm(rule) {
|
|
220
|
+
if (/^Read(\(.*\))?$/s.test(rule)) return { needless: true };
|
|
221
|
+
const bash = /^Bash\((.*)\)$/s.exec(rule);
|
|
222
|
+
if (bash === null) return { why: 'Codex has no rule for anything but a command, so its sandbox decides it' };
|
|
223
|
+
const { words, odd } = shellWords(bash[1]);
|
|
224
|
+
if (odd !== undefined) return { why: `Codex has no rule for it: it holds ${odd}` };
|
|
225
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) {
|
|
226
|
+
return { why: 'Codex has no rule for a command with a variable set in front of it' };
|
|
227
|
+
}
|
|
228
|
+
if (!words.some((word) => word.includes('*'))) {
|
|
229
|
+
return { why: 'Codex has no rule for one exact command: its rules match a command\'s first words, whatever comes after them' };
|
|
230
|
+
}
|
|
231
|
+
// Only the wildcard comes off, ` *` or `:*` at the very end: every word
|
|
232
|
+
// before it stays, an empty quoted one included, or the rule would be looser
|
|
233
|
+
// than the one the user said yes to.
|
|
234
|
+
const text = bash[1].trimEnd();
|
|
235
|
+
const end = /(?: |:)\*$/.exec(text);
|
|
236
|
+
const prefix = end === null ? [] : shellWords(text.slice(0, end.index)).words;
|
|
237
|
+
if (prefix.length === 0 || prefix.some((word) => word.includes('*'))) {
|
|
238
|
+
return { why: 'Codex has no rule for a wildcard anywhere but at the end, as a word of its own or after a colon' };
|
|
239
|
+
}
|
|
240
|
+
return { line: `prefix_rule(pattern=[${prefix.map((word) => JSON.stringify(word)).join(', ')}], decision="allow")` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Refuse the rules `--allow` was given for a bot that runs only on Codex when
|
|
245
|
+
* Codex has no form for one, before anything is written: recorded, it would
|
|
246
|
+
* let the bot do nothing.
|
|
247
|
+
*/
|
|
248
|
+
export function refuseNoCodexForm(bot, rules) {
|
|
249
|
+
if (runsOnClaude(bot) || !runsOnCodex(bot)) return;
|
|
250
|
+
for (const rule of rules) {
|
|
251
|
+
const { why } = codexForm(rule);
|
|
252
|
+
if (why === undefined) continue;
|
|
253
|
+
throw new Error(`--allow ${rule} is not for ${bot.name}: ${why}, and ${bot.name} runs only on Codex, so nothing was written.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The one command that allows `rules` for `bot`, as the user can run it. */
|
|
258
|
+
export const allowCommand = (bots, bot, rules) => [
|
|
259
|
+
shellWord(ownCli()), 'bot', 'change', '--bots', shellWord(bots), '--bot', shellWord(bot),
|
|
260
|
+
...rules.flatMap((rule) => ['--allow', shellWord(rule)]),
|
|
261
|
+
].join(' ');
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Write what the bot is allowed into the files of the harnesses it runs on, and
|
|
265
|
+
* say what waits: one entry per file, Claude's first. With `keepGoing`, a file
|
|
266
|
+
* the kit may not write is an entry with its `trouble` rather than a throw.
|
|
267
|
+
*/
|
|
268
|
+
export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
|
|
269
|
+
const writers = [[runsOnClaude, FILE, writeClaude], [runsOnCodex, CODEX_FILE, writeCodex]];
|
|
270
|
+
return writers.filter(([runs]) => runs(bot)).map(([, file, write]) => {
|
|
271
|
+
try {
|
|
272
|
+
return write(bots, home, bot);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (!keepGoing) throw error;
|
|
275
|
+
return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Write what the bot is allowed into its Claude settings: `{ bot, file,
|
|
282
|
+
* written, waiting }`, with `written` the rules this run added.
|
|
283
|
+
*
|
|
284
|
+
* Only rules `allow` holds are written. Everything else in the file stays as it
|
|
285
|
+
* is, the user's own entries and their order included; a rule missing from the
|
|
286
|
+
* file goes after them, and a run with nothing to add writes nothing.
|
|
287
|
+
*/
|
|
288
|
+
function writeClaude(bots, home, bot) {
|
|
289
|
+
const file = path.join(home, FILE);
|
|
290
|
+
const allowed = allowOf(home, bot);
|
|
291
|
+
const waiting = waitingFor(bots, home, bot, 'claude');
|
|
292
|
+
const answer = { bot: bot.name, file, written: [], waiting };
|
|
293
|
+
if (allowed.length === 0) return answer;
|
|
294
|
+
|
|
295
|
+
refuseOutside(home, file);
|
|
296
|
+
const settings = readSettings(file, 'the permission rules the user allowed');
|
|
297
|
+
const present = presentIn(settings, file);
|
|
298
|
+
const missing = [...new Set(allowed)].filter((rule) => !present.includes(rule));
|
|
299
|
+
if (missing.length === 0) return answer;
|
|
300
|
+
|
|
301
|
+
const wanted = { ...settings, permissions: { ...settings.permissions, allow: [...present, ...missing] } };
|
|
302
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
303
|
+
writeFileSync(file, `${JSON.stringify(wanted, null, 2)}\n`);
|
|
304
|
+
return { ...answer, written: missing };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The Codex form of what the bot is allowed: the file's rule lines, each once,
|
|
309
|
+
* with the rules that gave them, and the rules Codex has no form for.
|
|
310
|
+
*/
|
|
311
|
+
function codexOf(home, bot) {
|
|
312
|
+
const lines = new Map();
|
|
313
|
+
const unwritten = [];
|
|
314
|
+
for (const rule of allowOf(home, bot)) {
|
|
315
|
+
const form = codexForm(rule);
|
|
316
|
+
if (form.why !== undefined) unwritten.push({ rule, why: form.why });
|
|
317
|
+
if (form.line === undefined) continue;
|
|
318
|
+
lines.set(form.line, [...(lines.get(form.line) ?? []), rule]);
|
|
319
|
+
}
|
|
320
|
+
return { lines, unwritten };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The lines of a rules file that are rules: not blank, not a comment. */
|
|
324
|
+
const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#'));
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Write the Codex form of what the bot is allowed into `.codex/rules/obk.rules`:
|
|
328
|
+
* `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
|
|
329
|
+
* the file when this run wrote it. The kit owns the file whole and writes it
|
|
330
|
+
* only when it would change; with nothing to write and no file, it makes none.
|
|
331
|
+
*/
|
|
332
|
+
function writeCodex(bots, home, bot) {
|
|
333
|
+
const file = path.join(home, CODEX_FILE);
|
|
334
|
+
const { lines, unwritten } = codexOf(home, bot);
|
|
335
|
+
const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
|
|
336
|
+
if (lines.size === 0 && !existsSync(file)) return answer;
|
|
337
|
+
|
|
338
|
+
refuseOutside(home, file);
|
|
339
|
+
const text = [
|
|
340
|
+
`# Written by obk from ${bot.name}'s bot.yaml, where the user's yes to each rule is kept.`,
|
|
341
|
+
'# obk rewrites this whole file: put rules of your own in another file in this folder.',
|
|
342
|
+
...lines.keys(),
|
|
343
|
+
].join('\n');
|
|
344
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return answer;
|
|
345
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
346
|
+
writeFileSync(file, `${text}\n`);
|
|
347
|
+
return { ...answer, written: [...lines.values()].flat() };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* What health has to say about a bot's permission rules: `{ where, says }` for
|
|
352
|
+
* each entry in the file that `allow` does not hold, and each rule `allow`
|
|
353
|
+
* holds that the file lacks. Read only: an entry the kit did not write is named,
|
|
354
|
+
* never taken out.
|
|
355
|
+
*/
|
|
356
|
+
export function permissionsTrouble(home, bot) {
|
|
357
|
+
let allowed;
|
|
358
|
+
try {
|
|
359
|
+
allowed = allowOf(home, bot);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
|
|
362
|
+
return [{ where: path.join(home, 'bot.yaml'), says: error.message }];
|
|
363
|
+
}
|
|
364
|
+
return [...claudeTrouble(home, bot, allowed), ...codexTrouble(home, bot)];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Health's findings in the bot's Claude settings. */
|
|
368
|
+
function claudeTrouble(home, bot, allowed) {
|
|
369
|
+
if (!runsOnClaude(bot)) return [];
|
|
370
|
+
const file = path.join(home, FILE);
|
|
371
|
+
if (!existsSync(file) && allowed.length === 0) return [];
|
|
372
|
+
|
|
373
|
+
// A link out of the bot folder is named by the hook's check already; here it
|
|
374
|
+
// matters only when there are rules the kit would write through it.
|
|
375
|
+
if (allowed.length === 0 && leadsOutside(home, file) !== undefined) return [];
|
|
376
|
+
|
|
377
|
+
let present;
|
|
378
|
+
try {
|
|
379
|
+
refuseOutside(home, file);
|
|
380
|
+
present = presentIn(readSettings(file, 'the permission rules the user allowed'), file);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return [{ where: file, says: error.message }];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return [
|
|
386
|
+
...present.filter((rule) => !allowed.includes(rule)).map((rule) => ({
|
|
387
|
+
where: file,
|
|
388
|
+
// One `--allow` would refuse was added by the user by hand, which the
|
|
389
|
+
// boundary leaves to them: named, in neutral words, and left alone.
|
|
390
|
+
says: broadness(rule) === undefined
|
|
391
|
+
? `${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 bot change --allow; if not, take it out of the file.`
|
|
392
|
+
: `${file} allows ${rule}, which the user added by hand, not the kit. It stays where it is.`,
|
|
393
|
+
})),
|
|
394
|
+
...allowed.filter((rule) => !present.includes(rule)).map((rule) => ({
|
|
395
|
+
where: file,
|
|
396
|
+
says: `${file} does not hold ${rule}, which ${bot.name}'s bot.yaml allows, so ${bot.name}'s Claude sessions are asked about it. obk up writes it.`,
|
|
397
|
+
})),
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Health's findings in the bot's Codex rules: each rule in a file of the
|
|
403
|
+
* user's beside obk.rules, named and left alone, and an obk.rules that is not
|
|
404
|
+
* what the kit writes from `allow`.
|
|
405
|
+
*/
|
|
406
|
+
function codexTrouble(home, bot) {
|
|
407
|
+
if (!runsOnCodex(bot)) return [];
|
|
408
|
+
const folder = path.join(home, CODEX_RULES);
|
|
409
|
+
const file = path.join(home, CODEX_FILE);
|
|
410
|
+
const { lines } = codexOf(home, bot);
|
|
411
|
+
if (leadsOutside(home, file) !== undefined) {
|
|
412
|
+
if (lines.size === 0) return [];
|
|
413
|
+
try {
|
|
414
|
+
refuseOutside(home, file);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return [{ where: file, says: error.message }];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A file health cannot read is one finding of its own, and the rest of the
|
|
421
|
+
// fleet's health still comes out.
|
|
422
|
+
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.` }];
|
|
423
|
+
let names = [];
|
|
424
|
+
try {
|
|
425
|
+
names = existsSync(folder) ? readdirSync(folder) : [];
|
|
426
|
+
} catch (error) {
|
|
427
|
+
return unread(folder, error);
|
|
428
|
+
}
|
|
429
|
+
const theirs = names
|
|
430
|
+
.filter((name) => name.endsWith('.rules') && name !== path.basename(file))
|
|
431
|
+
.sort()
|
|
432
|
+
.flatMap((name) => {
|
|
433
|
+
const where = path.join(folder, name);
|
|
434
|
+
let text;
|
|
435
|
+
try {
|
|
436
|
+
text = readFileSync(where, 'utf8');
|
|
437
|
+
} catch (error) {
|
|
438
|
+
return unread(where, error);
|
|
439
|
+
}
|
|
440
|
+
return ruleLines(text).map((line) => ({
|
|
441
|
+
where,
|
|
442
|
+
says: `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
|
|
443
|
+
}));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const wanted = [...lines.keys()];
|
|
447
|
+
let present = [];
|
|
448
|
+
try {
|
|
449
|
+
present = existsSync(file) ? ruleLines(readFileSync(file, 'utf8')) : [];
|
|
450
|
+
} catch (error) {
|
|
451
|
+
return [...theirs, ...unread(file, error)];
|
|
452
|
+
}
|
|
453
|
+
const missing = wanted.filter((line) => !present.includes(line));
|
|
454
|
+
const extra = present.filter((line) => !wanted.includes(line));
|
|
455
|
+
if (missing.length === 0 && extra.length === 0) return theirs;
|
|
456
|
+
const what = [
|
|
457
|
+
...(missing.length === 0 ? [] : [`it lacks ${missing.join(' and ')}, which ${bot.name}'s bot.yaml allows`]),
|
|
458
|
+
...(extra.length === 0 ? [] : [`it holds ${extra.join(' and ')}, which the kit did not write`]),
|
|
459
|
+
].join(', and ');
|
|
460
|
+
return [...theirs, {
|
|
461
|
+
where: file,
|
|
462
|
+
says: `${file} is not what the kit writes from ${bot.name}'s bot.yaml: ${what}. The kit owns this file, and obk up rewrites it; a rule of the user's own goes in another file in ${folder}.`,
|
|
463
|
+
}];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The file's `permissions.allow`, or a refusal when it is there and is not a list. */
|
|
467
|
+
function presentIn(settings, file) {
|
|
468
|
+
const permissions = settings.permissions;
|
|
469
|
+
if (permissions === undefined) return [];
|
|
470
|
+
if (permissions === null || typeof permissions !== 'object' || Array.isArray(permissions)) {
|
|
471
|
+
throw new Error(`${file} has a permissions entry that is not a mapping, and the permission rules the user allowed go in it. Fix it or move it aside, then run the command again.`);
|
|
472
|
+
}
|
|
473
|
+
if (permissions.allow === undefined) return [];
|
|
474
|
+
if (!Array.isArray(permissions.allow)) {
|
|
475
|
+
throw new Error(`${file} has a permissions.allow entry that is not a list, and the permission rules the user allowed go in it. Fix it or move it aside, then run the command again.`);
|
|
476
|
+
}
|
|
477
|
+
return permissions.allow;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The kit writes a bot's settings in the bot folder and nowhere else (ADR 0022). */
|
|
481
|
+
function refuseOutside(home, file) {
|
|
482
|
+
const real = leadsOutside(home, file);
|
|
483
|
+
if (real === undefined) return;
|
|
484
|
+
throw new Error(`${file} leads outside the bot folder, to ${real}, through a link, and the kit writes permission rules only inside the bot folder. Replace the link with a file of the bot's own, then run the command again.`);
|
|
485
|
+
}
|
package/src/retire.js
CHANGED
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
17
17
|
// The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
|
|
18
18
|
|
|
19
|
-
import { existsSync, mkdirSync,
|
|
19
|
+
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { readBook, tabIdsIn, updateBook } from './book.js';
|
|
23
23
|
import { botDir, dropSession, readBot } from './bot.js';
|
|
24
|
-
import { deleteProject, findProject, projects,
|
|
24
|
+
import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
|
|
25
25
|
import { fleetMember } from './pause.js';
|
|
26
26
|
import { closeTabs, tabsToClose } from './restart.js';
|
|
27
27
|
import { unlinkSkills } from './skills.js';
|
|
28
|
-
import {
|
|
28
|
+
import { promptPath, sessionsOf } from './up.js';
|
|
29
29
|
|
|
30
30
|
/** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
|
|
31
31
|
export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
@@ -53,8 +53,9 @@ export async function retireSession(bots, { bot, session }) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Retire the bot `bot`. Returns `{ bot, closed, project, moved }`:
|
|
57
|
-
* closed, the Orca project it took away (if it had one)
|
|
56
|
+
* Retire the bot `bot`. Returns `{ bot, closed, project, windowReloaded, moved }`:
|
|
57
|
+
* the tabs it closed, the Orca project it took away (if it had one), whether
|
|
58
|
+
* Orca's window was reloaded after that, and where the bot is now.
|
|
58
59
|
* When Orca does not confirm the project gone, it returns `{ bot, closed,
|
|
59
60
|
* project, trouble }` instead, and the bot is left where it was.
|
|
60
61
|
*/
|
|
@@ -81,13 +82,13 @@ export async function retireBot(bots, { bot }) {
|
|
|
81
82
|
// they are gone.
|
|
82
83
|
const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
|
|
83
84
|
const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot);
|
|
85
|
+
let windowReloaded;
|
|
84
86
|
if (project !== undefined) {
|
|
85
87
|
deleteProject(project.id);
|
|
86
88
|
// Orca's answer to the delete is not the same as the project being gone
|
|
87
89
|
// (#282): its list afterwards is. Until that list is read and no longer
|
|
88
90
|
// has the project, the bot stays where it is, so that retiring it again
|
|
89
|
-
// takes the project away and then finishes.
|
|
90
|
-
// this and for the window both.
|
|
91
|
+
// takes the project away and then finishes.
|
|
91
92
|
let setups;
|
|
92
93
|
try {
|
|
93
94
|
setups = projects();
|
|
@@ -97,7 +98,9 @@ export async function retireBot(bots, { bot }) {
|
|
|
97
98
|
if (setups.some((setup) => setup.id === project.id || setup.path === home)) {
|
|
98
99
|
return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
+
// Only now: Orca's window keeps a removed project in its sidebar until
|
|
102
|
+
// it is rebuilt (#343).
|
|
103
|
+
windowReloaded = reloadWindow();
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
for (const name of new Set([...known.sessions, ...booked].map((session) => session.name))) {
|
|
@@ -107,21 +110,5 @@ export async function retireBot(bots, { bot }) {
|
|
|
107
110
|
mkdirSync(retiredDir(bots), { recursive: true });
|
|
108
111
|
renameSync(botDir(bots, bot), moved);
|
|
109
112
|
|
|
110
|
-
return { bot, closed, ...(project === undefined ? {} : { project: project.id }), moved };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Tell Orca's window a project went (#224). The call names a project that is
|
|
115
|
-
* still there, and Bot Father's is the one that always is; when Orca has none
|
|
116
|
-
* for it in `setups`, Orca's list after the delete, there is nothing to call
|
|
117
|
-
* on. Finding it is part of the workaround, so it fails as quietly as the call.
|
|
118
|
-
*/
|
|
119
|
-
function tellWindowOfRemoval(bots, setups) {
|
|
120
|
-
try {
|
|
121
|
-
const home = botDir(bots, BOT_FATHER);
|
|
122
|
-
const father = existsSync(home) ? setups.find((setup) => setup.path === realpathSync(home)) : undefined;
|
|
123
|
-
if (father !== undefined) tellWindow(father.projectId);
|
|
124
|
-
} catch {
|
|
125
|
-
// Nothing: see above.
|
|
126
|
-
}
|
|
113
|
+
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved };
|
|
127
114
|
}
|
package/src/rules.js
CHANGED
|
@@ -21,10 +21,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
21
|
import { parse } from 'yaml';
|
|
22
22
|
|
|
23
23
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
24
|
+
import { ownCli, shellWord } from './launch.js';
|
|
24
25
|
|
|
25
26
|
/** The kit's own units, inside the installed package. */
|
|
26
27
|
const KIT_RULES = fileURLToPath(new URL('../rules', import.meta.url));
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* How a rule unit names the kit, and what the build writes in its place: the
|
|
31
|
+
* CLI that builds the file, by its own path. A bot runs the command as the file
|
|
32
|
+
* gives it, and Claude Code matches an allow rule against the command as
|
|
33
|
+
* written, not after the shell expands a variable, so a bot told to run
|
|
34
|
+
* `"${OBK_CLI:-obk}"` would never match the kit's own mail rule (#344).
|
|
35
|
+
*/
|
|
36
|
+
const KIT_WORD = '"${OBK_CLI:-obk}"';
|
|
37
|
+
|
|
38
|
+
/** How a rule unit names the bots folder, which the build writes out for the same reason. */
|
|
39
|
+
const BOTS_WORD = '<bots>';
|
|
40
|
+
|
|
28
41
|
/** What a name in a list carries to mean one of the kit's rather than one of the user's. */
|
|
29
42
|
const KIT = 'kit:';
|
|
30
43
|
|
|
@@ -116,7 +129,9 @@ export function blockFor(bots, bot) {
|
|
|
116
129
|
|
|
117
130
|
const charter = typeof bot.charter === 'string' ? bot.charter.trim() : '';
|
|
118
131
|
if (charter !== '') parts.push('## Charter', charter);
|
|
119
|
-
for (const unit of units)
|
|
132
|
+
for (const unit of units) {
|
|
133
|
+
parts.push(`## ${unit.title}`, unit.body.replaceAll(KIT_WORD, shellWord(ownCli())).replaceAll(BOTS_WORD, shellWord(bots)));
|
|
134
|
+
}
|
|
120
135
|
|
|
121
136
|
return { body: parts.join('\n\n'), units: units.map((unit) => unit.name) };
|
|
122
137
|
}
|