@hopping-dev/hub 0.1.0

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 (165) hide show
  1. package/assets/hopping-skill/SKILL.md +221 -0
  2. package/dist/approval/manager.d.ts +60 -0
  3. package/dist/approval/manager.d.ts.map +1 -0
  4. package/dist/approval/manager.js +101 -0
  5. package/dist/approval/manager.js.map +1 -0
  6. package/dist/approval/session-memory.d.ts +37 -0
  7. package/dist/approval/session-memory.d.ts.map +1 -0
  8. package/dist/approval/session-memory.js +63 -0
  9. package/dist/approval/session-memory.js.map +1 -0
  10. package/dist/cli/config-writer.d.ts +57 -0
  11. package/dist/cli/config-writer.d.ts.map +1 -0
  12. package/dist/cli/config-writer.js +318 -0
  13. package/dist/cli/config-writer.js.map +1 -0
  14. package/dist/cli/index.d.ts +3 -0
  15. package/dist/cli/index.d.ts.map +1 -0
  16. package/dist/cli/index.js +82 -0
  17. package/dist/cli/index.js.map +1 -0
  18. package/dist/cli/path-resolver.d.ts +48 -0
  19. package/dist/cli/path-resolver.d.ts.map +1 -0
  20. package/dist/cli/path-resolver.js +212 -0
  21. package/dist/cli/path-resolver.js.map +1 -0
  22. package/dist/cli/setup.d.ts +10 -0
  23. package/dist/cli/setup.d.ts.map +1 -0
  24. package/dist/cli/setup.js +268 -0
  25. package/dist/cli/setup.js.map +1 -0
  26. package/dist/cloud/connector.d.ts +74 -0
  27. package/dist/cloud/connector.d.ts.map +1 -0
  28. package/dist/cloud/connector.js +524 -0
  29. package/dist/cloud/connector.js.map +1 -0
  30. package/dist/cloud/index.d.ts +3 -0
  31. package/dist/cloud/index.d.ts.map +1 -0
  32. package/dist/cloud/index.js +6 -0
  33. package/dist/cloud/index.js.map +1 -0
  34. package/dist/config/manager.d.ts +76 -0
  35. package/dist/config/manager.d.ts.map +1 -0
  36. package/dist/config/manager.js +296 -0
  37. package/dist/config/manager.js.map +1 -0
  38. package/dist/dev-mode.d.ts +30 -0
  39. package/dist/dev-mode.d.ts.map +1 -0
  40. package/dist/dev-mode.js +53 -0
  41. package/dist/dev-mode.js.map +1 -0
  42. package/dist/index.d.ts +10 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +354 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/ipc/index.d.ts +12 -0
  47. package/dist/ipc/index.d.ts.map +1 -0
  48. package/dist/ipc/index.js +15 -0
  49. package/dist/ipc/index.js.map +1 -0
  50. package/dist/ipc/watcher.d.ts +226 -0
  51. package/dist/ipc/watcher.d.ts.map +1 -0
  52. package/dist/ipc/watcher.js +745 -0
  53. package/dist/ipc/watcher.js.map +1 -0
  54. package/dist/local/approval-dialog.d.ts +30 -0
  55. package/dist/local/approval-dialog.d.ts.map +1 -0
  56. package/dist/local/approval-dialog.js +214 -0
  57. package/dist/local/approval-dialog.js.map +1 -0
  58. package/dist/local/index.d.ts +8 -0
  59. package/dist/local/index.d.ts.map +1 -0
  60. package/dist/local/index.js +13 -0
  61. package/dist/local/index.js.map +1 -0
  62. package/dist/local/local-approval.d.ts +55 -0
  63. package/dist/local/local-approval.d.ts.map +1 -0
  64. package/dist/local/local-approval.js +125 -0
  65. package/dist/local/local-approval.js.map +1 -0
  66. package/dist/local/notifier.d.ts +19 -0
  67. package/dist/local/notifier.d.ts.map +1 -0
  68. package/dist/local/notifier.js +110 -0
  69. package/dist/local/notifier.js.map +1 -0
  70. package/dist/local/sanitize.d.ts +20 -0
  71. package/dist/local/sanitize.d.ts.map +1 -0
  72. package/dist/local/sanitize.js +28 -0
  73. package/dist/local/sanitize.js.map +1 -0
  74. package/dist/mcp/file-extractor.d.ts +11 -0
  75. package/dist/mcp/file-extractor.d.ts.map +1 -0
  76. package/dist/mcp/file-extractor.js +74 -0
  77. package/dist/mcp/file-extractor.js.map +1 -0
  78. package/dist/mcp/risk-level.d.ts +44 -0
  79. package/dist/mcp/risk-level.d.ts.map +1 -0
  80. package/dist/mcp/risk-level.js +127 -0
  81. package/dist/mcp/risk-level.js.map +1 -0
  82. package/dist/mcp/schemas.d.ts +83 -0
  83. package/dist/mcp/schemas.d.ts.map +1 -0
  84. package/dist/mcp/schemas.js +84 -0
  85. package/dist/mcp/schemas.js.map +1 -0
  86. package/dist/mcp/summary.d.ts +11 -0
  87. package/dist/mcp/summary.d.ts.map +1 -0
  88. package/dist/mcp/summary.js +150 -0
  89. package/dist/mcp/summary.js.map +1 -0
  90. package/dist/mcp/tools.d.ts +45 -0
  91. package/dist/mcp/tools.d.ts.map +1 -0
  92. package/dist/mcp/tools.js +1217 -0
  93. package/dist/mcp/tools.js.map +1 -0
  94. package/dist/pairing/auto-pairing.d.ts +37 -0
  95. package/dist/pairing/auto-pairing.d.ts.map +1 -0
  96. package/dist/pairing/auto-pairing.js +144 -0
  97. package/dist/pairing/auto-pairing.js.map +1 -0
  98. package/dist/pairing/binding-poller.d.ts +26 -0
  99. package/dist/pairing/binding-poller.d.ts.map +1 -0
  100. package/dist/pairing/binding-poller.js +108 -0
  101. package/dist/pairing/binding-poller.js.map +1 -0
  102. package/dist/pairing/pairing-server.d.ts +14 -0
  103. package/dist/pairing/pairing-server.d.ts.map +1 -0
  104. package/dist/pairing/pairing-server.js +277 -0
  105. package/dist/pairing/pairing-server.js.map +1 -0
  106. package/dist/pairing/qr-display.d.ts +14 -0
  107. package/dist/pairing/qr-display.d.ts.map +1 -0
  108. package/dist/pairing/qr-display.js +40 -0
  109. package/dist/pairing/qr-display.js.map +1 -0
  110. package/dist/policy/engine.d.ts +31 -0
  111. package/dist/policy/engine.d.ts.map +1 -0
  112. package/dist/policy/engine.js +187 -0
  113. package/dist/policy/engine.js.map +1 -0
  114. package/dist/policy/store.d.ts +26 -0
  115. package/dist/policy/store.d.ts.map +1 -0
  116. package/dist/policy/store.js +70 -0
  117. package/dist/policy/store.js.map +1 -0
  118. package/dist/policy/system-policies.d.ts +15 -0
  119. package/dist/policy/system-policies.d.ts.map +1 -0
  120. package/dist/policy/system-policies.js +265 -0
  121. package/dist/policy/system-policies.js.map +1 -0
  122. package/dist/policy/tool-mapping.d.ts +45 -0
  123. package/dist/policy/tool-mapping.d.ts.map +1 -0
  124. package/dist/policy/tool-mapping.js +88 -0
  125. package/dist/policy/tool-mapping.js.map +1 -0
  126. package/dist/policy/tool-registry.json +85 -0
  127. package/dist/store/db.d.ts +17 -0
  128. package/dist/store/db.d.ts.map +1 -0
  129. package/dist/store/db.js +193 -0
  130. package/dist/store/db.js.map +1 -0
  131. package/dist/store/index.d.ts +4 -0
  132. package/dist/store/index.d.ts.map +1 -0
  133. package/dist/store/index.js +7 -0
  134. package/dist/store/index.js.map +1 -0
  135. package/dist/store/metadata.d.ts +31 -0
  136. package/dist/store/metadata.d.ts.map +1 -0
  137. package/dist/store/metadata.js +178 -0
  138. package/dist/store/metadata.js.map +1 -0
  139. package/dist/store/operations.d.ts +26 -0
  140. package/dist/store/operations.d.ts.map +1 -0
  141. package/dist/store/operations.js +171 -0
  142. package/dist/store/operations.js.map +1 -0
  143. package/dist/utils/json.d.ts +7 -0
  144. package/dist/utils/json.d.ts.map +1 -0
  145. package/dist/utils/json.js +33 -0
  146. package/dist/utils/json.js.map +1 -0
  147. package/dist/utils/logger.d.ts +13 -0
  148. package/dist/utils/logger.d.ts.map +1 -0
  149. package/dist/utils/logger.js +58 -0
  150. package/dist/utils/logger.js.map +1 -0
  151. package/dist/utils/open-browser.d.ts +8 -0
  152. package/dist/utils/open-browser.d.ts.map +1 -0
  153. package/dist/utils/open-browser.js +38 -0
  154. package/dist/utils/open-browser.js.map +1 -0
  155. package/node_modules/@hopping/shared/dist/types.d.ts +649 -0
  156. package/node_modules/@hopping/shared/dist/types.js +48 -0
  157. package/node_modules/@hopping/shared/dist/types.js.map +1 -0
  158. package/node_modules/@hopping/shared/package.json +14 -0
  159. package/node_modules/@hopping/shared/tsconfig.json +16 -0
  160. package/node_modules/@hopping/shared/types.d.ts +650 -0
  161. package/node_modules/@hopping/shared/types.d.ts.map +1 -0
  162. package/node_modules/@hopping/shared/types.js +48 -0
  163. package/node_modules/@hopping/shared/types.js.map +1 -0
  164. package/node_modules/@hopping/shared/types.ts +895 -0
  165. package/package.json +52 -0
