@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
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
import type { Store } from './store.js';
|
|
6
|
+
import { ServiceError } from './protocol.js';
|
|
7
|
+
|
|
8
|
+
/** Hooks for a person's own desk sessions (Phase 4b, operator 2026-10-31), in the Claude Code
|
|
9
|
+
* format: `{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command",
|
|
10
|
+
* "command": "...", "timeout": 30}]}]}}`. Yours live in `~/.config/salaam/hooks.json`; a
|
|
11
|
+
* repository's `.salaam/hooks.json` is ignored until you trust that exact file, and any change to it
|
|
12
|
+
* needs trusting again. A hook can block a tool call or a prompt; it can never approve anything. */
|
|
13
|
+
export const HOOK_EVENTS = [
|
|
14
|
+
'PreToolUse',
|
|
15
|
+
'PostToolUse',
|
|
16
|
+
'UserPromptSubmit',
|
|
17
|
+
'Stop',
|
|
18
|
+
'Notification',
|
|
19
|
+
] as const;
|
|
20
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
21
|
+
export interface HookHandler {
|
|
22
|
+
command: string;
|
|
23
|
+
/** Seconds. */
|
|
24
|
+
timeout?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface HookGroup {
|
|
27
|
+
matcher?: string;
|
|
28
|
+
hooks: HookHandler[];
|
|
29
|
+
}
|
|
30
|
+
export type HookConfig = Partial<Record<HookEvent, HookGroup[]>>;
|
|
31
|
+
export interface HookRun {
|
|
32
|
+
code: number | null;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
timedOut: boolean;
|
|
36
|
+
truncated: boolean;
|
|
37
|
+
/** Set when the hook was not started, e.g. this host refuses unsandboxed commands. */
|
|
38
|
+
refused?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Runs one hook command under the session's sandbox with `input` on stdin. */
|
|
41
|
+
export type HookRunner = (
|
|
42
|
+
session: string,
|
|
43
|
+
command: string,
|
|
44
|
+
input: string,
|
|
45
|
+
timeoutMs: number,
|
|
46
|
+
) => Promise<HookRun>;
|
|
47
|
+
export interface HookSource {
|
|
48
|
+
scope: 'user' | 'repo';
|
|
49
|
+
path: string;
|
|
50
|
+
digest: string | null;
|
|
51
|
+
trusted: boolean;
|
|
52
|
+
config: HookConfig;
|
|
53
|
+
problems: string[];
|
|
54
|
+
}
|
|
55
|
+
export interface HookOutcome {
|
|
56
|
+
/** The reason a blocking event was stopped. */
|
|
57
|
+
blocked?: string;
|
|
58
|
+
/** UserPromptSubmit output to add to the prompt. */
|
|
59
|
+
context?: string;
|
|
60
|
+
notices: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAX_FILE = 64 * 1024;
|
|
64
|
+
const MAX_COMMAND = 4096;
|
|
65
|
+
const MAX_HANDLERS = 16;
|
|
66
|
+
const MAX_CONTEXT = 8192;
|
|
67
|
+
const DEFAULT_TIMEOUT = 60;
|
|
68
|
+
const MAX_TIMEOUT = 600;
|
|
69
|
+
const BLOCKING = new Set<HookEvent>(['PreToolUse', 'UserPromptSubmit']);
|
|
70
|
+
const TOOL_EVENTS = new Set<HookEvent>(['PreToolUse', 'PostToolUse']);
|
|
71
|
+
|
|
72
|
+
export function defaultUserHooksFile(env = process.env): string {
|
|
73
|
+
return (
|
|
74
|
+
env.SALAAM_HOOKS_FILE ||
|
|
75
|
+
join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'salaam', 'hooks.json')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sha = (data: string | Buffer) => 'sha256:' + createHash('sha256').update(data).digest('hex');
|
|
80
|
+
|
|
81
|
+
export function parseHooks(text: string): { config: HookConfig; problems: string[] } {
|
|
82
|
+
const problems: string[] = [];
|
|
83
|
+
const config: HookConfig = {};
|
|
84
|
+
let raw: unknown;
|
|
85
|
+
try {
|
|
86
|
+
raw = JSON.parse(text);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { config, problems: ['not valid JSON: ' + (error as Error).message] };
|
|
89
|
+
}
|
|
90
|
+
const events = (raw as { hooks?: unknown })?.hooks;
|
|
91
|
+
if (!events || typeof events !== 'object' || Array.isArray(events))
|
|
92
|
+
return { config, problems: ['expected {"hooks": {...}}'] };
|
|
93
|
+
let handlers = 0;
|
|
94
|
+
for (const [event, groups] of Object.entries(events)) {
|
|
95
|
+
if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
96
|
+
problems.push(`${event}: not supported; Salaam runs ${HOOK_EVENTS.join(', ')}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(groups)) {
|
|
100
|
+
problems.push(`${event}: expected a list of matcher groups`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const group of groups as unknown[]) {
|
|
104
|
+
const g = group as { matcher?: unknown; hooks?: unknown };
|
|
105
|
+
if (g?.matcher !== undefined && typeof g.matcher !== 'string') {
|
|
106
|
+
problems.push(`${event}: matcher must be a string`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (typeof g.matcher === 'string' && !validMatcher(g.matcher)) {
|
|
110
|
+
problems.push(`${event}: matcher ${JSON.stringify(g.matcher)} is not a valid pattern`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(g.hooks)) {
|
|
114
|
+
problems.push(`${event}: a group needs a hooks list`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const kept: HookHandler[] = [];
|
|
118
|
+
for (const h of g.hooks as unknown[]) {
|
|
119
|
+
const hook = h as { type?: unknown; command?: unknown; timeout?: unknown };
|
|
120
|
+
if (hook?.type !== 'command') {
|
|
121
|
+
problems.push(
|
|
122
|
+
`${event}: hook type ${JSON.stringify(hook?.type)} is not supported (command only)`,
|
|
123
|
+
);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (typeof hook.command !== 'string' || !hook.command.trim()) {
|
|
127
|
+
problems.push(`${event}: a command hook needs a command`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (Buffer.byteLength(hook.command) > MAX_COMMAND) {
|
|
131
|
+
problems.push(`${event}: a hook command is longer than 4 KiB`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
hook.timeout !== undefined &&
|
|
136
|
+
(typeof hook.timeout !== 'number' || !(hook.timeout > 0) || hook.timeout > MAX_TIMEOUT)
|
|
137
|
+
) {
|
|
138
|
+
problems.push(`${event}: timeout must be 1–${MAX_TIMEOUT} seconds`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (++handlers > MAX_HANDLERS * HOOK_EVENTS.length) {
|
|
142
|
+
problems.push('too many hooks; the rest are ignored');
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
kept.push({
|
|
146
|
+
command: hook.command,
|
|
147
|
+
...(hook.timeout !== undefined ? { timeout: hook.timeout as number } : {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (kept.length)
|
|
151
|
+
(config[event as HookEvent] ??= []).push({
|
|
152
|
+
...(g.matcher !== undefined ? { matcher: g.matcher } : {}),
|
|
153
|
+
hooks: kept.slice(0, MAX_HANDLERS),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { config, problems };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validMatcher(m: string): boolean {
|
|
161
|
+
if (m === '' || m === '*' || /^[A-Za-z0-9_|,-]+$/.test(m)) return true;
|
|
162
|
+
try {
|
|
163
|
+
new RegExp(m);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Claude Code's rule: empty or `*` matches everything, plain names (letters, digits, `_`, `-`,
|
|
171
|
+
* separated by `|` or `,`) are exact, anything else is a regular expression. `names` holds the
|
|
172
|
+
* Salaam tool name and its Claude Code alias (`Bash`, `Edit`, …). */
|
|
173
|
+
export function matchesTool(matcher: string | undefined, names: readonly string[]): boolean {
|
|
174
|
+
if (matcher === undefined || matcher === '' || matcher === '*') return true;
|
|
175
|
+
if (/^[A-Za-z0-9_|,-]+$/.test(matcher)) {
|
|
176
|
+
const wanted = matcher.split(/[|,]/);
|
|
177
|
+
return names.some((n) => wanted.includes(n));
|
|
178
|
+
}
|
|
179
|
+
const re = new RegExp('^(?:' + matcher + ')$');
|
|
180
|
+
return names.some((n) => re.test(n));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class Hooks {
|
|
184
|
+
constructor(
|
|
185
|
+
readonly store: Store,
|
|
186
|
+
readonly run: HookRunner,
|
|
187
|
+
readonly userFile = defaultUserHooksFile(),
|
|
188
|
+
) {
|
|
189
|
+
store.db.exec(
|
|
190
|
+
'CREATE TABLE IF NOT EXISTS hook_trust(root TEXT NOT NULL,path TEXT NOT NULL,digest TEXT NOT NULL,trusted_at TEXT NOT NULL,PRIMARY KEY(root,path))',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
private repoFile(session: string): string {
|
|
194
|
+
return join(this.store.session(session).cwd, '.salaam', 'hooks.json');
|
|
195
|
+
}
|
|
196
|
+
/** Your hooks and this workspace's, read afresh, each with its trust state. */
|
|
197
|
+
sources(session: string): HookSource[] {
|
|
198
|
+
const root = this.store.session(session).cwd;
|
|
199
|
+
const out: HookSource[] = [];
|
|
200
|
+
for (const [scope, path] of [
|
|
201
|
+
['user', this.userFile],
|
|
202
|
+
['repo', this.repoFile(session)],
|
|
203
|
+
] as const) {
|
|
204
|
+
if (!existsSync(path) && !isLink(path)) continue;
|
|
205
|
+
const problems: string[] = [];
|
|
206
|
+
let digest: string | null = null;
|
|
207
|
+
let config: HookConfig = {};
|
|
208
|
+
try {
|
|
209
|
+
const real = realpathSync(path);
|
|
210
|
+
if (scope === 'repo' && !inside(realpathSync(root), real))
|
|
211
|
+
throw new Error('resolves outside the repository; ignored');
|
|
212
|
+
if (lstatSync(real).size > MAX_FILE) throw new Error('larger than 64 KiB; ignored');
|
|
213
|
+
const bytes = readFileSync(real);
|
|
214
|
+
digest = sha(bytes);
|
|
215
|
+
const parsed = parseHooks(bytes.toString('utf8'));
|
|
216
|
+
config = parsed.config;
|
|
217
|
+
problems.push(...parsed.problems);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
problems.push((error as Error).message);
|
|
220
|
+
digest = null;
|
|
221
|
+
}
|
|
222
|
+
const trusted =
|
|
223
|
+
scope === 'user'
|
|
224
|
+
? digest !== null
|
|
225
|
+
: digest !== null && this.trustedDigest(root, path) === digest;
|
|
226
|
+
out.push({ scope, path, digest, trusted, config, problems });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
private trustedDigest(root: string, path: string): string | undefined {
|
|
231
|
+
const row = this.store.db
|
|
232
|
+
.prepare('SELECT digest FROM hook_trust WHERE root=? AND path=?')
|
|
233
|
+
.get(root, path) as { digest?: string } | undefined;
|
|
234
|
+
return row?.digest;
|
|
235
|
+
}
|
|
236
|
+
/** Trust this workspace's hooks file as reviewed: `digest` must be the content shown to you. */
|
|
237
|
+
trust(session: string, digest: string): HookSource {
|
|
238
|
+
const source = this.sources(session).find((s) => s.scope === 'repo');
|
|
239
|
+
if (!source || !source.digest)
|
|
240
|
+
throw new ServiceError(
|
|
241
|
+
'hooks_missing',
|
|
242
|
+
'This workspace has no readable .salaam/hooks.json',
|
|
243
|
+
404,
|
|
244
|
+
);
|
|
245
|
+
if (source.digest !== digest)
|
|
246
|
+
throw new ServiceError(
|
|
247
|
+
'hooks_changed',
|
|
248
|
+
'The hooks file changed since you reviewed it; review it again',
|
|
249
|
+
409,
|
|
250
|
+
);
|
|
251
|
+
const root = this.store.session(session).cwd;
|
|
252
|
+
this.store.transaction(() => {
|
|
253
|
+
this.store.db
|
|
254
|
+
.prepare(
|
|
255
|
+
'INSERT INTO hook_trust VALUES (?,?,?,?) ON CONFLICT(root,path) DO UPDATE SET digest=excluded.digest,trusted_at=excluded.trusted_at',
|
|
256
|
+
)
|
|
257
|
+
.run(root, source.path, digest, new Date().toISOString());
|
|
258
|
+
this.store.event(session, 'hooks.trusted', { path: source.path, digest });
|
|
259
|
+
});
|
|
260
|
+
return { ...source, trusted: true };
|
|
261
|
+
}
|
|
262
|
+
revoke(session: string): void {
|
|
263
|
+
const root = this.store.session(session).cwd;
|
|
264
|
+
const path = this.repoFile(session);
|
|
265
|
+
this.store.transaction(() => {
|
|
266
|
+
this.store.db.prepare('DELETE FROM hook_trust WHERE root=? AND path=?').run(root, path);
|
|
267
|
+
this.store.event(session, 'hooks.revoked', { path });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** Run every trusted hook for `event`. Blocking events stop at the first block. */
|
|
271
|
+
async fire(
|
|
272
|
+
session: string,
|
|
273
|
+
event: HookEvent,
|
|
274
|
+
payload: Record<string, unknown>,
|
|
275
|
+
names: readonly string[],
|
|
276
|
+
): Promise<HookOutcome> {
|
|
277
|
+
const notices: string[] = [];
|
|
278
|
+
const context: string[] = [];
|
|
279
|
+
const cwd = this.store.session(session).cwd;
|
|
280
|
+
const input = JSON.stringify({ session_id: session, cwd, hook_event_name: event, ...payload });
|
|
281
|
+
for (const source of this.sources(session)) {
|
|
282
|
+
if (!source.trusted) continue;
|
|
283
|
+
for (const group of source.config[event] ?? []) {
|
|
284
|
+
if (TOOL_EVENTS.has(event) && !matchesTool(group.matcher, names)) continue;
|
|
285
|
+
for (const handler of group.hooks) {
|
|
286
|
+
const started = Date.now();
|
|
287
|
+
const r = await this.run(
|
|
288
|
+
session,
|
|
289
|
+
handler.command,
|
|
290
|
+
input,
|
|
291
|
+
(handler.timeout ?? DEFAULT_TIMEOUT) * 1000,
|
|
292
|
+
);
|
|
293
|
+
const decision = parseDecision(r.stdout);
|
|
294
|
+
let blocked: string | undefined;
|
|
295
|
+
if (BLOCKING.has(event) && !r.refused && !r.timedOut) {
|
|
296
|
+
if (r.code === 2) blocked = r.stderr.trim() || `Blocked by a ${event} hook`;
|
|
297
|
+
else if (r.code === 0 && decision.block)
|
|
298
|
+
blocked = decision.reason || `Blocked by a ${event} hook`;
|
|
299
|
+
}
|
|
300
|
+
if (r.refused) notices.push(`${event} hook refused: ${r.refused}`);
|
|
301
|
+
else if (r.timedOut) notices.push(`${event} hook timed out: ${short(handler.command)}`);
|
|
302
|
+
else if (r.code !== 0 && r.code !== 2)
|
|
303
|
+
notices.push(`${event} hook failed (exit ${r.code}): ${short(handler.command)}`);
|
|
304
|
+
if (event === 'UserPromptSubmit' && r.code === 0 && !blocked) {
|
|
305
|
+
const extra = decision.context ?? (decision.json ? '' : r.stdout.trim());
|
|
306
|
+
if (extra) context.push(extra);
|
|
307
|
+
}
|
|
308
|
+
this.store.transaction(() =>
|
|
309
|
+
this.store.event(session, 'hook.run', {
|
|
310
|
+
event,
|
|
311
|
+
scope: source.scope,
|
|
312
|
+
path: source.path,
|
|
313
|
+
file_digest: source.digest,
|
|
314
|
+
command_digest: sha(handler.command),
|
|
315
|
+
exit_code: r.code,
|
|
316
|
+
timed_out: r.timedOut,
|
|
317
|
+
refused: !!r.refused,
|
|
318
|
+
blocked: !!blocked,
|
|
319
|
+
...(decision.ignored ? { ignored_decision: decision.ignored } : {}),
|
|
320
|
+
duration_ms: Date.now() - started,
|
|
321
|
+
stdout_digest: sha(r.stdout),
|
|
322
|
+
stderr_digest: sha(r.stderr),
|
|
323
|
+
output_bytes: Buffer.byteLength(r.stdout) + Buffer.byteLength(r.stderr),
|
|
324
|
+
truncated: r.truncated,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
if (blocked) return { blocked, notices };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const joined = context.join('\n\n');
|
|
332
|
+
return {
|
|
333
|
+
notices,
|
|
334
|
+
...(joined ? { context: truncate(joined, MAX_CONTEXT) } : {}),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A JSON object on stdout, read the Claude Code way. "allow" and "ask" are recorded as ignored:
|
|
340
|
+
* Salaam's own approval flow decides those. */
|
|
341
|
+
function parseDecision(stdout: string): {
|
|
342
|
+
json: boolean;
|
|
343
|
+
block?: boolean;
|
|
344
|
+
reason?: string;
|
|
345
|
+
context?: string;
|
|
346
|
+
ignored?: string;
|
|
347
|
+
} {
|
|
348
|
+
const text = stdout.trim();
|
|
349
|
+
if (!text.startsWith('{')) return { json: false };
|
|
350
|
+
let value: Record<string, any>;
|
|
351
|
+
try {
|
|
352
|
+
value = JSON.parse(text);
|
|
353
|
+
} catch {
|
|
354
|
+
return { json: false };
|
|
355
|
+
}
|
|
356
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { json: false };
|
|
357
|
+
const specific = (value.hookSpecificOutput ?? {}) as Record<string, unknown>;
|
|
358
|
+
const decision = specific.permissionDecision;
|
|
359
|
+
const reason =
|
|
360
|
+
typeof specific.permissionDecisionReason === 'string'
|
|
361
|
+
? specific.permissionDecisionReason
|
|
362
|
+
: typeof value.reason === 'string'
|
|
363
|
+
? value.reason
|
|
364
|
+
: undefined;
|
|
365
|
+
const context =
|
|
366
|
+
typeof specific.additionalContext === 'string' ? specific.additionalContext : undefined;
|
|
367
|
+
if (decision === 'deny' || value.decision === 'block')
|
|
368
|
+
return { json: true, block: true, ...(reason ? { reason } : {}) };
|
|
369
|
+
return {
|
|
370
|
+
json: true,
|
|
371
|
+
...(context ? { context } : {}),
|
|
372
|
+
...(typeof decision === 'string' ? { ignored: decision } : {}),
|
|
373
|
+
...(value.decision === 'approve' ? { ignored: 'approve' } : {}),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function inside(root: string, path: string): boolean {
|
|
378
|
+
const r = relative(root, path);
|
|
379
|
+
return !r.startsWith('..') && !r.startsWith(sep);
|
|
380
|
+
}
|
|
381
|
+
function isLink(path: string): boolean {
|
|
382
|
+
try {
|
|
383
|
+
return lstatSync(path).isSymbolicLink();
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function short(command: string): string {
|
|
389
|
+
const line = command.split('\n')[0]!;
|
|
390
|
+
return line.length > 60 ? line.slice(0, 57) + '…' : line;
|
|
391
|
+
}
|
|
392
|
+
function truncate(text: string, max: number): string {
|
|
393
|
+
const buf = Buffer.from(text);
|
|
394
|
+
return buf.length <= max
|
|
395
|
+
? text
|
|
396
|
+
: buf.subarray(0, max).toString('utf8') + '\n[hook output truncated]';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** What `/hooks` shows, and what you review before trusting a workspace file: every command. */
|
|
400
|
+
export function describeHooks(sources: readonly HookSource[]): string {
|
|
401
|
+
if (!sources.length)
|
|
402
|
+
return "No hooks. Add your own in ~/.config/salaam/hooks.json, or a workspace's in .salaam/hooks.json (it runs only once you trust it with /hooks trust).";
|
|
403
|
+
return sources
|
|
404
|
+
.map((s) => {
|
|
405
|
+
const state =
|
|
406
|
+
s.scope === 'user'
|
|
407
|
+
? 'yours, runs'
|
|
408
|
+
: s.trusted
|
|
409
|
+
? 'workspace, trusted (this exact content)'
|
|
410
|
+
: s.digest
|
|
411
|
+
? 'workspace, NOT trusted, does not run (/hooks trust to review)'
|
|
412
|
+
: 'workspace, unreadable';
|
|
413
|
+
const lines = [`${s.path} [${state}]`];
|
|
414
|
+
for (const event of HOOK_EVENTS)
|
|
415
|
+
for (const g of s.config[event] ?? [])
|
|
416
|
+
for (const h of g.hooks)
|
|
417
|
+
lines.push(
|
|
418
|
+
` ${event}${g.matcher ? ` (${g.matcher})` : ''}: ${h.command}${h.timeout ? ` [${h.timeout}s]` : ''}`,
|
|
419
|
+
);
|
|
420
|
+
for (const p of s.problems) lines.push(` ! ${p}`);
|
|
421
|
+
if (s.digest) lines.push(` ${s.digest}`);
|
|
422
|
+
return lines.join('\n');
|
|
423
|
+
})
|
|
424
|
+
.join('\n\n');
|
|
425
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_COMMAND_NETWORK_SOCKET } from './command-network.js';
|
|
2
|
+
import { loadDangerClassifierOptions } from './danger-classifier.js';
|
|
2
3
|
import { spawn, execFileSync } from 'node:child_process';
|
|
3
4
|
import { once } from 'node:events';
|
|
4
5
|
import {
|
|
@@ -32,7 +33,12 @@ import { call } from './transport.js';
|
|
|
32
33
|
import { readStationConfig } from './station-config.js';
|
|
33
34
|
import { readToolkitConfig } from './toolkit-config.js';
|
|
34
35
|
import { OperatorMcp } from './operator-mcp.js';
|
|
35
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
chooseLocalSandbox,
|
|
38
|
+
resolveReadPaths,
|
|
39
|
+
sandboxLabel,
|
|
40
|
+
type SandboxKind,
|
|
41
|
+
} from './command-sandbox.js';
|
|
36
42
|
import { loadPersona } from './persona.js';
|
|
37
43
|
interface HostIdentity {
|
|
38
44
|
node: string;
|
|
@@ -102,6 +108,19 @@ export interface HostLocalSandbox {
|
|
|
102
108
|
/** Network allowlist (operator rulings 2026-10-30): local commands reach the network only
|
|
103
109
|
* through stk's local-commands socket, whose egress.hosts the host operator sets. */
|
|
104
110
|
command_network?: { mode: 'broker'; socket: string; label: string };
|
|
111
|
+
/** Phase 1 (2026-10-31): operator-allowed read-only paths outside the workspace, resolved at host
|
|
112
|
+
* plan (`toolchain` expanded) so the reviewed manifest names every one. */
|
|
113
|
+
read_paths?: string[];
|
|
114
|
+
/** Phase 2b: whether an interactive session on this host may choose full access (no OS sandbox).
|
|
115
|
+
* Hosts allow it only when the operator sets `allow_full_access_sessions: true`. */
|
|
116
|
+
full_access_sessions?: 'allow';
|
|
117
|
+
/** Operator ruling 2026-10-31: whether a desk session on this host may choose YOLO (no sandbox, no
|
|
118
|
+
* approvals). Hosts allow it only when the operator sets `allow_yolo_sessions: true`. */
|
|
119
|
+
yolo_sessions?: 'allow';
|
|
120
|
+
/** Operator 2026-10-31: the command reviewer on this host. Forbidden unless the operator names a
|
|
121
|
+
* reviewed config (its digest pinned here), optionally pinning one of its options. */
|
|
122
|
+
danger_classifier?:
|
|
123
|
+
{ mode: 'forbid' } | { mode: 'config'; config: string; digest: string; pin?: string };
|
|
105
124
|
}
|
|
106
125
|
export function hostLocalSandbox(
|
|
107
126
|
platform: NodeJS.Platform = process.platform,
|
|
@@ -109,6 +128,10 @@ export function hostLocalSandbox(
|
|
|
109
128
|
findSb?: Parameters<typeof chooseLocalSandbox>[3],
|
|
110
129
|
allowUnsandboxed = false,
|
|
111
130
|
brokerSocket?: string,
|
|
131
|
+
readPaths: readonly string[] = [],
|
|
132
|
+
allowFullAccessSessions = false,
|
|
133
|
+
allowYoloSessions = false,
|
|
134
|
+
dangerClassifier?: HostLocalSandbox['danger_classifier'],
|
|
112
135
|
): HostLocalSandbox {
|
|
113
136
|
const { kind } = chooseLocalSandbox('workspace-write', platform, find, findSb);
|
|
114
137
|
return {
|
|
@@ -124,8 +147,34 @@ export function hostLocalSandbox(
|
|
|
124
147
|
},
|
|
125
148
|
}
|
|
126
149
|
: {}),
|
|
150
|
+
...(readPaths.length ? { read_paths: [...readPaths] } : {}),
|
|
151
|
+
...(allowFullAccessSessions ? { full_access_sessions: 'allow' as const } : {}),
|
|
152
|
+
...(allowYoloSessions ? { yolo_sessions: 'allow' as const } : {}),
|
|
153
|
+
...(dangerClassifier ? { danger_classifier: dangerClassifier } : {}),
|
|
127
154
|
};
|
|
128
155
|
}
|
|
156
|
+
/** The read-only paths outside the workspace the reviewed manifest lets local commands read. */
|
|
157
|
+
export function hostReadPaths(m: HostManifest): string[] {
|
|
158
|
+
const r = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.read_paths;
|
|
159
|
+
return Array.isArray(r) ? r.filter((p): p is string => typeof p === 'string') : [];
|
|
160
|
+
}
|
|
161
|
+
/** Whether the reviewed manifest lets an interactive session choose full access (Phase 2b). */
|
|
162
|
+
export function hostAllowsFullAccessSessions(m: HostManifest): boolean {
|
|
163
|
+
return (
|
|
164
|
+
(m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.full_access_sessions === 'allow'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
/** Whether the reviewed manifest lets a desk session choose YOLO. */
|
|
168
|
+
export function hostAllowsYoloSessions(m: HostManifest): boolean {
|
|
169
|
+
return (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.yolo_sessions === 'allow';
|
|
170
|
+
}
|
|
171
|
+
/** The command reviewer a host service may run: a reviewed config and optional pin, or none
|
|
172
|
+
* ({ pin: 'off' }). Absent means forbidden: hosts get no reviewer without the operator. */
|
|
173
|
+
export function hostDangerClassifier(m: HostManifest): { config?: string; pin?: string } {
|
|
174
|
+
const d = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier;
|
|
175
|
+
if (!d || d.mode !== 'config') return { pin: 'off' };
|
|
176
|
+
return { config: d.config, ...(d.pin ? { pin: d.pin } : {}) };
|
|
177
|
+
}
|
|
129
178
|
/** The broker socket local commands reach the network through, or null for no network. */
|
|
130
179
|
export function hostCommandNetwork(m: HostManifest): { socket: string } | null {
|
|
131
180
|
const n = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.command_network;
|
|
@@ -245,6 +294,10 @@ async function planAgentPod(
|
|
|
245
294
|
'unsandboxed_commands',
|
|
246
295
|
'command_network',
|
|
247
296
|
'command_network_socket',
|
|
297
|
+
'local_sandbox_read_paths',
|
|
298
|
+
'allow_full_access_sessions',
|
|
299
|
+
'allow_yolo_sessions',
|
|
300
|
+
'danger_classifier',
|
|
248
301
|
];
|
|
249
302
|
if (Object.keys(c).some((k) => !fields.includes(k)))
|
|
250
303
|
throw Error('Unexpected AgentPod host config field');
|
|
@@ -286,6 +339,12 @@ async function planAgentPod(
|
|
|
286
339
|
if (c.unsandboxed_commands !== undefined && c.unsandboxed_commands !== 'allow')
|
|
287
340
|
throw Error('unsandboxed_commands is "allow" or absent');
|
|
288
341
|
const allowUnsandboxed = c.unsandboxed_commands === 'allow';
|
|
342
|
+
if (c.allow_full_access_sessions !== undefined && c.allow_full_access_sessions !== true)
|
|
343
|
+
throw Error('allow_full_access_sessions is true or absent');
|
|
344
|
+
const allowFullAccessSessions = c.allow_full_access_sessions === true;
|
|
345
|
+
if (c.allow_yolo_sessions !== undefined && c.allow_yolo_sessions !== true)
|
|
346
|
+
throw Error('allow_yolo_sessions is true or absent');
|
|
347
|
+
const allowYoloSessions = c.allow_yolo_sessions === true;
|
|
289
348
|
if (c.command_network !== undefined && c.command_network !== 'broker')
|
|
290
349
|
throw Error('command_network is "broker" or absent');
|
|
291
350
|
if (c.command_network_socket !== undefined) {
|
|
@@ -295,6 +354,18 @@ async function planAgentPod(
|
|
|
295
354
|
if (typeof sock !== 'string' || !isAbsolute(sock) || sock.includes('\0'))
|
|
296
355
|
throw Error('command_network_socket must be an absolute path');
|
|
297
356
|
}
|
|
357
|
+
const dangerClassifier = parseHostDangerClassifier(c.danger_classifier);
|
|
358
|
+
let readPaths: string[] = [];
|
|
359
|
+
if (c.local_sandbox_read_paths !== undefined) {
|
|
360
|
+
const raw = c.local_sandbox_read_paths;
|
|
361
|
+
if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string'))
|
|
362
|
+
throw Error('local_sandbox_read_paths is a list of absolute paths or "toolchain"');
|
|
363
|
+
try {
|
|
364
|
+
readPaths = resolveReadPaths(raw as string[]);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
throw Error((error as Error).message);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
298
369
|
const brokerSocket =
|
|
299
370
|
c.command_network === 'broker'
|
|
300
371
|
? String(c.command_network_socket ?? DEFAULT_COMMAND_NETWORK_SOCKET)
|
|
@@ -390,9 +461,50 @@ async function planAgentPod(
|
|
|
390
461
|
undefined,
|
|
391
462
|
allowUnsandboxed,
|
|
392
463
|
brokerSocket,
|
|
464
|
+
readPaths,
|
|
465
|
+
allowFullAccessSessions,
|
|
466
|
+
allowYoloSessions,
|
|
467
|
+
dangerClassifier,
|
|
393
468
|
),
|
|
394
469
|
};
|
|
395
470
|
}
|
|
471
|
+
function reviewerDigest(path: string): string | null {
|
|
472
|
+
try {
|
|
473
|
+
return digest(readFileSync(path, 'utf8'));
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function parseHostDangerClassifier(raw: unknown): HostLocalSandbox['danger_classifier'] {
|
|
479
|
+
if (raw === undefined) return undefined;
|
|
480
|
+
if (raw === 'forbid') return { mode: 'forbid' };
|
|
481
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
482
|
+
throw Error('danger_classifier is "forbid" or { config, pin? }');
|
|
483
|
+
const c = raw as Record<string, unknown>;
|
|
484
|
+
if (Object.keys(c).some((k) => k !== 'config' && k !== 'pin'))
|
|
485
|
+
throw Error('danger_classifier takes only config and pin');
|
|
486
|
+
if (typeof c.config !== 'string' || !isAbsolute(c.config) || c.config.includes('\0'))
|
|
487
|
+
throw Error('danger_classifier.config must be an absolute path');
|
|
488
|
+
let names: string[];
|
|
489
|
+
try {
|
|
490
|
+
// Keys are named by environment variable and checked when the service starts, not here.
|
|
491
|
+
const anyKey = new Proxy({}, { get: () => 'checked-at-start' }) as Record<string, string>;
|
|
492
|
+
names = loadDangerClassifierOptions(
|
|
493
|
+
JSON.parse(readFileSync(c.config, 'utf8')),
|
|
494
|
+
anyKey,
|
|
495
|
+
).options.map((o) => o.name);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
throw Error('danger_classifier.config: ' + (error as Error).message);
|
|
498
|
+
}
|
|
499
|
+
if (c.pin !== undefined && (typeof c.pin !== 'string' || !names.includes(c.pin)))
|
|
500
|
+
throw Error("danger_classifier.pin must name one of the config's options");
|
|
501
|
+
return {
|
|
502
|
+
mode: 'config',
|
|
503
|
+
config: c.config,
|
|
504
|
+
digest: reviewerDigest(c.config)!,
|
|
505
|
+
...(typeof c.pin === 'string' ? { pin: c.pin } : {}),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
396
508
|
export function hostDigests() {
|
|
397
509
|
const hashDir = (path: string) =>
|
|
398
510
|
digest(
|
|
@@ -553,10 +665,19 @@ export function readHost(path: string): HostManifest {
|
|
|
553
665
|
undefined,
|
|
554
666
|
hostAllowsUnsandboxed(m),
|
|
555
667
|
hostCommandNetwork(m)?.socket,
|
|
668
|
+
hostReadPaths(m),
|
|
669
|
+
hostAllowsFullAccessSessions(m),
|
|
670
|
+
hostAllowsYoloSessions(m),
|
|
671
|
+
(m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier,
|
|
556
672
|
),
|
|
557
673
|
)
|
|
558
674
|
)
|
|
559
675
|
throw Error('Host command sandbox changed; run host plan again');
|
|
676
|
+
{
|
|
677
|
+
const d = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.danger_classifier;
|
|
678
|
+
if (d?.mode === 'config' && reviewerDigest(d.config) !== d.digest)
|
|
679
|
+
throw Error('Host command reviewer config changed; run host plan again');
|
|
680
|
+
}
|
|
560
681
|
const pins = readAgentPodPins(m.agentpod_config, m.state);
|
|
561
682
|
const managed = hostMcp(m);
|
|
562
683
|
if (m.schema === 3 || ([4, 5, 6].includes(m.schema) && Object.hasOwn(m, 'managed_mcp'))) {
|