@thegitai/cli 1.0.0-preview.15 → 1.0.0-preview.17

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.
@@ -46,7 +46,7 @@ export function nextAgentMode(mode) {
46
46
  export function agentModeAllowsTool(mode, toolName) {
47
47
  return mode !== 'plan' || PLAN_MODE_TOOL_NAMES.has(toolName);
48
48
  }
49
- function getUnquotedShellText(command) {
49
+ export function getUnquotedShellText(command) {
50
50
  let quote = null;
51
51
  let escaped = false;
52
52
  let text = '';
@@ -248,6 +248,26 @@ async function postUserInputResult({ config, turnId, requestId, result, fetchImp
248
248
  throw await readErrorResponse(response, trace.traceId);
249
249
  }
250
250
  }
251
+ export async function postInterjection({ config, turnId, text, messageId, fetchImpl = globalThis.fetch, traceId, }) {
252
+ const payload = { text, messageId };
253
+ const trace = createTraceContext(traceId);
254
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
255
+ method: 'POST',
256
+ headers: {
257
+ authorization: `Bearer ${config.token}`,
258
+ 'content-type': 'application/json',
259
+ ...trace.headers,
260
+ },
261
+ body: JSON.stringify(payload),
262
+ });
263
+ if (response.status === 410) {
264
+ return 'stale';
265
+ }
266
+ if (!response.ok) {
267
+ throw await readErrorResponse(response, trace.traceId);
268
+ }
269
+ return 'delivered';
270
+ }
251
271
  const turnIdOverrides = new WeakMap();
252
272
  function enterServerTurnId(session, serverSessionTurnId) {
253
273
  const active = turnIdOverrides.get(session);
@@ -309,7 +329,7 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
309
329
  }
310
330
  }
311
331
  }
312
- async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
332
+ async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, onTurnStart, onInterjectionDelivered, }) {
313
333
  if (!response.body) {
314
334
  throw new Error('Server returned an empty chat stream.');
315
335
  }
@@ -345,6 +365,16 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
345
365
  }
346
366
  }
347
367
  async function handleEvent(event) {
368
+ if (event.event === 'turn-start') {
369
+ const turnId = String(event.data?.turnId ?? '').trim();
370
+ if (turnId)
371
+ onTurnStart?.(turnId);
372
+ return;
373
+ }
374
+ if (event.event === 'interjection-delivered') {
375
+ onInterjectionDelivered?.(event.data);
376
+ return;
377
+ }
348
378
  if (event.event === 'status') {
349
379
  const data = event.data;
350
380
  if (data?.phase === 'analyzing_image') {
@@ -478,7 +508,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
478
508
  }
479
509
  return finalResult.current;
480
510
  }
481
- export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
511
+ export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, }) {
482
512
  const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
483
513
  const requestImageAttachments = autoAttach.attachments.length > 0
484
514
  ? [...imageAttachments, ...autoAttach.attachments]
@@ -535,6 +565,8 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
535
565
  fetchImpl,
536
566
  signal,
537
567
  traceId: trace.traceId,
568
+ onTurnStart,
569
+ onInterjectionDelivered,
538
570
  });
539
571
  applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
