@intutic/cli 1.10.0 → 2.0.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 (357) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +868 -30
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/attenuate.d.ts +39 -0
  5. package/dist/commands/attenuate.d.ts.map +1 -0
  6. package/dist/commands/attenuate.js +98 -0
  7. package/dist/commands/attenuate.js.map +1 -0
  8. package/dist/commands/budget.d.ts +80 -0
  9. package/dist/commands/budget.d.ts.map +1 -1
  10. package/dist/commands/budget.js +119 -3
  11. package/dist/commands/budget.js.map +1 -1
  12. package/dist/commands/connect.d.ts +10 -0
  13. package/dist/commands/connect.d.ts.map +1 -1
  14. package/dist/commands/connect.js +358 -48
  15. package/dist/commands/connect.js.map +1 -1
  16. package/dist/commands/credentials.d.ts +31 -0
  17. package/dist/commands/credentials.d.ts.map +1 -0
  18. package/dist/commands/credentials.js +134 -0
  19. package/dist/commands/credentials.js.map +1 -0
  20. package/dist/commands/decision.d.ts +37 -0
  21. package/dist/commands/decision.d.ts.map +1 -0
  22. package/dist/commands/decision.js +56 -0
  23. package/dist/commands/decision.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +38 -1
  25. package/dist/commands/doctor.d.ts.map +1 -1
  26. package/dist/commands/doctor.js +228 -18
  27. package/dist/commands/doctor.js.map +1 -1
  28. package/dist/commands/enforce.d.ts +33 -0
  29. package/dist/commands/enforce.d.ts.map +1 -0
  30. package/dist/commands/enforce.js +150 -0
  31. package/dist/commands/enforce.js.map +1 -0
  32. package/dist/commands/enterprise.d.ts +29 -0
  33. package/dist/commands/enterprise.d.ts.map +1 -0
  34. package/dist/commands/enterprise.js +141 -0
  35. package/dist/commands/enterprise.js.map +1 -0
  36. package/dist/commands/env.d.ts +7 -0
  37. package/dist/commands/env.d.ts.map +1 -0
  38. package/dist/commands/env.js +55 -0
  39. package/dist/commands/env.js.map +1 -0
  40. package/dist/commands/exec.d.ts +38 -2
  41. package/dist/commands/exec.d.ts.map +1 -1
  42. package/dist/commands/exec.js +247 -10
  43. package/dist/commands/exec.js.map +1 -1
  44. package/dist/commands/findings.d.ts +84 -0
  45. package/dist/commands/findings.d.ts.map +1 -0
  46. package/dist/commands/findings.js +329 -0
  47. package/dist/commands/findings.js.map +1 -0
  48. package/dist/commands/gateway.d.ts +62 -0
  49. package/dist/commands/gateway.d.ts.map +1 -0
  50. package/dist/commands/gateway.js +298 -0
  51. package/dist/commands/gateway.js.map +1 -0
  52. package/dist/commands/guardrails.d.ts +81 -0
  53. package/dist/commands/guardrails.d.ts.map +1 -0
  54. package/dist/commands/guardrails.js +735 -0
  55. package/dist/commands/guardrails.js.map +1 -0
  56. package/dist/commands/init.d.ts +8 -0
  57. package/dist/commands/init.d.ts.map +1 -1
  58. package/dist/commands/init.js +14 -2
  59. package/dist/commands/init.js.map +1 -1
  60. package/dist/commands/install-daemon.d.ts +70 -3
  61. package/dist/commands/install-daemon.d.ts.map +1 -1
  62. package/dist/commands/install-daemon.js +482 -47
  63. package/dist/commands/install-daemon.js.map +1 -1
  64. package/dist/commands/integrity.d.ts +205 -0
  65. package/dist/commands/integrity.d.ts.map +1 -0
  66. package/dist/commands/integrity.js +620 -0
  67. package/dist/commands/integrity.js.map +1 -0
  68. package/dist/commands/judge.d.ts +31 -0
  69. package/dist/commands/judge.d.ts.map +1 -0
  70. package/dist/commands/judge.js +118 -0
  71. package/dist/commands/judge.js.map +1 -0
  72. package/dist/commands/org.d.ts +31 -0
  73. package/dist/commands/org.d.ts.map +1 -0
  74. package/dist/commands/org.js +159 -0
  75. package/dist/commands/org.js.map +1 -0
  76. package/dist/commands/policy.d.ts +138 -4
  77. package/dist/commands/policy.d.ts.map +1 -1
  78. package/dist/commands/policy.js +538 -97
  79. package/dist/commands/policy.js.map +1 -1
  80. package/dist/commands/predict-cost.d.ts +45 -0
  81. package/dist/commands/predict-cost.d.ts.map +1 -0
  82. package/dist/commands/predict-cost.js +123 -0
  83. package/dist/commands/predict-cost.js.map +1 -0
  84. package/dist/commands/rollback.d.ts +5 -0
  85. package/dist/commands/rollback.d.ts.map +1 -0
  86. package/dist/commands/rollback.js +142 -0
  87. package/dist/commands/rollback.js.map +1 -0
  88. package/dist/commands/routing.d.ts +62 -0
  89. package/dist/commands/routing.d.ts.map +1 -0
  90. package/dist/commands/routing.js +121 -0
  91. package/dist/commands/routing.js.map +1 -0
  92. package/dist/commands/setup.d.ts +62 -0
  93. package/dist/commands/setup.d.ts.map +1 -0
  94. package/dist/commands/setup.js +238 -0
  95. package/dist/commands/setup.js.map +1 -0
  96. package/dist/commands/skill.d.ts +268 -1
  97. package/dist/commands/skill.d.ts.map +1 -1
  98. package/dist/commands/skill.js +744 -39
  99. package/dist/commands/skill.js.map +1 -1
  100. package/dist/commands/sops.d.ts +95 -0
  101. package/dist/commands/sops.d.ts.map +1 -1
  102. package/dist/commands/sops.js +334 -34
  103. package/dist/commands/sops.js.map +1 -1
  104. package/dist/commands/start.d.ts.map +1 -1
  105. package/dist/commands/start.js +1 -18
  106. package/dist/commands/start.js.map +1 -1
  107. package/dist/commands/status.d.ts.map +1 -1
  108. package/dist/commands/status.js +44 -1
  109. package/dist/commands/status.js.map +1 -1
  110. package/dist/commands/team.d.ts +38 -0
  111. package/dist/commands/team.d.ts.map +1 -0
  112. package/dist/commands/team.js +140 -0
  113. package/dist/commands/team.js.map +1 -0
  114. package/dist/commands/traces.d.ts +8 -1
  115. package/dist/commands/traces.d.ts.map +1 -1
  116. package/dist/commands/traces.js +114 -15
  117. package/dist/commands/traces.js.map +1 -1
  118. package/dist/commands/whoami.d.ts.map +1 -1
  119. package/dist/commands/whoami.js +2 -1
  120. package/dist/commands/whoami.js.map +1 -1
  121. package/dist/config/keychain.d.ts +10 -2
  122. package/dist/config/keychain.d.ts.map +1 -1
  123. package/dist/config/keychain.js +83 -14
  124. package/dist/config/keychain.js.map +1 -1
  125. package/dist/config/paths.d.ts +27 -0
  126. package/dist/config/paths.d.ts.map +1 -1
  127. package/dist/config/paths.js +37 -6
  128. package/dist/config/paths.js.map +1 -1
  129. package/dist/config/store.d.ts.map +1 -1
  130. package/dist/config/store.js +19 -3
  131. package/dist/config/store.js.map +1 -1
  132. package/dist/harness/ag2.d.ts +22 -0
  133. package/dist/harness/ag2.d.ts.map +1 -0
  134. package/dist/harness/ag2.js +34 -0
  135. package/dist/harness/ag2.js.map +1 -0
  136. package/dist/harness/agentcore.d.ts +55 -0
  137. package/dist/harness/agentcore.d.ts.map +1 -0
  138. package/dist/harness/agentcore.js +119 -0
  139. package/dist/harness/agentcore.js.map +1 -0
  140. package/dist/harness/agenticOrchestrator.d.ts +52 -0
  141. package/dist/harness/agenticOrchestrator.d.ts.map +1 -0
  142. package/dist/harness/agenticOrchestrator.js +115 -0
  143. package/dist/harness/agenticOrchestrator.js.map +1 -0
  144. package/dist/harness/aiSdkHarness.d.ts +30 -0
  145. package/dist/harness/aiSdkHarness.d.ts.map +1 -0
  146. package/dist/harness/aiSdkHarness.js +51 -0
  147. package/dist/harness/aiSdkHarness.js.map +1 -0
  148. package/dist/harness/aiSdkWorkflow.d.ts +26 -0
  149. package/dist/harness/aiSdkWorkflow.d.ts.map +1 -0
  150. package/dist/harness/aiSdkWorkflow.js +39 -0
  151. package/dist/harness/aiSdkWorkflow.js.map +1 -0
  152. package/dist/harness/autogen.d.ts +24 -0
  153. package/dist/harness/autogen.d.ts.map +1 -0
  154. package/dist/harness/autogen.js +36 -0
  155. package/dist/harness/autogen.js.map +1 -0
  156. package/dist/harness/base.js.map +1 -1
  157. package/dist/harness/continue.d.ts.map +1 -1
  158. package/dist/harness/continue.js +2 -1
  159. package/dist/harness/continue.js.map +1 -1
  160. package/dist/harness/crewai.d.ts +15 -0
  161. package/dist/harness/crewai.d.ts.map +1 -0
  162. package/dist/harness/crewai.js +26 -0
  163. package/dist/harness/crewai.js.map +1 -0
  164. package/dist/harness/detector.d.ts +1 -1
  165. package/dist/harness/detector.d.ts.map +1 -1
  166. package/dist/harness/detector.js +47 -1
  167. package/dist/harness/detector.js.map +1 -1
  168. package/dist/harness/dsh.d.ts +19 -0
  169. package/dist/harness/dsh.d.ts.map +1 -0
  170. package/dist/harness/dsh.js +93 -0
  171. package/dist/harness/dsh.js.map +1 -0
  172. package/dist/harness/eve.d.ts +40 -0
  173. package/dist/harness/eve.d.ts.map +1 -0
  174. package/dist/harness/eve.js +67 -0
  175. package/dist/harness/eve.js.map +1 -0
  176. package/dist/harness/googleAdk.d.ts +18 -0
  177. package/dist/harness/googleAdk.d.ts.map +1 -0
  178. package/dist/harness/googleAdk.js +29 -0
  179. package/dist/harness/googleAdk.js.map +1 -0
  180. package/dist/harness/goose.d.ts.map +1 -1
  181. package/dist/harness/goose.js +5 -1
  182. package/dist/harness/goose.js.map +1 -1
  183. package/dist/harness/grok.d.ts +16 -0
  184. package/dist/harness/grok.d.ts.map +1 -0
  185. package/dist/harness/grok.js +78 -0
  186. package/dist/harness/grok.js.map +1 -0
  187. package/dist/harness/jsSdkGatedAdapter.d.ts +102 -0
  188. package/dist/harness/jsSdkGatedAdapter.d.ts.map +1 -0
  189. package/dist/harness/jsSdkGatedAdapter.js +139 -0
  190. package/dist/harness/jsSdkGatedAdapter.js.map +1 -0
  191. package/dist/harness/langchain.d.ts +22 -0
  192. package/dist/harness/langchain.d.ts.map +1 -0
  193. package/dist/harness/langchain.js +33 -0
  194. package/dist/harness/langchain.js.map +1 -0
  195. package/dist/harness/langgraph.d.ts +22 -0
  196. package/dist/harness/langgraph.d.ts.map +1 -0
  197. package/dist/harness/langgraph.js +80 -0
  198. package/dist/harness/langgraph.js.map +1 -0
  199. package/dist/harness/mastra.d.ts +21 -0
  200. package/dist/harness/mastra.d.ts.map +1 -0
  201. package/dist/harness/mastra.js +33 -0
  202. package/dist/harness/mastra.js.map +1 -0
  203. package/dist/harness/muse.d.ts +22 -0
  204. package/dist/harness/muse.d.ts.map +1 -0
  205. package/dist/harness/muse.js +92 -0
  206. package/dist/harness/muse.js.map +1 -0
  207. package/dist/harness/n8n.d.ts.map +1 -1
  208. package/dist/harness/n8n.js +36 -11
  209. package/dist/harness/n8n.js.map +1 -1
  210. package/dist/harness/openaiAgents.d.ts +40 -0
  211. package/dist/harness/openaiAgents.d.ts.map +1 -0
  212. package/dist/harness/openaiAgents.js +107 -0
  213. package/dist/harness/openaiAgents.js.map +1 -0
  214. package/dist/harness/opencode.d.ts +24 -0
  215. package/dist/harness/opencode.d.ts.map +1 -0
  216. package/dist/harness/opencode.js +97 -0
  217. package/dist/harness/opencode.js.map +1 -0
  218. package/dist/harness/pydanticAi.d.ts +19 -0
  219. package/dist/harness/pydanticAi.d.ts.map +1 -0
  220. package/dist/harness/pydanticAi.js +30 -0
  221. package/dist/harness/pydanticAi.js.map +1 -0
  222. package/dist/harness/sdkGatedAdapter.d.ts +55 -0
  223. package/dist/harness/sdkGatedAdapter.d.ts.map +1 -0
  224. package/dist/harness/sdkGatedAdapter.js +85 -0
  225. package/dist/harness/sdkGatedAdapter.js.map +1 -0
  226. package/dist/harness/smolagents.d.ts +22 -0
  227. package/dist/harness/smolagents.d.ts.map +1 -0
  228. package/dist/harness/smolagents.js +33 -0
  229. package/dist/harness/smolagents.js.map +1 -0
  230. package/dist/harness/strands.d.ts +22 -0
  231. package/dist/harness/strands.d.ts.map +1 -0
  232. package/dist/harness/strands.js +35 -0
  233. package/dist/harness/strands.js.map +1 -0
  234. package/dist/harness/trueforge.d.ts +25 -0
  235. package/dist/harness/trueforge.d.ts.map +1 -0
  236. package/dist/harness/trueforge.js +40 -0
  237. package/dist/harness/trueforge.js.map +1 -0
  238. package/dist/harness/types.d.ts.map +1 -1
  239. package/dist/harness/types.js +70 -0
  240. package/dist/harness/types.js.map +1 -1
  241. package/dist/harness/vercelAiSdk.d.ts +31 -0
  242. package/dist/harness/vercelAiSdk.d.ts.map +1 -0
  243. package/dist/harness/vercelAiSdk.js +44 -0
  244. package/dist/harness/vercelAiSdk.js.map +1 -0
  245. package/dist/harness/xirp.d.ts +43 -0
  246. package/dist/harness/xirp.d.ts.map +1 -0
  247. package/dist/harness/xirp.js +95 -0
  248. package/dist/harness/xirp.js.map +1 -0
  249. package/dist/lib/api.d.ts +68 -0
  250. package/dist/lib/api.d.ts.map +1 -1
  251. package/dist/lib/api.js +108 -67
  252. package/dist/lib/api.js.map +1 -1
  253. package/dist/lib/authMessages.d.ts +19 -0
  254. package/dist/lib/authMessages.d.ts.map +1 -0
  255. package/dist/lib/authMessages.js +20 -0
  256. package/dist/lib/authMessages.js.map +1 -0
  257. package/dist/lib/binaryChecksum.d.ts +12 -0
  258. package/dist/lib/binaryChecksum.d.ts.map +1 -0
  259. package/dist/lib/binaryChecksum.js +45 -0
  260. package/dist/lib/binaryChecksum.js.map +1 -0
  261. package/dist/lib/binaryOnPath.d.ts +24 -0
  262. package/dist/lib/binaryOnPath.d.ts.map +1 -0
  263. package/dist/lib/binaryOnPath.js +31 -0
  264. package/dist/lib/binaryOnPath.js.map +1 -0
  265. package/dist/lib/caTrust.d.ts +69 -0
  266. package/dist/lib/caTrust.d.ts.map +1 -0
  267. package/dist/lib/caTrust.js +169 -0
  268. package/dist/lib/caTrust.js.map +1 -0
  269. package/dist/lib/ciscoScanner.d.ts +178 -0
  270. package/dist/lib/ciscoScanner.d.ts.map +1 -0
  271. package/dist/lib/ciscoScanner.js +292 -0
  272. package/dist/lib/ciscoScanner.js.map +1 -0
  273. package/dist/lib/decisionsLogRefresh.d.ts +28 -0
  274. package/dist/lib/decisionsLogRefresh.d.ts.map +1 -0
  275. package/dist/lib/decisionsLogRefresh.js +54 -0
  276. package/dist/lib/decisionsLogRefresh.js.map +1 -0
  277. package/dist/lib/deviceReport.d.ts +26 -0
  278. package/dist/lib/deviceReport.d.ts.map +1 -0
  279. package/dist/lib/deviceReport.js +55 -0
  280. package/dist/lib/deviceReport.js.map +1 -0
  281. package/dist/lib/elevation.d.ts +58 -0
  282. package/dist/lib/elevation.d.ts.map +1 -0
  283. package/dist/lib/elevation.js +83 -0
  284. package/dist/lib/elevation.js.map +1 -0
  285. package/dist/lib/enforcementState.d.ts +69 -0
  286. package/dist/lib/enforcementState.d.ts.map +1 -0
  287. package/dist/lib/enforcementState.js +113 -0
  288. package/dist/lib/enforcementState.js.map +1 -0
  289. package/dist/lib/ensureValkey.d.ts.map +1 -1
  290. package/dist/lib/ensureValkey.js +2 -2
  291. package/dist/lib/ensureValkey.js.map +1 -1
  292. package/dist/lib/envInjector.d.ts.map +1 -1
  293. package/dist/lib/envInjector.js +22 -4
  294. package/dist/lib/envInjector.js.map +1 -1
  295. package/dist/lib/gitHooks.d.ts +7 -2
  296. package/dist/lib/gitHooks.d.ts.map +1 -1
  297. package/dist/lib/gitHooks.js +180 -13
  298. package/dist/lib/gitHooks.js.map +1 -1
  299. package/dist/lib/gracefulShutdown.d.ts +37 -0
  300. package/dist/lib/gracefulShutdown.d.ts.map +1 -0
  301. package/dist/lib/gracefulShutdown.js +57 -0
  302. package/dist/lib/gracefulShutdown.js.map +1 -0
  303. package/dist/lib/graphIdentity.d.ts +80 -0
  304. package/dist/lib/graphIdentity.d.ts.map +1 -0
  305. package/dist/lib/graphIdentity.js +120 -0
  306. package/dist/lib/graphIdentity.js.map +1 -0
  307. package/dist/lib/localTraces.d.ts +61 -0
  308. package/dist/lib/localTraces.d.ts.map +1 -0
  309. package/dist/lib/localTraces.js +173 -0
  310. package/dist/lib/localTraces.js.map +1 -0
  311. package/dist/lib/mdmManifest.d.ts +73 -0
  312. package/dist/lib/mdmManifest.d.ts.map +1 -0
  313. package/dist/lib/mdmManifest.js +190 -0
  314. package/dist/lib/mdmManifest.js.map +1 -0
  315. package/dist/lib/onboarding.d.ts.map +1 -1
  316. package/dist/lib/onboarding.js +60 -1
  317. package/dist/lib/onboarding.js.map +1 -1
  318. package/dist/lib/otelPropagation.d.ts +26 -0
  319. package/dist/lib/otelPropagation.d.ts.map +1 -0
  320. package/dist/lib/otelPropagation.js +40 -0
  321. package/dist/lib/otelPropagation.js.map +1 -0
  322. package/dist/lib/policySnapshot.d.ts +92 -0
  323. package/dist/lib/policySnapshot.d.ts.map +1 -0
  324. package/dist/lib/policySnapshot.js +173 -0
  325. package/dist/lib/policySnapshot.js.map +1 -0
  326. package/dist/lib/process.d.ts.map +1 -1
  327. package/dist/lib/process.js +60 -0
  328. package/dist/lib/process.js.map +1 -1
  329. package/dist/lib/providerProbe.d.ts +28 -0
  330. package/dist/lib/providerProbe.d.ts.map +1 -0
  331. package/dist/lib/providerProbe.js +57 -0
  332. package/dist/lib/providerProbe.js.map +1 -0
  333. package/dist/lib/proxyBinary.d.ts +19 -0
  334. package/dist/lib/proxyBinary.d.ts.map +1 -0
  335. package/dist/lib/proxyBinary.js +30 -0
  336. package/dist/lib/proxyBinary.js.map +1 -0
  337. package/dist/lib/sandbox/firecracker.d.ts +89 -0
  338. package/dist/lib/sandbox/firecracker.d.ts.map +1 -0
  339. package/dist/lib/sandbox/firecracker.js +135 -0
  340. package/dist/lib/sandbox/firecracker.js.map +1 -0
  341. package/dist/lib/sandbox/index.d.ts +34 -0
  342. package/dist/lib/sandbox/index.d.ts.map +1 -0
  343. package/dist/lib/sandbox/index.js +54 -0
  344. package/dist/lib/sandbox/index.js.map +1 -0
  345. package/dist/lib/sandbox/oci.d.ts +38 -0
  346. package/dist/lib/sandbox/oci.d.ts.map +1 -0
  347. package/dist/lib/sandbox/oci.js +108 -0
  348. package/dist/lib/sandbox/oci.js.map +1 -0
  349. package/dist/lib/sandbox/types.d.ts +48 -0
  350. package/dist/lib/sandbox/types.d.ts.map +1 -0
  351. package/dist/lib/sandbox/types.js +12 -0
  352. package/dist/lib/sandbox/types.js.map +1 -0
  353. package/dist/lib/sopFrontMatter.d.ts +50 -0
  354. package/dist/lib/sopFrontMatter.d.ts.map +1 -0
  355. package/dist/lib/sopFrontMatter.js +129 -0
  356. package/dist/lib/sopFrontMatter.js.map +1 -0
  357. package/package.json +11 -6
