@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.
- package/CHANGELOG.md +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- 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 =
|
|
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(
|
|
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({
|
|
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): {
|
|
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
|
-
!
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|