jaitus 0.3.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/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// ─── Asking before acting ───────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The agent can change files and run commands. Whether that is fine or catastrophic
|
|
4
|
+
// depends entirely on what it decided to do, and it decided in a language model.
|
|
5
|
+
//
|
|
6
|
+
// Four permission modes, cycled from the prompt with Shift+Tab:
|
|
7
|
+
//
|
|
8
|
+
// default ask about every write and every command (the start)
|
|
9
|
+
// acceptEdits write_file and edit_file run without asking; commands still ask
|
|
10
|
+
// plan nothing runs at all — the agent reads, thinks, and proposes a plan
|
|
11
|
+
// auto nothing asks; for CI and people who enjoy living dangerously
|
|
12
|
+
//
|
|
13
|
+
// "Allow for this session" still exists inside default, because being asked forty
|
|
14
|
+
// times in a row trains people to hit `a` without reading, which is worse than not
|
|
15
|
+
// asking at all.
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { c, out, askKey, glyph } from '../ui.js';
|
|
19
|
+
import { decide, ruleForCall, broaderRule, DEFAULT_DENY, subjectOf } from './rules.js';
|
|
20
|
+
|
|
21
|
+
export class PermissionDenied extends Error {}
|
|
22
|
+
|
|
23
|
+
export const MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
|
|
24
|
+
|
|
25
|
+
/** opencode permission icons per tool: shell #, edits ←, reads →, search ✱. */
|
|
26
|
+
const TOOL_ICONS = {
|
|
27
|
+
run_command: '#', write_file: '←', edit_file: '←',
|
|
28
|
+
read_file: '→', read_image: '→', list_files: '→',
|
|
29
|
+
search: '✱', web_fetch: '%', web_search: '◈',
|
|
30
|
+
background_run: '◉', todo_write: '⚙',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createPermissions({
|
|
34
|
+
autoApprove = false, mode = 'default', onModeChange = null,
|
|
35
|
+
allow = [], deny = [], onRulesChange = null,
|
|
36
|
+
} = {}) {
|
|
37
|
+
let current = autoApprove ? 'bypassPermissions' : (MODES.includes(mode) ? mode : mode === 'auto' ? 'bypassPermissions' : 'default');
|
|
38
|
+
// Session rules live beside the saved ones and are never written to disk: "allow for
|
|
39
|
+
// this session" has to mean this session, or the word is a lie.
|
|
40
|
+
const savedAllow = [...allow];
|
|
41
|
+
const savedDeny = [...DEFAULT_DENY, ...deny];
|
|
42
|
+
const sessionAllow = [];
|
|
43
|
+
|
|
44
|
+
const api = {
|
|
45
|
+
get mode() { return current; },
|
|
46
|
+
get rules() { return { allow: [...savedAllow], deny: [...savedDeny], session: [...sessionAllow] }; },
|
|
47
|
+
|
|
48
|
+
/** Add a rule. `persist` writes it to settings via onRulesChange. */
|
|
49
|
+
addRule(rule, { persist = false, kind = 'allow' } = {}) {
|
|
50
|
+
const list = kind === 'deny' ? savedDeny : (persist ? savedAllow : sessionAllow);
|
|
51
|
+
if (!list.includes(rule)) list.push(rule);
|
|
52
|
+
if (persist) onRulesChange?.({ allow: savedAllow, deny: savedDeny.filter((r) => !DEFAULT_DENY.includes(r)) });
|
|
53
|
+
return rule;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
removeRule(rule) {
|
|
57
|
+
for (const list of [savedAllow, savedDeny, sessionAllow]) {
|
|
58
|
+
const at = list.indexOf(rule);
|
|
59
|
+
if (at >= 0) list.splice(at, 1);
|
|
60
|
+
}
|
|
61
|
+
onRulesChange?.({ allow: savedAllow, deny: savedDeny.filter((r) => !DEFAULT_DENY.includes(r)) });
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** What the rules say about this call, without asking. */
|
|
65
|
+
verdict(toolName, input) {
|
|
66
|
+
return decide({ allow: [...savedAllow, ...sessionAllow], deny: savedDeny }, toolName, input);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
setMode(next) {
|
|
70
|
+
if (!MODES.includes(next)) return;
|
|
71
|
+
current = next;
|
|
72
|
+
onModeChange?.(current);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Shift+Tab: default → acceptEdits → plan → auto → default. */
|
|
76
|
+
cycleMode() {
|
|
77
|
+
const next = MODES[(MODES.indexOf(current) + 1) % MODES.length];
|
|
78
|
+
api.setMode(next);
|
|
79
|
+
return current;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @returns true to proceed. Throws PermissionDenied if the user says no.
|
|
84
|
+
*/
|
|
85
|
+
async request(tool, input, { cwd, tui = null } = {}) {
|
|
86
|
+
// A deny rule outranks everything, including bypass. Someone who wrote
|
|
87
|
+
// `deny: ["run_command(git push:*)"]` and then turned on bypass to get through a
|
|
88
|
+
// long refactor did not mean "except that one".
|
|
89
|
+
const verdict = api.verdict(tool.name, input);
|
|
90
|
+
if (verdict === 'deny') {
|
|
91
|
+
throw new PermissionDenied(
|
|
92
|
+
`A deny rule blocks ${tool.name}${subjectOf(tool.name, input) ? `: ${subjectOf(tool.name, input).slice(0, 80)}` : ''}. `
|
|
93
|
+
+ 'Change it with /permissions if that is wrong.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!tool.needsPermission) return true;
|
|
97
|
+
if (verdict === 'allow') return true;
|
|
98
|
+
if (current === 'bypassPermissions') return true;
|
|
99
|
+
if (current === 'acceptEdits' && (tool.name === 'write_file' || tool.name === 'edit_file')) return true;
|
|
100
|
+
|
|
101
|
+
// Not a terminal — a script cannot answer, and silently proceeding would be the
|
|
102
|
+
// worst possible reading of "no answer". In plan mode the same wall is the point.
|
|
103
|
+
if (!process.stdin.isTTY) {
|
|
104
|
+
throw new PermissionDenied(
|
|
105
|
+
current === 'plan'
|
|
106
|
+
? 'Plan mode blocks every write and command. Shift+Tab out of plan mode, or run it with a mode that acts.'
|
|
107
|
+
: `${tool.name} needs permission, but there is no terminal to ask. Re-run with --yes to allow it.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Inside the TUI the ask is an inline panel pinned above the prompt
|
|
112
|
+
// (opencode shape), with the preview inside it; outside it stays
|
|
113
|
+
// inline below the last output.
|
|
114
|
+
// The scope of "always" is spelled out in the option itself. Previously it read
|
|
115
|
+
// "Allow always" and meant the whole tool, so approving `npm test` also approved
|
|
116
|
+
// every future shell command — including the one you would never have allowed.
|
|
117
|
+
const exact = ruleForCall(tool.name, input, cwd);
|
|
118
|
+
const broad = broaderRule(tool.name, input, cwd);
|
|
119
|
+
|
|
120
|
+
if (tui) {
|
|
121
|
+
tui.bellIfAway?.();
|
|
122
|
+
const previewText = previewTextOf(tool, input);
|
|
123
|
+
const options = [
|
|
124
|
+
{ label: 'Allow once', value: 'y' },
|
|
125
|
+
{ label: `Allow ${describeRule(exact)} for this session`, value: 'session' },
|
|
126
|
+
{ label: `Always allow ${describeRule(exact)}`, value: 'always' },
|
|
127
|
+
];
|
|
128
|
+
if (broad !== exact) options.push({ label: `Always allow ${describeRule(broad)}`, value: 'broad' });
|
|
129
|
+
options.push({ label: 'Reject', value: 'deny' });
|
|
130
|
+
|
|
131
|
+
const answer = await tui.askDialog(describe(tool, input, cwd), options, previewText, {
|
|
132
|
+
icon: TOOL_ICONS[tool.name] || '⚙',
|
|
133
|
+
pills: true,
|
|
134
|
+
title: 'Permission',
|
|
135
|
+
tone: tool.name === 'run_command' ? 'danger' : undefined,
|
|
136
|
+
});
|
|
137
|
+
const value = answer && typeof answer === 'object' ? answer.value : answer;
|
|
138
|
+
const comment = (answer?.comment || '').trim();
|
|
139
|
+
if (value === 'session') { api.addRule(exact); return true; }
|
|
140
|
+
if (value === 'always') { api.addRule(exact, { persist: true }); return true; }
|
|
141
|
+
if (value === 'broad') { api.addRule(broad, { persist: true }); return true; }
|
|
142
|
+
if (value === 'y') return true;
|
|
143
|
+
const err = new PermissionDenied(`You declined: ${tool.name}`);
|
|
144
|
+
if (comment) err.comment = comment;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
out(` ${c.permission(glyph('△', '!'))} ${c.bold(describe(tool, input, cwd))}`);
|
|
149
|
+
preview(tool, input);
|
|
150
|
+
out('');
|
|
151
|
+
|
|
152
|
+
out(` ${c.dim(`always → adds the rule ${c.bold(exact)}`)}`);
|
|
153
|
+
const answer = await askKey(
|
|
154
|
+
` ${c.dim('[y] Allow once [s] this session [a] always [n] Reject ›')} `,
|
|
155
|
+
['y', 's', 'a', 'n', '\r']
|
|
156
|
+
);
|
|
157
|
+
out('');
|
|
158
|
+
|
|
159
|
+
if (answer === 's') { api.addRule(exact); return true; }
|
|
160
|
+
if (answer === 'a') { api.addRule(exact, { persist: true }); return true; }
|
|
161
|
+
if (answer === 'y' || answer === '\r') return true;
|
|
162
|
+
throw new PermissionDenied(`You declined: ${tool.name}`);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return api;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `run_command(npm test:*)` → `npm test *`, which is what the rule means out loud. */
|
|
170
|
+
function describeRule(rule) {
|
|
171
|
+
const m = /^([a-z_]+)\((.*)\)$/i.exec(rule);
|
|
172
|
+
if (!m) return `every ${String(rule).replace(/_/g, ' ')}`;
|
|
173
|
+
return m[2].endsWith(':*') ? `${m[2].slice(0, -2)} …` : m[2];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The whole tool call as plain lines, for the TUI permission dialog. */
|
|
177
|
+
function previewTextOf(tool, input) {
|
|
178
|
+
const lines = [];
|
|
179
|
+
if (tool.name === 'run_command') lines.push(`$ ${input.command}`);
|
|
180
|
+
else if (tool.name === 'write_file') lines.push(...String(input.content ?? '').split('\n').slice(0, 12));
|
|
181
|
+
else if (tool.name === 'edit_file') {
|
|
182
|
+
for (const l of String(input.old ?? '').split('\n').slice(0, 6)) lines.push(`- ${l}`);
|
|
183
|
+
for (const l of String(input.new ?? '').split('\n').slice(0, 6)) lines.push(`+ ${l}`);
|
|
184
|
+
} else lines.push(JSON.stringify(input).slice(0, 200));
|
|
185
|
+
return lines.slice(0, 12);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function describe(tool, input, cwd) {
|
|
189
|
+
switch (tool.name) {
|
|
190
|
+
case 'write_file': return `Write ${path.relative(cwd, path.resolve(cwd, input.path)) || input.path}`;
|
|
191
|
+
case 'edit_file': return `Edit ${path.relative(cwd, path.resolve(cwd, input.path)) || input.path}`;
|
|
192
|
+
case 'run_command': return `Run a command`;
|
|
193
|
+
default: return `${tool.name}`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Show enough that the answer is informed, but not so much it scrolls away. */
|
|
198
|
+
function preview(tool, input) {
|
|
199
|
+
const show = (label, text, limit = 14) => {
|
|
200
|
+
const lines = String(text ?? '').split('\n');
|
|
201
|
+
out(` ${c.dim(label)}`);
|
|
202
|
+
lines.slice(0, limit).forEach((l) => out(` ${c.grey(glyph('│', '|'))} ${l.slice(0, 160)}`));
|
|
203
|
+
if (lines.length > limit) out(` ${c.grey(glyph('│', '|'))} ${c.dim(`${glyph('…', '...')} ${lines.length - limit} more lines`)}`);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (tool.name === 'run_command') {
|
|
207
|
+
out(` ${c.grey('$')} ${c.bold(input.command)}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (tool.name === 'write_file') {
|
|
211
|
+
show('new contents', input.content);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (tool.name === 'edit_file') {
|
|
215
|
+
String(input.old ?? '').split('\n').slice(0, 10).forEach((l) => out(` ${c.red('-')} ${l.slice(0, 160)}`));
|
|
216
|
+
String(input.new ?? '').split('\n').slice(0, 10).forEach((l) => out(` ${c.green('+')} ${l.slice(0, 160)}`));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// ─── Permission rules ───────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// "Allow always" used to add the TOOL to a set. Answering it once for `npm test`
|
|
4
|
+
// therefore also approved `rm -rf /`, `curl | sh`, and every other command for the
|
|
5
|
+
// rest of the session — from a prompt whose preview showed only the harmless one. It
|
|
6
|
+
// is the most dangerous kind of bug: the safe answer and the catastrophic answer are
|
|
7
|
+
// the same keystroke, and nothing on screen distinguishes them.
|
|
8
|
+
//
|
|
9
|
+
// A rule names the tool AND what it may be used on:
|
|
10
|
+
//
|
|
11
|
+
// run_command(npm test:*) that command and its arguments
|
|
12
|
+
// run_command(git status) exactly that, nothing else
|
|
13
|
+
// edit_file(src/**) any path under src
|
|
14
|
+
// web_fetch(https://docs.*) any URL matching the glob
|
|
15
|
+
// read_file the whole tool, for the genuinely harmless ones
|
|
16
|
+
//
|
|
17
|
+
// Deny wins over allow, always, at any specificity. A deny rule is the only mechanism
|
|
18
|
+
// here that someone would rely on to stop something dangerous, and a precedence order
|
|
19
|
+
// that lets a broad allow override a narrow deny would make it worthless.
|
|
20
|
+
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** The argument a rule matches against, per tool. */
|
|
24
|
+
export function subjectOf(toolName, input = {}) {
|
|
25
|
+
switch (toolName) {
|
|
26
|
+
case 'run_command':
|
|
27
|
+
case 'background_run':
|
|
28
|
+
return String(input.command ?? '');
|
|
29
|
+
case 'write_file':
|
|
30
|
+
case 'edit_file':
|
|
31
|
+
case 'read_file':
|
|
32
|
+
case 'read_image':
|
|
33
|
+
case 'list_files':
|
|
34
|
+
return normalise(input.path);
|
|
35
|
+
case 'web_fetch':
|
|
36
|
+
return String(input.url ?? '');
|
|
37
|
+
case 'web_search':
|
|
38
|
+
return String(input.query ?? '');
|
|
39
|
+
default:
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const normalise = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
45
|
+
|
|
46
|
+
/** `tool(pattern)` or `tool`. Returns null for anything unparseable. */
|
|
47
|
+
export function parseRule(text) {
|
|
48
|
+
const raw = String(text ?? '').trim();
|
|
49
|
+
if (!raw) return null;
|
|
50
|
+
const m = /^([a-z_][\w]*)\s*(?:\(([\s\S]*)\))?$/i.exec(raw);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
return { tool: m[1], pattern: m[2] === undefined ? null : m[2].trim(), source: raw };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Glob match, with one extension that matters more than the rest of the syntax
|
|
57
|
+
* combined: a trailing `:*` means "this command and any arguments after it". Without
|
|
58
|
+
* it, approving `npm test` would not cover `npm test -- --watch`, and people would
|
|
59
|
+
* write `npm*` instead — which also matches `npm publish`.
|
|
60
|
+
*/
|
|
61
|
+
export function matches(pattern, subject) {
|
|
62
|
+
if (pattern === null || pattern === '') return true;
|
|
63
|
+
const value = String(subject ?? '');
|
|
64
|
+
|
|
65
|
+
if (pattern.endsWith(':*')) {
|
|
66
|
+
const prefix = pattern.slice(0, -2).trim();
|
|
67
|
+
if (!value.startsWith(prefix)) return false;
|
|
68
|
+
// A prefix must end at a word boundary: `git pu:*` must not match `git push`
|
|
69
|
+
// by accident of spelling, but `git push:*` must match `git push --force`.
|
|
70
|
+
const rest = value.slice(prefix.length);
|
|
71
|
+
return rest === '' || /^[\s\-]/.test(rest);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return globToRegExp(pattern).test(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cache = new Map();
|
|
78
|
+
function globToRegExp(pattern) {
|
|
79
|
+
if (cache.has(pattern)) return cache.get(pattern);
|
|
80
|
+
let out = '';
|
|
81
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
82
|
+
const ch = pattern[i];
|
|
83
|
+
if (ch === '*') {
|
|
84
|
+
// `**` crosses directory separators; a single `*` does not, so `edit_file(src/*)`
|
|
85
|
+
// covers src/a.js and not src/deep/a.js — which is what someone writing it means.
|
|
86
|
+
if (pattern[i + 1] === '*') { out += '.*'; i += 1; if (pattern[i + 1] === '/') i += 1; }
|
|
87
|
+
else out += '[^/]*';
|
|
88
|
+
} else if (ch === '?') out += '[^/]';
|
|
89
|
+
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
90
|
+
}
|
|
91
|
+
const re = new RegExp(`^${out}$`, 'i');
|
|
92
|
+
cache.set(pattern, re);
|
|
93
|
+
return re;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decide from a rule set. Returns 'deny', 'allow' or 'ask'.
|
|
98
|
+
*
|
|
99
|
+
* Both lists are scanned in full even after a match, because a deny anywhere has to
|
|
100
|
+
* beat an allow everywhere and short-circuiting on the first allow would lose that.
|
|
101
|
+
*/
|
|
102
|
+
export function decide({ allow = [], deny = [] }, toolName, input) {
|
|
103
|
+
const subject = subjectOf(toolName, input);
|
|
104
|
+
for (const rule of deny) {
|
|
105
|
+
const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
106
|
+
if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject)) return 'deny';
|
|
107
|
+
}
|
|
108
|
+
for (const rule of allow) {
|
|
109
|
+
const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
110
|
+
if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject)) return 'allow';
|
|
111
|
+
}
|
|
112
|
+
return 'ask';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The rule "Allow always" should create for this exact call.
|
|
117
|
+
*
|
|
118
|
+
* Narrow on purpose. For a shell command it is the executable and its first
|
|
119
|
+
* subcommand — `npm test:*`, `git status:*` — because that is the unit a person
|
|
120
|
+
* actually means when they say "yes, this is fine". For a path it is the file, not its
|
|
121
|
+
* directory: approving one edit is not approving the tree.
|
|
122
|
+
*/
|
|
123
|
+
export function ruleForCall(toolName, input, cwd) {
|
|
124
|
+
const subject = subjectOf(toolName, input);
|
|
125
|
+
if (!subject) return `${toolName}`;
|
|
126
|
+
|
|
127
|
+
if (toolName === 'run_command' || toolName === 'background_run') {
|
|
128
|
+
const words = subject.trim().split(/\s+/);
|
|
129
|
+
// A command with a shell operator in it cannot be generalised safely: `npm test &&
|
|
130
|
+
// rm -rf .` would become a rule that approves the rm. Pin those exactly.
|
|
131
|
+
if (/[;&|>`$(){}]/.test(subject)) return `${toolName}(${subject})`;
|
|
132
|
+
const head = words.slice(0, words[1] && !words[1].startsWith('-') ? 2 : 1).join(' ');
|
|
133
|
+
return `${toolName}(${head}:*)`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (toolName === 'web_fetch') {
|
|
137
|
+
try {
|
|
138
|
+
const url = new URL(subject);
|
|
139
|
+
return `${toolName}(${url.protocol}//${url.host}/**)`;
|
|
140
|
+
} catch { return `${toolName}(${subject})`; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const relative = cwd ? normalise(path.relative(cwd, path.resolve(cwd, subject))) : subject;
|
|
144
|
+
return `${toolName}(${relative && !relative.startsWith('..') ? relative : subject})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A broader rule, offered beside the exact one: the whole directory, or the tool. */
|
|
148
|
+
export function broaderRule(toolName, input, cwd) {
|
|
149
|
+
const subject = subjectOf(toolName, input);
|
|
150
|
+
if (['write_file', 'edit_file', 'read_file', 'read_image', 'list_files'].includes(toolName) && subject) {
|
|
151
|
+
const relative = cwd ? normalise(path.relative(cwd, path.resolve(cwd, subject))) : subject;
|
|
152
|
+
const dir = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '';
|
|
153
|
+
return dir && !dir.startsWith('..') ? `${toolName}(${dir}/**)` : `${toolName}`;
|
|
154
|
+
}
|
|
155
|
+
return `${toolName}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Rules that ship on by default.
|
|
160
|
+
*
|
|
161
|
+
* Deny-only. Nothing is pre-approved, because a default allow-list is a promise about
|
|
162
|
+
* someone else's repository that this code is in no position to make — but these are
|
|
163
|
+
* destructive in a way no plausible task needs, and a model that has talked itself into
|
|
164
|
+
* one of them should be stopped rather than asked.
|
|
165
|
+
*
|
|
166
|
+
* This is a tripwire, not a sandbox. It catches the handful of literal spellings that
|
|
167
|
+
* have destroyed real machines; it does not and cannot catch an equivalent written any
|
|
168
|
+
* other way, and nothing here should be read as making `bypassPermissions` safe.
|
|
169
|
+
*/
|
|
170
|
+
export const DEFAULT_DENY = [
|
|
171
|
+
'run_command(**rm -rf /)',
|
|
172
|
+
'run_command(**rm -rf / **)',
|
|
173
|
+
'run_command(**rm -rf /*)',
|
|
174
|
+
'run_command(**mkfs**)',
|
|
175
|
+
'run_command(**:(){ :|:& };:**)',
|
|
176
|
+
'run_command(**of=/dev/sd**)',
|
|
177
|
+
'run_command(**> /dev/sd**)',
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
/** Validate a list of rule strings, returning { rules, bad }. */
|
|
181
|
+
export function normaliseRules(list) {
|
|
182
|
+
const rules = [];
|
|
183
|
+
const bad = [];
|
|
184
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
185
|
+
const parsed = parseRule(entry);
|
|
186
|
+
if (parsed) rules.push(parsed.source);
|
|
187
|
+
else bad.push(String(entry));
|
|
188
|
+
}
|
|
189
|
+
return { rules, bad };
|
|
190
|
+
}
|