@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.
- package/dist/src/agent-mode.js +1 -1
- package/dist/src/api/chat.js +34 -2
- package/dist/src/help-text.js +32 -4
- package/dist/src/permissions.js +243 -0
- package/dist/src/session-store.js +5 -0
- package/dist/src/session.js +5 -3
- package/dist/src/tool-executor.js +2 -2
- package/dist/src/tools/delete-file.js +14 -0
- package/dist/src/tools/patch-file.js +12 -16
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/run-command.js +13 -27
- package/dist/src/tools/run-node-script.js +11 -26
- package/dist/src/tools/str-replace.js +12 -16
- package/dist/src/tools/write-file.js +66 -0
- package/dist/src/ui/repl.js +206 -85
- package/dist/src/ui/tui/build-frame.js +54 -16
- package/dist/src/ui/tui/shell-input.js +27 -13
- package/package.json +5 -5
package/dist/src/agent-mode.js
CHANGED
|
@@ -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 = '';
|
package/dist/src/api/chat.js
CHANGED
|
@@ -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 {
|
package/dist/src/help-text.js
CHANGED
|
@@ -47,7 +47,9 @@ const HELP_MARKDOWN = [
|
|
|
47
47
|
'',
|
|
48
48
|
'## Modes',
|
|
49
49
|
'',
|
|
50
|
-
'- Default — asks before
|
|
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
|
|
88
|
-
'
|
|
89
|
-
'
|
|
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,
|
package/dist/src/session.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
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 (!
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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);
|