@superjackfruitlabs/salaam 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,360 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import type { Store } from './store.js';
6
+ import { object, ServiceError } from './protocol.js';
7
+
8
+ /**
9
+ * Approval rules for desk sessions (operator plan 2026-10-31, phase 2a), in the manner of OpenCode:
10
+ * allow, ask or deny by tool, command pattern and path glob. Deny beats ask, ask beats allow, and with
11
+ * no matching rule the answer is ask.
12
+ *
13
+ * Commands are matched on their parsed words, never as text, so an `npm test` rule cannot carry
14
+ * `npm test; rm -rf /`: anything a shell would reinterpret (chaining, pipes, redirection, expansion,
15
+ * globs, assignments, quoting it cannot close) makes the command unreadable, and an unreadable command
16
+ * can only be denied, never allowed.
17
+ *
18
+ * Rules live in Salaam's own state, not in the repository: the agent can write to its workspace, so
19
+ * a rules file there would let it grant itself permissions. Session rules last for one session; repo
20
+ * rules are kept against the repository's real path and apply to every desk session on it; user rules
21
+ * come from a file in the user's config directory, which the default sandbox cannot write.
22
+ */
23
+ export type RuleEffect = 'allow' | 'ask' | 'deny';
24
+ export type RuleTool = 'command' | 'edit' | 'any';
25
+ export type RuleSource = 'session' | 'repo' | 'user';
26
+ export interface ApprovalRule {
27
+ effect: RuleEffect;
28
+ tool: RuleTool;
29
+ /** Command words; a word may contain `*`, and a final lone `*` takes any further words. */
30
+ command?: string;
31
+ /** Project-relative globs for edits: `*` within one path segment, `**` across segments. */
32
+ paths?: string[];
33
+ }
34
+ export interface StoredRule extends ApprovalRule {
35
+ id: string;
36
+ source: RuleSource;
37
+ }
38
+ export type RuleRequest =
39
+ { tool: 'command'; argv: string[] | null } | { tool: 'edit'; paths: string[] };
40
+
41
+ const SHELL_SPECIAL = /[;&|<>`$(){}\\\n\r*?[\]~!#]/;
42
+ const PRIVATE_PART = /^(\.|credentials$|secrets$)/;
43
+
44
+ /** The command's words, or null when a shell would read it as more than plain words. */
45
+ export function commandArgv(command: string): string[] | null {
46
+ const words: string[] = [];
47
+ let word = '',
48
+ inWord = false;
49
+ for (let i = 0; i < command.length; i++) {
50
+ const ch = command[i]!;
51
+ if (ch === "'") {
52
+ const end = command.indexOf("'", i + 1);
53
+ if (end < 0) return null;
54
+ word += command.slice(i + 1, end);
55
+ inWord = true;
56
+ i = end;
57
+ } else if (ch === '"') {
58
+ const end = command.indexOf('"', i + 1);
59
+ if (end < 0) return null;
60
+ const inner = command.slice(i + 1, end);
61
+ if (/[$`\\]/.test(inner)) return null;
62
+ word += inner;
63
+ inWord = true;
64
+ i = end;
65
+ } else if (ch === ' ' || ch === '\t') {
66
+ if (inWord) words.push(word);
67
+ word = '';
68
+ inWord = false;
69
+ } else if (SHELL_SPECIAL.test(ch)) return null;
70
+ else {
71
+ word += ch;
72
+ inWord = true;
73
+ }
74
+ }
75
+ if (inWord) words.push(word);
76
+ // A leading NAME=value is an environment assignment, not a command word.
77
+ if (!words.length || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]!)) return null;
78
+ return words;
79
+ }
80
+ function patternWords(pattern: string): string[] {
81
+ const words = pattern.trim().split(/\s+/);
82
+ if (!pattern.trim() || words.some((w) => /[;&|<>`$(){}\\'"!#~?[\]]/.test(w)))
83
+ throw new ServiceError(
84
+ 'invalid_rule',
85
+ 'A command rule is plain words; a word may use *, and a final * takes any further words',
86
+ );
87
+ return words;
88
+ }
89
+ const wordRe = (w: string) =>
90
+ new RegExp(
91
+ '^' +
92
+ w
93
+ .split('*')
94
+ .map((p) => p.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
95
+ .join('.*') +
96
+ '$',
97
+ );
98
+ function commandMatches(pattern: string, argv: string[]): boolean {
99
+ const words = patternWords(pattern);
100
+ const rest = words[words.length - 1] === '*';
101
+ const fixed = rest ? words.slice(0, -1) : words;
102
+ if (rest ? argv.length < fixed.length : argv.length !== fixed.length) return false;
103
+ return fixed.every((w, i) => wordRe(w).test(argv[i]!));
104
+ }
105
+ /** A project-relative glob as a regex: `**` spans segments, `*` stays within one. */
106
+ function globRe(glob: string): RegExp {
107
+ let re = '';
108
+ for (let i = 0; i < glob.length; i++) {
109
+ if (glob.startsWith('**/', i)) {
110
+ re += '(?:.*/)?';
111
+ i += 2;
112
+ } else if (glob.startsWith('/**', i) && i + 3 === glob.length) {
113
+ re += '(?:/.*)?';
114
+ i += 2;
115
+ } else if (glob.startsWith('**', i)) {
116
+ re += '.*';
117
+ i += 1;
118
+ } else if (glob[i] === '*') re += '[^/]*';
119
+ else re += glob[i]!.replace(/[.+^${}()|[\]\\?]/g, '\\$&');
120
+ }
121
+ return new RegExp('^' + re + '$');
122
+ }
123
+ const pathMatches = (globs: string[], path: string) => globs.some((g) => globRe(g).test(path));
124
+
125
+ export function validateRule(value: unknown): ApprovalRule {
126
+ const input = object(value);
127
+ if (Object.keys(input).some((k) => !['effect', 'tool', 'command', 'paths'].includes(k)))
128
+ throw new ServiceError('invalid_rule', 'A rule has effect, tool, and command or paths only');
129
+ if (!['allow', 'ask', 'deny'].includes(String(input.effect)))
130
+ throw new ServiceError('invalid_rule', 'A rule effect is allow, ask or deny');
131
+ if (!['command', 'edit', 'any'].includes(String(input.tool)))
132
+ throw new ServiceError('invalid_rule', 'A rule tool is command, edit or any');
133
+ const rule: ApprovalRule = { effect: input.effect as RuleEffect, tool: input.tool as RuleTool };
134
+ if (input.command !== undefined) {
135
+ if (typeof input.command !== 'string' || input.command.length > 512 || input.tool === 'edit')
136
+ throw new ServiceError('invalid_rule', 'Only command rules take a command pattern');
137
+ patternWords(input.command);
138
+ rule.command = input.command.trim().split(/\s+/).join(' ');
139
+ }
140
+ if (input.paths !== undefined) {
141
+ const paths = input.paths;
142
+ if (
143
+ input.tool === 'command' ||
144
+ !Array.isArray(paths) ||
145
+ !paths.length ||
146
+ paths.length > 32 ||
147
+ paths.some(
148
+ (p) =>
149
+ typeof p !== 'string' ||
150
+ p.length > 512 ||
151
+ p.startsWith('/') ||
152
+ /[\x00-\x1f\x7f\\]/.test(p) ||
153
+ p.split('/').some((part) => !part || part === '..' || PRIVATE_PART.test(part)),
154
+ )
155
+ )
156
+ throw new ServiceError(
157
+ 'invalid_rule',
158
+ 'Edit rules take up to 32 project-relative globs; no absolute paths, traversal or private paths',
159
+ );
160
+ rule.paths = [...(paths as string[])];
161
+ }
162
+ if (rule.tool === 'edit' && !rule.paths)
163
+ throw new ServiceError('invalid_rule', 'An edit rule names the paths it covers');
164
+ return rule;
165
+ }
166
+
167
+ /** The deciding rule for a request: deny beats ask beats allow; no match asks. */
168
+ export function evaluateRules(
169
+ rules: StoredRule[],
170
+ request: RuleRequest,
171
+ ): { effect: RuleEffect; rule?: StoredRule } {
172
+ const matching = rules.filter((r) => {
173
+ if (r.tool !== 'any' && r.tool !== request.tool) return false;
174
+ if (request.tool === 'command') {
175
+ if (r.paths) return false;
176
+ if (!r.command) return r.effect === 'deny' || request.argv !== null;
177
+ return request.argv !== null && commandMatches(r.command, request.argv);
178
+ }
179
+ if (!r.paths) return r.tool === 'any' && !r.command;
180
+ return r.effect === 'allow'
181
+ ? request.paths.length > 0 && request.paths.every((p) => pathMatches(r.paths!, p))
182
+ : request.paths.some((p) => pathMatches(r.paths!, p));
183
+ });
184
+ for (const effect of ['deny', 'ask', 'allow'] as const) {
185
+ const rule = matching.find((r) => r.effect === effect);
186
+ if (rule) return { effect, rule };
187
+ }
188
+ return { effect: 'ask' };
189
+ }
190
+
191
+ export function defaultUserRulesFile(env = process.env): string {
192
+ return (
193
+ env.SALAAM_PERMISSIONS_FILE ||
194
+ join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'salaam', 'permissions.json')
195
+ );
196
+ }
197
+
198
+ export class ApprovalRules {
199
+ constructor(
200
+ readonly store: Store,
201
+ readonly userFile: string = defaultUserRulesFile(),
202
+ ) {
203
+ store.db.exec(`CREATE TABLE IF NOT EXISTS approval_rules(
204
+ id TEXT PRIMARY KEY NOT NULL,
205
+ scope TEXT NOT NULL CHECK(scope IN ('session','repo')),
206
+ session_id TEXT,
207
+ root TEXT NOT NULL,
208
+ rule TEXT NOT NULL,
209
+ created_at INTEGER NOT NULL)`);
210
+ }
211
+ /** Whether rules apply: only desk sessions, never host-managed (AgentPod) or child work. */
212
+ applies(session: string): boolean {
213
+ if (!this.store.localCoding(session)) return false;
214
+ const has = (sql: string) => {
215
+ try {
216
+ return !!this.store.db.prepare(sql).get(session);
217
+ } catch {
218
+ return false;
219
+ }
220
+ };
221
+ return (
222
+ !has('SELECT 1 FROM agentpod_admissions WHERE session_id=?') &&
223
+ !has('SELECT 1 FROM child_tasks WHERE child_session=?')
224
+ );
225
+ }
226
+ private userRules(): { rules: StoredRule[]; problem?: string } {
227
+ let text: string;
228
+ try {
229
+ text = readFileSync(this.userFile, 'utf8');
230
+ } catch {
231
+ return { rules: [] };
232
+ }
233
+ try {
234
+ const doc = object(JSON.parse(text));
235
+ if (doc.version !== 1 || !Array.isArray(doc.rules))
236
+ throw new Error('expected {"version": 1, "rules": [...]}');
237
+ return {
238
+ rules: doc.rules.map((r, i) => ({
239
+ ...validateRule(r),
240
+ id: 'user:' + i,
241
+ source: 'user' as const,
242
+ })),
243
+ };
244
+ } catch (error) {
245
+ return {
246
+ rules: [],
247
+ problem: `${this.userFile} is ignored: ${error instanceof Error ? error.message : 'unreadable'}`,
248
+ };
249
+ }
250
+ }
251
+ list(session: string): StoredRule[] {
252
+ const root = this.store.session(session).cwd;
253
+ const stored = this.store.db
254
+ .prepare(
255
+ "SELECT id,scope,rule FROM approval_rules WHERE (scope='session' AND session_id=?) OR (scope='repo' AND root=?) ORDER BY created_at,rowid",
256
+ )
257
+ .all(session, root) as { id: string; scope: RuleSource; rule: string }[];
258
+ return [
259
+ ...stored.map((r) => ({
260
+ ...(JSON.parse(r.rule) as ApprovalRule),
261
+ id: r.id,
262
+ source: r.scope,
263
+ })),
264
+ ...this.userRules().rules,
265
+ ];
266
+ }
267
+ problems(session: string): string[] {
268
+ void session;
269
+ const problem = this.userRules().problem;
270
+ return problem ? [problem] : [];
271
+ }
272
+ add(session: string, scope: 'session' | 'repo', value: unknown, request: string): StoredRule {
273
+ const rule = validateRule(value);
274
+ if (!this.applies(session))
275
+ throw new ServiceError(
276
+ 'rules_unavailable',
277
+ 'Approval rules apply only to desk coding sessions',
278
+ );
279
+ return this.store.request(
280
+ 'local-operator',
281
+ request,
282
+ { kind: 'approval-rule', session, scope, rule },
283
+ () => {
284
+ const id = randomUUID();
285
+ this.store.db
286
+ .prepare(
287
+ 'INSERT INTO approval_rules(id,scope,session_id,root,rule,created_at) VALUES(?,?,?,?,?,?)',
288
+ )
289
+ .run(
290
+ id,
291
+ scope,
292
+ scope === 'session' ? session : null,
293
+ this.store.session(session).cwd,
294
+ JSON.stringify(rule),
295
+ Date.now(),
296
+ );
297
+ this.store.event(session, 'approval_rule.added', { id, scope, rule });
298
+ return { ...rule, id, source: scope };
299
+ },
300
+ );
301
+ }
302
+ remove(session: string, id: string): void {
303
+ const root = this.store.session(session).cwd;
304
+ const done = this.store.db
305
+ .prepare(
306
+ "DELETE FROM approval_rules WHERE id=? AND ((scope='session' AND session_id=?) OR (scope='repo' AND root=?))",
307
+ )
308
+ .run(id, session, root);
309
+ if (!done.changes)
310
+ throw new ServiceError('rule_not_found', 'No such rule for this session or repository');
311
+ this.store.event(session, 'approval_rule.removed', { id });
312
+ }
313
+ /** The rule an approved operation stands for: the exact command words, or the exact edited paths. */
314
+ ruleFor(args: { action: string; command?: string; edits?: { path: string }[] }): ApprovalRule {
315
+ if (args.action === 'command') {
316
+ const argv = commandArgv(args.command ?? '');
317
+ if (!argv)
318
+ throw new ServiceError(
319
+ 'invalid_rule',
320
+ 'This command uses shell features a rule cannot match safely; approve it once instead',
321
+ );
322
+ const quote = (w: string) =>
323
+ /^[A-Za-z0-9_@%+=:,./-]+$/.test(w) && !w.includes('*') ? w : null;
324
+ if (argv.some((w) => quote(w) === null))
325
+ throw new ServiceError(
326
+ 'invalid_rule',
327
+ 'This command has words a rule cannot spell exactly; approve it once instead',
328
+ );
329
+ return validateRule({ effect: 'allow', tool: 'command', command: argv.join(' ') });
330
+ }
331
+ if (args.action === 'edit' && args.edits?.length)
332
+ return validateRule({ effect: 'allow', tool: 'edit', paths: args.edits.map((e) => e.path) });
333
+ throw new ServiceError('invalid_rule', 'Only local commands and edits can become rules');
334
+ }
335
+ addFromOperation(
336
+ session: string,
337
+ operation: string,
338
+ scope: 'session' | 'repo',
339
+ request: string,
340
+ ): StoredRule {
341
+ const row = this.store.db
342
+ .prepare('SELECT args,session_id FROM local_calls WHERE operation_id=?')
343
+ .get(operation) as { args: string; session_id: string } | undefined;
344
+ if (!row || row.session_id !== session)
345
+ throw new ServiceError(
346
+ 'rule_not_found',
347
+ 'That request is not a local command or edit in this session',
348
+ );
349
+ return this.add(session, scope, this.ruleFor(JSON.parse(row.args)), request);
350
+ }
351
+ }
352
+
353
+ /** Why a reviewed request can only be approved once: a command with shell features (pipes,
354
+ * redirects, substitutions) can never become an approval rule. Undefined when a rule is possible. */
355
+ export function onceOnlyReason(value: string): string | undefined {
356
+ const command = /\nCommand:\n([\s\S]*)$/.exec(value)?.[1];
357
+ return command !== undefined && commandArgv(command) === null
358
+ ? 'This command uses shell features (pipes, redirects, substitutions), so it cannot become a rule. Approve it once or deny it.'
359
+ : undefined;
360
+ }
package/src/attach.ts CHANGED
@@ -29,6 +29,7 @@ interface Pending {
29
29
  expected_revision: number;
30
30
  text: string;
31
31
  continues_attempt_id?: string;
32
+ confirm_sensitive?: boolean;
32
33
  command?:
33
34
  | {
34
35
  kind: 'decision';
@@ -74,7 +75,7 @@ export class Outbox {
74
75
  renameSync(temporary, path);
75
76
  syncDirectory(this.dir);
76
77
  }
77
- enqueue(text: string, revision: number, continues?: string): Pending {
78
+ enqueue(text: string, revision: number, continues?: string, confirmSensitive = false): Pending {
78
79
  if (!text.trim() || text.length > 256 * 1024 || text.trimStart().startsWith('/'))
79
80
  throw new Error('Enter a plain instruction of at most 256 Ki characters');
80
81
  const created = this.records().reduce(
@@ -87,6 +88,7 @@ export class Outbox {
87
88
  expected_revision: revision,
88
89
  text,
89
90
  ...(continues ? { continues_attempt_id: continues } : {}),
91
+ ...(confirmSensitive ? { confirm_sensitive: true } : {}),
90
92
  };
91
93
  this.save(record);
92
94
  return record;
@@ -228,6 +230,7 @@ export class Outbox {
228
230
  'approval_mismatch',
229
231
  'binding_changed',
230
232
  'attempt_settled',
233
+ 'sensitive_attachment',
231
234
  ].includes(error.code) ||
232
235
  (record.command && error.code === 'not_found') ||
233
236
  (['task', 'job'].includes(record.command?.kind ?? '') &&
package/src/autonomy.ts CHANGED
@@ -1,12 +1,39 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import type { Store } from './store.js';
3
3
  import { canonical, digest, object, ServiceError, type Controller } from './protocol.js';
4
- export type PermissionMode = 'inspect' | 'review' | 'scoped-edit';
4
+ export type PermissionMode =
5
+ | 'inspect'
6
+ | 'review'
7
+ | 'scoped-edit'
8
+ // Phase 2b (operator rulings 2026-10-31): the one-key modes of an interactive session.
9
+ | 'accept-edits'
10
+ | 'auto';
11
+ const MODES: readonly PermissionMode[] = [
12
+ 'inspect',
13
+ 'review',
14
+ 'scoped-edit',
15
+ 'accept-edits',
16
+ 'auto',
17
+ ];
18
+ /** Modes in which the service performs no local mutation, however it was approved. */
19
+ const READ_ONLY_MODES: readonly PermissionMode[] = ['inspect'];
20
+ /** Shift+Tab: review → accept edits → auto → review. Full access and YOLO are never in the cycle. */
21
+ const CYCLE: readonly PermissionMode[] = ['review', 'accept-edits', 'auto'];
22
+ export function nextMode(mode: PermissionMode): PermissionMode {
23
+ const at = CYCLE.indexOf(mode);
24
+ return at < 0 ? 'review' : CYCLE[(at + 1) % CYCLE.length]!;
25
+ }
26
+ /** Sandbox levels a session can choose beyond its mode: full access (no OS sandbox; dangerous
27
+ * commands still ask) and YOLO (no OS sandbox and no approvals at all). */
28
+ export type SessionAccess = 'full' | 'yolo';
5
29
  export interface PermissionPolicy {
6
30
  revision: number;
7
31
  mode: PermissionMode;
8
32
  paths: string[];
9
33
  root: string;
34
+ /** Present only when the person chose full access or YOLO: later commands run without the OS
35
+ * sandbox. Absent for sandboxed policies, so their digests (and receipts bound to them) are unchanged. */
36
+ access?: SessionAccess;
10
37
  }
11
38
  export function permissionPolicy(store: Store, session: string): PermissionPolicy {
12
39
  const root = store.session(session).cwd;
@@ -28,10 +55,17 @@ export function permissionPolicy(store: Store, session: string): PermissionPolic
28
55
  !Number.isSafeInteger(saved.revision) ||
29
56
  Number(saved.revision) < 1 ||
30
57
  saved.root !== root ||
31
- Object.keys(saved).some((key) => !['revision', 'root', 'mode', 'paths'].includes(key))
58
+ Object.keys(saved).some(
59
+ (key) => !['revision', 'root', 'mode', 'paths', 'access'].includes(key),
60
+ )
32
61
  )
33
62
  throw new Error('Invalid saved policy');
34
- const policy = validatePermissions({ mode: saved.mode, paths: saved.paths });
63
+ const policy = validatePermissions({
64
+ // Plan mode was removed (operator ruling 2026-10-31): a session saved in it reads as review.
65
+ mode: saved.mode === 'plan' ? 'review' : saved.mode,
66
+ paths: saved.paths,
67
+ ...(saved.access === undefined ? {} : { access: saved.access }),
68
+ });
35
69
  return { ...policy, revision: Number(saved.revision), root };
36
70
  } catch {
37
71
  throw new ServiceError(
@@ -40,15 +74,20 @@ export function permissionPolicy(store: Store, session: string): PermissionPolic
40
74
  );
41
75
  }
42
76
  }
43
- export function validatePermissions(value: unknown): { mode: PermissionMode; paths: string[] } {
77
+ export function validatePermissions(value: unknown): {
78
+ mode: PermissionMode;
79
+ paths: string[];
80
+ access?: SessionAccess;
81
+ } {
44
82
  const input = object(value);
45
83
  if (
46
- Object.keys(input).some((k) => !['mode', 'paths'].includes(k)) ||
47
- !['inspect', 'review', 'scoped-edit'].includes(String(input.mode))
84
+ Object.keys(input).some((k) => !['mode', 'paths', 'access'].includes(k)) ||
85
+ !(MODES as readonly string[]).includes(String(input.mode)) ||
86
+ (input.access !== undefined && input.access !== 'full' && input.access !== 'yolo')
48
87
  )
49
88
  throw new ServiceError(
50
89
  'invalid_permissions',
51
- 'Choose inspect, review or scoped-edit; unknown policy fields are rejected',
90
+ 'Choose inspect, review, scoped-edit, accept-edits or auto, and access "full", "yolo" or none; unknown policy fields are rejected',
52
91
  );
53
92
  const paths = input.paths ?? [];
54
93
  if (
@@ -81,7 +120,13 @@ export function validatePermissions(value: unknown): { mode: PermissionMode; pat
81
120
  'invalid_permissions',
82
121
  'Only scoped-edit takes paths, and requires at least one',
83
122
  );
84
- return { mode: input.mode as PermissionMode, paths: [...paths].sort() };
123
+ return {
124
+ mode: input.mode as PermissionMode,
125
+ paths: [...paths].sort(),
126
+ ...(input.access === 'full' || input.access === 'yolo'
127
+ ? { access: input.access as SessionAccess }
128
+ : {}),
129
+ };
85
130
  }
86
131
  export function configurePermissions(
87
132
  store: Store,
@@ -90,8 +135,23 @@ export function configurePermissions(
90
135
  expected: number,
91
136
  value: unknown,
92
137
  actor: Controller,
138
+ /** The operator's settings: may an interactive session choose full access or YOLO, and is this a
139
+ * desk session (YOLO is never for host-managed, board or child work)? */
140
+ opts: { allowFullAccess?: boolean; allowYolo?: boolean; deskSession?: boolean } = {},
93
141
  ) {
94
142
  const policy = validatePermissions(value);
143
+ if (policy.access === 'full' && opts.allowFullAccess === false)
144
+ throw new ServiceError(
145
+ 'full_access_denied',
146
+ 'The operator does not allow full access on this host; commands stay in the OS sandbox',
147
+ );
148
+ if (policy.access === 'yolo' && (opts.allowYolo === false || opts.deskSession !== true))
149
+ throw new ServiceError(
150
+ 'yolo_denied',
151
+ opts.deskSession !== true
152
+ ? 'YOLO is only for your own desk sessions, never host-managed, board or child work'
153
+ : 'The operator does not allow YOLO on this host; commands keep their approvals',
154
+ );
95
155
  return store.request(
96
156
  'local-operator',
97
157
  request,
@@ -104,7 +164,7 @@ export function configurePermissions(
104
164
  'permissions_unavailable',
105
165
  'This policy applies only to local workspace sessions, not managed stations or child workers',
106
166
  );
107
- if (policy.mode !== 'inspect' && !store.localCoding(session))
167
+ if (!READ_ONLY_MODES.includes(policy.mode) && !store.localCoding(session))
108
168
  throw new ServiceError(
109
169
  'local_denied',
110
170
  'Create a coding session first; policy cannot grant missing coding capability',
@@ -140,7 +200,7 @@ export function assertPermissionBinding(
140
200
  root: string,
141
201
  ): void {
142
202
  const policy = permissionPolicy(store, session);
143
- if (policy.root !== root || policy.mode === 'inspect')
203
+ if (policy.root !== root || READ_ONLY_MODES.includes(policy.mode))
144
204
  throw new ServiceError('permission_denied', 'Current policy does not permit local mutations');
145
205
  const binding = store.db
146
206
  .prepare(
@@ -153,4 +213,45 @@ export function assertPermissionBinding(
153
213
  'Auto-approval belongs to an older policy; prepare and review a fresh operation',
154
214
  );
155
215
  }
216
+ /** A project-relative path the session's modes may edit without review: not private or hidden. */
217
+ function ordinaryProjectPath(path: string): boolean {
218
+ return (
219
+ path.length > 0 &&
220
+ !path.startsWith('/') &&
221
+ path
222
+ .split('/')
223
+ .every(
224
+ (part) =>
225
+ part !== '' &&
226
+ part !== '..' &&
227
+ !part.startsWith('.') &&
228
+ !['credentials', 'secrets', 'node_modules', 'vendor'].includes(part),
229
+ )
230
+ );
231
+ }
232
+ /** Whether this policy approves these edits by itself: scoped edits, accept-edits, auto and full
233
+ * access approve ordinary project edits; YOLO approves every edit. */
234
+ export function autoApprovesEdits(policy: PermissionPolicy, paths: string[]): boolean {
235
+ if (policy.access === 'yolo') return paths.length > 0;
236
+ if (policy.mode === 'scoped-edit' && !policy.access) return matchesEditScope(policy, paths);
237
+ return (
238
+ (policy.mode === 'accept-edits' || policy.mode === 'auto' || policy.access === 'full') &&
239
+ paths.length > 0 &&
240
+ paths.every(ordinaryProjectPath)
241
+ );
242
+ }
243
+ /** Whether this policy approves a command by itself: auto inside the OS sandbox; full access unless
244
+ * the danger check calls the command dangerous; YOLO always. */
245
+ export function autoApprovesCommands(
246
+ policy: PermissionPolicy,
247
+ sandboxed: boolean,
248
+ dangerous: boolean,
249
+ /** Pushes or opens a pull request: asks in every mode but YOLO (operator ruling 2026-10-31). */
250
+ publishes = false,
251
+ ): boolean {
252
+ if (policy.access === 'yolo') return true;
253
+ if (publishes) return false;
254
+ if (policy.access === 'full') return !dangerous;
255
+ return policy.mode === 'auto' && sandboxed;
256
+ }
156
257
  export const permissionDigest = (policy: PermissionPolicy) => digest(JSON.parse(canonical(policy)));