@@ -16,17 +16,27 @@ const { version: cliPkgVersion } = createRequire(import.meta.url)('../../package
16
16
  import * as node_fs from 'node:fs/promises';
17
17
  import { log } from '../lib/logger.js';
18
18
  import { ensureValkey, valkeyRemediation, isValkeyRunning } from '../lib/ensureValkey.js';
19
+ import { createShutdownHandler, terminateChild } from '../lib/gracefulShutdown.js';
19
20
  import { loadCredentials, loadConfig, saveConfig, loadIntegrity, saveIntegrity, } from '../config/store.js';
20
21
  import { resolveControlPlaneUrl, getIntuticDir } from '../config/paths.js';
21
22
  import { createApiClient } from '../lib/api.js';
22
23
  import { getActiveAgentProcesses } from '../lib/process.js';
23
24
  import { getAdapter } from '../harness/detector.js';
24
25
  import { printOnboardingGuide } from '../lib/onboarding.js';
26
+ import { writeEnforcementState } from '../lib/enforcementState.js';
27
+ import { reportDeviceState } from '../lib/deviceReport.js';
28
+ import { parseChecksums, verifyChecksum } from '../lib/binaryChecksum.js';
25
29
  import { newIso } from '@intutic/id';
26
30
  import pc from 'picocolors';
27
- import { SyncWsClient, startWatcher, updatePreToolUseHooks, injectMcpServer, guardSettingsFile, writeRuntimeEnv, runComplianceProbes, drainHookEvents, syncOfflineTraces, TrajectoryMonitor, } from '@intutic/sync-daemon';
31
+ import { SyncWsClient, startWatcher, updatePreToolUseHooks, injectMcpServer, guardSettingsFile, warnIfDshCoverageGap, writeRuntimeEnv, refreshPolicySnapshot, refreshGateCaches, localHoldTokensFor, runComplianceProbes, drainHookEvents, drainReviewRequests, REVIEW_REQUESTS_LOG, syncOfflineTraces, TrajectoryMonitor, collectAgentReport, reportAgent, fetchLocalProxyInstanceId, startHarnessSession, endAllOpenSessions, } from '@intutic/sync-daemon';
28
32
  import { watch } from 'chokidar';
29
- import Redis from 'ioredis';
33
+ // Named, not default: under `module: Node16` TypeScript resolves ioredis's
34
+ // default export to the module namespace rather than the class, so `new
35
+ // Redis(...)` is not constructable and `Redis` is not a type. That is what the
36
+ // `new (Redis as any)(...)` below was working around, at the cost of also
37
+ // giving up the client's type. `packages/mcp-proxy` already imports it this
38
+ // way; both forms are the same class at run time.
39
+ import { Redis } from 'ioredis';
30
40
  import * as net from 'node:net';
31
41
  import { spawn, execSync } from 'node:child_process';
32
42
  import { createWriteStream } from 'node:fs';
@@ -49,26 +59,54 @@ function isPortInUse(port) {
49
59
  server.listen(port, '127.0.0.1');
50
60
  });
