@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,287 @@
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 { object, ServiceError } from './protocol.js';
6
+ const SHELL_SPECIAL = /[;&|<>`$(){}\\\n\r*?[\]~!#]/;
7
+ const PRIVATE_PART = /^(\.|credentials$|secrets$)/;
8
+ /** The command's words, or null when a shell would read it as more than plain words. */
9
+ export function commandArgv(command) {
10
+ const words = [];
11
+ let word = '', inWord = false;
12
+ for (let i = 0; i < command.length; i++) {
13
+ const ch = command[i];
14
+ if (ch === "'") {
15
+ const end = command.indexOf("'", i + 1);
16
+ if (end < 0)
17
+ return null;
18
+ word += command.slice(i + 1, end);
19
+ inWord = true;
20
+ i = end;
21
+ }
22
+ else if (ch === '"') {
23
+ const end = command.indexOf('"', i + 1);
24
+ if (end < 0)
25
+ return null;
26
+ const inner = command.slice(i + 1, end);
27
+ if (/[$`\\]/.test(inner))
28
+ return null;
29
+ word += inner;
30
+ inWord = true;
31
+ i = end;
32
+ }
33
+ else if (ch === ' ' || ch === '\t') {
34
+ if (inWord)
35
+ words.push(word);
36
+ word = '';
37
+ inWord = false;
38
+ }
39
+ else if (SHELL_SPECIAL.test(ch))
40
+ return null;
41
+ else {
42
+ word += ch;
43
+ inWord = true;
44
+ }
45
+ }
46
+ if (inWord)
47
+ words.push(word);
48
+ // A leading NAME=value is an environment assignment, not a command word.
49
+ if (!words.length || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))
50
+ return null;
51
+ return words;
52
+ }
53
+ function patternWords(pattern) {
54
+ const words = pattern.trim().split(/\s+/);
55
+ if (!pattern.trim() || words.some((w) => /[;&|<>`$(){}\\'"!#~?[\]]/.test(w)))
56
+ throw new ServiceError('invalid_rule', 'A command rule is plain words; a word may use *, and a final * takes any further words');
57
+ return words;
58
+ }
59
+ const wordRe = (w) => new RegExp('^' +
60
+ w
61
+ .split('*')
62
+ .map((p) => p.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
63
+ .join('.*') +
64
+ '$');
65
+ function commandMatches(pattern, argv) {
66
+ const words = patternWords(pattern);
67
+ const rest = words[words.length - 1] === '*';
68
+ const fixed = rest ? words.slice(0, -1) : words;
69
+ if (rest ? argv.length < fixed.length : argv.length !== fixed.length)
70
+ return false;
71
+ return fixed.every((w, i) => wordRe(w).test(argv[i]));
72
+ }
73
+ /** A project-relative glob as a regex: `**` spans segments, `*` stays within one. */
74
+ function globRe(glob) {
75
+ let re = '';
76
+ for (let i = 0; i < glob.length; i++) {
77
+ if (glob.startsWith('**/', i)) {
78
+ re += '(?:.*/)?';
79
+ i += 2;
80
+ }
81
+ else if (glob.startsWith('/**', i) && i + 3 === glob.length) {
82
+ re += '(?:/.*)?';
83
+ i += 2;
84
+ }
85
+ else if (glob.startsWith('**', i)) {
86
+ re += '.*';
87
+ i += 1;
88
+ }
89
+ else if (glob[i] === '*')
90
+ re += '[^/]*';
91
+ else
92
+ re += glob[i].replace(/[.+^${}()|[\]\\?]/g, '\\$&');
93
+ }
94
+ return new RegExp('^' + re + '$');
95
+ }
96
+ const pathMatches = (globs, path) => globs.some((g) => globRe(g).test(path));
97
+ export function validateRule(value) {
98
+ const input = object(value);
99
+ if (Object.keys(input).some((k) => !['effect', 'tool', 'command', 'paths'].includes(k)))
100
+ throw new ServiceError('invalid_rule', 'A rule has effect, tool, and command or paths only');
101
+ if (!['allow', 'ask', 'deny'].includes(String(input.effect)))
102
+ throw new ServiceError('invalid_rule', 'A rule effect is allow, ask or deny');
103
+ if (!['command', 'edit', 'any'].includes(String(input.tool)))
104
+ throw new ServiceError('invalid_rule', 'A rule tool is command, edit or any');
105
+ const rule = { effect: input.effect, tool: input.tool };
106
+ if (input.command !== undefined) {
107
+ if (typeof input.command !== 'string' || input.command.length > 512 || input.tool === 'edit')
108
+ throw new ServiceError('invalid_rule', 'Only command rules take a command pattern');
109
+ patternWords(input.command);
110
+ rule.command = input.command.trim().split(/\s+/).join(' ');
111
+ }
112
+ if (input.paths !== undefined) {
113
+ const paths = input.paths;
114
+ if (input.tool === 'command' ||
115
+ !Array.isArray(paths) ||
116
+ !paths.length ||
117
+ paths.length > 32 ||
118
+ paths.some((p) => typeof p !== 'string' ||
119
+ p.length > 512 ||
120
+ p.startsWith('/') ||
121
+ /[\x00-\x1f\x7f\\]/.test(p) ||
122
+ p.split('/').some((part) => !part || part === '..' || PRIVATE_PART.test(part))))
123
+ throw new ServiceError('invalid_rule', 'Edit rules take up to 32 project-relative globs; no absolute paths, traversal or private paths');
124
+ rule.paths = [...paths];
125
+ }
126
+ if (rule.tool === 'edit' && !rule.paths)
127
+ throw new ServiceError('invalid_rule', 'An edit rule names the paths it covers');
128
+ return rule;
129
+ }
130
+ /** The deciding rule for a request: deny beats ask beats allow; no match asks. */
131
+ export function evaluateRules(rules, request) {
132
+ const matching = rules.filter((r) => {
133
+ if (r.tool !== 'any' && r.tool !== request.tool)
134
+ return false;
135
+ if (request.tool === 'command') {
136
+ if (r.paths)
137
+ return false;
138
+ if (!r.command)
139
+ return r.effect === 'deny' || request.argv !== null;
140
+ return request.argv !== null && commandMatches(r.command, request.argv);
141
+ }
142
+ if (!r.paths)
143
+ return r.tool === 'any' && !r.command;
144
+ return r.effect === 'allow'
145
+ ? request.paths.length > 0 && request.paths.every((p) => pathMatches(r.paths, p))
146
+ : request.paths.some((p) => pathMatches(r.paths, p));
147
+ });
148
+ for (const effect of ['deny', 'ask', 'allow']) {
149
+ const rule = matching.find((r) => r.effect === effect);
150
+ if (rule)
151
+ return { effect, rule };
152
+ }
153
+ return { effect: 'ask' };
154
+ }
155
+ export function defaultUserRulesFile(env = process.env) {
156
+ return (env.SALAAM_PERMISSIONS_FILE ||
157
+ join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'salaam', 'permissions.json'));
158
+ }
159
+ export class ApprovalRules {
160
+ store;
161
+ userFile;
162
+ constructor(store, userFile = defaultUserRulesFile()) {
163
+ this.store = store;
164
+ this.userFile = userFile;
165
+ store.db.exec(`CREATE TABLE IF NOT EXISTS approval_rules(
166
+ id TEXT PRIMARY KEY NOT NULL,
167
+ scope TEXT NOT NULL CHECK(scope IN ('session','repo')),
168
+ session_id TEXT,
169
+ root TEXT NOT NULL,
170
+ rule TEXT NOT NULL,
171
+ created_at INTEGER NOT NULL)`);
172
+ }
173
+ /** Whether rules apply: only desk sessions, never host-managed (AgentPod) or child work. */
174
+ applies(session) {
175
+ if (!this.store.localCoding(session))
176
+ return false;
177
+ const has = (sql) => {
178
+ try {
179
+ return !!this.store.db.prepare(sql).get(session);
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ };
185
+ return (!has('SELECT 1 FROM agentpod_admissions WHERE session_id=?') &&
186
+ !has('SELECT 1 FROM child_tasks WHERE child_session=?'));
187
+ }
188
+ userRules() {
189
+ let text;
190
+ try {
191
+ text = readFileSync(this.userFile, 'utf8');
192
+ }
193
+ catch {
194
+ return { rules: [] };
195
+ }
196
+ try {
197
+ const doc = object(JSON.parse(text));
198
+ if (doc.version !== 1 || !Array.isArray(doc.rules))
199
+ throw new Error('expected {"version": 1, "rules": [...]}');
200
+ return {
201
+ rules: doc.rules.map((r, i) => ({
202
+ ...validateRule(r),
203
+ id: 'user:' + i,
204
+ source: 'user',
205
+ })),
206
+ };
207
+ }
208
+ catch (error) {
209
+ return {
210
+ rules: [],
211
+ problem: `${this.userFile} is ignored: ${error instanceof Error ? error.message : 'unreadable'}`,
212
+ };
213
+ }
214
+ }
215
+ list(session) {
216
+ const root = this.store.session(session).cwd;
217
+ const stored = this.store.db
218
+ .prepare("SELECT id,scope,rule FROM approval_rules WHERE (scope='session' AND session_id=?) OR (scope='repo' AND root=?) ORDER BY created_at,rowid")
219
+ .all(session, root);
220
+ return [
221
+ ...stored.map((r) => ({
222
+ ...JSON.parse(r.rule),
223
+ id: r.id,
224
+ source: r.scope,
225
+ })),
226
+ ...this.userRules().rules,
227
+ ];
228
+ }
229
+ problems(session) {
230
+ void session;
231
+ const problem = this.userRules().problem;
232
+ return problem ? [problem] : [];
233
+ }
234
+ add(session, scope, value, request) {
235
+ const rule = validateRule(value);
236
+ if (!this.applies(session))
237
+ throw new ServiceError('rules_unavailable', 'Approval rules apply only to desk coding sessions');
238
+ return this.store.request('local-operator', request, { kind: 'approval-rule', session, scope, rule }, () => {
239
+ const id = randomUUID();
240
+ this.store.db
241
+ .prepare('INSERT INTO approval_rules(id,scope,session_id,root,rule,created_at) VALUES(?,?,?,?,?,?)')
242
+ .run(id, scope, scope === 'session' ? session : null, this.store.session(session).cwd, JSON.stringify(rule), Date.now());
243
+ this.store.event(session, 'approval_rule.added', { id, scope, rule });
244
+ return { ...rule, id, source: scope };
245
+ });
246
+ }
247
+ remove(session, id) {
248
+ const root = this.store.session(session).cwd;
249
+ const done = this.store.db
250
+ .prepare("DELETE FROM approval_rules WHERE id=? AND ((scope='session' AND session_id=?) OR (scope='repo' AND root=?))")
251
+ .run(id, session, root);
252
+ if (!done.changes)
253
+ throw new ServiceError('rule_not_found', 'No such rule for this session or repository');
254
+ this.store.event(session, 'approval_rule.removed', { id });
255
+ }
256
+ /** The rule an approved operation stands for: the exact command words, or the exact edited paths. */
257
+ ruleFor(args) {
258
+ if (args.action === 'command') {
259
+ const argv = commandArgv(args.command ?? '');
260
+ if (!argv)
261
+ throw new ServiceError('invalid_rule', 'This command uses shell features a rule cannot match safely; approve it once instead');
262
+ const quote = (w) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(w) && !w.includes('*') ? w : null;
263
+ if (argv.some((w) => quote(w) === null))
264
+ throw new ServiceError('invalid_rule', 'This command has words a rule cannot spell exactly; approve it once instead');
265
+ return validateRule({ effect: 'allow', tool: 'command', command: argv.join(' ') });
266
+ }
267
+ if (args.action === 'edit' && args.edits?.length)
268
+ return validateRule({ effect: 'allow', tool: 'edit', paths: args.edits.map((e) => e.path) });
269
+ throw new ServiceError('invalid_rule', 'Only local commands and edits can become rules');
270
+ }
271
+ addFromOperation(session, operation, scope, request) {
272
+ const row = this.store.db
273
+ .prepare('SELECT args,session_id FROM local_calls WHERE operation_id=?')
274
+ .get(operation);
275
+ if (!row || row.session_id !== session)
276
+ throw new ServiceError('rule_not_found', 'That request is not a local command or edit in this session');
277
+ return this.add(session, scope, this.ruleFor(JSON.parse(row.args)), request);
278
+ }
279
+ }
280
+ /** Why a reviewed request can only be approved once: a command with shell features (pipes,
281
+ * redirects, substitutions) can never become an approval rule. Undefined when a rule is possible. */
282
+ export function onceOnlyReason(value) {
283
+ const command = /\nCommand:\n([\s\S]*)$/.exec(value)?.[1];
284
+ return command !== undefined && commandArgv(command) === null
285
+ ? 'This command uses shell features (pipes, redirects, substitutions), so it cannot become a rule. Approve it once or deny it.'
286
+ : undefined;
287
+ }
@@ -41,7 +41,7 @@ export class Outbox {
41
41
  renameSync(temporary, path);
42
42
  syncDirectory(this.dir);
43
43
  }
44
- enqueue(text, revision, continues) {
44
+ enqueue(text, revision, continues, confirmSensitive = false) {
45
45
  if (!text.trim() || text.length > 256 * 1024 || text.trimStart().startsWith('/'))
46
46
  throw new Error('Enter a plain instruction of at most 256 Ki characters');
47
47
  const created = this.records().reduce((latest, record) => Math.max(latest, record.created_at + 1), Date.now());
@@ -51,6 +51,7 @@ export class Outbox {
51
51
  expected_revision: revision,
52
52
  text,
53
53
  ...(continues ? { continues_attempt_id: continues } : {}),
54
+ ...(confirmSensitive ? { confirm_sensitive: true } : {}),
54
55
  };
55
56
  this.save(record);
56
57
  return record;
@@ -174,6 +175,7 @@ export class Outbox {
174
175
  'approval_mismatch',
175
176
  'binding_changed',
176
177
  'attempt_settled',
178
+ 'sensitive_attachment',
177
179
  ].includes(error.code) ||
178
180
  (record.command && error.code === 'not_found') ||
179
181
  (['task', 'job'].includes(record.command?.kind ?? '') &&
@@ -1,5 +1,20 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { canonical, digest, object, ServiceError } from './protocol.js';
3
+ const MODES = [
4
+ 'inspect',
5
+ 'review',
6
+ 'scoped-edit',
7
+ 'accept-edits',
8
+ 'auto',
9
+ ];
10
+ /** Modes in which the service performs no local mutation, however it was approved. */
11
+ const READ_ONLY_MODES = ['inspect'];
12
+ /** Shift+Tab: review → accept edits → auto → review. Full access and YOLO are never in the cycle. */
13
+ const CYCLE = ['review', 'accept-edits', 'auto'];
14
+ export function nextMode(mode) {
15
+ const at = CYCLE.indexOf(mode);
16
+ return at < 0 ? 'review' : CYCLE[(at + 1) % CYCLE.length];
17
+ }
3
18
  export function permissionPolicy(store, session) {
4
19
  const root = store.session(session).cwd;
5
20
  const row = store.db
@@ -17,9 +32,14 @@ export function permissionPolicy(store, session) {
17
32
  if (!Number.isSafeInteger(saved.revision) ||
18
33
  Number(saved.revision) < 1 ||
19
34
  saved.root !== root ||
20
- Object.keys(saved).some((key) => !['revision', 'root', 'mode', 'paths'].includes(key)))
35
+ Object.keys(saved).some((key) => !['revision', 'root', 'mode', 'paths', 'access'].includes(key)))
21
36
  throw new Error('Invalid saved policy');
22
- const policy = validatePermissions({ mode: saved.mode, paths: saved.paths });
37
+ const policy = validatePermissions({
38
+ // Plan mode was removed (operator ruling 2026-10-31): a session saved in it reads as review.
39
+ mode: saved.mode === 'plan' ? 'review' : saved.mode,
40
+ paths: saved.paths,
41
+ ...(saved.access === undefined ? {} : { access: saved.access }),
42
+ });
23
43
  return { ...policy, revision: Number(saved.revision), root };
24
44
  }
25
45
  catch {
@@ -28,9 +48,10 @@ export function permissionPolicy(store, session) {
28
48
  }
29
49
  export function validatePermissions(value) {
30
50
  const input = object(value);
31
- if (Object.keys(input).some((k) => !['mode', 'paths'].includes(k)) ||
32
- !['inspect', 'review', 'scoped-edit'].includes(String(input.mode)))
33
- throw new ServiceError('invalid_permissions', 'Choose inspect, review or scoped-edit; unknown policy fields are rejected');
51
+ if (Object.keys(input).some((k) => !['mode', 'paths', 'access'].includes(k)) ||
52
+ !MODES.includes(String(input.mode)) ||
53
+ (input.access !== undefined && input.access !== 'full' && input.access !== 'yolo'))
54
+ throw new ServiceError('invalid_permissions', 'Choose inspect, review, scoped-edit, accept-edits or auto, and access "full", "yolo" or none; unknown policy fields are rejected');
34
55
  const paths = input.paths ?? [];
35
56
  if (!Array.isArray(paths) ||
36
57
  paths.length > 32 ||
@@ -48,16 +69,31 @@ export function validatePermissions(value) {
48
69
  throw new ServiceError('invalid_permissions', 'Use up to 32 distinct project-relative files or directories ending in /; no wildcards, traversal or private paths');
49
70
  if ((input.mode === 'scoped-edit') !== paths.length > 0)
50
71
  throw new ServiceError('invalid_permissions', 'Only scoped-edit takes paths, and requires at least one');
51
- return { mode: input.mode, paths: [...paths].sort() };
72
+ return {
73
+ mode: input.mode,
74
+ paths: [...paths].sort(),
75
+ ...(input.access === 'full' || input.access === 'yolo'
76
+ ? { access: input.access }
77
+ : {}),
78
+ };
52
79
  }
53
- export function configurePermissions(store, session, request, expected, value, actor) {
80
+ export function configurePermissions(store, session, request, expected, value, actor,
81
+ /** The operator's settings: may an interactive session choose full access or YOLO, and is this a
82
+ * desk session (YOLO is never for host-managed, board or child work)? */
83
+ opts = {}) {
54
84
  const policy = validatePermissions(value);
85
+ if (policy.access === 'full' && opts.allowFullAccess === false)
86
+ throw new ServiceError('full_access_denied', 'The operator does not allow full access on this host; commands stay in the OS sandbox');
87
+ if (policy.access === 'yolo' && (opts.allowYolo === false || opts.deskSession !== true))
88
+ throw new ServiceError('yolo_denied', opts.deskSession !== true
89
+ ? 'YOLO is only for your own desk sessions, never host-managed, board or child work'
90
+ : 'The operator does not allow YOLO on this host; commands keep their approvals');
55
91
  return store.request('local-operator', request, { kind: 'configure-permissions', session, expected, policy }, () => {
56
92
  store.authorizeControl(session, actor);
57
93
  store.execution.assert(session);
58
94
  if (!store.localWorkspace(session))
59
95
  throw new ServiceError('permissions_unavailable', 'This policy applies only to local workspace sessions, not managed stations or child workers');
60
- if (policy.mode !== 'inspect' && !store.localCoding(session))
96
+ if (!READ_ONLY_MODES.includes(policy.mode) && !store.localCoding(session))
61
97
  throw new ServiceError('local_denied', 'Create a coding session first; policy cannot grant missing coding capability');
62
98
  const current = permissionPolicy(store, session);
63
99
  if (current.revision !== expected)
@@ -80,7 +116,7 @@ export function matchesEditScope(policy, paths) {
80
116
  }
81
117
  export function assertPermissionBinding(store, session, operation, root) {
82
118
  const policy = permissionPolicy(store, session);
83
- if (policy.root !== root || policy.mode === 'inspect')
119
+ if (policy.root !== root || READ_ONLY_MODES.includes(policy.mode))
84
120
  throw new ServiceError('permission_denied', 'Current policy does not permit local mutations');
85
121
  const binding = store.db
86
122
  .prepare("SELECT payload FROM events WHERE session_id=? AND kind='permission.auto-approved' AND json_extract(payload,'$.operation_id')=? ORDER BY seq DESC LIMIT 1")
@@ -88,4 +124,39 @@ export function assertPermissionBinding(store, session, operation, root) {
88
124
  if (binding && JSON.parse(binding.payload).policy_digest !== digest(policy))
89
125
  throw new ServiceError('permission_changed', 'Auto-approval belongs to an older policy; prepare and review a fresh operation');
90
126
  }
127
+ /** A project-relative path the session's modes may edit without review: not private or hidden. */
128
+ function ordinaryProjectPath(path) {
129
+ return (path.length > 0 &&
130
+ !path.startsWith('/') &&
131
+ path
132
+ .split('/')
133
+ .every((part) => part !== '' &&
134
+ part !== '..' &&
135
+ !part.startsWith('.') &&
136
+ !['credentials', 'secrets', 'node_modules', 'vendor'].includes(part)));
137
+ }
138
+ /** Whether this policy approves these edits by itself: scoped edits, accept-edits, auto and full
139
+ * access approve ordinary project edits; YOLO approves every edit. */
140
+ export function autoApprovesEdits(policy, paths) {
141
+ if (policy.access === 'yolo')
142
+ return paths.length > 0;
143
+ if (policy.mode === 'scoped-edit' && !policy.access)
144
+ return matchesEditScope(policy, paths);
145
+ return ((policy.mode === 'accept-edits' || policy.mode === 'auto' || policy.access === 'full') &&
146
+ paths.length > 0 &&
147
+ paths.every(ordinaryProjectPath));
148
+ }
149
+ /** Whether this policy approves a command by itself: auto inside the OS sandbox; full access unless
150
+ * the danger check calls the command dangerous; YOLO always. */
151
+ export function autoApprovesCommands(policy, sandboxed, dangerous,
152
+ /** Pushes or opens a pull request: asks in every mode but YOLO (operator ruling 2026-10-31). */
153
+ publishes = false) {
154
+ if (policy.access === 'yolo')
155
+ return true;
156
+ if (publishes)
157
+ return false;
158
+ if (policy.access === 'full')
159
+ return !dangerous;
160
+ return policy.mode === 'auto' && sandboxed;
161
+ }
91
162
  export const permissionDigest = (policy) => digest(JSON.parse(canonical(policy)));
@@ -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,8 @@ 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) {
325
+ if (group === 'here')
326
+ group = 'code';
298
327
  if (group)
299
328
  return commands[group] ? `${commands[group]}\n\n${globalOptions}` : undefined;
300
329
  return `Salaam — persistent coding sessions
@@ -303,6 +332,7 @@ Usage: salaam <command> [options]
303
332
  sal <command> [options]
304
333
 
305
334
  Commands:
335
+ code Work in this folder: continue its session or start one (also: here, -c)
306
336
  tui Open the Salaam terminal interface (also bare sal)
307
337
  remote Open the same session over SSH
308
338
  providers List pinned Pi providers and capabilities
package/dist/src/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { loadDangerClassifierOptions } from './danger-classifier.js';
3
+ import { loadWebSearchConfig } from './web-search.js';
2
4
  import { SANDBOX_LEVELS } from './command-sandbox.js';
3
5
  import { acp } from './acp.js';
4
6
  import { homedir } from 'node:os';
@@ -33,6 +35,10 @@ async function main() {
33
35
  console.log('salaam ' + VERSION);
34
36
  return;
35
37
  }
38
+ if (args[0] === '-c' || args[0] === '--continue') {
39
+ const { continueArgs } = await import('./session-pick.js');
40
+ args.splice(0, args.length, ...continueArgs(args));
41
+ }
36
42
  if (args[0] === 'mcp') {
37
43
  // Parsed before global help: a declared command's own flags follow "--".
38
44
  const rest = args.slice(1);
@@ -403,7 +409,7 @@ async function main() {
403
409
  return;
404
410
  }
405
411
  if (group === 'host') {
406
- const { planHost, readHost, hostUnit, runHost, hostReceipt, hostToolsUser, hostApprovalTtlMs, hostAllowsUnsandboxed, hostCommandNetwork, } = await import('./host.js');
412
+ const { planHost, readHost, hostUnit, runHost, hostReceipt, hostToolsUser, hostApprovalTtlMs, hostAllowsUnsandboxed, hostAllowsFullAccessSessions, hostAllowsYoloSessions, hostDangerClassifier, hostCommandNetwork, hostReadPaths, } = await import('./host.js');
407
413
  if (verb?.startsWith('config-')) {
408
414
  const file = option('--manifest');
409
415
  if (!file)
@@ -527,7 +533,18 @@ async function main() {
527
533
  providerRoute: m.provider,
528
534
  approvalTtlMs: hostApprovalTtlMs(m),
529
535
  ...(hostAllowsUnsandboxed(m) ? { unsandboxedCommands: 'allow' } : {}),
536
+ // A host session may choose full access only when the reviewed manifest says so.
537
+ allowFullAccessSessions: hostAllowsFullAccessSessions(m),
538
+ allowYoloSessions: hostAllowsYoloSessions(m),
539
+ // The command reviewer only as the reviewed manifest names it (forbidden when absent).
540
+ ...(hostDangerClassifier(m).config
541
+ ? { dangerClassifierConfig: hostDangerClassifier(m).config }
542
+ : {}),
543
+ ...(hostDangerClassifier(m).pin
544
+ ? { dangerClassifierPin: hostDangerClassifier(m).pin }
545
+ : {}),
530
546
  ...(hostCommandNetwork(m) ? { commandNetwork: hostCommandNetwork(m) } : {}),
547
+ ...(hostReadPaths(m).length ? { localSandboxReadPaths: hostReadPaths(m) } : {}),
531
548
  });
532
549
  if (process.connected)
533
550
  process.once('disconnect', () => void service.close());
@@ -556,6 +573,20 @@ async function main() {
556
573
  });
557
574
  return;
558
575
  }
576
+ if (group === 'code' || group === 'here') {
577
+ const { parseCodeStart } = await import('./session-pick.js');
578
+ const start = parseCodeStart(args.slice(1));
579
+ const { tui } = await import('./tui.js');
580
+ await tui({
581
+ state,
582
+ start,
583
+ cwd: option('--cwd'),
584
+ takeover: args.includes('--takeover'),
585
+ codingRoot: option('--coding-root'),
586
+ codingSocket: option('--coding-socket'),
587
+ });
588
+ return;
589
+ }
559
590
  if (!group ||
560
591
  group === 'tui' ||
561
592
  [
@@ -689,7 +720,7 @@ async function main() {
689
720
  return;
690
721
  }
691
722
  if (group === 'acp') {
692
- await acp(state, { coding: args.includes('--coding') });
723
+ await acp(state, { coding: args.includes('--coding'), local: args.includes('--local') });
693
724
  return;
694
725
  }
695
726
  if (group === 'providers' && verb === 'list') {
@@ -1308,7 +1339,18 @@ async function main() {
1308
1339
  }
1309
1340
  const effectSocket = option('--effect-socket');
1310
1341
  const opaque = args.includes('--effect-opaque');
1311
- const codingRoot = option('--coding-root'), codingSocket = option('--coding-socket'), harnessConfig = option('--harness-config'), a2aConfig = option('--a2a-config'), agentpodConfig = option('--agentpod-config') ?? defaultAgentPodConfig(process.env, existsSync), a2aListenConfig = option('--a2a-listen-config'), mcpConfig = resolve(option('--mcp-config') ?? operatorMcpPath(process.env)), toolsUser = option('--tools-user') ?? (process.env.SALAAM_TOOLS_USER || undefined), localSandbox = option('--local-sandbox') ?? (process.env.SALAAM_LOCAL_SANDBOX || undefined), unsandboxedCommands = option('--unsandboxed-commands') ?? (process.env.SALAAM_UNSANDBOXED_COMMANDS || undefined);
1342
+ const codingRoot = option('--coding-root'), codingSocket = option('--coding-socket'), harnessConfig = option('--harness-config'), a2aConfig = option('--a2a-config'), agentpodConfig = option('--agentpod-config') ?? defaultAgentPodConfig(process.env, existsSync), a2aListenConfig = option('--a2a-listen-config'), mcpConfig = resolve(option('--mcp-config') ?? operatorMcpPath(process.env)), toolsUser = option('--tools-user') ?? (process.env.SALAAM_TOOLS_USER || undefined), localSandbox = option('--local-sandbox') ?? (process.env.SALAAM_LOCAL_SANDBOX || undefined), unsandboxedCommands = option('--unsandboxed-commands') ?? (process.env.SALAAM_UNSANDBOXED_COMMANDS || undefined), localSandboxRead = (option('--local-sandbox-read') ??
1343
+ (process.env.SALAAM_LOCAL_SANDBOX_READ || ''))
1344
+ .split(',')
1345
+ .map((p) => p.trim())
1346
+ .filter(Boolean), webSearchConfig = option('--web-search-config') ?? (process.env.SALAAM_WEB_SEARCH_CONFIG || undefined), dangerClassifierConfig = option('--danger-classifier-config') ??
1347
+ (process.env.SALAAM_DANGER_CLASSIFIER_CONFIG || undefined);
1348
+ // Fail early on a bad command-reviewer config; its key is read from the named variable, never printed.
1349
+ if (dangerClassifierConfig)
1350
+ loadDangerClassifierOptions(JSON.parse(readFileSync(resolve(dangerClassifierConfig), 'utf8')));
1351
+ // Fail early on a bad web search config; its keys are read, never printed.
1352
+ if (webSearchConfig)
1353
+ loadWebSearchConfig(resolve(webSearchConfig));
1312
1354
  if (localSandbox !== undefined && !SANDBOX_LEVELS.includes(localSandbox))
1313
1355
  throw Error('--local-sandbox is read-only, workspace-write or full-access');
1314
1356
  if (unsandboxedCommands !== undefined && unsandboxedCommands !== 'allow')
@@ -1327,6 +1369,11 @@ async function main() {
1327
1369
  toolsUser,
1328
1370
  ...(localSandbox ? { localSandbox: localSandbox } : {}),
1329
1371
  ...(unsandboxedCommands ? { unsandboxedCommands: 'allow' } : {}),
1372
+ ...(localSandboxRead.length ? { localSandboxReadPaths: localSandboxRead } : {}),
1373
+ ...(webSearchConfig ? { webSearchConfig: resolve(webSearchConfig) } : {}),
1374
+ ...(dangerClassifierConfig
1375
+ ? { dangerClassifierConfig: resolve(dangerClassifierConfig) }
1376
+ : {}),
1330
1377
  effectConnector: effectSocket
1331
1378
  ? new SocketEffectConnector(resolve(effectSocket), { opaque })
1332
1379
  : undefined,
@@ -1399,6 +1446,11 @@ async function main() {
1399
1446
  ...(toolsUser ? ['--tools-user', toolsUser] : []),
1400
1447
  ...(localSandbox ? ['--local-sandbox', localSandbox] : []),
1401
1448
  ...(unsandboxedCommands ? ['--unsandboxed-commands', 'allow'] : []),
1449
+ ...(localSandboxRead.length ? ['--local-sandbox-read', localSandboxRead.join(',')] : []),
1450
+ ...(webSearchConfig ? ['--web-search-config', resolve(webSearchConfig)] : []),
1451
+ ...(dangerClassifierConfig
1452
+ ? ['--danger-classifier-config', resolve(dangerClassifierConfig)]
1453
+ : []),
1402
1454
  ], { detached: true, stdio: ['ignore', fd, fd] });
1403
1455
  closeSync(fd);
1404
1456
  child.unref();