@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,160 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // Phase 4a git helpers for desk sessions. /commit, /branch and /pr are instructions to the model:
6
+ // every git command they lead to goes through the session's normal command approval and sandbox.
7
+ // Operator ruling 2026-10-31: pushing and opening a pull request ask in every mode except YOLO.
8
+ /** Why a command publishes to a remote, or null. Read from the raw text on purpose: a push hidden in
9
+ * a substitution, `sh -c` or a compound command still counts, and a false match only asks. */
10
+ export function publishesToRemote(command) {
11
+ for (const part of command.split(/&&|\|\||[;|\n]|\$\(|`/)) {
12
+ const words = part.replace(/["']/g, ' ').trim().split(/\s+/);
13
+ const at = words.findIndex((w) => /(^|\/)(git|gh|tea)$/.test(w));
14
+ if (at < 0)
15
+ continue;
16
+ const tool = words[at].split('/').pop();
17
+ const rest = words.slice(at + 1).filter((w) => !w.startsWith('-'));
18
+ if (tool === 'git') {
19
+ // git's own options take a value: -C dir, -c key=value.
20
+ const args = words.slice(at + 1);
21
+ let i = 0;
22
+ while (i < args.length && args[i].startsWith('-'))
23
+ i += ['-C', '-c'].includes(args[i]) ? 2 : 1;
24
+ if (args[i] === 'push')
25
+ return 'pushes commits to a remote';
26
+ }
27
+ else if (/^(pr|pulls)$/.test(rest[0] ?? '') && rest[1] === 'create')
28
+ return 'opens a pull request on the forge';
29
+ }
30
+ return null;
31
+ }
32
+ /** The author and committer git would use here, from the person's own git config (repo, global and
33
+ * system), so a sandboxed command that cannot read ~/.gitconfig still commits as them. Empty when
34
+ * either the name or the email is missing: Salaam never invents an identity. */
35
+ export function gitIdentityEnv(root, env = process.env) {
36
+ const read = (key) => {
37
+ try {
38
+ return execFileSync('git', ['config', '--get', key], {
39
+ cwd: root,
40
+ encoding: 'utf8',
41
+ timeout: 2000,
42
+ env: { ...env, GIT_TERMINAL_PROMPT: '0' },
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ }).trim();
45
+ }
46
+ catch {
47
+ return '';
48
+ }
49
+ };
50
+ const name = read('user.name'), email = read('user.email');
51
+ if (!name || !email || /[\0\n]/.test(name + email))
52
+ return {};
53
+ return {
54
+ GIT_AUTHOR_NAME: name,
55
+ GIT_AUTHOR_EMAIL: email,
56
+ GIT_COMMITTER_NAME: name,
57
+ GIT_COMMITTER_EMAIL: email,
58
+ };
59
+ }
60
+ /** git check-ref-format --branch, plus nothing a shell would read specially. */
61
+ export function validBranchName(name) {
62
+ return (/^[A-Za-z0-9._/-]+$/.test(name) &&
63
+ !/^[-./]|\/$|\.\.|\/\/|\/\.|\.lock(\/|$)|\.$/.test(name) &&
64
+ name !== '@');
65
+ }
66
+ export const GIT_PREFS_PATH = join(homedir(), '.config', 'salaam', 'git.json');
67
+ /** Per-person commit trailers: {"trailers": ["Key: value"], "session_trailer": true}. */
68
+ export function loadGitPrefs(path = GIT_PREFS_PATH) {
69
+ let raw;
70
+ try {
71
+ raw = JSON.parse(readFileSync(path, 'utf8'));
72
+ }
73
+ catch {
74
+ return { trailers: [], sessionTrailer: false };
75
+ }
76
+ const trailers = Array.isArray(raw.trailers)
77
+ ? raw.trailers.filter((t) => typeof t === 'string' && /^[A-Za-z][A-Za-z0-9-]*: [^\n\r]+$/.test(t))
78
+ : [];
79
+ return { trailers, sessionTrailer: raw.session_trailer === true };
80
+ }
81
+ const RULES = 'Never use --amend, rebase, reset or force anything, never pass --no-verify, and never change git config or hooks.';
82
+ export function commitInstruction(o) {
83
+ const trailers = [...o.trailers, ...(o.sessionTrailer ? [`Salaam-Session: ${o.session}`] : [])];
84
+ return [
85
+ 'Commit the current work.',
86
+ 'Run git status and git diff (staged and unstaged) to see what changed.',
87
+ 'Stage only the files that belong to this change, by path with git add; leave unrelated or generated files alone and say which you left.',
88
+ 'Write a commit message in this repository’s style (read git log --oneline -10): a short subject, a blank line, then a body saying why.',
89
+ trailers.length
90
+ ? `End the message with exactly these trailer lines:\n${trailers.join('\n')}`
91
+ : 'Add no trailer lines.',
92
+ 'Commit with git commit -F and a message file in the system temp directory, so the approval shows the exact message. The author comes from the person’s own git config; do not set one.',
93
+ RULES,
94
+ ].join('\n');
95
+ }
96
+ export function branchInstruction(name) {
97
+ if (!validBranchName(name))
98
+ throw new Error(`"${name}" is not a valid git branch name.`);
99
+ return [
100
+ `Switch to branch ${name}: run git switch ${name} if it exists (git branch --list ${name}), otherwise git switch -c ${name}.`,
101
+ 'If there are uncommitted changes that would be overwritten, stop and tell me instead of forcing.',
102
+ RULES,
103
+ ].join('\n');
104
+ }
105
+ export function prInstruction(o) {
106
+ if (!o.tool)
107
+ throw new Error('/pr needs gh or tea on this machine to open a pull request.');
108
+ const create = o.tool === 'gh'
109
+ ? `gh pr create --base ${o.base} --head ${o.branch} --title <title> --body-file <file>`
110
+ : `tea pr create --base ${o.base} --head ${o.branch} --title <title> --description <body>`;
111
+ return [
112
+ `Open a pull request for ${o.branch} into ${o.base}.`,
113
+ `Read git log ${o.base}..HEAD and git diff ${o.base}...HEAD, then draft a title and a body: what changed, why, and how it was tested.`,
114
+ `Push the branch with git push -u origin ${o.branch}; never use --force or any push option that rewrites the remote.`,
115
+ `Then open it with ${create}, writing the body to a file in the system temp directory.`,
116
+ 'Pushing and opening the pull request each ask for approval; show the drafted title and body before asking.',
117
+ RULES,
118
+ ].join('\n');
119
+ }
120
+ function gitOut(root, args) {
121
+ try {
122
+ return execFileSync('git', args, {
123
+ cwd: root,
124
+ encoding: 'utf8',
125
+ timeout: 2000,
126
+ stdio: ['ignore', 'pipe', 'ignore'],
127
+ }).trim();
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ /** The checked-out branch, or null when detached. */
134
+ export const currentBranch = (root) => gitOut(root, ['branch', '--show-current']) || null;
135
+ /** The remote's default branch (origin/HEAD), else main. */
136
+ export function defaultBase(root) {
137
+ const ref = gitOut(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
138
+ return ref?.replace(/^origin\//, '') || 'main';
139
+ }
140
+ /** Which forge CLI opens pull requests here: gh for GitHub remotes, tea otherwise, if installed. */
141
+ export function forgeTool(root) {
142
+ const run = (cmd, args) => {
143
+ try {
144
+ return execFileSync(cmd, args, {
145
+ cwd: root,
146
+ encoding: 'utf8',
147
+ timeout: 2000,
148
+ stdio: ['ignore', 'pipe', 'ignore'],
149
+ }).trim();
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ };
155
+ const url = run('git', ['remote', 'get-url', 'origin']) ?? '';
156
+ const has = (cmd) => run(cmd, ['--version']) !== null;
157
+ if (/github\.com[:/]/.test(url))
158
+ return has('gh') ? 'gh' : null;
159
+ return has('tea') ? 'tea' : has('gh') ? 'gh' : null;
160
+ }
@@ -0,0 +1,351 @@
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 { ServiceError } from './protocol.js';
6
+ /** Hooks for a person's own desk sessions (Phase 4b, operator 2026-10-31), in the Claude Code
7
+ * format: `{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command",
8
+ * "command": "...", "timeout": 30}]}]}}`. Yours live in `~/.config/salaam/hooks.json`; a
9
+ * repository's `.salaam/hooks.json` is ignored until you trust that exact file, and any change to it
10
+ * needs trusting again. A hook can block a tool call or a prompt; it can never approve anything. */
11
+ export const HOOK_EVENTS = [
12
+ 'PreToolUse',
13
+ 'PostToolUse',
14
+ 'UserPromptSubmit',
15
+ 'Stop',
16
+ 'Notification',
17
+ ];
18
+ const MAX_FILE = 64 * 1024;
19
+ const MAX_COMMAND = 4096;
20
+ const MAX_HANDLERS = 16;
21
+ const MAX_CONTEXT = 8192;
22
+ const DEFAULT_TIMEOUT = 60;
23
+ const MAX_TIMEOUT = 600;
24
+ const BLOCKING = new Set(['PreToolUse', 'UserPromptSubmit']);
25
+ const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
26
+ export function defaultUserHooksFile(env = process.env) {
27
+ return (env.SALAAM_HOOKS_FILE ||
28
+ join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'salaam', 'hooks.json'));
29
+ }
30
+ const sha = (data) => 'sha256:' + createHash('sha256').update(data).digest('hex');
31
+ export function parseHooks(text) {
32
+ const problems = [];
33
+ const config = {};
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(text);
37
+ }
38
+ catch (error) {
39
+ return { config, problems: ['not valid JSON: ' + error.message] };
40
+ }
41
+ const events = raw?.hooks;
42
+ if (!events || typeof events !== 'object' || Array.isArray(events))
43
+ return { config, problems: ['expected {"hooks": {...}}'] };
44
+ let handlers = 0;
45
+ for (const [event, groups] of Object.entries(events)) {
46
+ if (!HOOK_EVENTS.includes(event)) {
47
+ problems.push(`${event}: not supported; Salaam runs ${HOOK_EVENTS.join(', ')}`);
48
+ continue;
49
+ }
50
+ if (!Array.isArray(groups)) {
51
+ problems.push(`${event}: expected a list of matcher groups`);
52
+ continue;
53
+ }
54
+ for (const group of groups) {
55
+ const g = group;
56
+ if (g?.matcher !== undefined && typeof g.matcher !== 'string') {
57
+ problems.push(`${event}: matcher must be a string`);
58
+ continue;
59
+ }
60
+ if (typeof g.matcher === 'string' && !validMatcher(g.matcher)) {
61
+ problems.push(`${event}: matcher ${JSON.stringify(g.matcher)} is not a valid pattern`);
62
+ continue;
63
+ }
64
+ if (!Array.isArray(g.hooks)) {
65
+ problems.push(`${event}: a group needs a hooks list`);
66
+ continue;
67
+ }
68
+ const kept = [];
69
+ for (const h of g.hooks) {
70
+ const hook = h;
71
+ if (hook?.type !== 'command') {
72
+ problems.push(`${event}: hook type ${JSON.stringify(hook?.type)} is not supported (command only)`);
73
+ continue;
74
+ }
75
+ if (typeof hook.command !== 'string' || !hook.command.trim()) {
76
+ problems.push(`${event}: a command hook needs a command`);
77
+ continue;
78
+ }
79
+ if (Buffer.byteLength(hook.command) > MAX_COMMAND) {
80
+ problems.push(`${event}: a hook command is longer than 4 KiB`);
81
+ continue;
82
+ }
83
+ if (hook.timeout !== undefined &&
84
+ (typeof hook.timeout !== 'number' || !(hook.timeout > 0) || hook.timeout > MAX_TIMEOUT)) {
85
+ problems.push(`${event}: timeout must be 1–${MAX_TIMEOUT} seconds`);
86
+ continue;
87
+ }
88
+ if (++handlers > MAX_HANDLERS * HOOK_EVENTS.length) {
89
+ problems.push('too many hooks; the rest are ignored');
90
+ break;
91
+ }
92
+ kept.push({
93
+ command: hook.command,
94
+ ...(hook.timeout !== undefined ? { timeout: hook.timeout } : {}),
95
+ });
96
+ }
97
+ if (kept.length)
98
+ (config[event] ??= []).push({
99
+ ...(g.matcher !== undefined ? { matcher: g.matcher } : {}),
100
+ hooks: kept.slice(0, MAX_HANDLERS),
101
+ });
102
+ }
103
+ }
104
+ return { config, problems };
105
+ }
106
+ function validMatcher(m) {
107
+ if (m === '' || m === '*' || /^[A-Za-z0-9_|,-]+$/.test(m))
108
+ return true;
109
+ try {
110
+ new RegExp(m);
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** Claude Code's rule: empty or `*` matches everything, plain names (letters, digits, `_`, `-`,
118
+ * separated by `|` or `,`) are exact, anything else is a regular expression. `names` holds the
119
+ * Salaam tool name and its Claude Code alias (`Bash`, `Edit`, …). */
120
+ export function matchesTool(matcher, names) {
121
+ if (matcher === undefined || matcher === '' || matcher === '*')
122
+ return true;
123
+ if (/^[A-Za-z0-9_|,-]+$/.test(matcher)) {
124
+ const wanted = matcher.split(/[|,]/);
125
+ return names.some((n) => wanted.includes(n));
126
+ }
127
+ const re = new RegExp('^(?:' + matcher + ')$');
128
+ return names.some((n) => re.test(n));
129
+ }
130
+ export class Hooks {
131
+ store;
132
+ run;
133
+ userFile;
134
+ constructor(store, run, userFile = defaultUserHooksFile()) {
135
+ this.store = store;
136
+ this.run = run;
137
+ this.userFile = userFile;
138
+ store.db.exec('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))');
139
+ }
140
+ repoFile(session) {
141
+ return join(this.store.session(session).cwd, '.salaam', 'hooks.json');
142
+ }
143
+ /** Your hooks and this workspace's, read afresh, each with its trust state. */
144
+ sources(session) {
145
+ const root = this.store.session(session).cwd;
146
+ const out = [];
147
+ for (const [scope, path] of [
148
+ ['user', this.userFile],
149
+ ['repo', this.repoFile(session)],
150
+ ]) {
151
+ if (!existsSync(path) && !isLink(path))
152
+ continue;
153
+ const problems = [];
154
+ let digest = null;
155
+ let config = {};
156
+ try {
157
+ const real = realpathSync(path);
158
+ if (scope === 'repo' && !inside(realpathSync(root), real))
159
+ throw new Error('resolves outside the repository; ignored');
160
+ if (lstatSync(real).size > MAX_FILE)
161
+ throw new Error('larger than 64 KiB; ignored');
162
+ const bytes = readFileSync(real);
163
+ digest = sha(bytes);
164
+ const parsed = parseHooks(bytes.toString('utf8'));
165
+ config = parsed.config;
166
+ problems.push(...parsed.problems);
167
+ }
168
+ catch (error) {
169
+ problems.push(error.message);
170
+ digest = null;
171
+ }
172
+ const trusted = scope === 'user'
173
+ ? digest !== null
174
+ : digest !== null && this.trustedDigest(root, path) === digest;
175
+ out.push({ scope, path, digest, trusted, config, problems });
176
+ }
177
+ return out;
178
+ }
179
+ trustedDigest(root, path) {
180
+ const row = this.store.db
181
+ .prepare('SELECT digest FROM hook_trust WHERE root=? AND path=?')
182
+ .get(root, path);
183
+ return row?.digest;
184
+ }
185
+ /** Trust this workspace's hooks file as reviewed: `digest` must be the content shown to you. */
186
+ trust(session, digest) {
187
+ const source = this.sources(session).find((s) => s.scope === 'repo');
188
+ if (!source || !source.digest)
189
+ throw new ServiceError('hooks_missing', 'This workspace has no readable .salaam/hooks.json', 404);
190
+ if (source.digest !== digest)
191
+ throw new ServiceError('hooks_changed', 'The hooks file changed since you reviewed it; review it again', 409);
192
+ const root = this.store.session(session).cwd;
193
+ this.store.transaction(() => {
194
+ this.store.db
195
+ .prepare('INSERT INTO hook_trust VALUES (?,?,?,?) ON CONFLICT(root,path) DO UPDATE SET digest=excluded.digest,trusted_at=excluded.trusted_at')
196
+ .run(root, source.path, digest, new Date().toISOString());
197
+ this.store.event(session, 'hooks.trusted', { path: source.path, digest });
198
+ });
199
+ return { ...source, trusted: true };
200
+ }
201
+ revoke(session) {
202
+ const root = this.store.session(session).cwd;
203
+ const path = this.repoFile(session);
204
+ this.store.transaction(() => {
205
+ this.store.db.prepare('DELETE FROM hook_trust WHERE root=? AND path=?').run(root, path);
206
+ this.store.event(session, 'hooks.revoked', { path });
207
+ });
208
+ }
209
+ /** Run every trusted hook for `event`. Blocking events stop at the first block. */
210
+ async fire(session, event, payload, names) {
211
+ const notices = [];
212
+ const context = [];
213
+ const cwd = this.store.session(session).cwd;
214
+ const input = JSON.stringify({ session_id: session, cwd, hook_event_name: event, ...payload });
215
+ for (const source of this.sources(session)) {
216
+ if (!source.trusted)
217
+ continue;
218
+ for (const group of source.config[event] ?? []) {
219
+ if (TOOL_EVENTS.has(event) && !matchesTool(group.matcher, names))
220
+ continue;
221
+ for (const handler of group.hooks) {
222
+ const started = Date.now();
223
+ const r = await this.run(session, handler.command, input, (handler.timeout ?? DEFAULT_TIMEOUT) * 1000);
224
+ const decision = parseDecision(r.stdout);
225
+ let blocked;
226
+ if (BLOCKING.has(event) && !r.refused && !r.timedOut) {
227
+ if (r.code === 2)
228
+ blocked = r.stderr.trim() || `Blocked by a ${event} hook`;
229
+ else if (r.code === 0 && decision.block)
230
+ blocked = decision.reason || `Blocked by a ${event} hook`;
231
+ }
232
+ if (r.refused)
233
+ notices.push(`${event} hook refused: ${r.refused}`);
234
+ else if (r.timedOut)
235
+ notices.push(`${event} hook timed out: ${short(handler.command)}`);
236
+ else if (r.code !== 0 && r.code !== 2)
237
+ notices.push(`${event} hook failed (exit ${r.code}): ${short(handler.command)}`);
238
+ if (event === 'UserPromptSubmit' && r.code === 0 && !blocked) {
239
+ const extra = decision.context ?? (decision.json ? '' : r.stdout.trim());
240
+ if (extra)
241
+ context.push(extra);
242
+ }
243
+ this.store.transaction(() => this.store.event(session, 'hook.run', {
244
+ event,
245
+ scope: source.scope,
246
+ path: source.path,
247
+ file_digest: source.digest,
248
+ command_digest: sha(handler.command),
249
+ exit_code: r.code,
250
+ timed_out: r.timedOut,
251
+ refused: !!r.refused,
252
+ blocked: !!blocked,
253
+ ...(decision.ignored ? { ignored_decision: decision.ignored } : {}),
254
+ duration_ms: Date.now() - started,
255
+ stdout_digest: sha(r.stdout),
256
+ stderr_digest: sha(r.stderr),
257
+ output_bytes: Buffer.byteLength(r.stdout) + Buffer.byteLength(r.stderr),
258
+ truncated: r.truncated,
259
+ }));
260
+ if (blocked)
261
+ return { blocked, notices };
262
+ }
263
+ }
264
+ }
265
+ const joined = context.join('\n\n');
266
+ return {
267
+ notices,
268
+ ...(joined ? { context: truncate(joined, MAX_CONTEXT) } : {}),
269
+ };
270
+ }
271
+ }
272
+ /** A JSON object on stdout, read the Claude Code way. "allow" and "ask" are recorded as ignored:
273
+ * Salaam's own approval flow decides those. */
274
+ function parseDecision(stdout) {
275
+ const text = stdout.trim();
276
+ if (!text.startsWith('{'))
277
+ return { json: false };
278
+ let value;
279
+ try {
280
+ value = JSON.parse(text);
281
+ }
282
+ catch {
283
+ return { json: false };
284
+ }
285
+ if (!value || typeof value !== 'object' || Array.isArray(value))
286
+ return { json: false };
287
+ const specific = (value.hookSpecificOutput ?? {});
288
+ const decision = specific.permissionDecision;
289
+ const reason = typeof specific.permissionDecisionReason === 'string'
290
+ ? specific.permissionDecisionReason
291
+ : typeof value.reason === 'string'
292
+ ? value.reason
293
+ : undefined;
294
+ const context = typeof specific.additionalContext === 'string' ? specific.additionalContext : undefined;
295
+ if (decision === 'deny' || value.decision === 'block')
296
+ return { json: true, block: true, ...(reason ? { reason } : {}) };
297
+ return {
298
+ json: true,
299
+ ...(context ? { context } : {}),
300
+ ...(typeof decision === 'string' ? { ignored: decision } : {}),
301
+ ...(value.decision === 'approve' ? { ignored: 'approve' } : {}),
302
+ };
303
+ }
304
+ function inside(root, path) {
305
+ const r = relative(root, path);
306
+ return !r.startsWith('..') && !r.startsWith(sep);
307
+ }
308
+ function isLink(path) {
309
+ try {
310
+ return lstatSync(path).isSymbolicLink();
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ function short(command) {
317
+ const line = command.split('\n')[0];
318
+ return line.length > 60 ? line.slice(0, 57) + '…' : line;
319
+ }
320
+ function truncate(text, max) {
321
+ const buf = Buffer.from(text);
322
+ return buf.length <= max
323
+ ? text
324
+ : buf.subarray(0, max).toString('utf8') + '\n[hook output truncated]';
325
+ }
326
+ /** What `/hooks` shows, and what you review before trusting a workspace file: every command. */
327
+ export function describeHooks(sources) {
328
+ if (!sources.length)
329
+ 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).";
330
+ return sources
331
+ .map((s) => {
332
+ const state = s.scope === 'user'
333
+ ? 'yours, runs'
334
+ : s.trusted
335
+ ? 'workspace, trusted (this exact content)'
336
+ : s.digest
337
+ ? 'workspace, NOT trusted, does not run (/hooks trust to review)'
338
+ : 'workspace, unreadable';
339
+ const lines = [`${s.path} [${state}]`];
340
+ for (const event of HOOK_EVENTS)
341
+ for (const g of s.config[event] ?? [])
342
+ for (const h of g.hooks)
343
+ lines.push(` ${event}${g.matcher ? ` (${g.matcher})` : ''}: ${h.command}${h.timeout ? ` [${h.timeout}s]` : ''}`);
344
+ for (const p of s.problems)
345
+ lines.push(` ! ${p}`);
346
+ if (s.digest)
347
+ lines.push(` ${s.digest}`);
348
+ return lines.join('\n');
349
+ })
350
+ .join('\n\n');
351
+ }
package/dist/src/host.js 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 { readFileSync, readdirSync, mkdirSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, existsSync, realpathSync, constants, fstatSync, lstatSync, } from 'node:fs';
@@ -17,9 +18,9 @@ import { call } from './transport.js';
17
18
  import { readStationConfig } from './station-config.js';
18
19
  import { readToolkitConfig } from './toolkit-config.js';
19
20
  import { OperatorMcp } from './operator-mcp.js';
20
- import { chooseLocalSandbox, sandboxLabel } from './command-sandbox.js';
21
+ import { chooseLocalSandbox, resolveReadPaths, sandboxLabel, } from './command-sandbox.js';
21
22
  import { loadPersona } from './persona.js';
22
- export function hostLocalSandbox(platform = process.platform, find, findSb, allowUnsandboxed = false, brokerSocket) {
23
+ export function hostLocalSandbox(platform = process.platform, find, findSb, allowUnsandboxed = false, brokerSocket, readPaths = [], allowFullAccessSessions = false, allowYoloSessions = false, dangerClassifier) {
23
24
  const { kind } = chooseLocalSandbox('workspace-write', platform, find, findSb);
24
25
  return {
25
26
  kind,
@@ -34,8 +35,33 @@ export function hostLocalSandbox(platform = process.platform, find, findSb, allo
34
35
  },
35
36
  }
36
37
  : {}),
38
+ ...(readPaths.length ? { read_paths: [...readPaths] } : {}),
39
+ ...(allowFullAccessSessions ? { full_access_sessions: 'allow' } : {}),
40
+ ...(allowYoloSessions ? { yolo_sessions: 'allow' } : {}),
41
+ ...(dangerClassifier ? { danger_classifier: dangerClassifier } : {}),
37
42
  };
38
43
  }
44
+ /** The read-only paths outside the workspace the reviewed manifest lets local commands read. */
45
+ export function hostReadPaths(m) {
46
+ const r = m.local_sandbox?.read_paths;
47
+ return Array.isArray(r) ? r.filter((p) => typeof p === 'string') : [];
48
+ }
49
+ /** Whether the reviewed manifest lets an interactive session choose full access (Phase 2b). */
50
+ export function hostAllowsFullAccessSessions(m) {
51
+ return (m.local_sandbox?.full_access_sessions === 'allow');
52
+ }
53
+ /** Whether the reviewed manifest lets a desk session choose YOLO. */
54
+ export function hostAllowsYoloSessions(m) {
55
+ return m.local_sandbox?.yolo_sessions === 'allow';
56
+ }
57
+ /** The command reviewer a host service may run: a reviewed config and optional pin, or none
58
+ * ({ pin: 'off' }). Absent means forbidden: hosts get no reviewer without the operator. */
59
+ export function hostDangerClassifier(m) {
60
+ const d = m.local_sandbox?.danger_classifier;
61
+ if (!d || d.mode !== 'config')
62
+ return { pin: 'off' };
63
+ return { config: d.config, ...(d.pin ? { pin: d.pin } : {}) };
64
+ }
39
65
  /** The broker socket local commands reach the network through, or null for no network. */
40
66
  export function hostCommandNetwork(m) {
41
67
  const n = m.local_sandbox?.command_network;
@@ -133,6 +159,10 @@ async function planAgentPod(c) {
133
159
  'unsandboxed_commands',
134
160
  'command_network',
135
161
  'command_network_socket',
162
+ 'local_sandbox_read_paths',
163
+ 'allow_full_access_sessions',
164
+ 'allow_yolo_sessions',
165
+ 'danger_classifier',
136
166
  ];
137
167
  if (Object.keys(c).some((k) => !fields.includes(k)))
138
168
  throw Error('Unexpected AgentPod host config field');
@@ -172,6 +202,12 @@ async function planAgentPod(c) {
172
202
  if (c.unsandboxed_commands !== undefined && c.unsandboxed_commands !== 'allow')
173
203
  throw Error('unsandboxed_commands is "allow" or absent');
174
204
  const allowUnsandboxed = c.unsandboxed_commands === 'allow';
205
+ if (c.allow_full_access_sessions !== undefined && c.allow_full_access_sessions !== true)
206
+ throw Error('allow_full_access_sessions is true or absent');
207
+ const allowFullAccessSessions = c.allow_full_access_sessions === true;
208
+ if (c.allow_yolo_sessions !== undefined && c.allow_yolo_sessions !== true)
209
+ throw Error('allow_yolo_sessions is true or absent');
210
+ const allowYoloSessions = c.allow_yolo_sessions === true;
175
211
  if (c.command_network !== undefined && c.command_network !== 'broker')
176
212
  throw Error('command_network is "broker" or absent');
177
213
  if (c.command_network_socket !== undefined) {
@@ -181,6 +217,19 @@ async function planAgentPod(c) {
181
217
  if (typeof sock !== 'string' || !isAbsolute(sock) || sock.includes('\0'))
182
218
  throw Error('command_network_socket must be an absolute path');
183
219
  }
220
+ const dangerClassifier = parseHostDangerClassifier(c.danger_classifier);
221
+ let readPaths = [];
222
+ if (c.local_sandbox_read_paths !== undefined) {
223
+ const raw = c.local_sandbox_read_paths;
224
+ if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string'))
225
+ throw Error('local_sandbox_read_paths is a list of absolute paths or "toolchain"');
226
+ try {
227
+ readPaths = resolveReadPaths(raw);
228
+ }
229
+ catch (error) {
230
+ throw Error(error.message);
231
+ }
232
+ }
184
233
  const brokerSocket = c.command_network === 'broker'
185
234
  ? String(c.command_network_socket ?? DEFAULT_COMMAND_NETWORK_SOCKET)
186
235
  : undefined;
@@ -253,7 +302,45 @@ async function planAgentPod(c) {
253
302
  pins_digest: digest(pins),
254
303
  workspace: pins.workspace,
255
304
  cli_digest: digest(readFileSync(join(ROOT, 'bin/salaam.mjs'), 'utf8')),
256
- local_sandbox: hostLocalSandbox(undefined, undefined, undefined, allowUnsandboxed, brokerSocket),
305
+ local_sandbox: hostLocalSandbox(undefined, undefined, undefined, allowUnsandboxed, brokerSocket, readPaths, allowFullAccessSessions, allowYoloSessions, dangerClassifier),
306
+ };
307
+ }
308
+ function reviewerDigest(path) {
309
+ try {
310
+ return digest(readFileSync(path, 'utf8'));
311
+ }
312
+ catch {
313
+ return null;
314
+ }
315
+ }
316
+ function parseHostDangerClassifier(raw) {
317
+ if (raw === undefined)
318
+ return undefined;
319
+ if (raw === 'forbid')
320
+ return { mode: 'forbid' };
321
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
322
+ throw Error('danger_classifier is "forbid" or { config, pin? }');
323
+ const c = raw;
324
+ if (Object.keys(c).some((k) => k !== 'config' && k !== 'pin'))
325
+ throw Error('danger_classifier takes only config and pin');
326
+ if (typeof c.config !== 'string' || !isAbsolute(c.config) || c.config.includes('\0'))
327
+ throw Error('danger_classifier.config must be an absolute path');
328
+ let names;
329
+ try {
330
+ // Keys are named by environment variable and checked when the service starts, not here.
331
+ const anyKey = new Proxy({}, { get: () => 'checked-at-start' });
332
+ names = loadDangerClassifierOptions(JSON.parse(readFileSync(c.config, 'utf8')), anyKey).options.map((o) => o.name);
333
+ }
334
+ catch (error) {
335
+ throw Error('danger_classifier.config: ' + error.message);
336
+ }
337
+ if (c.pin !== undefined && (typeof c.pin !== 'string' || !names.includes(c.pin)))
338
+ throw Error("danger_classifier.pin must name one of the config's options");
339
+ return {
340
+ mode: 'config',
341
+ config: c.config,
342
+ digest: reviewerDigest(c.config),
343
+ ...(typeof c.pin === 'string' ? { pin: c.pin } : {}),
257
344
  };
258
345
  }
259
346
  export function hostDigests() {
@@ -389,8 +476,13 @@ export function readHost(path) {
389
476
  throw Error('Host CLI changed');
390
477
  if (Object.hasOwn(m, 'local_sandbox') &&
391
478
  canonical(m.local_sandbox) !==
392
- canonical(hostLocalSandbox(undefined, undefined, undefined, hostAllowsUnsandboxed(m), hostCommandNetwork(m)?.socket)))
479
+ canonical(hostLocalSandbox(undefined, undefined, undefined, hostAllowsUnsandboxed(m), hostCommandNetwork(m)?.socket, hostReadPaths(m), hostAllowsFullAccessSessions(m), hostAllowsYoloSessions(m), m.local_sandbox?.danger_classifier)))
393
480
  throw Error('Host command sandbox changed; run host plan again');
481
+ {
482
+ const d = m.local_sandbox?.danger_classifier;
483
+ if (d?.mode === 'config' && reviewerDigest(d.config) !== d.digest)
484
+ throw Error('Host command reviewer config changed; run host plan again');
485
+ }
394
486
  const pins = readAgentPodPins(m.agentpod_config, m.state);
395
487
  const managed = hostMcp(m);
396
488
  if (m.schema === 3 || ([4, 5, 6].includes(m.schema) && Object.hasOwn(m, 'managed_mcp'))) {