51
61
  }
52
- async function downloadProxyBinary(destPath) {
53
- const platform = process.platform;
54
- const arch = process.arch;
55
- let assetName = '';
62
+ /**
63
+ * Is this a path the tamper guard should inspect?
64
+ *
65
+ * Deliberately broad: `guardSettingsFile` already branches per harness, so the only
66
+ * job here is to stop filtering out paths it knows how to handle.
67
+ */
68
+ function isGovernedConfigPath(changedPath, filename) {
69
+ if (filename === 'settings.json' || filename === 'settings.local.json')
70
+ return true;
71
+ if (filename === 'hooks.json')
72
+ return true;
73
+ // Goose's immutable governance plugin.
74
+ if (changedPath.includes('intutic-governance'))
75
+ return true;
76
+ return false;
77
+ }
78
+ /**
79
+ * Asset names MUST match publish.yml's build-rust-proxy matrix
80
+ * artifact_name — mirrors packages/proxy/bin/proxy.js's `resolveAssetName`
81
+ * exactly (same five names, same live-release verification: `gh release
82
+ * view` on v1.6.0 through the current release confirmed every one of
83
+ * these). `platform`/`arch` are parameters, not read from `process`
84
+ * directly, so connect.test.ts can exercise every combination without
85
+ * stubbing global process state.
86
+ */
87
+ export function resolveProxyAssetName(platform = process.platform, arch = process.arch) {
56
88
  if (platform === 'darwin') {
57
89
  if (arch === 'arm64')
58
- assetName = 'intutic-proxy-darwin-arm64';
59
- else if (arch === 'x64')
60
- assetName = 'intutic-proxy-darwin-x64';
90
+ return 'intutic-proxy-darwin-arm64';
91
+ if (arch === 'x64')
92
+ return 'intutic-proxy-darwin-x64';
61
93
  }
62
94
  else if (platform === 'linux') {
63
95
  if (arch === 'x64')
64
- assetName = 'intutic-proxy-linux-x64';
65
- else if (arch === 'arm64')
66
- assetName = 'intutic-proxy-linux-arm64';
96
+ return 'intutic-proxy-linux-x64';
97
+ if (arch === 'arm64')
98
+ return 'intutic-proxy-linux-arm64';
67
99
  }
68
100
  else if (platform === 'win32') {
69
101
  if (arch === 'x64')
70
- assetName = 'intutic-proxy-win32-x64.exe';
102
+ return 'intutic-proxy-win32-x64.exe';
71
103
  }
104
+ return null;
105
+ }
106
+ async function downloadProxyBinary(destPath) {
107
+ const platform = process.platform;
108
+ const arch = process.arch;
109
+ const assetName = resolveProxyAssetName(platform, arch);
72
110
  if (!assetName) {
73
111
  throw new Error(`Unsupported platform/architecture: ${platform}-${arch}`);
74
112
  }
@@ -89,6 +127,16 @@ async function downloadProxyBinary(destPath) {
89
127
  }
90
128
  const arrayBuffer = await response.arrayBuffer();
91
129
  const buffer = Buffer.from(arrayBuffer);
130
+ log.info('Verifying checksum...');
131
+ const checksumsUrl = `https://github.com/intutic/intutic/releases/download/v${cliPkgVersion}/checksums.json`;
132
+ const checksumsResponse = await fetch(checksumsUrl);
133
+ if (!checksumsResponse.ok) {
134
+ throw new Error(`Failed to download checksums.json (HTTP ${checksumsResponse.status} ${checksumsResponse.statusText}).\n` +
135
+ ` URL: ${checksumsUrl}\n` +
136
+ `Refusing to install an unverified binary.`);
137
+ }
138
+ const checksums = parseChecksums(await checksumsResponse.text());
139
+ verifyChecksum(buffer, checksums, assetName);
92
140
  const destDir = node_path.dirname(destPath);
93
141
  await node_fs.mkdir(destDir, { recursive: true });
94
142
  await node_fs.writeFile(destPath, buffer);
@@ -136,11 +184,23 @@ export async function runConnect(opts) {
136
184
  }
137
185
  let config = loadConfig();
138
186
  if (!config && opts.workspaceId && opts.apiKey) {
187
+ // `IntuticConfig`, not `as any`. This literal used to carry a
188
+ // `workspaceId` too, which the type does not declare and nothing reads —
189
+ // `init.ts`, the only other writer of ~/.intutic/config.json, has never
190
+ // written one, and every consumer takes the workspace id from the
191
+ // credentials instead. The cast was there to admit it, so it went with the
192
+ // cast. The workspace id from --workspace-id still reaches everything that
193
+ // needs it, via `creds` above.
139
194
  config = {
140
- workspaceId: opts.workspaceId,
141
195
  harnesses: [],
142
196
  configVersion: 0,
143
197
  devMode: opts.dev || false,
198
+ // workspaceRoot must be set here: step 2.5 path.join()s it, and
199
+ // path.join(undefined) throws a TypeError that nothing catches. That
200
+ // crash is what made `intutic daemon install` produce a crash-looping
201
+ // service on any machine without ~/.intutic/config.json (the launchd
202
+ // plist runs `connect --workspace-id --api-key` with KeepAlive=true).
203
+ workspaceRoot: process.cwd(),
144
204
  };
145
205
  }
146
206
  if (!config) {
@@ -195,16 +255,21 @@ export async function runConnect(opts) {
195
255
  // 2. AbortController for clean shutdown
196
256
  let proxyProc = null;
197
257
  const ac = new AbortController();
198
- const shutdown = () => {
199
- log.info('Shutting down sync daemon...');
200
- if (proxyProc) {
201
- log.info('Stopping managed proxy gateway...');
202
- proxyProc.kill('SIGTERM');
203
- }
204
- trajectoryMonitor?.stop();
205
- trajectorySubscriber?.disconnect();
206
- ac.abort();
207
- };
258
+ // TD-484: this handler used to abort the loop and hope. The process stayed
259
+ // alive for hours. `createShutdownHandler` adds the exit deadline and the
260
+ // second-signal exit; `terminateChild` escalates to SIGKILL.
261
+ const shutdown = createShutdownHandler({
262
+ onShutdown: () => {
263
+ log.info('Shutting down sync daemon...');
264
+ ac.abort();
265
+ if (proxyProc) {
266
+ log.info('Stopping managed proxy gateway...');
267
+ terminateChild(proxyProc);
268
+ }
269
+ trajectoryMonitor?.stop();
270
+ trajectorySubscriber?.disconnect();
271
+ },
272
+ });
208
273
  process.on('SIGINT', shutdown);
209
274
  process.on('SIGTERM', shutdown);
210
275
  // 2.4. Pre-flight Valkey Validation
@@ -229,8 +294,16 @@ export async function runConnect(opts) {
229
294
  }
230
295
  // 2.5. Manage LiteLLM-Rust Proxy Gateway Process
231
296
  const proxyPort = parseInt(process.env.PORT || '4000', 10);
297
+ // The daemon-side probes (`fetchEgressStatus`, `fetchGuardProbes`,
298
+ // `fetchLocalProxyInstanceId`) read the proxy at `INTUTIC_PROXY_URL`,
299
+ // defaulting to port 4000; connect knows the port it will spawn on, so an
300
+ // operator running on another `PORT` still gets probed at the right one.
301
+ process.env.INTUTIC_PROXY_URL ??= `http://127.0.0.1:${proxyPort}`;
232
302
  let exeCmd = 'cargo';
233
303
  let exeArgs = ['run', '--manifest-path', node_path.join(safeConfig.workspaceRoot, 'packages', 'proxy', 'Cargo.toml')];
304
+ // Populated only on the branch that actually spawns the proxy; the DR
305
+ // re-spawn below reuses it and is guarded by `proxyProc`, which is null
306
+ // whenever this is still empty.
234
307
  let proxyEnv = {};
235
308
  try {
236
309
  const inUse = await isPortInUse(proxyPort);
@@ -278,7 +351,7 @@ export async function runConnect(opts) {
278
351
  exeArgs = [];
279
352
  }
280
353
  catch (downloadErr) {
281
- log.warn(`Auto-download failed: ${downloadErr.message}`);
354
+ log.warn(`Auto-download failed: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`);
282
355
  log.dim('Falling back to cargo run...');
283
356
  }
284
357
  }
@@ -294,6 +367,13 @@ export async function runConnect(opts) {
294
367
  INTUTIC_WORKSPACE_ID: safeCreds.workspaceId,
295
368
  INTUTIC_API_KEY: safeCreds.apiKey,
296
369
  CONFIG_PATH: node_path.join(safeConfig.workspaceRoot, 'config.yaml'),
370
+ // Workspace policy: admins can disallow local Obsidian/Logseq/Foam
371
+ // vaults from feeding /fix. Applied at spawn; a policy change takes
372
+ // effect on the next connect. Vault content never leaves the machine
373
+ // under either setting — this only governs whether the search runs.
374
+ ...(safeConfig.settings?.allowLocalMemoryVaults === false
375
+ ? { INTUTIC_LOCAL_VAULTS: 'off' }
376
+ : {}),
297
377
  };
298
378
  proxyProc = spawn(exeCmd, exeArgs, {
299
379
  cwd: safeConfig.workspaceRoot,
@@ -341,12 +421,12 @@ export async function runConnect(opts) {
341
421
  log.success('Successfully trusted SSL CA certificate.');
342
422
  }
343
423
  catch (err) {
344
- log.warn(`Failed to auto-trust SSL certificate on Windows: ${err.message}`);
424
+ log.warn(`Failed to auto-trust SSL certificate on Windows: ${err instanceof Error ? err.message : String(err)}`);
345
425
  }
346
426
  }
347
427
  }
348
428
  catch (err) {
349
- log.warn(`Could not verify or auto-trust CA certificate: ${err.message}`);
429
+ log.warn(`Could not verify or auto-trust CA certificate: ${err instanceof Error ? err.message : String(err)}`);
350
430
  }
351
431
  }
352
432
  }
@@ -356,10 +436,29 @@ export async function runConnect(opts) {
356
436
  }
357
437
  let localConfigVersion = safeConfig.configVersion;
358
438
  let lastCachedConfig = null;
439
+ // The three gate-side caches every hook reads — the policy snapshot, the
440
+ // approved review-hold bypasses and the central egress policy. Refreshed on
441
+ // EVERY applySyncConfig (each poll and each pushed config_update), not only
442
+ // when the config version moved: policy changes without the config version
443
+ // changing, and a guardrail promoted, a hold approved or an egress mode
444
+ // flipped mid-session used to reach a connected machine only on restart —
445
+ // this function was called once, at startup (TD-488). Parity with the
446
+ // daemon's own loop (`syncLoop.ts`'s Steps 0b–0d). None of these throw.
447
+ //
448
+ // The snapshot also carries this workspace's `review_before:` tokens as hold
449
+ // rules (synced SOPs, settings and local `.intutic/sops`), so every gate —
450
+ // not only the Claude Code hook — holds on them (TD-474 item 4).
451
+ async function refreshGateCachesForConnect() {
452
+ const localHoldTokens = await localHoldTokensFor(safeConfig.workspaceRoot, lastCachedConfig?.sops ?? [], lastCachedConfig?.settings, safeConfig.harnesses);
453
+ const landed = await refreshGateCaches({ controlPlaneUrl, apiKey: safeCreds.apiKey, workspaceId: safeCreds.workspaceId, localHoldTokens });
454
+ if (!landed.snapshot)
455
+ log.dim('Policy snapshot refresh failed (will retry next sync); built-in protections are unaffected.');
456
+ }
359
457
  // 3. Define configuration applier function
360
458
  async function applySyncConfig(syncConfig, force = false) {
361
459
  let sopsWritten = 0;
362
460
  lastCachedConfig = syncConfig;
461
+ await refreshGateCachesForConnect();
363
462
  if (syncConfig.configVersion > localConfigVersion || force) {
364
463
  log.info(`Applying configuration v${syncConfig.configVersion}...`);
365
464
  // Load and compile local SOP entries
@@ -399,7 +498,19 @@ export async function runConnect(opts) {
399
498
  }
400
499
  }
401
500
  catch (err) {
402
- // ignore directory read errors
501
+ // Only a missing `.intutic/sops` is unremarkable — most workspaces have
502
+ // none, and `readdir` ENOENTs on the first line of the block.
503
+ //
504
+ // Everything else was being swallowed with it, and the block goes on to
505
+ // read every SOP file: an unreadable file or a permissions error dropped
506
+ // that SOP from `localSopEntries`, and the harness configs written a few
507
+ // lines below were then generated *without* it, silently. Governance
508
+ // content going missing is the failure this whole function exists to
509
+ // prevent, so it does not get to be quiet.
510
+ const code = err instanceof Error ? err.code : undefined;
511
+ if (code !== 'ENOENT') {
512
+ log.warn(`Failed to load local SOPs from .intutic/sops — harness configs will be written without them: ${err instanceof Error ? err.message : String(err)}`);
513
+ }
403
514
  }
404
515
  const combinedSops = [...syncConfig.sops, ...localSopEntries];
405
516
  // a. Write configs for all active harnesses
@@ -432,7 +543,10 @@ export async function runConnect(opts) {
432
543
  log.warn(`Failed to inject MCP server configs: ${err instanceof Error ? err.message : String(err)}`);
433
544
  }
434
545
  localConfigVersion = syncConfig.configVersion;
435
- saveConfig({ ...safeConfig, configVersion: localConfigVersion });
546
+ // Mirror workspace settings locally so spawn-time policies (like
547
+ // allowLocalMemoryVaults) apply on the next connect without a fetch.
548
+ safeConfig.settings = syncConfig.settings;
549
+ saveConfig({ ...safeConfig, configVersion: localConfigVersion, settings: syncConfig.settings });
436
550
  }
437
551
  // c. Compute file hashes + update integrity store
438
552
  const fileHashes = [];
@@ -497,14 +611,20 @@ export async function runConnect(opts) {
497
611
  execSync('docker info', { stdio: 'ignore' });
498
612
  dockerActive = true;
499
613
  }
500
- catch { }
614
+ catch {
615
+ // Deliberate swallow: `docker info` exiting non-zero IS the answer we
616
+ // asked for — Docker is not installed or its daemon is down. That is a
617
+ // normal state on machines running Valkey natively, not an error to
618
+ // report. dockerActive stays false and we fall through to the native
619
+ // binary / downloaded-static healing paths below.
620
+ }
501
621
  if (dockerActive) {
502
622
  try {
503
623
  execSync('docker start intutic-valkey', { stdio: 'ignore' });
504
624
  log.info('[DR] Successfully sent container start command to intutic-valkey.');
505
625
  }
506
626
  catch (err) {
507
- log.warn(`[DR] Docker container start failed: ${err.message}`);
627
+ log.warn(`[DR] Docker container start failed: ${err instanceof Error ? err.message : String(err)}`);
508
628
  }
509
629
  }
510
630
  else {
@@ -520,7 +640,13 @@ export async function runConnect(opts) {
520
640
  nativeCmd = 'redis-server';
521
641
  hasNativeBinary = true;
522
642
  }
523
- catch { }
643
+ catch {
644
+ // Deliberate swallow: this is the last of three probes (docker,
645
+ // valkey-server, redis-server). `which` failing just means the
646
+ // binary is not on PATH, which is the question we asked.
647
+ // hasNativeBinary stays false and healing falls through to the
648
+ // downloaded static binary under ~/.intutic/bin.
649
+ }
524
650
  }
525
651
  if (hasNativeBinary) {
526
652
  try {
@@ -529,7 +655,7 @@ export async function runConnect(opts) {
529
655
  log.info(`[DR] Successfully spawned native ${nativeCmd} in background.`);
530
656
  }
531
657
  catch (err) {
532
- log.warn(`[DR] Native daemon spawn failed: ${err.message}`);
658
+ log.warn(`[DR] Native daemon spawn failed: ${err instanceof Error ? err.message : String(err)}`);
533
659
  }
534
660
  }
535
661
  else {
@@ -542,7 +668,7 @@ export async function runConnect(opts) {
542
668
  log.info('[DR] Successfully spawned downloaded static Valkey server in background.');
543
669
  }
544
670
  catch (err) {
545
- log.warn(`[DR] Static binary spawn failed: ${err.message}`);
671
+ log.warn(`[DR] Static binary spawn failed: ${err instanceof Error ? err.message : String(err)}`);
546
672
  }
547
673
  }
548
674
  }
@@ -556,7 +682,12 @@ export async function runConnect(opts) {
556
682
  const proxyActive = await isPortInUse(proxyPort);
557
683
  if (!proxyActive) {
558
684
  proxyStatus = 'unhealthy';
559
- if (proxyProc) {
685
+ if (ac.signal.aborted) {
686
+ // Shutting down: the proxy is gone because we stopped it. Re-spawning
687
+ // it here would leave an orphan holding the port (TD-484).
688
+ proxyStatus = 'stopped';
689
+ }
690
+ else if (proxyProc) {
560
691
  log.warn('[DR] Managed proxy gateway process has terminated. Auto-healing re-spawn...');
561
692
  const logDir = node_path.join(safeConfig.workspaceRoot, '.intutic', 'logs');
562
693
  const logStream = createWriteStream(node_path.join(logDir, 'proxy-gateway.log'), { flags: 'a' });
@@ -620,6 +751,30 @@ export async function runConnect(opts) {
620
751
  catch (err) {
621
752
  log.warn(`Failed to send daemon heartbeat: ${err instanceof Error ? err.message : String(err)}`);
622
753
  }
754
+ // g. Enforcement device visibility (post-strip gap #2, LLD #63
755
+ // hardening) — the "interval" half of "continuous compliance": this
756
+ // loop already runs unprivileged, as the real user, on a timer, and
757
+ // already just computed sslTrustStatus above for its own DR-healing
758
+ // purposes. Recording it as a user-scope CA-trust leg means every
759
+ // `intutic connect` session reports SOMETHING even on a machine that
760
+ // never ran `enforce apply` or `enterprise install` — best-effort,
761
+ // never fails the sync loop over it.
762
+ try {
763
+ await writeEnforcementState({
764
+ caTrust: {
765
+ installed: sslTrustStatus === 'trusted',
766
+ scope: 'user',
767
+ reportedAt: newIso(),
768
+ },
769
+ }, cliPkgVersion);
770
+ const deviceReport = await reportDeviceState({ dev: devMode });
771
+ if (!deviceReport.reported) {
772
+ log.dim(`[sync] Device report not sent: ${deviceReport.reason}`);
773
+ }
774
+ }
775
+ catch (err) {
776
+ log.warn(`Failed to record/report device enforcement state: ${err instanceof Error ? err.message : String(err)}`);
777
+ }
623
778
  const driftLabel = driftCount > 0 ? pc.yellow(` — ${driftCount} drift(s) detected`) : '';
624
779
  log.dim(`[sync] Config v${localConfigVersion} — ${sopsWritten} SOPs synced${driftLabel}`);
625
780
  // Refresh runtime env with resolved settings
@@ -631,6 +786,8 @@ export async function runConnect(opts) {
631
786
  mcpProxyFailBehavior: syncConfig.settings?.mcpProxyFailBehavior,
632
787
  mcpProxyMode: syncConfig.settings?.mcpProxyMode,
633
788
  bypassEnforcementTier: syncConfig.settings?.bypassEnforcementTier,
789
+ // Shared by the MCP proxies for their session window (Wave 5.3, TD-437) — only when it is running.
790
+ valkeyUrl: valkeyResult.running ? valkeyUrl : undefined,
634
791
  });
635
792
  }
636
793
  catch (err) {
@@ -660,8 +817,15 @@ export async function runConnect(opts) {
660
817
  log.warn(`Compliance probes failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
661
818
  }
662
819
  };
663
- // Helper to drain hook events (WS-A)
664
- const runDrain = async () => {
820
+ // Drains BOTH logs, matching `services/sync-daemon/src/syncLoop.ts`.
821
+ //
822
+ // This drained hook events only. `intutic connect` runs the daemon in-process,
823
+ // so under the CLI runtime a `review_before` hold still blocked the tool
824
+ // locally and then sat in `.intutic/events/review-requests.jsonl` forever: the
825
+ // developer was stopped, and the decision that was supposed to become a
826
+ // learned rule never reached the control plane. The block working is what made
827
+ // it invisible.
828
+ const drainOnce = async () => {
665
829
  try {
666
830
  const drained = await drainHookEvents(safeConfig.workspaceRoot, controlPlaneUrl, safeCreds.apiKey);
667
831
  if (drained > 0) {
@@ -671,6 +835,52 @@ export async function runConnect(opts) {
671
835
  catch (err) {
672
836
  log.warn(`[sync-daemon] Hook event drain error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
673
837
  }
838
+ // Separate try: the two go to different endpoints, and one being down says
839
+ // nothing about the other.
840
+ try {
841
+ const held = await drainReviewRequests(safeConfig.workspaceRoot, controlPlaneUrl, safeCreds.apiKey);
842
+ if (held > 0) {
843
+ log.info(`[sync-daemon] Drained ${held} review hold(s) to control plane`);
844
+ }
845
+ }
846
+ catch (err) {
847
+ log.warn(`[sync-daemon] Review hold drain error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
848
+ }
849
+ };
850
+ /**
851
+ * Serialises drains, and coalesces the ones that arrive during one.
852
+ *
853
+ * `runDrain` is bound to `change` and `add` on **two** watched files and to a
854
+ * 60-second timer, and each drain reads a log and then truncates it. Those are
855
+ * separate awaits, so two overlapping runs both read the same lines before
856
+ * either truncates and the same governance events are POSTed twice — a hook
857
+ * incident counted once by the agent and twice by the control plane. A hook
858
+ * writing to both `.intutic/events/` files in the same tick is enough on its
859
+ * own, and that is the ordinary case: a held tool call writes the review
860
+ * request and the event log together.
861
+ *
862
+ * The latch matters as much as the flag. Dropping an overlapping request
863
+ * outright would lose the write that triggered it whenever it landed after
864
+ * this pass had already read the file, so the drain would wait for the next
865
+ * event or the 60s poll — the exact delay the watcher exists to remove.
866
+ */
867
+ let draining = false;
868
+ let drainRequestedAgain = false;
869
+ const runDrain = async () => {
870
+ if (draining) {
871
+ drainRequestedAgain = true;
872
+ return;
873
+ }
874
+ draining = true;
875
+ try {
876
+ do {
877
+ drainRequestedAgain = false;
878
+ await drainOnce();
879
+ } while (drainRequestedAgain);
880
+ }
881
+ finally {
882
+ draining = false;
883
+ }
674
884
  };
675
885
  // Step 0: Write runtime env file (hook scripts source this for credentials)
676
886
  try {
@@ -678,11 +888,30 @@ export async function runConnect(opts) {
678
888
  controlPlaneUrl,
679
889
  apiKey: safeCreds.apiKey,
680
890
  workspaceId: safeCreds.workspaceId,
891
+ // Shared by the MCP proxies for their session window (Wave 5.3, TD-437) — only when it is running.
892
+ valkeyUrl: valkeyResult.running ? valkeyUrl : undefined,
681
893
  });
682
894
  }
683
895
  catch (err) {
684
896
  log.warn(`Could not write runtime env file (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
685
897
  }
898
+ // Seed the policy snapshot before the first sync cycle rather than after it.
899
+ //
900
+ // Without this, every fresh install runs its first poll interval with no
901
+ // snapshot — the gates enforce the static floor and nothing else. Seeding here
902
+ // narrows that window to "the fetch failed, and we said so" instead of "always,
903
+ // briefly". `refreshPolicySnapshot` never throws. Every later refresh rides
904
+ // `applySyncConfig` (see `refreshGateCaches`).
905
+ const seeded = await refreshPolicySnapshot({
906
+ controlPlaneUrl,
907
+ apiKey: safeCreds.apiKey,
908
+ workspaceId: safeCreds.workspaceId,
909
+ });
910
+ if (!seeded) {
911
+ log.warn('Could not seed the policy snapshot — governance rules from the control plane ' +
912
+ 'will not apply until the next successful sync. Built-in protections are unaffected.');
913
+ }
914
+ await refreshGateCachesForConnect();
686
915
  // Sync offline traces back to PostgreSQL on startup
687
916
  try {
688
917
  await syncOfflineTraces(controlPlaneUrl, safeCreds.apiKey);
@@ -743,25 +972,52 @@ export async function runConnect(opts) {
743
972
  }).catch(() => { });
744
973
  }
745
974
  catch (err) {
746
- // ignore
975
+ // Nothing in the block above is expected to throw — the `readFile` has its
976
+ // own catch and `scanLocalSops` resolves to [] on failure — so this is the
977
+ // outer guard for the one thing that can: `node_path.join` on an undefined
978
+ // workspaceRoot, the TypeError that used to crash-loop the launchd service.
979
+ // Reported rather than swallowed, because reaching it means the startup
980
+ // context report never happened and the dashboard is missing this daemon.
981
+ log.warn(`Failed to send initial context report: ${err instanceof Error ? err.message : String(err)}`);
747
982
  }
748
- // FSEvents-driven hook event drain
983
+ // FSEvents-driven drain, watching BOTH logs.
984
+ //
985
+ // Watching only hook events meant a review hold waited for the 60s safety
986
+ // timer at best — and, before `runDrain` learned to drain holds at all, never.
749
987
  const hookEventsLog = node_path.join(safeConfig.workspaceRoot, '.intutic', 'events', 'hook-events.jsonl');
988
+ const reviewHoldsLog = node_path.join(safeConfig.workspaceRoot, REVIEW_REQUESTS_LOG);
750
989
  let fsWatcher = null;
751
- let drainSafetyTimer = null;
752
990
  try {
753
991
  await node_fs.mkdir(node_path.dirname(hookEventsLog), { recursive: true });
754
- fsWatcher = watch(hookEventsLog, { ignoreInitial: true, persistent: false });
992
+ fsWatcher = watch([hookEventsLog, reviewHoldsLog], { ignoreInitial: true, persistent: false });
755
993
  fsWatcher.on('change', runDrain);
756
994
  fsWatcher.on('add', runDrain);
757
995
  }
758
996
  catch (err) {
759
- // chokidar unavailable - rely on fallback
997
+ // A real fallback, not a swallow: `drainSafetyTimer` below drains every 60 s
998
+ // regardless. But losing the FSEvents watcher turns a near-immediate drain
999
+ // into a up-to-60-second one, which is the difference between a hook
1000
+ // incident showing up while the agent is still running and after it exited.
1001
+ // Say so, at dim, so the degradation is diagnosable.
1002
+ log.dim(`Hook-event watcher unavailable, falling back to the 60s drain poll: ${err instanceof Error ? err.message : String(err)}`);
760
1003
  }
761
1004
  // 60-second safety-net drain poll
762
- drainSafetyTimer = setInterval(runDrain, 60_000);
1005
+ const drainSafetyTimer = setInterval(runDrain, 60_000);
763
1006
  // Run initial compliance check on startup
764
1007
  await runProbes();
1008
+ // dsh coverage-gap visibility (TD-370): once at startup, not every poll
1009
+ // tick — the gap only changes state on the user's first `dsh --profile
1010
+ // <name>` run, which the filesystem watcher below reacts to immediately
1011
+ // via settingsGuard.ts's isDshProfilesRoot `addDir` handling. Only worth
1012
+ // checking when dsh is actually a configured harness for this workspace.
1013
+ if (safeConfig.harnesses.includes('dsh')) {
1014
+ try {
1015
+ await warnIfDshCoverageGap();
1016
+ }
1017
+ catch (err) {
1018
+ log.dim(`dsh coverage-gap check failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1019
+ }
1020
+ }
765
1021
  // 5. Start Filesystem Watcher
766
1022
  const watcher = startWatcher(safeConfig.workspaceRoot, safeConfig.harnesses, async (changedPath) => {
767
1023
  const filename = node_path.basename(changedPath);
@@ -793,8 +1049,19 @@ export async function runConnect(opts) {
793
1049
  }
794
1050
  return;
795
1051
  }
796
- // B. Handle Claude Code settings.json tamper detection (privilege escalation guard)
797
- if (filename === 'settings.json' || filename === 'settings.local.json') {
1052
+ // B. Tamper detection for every governed config, not just Claude Code's.
1053
+ //
1054
+ // This used to filter on `filename === 'settings.json'`, which silently disabled
1055
+ // five of the six branches inside guardSettingsFile: the Cursor, Windsurf, Cline
1056
+ // and OpenHands restores all key off `hooks.json`, and the Goose
1057
+ // governance-plugin override incident keys off a path containing
1058
+ // `intutic-governance`. None of those basenames could ever reach the guard, so a
1059
+ // tampered hooks file on any harness but Claude Code was a no-op — and a tampered
1060
+ // Windsurf/VS Code/Gemini settings file passed the basename test only to fall
1061
+ // through and log `settings_intact`.
1062
+ //
1063
+ // Route anything on the governed list to the guard and let it decide.
1064
+ if (isGovernedConfigPath(changedPath, filename)) {
798
1065
  try {
799
1066
  const sops = lastCachedConfig?.sops ?? [];
800
1067
  const tampered = await guardSettingsFile(changedPath, safeConfig.workspaceRoot, sops);
@@ -868,7 +1135,50 @@ export async function runConnect(opts) {
868
1135
  try {
869
1136
  await syncOfflineTraces(controlPlaneUrl, safeCreds.apiKey);
870
1137
  }
871
- catch { }
1138
+ catch (err) {
1139
+ // Non-fatal by design: a backlog of offline traces must never stop the
1140
+ // sync iteration — the next tick retries the same backlog. But it is not
1141
+ // a no-op either, so report it the way the identical startup call above
1142
+ // does, at dim level because this runs every poll interval.
1143
+ log.dim(`Offline trace sync failed (will retry next poll): ${err instanceof Error ? err.message : String(err)}`);
1144
+ }
1145
+ // Register agents + report one session per harness (the reporter
1146
+ // dedupes per run and per proxy process). connect's inline loop bypassed
1147
+ // both, leaving the dashboard graph empty for the primary user path.
1148
+ // The local proxy's instance id is read once per iteration: with it the
1149
+ // harness's git/task context lands on the proxy's own session row, the
1150
+ // one its traces are filed under (TD-231, Wave 5.6).
1151
+ const proxyInstanceId = safeConfig.harnesses.length > 0 ? await fetchLocalProxyInstanceId() : null;
1152
+ for (const harnessType of safeConfig.harnesses) {
1153
+ try {
1154
+ const report = await collectAgentReport({
1155
+ workspaceRoot: process.cwd(),
1156
+ harnessType,
1157
+ configSynced: true,
1158
+ dlpEnabled: true,
1159
+ policyEnforced: true,
1160
+ allowLocalVaults: syncConfig.settings?.allowLocalMemoryVaults,
1161
+ });
1162
+ await reportAgent(controlPlaneUrl, safeCreds.apiKey, safeCreds.workspaceId, report);
1163
+ await startHarnessSession({
1164
+ controlPlaneUrl,
1165
+ apiKey: safeCreds.apiKey,
1166
+ workspaceId: safeCreds.workspaceId,
1167
+ harnessType,
1168
+ workspaceRoot: process.cwd(),
1169
+ ...(proxyInstanceId ? { proxyInstanceId } : {}),
1170
+ });
1171
+ }
1172
+ catch (err) {
1173
+ // Per-harness isolation is deliberate: one harness failing to register
1174
+ // must not stop the others or abort the poll iteration. Silence was
1175
+ // not deliberate — fetchConfig already succeeded above, so a failure
1176
+ // here means the control plane accepted the config read and rejected
1177
+ // the agent report, which is exactly the "dashboard graph is empty"
1178
+ // symptom this loop was added to fix. Report it so it is diagnosable.
1179
+ log.dim(`Agent report/session for harness '${harnessType}' failed: ${err instanceof Error ? err.message : String(err)}`);
1180
+ }
1181
+ }
872
1182
  // Run compliance probes on each iteration
873
1183
  await runProbes();
874
1184
  }
@@ -888,9 +1198,9 @@ export async function runConnect(opts) {
888
1198
  // Cleanup watcher, intervals, and WS connection on exit
889
1199
  watcher.stop();
890
1200
  fsWatcher?.close();
891
- if (drainSafetyTimer)
892
- clearInterval(drainSafetyTimer);
1201
+ clearInterval(drainSafetyTimer);
893
1202
  wsClient.close();
1203
+ await endAllOpenSessions(controlPlaneUrl, safeCreds.apiKey);
894
1204
  log.success('Sync daemon stopped.');
895
1205
  }
896
1206
  async function scanLocalSops(workspaceRoot) {