@@ -0,0 +1,745 @@
1
+ "use strict";
2
+ /**
3
+ * Hub IPC Watcher — Hook ↔ Hub 檔案交換橋樑
4
+ *
5
+ * === 架構背景 ===
6
+ *
7
+ * 問題:
8
+ * pre-tool-use.js 是 Claude Code 為每次工具呼叫啟動的獨立 Node 程序。
9
+ * Hub 使用 MCP stdio transport(由 Claude Code 以子程序啟動,透過 stdin/stdout 通訊)。
10
+ * 因此,Hook 程序無法直接連到 Hub(Hub 沒有 HTTP/WebSocket port)。
11
+ *
12
+ * 解決方案(檔案交換 IPC):
13
+ *
14
+ * pre-tool-use.js Hub(IpcWatcher)
15
+ * | |
16
+ * | 1. 寫入 pending/{id}.json |
17
+ * | (IpcPendingRequest JSON) |
18
+ * |---------------------------------------->|
19
+ * | | 2. chokidar 偵測到新檔案
20
+ * | | 3. PolicyEngine.evaluate()
21
+ * | | 4. 寫入 resolved/{id}.json
22
+ * | | (IpcResolvedResponse JSON)
23
+ * |<----------------------------------------|
24
+ * | 5. polling 讀到 resolved 檔案 |
25
+ * | 6. 輸出 permissionDecision 給 Claude Code|
26
+ *
27
+ * === 技術選擇 ===
28
+ *
29
+ * 為什麼用 chokidar(而不是 Node.js 原生 fs.watch)?
30
+ * - chokidar 跨平台更穩定(macOS 的 kqueue、Linux 的 inotify、Windows 的 ReadDirectoryChanges)
31
+ * - awaitWriteFinish 選項等待檔案寫入穩定後再觸發事件,避免讀到寫到一半的 JSON
32
+ * - Hub 是長期執行的 MCP Server,穩定性比輕量更重要
33
+ *
34
+ * 為什麼用原子寫入(.tmp + rename)?
35
+ * - rename 在同一 filesystem 上是原子操作(POSIX 保證)
36
+ * - 避免 pre-tool-use.js 讀到 Hub 寫到一半的 resolved JSON(導致 JSON.parse 失敗)
37
+ *
38
+ * === Mode-Aware 行為(S4-HOOK-RPERM-001)===
39
+ *
40
+ * IpcWatcher 依據 askMode 決定行為:
41
+ * - local 模式(預設):TD-007 觀測 — ALLOW/ASK/WARN 全部 approved=true
42
+ * - away 模式:真正攔截 — ALLOW 自動放行,ASK/WARN 送 Cloud 等手機審核
43
+ *
44
+ * 避免重複審核:
45
+ * - local 模式用戶在電腦前 → hopping.check 已處理審核,Hook 層不重複送 Cloud
46
+ * - away 模式用戶不在電腦前 → Hook 層負責攔截,hopping.check 只在 Skill 呼叫時觸發
47
+ */
48
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
49
+ if (k2 === undefined) k2 = k;
50
+ var desc = Object.getOwnPropertyDescriptor(m, k);
51
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
52
+ desc = { enumerable: true, get: function() { return m[k]; } };
53
+ }
54
+ Object.defineProperty(o, k2, desc);
55
+ }) : (function(o, m, k, k2) {
56
+ if (k2 === undefined) k2 = k;
57
+ o[k2] = m[k];
58
+ }));
59
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
60
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
61
+ }) : function(o, v) {
62
+ o["default"] = v;
63
+ });
64
+ var __importStar = (this && this.__importStar) || (function () {
65
+ var ownKeys = function(o) {
66
+ ownKeys = Object.getOwnPropertyNames || function (o) {
67
+ var ar = [];
68
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
69
+ return ar;
70
+ };
71
+ return ownKeys(o);
72
+ };
73
+ return function (mod) {
74
+ if (mod && mod.__esModule) return mod;
75
+ var result = {};
76
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
77
+ __setModuleDefault(result, mod);
78
+ return result;
79
+ };
80
+ })();
81
+ var __importDefault = (this && this.__importDefault) || function (mod) {
82
+ return (mod && mod.__esModule) ? mod : { "default": mod };
83
+ };
84
+ Object.defineProperty(exports, "__esModule", { value: true });
85
+ exports.IpcWatcher = void 0;
86
+ const chokidar_1 = __importDefault(require("chokidar"));
87
+ const fs = __importStar(require("fs"));
88
+ const path = __importStar(require("path"));
89
+ const manager_1 = require("../config/manager");
90
+ const risk_level_1 = require("../mcp/risk-level");
91
+ const summary_1 = require("../mcp/summary");
92
+ const tool_mapping_1 = require("../policy/tool-mapping");
93
+ // ============================================
94
+ // 常數
95
+ // ============================================
96
+ /**
97
+ * IPC 審核超時上限(毫秒)
98
+ *
99
+ * pre-tool-use.js 有 RESPONSE_TIMEOUT_MS = 120,000ms(2 分鐘)。
100
+ * IpcWatcher 的 approval timeout 必須小於此值,確保 Hub 回應 before hook 超時。
101
+ * 預留 10 秒 buffer:120,000 - 10,000 = 110,000ms。
102
+ */
103
+ const MAX_IPC_TIMEOUT_MS = 110_000;
104
+ // ============================================
105
+ // IpcWatcher 類別
106
+ // ============================================
107
+ /**
108
+ * IPC Watcher:Hub 端的 Hook 請求處理器
109
+ *
110
+ * 在 Hub process 內執行,監聽 pending/ 目錄的新 JSON 檔案,
111
+ * 對每個審核請求執行 PolicyEngine 評估,並將結果寫回 resolved/ 目錄。
112
+ *
113
+ * S4-HOOK-RPERM-001: Mode-Aware
114
+ * - local 模式 → TD-007 觀測(auto-approve)
115
+ * - away 模式 → 真正攔截(ALLOW 放行,ASK/WARN 送 Cloud)
116
+ *
117
+ * 生命週期:
118
+ * new IpcWatcher(opts) → start() → [處理請求...] → stop()
119
+ */
120
+ class IpcWatcher {
121
+ opts;
122
+ /** pending/ 目錄完整路徑 */
123
+ pendingDir;
124
+ /** resolved/ 目錄完整路徑 */
125
+ resolvedDir;
126
+ /** chokidar FSWatcher 實例(null 表示尚未啟動或已停止) */
127
+ watcher = null;
128
+ /** 目前是否在執行中(防止重複 start()) */
129
+ isRunning = false;
130
+ /** 快取的 tool registry(避免每次 away-mode 審核都從磁碟讀取,隨實例生命週期管理) */
131
+ cachedRegistry = null;
132
+ constructor(opts) {
133
+ this.opts = opts;
134
+ this.pendingDir = path.join(opts.ipcDir, 'pending');
135
+ this.resolvedDir = path.join(opts.ipcDir, 'resolved');
136
+ }
137
+ /**
138
+ * 啟動 IPC 監聽
139
+ *
140
+ * 建立必要目錄,並開始用 chokidar 監聽 pending/ 的新增 .json 檔案。
141
+ * 呼叫多次不會重複啟動(有 isRunning 保護)。
142
+ */
143
+ start() {
144
+ if (this.isRunning) {
145
+ this.opts.logger.warn('IpcWatcher: 已在執行中,忽略重複 start()');
146
+ return;
147
+ }
148
+ // 確保 IPC 目錄存在
149
+ // pre-tool-use.js 也會建立這些目錄,但 Hub 可能先啟動
150
+ fs.mkdirSync(this.pendingDir, { recursive: true });
151
+ fs.mkdirSync(this.resolvedDir, { recursive: true });
152
+ this.opts.logger.info('IpcWatcher: 啟動監聽', {
153
+ pendingDir: this.pendingDir,
154
+ resolvedDir: this.resolvedDir,
155
+ });
156
+ this.watcher = chokidar_1.default.watch(this.pendingDir, {
157
+ // persistent: true 確保 Node process 不因為只剩 watcher 而退出
158
+ // Hub 已由 MCP transport 保持存活,這裡主要是明確聲明意圖
159
+ persistent: true,
160
+ // depth: 0 只監聽 pending/ 目錄本身,不進入子目錄
161
+ // pending/ 是 flat 結構({id}.json),不需要遞迴監聽
162
+ depth: 0,
163
+ // ignoreInitial: true 忽略 Hub 啟動時已存在的 pending 檔案
164
+ //
165
+ // 原因:這些是 Hub 上次關閉前遺留的「殭屍請求」:
166
+ // - 對應的 pre-tool-use.js 程序早已超時退出(2 分鐘超時)
167
+ // - 即使 Hub 回應了 resolved,也沒有任何程序在等待
168
+ // - 處理這些殭屍請求只會浪費資源並堆積 resolved 檔案
169
+ ignoreInitial: true,
170
+ // awaitWriteFinish: 等待檔案寫入穩定後才觸發 'add' 事件
171
+ //
172
+ // 雖然 pre-tool-use.js 已使用原子寫入(.tmp + rename),
173
+ // 但某些 OS(尤其是 Windows/虛擬機)的 rename 可能觸發多次 fs 事件。
174
+ // 等待 100ms 穩定可以確保 chokidar 只觸發一次 'add' 事件。
175
+ awaitWriteFinish: {
176
+ stabilityThreshold: 100, // 100ms 內無變化視為寫入完成
177
+ pollInterval: 50, // 每 50ms 輪詢一次穩定性
178
+ },
179
+ });
180
+ // 'add' 事件:pending/ 目錄內出現新檔案(或 Hub 啟動後新增的檔案)
181
+ this.watcher.on('add', (filePath) => {
182
+ // 只處理 .json 檔案(忽略 .tmp 等臨時檔案)
183
+ if (!filePath.endsWith('.json'))
184
+ return;
185
+ this.opts.logger.debug('IpcWatcher: 偵測到新 pending 請求', {
186
+ file: path.basename(filePath),
187
+ });
188
+ // 非同步處理,但錯誤不拋出(確保 watcher 繼續運作)
189
+ // handlePendingFile 內部已有完整的錯誤處理
190
+ this.handlePendingFile(filePath).catch((err) => {
191
+ this.opts.logger.error('IpcWatcher: handlePendingFile 發生未預期錯誤', {
192
+ filePath,
193
+ error: err instanceof Error ? err.message : 'Unknown error',
194
+ });
195
+ });
196
+ });
197
+ // 'error' 事件:chokidar 內部錯誤(例如被監聽的目錄被刪除)
198
+ this.watcher.on('error', (err) => {
199
+ this.opts.logger.error('IpcWatcher: chokidar 錯誤', {
200
+ error: err instanceof Error ? err.message : 'Unknown error',
201
+ });
202
+ // 不 throw,讓 watcher 繼續嘗試恢復(chokidar 會自動重試)
203
+ });
204
+ this.isRunning = true;
205
+ }
206
+ /**
207
+ * 停止 IPC 監聽
208
+ *
209
+ * 在 Hub 收到 SIGINT/SIGTERM 時呼叫(hub/src/index.ts 的 shutdown())。
210
+ * 停止後 Hub 不再處理新的 pending 請求,但已在進行中的請求不受影響。
211
+ */
212
+ async stop() {
213
+ if (!this.isRunning)
214
+ return;
215
+ if (this.watcher) {
216
+ try {
217
+ // await chokidar.close() 確保所有 in-flight handler 完成後才繼續
218
+ // 避免 shutdown 時 DB 在 handler 寫入前就被關閉
219
+ await this.watcher.close();
220
+ }
221
+ catch (err) {
222
+ this.opts.logger.warn('IpcWatcher: watcher.close() 發生錯誤(忽略)', {
223
+ error: err instanceof Error ? err.message : 'Unknown error',
224
+ });
225
+ }
226
+ this.watcher = null;
227
+ }
228
+ this.isRunning = false;
229
+ this.opts.logger.info('IpcWatcher: 已停止監聽');
230
+ }
231
+ // ============================================
232
+ // 內部方法
233
+ // ============================================
234
+ /**
235
+ * 處理一個新的 pending 請求檔案
236
+ *
237
+ * 完整流程:
238
+ * 1. 讀取並解析 JSON(IpcPendingRequest)
239
+ * 2. 判斷是否需要攔截(shouldIntercept)
240
+ * 3. 執行 PolicyEngine 評估(若需要攔截)
241
+ * 4. Mode-Aware 決策:
242
+ * - ALLOW(any mode)→ auto-approve
243
+ * - local + ASK/WARN → auto-approve(TD-007 觀測)
244
+ * - away + ASK/WARN → 送 Cloud 等手機審核
245
+ * 5. 原子寫入 resolved 回應(IpcResolvedResponse)
246
+ * 6. 寫入 SQLite 操作記錄(若 store 可用)
247
+ *
248
+ * @param filePath - pending/ 目錄中的 JSON 檔案完整路徑
249
+ */
250
+ async handlePendingFile(filePath) {
251
+ // --- 步驟 1:讀取並解析請求 ---
252
+ let request;
253
+ try {
254
+ const content = fs.readFileSync(filePath, 'utf8');
255
+ request = JSON.parse(content);
256
+ }
257
+ catch (err) {
258
+ // 檔案可能已被刪除(pre-tool-use.js 超時後會清理),或 JSON 格式錯誤
259
+ this.opts.logger.warn('IpcWatcher: 無法讀取/解析 pending 檔案', {
260
+ filePath,
261
+ error: err instanceof Error ? err.message : 'Unknown error',
262
+ });
263
+ return;
264
+ }
265
+ // 驗證必要欄位(防禦性檢查)
266
+ if (!request.id || !request.toolName) {
267
+ this.opts.logger.warn('IpcWatcher: pending 請求格式無效(缺少 id 或 toolName)', {
268
+ filePath,
269
+ request,
270
+ });
271
+ return;
272
+ }
273
+ // 防禦 path traversal:id 必須是單純的檔案名稱(不含路徑分隔符)
274
+ // 正常的 id 格式為 "hook-{uuid}",不應包含 / 或 \
275
+ if (request.id !== path.basename(request.id)) {
276
+ this.opts.logger.warn('IpcWatcher: id 含路徑分隔符,拒絕處理(可能的 path traversal)', {
277
+ id: request.id,
278
+ filePath,
279
+ });
280
+ return;
281
+ }
282
+ const { id, toolName, sessionId } = request;
283
+ const now = new Date().toISOString();
284
+ this.opts.logger.info('IpcWatcher: 處理審核請求', {
285
+ id,
286
+ toolName,
287
+ sessionId,
288
+ });
289
+ // --- 步驟 2:判斷是否需要攔截 ---
290
+ if (!this.opts.shouldIntercept(toolName)) {
291
+ // dev-mode(isolated/allowlist)情況下,此工具不需要攔截 → 自動放行
292
+ //
293
+ // 這是「雞生蛋解法」的關鍵:
294
+ // 在 HOPPING_DEV_MODE=isolated 時,所有工具都自動放行,
295
+ // 避免用 Claude Code 開發 HopPing 本身時被自己攔截
296
+ this.opts.logger.debug('IpcWatcher: 自動放行(shouldIntercept 返回 false)', {
297
+ toolName,
298
+ });
299
+ this.writeResolved(id, {
300
+ id,
301
+ approved: true,
302
+ reason: 'Auto-approved (dev mode: not intercepting this tool)',
303
+ timestamp: now,
304
+ });
305
+ return;
306
+ }
307
+ // --- 步驟 3:執行 PolicyEngine 評估 ---
308
+ const evaluationContext = {
309
+ requestId: id,
310
+ agentId: sessionId ?? 'unknown',
311
+ agentType: this.opts.agentType,
312
+ operation: {
313
+ type: toolName,
314
+ target: request.cwd,
315
+ },
316
+ session: {
317
+ costUsd: 0,
318
+ durationSeconds: (Date.now() - this.opts.startTime) / 1000,
319
+ approvalCount: 0,
320
+ },
321
+ timestamp: now,
322
+ };
323
+ let policyResult = null;
324
+ try {
325
+ policyResult = await this.opts.policyEngine.evaluate(evaluationContext);
326
+ this.opts.logger.info('IpcWatcher: Policy 評估完成', {
327
+ id,
328
+ toolName,
329
+ finalAction: policyResult.finalAction,
330
+ triggeredPolicies: policyResult.triggeredPolicies.map((p) => p.policyName),
331
+ });
332
+ }
333
+ catch (err) {
334
+ // PolicyEngine 評估失敗 → 安全降級:auto-approve
335
+ this.opts.logger.error('IpcWatcher: PolicyEngine 評估失敗(降級 auto-approve)', {
336
+ id,
337
+ toolName,
338
+ error: err instanceof Error ? err.message : 'Unknown error',
339
+ });
340
+ this.writeResolved(id, {
341
+ id,
342
+ approved: true,
343
+ reason: 'Policy evaluation failed; allowing in observation mode.',
344
+ timestamp: now,
345
+ });
346
+ this.cleanupPending(filePath);
347
+ if (this.opts.store) {
348
+ this.writeOperationRecord({
349
+ id,
350
+ toolName,
351
+ request,
352
+ approved: true,
353
+ now,
354
+ policyResult: null,
355
+ });
356
+ }
357
+ return;
358
+ }
359
+ // --- 步驟 4:Mode-Aware 決策 ---
360
+ // 統一計算 riskLevel(YOLO / away 模式皆需要,ALLOW / local 路徑成本極低可忽略)
361
+ const riskLevel = (0, risk_level_1.deriveRiskLevel)(policyResult.finalAction, toolName, request.toolInput ?? {});
362
+ // YOLO 模式:auto-approve(Policy 已執行,riskLevel 用於觀測記錄)
363
+ if (this.opts.isYoloMode?.()) {
364
+ this.writeResolved(id, {
365
+ id,
366
+ approved: true,
367
+ reason: 'YOLO mode: auto-approved (observation)',
368
+ timestamp: now,
369
+ });
370
+ this.cleanupPending(filePath);
371
+ if (this.opts.store) {
372
+ this.writeOperationRecord({
373
+ id,
374
+ toolName,
375
+ request,
376
+ approved: true,
377
+ now,
378
+ policyResult,
379
+ });
380
+ }
381
+ // 可選:透過 CloudConnector 發送 progress_update(Cloud 未連線則靜默略過)
382
+ const yoloConnector = this.opts.getCloudConnector?.();
383
+ if (yoloConnector?.isConnected) {
384
+ const agentId = this.opts.getAgentId?.() ?? 'unknown';
385
+ yoloConnector.sendProgressUpdate((0, risk_level_1.buildYoloProgressPayload)({
386
+ agentId,
387
+ sessionId: request.sessionId ?? agentId,
388
+ toolName,
389
+ riskLevel,
390
+ timestamp: now,
391
+ filePaths: extractFilePathsFromInput(toolName, request.toolInput ?? {}).filter(Boolean),
392
+ }));
393
+ }
394
+ return;
395
+ }
396
+ // ALLOW:任何模式都自動放行
397
+ if (policyResult.finalAction === 'ALLOW') {
398
+ this.writeResolved(id, {
399
+ id,
400
+ approved: true,
401
+ reason: 'Approved by local policy',
402
+ timestamp: now,
403
+ });
404
+ this.cleanupPending(filePath);
405
+ if (this.opts.store) {
406
+ this.writeOperationRecord({ id, toolName, request, approved: true, now, policyResult });
407
+ }
408
+ return;
409
+ }
410
+ // ASK/WARN:依 askMode 決策
411
+ const mode = this.opts.getAskMode();
412
+ if (mode === 'local') {
413
+ // local 模式:TD-007 觀測(auto-approve)
414
+ // 審核職責交由 Skill + hopping.check(避免重複審核)
415
+ this.writeResolved(id, {
416
+ id,
417
+ approved: true,
418
+ reason: `Observed (${policyResult.finalAction}) — local mode, approval delegated to hopping.check`,
419
+ timestamp: now,
420
+ });
421
+ this.cleanupPending(filePath);
422
+ if (this.opts.store) {
423
+ this.writeOperationRecord({ id, toolName, request, approved: true, now, policyResult });
424
+ }
425
+ return;
426
+ }
427
+ // away 模式:真正攔截
428
+ await this.handleAwayModeApproval(id, toolName, request, policyResult, filePath, now, riskLevel);
429
+ }
430
+ /**
431
+ * Away 模式的 Cloud 審核流程
432
+ *
433
+ * 1. Cloud connected → sendPermissionPrompt + waitForApproval
434
+ * 2. Cloud disconnected → auto-allow fallback(降級觀測)
435
+ * 3. Timeout → 依 riskLevel 決策
436
+ * 4. Denied → approved=false
437
+ */
438
+ async handleAwayModeApproval(id, toolName, request, policyResult, filePath, now, riskLevel) {
439
+ // S4-HUB-DONTASK-001: Session memory check — auto-approve if "don't ask again" was set
440
+ if (this.opts.sessionMemory?.has(toolName, riskLevel)) {
441
+ this.opts.logger.info('IpcWatcher: session memory hit — auto-approve', {
442
+ id,
443
+ toolName,
444
+ riskLevel,
445
+ });
446
+ this.writeResolved(id, {
447
+ id,
448
+ approved: true,
449
+ reason: 'Session memory: dont_ask_again',
450
+ timestamp: now,
451
+ });
452
+ this.cleanupPending(filePath);
453
+ if (this.opts.store) {
454
+ this.writeOperationRecord({
455
+ id,
456
+ toolName,
457
+ request,
458
+ approved: true,
459
+ now,
460
+ policyResult,
461
+ promptType: 'native_permission',
462
+ responseType: 'binary',
463
+ });
464
+ }
465
+ return;
466
+ }
467
+ const cloudConnector = this.opts.getCloudConnector();
468
+ // Cloud 未連線:auto-allow fallback(降級觀測模式)
469
+ if (!cloudConnector?.isConnected) {
470
+ this.opts.logger.warn('IpcWatcher: away 模式但 Cloud 未連線,降級 auto-allow', {
471
+ id,
472
+ toolName,
473
+ riskLevel,
474
+ });
475
+ this.writeResolved(id, {
476
+ id,
477
+ approved: true,
478
+ reason: 'Auto-allowed (Cloud offline, observation mode)',
479
+ timestamp: now,
480
+ });
481
+ this.cleanupPending(filePath);
482
+ if (this.opts.store) {
483
+ this.writeOperationRecord({
484
+ id,
485
+ toolName,
486
+ request,
487
+ approved: true,
488
+ now,
489
+ policyResult,
490
+ promptType: 'native_permission',
491
+ responseType: 'binary',
492
+ overrideDecision: 'auto_allowed_offline',
493
+ });
494
+ }
495
+ return;
496
+ }
497
+ // Cloud 已連線:送 permission_prompt 並等待 Mobile 回應
498
+ this.opts.logger.info('IpcWatcher: away 模式,送 Cloud 審核', {
499
+ id,
500
+ toolName,
501
+ riskLevel,
502
+ finalAction: policyResult.finalAction,
503
+ });
504
+ // 取得 approval timeout 設定
505
+ const approvalConfig = await this.getApprovalConfigSafe();
506
+ const configTimeout = approvalConfig.timeouts[riskLevel];
507
+ const effectiveTimeout = Math.min(configTimeout, MAX_IPC_TIMEOUT_MS);
508
+ // 建立 summary 和 toolContext(registry 快取避免重複磁碟讀取)
509
+ const summary = (0, summary_1.generateSummary)(toolName, request.toolInput ?? {});
510
+ if (!this.cachedRegistry) {
511
+ this.cachedRegistry = (0, tool_mapping_1.loadBundledRegistry)();
512
+ }
513
+ const mappingEntry = (0, tool_mapping_1.resolveToolMapping)(toolName, this.opts.agentType, null, this.cachedRegistry);
514
+ const agentId = this.opts.getAgentId?.() ?? 'unknown';
515
+ // 先寫入 SQLite(decision 暫為 null,等待 Mobile 回應後更新)
516
+ if (this.opts.store) {
517
+ this.writeOperationRecord({
518
+ id,
519
+ toolName,
520
+ request,
521
+ approved: false,
522
+ now,
523
+ policyResult,
524
+ promptType: 'native_permission',
525
+ responseType: 'binary',
526
+ });
527
+ }
528
+ // 發送 permission_prompt 到 Cloud
529
+ const promptPayload = {
530
+ id,
531
+ agentId,
532
+ toolName,
533
+ riskLevel,
534
+ summary,
535
+ policyAction: policyResult.finalAction,
536
+ sessionId: request.sessionId,
537
+ timestamp: now,
538
+ timeoutMs: effectiveTimeout,
539
+ onTimeout: (0, manager_1.toApprovalDecision)(approvalConfig.onTimeout[riskLevel]),
540
+ promptType: 'native_permission',
541
+ responseType: 'binary',
542
+ toolContext: {
543
+ description: risk_level_1.OPERATION_TYPE_DESCRIPTIONS[mappingEntry.type] ?? risk_level_1.OPERATION_TYPE_DESCRIPTIONS['unknown'],
544
+ parameters: request.toolInput ?? {},
545
+ },
546
+ };
547
+ cloudConnector.sendPermissionPrompt(promptPayload);
548
+ // 等待 ApprovalManager 回傳決策
549
+ const result = await this.opts.approvalManager.waitForApproval(id, effectiveTimeout);
550
+ if (!result.timedOut) {
551
+ // Mobile 及時回應
552
+ const approved = result.approved;
553
+ const reason = approved
554
+ ? `Approved remotely${result.reason ? `: ${result.reason}` : ''}`
555
+ : `Denied remotely${result.reason ? `: ${result.reason}` : ''}`;
556
+ this.writeResolved(id, {
557
+ id,
558
+ approved,
559
+ reason,
560
+ timestamp: new Date().toISOString(),
561
+ });
562
+ this.cleanupPending(filePath);
563
+ // 更新 SQLite decision
564
+ if (this.opts.store) {
565
+ const decidedAt = new Date().toISOString();
566
+ this.opts.store.operations.updateDecision(id, approved ? 'approved' : 'denied', decidedAt);
567
+ }
568
+ return;
569
+ }
570
+ // Timeout:依 config 決策
571
+ const onTimeout = (0, manager_1.toApprovalDecision)(approvalConfig.onTimeout[riskLevel]);
572
+ const timeoutApproved = onTimeout === 'approved' || onTimeout === 'pending';
573
+ this.opts.logger.warn('IpcWatcher: away 模式審核超時', {
574
+ id,
575
+ toolName,
576
+ riskLevel,
577
+ onTimeout,
578
+ timeoutApproved,
579
+ });
580
+ this.writeResolved(id, {
581
+ id,
582
+ approved: timeoutApproved,
583
+ reason: `Approval timeout (${effectiveTimeout}ms), decision: ${onTimeout}`,
584
+ timestamp: new Date().toISOString(),
585
+ });
586
+ this.cleanupPending(filePath);
587
+ // 更新 SQLite
588
+ if (this.opts.store) {
589
+ const decidedAt = new Date().toISOString();
590
+ this.opts.store.operations.updateDecision(id, onTimeout, decidedAt);
591
+ }
592
+ }
593
+ /**
594
+ * 安全取得 ApprovalTimeoutConfig
595
+ */
596
+ async getApprovalConfigSafe() {
597
+ if (!this.opts.configManager)
598
+ return { ...manager_1.DEFAULT_APPROVAL_CONFIG };
599
+ try {
600
+ return await this.opts.configManager.getApprovalConfig();
601
+ }
602
+ catch {
603
+ return { ...manager_1.DEFAULT_APPROVAL_CONFIG };
604
+ }
605
+ }
606
+ /**
607
+ * 原子寫入 resolved 回應檔案
608
+ *
609
+ * 步驟:
610
+ * 1. 寫入 {id}.json.tmp(暫存)
611
+ * 2. rename 為 {id}.json(原子替換)
612
+ *
613
+ * 為什麼需要原子寫入?
614
+ * pre-tool-use.js 每 200ms polling resolved/ 目錄,
615
+ * 若不用原子寫入,可能讀到寫到一半的 JSON(JSON.parse 失敗)。
616
+ * rename 在同一 filesystem 上是 POSIX 原子操作,不存在這個問題。
617
+ *
618
+ * @param id - IPC 請求 ID(對應 pending 檔案名,不含副檔名)
619
+ * @param response - 要寫入的回應資料
620
+ */
621
+ writeResolved(id, response) {
622
+ const resolvedPath = path.join(this.resolvedDir, `${id}.json`);
623
+ const tmpPath = `${resolvedPath}.tmp`;
624
+ try {
625
+ // 先寫暫存檔
626
+ fs.writeFileSync(tmpPath, JSON.stringify(response, null, 2), 'utf8');
627
+ // 原子替換:rename 成功則 pre-tool-use.js 可以立刻讀到完整 JSON
628
+ fs.renameSync(tmpPath, resolvedPath);
629
+ this.opts.logger.debug('IpcWatcher: resolved 回應已寫入', {
630
+ id,
631
+ approved: response.approved,
632
+ resolvedPath,
633
+ });
634
+ }
635
+ catch (err) {
636
+ this.opts.logger.error('IpcWatcher: 寫入 resolved 失敗', {
637
+ id,
638
+ resolvedPath,
639
+ error: err instanceof Error ? err.message : 'Unknown error',
640
+ });
641
+ // 寫入失敗時,pre-tool-use.js 會在 2 分鐘超時後自動放行
642
+ // 這是 fallback 的 fallback:即使 Hub 出問題,Claude Code 不會永久阻塞
643
+ }
644
+ }
645
+ /**
646
+ * 清理 pending 檔案
647
+ */
648
+ cleanupPending(filePath) {
649
+ try {
650
+ fs.unlinkSync(filePath);
651
+ }
652
+ catch {
653
+ // 檔案可能已被 pre-tool-use.js 清理,忽略
654
+ }
655
+ }
656
+ /**
657
+ * 將操作記錄寫入 SQLite operations + metadata 表
658
+ */
659
+ writeOperationRecord(params) {
660
+ const { id, toolName, request, approved, now, policyResult, promptType, responseType, overrideDecision, } = params;
661
+ if (!this.opts.store)
662
+ return;
663
+ try {
664
+ const decision = (overrideDecision ?? (approved ? 'approved' : 'denied'));
665
+ // 從 PolicyEngine 結果或 content pattern 推導 riskLevel
666
+ const riskLevel = policyResult
667
+ ? (0, risk_level_1.deriveRiskLevel)(policyResult.finalAction, toolName, request.toolInput ?? {})
668
+ : null;
669
+ // 從 toolInput 提取受影響的檔案路徑
670
+ const filePaths = extractFilePathsFromInput(toolName, request.toolInput ?? {});
671
+ // 寫入 operations 表(含 v4 新欄位)
672
+ this.opts.store.operations.insert({
673
+ id,
674
+ approvalId: null,
675
+ toolName,
676
+ toolParams: request.toolInput ? JSON.stringify(request.toolInput) : null,
677
+ filePaths: filePaths.length > 0 ? JSON.stringify(filePaths) : null,
678
+ riskLevel,
679
+ decision: (overrideDecision === undefined && !approved
680
+ ? null
681
+ : decision),
682
+ createdAt: now,
683
+ decidedAt: overrideDecision === undefined && !approved ? null : now,
684
+ toolUseId: request.toolUseId ?? null,
685
+ promptType: promptType ?? null,
686
+ responseType: responseType ?? null,
687
+ });
688
+ // 寫入 metadata 表(source 修正為 'hook')
689
+ this.opts.store.metadata.insert({
690
+ operationId: id,
691
+ agentType: this.opts.agentType,
692
+ sessionId: request.sessionId ?? 'unknown',
693
+ durationMs: null,
694
+ tokenUsage: null,
695
+ contextTags: null,
696
+ source: 'hook',
697
+ resultStatus: null,
698
+ toolResultSummary: null,
699
+ createdAt: now,
700
+ });
701
+ this.opts.logger.debug('IpcWatcher: 操作記錄已寫入 SQLite', {
702
+ operationId: id,
703
+ toolName,
704
+ decision,
705
+ riskLevel,
706
+ toolUseId: request.toolUseId,
707
+ });
708
+ }
709
+ catch (err) {
710
+ this.opts.logger.warn('IpcWatcher: SQLite 寫入失敗(不影響審核結果)', {
711
+ id,
712
+ toolName,
713
+ error: err instanceof Error ? err.message : 'Unknown error',
714
+ });
715
+ }
716
+ }
717
+ }
718
+ exports.IpcWatcher = IpcWatcher;
719
+ // ============================================
720
+ // 輔助函式
721
+ // ============================================
722
+ /**
723
+ * 從 toolInput 中提取受影響的檔案路徑
724
+ *
725
+ * ⚠️ 同步要求:此函式的邏輯與 packages/hooks/hooks/post-tool-use.js 的
726
+ * extractFilePaths() 完全一致。修改任一份時必須同步更新另一份。
727
+ * 兩份程式碼分屬不同 package(TS vs JS),無法直接共用。
728
+ */
729
+ function extractFilePathsFromInput(toolName, toolInput) {
730
+ const paths = [];
731
+ if (toolInput.file_path && typeof toolInput.file_path === 'string') {
732
+ paths.push(toolInput.file_path);
733
+ }
734
+ if (toolInput.notebook_path && typeof toolInput.notebook_path === 'string') {
735
+ paths.push(toolInput.notebook_path);
736
+ }
737
+ if (toolInput.path &&
738
+ typeof toolInput.path === 'string' &&
739
+ toolName !== 'Bash' &&
740
+ !paths.includes(toolInput.path)) {
741
+ paths.push(toolInput.path);
742
+ }
743
+ return paths;
744
+ }
745
+ //# sourceMappingURL=watcher.js.map