@superjackfruitlabs/salaam 0.0.4 → 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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. 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)));
package/src/cli-help.ts CHANGED
@@ -53,12 +53,22 @@ salaam agents revoke GRANT_ID --session SESSION_ID
53
53
  Profiles are data in .salaam/agents/*.md with JSON front matter and a prompt body.
54
54
  Inspect the effective tools and digest before admission. Project definitions cannot
55
55
  grant themselves authority. Mutations accept --request-id and --takeover.`,
56
+ code: `salaam code [--continue | --new | --resume] [--cwd DIR] [--takeover]
57
+ salaam here (the same)
58
+ salaam -c (short for salaam code --continue)
59
+
60
+ Work in this folder with one command: start the service if it is not running, then continue
61
+ the folder's latest session (the default), start a new local coding session (--new), or pick
62
+ any session from a list (--resume), this folder's sessions first. No session id needed.
63
+ SALAAM_TERMINAL_STATUS=off stops the window-title and Herdr status reports.`,
56
64
  tui: `salaam tui [--session SESSION_ID] [--observe|--takeover] [--theme night|paper]
57
65
  [--cwd DIR] [--coding-root PRIVATE_DIR] [--coding-socket DOCKER_SOCKET]
58
66
 
59
67
  Bare sal opens the same Salaam terminal interface in a TTY.
60
- Ctrl+K actions · Ctrl+P sessions · Ctrl+N new · Ctrl+O approvals
61
- Ctrl+G changes · Ctrl+T commands · Ctrl+E external editor · Ctrl+C detach.
68
+ Ctrl+K (empty composer) or F2 actions · Ctrl+P sessions · Ctrl+N new · Ctrl+O approvals
69
+ Alt+G changes · Ctrl+T commands · Ctrl+G external editor · Ctrl+R history search
70
+ F5 refresh · !command shell · Ctrl+C stops/clears, twice to detach.
71
+ ? on an empty composer (or F1) shows the keys for where you are.
62
72
  Work continues in the service when the terminal closes.`,
63
73
  remote: `salaam remote SSH_HOST [--remote-bin ABSOLUTE_PATH] [--state REMOTE_DIR]
64
74
  [--session SESSION_ID] [--observe|--takeover] [--theme night|paper]
@@ -88,9 +98,11 @@ Run the M9.1 boundary proof on a pre-provisioned local Linux Docker engine.
88
98
  Only disposable fixtures are mounted. The pinned image must already be present.
89
99
  No coding tools are enabled. A failed/unavailable backend exits nonzero.
90
100
  Cleanup reconciles the saved proof run without replaying commands.`,
91
- acp: `salaam acp [--state DIR] [--coding]
101
+ acp: `salaam acp [--state DIR] [--local | --coding]
92
102
 
93
103
  Run the ACP v1 stdio client adapter against an already running Salaam service.
104
+ --local creates your desk session in the editor's project folder, the same as
105
+ salaam in a terminal there: modes, approvals with diffs, rules and /rewind.
94
106
  --coding creates owned coding sessions; the service must have --coding-root.
95
107
  Loading an existing session preserves its original tool authority.`,
96
108
  artifacts: `salaam artifacts put SESSION_ID --text TEXT [--request-id ID] [--takeover]
@@ -225,6 +237,8 @@ Status prints metadata only. Existing Anthropic keys remain compatible.`,
225
237
  [--a2a-listen-config OWNER_ONLY_FILE]
226
238
  [--agentpod-config OWNER_ONLY_FILE] [--mcp-config FILE]
227
239
  [--tools-user USER]
240
+ [--local-sandbox read-only|workspace-write|full-access]
241
+ [--local-sandbox-read PATH,…|toolchain] [--unsandboxed-commands allow]
228
242
  salaam service status
229
243
  salaam service stop
230
244
 
@@ -232,6 +246,18 @@ salaam service stop
232
246
  USER through sudo -n -u USER on Linux. Without it, tools share the provider
233
247
  credential's OS user and start prints a warning.
234
248
 
249
+ Local commands (session create --local-coding) run under the host OS sandbox:
250
+ bubblewrap on Linux, Seatbelt on macOS. --local-sandbox (or SALAAM_LOCAL_SANDBOX)
251
+ picks the level: workspace-write (default) writes only inside the workspace,
252
+ read-only writes nothing, full-access runs commands unsandboxed with network. A
253
+ sandboxed command has no network and cannot read outside the workspace and the
254
+ system; --local-sandbox-read (or SALAAM_LOCAL_SANDBOX_READ) lists read-only paths
255
+ it may also read, comma-separated, and "toolchain" adds this host's node prefix,
256
+ ~/.npm, ~/.nvm, ~/.cargo, ~/.rustup, ~/go and ~/.gitconfig where present. Home
257
+ itself and credential directories (~/.ssh, ~/.aws, keychains …) are refused. On
258
+ macOS commands get the real git from the active developer dir. A host with no
259
+ usable sandbox refuses commands unless --unsandboxed-commands allow.
260
+
235
261
  Without --agentpod-config, start binds to ~/.config/salaam/agentpod-pins.json
236
262
  ($XDG_CONFIG_HOME/salaam) when that file exists.
237
263
 
@@ -242,7 +268,8 @@ An AgentPod daemon requires node-bound admissions and a fresh authority lease.
242
268
  Keep its private state outside the pinned workspace. Coding, external runtimes,
243
269
  effect connectors and managed MCP are unavailable in this profile.`,
244
270
  session: `salaam session create [--cwd DIR] [--request-id ID] [--coding | --local | --local-coding]
245
- --local-coding allows reviewed edits and native commands (no OS sandbox)
271
+ --local-coding allows reviewed edits and native commands, each under the host OS sandbox
272
+ (see salaam help service for levels and read paths)
246
273
  --local allows bounded local file inspection; optionally select --provider ID --model ID
247
274
  salaam session list
248
275
  salaam session inspect SESSION_ID
@@ -295,6 +322,7 @@ const globalOptions = `Global options:
295
322
  -h, --help Show help without starting a service or model request.
296
323
  -V, --version Show the installed version.`;
297
324
  export function cliHelp(group?: string): string | undefined {
325
+ if (group === 'here') group = 'code';
298
326
  if (group) return commands[group] ? `${commands[group]}\n\n${globalOptions}` : undefined;
299
327
  return `Salaam — persistent coding sessions
300
328
 
@@ -302,6 +330,7 @@ Usage: salaam <command> [options]
302
330
  sal <command> [options]
303
331
 
304
332
  Commands:
333
+ code Work in this folder: continue its session or start one (also: here, -c)
305
334
  tui Open the Salaam terminal interface (also bare sal)
306
335
  remote Open the same session over SSH
307
336
  providers List pinned Pi providers and capabilities