@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.21

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.
Files changed (44) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +236 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/core/clipboard.js +7 -13
  11. package/dist/src/core/image-limits.js +56 -0
  12. package/dist/src/core/image-path-extractor.js +70 -3
  13. package/dist/src/core/session-image-store.js +199 -0
  14. package/dist/src/executor.js +1 -1
  15. package/dist/src/help-text.js +51 -11
  16. package/dist/src/permissions.js +243 -0
  17. package/dist/src/project-index.js +13 -1
  18. package/dist/src/session-store.js +119 -20
  19. package/dist/src/session.js +14 -3
  20. package/dist/src/tool-executor.js +2 -2
  21. package/dist/src/tools/delete-file.js +14 -0
  22. package/dist/src/tools/index.js +2 -0
  23. package/dist/src/tools/patch-file.js +12 -16
  24. package/dist/src/tools/read-image-file.js +85 -0
  25. package/dist/src/tools/replace-document-text.js +28 -18
  26. package/dist/src/tools/run-command.js +13 -27
  27. package/dist/src/tools/run-node-script.js +11 -26
  28. package/dist/src/tools/str-replace.js +12 -16
  29. package/dist/src/tools/write-file.js +66 -0
  30. package/dist/src/turn-failure-marker.js +11 -0
  31. package/dist/src/ui/prompt-history-store.js +1 -1
  32. package/dist/src/ui/repl.js +579 -154
  33. package/dist/src/ui/tui/bridge.js +10 -0
  34. package/dist/src/ui/tui/build-frame.js +535 -159
  35. package/dist/src/ui/tui/markdown-render.js +81 -73
  36. package/dist/src/ui/tui/shell-input.js +206 -63
  37. package/dist/src/ui/tui/terminal-theme.js +28 -0
  38. package/dist/src/ui/tui/terminal-title.js +3 -0
  39. package/dist/src/ui/tui/terminal-writes.js +48 -0
  40. package/dist/src/ui/tui/text.js +158 -4
  41. package/dist/src/ui/tui/user-input.js +568 -0
  42. package/dist/src/utils.js +9 -0
  43. package/package.json +18 -6
  44. package/dist/src/markdown-renderer.js +0 -112
@@ -1,7 +1,9 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { loadImageFromFile } from './clipboard.js';
5
+ import { MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_SIZE_BYTES, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, sniffImageMimeType, totalAttachmentBytes, } from './image-limits.js';
6
+ import { tryCacheAttachmentBytes } from './session-image-store.js';
5
7
  const EXT = '(?:png|jpe?g|gif|webp)';
6
8
  const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
7
9
  const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
@@ -110,13 +112,67 @@ function detectImagePaths(input, cwd) {
110
112
  }
111
113
  return rawsByPath;
112
114
  }
