slaunt 0.1.17 → 0.1.18

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 (168) hide show
  1. package/README.md +17 -122
  2. package/bin/slaunt-hooks.js +96 -0
  3. package/package.json +9 -59
  4. package/release/SlauntKillBar.zip +0 -0
  5. package/release/manifest.json +7 -0
  6. package/scripts/killbar-portability-check.js +67 -0
  7. package/scripts/package-killbar-macos.sh +38 -0
  8. package/src/approval-manager.js +53 -0
  9. package/src/cli-auth.js +230 -0
  10. package/src/compact-wordmark.js +13 -0
  11. package/src/desktop/broker-daemon.js +18 -0
  12. package/src/desktop/broker-grants.js +37 -0
  13. package/src/desktop/broker-hook-command.js +23 -0
  14. package/src/desktop/broker-keychain.js +29 -0
  15. package/src/desktop/broker-launch-agent.js +34 -0
  16. package/src/desktop/broker-service.js +40 -0
  17. package/src/desktop/chat-capture-command.js +7 -0
  18. package/src/desktop/chat-hook-capture.js +445 -0
  19. package/src/desktop/claude-lifecycle.js +143 -0
  20. package/src/desktop/client-identity.js +19 -0
  21. package/src/desktop/native-claude-broker.js +34 -0
  22. package/src/desktop/native-claude-connection.js +42 -0
  23. package/src/desktop/tool-guard-command.js +13 -0
  24. package/src/desktop/tool-guard.js +225 -0
  25. package/src/discovery/catalog.js +734 -0
  26. package/src/full-screen.js +88 -0
  27. package/src/helper-release.js +27 -0
  28. package/src/hidden-oauth.js +70 -0
  29. package/src/independent-grants.js +38 -0
  30. package/src/install-flow.js +71 -0
  31. package/src/killbar-background-install.js +47 -0
  32. package/src/killbar-enrollment.js +74 -0
  33. package/src/killbar-launch-agent.js +10 -0
  34. package/src/mcp-identity.js +6 -0
  35. package/src/native-config.js +150 -0
  36. package/src/native-oauth.js +33 -0
  37. package/src/oauth-cohort.js +43 -0
  38. package/src/pty-wrapper.py +57 -0
  39. package/src/read-json.js +8 -0
  40. package/src/setup/client-installer.js +93 -0
  41. package/src/setup/codex-guard-supervisor.js +34 -0
  42. package/src/setup/hook-installer.js +496 -0
  43. package/src/setup/managed-policy.js +26 -0
  44. package/src/setup/mcp-config-installer.js +204 -0
  45. package/src/setup-flow.js +66 -0
  46. package/src/tui.js +205 -0
  47. package/test/approval-manager.test.js +27 -0
  48. package/test/broker-grants.test.js +14 -0
  49. package/test/broker-keychain.test.js +14 -0
  50. package/test/broker-launch-agent.test.js +15 -0
  51. package/test/broker-service.test.js +42 -0
  52. package/test/cli-auth-recovery.test.js +15 -0
  53. package/test/codex-guard-supervisor.test.js +25 -0
  54. package/test/full-screen.test.js +49 -0
  55. package/test/helper-release.test.js +22 -0
  56. package/test/hidden-oauth.test.js +44 -0
  57. package/test/hook-installer.test.js +232 -0
  58. package/test/independent-grants.test.js +16 -0
  59. package/test/install-flow.test.js +24 -0
  60. package/test/killbar-background-install.test.js +15 -0
  61. package/test/killbar-enrollment.test.js +115 -0
  62. package/test/killbar-launch-agent.test.js +12 -0
  63. package/test/killbar-portability.test.js +17 -0
  64. package/test/managed-policy.test.js +18 -0
  65. package/test/native-claude-broker.test.js +19 -0
  66. package/test/native-claude-connection.test.js +17 -0
  67. package/test/native-claude-hooks.test.js +9 -0
  68. package/test/native-config.test.js +3 -0
  69. package/test/native-integration.cjs +29 -0
  70. package/test/native-oauth.test.js +23 -0
  71. package/test/oauth-cohort.test.js +26 -0
  72. package/test/tui.test.js +100 -0
  73. package/LICENSE +0 -201
  74. package/dist/audit/risk-engine.d.ts +0 -4
  75. package/dist/audit/risk-engine.d.ts.map +0 -1
  76. package/dist/audit/risk-engine.js +0 -174
  77. package/dist/audit/risk-engine.js.map +0 -1
  78. package/dist/audit/run.d.ts +0 -18
  79. package/dist/audit/run.d.ts.map +0 -1
  80. package/dist/audit/run.js +0 -68
  81. package/dist/audit/run.js.map +0 -1
  82. package/dist/classification/builtin-catalog.d.ts +0 -3
  83. package/dist/classification/builtin-catalog.d.ts.map +0 -1
  84. package/dist/classification/builtin-catalog.js +0 -283
  85. package/dist/classification/builtin-catalog.js.map +0 -1
  86. package/dist/classification/catalog.d.ts +0 -10
  87. package/dist/classification/catalog.d.ts.map +0 -1
  88. package/dist/classification/catalog.js +0 -194
  89. package/dist/classification/catalog.js.map +0 -1
  90. package/dist/classification/classifier.d.ts +0 -3
  91. package/dist/classification/classifier.d.ts.map +0 -1
  92. package/dist/classification/classifier.js +0 -64
  93. package/dist/classification/classifier.js.map +0 -1
  94. package/dist/classification/public-catalog-config.d.ts +0 -3
  95. package/dist/classification/public-catalog-config.d.ts.map +0 -1
  96. package/dist/classification/public-catalog-config.js +0 -5
  97. package/dist/classification/public-catalog-config.js.map +0 -1
  98. package/dist/cli.d.ts +0 -3
  99. package/dist/cli.d.ts.map +0 -1
  100. package/dist/cli.js +0 -239
  101. package/dist/cli.js.map +0 -1
  102. package/dist/config/discover.d.ts +0 -8
  103. package/dist/config/discover.d.ts.map +0 -1
  104. package/dist/config/discover.js +0 -163
  105. package/dist/config/discover.js.map +0 -1
  106. package/dist/config/install.d.ts +0 -10
  107. package/dist/config/install.d.ts.map +0 -1
  108. package/dist/config/install.js +0 -143
  109. package/dist/config/install.js.map +0 -1
  110. package/dist/config/paths.d.ts +0 -24
  111. package/dist/config/paths.d.ts.map +0 -1
  112. package/dist/config/paths.js +0 -90
  113. package/dist/config/paths.js.map +0 -1
  114. package/dist/demo.d.ts +0 -4
  115. package/dist/demo.d.ts.map +0 -1
  116. package/dist/demo.js +0 -99
  117. package/dist/demo.js.map +0 -1
  118. package/dist/explanation/local.d.ts +0 -24
  119. package/dist/explanation/local.d.ts.map +0 -1
  120. package/dist/explanation/local.js +0 -696
  121. package/dist/explanation/local.js.map +0 -1
  122. package/dist/mcp/introspect.d.ts +0 -18
  123. package/dist/mcp/introspect.d.ts.map +0 -1
  124. package/dist/mcp/introspect.js +0 -236
  125. package/dist/mcp/introspect.js.map +0 -1
  126. package/dist/mcp/oauth.d.ts +0 -14
  127. package/dist/mcp/oauth.d.ts.map +0 -1
  128. package/dist/mcp/oauth.js +0 -257
  129. package/dist/mcp/oauth.js.map +0 -1
  130. package/dist/presentation/plain-language.d.ts +0 -12
  131. package/dist/presentation/plain-language.d.ts.map +0 -1
  132. package/dist/presentation/plain-language.js +0 -120
  133. package/dist/presentation/plain-language.js.map +0 -1
  134. package/dist/privacy/redaction.d.ts +0 -2
  135. package/dist/privacy/redaction.d.ts.map +0 -1
  136. package/dist/privacy/redaction.js +0 -50
  137. package/dist/privacy/redaction.js.map +0 -1
  138. package/dist/report/html.d.ts +0 -4
  139. package/dist/report/html.d.ts.map +0 -1
  140. package/dist/report/html.js +0 -101
  141. package/dist/report/html.js.map +0 -1
  142. package/dist/tui/analysis-path.d.ts +0 -17
  143. package/dist/tui/analysis-path.d.ts.map +0 -1
  144. package/dist/tui/analysis-path.js +0 -112
  145. package/dist/tui/analysis-path.js.map +0 -1
  146. package/dist/tui/render.d.ts +0 -4
  147. package/dist/tui/render.d.ts.map +0 -1
  148. package/dist/tui/render.js +0 -262
  149. package/dist/tui/render.js.map +0 -1
  150. package/dist/tui/splash.d.ts +0 -15
  151. package/dist/tui/splash.d.ts.map +0 -1
  152. package/dist/tui/splash.js +0 -130
  153. package/dist/tui/splash.js.map +0 -1
  154. package/dist/tui/stream.d.ts +0 -6
  155. package/dist/tui/stream.d.ts.map +0 -1
  156. package/dist/tui/stream.js +0 -56
  157. package/dist/tui/stream.js.map +0 -1
  158. package/dist/types.d.ts +0 -132
  159. package/dist/types.d.ts.map +0 -1
  160. package/dist/types.js +0 -2
  161. package/dist/types.js.map +0 -1
  162. package/dist/version.d.ts +0 -2
  163. package/dist/version.d.ts.map +0 -1
  164. package/dist/version.js +0 -8
  165. package/dist/version.js.map +0 -1
  166. package/models/APACHE-2.0.txt +0 -202
  167. package/models/MODEL.json +0 -29
  168. package/models/NOTICE.md +0 -23
