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.
@@ -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
- if (!process.stdin.isTTY) {
104
- throw new PermissionDenied(
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, but there is no terminal to ask. Re-run with --yes to allow it.`
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
- if (answer === 'y' || answer === '\r') return true;
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
- 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);
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) {
@@ -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
- const value = String(subject ?? '');
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
- return rest === '' || /^[\s\-]/.test(rest);
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 subject = subjectOf(toolName, input);
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, subject)) return 'deny';
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 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}:*)`;
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 }. */