540
572
  return {
@@ -47,7 +47,9 @@ const HELP_MARKDOWN = [
47
47
  '',
48
48
  '## Modes',
49
49
  '',
50
- '- Default — asks before shell commands and file edits.',
50
+ '- Default — asks before creating, editing or deleting files and before',
51
+ ' running commands. Each answer can be remembered for the rest of the',
52
+ ' session; see Safety & approvals.',
51
53
  '- Auto-Accept — approves shell commands and file edits for the session.',
52
54
  '- Plan — read-only; the agent can inspect, ask typed questions, and plan,',
53
55
  ' with file-reading shell commands only. It will not edit files or run tests,',
@@ -59,6 +61,13 @@ const HELP_MARKDOWN = [
59
61
  '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
60
62
  ' **Ctrl+C** clears the composer or the queued message, and quits once there',
61
63
  ' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
64
+ '- **While the agent is working**, Enter queues your message and locks the',
65
+ ' composer. Press **Enter** again to send it into the running turn: the agent',
66
+ ' picks it up at its next step instead of waiting for the turn to finish, and',
67
+ ' the Your messages panel tracks it from queued to delivered. **↑** brings it',
68
+ ' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
69
+ ' composer for the next message. Messages with images cannot be sent mid-turn',
70
+ ' and are sent with the next prompt instead.',
62
71
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
63
72
  ' on this system) or by right-clicking the composer.',
64
73
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -84,9 +93,28 @@ const HELP_MARKDOWN = [
84
93
  '',
85
94
  '## Safety & approvals',
86
95
  '',
87
- '- TheGitAI asks before running shell commands or applying file edits.',
88
- '- At each prompt: **y** approves once, **a** approves the rest of the',
89
- ' session, **n** denies.',
96
+ '- In Default mode TheGitAI asks before it creates a file, edits an existing',
97
+ ' file, deletes a file, or runs a command. These are four separate',
98
+ ' permissions: allowing edits does not allow deletions, and allowing file',
99
+ ' changes does not allow commands.',
100
+ '- At each prompt: **↑/↓** moves between choices, **Enter** confirms the',
101
+ ' highlighted one, and **Esc** denies. Deny is selected by default.',
102
+ ' Single-letter shortcuts were removed on purpose: a prompt can appear',
103
+ ' while you are typing, and a stray letter must never approve anything.',
104
+ '- Every prompt offers Approve once, an "Always allow" for that kind of',
105
+ ' action, and Deny. A command also offers to remember just its prefix — for',
106
+ ' example approving `npm test -- --watch=false` can allow `npm test`.',
107
+ '- A prefix is only offered when the command shows a plain verb, as in',
108
+ ' `npm test` or `git status`. Commands with no verb (`ls -la`), interpreters',
109
+ ' and wrappers (`bash -c`, `python -c`, `env`, `timeout`, `make`), and',
110
+ ' destructive or network binaries (`rm`, `sudo`, `curl`) are never offered',
111
+ ' one — a bare binary grant would mean "anything this program can do".',
112
+ ' `npm run` must name its script, so the grant is `npm run build`.',
113
+ '- A remembered prefix never covers a command that chains, pipes, redirects,',
114
+ ' or substitutes, so allowing `npm test` can never green-light',
115
+ ' `npm test && rm -rf build`.',
116
+ '- Everything you allow lasts for the current session only and is forgotten',
117
+ ' when it ends. `/new` clears it immediately.',
90
118
  '- If an approved `sudo` command needs a password, the TUI shows the exact',
91
119
  ' command and keeps the password masked and local.',
92
120
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
@@ -0,0 +1,243 @@
1
+ import { getUnquotedShellText } from './agent-mode.js';
2
+ export const PERMISSION_BUCKETS = ['create', 'edit', 'delete', 'run'];
3
+ export function createSessionGrants() {
4
+ return { buckets: [], commandPrefixes: [] };
5
+ }
6
+ export function bucketActionLabel(bucket) {
7
+ if (bucket === 'create')
8
+ return 'create files';
9
+ if (bucket === 'edit')
10
+ return 'edit files';
11
+ if (bucket === 'delete')
12
+ return 'delete files';
13
+ return 'run commands';
14
+ }
15
+ const NO_PREFIX_GRANT_BINARIES = new Set([
16
+ 'rm',
17
+ 'rmdir',
18
+ 'mv',
19
+ 'dd',
20
+ 'mkfs',
21
+ 'shred',
22
+ 'sudo',
23
+ 'doas',
24
+ 'su',
25
+ 'chmod',
26
+ 'chown',
27
+ 'chgrp',
28
+ 'kill',
29
+ 'pkill',
30
+ 'killall',
31
+ 'shutdown',
32
+ 'reboot',
33
+ 'curl',
34
+ 'wget',
35
+ 'ssh',
36
+ 'scp',
37
+ 'nc',
38
+ 'eval',
39
+ 'exec',
40
+ 'source',
41
+ ]);
42
+ const INTERPRETER_OR_WRAPPER_BINARIES = new Set([
43
+ 'bash',
44
+ 'sh',
45
+ 'zsh',
46
+ 'fish',
47
+ 'dash',
48
+ 'ksh',
49
+ 'csh',
50
+ 'tcsh',
51
+ 'python',
52
+ 'python2',
53
+ 'python3',
54
+ 'node',
55
+ 'deno',
56
+ 'bun',
57
+ 'ruby',
58
+ 'perl',
59
+ 'php',
60
+ 'lua',
61
+ 'osascript',
62
+ 'awk',
63
+ 'gawk',
64
+ 'sed',
65
+ 'env',
66
+ 'timeout',
67
+ 'xargs',
68
+ 'nohup',
69
+ 'setsid',
70
+ 'watch',
71
+ 'script',
72
+ 'nice',
73
+ 'ionice',
74
+ 'stdbuf',
75
+ 'time',
76
+ 'sudo',
77
+ 'doas',
78
+ 'su',
79
+ 'find',
80
+ 'make',
81
+ ]);
82
+ const SCRIPT_RUNNER_VERBS = new Set(['run', 'exec', 'run-script', 'x', 'dlx']);
83
+ const VERB_PATTERN = /^[A-Za-z][A-Za-z0-9_:-]*$/;
84
+ const BINARY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
85
+ const SHELL_CONTROL_OPERATOR_PATTERN = /[&|;<>\n]/;
86
+ const COMMAND_SUBSTITUTION_PATTERN = /`|\$\(/;
87
+ function normalizeCommand(command) {
88
+ return String(command ?? '').trim().replace(/\s+/g, ' ');
89
+ }
90
+ function parseCommand(command) {
91
+ const raw = String(command ?? '');
92
+ if (!raw.trim())
93
+ return null;
94
+ if (COMMAND_SUBSTITUTION_PATTERN.test(raw))
95
+ return null;
96
+ if (SHELL_CONTROL_OPERATOR_PATTERN.test(getUnquotedShellText(raw)))
97
+ return null;
98
+ const all = normalizeCommand(raw).match(/\S+/g) ?? [];
99
+ const tokens = [];
100
+ for (const token of all) {
101
+ if (!tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
102
+ continue;
103
+ if (!tokens.length && token === 'command')
104
+ continue;
105
+ tokens.push(token);
106
+ }
107
+ if (!tokens.length)
108
+ return null;
109
+ const binary = (tokens[0].split('/').pop() ?? '').trim();
110
+ if (!binary)
111
+ return null;
112
+ tokens[0] = binary;
113
+ return { canonical: tokens.join(' '), tokens };
114
+ }
115
+ function grantPrefixFromTokens(tokens) {
116
+ const binary = tokens[0];
117
+ if (!BINARY_PATTERN.test(binary))
118
+ return null;
119
+ if (NO_PREFIX_GRANT_BINARIES.has(binary))
120
+ return null;
121
+ if (INTERPRETER_OR_WRAPPER_BINARIES.has(binary))
122
+ return null;
123
+ const verb = tokens[1];
124
+ if (!verb || !VERB_PATTERN.test(verb))
125
+ return null;
126
+ if (SCRIPT_RUNNER_VERBS.has(verb)) {
127
+ const script = tokens[2];
128
+ if (!script || !VERB_PATTERN.test(script))
129
+ return null;
130
+ return `${binary} ${verb} ${script}`;
131
+ }
132
+ return `${binary} ${verb}`;
133
+ }
134
+ export function commandGrantPrefix(command) {
135
+ const parsed = parseCommand(command);
136
+ return parsed ? grantPrefixFromTokens(parsed.tokens) : null;
137
+ }
138
+ export function isBucketGranted(grants, bucket) {
139
+ return Boolean(grants?.buckets.includes(bucket));
140
+ }
141
+ export function isCommandGranted(grants, command) {
142
+ if (!grants)
143
+ return false;
144
+ if (grants.buckets.includes('run'))
145
+ return true;
146
+ if (!grants.commandPrefixes.length)
147
+ return false;
148
+ const parsed = parseCommand(command);
149
+ if (!parsed)
150
+ return false;
151
+ if (grantPrefixFromTokens(parsed.tokens) === null)
152
+ return false;
153
+ return grants.commandPrefixes.some((prefix) => parsed.canonical === prefix || parsed.canonical.startsWith(`${prefix} `));
154
+ }
155
+ export function grantBucket(grants, bucket) {
156
+ if (!grants.buckets.includes(bucket))
157
+ grants.buckets.push(bucket);
158
+ }
159
+ export function grantCommandPrefix(grants, prefix) {
160
+ const normalized = normalizeCommand(prefix);
161
+ if (!normalized)
162
+ return;
163
+ if (!grants.commandPrefixes.includes(normalized)) {
164
+ grants.commandPrefixes.push(normalized);
165
+ }
166
+ }
167
+ export function buildPermissionOptions(bucket, command) {
168
+ const options = [
169
+ { label: 'Approve once', decision: { kind: 'once' } },
170
+ ];
171
+ const prefix = bucket === 'run' && command ? commandGrantPrefix(command) : null;
172
+ if (prefix) {
173
+ options.push({
174
+ label: `Always allow: ${prefix}`,
175
+ decision: { kind: 'always-prefix', prefix },
176
+ });
177
+ }
178
+ options.push({
179
+ label: `Always allow: ${bucketActionLabel(bucket)}`,
180
+ decision: { kind: 'always-bucket' },
181
+ });
182
+ options.push({ label: 'Deny', decision: { kind: 'deny' } });
183
+ return options;
184
+ }
185
+ function declinedSubject(bucket) {
186
+ if (bucket === 'run') {
187
+ return { noun: 'command', effect: 'Nothing was executed' };
188
+ }
189
+ if (bucket === 'create') {
190
+ return { noun: 'file creation', effect: 'Nothing was created' };
191
+ }
192
+ if (bucket === 'delete') {
193
+ return { noun: 'file deletion', effect: 'Nothing was deleted' };
194
+ }
195
+ return { noun: 'edit', effect: 'Nothing was changed' };
196
+ }
197
+ function declinedResponse(bucket, toolName, extra) {
198
+ const { noun, effect } = declinedSubject(bucket);
199
+ return {
200
+ ok: false,
201
+ skipped: true,
202
+ ...extra,
203
+ failureCategory: 'user_declined',
204
+ failureDetails: {
205
+ category: 'user_declined',
206
+ tool: toolName,
207
+ action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
208
+ },
209
+ error: `The real user rejected this proposed ${noun}. ${effect}; this was not a tool failure or an automated system skip.`,
210
+ };
211
+ }
212
+ export async function ensurePermission(context, request, toolName, extra = {}) {
213
+ if (context.autoYes)
214
+ return null;
215
+ if (isBucketGranted(context.grants, request.bucket))
216
+ return null;
217
+ if (request.bucket === 'run' &&
218
+ request.command &&
219
+ isCommandGranted(context.grants, request.command)) {
220
+ return null;
221
+ }
222
+ if (!context.requestPermission) {
223
+ return {
224
+ ok: false,
225
+ ...extra,
226
+ error: `requestPermission is required when autoYes is false (${toolName})`,
227
+ };
228
+ }
229
+ const options = buildPermissionOptions(request.bucket, request.command);
230
+ const decision = await context.requestPermission({ ...request, options });
231
+ if (decision.kind === 'deny') {
232
+ return declinedResponse(request.bucket, toolName, extra);
233
+ }
234
+ if (context.grants) {
235
+ if (decision.kind === 'always-bucket') {
236
+ grantBucket(context.grants, request.bucket);
237
+ }
238
+ else if (decision.kind === 'always-prefix') {
239
+ grantCommandPrefix(context.grants, decision.prefix);
240
+ }
241
+ }
242
+ return null;
243
+ }
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
4
4
  import path from 'node:path';
5
5
  import { getClientStateDir } from './client-state.js';
6
6
  import { normalizeAssistantEditJournal } from './edit-journal.js';
7
+ import { createSessionGrants } from './permissions.js';
7
8
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
8
9
  import { singleLinePreview } from './utils.js';
9
10
  const SESSION_STORE_VERSION = 1;
@@ -211,6 +212,7 @@ export function saveSessionState(session, env = process.env) {
211
212
  }
212
213
  export function applySessionSnapshot(session, snapshot, options = {}) {
213
214
  const currentAgentMode = session.agentMode;
215
+ const previousSessionId = session.sessionId;
214
216
  session.sessionId = snapshot.id;
215
217
  session.sessionName = snapshot.name;
216
218
  session.sessionCreatedAt = snapshot.createdAt;
@@ -220,6 +222,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
220
222
  session.serverState = sanitizeOpaqueState(snapshot.serverState);
221
223
  session.agentMode = options.preserveAgentMode ? currentAgentMode : 'default';
222
224
  session.autoYes = session.agentMode === 'auto-accept';
225
+ if (previousSessionId !== session.sessionId) {
226
+ session.grants = createSessionGrants();
227
+ }
223
228
  session.turnState = {
224
229
  id: null,
225
230
  historyStartIndex: session.history.length,
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { normalizeAgentMode, } from './agent-mode.js';
3
+ import { createSessionGrants, } from './permissions.js';
3
4
  import { createSessionSafetyState, } from './session-safety.js';
4
5
  import { clampInteger } from './utils.js';
5
6
  const DEFAULT_MAX_TOOL_STEPS = 32;
@@ -31,7 +32,7 @@ function preserveProviderSelection(serverState) {
31
32
  : null;
32
33
  return providerSelection ? { providerSelection } : {};
33
34
  }
34
- export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, confirmCommand = null, confirmPatch = null, requestSudoPassword = null, requestUserInput = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
+ export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, requestPermission = null, requestSudoPassword = null, requestUserInput = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
36
  const createdAt = new Date().toISOString();
36
37
  const initialAgentMode = normalizeAgentMode(agentMode ?? (autoYes ? 'auto-accept' : 'default'));
37
38
  return {
@@ -43,8 +44,8 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
43
44
  onStatus: onStatus ?? defaultStatus,
44
45
  onContextLog: onContextLog ?? defaultContextLog,
45
46
  onToolEvent,
46
- confirmCommand,
47
- confirmPatch,
47
+ grants: createSessionGrants(),
48
+ requestPermission,
48
49
  requestSudoPassword,
49
50
  requestUserInput,
50
51
  history: JSON.parse(JSON.stringify(history)),
@@ -80,6 +81,7 @@ export function startNewConversation(session) {
80
81
  }
81
82
  export function clearConversation(session) {
82
83
  session.history = [];
84
+ session.grants = createSessionGrants();
83
85
  session.serverState = preserveProviderSelection(session.serverState);
84
86
  session.turnState = {
85
87
  id: null,
@@ -277,8 +277,8 @@ export async function executeLocalToolCall(toolContext, session, call) {
277
277
  sessionId: session.sessionId,
278
278
  projectIndex: toolContext.projectIndex,
279
279
  autoYes: session.autoYes,
280
- confirmCommand: session.confirmCommand,
281
- confirmPatch: session.confirmPatch,
280
+ grants: session.grants,
281
+ requestPermission: session.requestPermission,
282
282
  requestSudoPassword: session.requestSudoPassword,
283
283
  onStatus: session.onStatus,
284
284
  editJournal: session.clientState.editJournal,
@@ -3,6 +3,7 @@ import { classifyProjectPath, deleteProjectFile } from '../patcher.js';
3
3
  import { isTuiMode } from '../runtime-mode.js';
4
4
  import { removeIndexFile } from '../project-index.js';
5
5
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
6
+ import { ensurePermission } from '../permissions.js';
6
7
  export async function deleteFile(context, args) {
7
8
  const { rootDir, projectIndex } = context;
8
9
  const filePath = String(args.filePath ?? '').trim();
@@ -24,6 +25,19 @@ export async function deleteFile(context, args) {
24
25
  };
25
26
  }
26
27
  const scratchPath = pathKind === 'scratch';
28
+ if (!scratchPath) {
29
+ const denied = await ensurePermission(context, {
30
+ bucket: 'delete',
31
+ title: 'Approve file deletion?',
32
+ body: `Delete ${filePath}`,
33
+ filePath,
34
+ }, 'delete_file', { filePath });
35
+ if (denied) {
36
+ if (!isTuiMode())
37
+ console.log(chalk.dim(` ⏭ Delete skipped: ${filePath}`));
38
+ return denied;
39
+ }
40
+ }
27
41
  const result = deleteProjectFile(rootDir, filePath);
28
42
  if (result.deleted) {
29
43
  if (!scratchPath) {
@@ -7,9 +7,10 @@ import { repairFilePath } from './path-suggest.js';
7
7
  import { isTuiMode } from '../runtime-mode.js';
8
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
9
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
10
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
12
  export async function patchFile(context, args) {
12
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
13
+ const { rootDir, projectIndex } = context;
13
14
  const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
14
15
  let patch = typeof args.patch === 'string' ? args.patch : '';
15
16
  if (!filePath) {
@@ -74,23 +75,18 @@ export async function patchFile(context, args) {
74
75
  };
75
76
  }
76
77
  renderDiffPreview(filePath, patch);
77
- if (!autoYes && confirmPatch) {
78
- const confirmed = await confirmPatch(filePath, patch);
79
- if (!confirmed) {
78
+ if (!scratchPath) {
79
+ const denied = await ensurePermission(context, {
80
+ bucket: getCurrentFileHash(rootDir, filePath) === null ? 'create' : 'edit',
81
+ title: 'Approve patch?',
82
+ body: 'Review changes before applying.',
83
+ filePath,
84
+ diff: patch,
85
+ }, 'patch_file', { filePath });
86
+ if (denied) {
80
87
  if (!isTuiMode())
81
88
  console.log(chalk.dim(` ⏭ Patch skipped: ${filePath}`));
82
- return {
83
- ok: false,
84
- skipped: true,
85
- filePath,
86
- failureCategory: 'user_declined',
87
- failureDetails: {
88
- category: 'user_declined',
89
- tool: 'patch_file',
90
- action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
91
- },
92
- error: 'The real user rejected this proposed patch. Nothing was changed; this was not a tool failure or an automated system skip.',
93
- };
89
+ return denied;
94
90
  }
95
91
  }
96
92
  const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
@@ -6,6 +6,8 @@ import { readCliAuthConfig } from '../api/auth.js';
6
6
  import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
7
7
  import { repairFilePath, suggestClosestPath } from './path-suggest.js';
8
8
  import { isTuiMode } from '../runtime-mode.js';
9
+ import { ensurePermission } from '../permissions.js';
10
+ import { getCurrentFileHash } from '../session-safety.js';
9
11
  function normalizeReplacements(value) {
10
12
  if (!Array.isArray(value))
11
13
  return [];
@@ -178,27 +180,35 @@ export async function replaceDocumentText(context, args) {
178
180
  }
179
181
  const preview = String(serverResult.preview ?? '');
180
182
  renderPreview(targetPath, preview);
181
- if (!context.autoYes && context.confirmPatch) {
182
- const confirmed = await context.confirmPatch(targetPath, preview);
183
- if (!confirmed) {
184
- if (!isTuiMode()) {
185
- console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
186
- }
187
- return {
188
- ok: false,
189
- skipped: true,
190
- filePath: targetPath,
191
- failureCategory: 'user_declined',
192
- failureDetails: {
193
- category: 'user_declined',
194
- tool: 'replace_document_text',
195
- action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
196
- },
197
- error: 'The real user rejected this proposed document edit. Nothing was changed; this was not a tool failure or an automated system skip.',
198
- };
183
+ const targetHashBeforeApproval = getCurrentFileHash(context.rootDir, targetPath);
184
+ const documentIsNew = targetHashBeforeApproval === null;
185
+ const denied = await ensurePermission(context, {
186
+ bucket: documentIsNew ? 'create' : 'edit',
187
+ title: documentIsNew ? 'Approve new document?' : 'Approve document edit?',
188
+ body: 'Review changes before applying.',
189
+ filePath: targetPath,
190
+ diff: preview,
191
+ }, 'replace_document_text', { filePath: targetPath });
192
+ if (denied) {
193
+ if (!isTuiMode()) {
194
+ console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
199
195
  }
196
+ return denied;
200
197
  }
201
198
  const fileData = String(serverResult.fileData ?? '');
199
+ if (getCurrentFileHash(context.rootDir, targetPath) !== targetHashBeforeApproval) {
200
+ return {
201
+ ok: false,
202
+ filePath: targetPath,
203
+ failureCategory: 'conflict',
204
+ error: `replace_document_text refused: ${targetPath} changed on disk while the approval prompt was open.`,
205
+ failureDetails: {
206
+ category: 'conflict',
207
+ tool: 'replace_document_text',
208
+ action: 'Re-read the document to see its current contents, then rebuild the replacements against it and retry.',
209
+ },
210
+ };
211
+ }
202
212
  const nextData = Buffer.from(fileData, 'base64');
203
213
  const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
204
214
  const failedCount = Number(serverResult.failedCount ?? 0);
@@ -2,13 +2,14 @@ import chalk from '../colors.js';
2
2
  import { startBackgroundJob } from '../background-jobs.js';
3
3
  import { getBlockedCommandReason, runCommand, } from '../executor.js';
4
4
  import { syncIndexFromDisk } from '../project-index.js';
5
+ import { ensurePermission } from '../permissions.js';
5
6
  import { isTuiMode } from '../runtime-mode.js';
6
7
  import { redactConnectionStringCredentials } from '../secret-preview.js';
7
8
  import { buildNestedGitHint } from '../session-safety.js';
8
9
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
9
10
  const MAX_OUTPUT_CHARS = 4000;
10
11
  export async function runShellCommand(context, args) {
11
- const { rootDir, projectIndex, autoYes, confirmCommand, requestSudoPassword, onStatus, } = context;
12
+ const { rootDir, projectIndex, requestSudoPassword, onStatus, } = context;
12
13
  const command = String(args.command ?? '').trim();
13
14
  if (!command) {
14
15
  return { ok: false, error: 'command is required' };
@@ -31,33 +32,18 @@ export async function runShellCommand(context, args) {
31
32
  }
32
33
  if (!isTuiMode())
33
34
  console.log(chalk.bold.yellow(`\n ⚡ Command: ${command}`));
34
- if (!autoYes) {
35
- if (confirmCommand == null) {
36
- return {
37
- ok: false,
38
- command,
39
- error: 'confirmCommand is required when autoYes is false',
40
- };
41
- }
42
- const approved = await confirmCommand(runInBackground
35
+ const denied = await ensurePermission(context, {
36
+ bucket: 'run',
37
+ title: 'Approve command?',
38
+ body: runInBackground
43
39
  ? `${command}\n\nRuns as a managed background job until it exits or is killed.`
44
- : command);
45
- if (!approved) {
46
- if (!isTuiMode())
47
- console.log(chalk.dim(` ⏭ Skipped: ${command}`));
48
- return {
49
- ok: false,
50
- skipped: true,
51
- command,
52
- failureCategory: 'user_declined',
53
- failureDetails: {
54
- category: 'user_declined',
55
- tool: 'run_command',
56
- action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
57
- },
58
- error: 'The real user rejected this proposed command. Nothing was executed; this was not a tool failure or an automated system skip.',
59
- };
60
- }
40
+ : command,
41
+ command,
42
+ }, 'run_command', { command });
43
+ if (denied) {
44
+ if (!isTuiMode())
45
+ console.log(chalk.dim(` ⏭ Skipped: ${command}`));
46
+ return denied;
61
47
  }
62
48
  if (runInBackground) {
63
49
  return runBackgroundCommand(context, command, args.timeout_ms, repoHint);