@semalt-ai/code 1.8.5 → 1.20.0

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 (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
@@ -0,0 +1,401 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Per-pattern permission rules (Task 4.1) — the pure rule engine.
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // Extends the coarse per-tier permission model (--allow-fs/exec/net, --readonly,
8
+ // per-session "always") with rich rules that match on a TOOL plus its ARGUMENTS
9
+ // (glob OR regex) and resolve to one of `allow` / `deny` / `ask`. Rules are
10
+ // layered across user scope (~/.semalt-ai/config.json) and project scope
11
+ // (.semalt/config.json — attacker-controllable in a cloned repo).
12
+ //
13
+ // EVERYTHING in this module is a pure function: no I/O beyond fs.realpathSync for
14
+ // path canonicalization (constraint 3), which is unavoidable to resolve symlinks.
15
+ // The manager (lib/permissions.js) and the agent gate (lib/agent.js) consume the
16
+ // decisions; composition with the unbypassable Phase 0 controls (deny-list,
17
+ // secret-file guard, --readonly, isPathSafe) happens THERE and downstream in the
18
+ // executors — an `allow` rule can never re-enable something those forbid.
19
+ //
20
+ // The six security constraints (see Task 4.1 brief), and where each lives:
21
+ // 1. Project can only NARROW — collectMatches drops every project `allow` rule
22
+ // structurally before resolution, so a project rule can only ever contribute
23
+ // `deny`/`ask`. Enforced here, not by convention.
24
+ // 2. Precedence is total + deterministic — deny > ask > allow; more-specific
25
+ // beats less-specific; equal specificity resolves by deny>ask>allow (so it
26
+ // is order-independent). Across layers: most-restrictive wins.
27
+ // 3. Canonicalize before matching — normalizeCall resolves `..`, symlinks, and
28
+ // absolute/relative forms; matching is on the canonical form.
29
+ // 4. Regex safety — normalizeRule rejects pathological patterns (ReDoS guard)
30
+ // and bounds subject length; a regex that errors/over-runs fails closed.
31
+ // 5. Fail closed — a malformed rule is dropped at load; a matcher error never
32
+ // GRANTS (an erroring `allow` is treated as no-match) and still RESTRICTS
33
+ // (an erroring `deny`/`ask` is treated as a match).
34
+ // 6. Compose, don't bypass — the resolver only ever returns allow/deny/ask/null
35
+ // for the RULE layer; the manager keeps the deny-list/secret/readonly checks.
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+
40
+ // Per canonical action (call[0]): its public tag (for matching by either name)
41
+ // and the argument shape used to derive matchable subjects.
42
+ // category 'shell' → args[0] is the command string
43
+ // category 'file' → `paths` indices are filesystem paths (canonicalized)
44
+ // category 'net' → `urls` indices are URLs; `paths` indices are dest files
45
+ // category 'other' → no matchable argument subject (only tool-only rules match)
46
+ const ACTION_META = {
47
+ shell: { tag: 'exec', category: 'shell' },
48
+ read: { tag: 'read_file', category: 'file', paths: [0] },
49
+ write: { tag: 'write_file', category: 'file', paths: [0] },
50
+ append: { tag: 'append_file', category: 'file', paths: [0] },
51
+ list_dir: { tag: 'list_dir', category: 'file', paths: [0] },
52
+ delete_file: { tag: 'delete_file', category: 'file', paths: [0] },
53
+ make_dir: { tag: 'make_dir', category: 'file', paths: [0] },
54
+ remove_dir: { tag: 'remove_dir', category: 'file', paths: [0] },
55
+ move_file: { tag: 'move_file', category: 'file', paths: [0, 1] },
56
+ copy_file: { tag: 'copy_file', category: 'file', paths: [0, 1] },
57
+ edit_file: { tag: 'edit_file', category: 'file', paths: [0] },
58
+ search_in_file: { tag: 'search_in_file', category: 'file', paths: [0] },
59
+ replace_in_file: { tag: 'replace_in_file', category: 'file', paths: [0] },
60
+ search_files: { tag: 'search_files', category: 'file', paths: [1] },
61
+ file_stat: { tag: 'file_stat', category: 'file', paths: [0] },
62
+ upload: { tag: 'upload', category: 'file', paths: [0] },
63
+ grep: { tag: 'grep', category: 'file' },
64
+ glob: { tag: 'glob', category: 'file' },
65
+ download: { tag: 'download', category: 'net', urls: [0], paths: [1] },
66
+ http_get: { tag: 'http_get', category: 'net', urls: [0] },
67
+ ask_user: { tag: 'ask_user', category: 'other' },
68
+ store_memory: { tag: 'store_memory', category: 'other' },
69
+ recall_memory: { tag: 'recall_memory', category: 'other' },
70
+ list_memories: { tag: 'list_memories', category: 'other' },
71
+ get_env: { tag: 'get_env', category: 'other' },
72
+ set_env: { tag: 'set_env', category: 'other' },
73
+ system_info: { tag: 'system_info', category: 'other' },
74
+ };
75
+
76
+ const VALID_ACTIONS = new Set(['allow', 'deny', 'ask']);
77
+ // Restrictiveness rank — used to pick the most-restrictive decision across layers.
78
+ const RANK = { deny: 3, ask: 2, allow: 1 };
79
+
80
+ // ── ReDoS guard (constraint 4) ─────────────────────────────────────────────
81
+ // Mirror of the cheap heuristic in lib/tools.js: reject pathologically long
82
+ // patterns and the common catastrophic-backtracking anti-patterns. A pattern
83
+ // that trips this is dropped at load time (fail closed). Subject length is
84
+ // additionally bounded at match time.
85
+ const MAX_PATTERN_LEN = 1000;
86
+ const MAX_SUBJECT_LEN = 8192;
87
+
88
+ function isPatternUnsafe(source) {
89
+ if (typeof source !== 'string') return true;
90
+ if (source.length > MAX_PATTERN_LEN) return true;
91
+ if (/(\(.*[+*].*\).*[+*])|(\[.*\].*[+*].*[+*])/.test(source)) return true;
92
+ return false;
93
+ }
94
+
95
+ // ── matcher compilation ────────────────────────────────────────────────────
96
+
97
+ // A glob → anchored RegExp. `crossSep` controls whether `*`/`?` cross a path
98
+ // separator: false for path-style globs (segment-aware), true for command/URL
99
+ // globs (greedy). `**` always crosses separators; a trailing `/**` (or leading
100
+ // `**/`) collapses the separator so `src/**` matches `src/a/b` and `**/*.env`
101
+ // matches both `x.env` and `a/b/x.env`.
102
+ function globToRegExp(glob, { crossSep = false } = {}) {
103
+ let re = '';
104
+ for (let i = 0; i < glob.length; i++) {
105
+ const c = glob[i];
106
+ if (c === '*') {
107
+ if (glob[i + 1] === '*') {
108
+ i++;
109
+ if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; }
110
+ else re += '.*';
111
+ } else {
112
+ re += crossSep ? '.*' : '[^/]*';
113
+ }
114
+ } else if (c === '?') {
115
+ re += crossSep ? '.' : '[^/]';
116
+ } else if ('\\^$+.()|{}[]'.includes(c)) {
117
+ re += '\\' + c;
118
+ } else {
119
+ re += c;
120
+ }
121
+ }
122
+ return new RegExp('^' + re + '$');
123
+ }
124
+
125
+ // Count of "literal" (non-wildcard / non-metacharacter) characters — the
126
+ // specificity weight of a pattern. More literal chars ⇒ more specific.
127
+ function literalCount(source, kind) {
128
+ if (typeof source !== 'string') return 0;
129
+ const meta = kind === 'regex' ? new Set('.*+?()[]{}|^$\\') : new Set('*?');
130
+ let n = 0;
131
+ for (const ch of source) if (!meta.has(ch)) n++;
132
+ return n;
133
+ }
134
+
135
+ // Compile a rule's argument matcher from its source string. Returns null when no
136
+ // matcher is given (a tool-only rule), or throws on an unsafe/invalid pattern so
137
+ // normalizeRule can drop the rule (fail closed). `crossSep` comes from which key
138
+ // the user used (`path:` ⇒ false; `pattern:`/`url:`/`match:` ⇒ true).
139
+ function compileMatcher(source, crossSep) {
140
+ if (source == null) return { kind: 'any', specificity: 0, test: () => true };
141
+ const s = String(source);
142
+ if (s === '*' || s === '**') return { kind: 'any', specificity: 0, test: () => true };
143
+
144
+ const rx = s.match(/^\/(.*)\/([gimsuy]*)$/);
145
+ if (rx) {
146
+ const body = rx[1];
147
+ if (isPatternUnsafe(body)) throw new Error(`unsafe regex pattern: ${s}`);
148
+ // Strip the stateful `g` flag (it makes .test() position-dependent).
149
+ const flags = (rx[2] || '').replace(/g/g, '');
150
+ const re = new RegExp(body, flags);
151
+ return {
152
+ kind: 'regex',
153
+ specificity: literalCount(body, 'regex'),
154
+ test: (str) => re.test(str.length > MAX_SUBJECT_LEN ? str.slice(0, MAX_SUBJECT_LEN) : str),
155
+ };
156
+ }
157
+
158
+ if (isPatternUnsafe(s)) throw new Error(`unsafe glob pattern: ${s}`);
159
+ const re = globToRegExp(s, { crossSep });
160
+ return {
161
+ kind: 'glob',
162
+ specificity: literalCount(s, 'glob'),
163
+ test: (str) => re.test(str.length > MAX_SUBJECT_LEN ? str.slice(0, MAX_SUBJECT_LEN) : str),
164
+ };
165
+ }
166
+
167
+ const TOOL_WEIGHT = 1000; // a literal tool dominates an argument-pattern's weight
168
+
169
+ // Normalize one raw rule object into an internal rule, or null if malformed
170
+ // (logged via `log`). `scope` is 'user' | 'project'. The matcher source is taken
171
+ // from exactly one of `pattern` | `path` | `url` | `match`; supplying more than
172
+ // one is ambiguous and the rule is dropped (fail closed).
173
+ function normalizeRule(raw, scope, log) {
174
+ const warn = (msg) => { if (typeof log === 'function') log(`permission rule dropped (${scope}): ${msg}`); };
175
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { warn('not an object'); return null; }
176
+
177
+ const action = typeof raw.action === 'string' ? raw.action.trim().toLowerCase() : '';
178
+ if (!VALID_ACTIONS.has(action)) { warn(`bad action ${JSON.stringify(raw.action)}`); return null; }
179
+
180
+ const tool = typeof raw.tool === 'string' ? raw.tool.trim() : '';
181
+ if (!tool) { warn('missing tool'); return null; }
182
+
183
+ const keys = ['pattern', 'path', 'url', 'match'].filter((k) => raw[k] != null && raw[k] !== '');
184
+ if (keys.length > 1) { warn(`multiple matcher keys (${keys.join(', ')})`); return null; }
185
+ const key = keys[0] || null;
186
+ const source = key ? String(raw[key]) : null;
187
+ const crossSep = key !== 'path'; // path globs are segment-aware; everything else is greedy
188
+
189
+ let toolMatcher, matcher;
190
+ try {
191
+ toolMatcher = globToRegExp(tool, { crossSep: true });
192
+ matcher = compileMatcher(source, crossSep);
193
+ } catch (err) {
194
+ warn(err.message);
195
+ return null;
196
+ }
197
+
198
+ const toolSpecificity = (tool === '*' || tool === '**') ? 0 : TOOL_WEIGHT;
199
+ return {
200
+ scope,
201
+ tool,
202
+ toolMatcher,
203
+ matcher,
204
+ matcherKey: key,
205
+ source,
206
+ action,
207
+ specificity: toolSpecificity + matcher.specificity,
208
+ };
209
+ }
210
+
211
+ // Normalize an array of raw rules for one layer; malformed entries are dropped.
212
+ function normalizeRuleLayer(rawRules, scope, log) {
213
+ if (!Array.isArray(rawRules)) return [];
214
+ const out = [];
215
+ for (const raw of rawRules) {
216
+ const r = normalizeRule(raw, scope, log);
217
+ if (r) out.push(r);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ // Build the layered rule set from the two RAW config objects (already parsed
223
+ // JSON, NOT the shallow-merged view — the layers MUST stay separate so the
224
+ // project layer can be structurally prevented from widening). Reads
225
+ // `<cfg>.permissions.rules`.
226
+ function loadRuleLayers(userCfg, projectCfg, log) {
227
+ const pick = (cfg) => (cfg && cfg.permissions && Array.isArray(cfg.permissions.rules)) ? cfg.permissions.rules : [];
228
+ return {
229
+ user: normalizeRuleLayer(pick(userCfg), 'user', log),
230
+ project: normalizeRuleLayer(pick(projectCfg), 'project', log),
231
+ };
232
+ }
233
+
234
+ // ── call canonicalization (constraint 3) ───────────────────────────────────
235
+
236
+ // Resolve a path to its canonical absolute form (symlinks + `..` collapsed) and
237
+ // a cwd-relative form, both in posix separators so globs match identically on
238
+ // every platform. For a not-yet-existent path (writes), the existing ancestor is
239
+ // realpath'd and the basename re-appended.
240
+ function canonicalizePath(p, cwd) {
241
+ const base = cwd || process.cwd();
242
+ let abs = path.resolve(base, p);
243
+ try {
244
+ abs = fs.realpathSync(abs);
245
+ } catch {
246
+ try {
247
+ const dir = fs.realpathSync(path.dirname(abs));
248
+ abs = path.join(dir, path.basename(abs));
249
+ } catch { /* keep the path.resolve form */ }
250
+ }
251
+ const absPosix = abs.split(path.sep).join('/');
252
+ const rel = path.relative(base, abs).split(path.sep).join('/');
253
+ return { abs: absPosix, rel };
254
+ }
255
+
256
+ function normalizeCommand(cmd) {
257
+ return String(cmd == null ? '' : cmd).replace(/\s+/g, ' ').trim();
258
+ }
259
+
260
+ // Turn a [action, ...args] call tuple into the canonical, matchable shape.
261
+ function normalizeCall(call, opts = {}) {
262
+ const arr = Array.isArray(call) ? call : [];
263
+ const action = arr[0];
264
+ const args = arr.slice(1);
265
+ const meta = ACTION_META[action] || { tag: action, category: 'other' };
266
+ const cwd = opts.cwd || process.cwd();
267
+
268
+ const out = { action, tag: meta.tag, category: meta.category, command: null, url: null, paths: [] };
269
+
270
+ if (meta.category === 'shell') {
271
+ out.command = normalizeCommand(args[0]);
272
+ }
273
+ if (meta.urls) {
274
+ for (const i of meta.urls) {
275
+ if (args[i] != null && args[i] !== '') { out.url = String(args[i]); break; }
276
+ }
277
+ }
278
+ if (meta.paths) {
279
+ for (const i of meta.paths) {
280
+ const v = args[i];
281
+ if (v == null || v === '') continue;
282
+ const { abs, rel } = canonicalizePath(String(v), cwd);
283
+ out.paths.push(abs);
284
+ if (rel && rel !== abs) out.paths.push(rel);
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+
290
+ // ── matching + resolution ──────────────────────────────────────────────────
291
+
292
+ function toolMatches(rule, call) {
293
+ try {
294
+ return rule.toolMatcher.test(String(call.action)) || rule.toolMatcher.test(String(call.tag));
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ // Does a rule match a normalized call? Returns true | false | 'error'. 'error'
301
+ // (a matcher threw at runtime, e.g. a pathological regex that slipped the load
302
+ // guard) is propagated so the caller can fail closed.
303
+ function ruleMatchesCall(rule, call) {
304
+ if (!toolMatches(rule, call)) return false;
305
+ if (rule.matcher.kind === 'any') return true;
306
+
307
+ let subjects;
308
+ if (call.category === 'shell') subjects = [call.command];
309
+ else if (call.category === 'net') subjects = [call.url, ...call.paths];
310
+ else if (call.category === 'file') subjects = call.paths;
311
+ else subjects = []; // 'other' — only tool-only rules match
312
+
313
+ for (const s of subjects) {
314
+ if (s == null) continue;
315
+ try {
316
+ if (rule.matcher.test(String(s))) return true;
317
+ } catch {
318
+ return 'error';
319
+ }
320
+ }
321
+ return false;
322
+ }
323
+
324
+ // Collect the rules in one layer that match the call. Fail-closed handling of a
325
+ // matcher error: it NEVER grants (an erroring `allow` is treated as no-match)
326
+ // and still RESTRICTS (an erroring `deny`/`ask` is treated as a match).
327
+ function collectMatches(rules, call) {
328
+ const matches = [];
329
+ for (const rule of rules || []) {
330
+ let m;
331
+ try { m = ruleMatchesCall(rule, call); } catch { m = 'error'; }
332
+ if (m === true) matches.push(rule);
333
+ else if (m === 'error' && rule.action !== 'allow') matches.push(rule);
334
+ }
335
+ return matches;
336
+ }
337
+
338
+ // Resolve one layer's matches to a single { decision, rule } or null. Precedence:
339
+ // most specific wins; among equal specificity, deny > ask > allow (so the result
340
+ // is independent of rule order — no ambiguity).
341
+ function layerDecision(matches) {
342
+ if (!matches || !matches.length) return null;
343
+ let maxSpec = -1;
344
+ for (const r of matches) if (r.specificity > maxSpec) maxSpec = r.specificity;
345
+ const top = matches.filter((r) => r.specificity === maxSpec);
346
+ const deny = top.find((r) => r.action === 'deny');
347
+ if (deny) return { decision: 'deny', rule: deny };
348
+ const ask = top.find((r) => r.action === 'ask');
349
+ if (ask) return { decision: 'ask', rule: ask };
350
+ return { decision: 'allow', rule: top[0] };
351
+ }
352
+
353
+ function ruleReason(rule) {
354
+ if (!rule) return null;
355
+ const src = rule.source ? ` ${rule.matcherKey || 'pattern'}=${rule.source}` : '';
356
+ return `${rule.scope} ${rule.action} ${rule.tool}${src}`;
357
+ }
358
+
359
+ // THE resolver. Takes a NORMALIZED call (already canonicalized — constraint 3),
360
+ // the layered rules, and a context bag (reserved for tier/readonly composition,
361
+ // which the manager performs). Returns the deterministic rule-layer decision:
362
+ // { decision: 'allow'|'deny'|'ask'|null, rule, reason, scope }
363
+ // `null` means no rule matched — the caller falls back to the tier/descriptor
364
+ // default. Project rules can only NARROW: every project `allow` is dropped before
365
+ // resolution, so the project layer can contribute only `deny`/`ask`. Across
366
+ // layers the MOST RESTRICTIVE decision wins.
367
+ function resolvePermission(call, layers, context = {}) { // eslint-disable-line no-unused-vars
368
+ const userMatches = collectMatches(layers && layers.user, call);
369
+ // Structural project-cannot-widen: drop project `allow` rules entirely.
370
+ const projectMatches = collectMatches(layers && layers.project, call).filter((r) => r.action !== 'allow');
371
+
372
+ const u = layerDecision(userMatches);
373
+ const p = layerDecision(projectMatches);
374
+
375
+ let winner;
376
+ if (u && p) winner = RANK[p.decision] > RANK[u.decision] ? p : u;
377
+ else winner = u || p;
378
+
379
+ if (!winner) return { decision: null, rule: null, reason: null, scope: null };
380
+ return { decision: winner.decision, rule: winner.rule, reason: ruleReason(winner.rule), scope: winner.rule.scope };
381
+ }
382
+
383
+ module.exports = {
384
+ ACTION_META,
385
+ resolvePermission,
386
+ normalizeCall,
387
+ canonicalizePath,
388
+ normalizeCommand,
389
+ normalizeRule,
390
+ normalizeRuleLayer,
391
+ loadRuleLayers,
392
+ globToRegExp,
393
+ compileMatcher,
394
+ ruleMatchesCall,
395
+ collectMatches,
396
+ layerDecision,
397
+ ruleReason,
398
+ // test seams
399
+ literalCount,
400
+ isPatternUnsafe,
401
+ };
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const writer = require('./ui/writer');
4
- const messages = require('./ui/messages');
4
+ const dbg = require('./debug');
5
+ const { resolvePermission, normalizeCall } = require('./permission-rules');
5
6
 
6
7
  const TIER_FS = ['read_file', 'write_file', 'append_file', 'delete_file', 'list_dir', 'make_dir', 'move_file', 'copy_file', 'file_stat', 'search_files', 'store_memory', 'recall_memory'];
7
8
  const TIER_EXEC = ['exec'];
@@ -9,11 +10,23 @@ const TIER_NET = ['http_get', 'download'];
9
10
  const TIER_SYS = ['system_info', 'get_env', 'set_env'];
10
11
 
11
12
  const TIER_MAP = { fs: TIER_FS, exec: TIER_EXEC, net: TIER_NET, sys: TIER_SYS };
12
- const READONLY_BLOCKED = new Set(['write_file', 'append_file', 'delete_file', 'move_file', 'copy_file']);
13
+ // Every FILE-mutating tool. --readonly governs file tools only; shell side
14
+ // effects are NOT constrained here (a read-only session must still run `ls` /
15
+ // `git status`) — shell writes are confined by the OS sandbox + deny-list,
16
+ // the right layer for that (Pre-Task 5.0c).
17
+ const READONLY_BLOCKED = new Set([
18
+ 'write_file', 'append_file', 'delete_file', 'move_file', 'copy_file', 'download',
19
+ 'edit_file', 'replace_in_file', 'make_dir', 'remove_dir', 'upload',
20
+ // Native git tools (Task 5.1). The mutating git tools (the create/delete paths
21
+ // of branch/worktree are gated inside their executors) honor --readonly too — a
22
+ // read-only session must not stage/commit/switch/create. Read-only git tools
23
+ // (git_status/git_diff/git_log, and the LIST ops) are NOT here, so they still run.
24
+ 'git_add', 'git_commit', 'git_branch', 'git_checkout', 'git_worktree',
25
+ ]);
13
26
 
14
27
  let _permissionQueueTail = Promise.resolve();
15
28
 
16
- function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {}) {
29
+ function createPermissionManager(ui, { allowedTiers = [], readonly = false, skipPermissions = false, rules = null, cwd = null, approver = null, quiet = false } = {}) {
17
30
  const { BOLD, FG_CYAN, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, interactiveSelect } = ui;
18
31
 
19
32
  const autoApprovedTags = new Set();
@@ -27,6 +40,29 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
27
40
  sessionApprovedTags: new Set(),
28
41
  };
29
42
 
43
+ // Per-pattern rule layers (Task 4.1). { user: [...], project: [...] } of
44
+ // already-normalized rules, kept SEPARATE so the project layer can be
45
+ // structurally prevented from widening (see lib/permission-rules.js).
46
+ const ruleLayers = (rules && typeof rules === 'object')
47
+ ? { user: rules.user || [], project: rules.project || [] }
48
+ : { user: [], project: [] };
49
+ const hasRules = ruleLayers.user.length > 0 || ruleLayers.project.length > 0;
50
+
51
+ // Resolve the per-pattern rule decision for a [action, ...args] call tuple.
52
+ // Returns { decision: 'allow'|'deny'|'ask'|null, rule, reason }. `null` when no
53
+ // rule matches → the caller falls back to the tier/descriptor default. Pure
54
+ // wrapper around resolvePermission; any failure fails closed to a null decision
55
+ // (the normal gate then still asks for mutating tools).
56
+ function resolveRule(call) {
57
+ if (!hasRules) return { decision: null, rule: null, reason: null };
58
+ try {
59
+ const normalized = normalizeCall(call, { cwd: cwd || process.cwd() });
60
+ return resolvePermission(normalized, ruleLayers, { readonly, tiers: allowedTiers });
61
+ } catch {
62
+ return { decision: null, rule: null, reason: null };
63
+ }
64
+ }
65
+
30
66
  let uiCallbacks = null;
31
67
 
32
68
  function setUICallbacks(callbacks) {
@@ -45,10 +81,10 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
45
81
  // The picker renders into the writer's modal region — a live band above
46
82
  // the status bar that redraws in place on every keystroke. Arrow-key
47
83
  // navigation rebuilds the lines array and calls onShowModal again; nothing
48
- // lands in scrollback until the user confirms. On resolve/cancel the
49
- // modal is cleared and a single summary line is emitted to scrollback
50
- // (for multi-line descriptions e.g. a file-write diff the full body
51
- // is retained so the user can still see what was approved).
84
+ // lands in scrollback until the user confirms. On resolve/cancel the modal
85
+ // is simply cleared NO summary line is emitted (Output Refactor Phase 2,
86
+ // D1): the execution result line is the single post-approval confirmation,
87
+ // so an echo here would just duplicate it.
52
88
  function requestPermission(description, onShowModal, onCloseModal, onCaptureNavigation) {
53
89
  // Serialize dialogs: each permission waits for the previous one to be answered
54
90
  const myTurn = _permissionQueueTail;
@@ -88,12 +124,13 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
88
124
 
89
125
  function finish(result) {
90
126
  const chosen = result === 'cancel' ? 'no' : options[selectedIdx].toLowerCase();
91
- const glyph = (chosen === 'no') ? '✗' : '✓';
92
- // The full `description` is preserved in the summary so multi-line
93
- // bodies (e.g. file-write diffs) remain visible in scrollback after
94
- // the modal closes. chatHistory's system-message renderer styles the
95
- // first line by the leading glyph and indents continuations.
96
- onCloseModal(`${glyph} ${description}`);
127
+ // Output Refactor Phase 2 (D1): close the modal WITHOUT emitting a
128
+ // post-close summary line. The execution result line (the descriptor's
129
+ // `result` phase via renderOperation) is the single confirmation of the
130
+ // operation, so a `✓ shell: ls` / `✓ file: Edit line N` echo here just
131
+ // duplicated it. Manual-approve now produces the same post-execution
132
+ // output as auto-approve: only the result line.
133
+ onCloseModal();
97
134
  releaseQueue();
98
135
  resolve(chosen);
99
136
  }
@@ -116,28 +153,87 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
116
153
  }));
117
154
  }
118
155
 
156
+ // Per-auto-approved-command breadcrumb. By DEFAULT this writes nothing to
157
+ // scrollback/UI: the per-command result line (command + outcome + duration,
158
+ // emitted for every tool) and the one-time "Auto-approve enabled for `tag`"
159
+ // grant line already cover it — a per-command `✓ Auto-approved: <cmd>` line
160
+ // just duplicates the command and reads as auto-approve chatter on every call.
161
+ // The diagnostic detail (incl. the `[rule: …]` / `[--dangerously-skip-...]`
162
+ // context the call sites embed in `description`) is preserved under
163
+ // --debug, routed through dbg.log — which is silent in 'off' mode (the
164
+ // default), scrollback in --debug, and the file in --debug-file. The audit
165
+ // log records every tool call independently, so nothing is lost regardless.
119
166
  function _emitAutoApproved(description) {
120
- if (uiCallbacks) {
121
- uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
122
- } else {
123
- messages.sysSuccess(`Auto-approved: ${description}`);
124
- }
167
+ dbg.log(`[permission] auto-approved: ${description}`);
125
168
  }
126
169
 
127
- async function askPermission(actionType, description, tag) {
128
- if (state.autoApproveAll) {
129
- _emitAutoApproved(description);
170
+ async function askPermission(actionType, description, tag, ruleVerdict = null) {
171
+ // --dangerously-skip-permissions is the ONLY way to fully auto-approve any
172
+ // tool call. It does not bypass the destructive-command deny-list (enforced
173
+ // unbypassably in tools.js) — it only skips the interactive/refusal gate.
174
+ // A per-pattern `deny` rule is handled in the agent gate BEFORE this point
175
+ // (it blocks even under skip-permissions); here we see only allow/ask/null.
176
+ if (skipPermissions) {
177
+ _emitAutoApproved(`[--dangerously-skip-permissions] ${description}`);
130
178
  return true;
131
179
  }
132
180
 
133
- if (tag && (autoApprovedTags.has(tag) || state.sessionApprovedTags.has(tag))) {
134
- _emitAutoApproved(description);
135
- return true;
181
+ // Per-pattern rules (Task 4.1). An `ask` rule FORCES the interactive prompt:
182
+ // it bypasses the auto-approve shortcuts below (tier flags, /approve, and the
183
+ // per-session "always") so a user policy of "ask for this" always holds. An
184
+ // `allow` rule auto-approves even what a tier wouldn't — but still composes
185
+ // with the deny-list / secret-guard / --readonly enforced downstream.
186
+ const ruleDecision = ruleVerdict && ruleVerdict.decision;
187
+ const forceAsk = ruleDecision === 'ask';
188
+
189
+ if (!forceAsk) {
190
+ if (ruleDecision === 'allow') {
191
+ _emitAutoApproved(`[rule${ruleVerdict.reason ? `: ${ruleVerdict.reason}` : ''}] ${description}`);
192
+ return true;
193
+ }
194
+
195
+ if (state.autoApproveAll) {
196
+ _emitAutoApproved(description);
197
+ return true;
198
+ }
199
+
200
+ if (tag && (autoApprovedTags.has(tag) || state.sessionApprovedTags.has(tag))) {
201
+ _emitAutoApproved(description);
202
+ return true;
203
+ }
204
+ }
205
+
206
+ // Programmatic approver (Task 5.2, SDK). When the process is embedded (no
207
+ // TTY) a host may supply an async approver — the programmatic equivalent of
208
+ // the interactive prompt. It is consulted ONLY when we would otherwise have
209
+ // to refuse for lack of a way to ask (no tier/rule/skip auto-approved above),
210
+ // so it never widens what a tier already granted, and an approver that throws
211
+ // or returns falsy means "no" (fail closed). With NO approver the safe
212
+ // default holds — refuse — exactly as headless does.
213
+ if (typeof approver === 'function') {
214
+ try {
215
+ const ok = await approver({ actionType, description, tag, rule: ruleVerdict || null });
216
+ return !!ok;
217
+ } catch {
218
+ return false;
219
+ }
136
220
  }
137
221
 
138
222
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
139
- writer.scrollback(` [non-TTY] Auto-approving: ${description}`);
140
- return true;
223
+ // Non-TTY / headless mode. WITHOUT --dangerously-skip-permissions we no
224
+ // longer silently auto-approve — that was the security hole. A tier flag
225
+ // (--allow-fs/exec/net/all) pre-approves its tag above; anything reaching
226
+ // here would otherwise require interactive confirmation we cannot show,
227
+ // so we refuse it instead of approving it. `quiet` (set by the embedding
228
+ // SDK) suppresses the scrollback line — the denial is already surfaced to
229
+ // the host in the structured run result.
230
+ if (!quiet) {
231
+ writer.scrollback(
232
+ ` [non-TTY] Refused (interactive confirmation required, and ` +
233
+ `--dangerously-skip-permissions not set): ${description}`
234
+ );
235
+ }
236
+ return false;
141
237
  }
142
238
 
143
239
  if (uiCallbacks) {
@@ -209,6 +305,7 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
209
305
  captureSelect,
210
306
  clear,
211
307
  readonlyBlock,
308
+ resolveRule,
212
309
  setUICallbacks,
213
310
  state,
214
311
  toggleAll,