jaitus 0.3.3 → 0.3.5
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 +34 -38
- package/bin/jaitus.js +14 -1
- package/package.json +2 -2
- package/src/agent/loop.js +234 -35
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/commands/update.js +78 -12
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +571 -119
- package/src/mcp.js +209 -72
- package/src/pendingUpdate.js +41 -0
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +161 -0
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui/widgets.js +6 -2
- package/src/tui-ink.js +2509 -1998
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/agent/permissions.js
CHANGED
|
@@ -32,7 +32,7 @@ const TOOL_ICONS = {
|
|
|
32
32
|
|
|
33
33
|
export function createPermissions({
|
|
34
34
|
autoApprove = false, mode = 'default', onModeChange = null,
|
|
35
|
-
allow = [], deny = [], onRulesChange = null,
|
|
35
|
+
allow = [], deny = [], onRulesChange = null, interactive = true,
|
|
36
36
|
} = {}) {
|
|
37
37
|
let current = autoApprove ? 'bypassPermissions' : (MODES.includes(mode) ? mode : mode === 'auto' ? 'bypassPermissions' : 'default');
|
|
38
38
|
// Session rules live beside the saved ones and are never written to disk: "allow for
|
|
@@ -62,8 +62,23 @@ export function createPermissions({
|
|
|
62
62
|
},
|
|
63
63
|
|
|
64
64
|
/** What the rules say about this call, without asking. */
|
|
65
|
-
verdict(toolName, input) {
|
|
66
|
-
return decide({ allow: [...savedAllow, ...sessionAllow], deny: savedDeny }, toolName, input);
|
|
65
|
+
verdict(toolName, input, cwd = null) {
|
|
66
|
+
return decide({ allow: [...savedAllow, ...sessionAllow], deny: savedDeny }, toolName, input, { cwd });
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The same rules, a different mode — for subagents and side commands (/review --fix,
|
|
71
|
+
* /btw, /test --fix). Those used to build fresh permissions with no user rules at
|
|
72
|
+
* all, so `deny run_command(git push:*)` did not apply inside them.
|
|
73
|
+
*/
|
|
74
|
+
fork({ mode: nextMode = current, interactive: canAsk = interactive, allow: extraAllow = [] } = {}) {
|
|
75
|
+
return createPermissions({
|
|
76
|
+
mode: nextMode,
|
|
77
|
+
interactive: canAsk,
|
|
78
|
+
// Extra rules for this run only — /research allows web_fetch for itself.
|
|
79
|
+
allow: [...savedAllow, ...sessionAllow, ...extraAllow],
|
|
80
|
+
deny: savedDeny.filter((r) => !DEFAULT_DENY.includes(r)),
|
|
81
|
+
});
|
|
67
82
|
},
|
|
68
83
|
|
|
69
84
|
setMode(next) {
|
|
@@ -82,11 +97,11 @@ export function createPermissions({
|
|
|
82
97
|
/**
|
|
83
98
|
* @returns true to proceed. Throws PermissionDenied if the user says no.
|
|
84
99
|
*/
|
|
85
|
-
async request(tool, input, { cwd, tui = null } = {}) {
|
|
100
|
+
async request(tool, input, { cwd, tui = null, signal = null } = {}) {
|
|
86
101
|
// A deny rule outranks everything, including bypass. Someone who wrote
|
|
87
102
|
// `deny: ["run_command(git push:*)"]` and then turned on bypass to get through a
|
|
88
103
|
// long refactor did not mean "except that one".
|
|
89
|
-
const verdict = api.verdict(tool.name, input);
|
|
104
|
+
const verdict = api.verdict(tool.name, input, cwd);
|
|
90
105
|
if (verdict === 'deny') {
|
|
91
106
|
throw new PermissionDenied(
|
|
92
107
|
`A deny rule blocks ${tool.name}${subjectOf(tool.name, input) ? `: ${subjectOf(tool.name, input).slice(0, 80)}` : ''}. `
|
|
@@ -100,12 +115,17 @@ export function createPermissions({
|
|
|
100
115
|
|
|
101
116
|
// Not a terminal — a script cannot answer, and silently proceeding would be the
|
|
102
117
|
// worst possible reading of "no answer". In plan mode the same wall is the point.
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
// One-shot runs (-p, a task on the command line) are this case too even in a
|
|
119
|
+
// terminal: a prompt there wrote to stdout — into --output-format json — and waited
|
|
120
|
+
// for a key nobody saw.
|
|
121
|
+
if (!process.stdin.isTTY || !interactive) {
|
|
122
|
+
const err = new PermissionDenied(
|
|
105
123
|
current === 'plan'
|
|
106
124
|
? '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,
|
|
125
|
+
: `${tool.name} needs permission, and this run cannot ask. Re-run with --yes, or --permission-mode acceptEdits for file edits only.`
|
|
108
126
|
);
|
|
127
|
+
err.headless = true;
|
|
128
|
+
throw err;
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
// Inside the TUI the ask is an inline panel pinned above the prompt
|
|
@@ -133,6 +153,7 @@ export function createPermissions({
|
|
|
133
153
|
pills: true,
|
|
134
154
|
title: 'Permission',
|
|
135
155
|
tone: tool.name === 'run_command' ? 'danger' : undefined,
|
|
156
|
+
signal,
|
|
136
157
|
});
|
|
137
158
|
const value = answer && typeof answer === 'object' ? answer.value : answer;
|
|
138
159
|
const comment = (answer?.comment || '').trim();
|
|
@@ -151,14 +172,16 @@ export function createPermissions({
|
|
|
151
172
|
|
|
152
173
|
out(` ${c.dim(`always → adds the rule ${c.bold(exact)}`)}`);
|
|
153
174
|
const answer = await askKey(
|
|
154
|
-
` ${c.dim('[y] Allow once [s] this session [a] always [n] Reject ›')} `,
|
|
175
|
+
` ${c.dim('[y] Allow once [s] this session [a] always [n] Reject (Enter) ›')} `,
|
|
155
176
|
['y', 's', 'a', 'n', '\r']
|
|
156
177
|
);
|
|
157
178
|
out('');
|
|
158
179
|
|
|
159
180
|
if (answer === 's') { api.addRule(exact); return true; }
|
|
160
181
|
if (answer === 'a') { api.addRule(exact, { persist: true }); return true; }
|
|
161
|
-
|
|
182
|
+
// Enter is the safe answer. It used to be "Allow once" — one stray key approved a
|
|
183
|
+
// command nobody had read.
|
|
184
|
+
if (answer === 'y') return true;
|
|
162
185
|
throw new PermissionDenied(`You declined: ${tool.name}`);
|
|
163
186
|
},
|
|
164
187
|
};
|
|
@@ -176,13 +199,23 @@ function describeRule(rule) {
|
|
|
176
199
|
/** The whole tool call as plain lines, for the TUI permission dialog. */
|
|
177
200
|
function previewTextOf(tool, input) {
|
|
178
201
|
const lines = [];
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
202
|
+
const more = (all, shown) => (all > shown ? [`… ${all - shown} more line${all - shown === 1 ? '' : 's'}`] : []);
|
|
203
|
+
// A command is shown WHOLE — every line of it (the dialog wraps and, if it must, shows
|
|
204
|
+
// the start and the end). Content previews are samples, and say how much is left out.
|
|
205
|
+
if (tool.name === 'run_command' || tool.name === 'background_run') lines.push(...`$ ${input.command}`.split('\n'));
|
|
206
|
+
else if (tool.name === 'write_file') {
|
|
207
|
+
const all = String(input.content ?? '').split('\n');
|
|
208
|
+
lines.push(...all.slice(0, 12), ...more(all.length, 12));
|
|
209
|
+
} else if (tool.name === 'edit_file') {
|
|
210
|
+
const olds = String(input.old ?? '').split('\n');
|
|
211
|
+
const news = String(input.new ?? '').split('\n');
|
|
212
|
+
for (const l of olds.slice(0, 6)) lines.push(`- ${l}`);
|
|
213
|
+
lines.push(...more(olds.length, 6).map((l) => `- ${l}`));
|
|
214
|
+
for (const l of news.slice(0, 6)) lines.push(`+ ${l}`);
|
|
215
|
+
lines.push(...more(news.length, 6).map((l) => `+ ${l}`));
|
|
216
|
+
} else if (tool.name === 'web_fetch') lines.push(String(input.url ?? ''));
|
|
217
|
+
else lines.push(JSON.stringify(input).slice(0, 400));
|
|
218
|
+
return lines;
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
function describe(tool, input, cwd) {
|
package/src/agent/rules.js
CHANGED
|
@@ -58,22 +58,52 @@ export function parseRule(text) {
|
|
|
58
58
|
* it, approving `npm test` would not cover `npm test -- --watch`, and people would
|
|
59
59
|
* write `npm*` instead — which also matches `npm publish`.
|
|
60
60
|
*/
|
|
61
|
-
export function matches(pattern, subject) {
|
|
61
|
+
export function matches(pattern, subject, { command = false } = {}) {
|
|
62
62
|
if (pattern === null || pattern === '') return true;
|
|
63
|
-
|
|
63
|
+
// Runs of spaces are one space: `git push` is `git push`.
|
|
64
|
+
const value = command ? squash(subject) : String(subject ?? '');
|
|
64
65
|
|
|
65
66
|
if (pattern.endsWith(':*')) {
|
|
66
|
-
const prefix = pattern.slice(0, -2).trim();
|
|
67
|
+
const prefix = (command ? squash(pattern.slice(0, -2)) : pattern.slice(0, -2).trim());
|
|
67
68
|
if (!value.startsWith(prefix)) return false;
|
|
68
69
|
// A prefix must end at a word boundary: `git pu:*` must not match `git push`
|
|
69
70
|
// by accident of spelling, but `git push:*` must match `git push --force`.
|
|
70
71
|
const rest = value.slice(prefix.length);
|
|
71
|
-
|
|
72
|
+
if (!(rest === '' || /^[\s\-]/.test(rest))) return false;
|
|
73
|
+
// "This command and its arguments" — never "this command, then anything". The rest
|
|
74
|
+
// of the line may not chain, pipe or redirect: `npm test:*` used to approve
|
|
75
|
+
// `npm test && del /s /q C:\` because only the first character after the prefix
|
|
76
|
+
// was looked at.
|
|
77
|
+
// Checked on the RAW line: squashing whitespace above turns a newline — which runs a
|
|
78
|
+
// second command — into a harmless-looking space.
|
|
79
|
+
if (command && SHELL_META.test(String(subject ?? '')) && !SHELL_META.test(prefix)) return false;
|
|
80
|
+
return true;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
return globToRegExp(pattern).test(value);
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Characters that make one shell line run more than the command it starts with — in
|
|
88
|
+
* cmd, PowerShell and POSIX shells: chaining, pipes, redirects, substitution, grouping,
|
|
89
|
+
* and a line break.
|
|
90
|
+
*/
|
|
91
|
+
export const SHELL_META = /[;&|<>`$(){}\r\n]/;
|
|
92
|
+
|
|
93
|
+
const squash = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The separate commands in one shell line, for DENY checks. A deny rule is anchored to
|
|
97
|
+
* the start of a command, so `deny git push:*` used to miss `cd . && git push --force` —
|
|
98
|
+
* the push is simply the second command. Each piece is checked on its own.
|
|
99
|
+
*/
|
|
100
|
+
export function commandsIn(line) {
|
|
101
|
+
return String(line ?? '')
|
|
102
|
+
.split(/&&|\|\||[;&|\r\n]/)
|
|
103
|
+
.map((part) => squash(part).replace(/^[({\s]+|[)}\s]+$/g, ''))
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
const cache = new Map();
|
|
78
108
|
function globToRegExp(pattern) {
|
|
79
109
|
if (cache.has(pattern)) return cache.get(pattern);
|
|
@@ -99,19 +129,43 @@ function globToRegExp(pattern) {
|
|
|
99
129
|
* Both lists are scanned in full even after a match, because a deny anywhere has to
|
|
100
130
|
* beat an allow everywhere and short-circuiting on the first allow would lose that.
|
|
101
131
|
*/
|
|
102
|
-
export function decide({ allow = [], deny = [] }, toolName, input) {
|
|
103
|
-
const
|
|
132
|
+
export function decide({ allow = [], deny = [] }, toolName, input, { cwd = null } = {}) {
|
|
133
|
+
const command = COMMAND_TOOLS.has(toolName);
|
|
134
|
+
let subject = subjectOf(toolName, input);
|
|
135
|
+
// A path rule is written relative to the project; the model often sends an absolute
|
|
136
|
+
// path. Compared raw, `deny edit_file(src/**)` did not stop `C:/p/src/a.js`.
|
|
137
|
+
if (cwd && PATH_TOOLS.has(toolName) && subject) {
|
|
138
|
+
const rel = normalise(path.relative(cwd, path.resolve(cwd, subject)));
|
|
139
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) subject = rel;
|
|
140
|
+
}
|
|
141
|
+
// Deny: the whole line, and each command inside it.
|
|
142
|
+
const denySubjects = command ? [subject, ...commandsIn(subject)] : [subject];
|
|
104
143
|
for (const rule of deny) {
|
|
105
144
|
const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
106
|
-
if (parsed && parsed.tool === toolName && matches(parsed.pattern,
|
|
145
|
+
if (parsed && parsed.tool === toolName && denySubjects.some((s) => matches(parsed.pattern, s, { command }))) return 'deny';
|
|
107
146
|
}
|
|
108
147
|
for (const rule of allow) {
|
|
109
148
|
const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
110
|
-
if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject)) return 'allow';
|
|
149
|
+
if (parsed && parsed.tool === toolName && matches(parsed.pattern, subject, { command })) return 'allow';
|
|
111
150
|
}
|
|
112
151
|
return 'ask';
|
|
113
152
|
}
|
|
114
153
|
|
|
154
|
+
const COMMAND_TOOLS = new Set(['run_command', 'background_run']);
|
|
155
|
+
const PATH_TOOLS = new Set(['write_file', 'edit_file', 'read_file', 'read_image', 'list_files']);
|
|
156
|
+
|
|
157
|
+
// Programs whose "first argument" is arbitrary code or another program. Generalised,
|
|
158
|
+
// `node -e x` becomes `node:*` and `cmd /c dir` becomes `cmd /c:*` — every command there
|
|
159
|
+
// is. These are always pinned to the exact line approved.
|
|
160
|
+
const RUNS_ANYTHING = new Set([
|
|
161
|
+
'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', 'bash', 'sh', 'zsh', 'fish', 'dash',
|
|
162
|
+
'node', 'node.exe', 'python', 'python3', 'py', 'ruby', 'perl', 'php', 'deno', 'bun', 'npx', 'bunx', 'pnpx',
|
|
163
|
+
'sudo', 'doas', 'env', 'xargs', 'eval', 'exec', 'start', 'call', 'wsl', 'wsl.exe', 'ssh', 'nohup', 'time', 'watch',
|
|
164
|
+
]);
|
|
165
|
+
// `npm run <script>` names the script as its third word; `npm run:*` would approve every
|
|
166
|
+
// script in package.json.
|
|
167
|
+
const RUNNER_VERBS = new Set(['run', 'run-script', 'exec', 'x', 'dlx']);
|
|
168
|
+
|
|
115
169
|
/**
|
|
116
170
|
* The rule "Allow always" should create for this exact call.
|
|
117
171
|
*
|
|
@@ -125,12 +179,20 @@ export function ruleForCall(toolName, input, cwd) {
|
|
|
125
179
|
if (!subject) return `${toolName}`;
|
|
126
180
|
|
|
127
181
|
if (toolName === 'run_command' || toolName === 'background_run') {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
182
|
+
const line = squash(subject);
|
|
183
|
+
const words = line.split(' ');
|
|
184
|
+
const exe = path.basename(words[0].replace(/^["']|["']$/g, '')).toLowerCase();
|
|
185
|
+
// Pinned exactly, never generalised:
|
|
186
|
+
// - a shell operator: `npm test && rm -rf .` must not become `npm test:*`;
|
|
187
|
+
// - an interpreter or launcher: its argument is the whole program;
|
|
188
|
+
// - a flag before the subcommand: `git -C sub status` generalised to `git:*`,
|
|
189
|
+
// which includes `git push`.
|
|
190
|
+
if (SHELL_META.test(line) || RUNS_ANYTHING.has(exe) || (words[1] && words[1].startsWith('-'))) {
|
|
191
|
+
return `${toolName}(${line})`;
|
|
192
|
+
}
|
|
193
|
+
let take = words[1] ? 2 : 1;
|
|
194
|
+
if (RUNNER_VERBS.has(String(words[1]).toLowerCase()) && words[2] && !words[2].startsWith('-')) take = 3;
|
|
195
|
+
return `${toolName}(${words.slice(0, take).join(' ')}:*)`;
|
|
134
196
|
}
|
|
135
197
|
|
|
136
198
|
if (toolName === 'web_fetch') {
|
|
@@ -175,6 +237,15 @@ export const DEFAULT_DENY = [
|
|
|
175
237
|
'run_command(**:(){ :|:& };:**)',
|
|
176
238
|
'run_command(**of=/dev/sd**)',
|
|
177
239
|
'run_command(**> /dev/sd**)',
|
|
240
|
+
// The Windows spellings of the same accidents — the list had none.
|
|
241
|
+
'run_command(**rd /s /q C:\\)',
|
|
242
|
+
'run_command(**rd /s /q C:\\ **)',
|
|
243
|
+
'run_command(**rmdir /s /q C:\\)',
|
|
244
|
+
'run_command(**del /s /q C:\\)',
|
|
245
|
+
'run_command(**del /s /q C:\\*)',
|
|
246
|
+
'run_command(**format C:**)',
|
|
247
|
+
'run_command(**Remove-Item -Recurse -Force C:\\)',
|
|
248
|
+
'run_command(**Remove-Item C:\\ -Recurse**)',
|
|
178
249
|
];
|
|
179
250
|
|
|
180
251
|
/** Validate a list of rule strings, returning { rules, bad }. */
|