@superjackfruitlabs/salaam 0.0.3 → 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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,177 @@
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
+
9
+ /** Why a command publishes to a remote, or null. Read from the raw text on purpose: a push hidden in
10
+ * a substitution, `sh -c` or a compound command still counts, and a false match only asks. */
11
+ export function publishesToRemote(command: string): string | null {
12
+ for (const part of command.split(/&&|\|\||[;|\n]|\$\(|`/)) {
13
+ const words = part.replace(/["']/g, ' ').trim().split(/\s+/);
14
+ const at = words.findIndex((w) => /(^|\/)(git|gh|tea)$/.test(w));
15
+ if (at < 0) 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') return 'pushes commits to a remote';
25
+ } else if (/^(pr|pulls)$/.test(rest[0] ?? '') && rest[1] === 'create')
26
+ return 'opens a pull request on the forge';
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** The author and committer git would use here, from the person's own git config (repo, global and
32
+ * system), so a sandboxed command that cannot read ~/.gitconfig still commits as them. Empty when
33
+ * either the name or the email is missing: Salaam never invents an identity. */
34
+ export function gitIdentityEnv(
35
+ root: string,
36
+ env: NodeJS.ProcessEnv = process.env,
37
+ ): Record<string, string> {
38
+ const read = (key: string) => {
39
+ try {
40
+ return execFileSync('git', ['config', '--get', key], {
41
+ cwd: root,
42
+ encoding: 'utf8',
43
+ timeout: 2000,
44
+ env: { ...env, GIT_TERMINAL_PROMPT: '0' },
45
+ stdio: ['ignore', 'pipe', 'ignore'],
46
+ }).trim();
47
+ } catch {
48
+ return '';
49
+ }
50
+ };
51
+ const name = read('user.name'),
52
+ email = read('user.email');
53
+ if (!name || !email || /[\0\n]/.test(name + email)) return {};
54
+ return {
55
+ GIT_AUTHOR_NAME: name,
56
+ GIT_AUTHOR_EMAIL: email,
57
+ GIT_COMMITTER_NAME: name,
58
+ GIT_COMMITTER_EMAIL: email,
59
+ };
60
+ }
61
+
62
+ /** git check-ref-format --branch, plus nothing a shell would read specially. */
63
+ export function validBranchName(name: string): boolean {
64
+ return (
65
+ /^[A-Za-z0-9._/-]+$/.test(name) &&
66
+ !/^[-./]|\/$|\.\.|\/\/|\/\.|\.lock(\/|$)|\.$/.test(name) &&
67
+ name !== '@'
68
+ );
69
+ }
70
+
71
+ export interface GitPrefs {
72
+ trailers: string[];
73
+ sessionTrailer: boolean;
74
+ }
75
+ export const GIT_PREFS_PATH = join(homedir(), '.config', 'salaam', 'git.json');
76
+ /** Per-person commit trailers: {"trailers": ["Key: value"], "session_trailer": true}. */
77
+ export function loadGitPrefs(path = GIT_PREFS_PATH): GitPrefs {
78
+ let raw: { trailers?: unknown; session_trailer?: unknown };
79
+ try {
80
+ raw = JSON.parse(readFileSync(path, 'utf8'));
81
+ } catch {
82
+ return { trailers: [], sessionTrailer: false };
83
+ }
84
+ const trailers = Array.isArray(raw.trailers)
85
+ ? raw.trailers.filter(
86
+ (t): t is string => typeof t === 'string' && /^[A-Za-z][A-Za-z0-9-]*: [^\n\r]+$/.test(t),
87
+ )
88
+ : [];
89
+ return { trailers, sessionTrailer: raw.session_trailer === true };
90
+ }
91
+
92
+ const RULES =
93
+ 'Never use --amend, rebase, reset or force anything, never pass --no-verify, and never change git config or hooks.';
94
+
95
+ export function commitInstruction(o: GitPrefs & { session: string }): string {
96
+ const trailers = [...o.trailers, ...(o.sessionTrailer ? [`Salaam-Session: ${o.session}`] : [])];
97
+ return [
98
+ 'Commit the current work.',
99
+ 'Run git status and git diff (staged and unstaged) to see what changed.',
100
+ '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.',
101
+ '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.',
102
+ trailers.length
103
+ ? `End the message with exactly these trailer lines:\n${trailers.join('\n')}`
104
+ : 'Add no trailer lines.',
105
+ '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.',
106
+ RULES,
107
+ ].join('\n');
108
+ }
109
+
110
+ export function branchInstruction(name: string): string {
111
+ if (!validBranchName(name)) throw new Error(`"${name}" is not a valid git branch name.`);
112
+ return [
113
+ `Switch to branch ${name}: run git switch ${name} if it exists (git branch --list ${name}), otherwise git switch -c ${name}.`,
114
+ 'If there are uncommitted changes that would be overwritten, stop and tell me instead of forcing.',
115
+ RULES,
116
+ ].join('\n');
117
+ }
118
+
119
+ export function prInstruction(o: {
120
+ tool: 'gh' | 'tea' | null;
121
+ branch: string;
122
+ base: string;
123
+ }): string {
124
+ if (!o.tool) throw new Error('/pr needs gh or tea on this machine to open a pull request.');
125
+ const create =
126
+ o.tool === 'gh'
127
+ ? `gh pr create --base ${o.base} --head ${o.branch} --title <title> --body-file <file>`
128
+ : `tea pr create --base ${o.base} --head ${o.branch} --title <title> --description <body>`;
129
+ return [
130
+ `Open a pull request for ${o.branch} into ${o.base}.`,
131
+ `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.`,
132
+ `Push the branch with git push -u origin ${o.branch}; never use --force or any push option that rewrites the remote.`,
133
+ `Then open it with ${create}, writing the body to a file in the system temp directory.`,
134
+ 'Pushing and opening the pull request each ask for approval; show the drafted title and body before asking.',
135
+ RULES,
136
+ ].join('\n');
137
+ }
138
+
139
+ function gitOut(root: string, args: string[]): string | null {
140
+ try {
141
+ return execFileSync('git', args, {
142
+ cwd: root,
143
+ encoding: 'utf8',
144
+ timeout: 2000,
145
+ stdio: ['ignore', 'pipe', 'ignore'],
146
+ }).trim();
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ /** The checked-out branch, or null when detached. */
152
+ export const currentBranch = (root: string) => gitOut(root, ['branch', '--show-current']) || null;
153
+ /** The remote's default branch (origin/HEAD), else main. */
154
+ export function defaultBase(root: string): string {
155
+ const ref = gitOut(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
156
+ return ref?.replace(/^origin\//, '') || 'main';
157
+ }
158
+
159
+ /** Which forge CLI opens pull requests here: gh for GitHub remotes, tea otherwise, if installed. */
160
+ export function forgeTool(root: string): 'gh' | 'tea' | null {
161
+ const run = (cmd: string, args: string[]) => {
162
+ try {
163
+ return execFileSync(cmd, args, {
164
+ cwd: root,
165
+ encoding: 'utf8',
166
+ timeout: 2000,
167
+ stdio: ['ignore', 'pipe', 'ignore'],
168
+ }).trim();
169
+ } catch {
170
+ return null;
171
+ }
172
+ };
173
+ const url = run('git', ['remote', 'get-url', 'origin']) ?? '';
174
+ const has = (cmd: string) => run(cmd, ['--version']) !== null;
175
+ if (/github\.com[:/]/.test(url)) return has('gh') ? 'gh' : null;
176
+ return has('tea') ? 'tea' : has('gh') ? 'gh' : null;
177
+ }
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
+ }