115
+ const EXTENSIONLESS_CANDIDATE = new RegExp(`"([^"]*[\\\\/][^"]*)"` +
116
+ `|'([^']*[\\\\/][^']*)'` +
117
+ `|((?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})*[\\\\/](?:\\\\ |${BARE_CHAR})+)`, 'g');
118
+ function sniffFileHeader(resolvedPath) {
119
+ let fd = null;
120
+ try {
121
+ const stat = statSync(resolvedPath, { throwIfNoEntry: false });
122
+ if (!stat?.isFile() || stat.size > MAX_IMAGE_SIZE_BYTES)
123
+ return null;
124
+ fd = openSync(resolvedPath, 'r');
125
+ const header = Buffer.alloc(12);
126
+ const read = readSync(fd, header, 0, 12, 0);
127
+ return read === 12 ? header : null;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ finally {
133
+ if (fd !== null) {
134
+ try {
135
+ closeSync(fd);
136
+ }
137
+ catch {
138
+ }
139
+ }
140
+ }
141
+ }
142
+ function detectExtensionlessImagePaths(input, cwd, alreadyDetected) {
143
+ const found = new Map();
144
+ const regex = new RegExp(EXTENSIONLESS_CANDIDATE.source, EXTENSIONLESS_CANDIDATE.flags);
145
+ let match;
146
+ while ((match = regex.exec(input)) !== null) {
147
+ const raw = match[0];
148
+ const inner = (match[1] ?? match[2] ?? match[3] ?? '').replace(/\\ /g, ' ');
149
+ if (!inner || inner.includes('://'))
150
+ continue;
151
+ if (/\.(?:png|jpe?g|gif|webp)$/i.test(inner))
152
+ continue;
153
+ const resolvedPath = path.isAbsolute(inner)
154
+ ? inner
155
+ : path.resolve(cwd, inner);
156
+ if (alreadyDetected.has(resolvedPath) || found.has(resolvedPath))
157
+ continue;
158
+ const header = sniffFileHeader(resolvedPath);
159
+ if (!header || !sniffImageMimeType(header))
160
+ continue;
161
+ found.set(resolvedPath, [raw]);
162
+ }
163
+ return found;
164
+ }
113
165
  export function autoAttachImages(input, cwd, existing = []) {
114
- const max = 2;
166
+ const max = MAX_IMAGES_PER_MESSAGE;
115
167
  const rawsByPath = detectImagePaths(input, cwd);
168
+ for (const [resolvedPath, rawForms] of detectExtensionlessImagePaths(input, cwd, new Set(rawsByPath.keys()))) {
169
+ rawsByPath.set(resolvedPath, rawForms);
170
+ }
116
171
  let sanitizedInput = input;
117
172
  const attachments = [];
118
173
  const errors = [];
119
174
  const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
175
+ let budgetUsed = totalAttachmentBytes(existing);
120
176
  for (const [resolvedPath, rawForms] of rawsByPath) {
121
177
  if (existing.length + attachments.length >= max)
122
178
  break;
@@ -124,13 +180,24 @@ export function autoAttachImages(input, cwd, existing = []) {
124
180
  continue;
125
181
  try {
126
182
  const loaded = loadImageFromFile(resolvedPath);
127
- const idx = maxExistingIndex + attachments.length + 1;
183
+ const bytes = approximateBase64DecodedBytes(loaded.base64Data);
184
+ if (budgetUsed + bytes > MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
185
+ errors.push(`${path.basename(resolvedPath)} was not attached: it would put this message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit.`);
186
+ continue;
187
+ }
188
+ budgetUsed += bytes;
189
+ const cached = tryCacheAttachmentBytes({
190
+ base64Data: loaded.base64Data,
191
+ mimeType: loaded.mimeType,
192
+ });
193
+ const idx = cached?.index ?? maxExistingIndex + attachments.length + 1;
128
194
  attachments.push({
129
195
  index: idx,
130
196
  mimeType: loaded.mimeType,
131
197
  base64Data: loaded.base64Data,
132
198
  source: 'file',
133
199
  filePath: resolvedPath,
200
+ ...(cached ? { cachePath: cached.cachePath } : {}),
134
201
  });
135
202
  for (const raw of rawForms) {
136
203
  sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
@@ -0,0 +1,199 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { getClientStateDir } from '../client-state.js';
5
+ import { MAX_IMAGE_SIZE_BYTES, imageExtensionForMime, isSupportedImageMimeType, sniffImageMimeType, } from './image-limits.js';
6
+ const IMAGE_FILE_MODE = 0o600;
7
+ const IMAGE_DIR_MODE = 0o700;
8
+ export const SESSION_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ let activeSessionId = null;
10
+ export function setImageStoreSession(sessionId) {
11
+ activeSessionId = String(sessionId ?? '').trim() || null;
12
+ contentIndex.clear();
13
+ }
14
+ const contentIndex = new Map();
15
+ function imageContentKey(base64Data) {
16
+ return createHash('sha256').update(base64Data).digest('hex');
17
+ }
18
+ function rememberStoredImage(base64Data, cachePath) {
19
+ contentIndex.set(imageContentKey(base64Data), cachePath);
20
+ }
21
+ export function findStoredImageByContent(base64Data) {
22
+ const cachePath = contentIndex.get(imageContentKey(base64Data));
23
+ if (!cachePath)
24
+ return undefined;
25
+ if (!existsSync(cachePath)) {
26
+ contentIndex.delete(imageContentKey(base64Data));
27
+ return undefined;
28
+ }
29
+ return cachePath;
30
+ }
31
+ export function getImageStoreSession() {
32
+ return activeSessionId;
33
+ }
34
+ function safeSessionDirName(sessionId) {
35
+ const normalized = String(sessionId ?? '').trim();
36
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
37
+ throw new Error(`Invalid session id "${sessionId}".`);
38
+ }
39
+ return normalized;
40
+ }
41
+ export function getSessionImageBaseDir(env = process.env) {
42
+ return path.join(getClientStateDir(env), 'sessions', 'images');
43
+ }
44
+ export function getSessionImageDir(sessionId, env = process.env) {
45
+ return path.join(getSessionImageBaseDir(env), safeSessionDirName(sessionId));
46
+ }
47
+ function nextImageNumber(dir) {
48
+ if (!existsSync(dir))
49
+ return 1;
50
+ let highest = 0;
51
+ for (const name of readdirSync(dir)) {
52
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
53
+ if (Number.isInteger(parsed) && parsed > highest)
54
+ highest = parsed;
55
+ }
56
+ return highest + 1;
57
+ }
58
+ function ensureSessionImageDir(sessionId, env) {
59
+ const dir = getSessionImageDir(sessionId, env);
60
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
61
+ return dir;
62
+ }
63
+ export function storeSessionImageBytes({ sessionId, base64Data, mimeType, env = process.env, }) {
64
+ const dir = ensureSessionImageDir(sessionId, env);
65
+ const index = nextImageNumber(dir);
66
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(mimeType)}`);
67
+ writeFileSync(cachePath, Buffer.from(base64Data, 'base64'), {
68
+ mode: IMAGE_FILE_MODE,
69
+ });
70
+ rememberStoredImage(base64Data, cachePath);
71
+ return { cachePath, mimeType, base64Data, index };
72
+ }
73
+ export function tryCacheAttachmentBytes({ base64Data, mimeType, env = process.env, }) {
74
+ if (!activeSessionId)
75
+ return undefined;
76
+ try {
77
+ const stored = storeSessionImageBytes({
78
+ sessionId: activeSessionId,
79
+ base64Data,
80
+ mimeType,
81
+ env,
82
+ });
83
+ return { cachePath: stored.cachePath, index: stored.index };
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ export function isSessionStorePath(candidate, env = process.env) {
90
+ if (!activeSessionId)
91
+ return false;
92
+ const dir = getSessionImageDir(activeSessionId, env);
93
+ const resolved = path.resolve(candidate);
94
+ return (resolved.startsWith(dir + path.sep) && path.dirname(resolved) === dir);
95
+ }
96
+ export function readSessionImageByIndex(index, env = process.env) {
97
+ if (!activeSessionId || !Number.isInteger(index) || index < 1)
98
+ return null;
99
+ const dir = getSessionImageDir(activeSessionId, env);
100
+ if (!existsSync(dir))
101
+ return null;
102
+ try {
103
+ for (const name of readdirSync(dir)) {
104
+ if (Number.parseInt(path.basename(name, path.extname(name)), 10) === index) {
105
+ return readSessionImage(path.join(dir, name));
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ return null;
113
+ }
114
+ export class SessionImageError extends Error {
115
+ code;
116
+ constructor(message, code) {
117
+ super(message);
118
+ this.code = code;
119
+ this.name = 'SessionImageError';
120
+ }
121
+ }
122
+ export function storeSessionImageFromPath({ sessionId, sourcePath, env = process.env, }) {
123
+ const resolved = path.resolve(sourcePath);
124
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
125
+ throw new SessionImageError(`Image file not found: ${resolved}`, 'NOT_FOUND');
126
+ }
127
+ const size = statSync(resolved).size;
128
+ if (size > MAX_IMAGE_SIZE_BYTES) {
129
+ throw new SessionImageError(`Image file exceeds ${Math.round(MAX_IMAGE_SIZE_BYTES / 1024 / 1024)}MB limit (${(size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'TOO_LARGE');
130
+ }
131
+ const bytes = readFileSync(resolved);
132
+ const sniffed = sniffImageMimeType(bytes);
133
+ if (!sniffed || !isSupportedImageMimeType(sniffed)) {
134
+ throw new SessionImageError(`Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'UNSUPPORTED');
135
+ }
136
+ const dir = ensureSessionImageDir(sessionId, env);
137
+ const index = nextImageNumber(dir);
138
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(sniffed)}`);
139
+ writeFileSync(cachePath, bytes, { mode: IMAGE_FILE_MODE });
140
+ const base64Data = bytes.toString('base64');
141
+ rememberStoredImage(base64Data, cachePath);
142
+ return { cachePath, mimeType: sniffed, base64Data, index };
143
+ }
144
+ export function readSessionImage(cachePath) {
145
+ try {
146
+ if (!existsSync(cachePath))
147
+ return null;
148
+ const bytes = readFileSync(cachePath);
149
+ const sniffed = sniffImageMimeType(bytes);
150
+ if (!sniffed)
151
+ return null;
152
+ const parsed = Number.parseInt(path.basename(cachePath, path.extname(cachePath)), 10);
153
+ const base64Data = bytes.toString('base64');
154
+ rememberStoredImage(base64Data, cachePath);
155
+ return {
156
+ cachePath,
157
+ mimeType: sniffed,
158
+ base64Data,
159
+ index: Number.isInteger(parsed) ? parsed : 0,
160
+ };
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ export function pruneSessionImages(sessionId, env = process.env) {
167
+ try {
168
+ rmSync(getSessionImageDir(sessionId, env), { recursive: true, force: true });
169
+ }
170
+ catch {
171
+ }
172
+ }
173
+ export function sweepOrphanSessionImages({ activeSessionIds, maxAgeMs = SESSION_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), }) {
174
+ const baseDir = getSessionImageBaseDir(env);
175
+ if (!existsSync(baseDir))
176
+ return 0;
177
+ let removed = 0;
178
+ let entries;
179
+ try {
180
+ entries = readdirSync(baseDir);
181
+ }
182
+ catch {
183
+ return 0;
184
+ }
185
+ for (const entry of entries) {
186
+ if (activeSessionIds.has(entry))
187
+ continue;
188
+ const dir = path.join(baseDir, entry);
189
+ try {
190
+ if (now - statSync(dir).mtimeMs < maxAgeMs)
191
+ continue;
192
+ rmSync(dir, { recursive: true, force: true });
193
+ removed += 1;
194
+ }
195
+ catch {
196
+ }
197
+ }
198
+ return removed;
199
+ }
@@ -596,7 +596,7 @@ export function commandUsesSudo(command) {
596
596
  }
597
597
  export function sudoPromptFromTail(text) {
598
598
  const tail = text.slice(-1000).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
599
- const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
599
+ const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|\[?sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
600
600
  return match?.[0] ?? null;
601
601
  }
602
602
  function isSudoPromptLine(text) {
@@ -22,6 +22,7 @@ const HELP_MARKDOWN = [
22
22
  '',
23
23
  '- `ai` — start an interactive chat session in the current repo',
24
24
  '- `ai "<request>"` — start an interactive session with `<request>` as the first message',
25
+ '- Coding sessions require terminal stdin and stdout; piped prompts are not supported.',
25
26
  '',
26
27
  '## Auth',
27
28
  '',
@@ -35,8 +36,8 @@ const HELP_MARKDOWN = [
35
36
  '',
36
37
  '- `ai --list-sessions` — list saved sessions for this repo',
37
38
  '- `ai --session <id|name>` — resume a saved session by id or name',
38
- '- Sessions are stored locally and scoped to the current repo. The five',
39
- ' most recent sessions per repo are kept.',
39
+ '- Sessions are stored locally and can be listed or resumed in the same repo.',
40
+ ' Continuing one requires the TheGitAI account used for that session.',
40
41
  '',
41
42
  '## Options',
42
43
  '',
@@ -46,7 +47,9 @@ const HELP_MARKDOWN = [
46
47
  '',
47
48
  '## Modes',
48
49
  '',
49
- '- 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.',
50
53
  '- Auto-Accept — approves shell commands and file edits for the session.',
51
54
  '- Plan — read-only; the agent can inspect, ask typed questions, and plan,',
52
55
  ' with file-reading shell commands only. It will not edit files or run tests,',
@@ -56,7 +59,15 @@ const HELP_MARKDOWN = [
56
59
  '## Keys & clipboard',
57
60
  '',
58
61
  '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
59
- ' **Ctrl+C** quits. These are the same on macOS, Linux, and Windows.',
62
+ ' **Ctrl+C** clears the composer or the queued message, and quits once there',
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. **Ctrl+V** pastes a screenshot onto the',
70
+ ' queued message, and it is delivered into the running turn with the text.',
60
71
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
61
72
  ' on this system) or by right-clicking the composer.',
62
73
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -77,14 +88,33 @@ const HELP_MARKDOWN = [
77
88
  ' browse them, press Enter to expand one and read its output, k to stop it',
78
89
  '- `/jobs output <id>` — print one job\'s full captured output',
79
90
  '- `/jobs kill <id>` — stop one background job',
80
- '- `/clear` — clear the current conversation history',
91
+ '- `/new` — start a new conversation; this session remains saved',
81
92
  '- `/exit` — quit the session',
82
93
  '',
83
94
  '## Safety & approvals',
84
95
  '',
85
- '- TheGitAI asks before running shell commands or applying file edits.',
86
- '- At each prompt: **y** approves once, **a** approves the rest of the',
87
- ' 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.',
88
118
  '- If an approved `sudo` command needs a password, the TUI shows the exact',
89
119
  ' command and keeps the password masked and local.',
90
120
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
@@ -105,7 +135,10 @@ const HELP_MARKDOWN = [
105
135
  '- Auth or permission errors → run `ai whoami` to confirm the signed-in',
106
136
  ' account.',
107
137
  '- Usage or quota errors → run `ai --usage`.',
108
- '- Stuck on the wrong account → `ai logout`, then `ai login` again.',
138
+ '- Signed in with the wrong credentials → `ai logout`, then `ai login` with',
139
+ ' the account you intended to use.',
140
+ '- A local session was used with a different sign-in → sign in with the',
141
+ ' account you used for that session or start a new session.',
109
142
  '- For anything else, re-run the command and report the printed error',
110
143
  ' message — there is no client-side debug mode by design.',
111
144
  ].join('\n');
@@ -126,8 +159,15 @@ export function formatInteractiveHelpText() {
126
159
  return HELP_MARKDOWN;
127
160
  }
128
161
  export function formatCliHelpText({ color = false } = {}) {
129
- if (!color)
130
- return HELP_MARKDOWN;
162
+ if (!color) {
163
+ return HELP_MARKDOWN.split('\n')
164
+ .map((line) => line
165
+ .replace(/^#{1,6}\s+/, '')
166
+ .replace(/^-\s+/, ' ')
167
+ .replace(/`([^`]+)`/g, '$1')
168
+ .replace(/\*\*([^*]+)\*\*/g, '$1'))
169
+ .join('\n');
170
+ }
131
171
  return HELP_MARKDOWN.split('\n')
132
172
  .map((line) => {
133
173
  const heading = line.match(/^(#{1,6})\s+(.*)$/);
@@ -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
+ }