@@ -0,0 +1,496 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const { catalog } = require('../discovery/catalog');
9
+
10
+ const { SLAUNT_SERVER_NAME } = require('../mcp-identity');
11
+ const { DENIAL } = require('../desktop/tool-guard');
12
+ const GUARD_MARKER = ': slaunt-pretooluse-v1; ';
13
+ const GUARD_STATUS = 'Requesting Slaunt tool authorization';
14
+ const CAPTURE_MARKER = ': slaunt-chat-capture-v1; ';
15
+ const CAPTURE_STATUS = 'Capturing visible Slaunt messages';
16
+ const NATIVE_LIFECYCLE_MARKER = ': slaunt-native-lifecycle-v1; ';
17
+ const PREVIOUS_SERVER_NAMES = new Set([SLAUNT_SERVER_NAME, 'Slaunt', 'slaunt-2']);
18
+ const MANAGED_TOOL_NAMES = new Set(['aki_workspace_context', 'codex_hook_event', 'slaunt_hook_event', 'tool_authorize_codex']);
19
+ const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
20
+ const HOOK_SETUP_TARGETS = Object.freeze({
21
+ 'claude-code': 'claude-mcp-tool',
22
+ 'codex-cli': 'codex-mcp-tool'
23
+ });
24
+ // Hook installation uses only documented, fixed per-client config paths. Do
25
+ // not search for arbitrary files or accept a target path from renderer input.
26
+ const CLIENT_HOOK_TARGETS = Object.freeze({
27
+ 'codex-mcp-tool': Object.freeze({ directory: '.codex', file: 'hooks.json' }),
28
+ 'claude-mcp-tool': Object.freeze({ directory: '.claude', file: 'settings.json' })
29
+ });
30
+
31
+ function makeHook(input, options = {}) {
32
+ const hook = {
33
+ type: 'mcp_tool',
34
+ server: SLAUNT_SERVER_NAME,
35
+ tool: options.tool || 'codex_hook_event',
36
+ input,
37
+ timeout: options.timeout || 15,
38
+ statusMessage: options.statusMessage
39
+ };
40
+ return hook;
41
+ }
42
+
43
+ function makeGuardHook(broker, clientId) {
44
+ const quote = value => `'${value.replace(/'/g, `'\\''`)}'`;
45
+ const fallback = `printf '%s\\n' ${quote(JSON.stringify(DENIAL))}`;
46
+ let command = fallback;
47
+ if (broker) {
48
+ if (!path.isAbsolute(broker.command || '') || !Array.isArray(broker.args)
49
+ || broker.args.length > 4 || [broker.command, ...broker.args].some(value =>
50
+ typeof value !== 'string' || !value || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value))
51
+ || broker.args.filter(value => value === '--slaunt-mcp-stdio').length !== 1
52
+ || broker.args.some(value => value.startsWith('--slaunt-client='))) throw new Error('invalid-guard-broker');
53
+ const script = path.resolve(__dirname, '../desktop/tool-guard-command.js');
54
+ command = `/usr/bin/env -u NODE_OPTIONS -u NODE_PATH ELECTRON_RUN_AS_NODE=1 ${[
55
+ broker.command, script, '--slaunt-pretooluse', `--slaunt-client=${clientId}`
56
+ ].map(quote).join(' ')} 2>/dev/null || { ${fallback}; }`;
57
+ }
58
+ return { type: 'command', command: GUARD_MARKER + command, timeout: 20, statusMessage: GUARD_STATUS };
59
+ }
60
+
61
+ function makeCaptureHook(broker) {
62
+ // Reuse the fixed-executable validation; no transcript path or content is
63
+ // interpolated into a shell command. Native hook JSON arrives only on stdin.
64
+ makeGuardHook(broker, 'codex-cli');
65
+ const quote = value => `'${value.replace(/'/g, `'\\''`)}'`;
66
+ const command = broker
67
+ ? `/usr/bin/env -u NODE_OPTIONS -u NODE_PATH ELECTRON_RUN_AS_NODE=1 ${[
68
+ broker.command, path.resolve(__dirname, '../desktop/chat-capture-command.js')
69
+ ].map(quote).join(' ')}`
70
+ : `printf '%s\\n' 'Slaunt visible chat capture unavailable (capture-broker-unavailable).' >&2; printf '%s\\n' '{}'`;
71
+ return { type: 'command', command: CAPTURE_MARKER + command, timeout: 10, statusMessage: CAPTURE_STATUS };
72
+ }
73
+
74
+ function createSlauntHooks({ broker, captureVisibleMessages = false } = {}) {
75
+ const config = {
76
+ hooks: {
77
+ SessionStart: [{
78
+ matcher: '^(startup|resume|clear|compact)$',
79
+ hooks: [makeHook({
80
+ event_name: 'SessionStart',
81
+ session_id: '${session_id}',
82
+ model: '${model}'
83
+ }, {
84
+ timeout: 30,
85
+ statusMessage: 'Registering Slaunt session'
86
+ })]
87
+ }],
88
+ UserPromptSubmit: [{
89
+ hooks: [makeHook({
90
+ event_name: 'UserPromptSubmit',
91
+ session_id: '${session_id}',
92
+ turn_id: '${turn_id}',
93
+ model: '${model}',
94
+ permission_mode: '${permission_mode}'
95
+ }, { statusMessage: 'Refreshing Slaunt context and starting thread logging' })]
96
+ }],
97
+ PreToolUse: [{
98
+ matcher: '*',
99
+ hooks: [makeHook({event_name:'PreToolUse',session_id:'${session_id}',turn_id:'${turn_id}',tool_name:'${tool_name}',tool_use_id:'${tool_use_id}'}, {tool:'tool_authorize_codex',timeout:10,statusMessage:GUARD_STATUS})]
100
+ }],
101
+ PostToolUse: [{
102
+ matcher: '*',
103
+ hooks: [makeHook({
104
+ event_name: 'PostToolUse',
105
+ session_id: '${session_id}',
106
+ turn_id: '${turn_id}',
107
+ tool_name: '${tool_name}',
108
+ tool_use_id: '${tool_use_id}',
109
+ model: '${model}',
110
+ permission_mode: '${permission_mode}'
111
+ }, { timeout: 15, statusMessage: 'Logging tool completion' })]
112
+ }],
113
+ SubagentStart: [{
114
+ hooks: [makeHook({
115
+ event_name: 'SubagentStart',
116
+ session_id: '${session_id}',
117
+ turn_id: '${turn_id}',
118
+ agent_type: '${agent_type}',
119
+ agent_id: '${agent_id}'
120
+ }, { timeout: 15, statusMessage: 'Logging subagent start' })]
121
+ }],
122
+ SubagentStop: [{
123
+ hooks: [makeHook({
124
+ event_name: 'SubagentStop',
125
+ session_id: '${session_id}',
126
+ turn_id: '${turn_id}',
127
+ agent_type: '${agent_type}',
128
+ agent_id: '${agent_id}'
129
+ }, { timeout: 15, statusMessage: 'Logging subagent completion' })]
130
+ }],
131
+ PreCompact: [{
132
+ matcher: '^(manual|auto)$',
133
+ hooks: [makeHook({
134
+ event_name: 'PreCompact',
135
+ session_id: '${session_id}',
136
+ turn_id: '${turn_id}',
137
+ trigger: '${trigger}'
138
+ }, { timeout: 15, statusMessage: 'Logging compaction start' })]
139
+ }],
140
+ PostCompact: [{
141
+ matcher: '^(manual|auto)$',
142
+ hooks: [makeHook({
143
+ event_name: 'PostCompact',
144
+ session_id: '${session_id}',
145
+ turn_id: '${turn_id}',
146
+ trigger: '${trigger}'
147
+ }, { timeout: 15, statusMessage: 'Logging compaction completion' })]
148
+ }],
149
+ Stop: [{
150
+ hooks: [makeHook({
151
+ event_name: 'Stop',
152
+ session_id: '${session_id}',
153
+ turn_id: '${turn_id}'
154
+ }, { timeout: 15, statusMessage: 'Closing Slaunt run' })]
155
+ }],
156
+ Interrupt: [{
157
+ hooks: [makeHook({
158
+ event_name: 'Interrupt',
159
+ session_id: '${session_id}',
160
+ turn_id: '${turn_id}'
161
+ }, { timeout: 3, statusMessage: 'Closing interrupted Slaunt run' })]
162
+ }]
163
+ }
164
+ };
165
+ if (captureVisibleMessages) {
166
+ for (const event of ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt']) {
167
+ config.hooks[event][0].hooks.push(makeCaptureHook(broker));
168
+ }
169
+ }
170
+ return config;
171
+ }
172
+
173
+ function createClaudeCodeHooks({ broker, nativeBroker = false } = {}) {
174
+ if(nativeBroker){
175
+ if(!broker)throw new Error('invalid-guard-broker');
176
+ const quote=value=>`'${value.replace(/'/g, `'\\''`)}'`;
177
+ const guard=makeGuardHook(broker,'claude-code');
178
+ const lifecycle=`/usr/bin/env -u NODE_OPTIONS -u NODE_PATH ${[broker.command,path.resolve(__dirname,'../desktop/broker-hook-command.js')].map(quote).join(' ')}`;
179
+ const eventHook=()=>({type:'command',command:NATIVE_LIFECYCLE_MARKER+lifecycle,timeout:20,onFailure:'block',statusMessage:'Connecting Slaunt run'});
180
+ return {hooks:{SessionStart:[{hooks:[eventHook()]}],UserPromptSubmit:[{hooks:[eventHook()]}],PreToolUse:[{matcher:'*',hooks:[{...guard,onFailure:'block'}]}],Stop:[{hooks:[eventHook()]}]}};
181
+ }
182
+ const hooks = createSlauntHooks({ broker, captureVisibleMessages: false }).hooks;
183
+ hooks.PreToolUse[0].hooks = [{ ...makeGuardHook(broker, 'claude-code'), onFailure: 'block' }];
184
+ delete hooks.Interrupt;
185
+
186
+ // Claude Code runs SessionStart before MCP servers are available on initial
187
+ // startup/resume. Only refresh after clear/compaction, when MCP is available.
188
+ hooks.SessionStart[0].matcher = '^(clear|compact)$';
189
+
190
+ hooks.UserPromptSubmit[0].hooks[0].input = {
191
+ event_name: 'UserPromptSubmit',
192
+ session_id: '${session_id}',
193
+ prompt_id: '${prompt_id}',
194
+ prompt: '${prompt}',
195
+ agent_id: '${agent_id}'
196
+ };
197
+ hooks.UserPromptSubmit[0].hooks[0].statusMessage = 'Saving Claude chat and refreshing Slaunt context';
198
+ hooks.PostToolUse[0].hooks[0].input = {
199
+ event_name: 'PostToolUse',
200
+ session_id: '${session_id}',
201
+ tool_name: '${tool_name}',
202
+ tool_use_id: '${tool_use_id}',
203
+ permission_mode: '${permission_mode}'
204
+ };
205
+ for (const eventName of ['SubagentStart', 'SubagentStop']) {
206
+ hooks[eventName][0].hooks[0].input = {
207
+ event_name: eventName,
208
+ session_id: '${session_id}',
209
+ agent_type: '${agent_type}',
210
+ agent_id: '${agent_id}'
211
+ };
212
+ }
213
+ for (const eventName of ['PreCompact', 'PostCompact']) {
214
+ hooks[eventName][0].hooks[0].input = {
215
+ event_name: eventName,
216
+ session_id: '${session_id}',
217
+ trigger: '${trigger}'
218
+ };
219
+ }
220
+ hooks.Stop[0].hooks[0].input = {
221
+ event_name: 'Stop',
222
+ session_id: '${session_id}',
223
+ prompt_id: '${prompt_id}',
224
+ last_assistant_message: '${last_assistant_message}'
225
+ };
226
+
227
+ for (const groups of Object.values(hooks)) {
228
+ for (const group of groups) for (const hook of group.hooks) {
229
+ if (hook.type === 'mcp_tool') hook.tool = 'slaunt_hook_event';
230
+ }
231
+ }
232
+ hooks.SessionStart[0].hooks[0].input = { event_name: 'SessionStart', session_id: '${session_id}' };
233
+ return { hooks };
234
+ }
235
+
236
+ function isManagedSlauntHook(hook) {
237
+ return Boolean(hook?.type === 'command' && typeof hook.command==='string' && hook.command.startsWith(NATIVE_LIFECYCLE_MARKER)) || Boolean(hook?.type === 'command' && hook.statusMessage === CAPTURE_STATUS
238
+ && typeof hook.command === 'string' && hook.command.startsWith(CAPTURE_MARKER)) || Boolean(hook?.type === 'command' && hook.statusMessage === GUARD_STATUS
239
+ && typeof hook.command === 'string' && hook.command.startsWith(GUARD_MARKER)) || Boolean(hook
240
+ && hook.type === 'mcp_tool'
241
+ && PREVIOUS_SERVER_NAMES.has(hook.server)
242
+ && MANAGED_TOOL_NAMES.has(hook.tool));
243
+ }
244
+
245
+ function mergeSlauntHooks(existingConfig, hookConfig = createSlauntHooks()) {
246
+ if (!existingConfig || typeof existingConfig !== 'object' || Array.isArray(existingConfig)) {
247
+ throw new Error('invalid-config-root');
248
+ }
249
+
250
+ const merged = { ...existingConfig };
251
+ if (merged.hooks === undefined) merged.hooks = {};
252
+ if (!merged.hooks || typeof merged.hooks !== 'object' || Array.isArray(merged.hooks)) {
253
+ throw new Error('invalid-hooks-section');
254
+ }
255
+ merged.hooks = { ...merged.hooks };
256
+
257
+ const managed = hookConfig.hooks;
258
+ for (const [eventName, groups] of Object.entries(managed)) {
259
+ const existingGroups = merged.hooks[eventName] || [];
260
+ if (!Array.isArray(existingGroups)) throw new Error('invalid-event-hook-list');
261
+
262
+ const retainedGroups = existingGroups
263
+ .map((group) => {
264
+ if (!group || typeof group !== 'object' || Array.isArray(group)) return group;
265
+ if (!Array.isArray(group.hooks)) return group;
266
+ const retainedHooks = group.hooks.filter((hook) => !isManagedSlauntHook(hook)
267
+ // The collector owns final assistant capture for Codex. Keep existing
268
+ // prompt/tool capture hooks, but avoid a second Stop final event.
269
+ && !(eventName === 'Stop' && groups.some(g => g.hooks.some(h => h.command?.startsWith(CAPTURE_MARKER)))
270
+ && hook.type === 'mcp_tool' && PREVIOUS_SERVER_NAMES.has(hook.server)
271
+ && hook.tool === 'chat_hook_event'));
272
+ if (retainedHooks.length === group.hooks.length) return group;
273
+ return retainedHooks.length > 0 ? { ...group, hooks: retainedHooks } : null;
274
+ })
275
+ .filter(Boolean);
276
+
277
+ merged.hooks[eventName] = [...retainedGroups, ...groups];
278
+ }
279
+
280
+ return merged;
281
+ }
282
+
283
+ function ensureConfigDirectory(homeDir, directoryName, fileSystem) {
284
+ if (!/^\.[a-z0-9-]+$/.test(directoryName)) throw new Error('unsafe-config-directory');
285
+ const configDirectory = path.join(homeDir, directoryName);
286
+ try {
287
+ const stats = fileSystem.lstatSync(configDirectory);
288
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('unsafe-config-directory');
289
+ } catch (error) {
290
+ if (error.code !== 'ENOENT') throw error;
291
+ fileSystem.mkdirSync(configDirectory, { mode: 0o700 });
292
+ }
293
+
294
+ const stats = fileSystem.lstatSync(configDirectory);
295
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('unsafe-config-directory');
296
+ return configDirectory;
297
+ }
298
+
299
+ function readExistingConfig(configPath, fileSystem) {
300
+ let stats;
301
+ try {
302
+ stats = fileSystem.lstatSync(configPath);
303
+ } catch (error) {
304
+ if (error.code === 'ENOENT') return {};
305
+ throw error;
306
+ }
307
+
308
+ if (stats.isSymbolicLink() || !stats.isFile()) throw new Error('unsafe-config-file');
309
+ if (stats.size > MAX_CONFIG_BYTES) throw new Error('config-file-too-large');
310
+ try {
311
+ const parsed = JSON.parse(fileSystem.readFileSync(configPath, 'utf8'));
312
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid-config-root');
313
+ return parsed;
314
+ } catch (error) {
315
+ if (error instanceof SyntaxError) throw new Error('invalid-config-json');
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ function writeConfigAtomically(configPath, configDirectory, serialized, fileSystem) {
321
+ const temporaryPath = path.join(
322
+ configDirectory,
323
+ `.hooks.json.slaunt-${process.pid}-${crypto.randomBytes(8).toString('hex')}.tmp`
324
+ );
325
+ let fileDescriptor;
326
+ try {
327
+ fileDescriptor = fileSystem.openSync(temporaryPath, 'wx', 0o600);
328
+ fileSystem.writeFileSync(fileDescriptor, serialized, 'utf8');
329
+ fileSystem.fsyncSync(fileDescriptor);
330
+ fileSystem.closeSync(fileDescriptor);
331
+ fileDescriptor = undefined;
332
+
333
+ try {
334
+ const current = fileSystem.lstatSync(configPath);
335
+ if (current.isSymbolicLink() || !current.isFile()) throw new Error('unsafe-config-file');
336
+ } catch (error) {
337
+ if (error.code !== 'ENOENT') throw error;
338
+ }
339
+
340
+ fileSystem.renameSync(temporaryPath, configPath);
341
+ fileSystem.chmodSync(configPath, 0o600);
342
+ } finally {
343
+ if (fileDescriptor !== undefined) fileSystem.closeSync(fileDescriptor);
344
+ try {
345
+ fileSystem.unlinkSync(temporaryPath);
346
+ } catch (error) {
347
+ if (error.code !== 'ENOENT') throw error;
348
+ }
349
+ }
350
+ }
351
+
352
+ function installHooksFile(homeDir, directoryName, fileName, hookConfig, fileSystem = fs) {
353
+ const configDirectory = ensureConfigDirectory(homeDir, directoryName, fileSystem);
354
+ const configPath = path.join(configDirectory, fileName);
355
+ const existing = readExistingConfig(configPath, fileSystem);
356
+ const merged = mergeSlauntHooks(existing, hookConfig);
357
+ const serialized = `${JSON.stringify(merged, null, 2)}\n`;
358
+ const previousSerialized = `${JSON.stringify(existing, null, 2)}\n`;
359
+
360
+ if (serialized === previousSerialized) return { status: 'unchanged' };
361
+ writeConfigAtomically(configPath, configDirectory, serialized, fileSystem);
362
+ return { status: 'installed' };
363
+ }
364
+
365
+ function installCodexHooks(homeDir, fileSystem = fs, options = {}) {
366
+ const target = CLIENT_HOOK_TARGETS['codex-mcp-tool'];
367
+ return installHooksFile(homeDir, target.directory, target.file, createSlauntHooks(options), fileSystem);
368
+ }
369
+
370
+ function claudeSupportsBlocking(versionOutput) {
371
+ const match = /^\s*(\d+)\.(\d+)\.(\d+)(?:\s|$)/.exec(versionOutput || '');
372
+ if (!match) return false;
373
+ const parts=match.slice(1).map(Number), minimum=[2,1,295];
374
+ for(let i=0;i<3;i++){if(parts[i]>minimum[i])return true;if(parts[i]<minimum[i])return false;}
375
+ return true;
376
+ }
377
+
378
+ function installClaudeCodeHooks(homeDir, fileSystem = fs, options = {}) {
379
+ const probe=(options.versionProbe || (()=>spawnSync('claude',['--version'],{encoding:'utf8',timeout:10000,stdio:['ignore','pipe','ignore']})))();
380
+ if (probe?.error || probe?.status !== 0 || !claudeSupportsBlocking(probe.stdout)) throw new Error('claude-version-unsupported');
381
+ const target = CLIENT_HOOK_TARGETS['claude-mcp-tool'];
382
+ const result = installHooksFile(homeDir, target.directory, target.file, createClaudeCodeHooks(options), fileSystem);
383
+ return {
384
+ status: 'partial',
385
+ reason: result.status === 'installed' || result.status === 'unchanged'
386
+ ? 'claude-lifecycle-differences'
387
+ : null
388
+ };
389
+ }
390
+
391
+ function safeClientResult(client, status, reason = null) {
392
+ return {
393
+ id: client.id,
394
+ surface: client.surface || 'unknown',
395
+ status,
396
+ reason
397
+ };
398
+ }
399
+
400
+ function safeInstallErrorReason(error) {
401
+ const knownReasons = new Set([
402
+ 'invalid-config-root',
403
+ 'invalid-hooks-section',
404
+ 'invalid-event-hook-list',
405
+ 'invalid-config-json',
406
+ 'unsafe-config-directory',
407
+ 'unsafe-config-file',
408
+ 'config-file-too-large'
409
+ ,'claude-version-unsupported'
410
+ ]);
411
+ if (knownReasons.has(error?.message)) return error.message;
412
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'permission-denied';
413
+ if (error?.code === 'ENOSPC') return 'insufficient-storage';
414
+ return 'config-write-failed';
415
+ }
416
+
417
+ function installHooksForDetectedClients({ clients = [], selectedClientIds, homeDir, broker, nativeBroker = false, fileSystem = fs, versionProbe } = {}) {
418
+ const byId = new Map(catalog.map((entry) => [entry.id, entry]));
419
+ const selected = Array.isArray(selectedClientIds) ? new Set(selectedClientIds) : null;
420
+ const detectedById = new Map();
421
+ clients.filter((client) => {
422
+ const entry = byId.get(client?.id);
423
+ return entry
424
+ && entry.localDetection
425
+ && entry.displayInOnboarding
426
+ && entry.nativeMcpClient
427
+ && (!selected || selected.has(entry.id))
428
+ && HOOK_SETUP_TARGETS[entry.id] === entry.hookAdapter;
429
+ }).forEach((client) => {
430
+ if (!detectedById.has(client.id)) detectedById.set(client.id, client);
431
+ });
432
+ const detectedClients = [...detectedById.values()];
433
+ const codexClients = detectedClients.filter((client) => byId.get(client.id).hookAdapter === 'codex-mcp-tool');
434
+ const claudeClients = detectedClients.filter((client) => byId.get(client.id).hookAdapter === 'claude-mcp-tool');
435
+
436
+ let codexInstallResult = null;
437
+ if (codexClients.length > 0) {
438
+ try {
439
+ codexInstallResult = installCodexHooks(homeDir, fileSystem, { broker });
440
+ } catch (error) {
441
+ codexInstallResult = {
442
+ status: 'error',
443
+ reason: safeInstallErrorReason(error)
444
+ };
445
+ }
446
+ }
447
+
448
+ let claudeInstallResult = null;
449
+ if (claudeClients.length > 0) {
450
+ try {
451
+ claudeInstallResult = installClaudeCodeHooks(homeDir, fileSystem, { broker, nativeBroker, versionProbe });
452
+ } catch (error) {
453
+ claudeInstallResult = {
454
+ status: 'error',
455
+ reason: safeInstallErrorReason(error)
456
+ };
457
+ }
458
+ }
459
+
460
+ const results = detectedClients.map((client) => {
461
+ const entry = byId.get(client.id);
462
+ if (entry.hookAdapter === 'codex-mcp-tool') {
463
+ return safeClientResult(client, codexInstallResult.status, codexInstallResult.reason || null);
464
+ }
465
+ if (entry.hookAdapter === 'claude-mcp-tool') {
466
+ return safeClientResult(client, claudeInstallResult.status, claudeInstallResult.reason || null);
467
+ }
468
+ return null;
469
+ });
470
+ const targetResults = results.filter(Boolean);
471
+
472
+ return {
473
+ serverName: SLAUNT_SERVER_NAME,
474
+ results: targetResults,
475
+ summary: {
476
+ detected: targetResults.length,
477
+ installed: targetResults.filter((result) => result.status === 'installed').length,
478
+ unchanged: targetResults.filter((result) => result.status === 'unchanged').length,
479
+ partial: targetResults.filter((result) => result.status === 'partial').length,
480
+ unsupported: 0,
481
+ failed: targetResults.filter((result) => result.status === 'error').length
482
+ }
483
+ };
484
+ }
485
+
486
+ module.exports = {
487
+ SLAUNT_SERVER_NAME,
488
+ CLIENT_HOOK_TARGETS,
489
+ createSlauntHooks,
490
+ createClaudeCodeHooks,
491
+ claudeSupportsBlocking,
492
+ mergeSlauntHooks,
493
+ installCodexHooks,
494
+ installClaudeCodeHooks,
495
+ installHooksForDetectedClients
496
+ };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { stringify } = require('smol-toml');
5
+
6
+ function createCodexManagedPolicy() {
7
+ return stringify({features:{hooks:true},hooks:{PreToolUse:[{matcher:'.*',hooks:[{type:'mcp_tool',server:'slaunt',tool:'tool_authorize_codex',input:{event_name:'PreToolUse',session_id:'${session_id}',turn_id:'${turn_id}',tool_name:'${tool_name}',tool_use_id:'${tool_use_id}'},timeout:10,statusMessage:'Requesting Slaunt tool authorization'}]}]}});
8
+ }
9
+
10
+ // Generate policy only; the administrator must deploy the guard executable in
11
+ // a root-owned directory and install the output through their management system.
12
+ // A managed hook cannot make Codex itself fail closed when the hook fails.
13
+ function createManagedPolicies({ guardPath, managedDir }) {
14
+ if (typeof guardPath !== 'string' || typeof managedDir !== 'string' ||
15
+ !path.posix.isAbsolute(guardPath) || !path.posix.isAbsolute(managedDir) ||
16
+ /[\x00-\x1f\x7f]/.test(guardPath + managedDir) ||
17
+ path.posix.normalize(guardPath) !== guardPath || path.posix.normalize(managedDir) !== managedDir ||
18
+ !guardPath.startsWith(managedDir.replace(/\/$/, '') + '/')) throw new Error('invalid-managed-guard-path');
19
+ const quote = value => `'${value.replace(/'/g, `'\\''`)}'`;
20
+ const command = quote(guardPath);
21
+ return {
22
+ codex: createCodexManagedPolicy(),
23
+ claude: {hooks:{PreToolUse:[{hooks:[{type:'command',command,onFailure:'block',timeout:20,statusMessage:'Requesting Slaunt tool authorization'}]}]}}
24
+ };
25
+ }
26
+ module.exports = { createManagedPolicies, createCodexManagedPolicy };