@intutic/sync-daemon 2.2.0 → 2.3.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 (235) hide show
  1. package/README.md +1 -1
  2. package/dist/agentReporter.d.ts +23 -11
  3. package/dist/agentReporter.d.ts.map +1 -1
  4. package/dist/agentReporter.js +20 -13
  5. package/dist/agentReporter.js.map +1 -1
  6. package/dist/configReader.d.ts +1 -3
  7. package/dist/configReader.d.ts.map +1 -1
  8. package/dist/configReader.js +0 -2
  9. package/dist/configReader.js.map +1 -1
  10. package/dist/configWriter.d.ts +11 -12
  11. package/dist/configWriter.d.ts.map +1 -1
  12. package/dist/configWriter.js +35 -58
  13. package/dist/configWriter.js.map +1 -1
  14. package/dist/disconnect/harnesses.d.ts +0 -2
  15. package/dist/disconnect/harnesses.d.ts.map +1 -1
  16. package/dist/disconnect/harnesses.js +157 -80
  17. package/dist/disconnect/harnesses.js.map +1 -1
  18. package/dist/disconnect/index.d.ts.map +1 -1
  19. package/dist/disconnect/index.js +11 -1
  20. package/dist/disconnect/index.js.map +1 -1
  21. package/dist/disconnect/mcp.d.ts +21 -0
  22. package/dist/disconnect/mcp.d.ts.map +1 -1
  23. package/dist/disconnect/mcp.js +71 -0
  24. package/dist/disconnect/mcp.js.map +1 -1
  25. package/dist/disconnect/recognise.d.ts +2 -2
  26. package/dist/disconnect/recognise.d.ts.map +1 -1
  27. package/dist/disconnect/recognise.js +15 -5
  28. package/dist/disconnect/recognise.js.map +1 -1
  29. package/dist/harness/aiderConfigMerger.d.ts +28 -8
  30. package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
  31. package/dist/harness/aiderConfigMerger.js +59 -11
  32. package/dist/harness/aiderConfigMerger.js.map +1 -1
  33. package/dist/harness/antigravityCliHooks.d.ts +61 -0
  34. package/dist/harness/antigravityCliHooks.d.ts.map +1 -0
  35. package/dist/harness/antigravityCliHooks.js +207 -0
  36. package/dist/harness/antigravityCliHooks.js.map +1 -0
  37. package/dist/harness/antigravityHooks.d.ts +12 -2
  38. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  39. package/dist/harness/antigravityHooks.js +36 -16
  40. package/dist/harness/antigravityHooks.js.map +1 -1
  41. package/dist/harness/antigravityProducts.d.ts +40 -0
  42. package/dist/harness/antigravityProducts.d.ts.map +1 -0
  43. package/dist/harness/antigravityProducts.js +109 -0
  44. package/dist/harness/antigravityProducts.js.map +1 -0
  45. package/dist/harness/claudeAgentsMd.d.ts +29 -0
  46. package/dist/harness/claudeAgentsMd.d.ts.map +1 -0
  47. package/dist/harness/claudeAgentsMd.js +80 -0
  48. package/dist/harness/claudeAgentsMd.js.map +1 -0
  49. package/dist/harness/claudeCodeHooks.d.ts +6 -5
  50. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  51. package/dist/harness/claudeCodeHooks.js +98 -24
  52. package/dist/harness/claudeCodeHooks.js.map +1 -1
  53. package/dist/harness/clineHooks.d.ts +0 -1
  54. package/dist/harness/clineHooks.d.ts.map +1 -1
  55. package/dist/harness/clineHooks.js +10 -4
  56. package/dist/harness/clineHooks.js.map +1 -1
  57. package/dist/harness/codexHooks.d.ts +2 -0
  58. package/dist/harness/codexHooks.d.ts.map +1 -1
  59. package/dist/harness/codexHooks.js +12 -4
  60. package/dist/harness/codexHooks.js.map +1 -1
  61. package/dist/harness/cursorHooks.d.ts +5 -2
  62. package/dist/harness/cursorHooks.d.ts.map +1 -1
  63. package/dist/harness/cursorHooks.js +9 -3
  64. package/dist/harness/cursorHooks.js.map +1 -1
  65. package/dist/harness/dshHooks.d.ts +5 -5
  66. package/dist/harness/dshHooks.d.ts.map +1 -1
  67. package/dist/harness/dshHooks.js +5 -5
  68. package/dist/harness/gateArtifacts.d.ts +35 -0
  69. package/dist/harness/gateArtifacts.d.ts.map +1 -0
  70. package/dist/harness/gateArtifacts.js +88 -0
  71. package/dist/harness/gateArtifacts.js.map +1 -0
  72. package/dist/harness/gateBody.d.ts +186 -69
  73. package/dist/harness/gateBody.d.ts.map +1 -1
  74. package/dist/harness/gateBody.js +820 -182
  75. package/dist/harness/gateBody.js.map +1 -1
  76. package/dist/harness/gateSightings.d.ts +47 -0
  77. package/dist/harness/gateSightings.d.ts.map +1 -0
  78. package/dist/harness/gateSightings.js +159 -0
  79. package/dist/harness/gateSightings.js.map +1 -0
  80. package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
  81. package/dist/harness/githubCopilotHooks.js +7 -1
  82. package/dist/harness/githubCopilotHooks.js.map +1 -1
  83. package/dist/harness/gooseHardener.d.ts +0 -1
  84. package/dist/harness/gooseHardener.d.ts.map +1 -1
  85. package/dist/harness/gooseHardener.js +1 -1
  86. package/dist/harness/gooseHardener.js.map +1 -1
  87. package/dist/harness/gooseHooks.d.ts +14 -1
  88. package/dist/harness/gooseHooks.d.ts.map +1 -1
  89. package/dist/harness/gooseHooks.js +55 -29
  90. package/dist/harness/gooseHooks.js.map +1 -1
  91. package/dist/harness/grokHooks.d.ts +2 -2
  92. package/dist/harness/grokHooks.d.ts.map +1 -1
  93. package/dist/harness/grokHooks.js +11 -6
  94. package/dist/harness/grokHooks.js.map +1 -1
  95. package/dist/harness/hermesHooks.d.ts +18 -8
  96. package/dist/harness/hermesHooks.d.ts.map +1 -1
  97. package/dist/harness/hermesHooks.js +48 -17
  98. package/dist/harness/hermesHooks.js.map +1 -1
  99. package/dist/harness/mcpAutoWrite.d.ts +24 -9
  100. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  101. package/dist/harness/mcpAutoWrite.js +218 -25
  102. package/dist/harness/mcpAutoWrite.js.map +1 -1
  103. package/dist/harness/museHooks.d.ts +3 -3
  104. package/dist/harness/museHooks.d.ts.map +1 -1
  105. package/dist/harness/museHooks.js +9 -5
  106. package/dist/harness/museHooks.js.map +1 -1
  107. package/dist/harness/n8nHooks.d.ts +0 -1
  108. package/dist/harness/n8nHooks.d.ts.map +1 -1
  109. package/dist/harness/n8nHooks.js +4 -1
  110. package/dist/harness/n8nHooks.js.map +1 -1
  111. package/dist/harness/openCodeHooks.d.ts +2 -2
  112. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  113. package/dist/harness/openCodeHooks.js +7 -3
  114. package/dist/harness/openCodeHooks.js.map +1 -1
  115. package/dist/harness/openWebuiHooks.d.ts +0 -1
  116. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  117. package/dist/harness/openWebuiHooks.js +5 -1
  118. package/dist/harness/openWebuiHooks.js.map +1 -1
  119. package/dist/harness/openclawHooks.d.ts +70 -18
  120. package/dist/harness/openclawHooks.d.ts.map +1 -1
  121. package/dist/harness/openclawHooks.js +213 -161
  122. package/dist/harness/openclawHooks.js.map +1 -1
  123. package/dist/harness/openhandsHooks.d.ts +24 -10
  124. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  125. package/dist/harness/openhandsHooks.js +49 -48
  126. package/dist/harness/openhandsHooks.js.map +1 -1
  127. package/dist/harness/piHooks.d.ts +52 -17
  128. package/dist/harness/piHooks.d.ts.map +1 -1
  129. package/dist/harness/piHooks.js +177 -166
  130. package/dist/harness/piHooks.js.map +1 -1
  131. package/dist/harness/protectedPaths.d.ts +107 -25
  132. package/dist/harness/protectedPaths.d.ts.map +1 -1
  133. package/dist/harness/protectedPaths.js +342 -48
  134. package/dist/harness/protectedPaths.js.map +1 -1
  135. package/dist/harness/rulesSection.d.ts +62 -0
  136. package/dist/harness/rulesSection.d.ts.map +1 -0
  137. package/dist/harness/rulesSection.js +145 -0
  138. package/dist/harness/rulesSection.js.map +1 -0
  139. package/dist/harness/windsurfHooks.d.ts +0 -1
  140. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  141. package/dist/harness/windsurfHooks.js +4 -1
  142. package/dist/harness/windsurfHooks.js.map +1 -1
  143. package/dist/index.d.ts +12 -8
  144. package/dist/index.d.ts.map +1 -1
  145. package/dist/index.js +11 -9
  146. package/dist/index.js.map +1 -1
  147. package/dist/inventory.d.ts +53 -0
  148. package/dist/inventory.d.ts.map +1 -0
  149. package/dist/inventory.js +133 -0
  150. package/dist/inventory.js.map +1 -0
  151. package/dist/lib/complianceProbes.d.ts +2 -1
  152. package/dist/lib/complianceProbes.d.ts.map +1 -1
  153. package/dist/lib/complianceProbes.js +1 -1
  154. package/dist/lib/complianceProbes.js.map +1 -1
  155. package/dist/lib/decisionsDigest.d.ts +34 -16
  156. package/dist/lib/decisionsDigest.d.ts.map +1 -1
  157. package/dist/lib/decisionsDigest.js +105 -46
  158. package/dist/lib/decisionsDigest.js.map +1 -1
  159. package/dist/lib/egressPolicy.d.ts +2 -2
  160. package/dist/lib/egressPolicy.js +2 -2
  161. package/dist/lib/mcpRegistryPy.d.ts +11 -0
  162. package/dist/lib/mcpRegistryPy.d.ts.map +1 -0
  163. package/dist/lib/mcpRegistryPy.js +173 -0
  164. package/dist/lib/mcpRegistryPy.js.map +1 -0
  165. package/dist/lib/phrasesPy.d.ts +11 -0
  166. package/dist/lib/phrasesPy.d.ts.map +1 -0
  167. package/dist/lib/phrasesPy.js +351 -0
  168. package/dist/lib/phrasesPy.js.map +1 -0
  169. package/dist/lib/policySnapshot.d.ts +46 -10
  170. package/dist/lib/policySnapshot.d.ts.map +1 -1
  171. package/dist/lib/policySnapshot.js +136 -47
  172. package/dist/lib/policySnapshot.js.map +1 -1
  173. package/dist/lib/processPoller.d.ts.map +1 -1
  174. package/dist/lib/processPoller.js +9 -2
  175. package/dist/lib/processPoller.js.map +1 -1
  176. package/dist/lib/regexLinearity.d.ts +43 -0
  177. package/dist/lib/regexLinearity.d.ts.map +1 -0
  178. package/dist/lib/regexLinearity.js +275 -0
  179. package/dist/lib/regexLinearity.js.map +1 -0
  180. package/dist/lib/runtimeEnv.d.ts +1 -3
  181. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  182. package/dist/lib/runtimeEnv.js +0 -2
  183. package/dist/lib/runtimeEnv.js.map +1 -1
  184. package/dist/lib/sequencePy.d.ts +10 -0
  185. package/dist/lib/sequencePy.d.ts.map +1 -0
  186. package/dist/lib/sequencePy.js +102 -0
  187. package/dist/lib/sequencePy.js.map +1 -0
  188. package/dist/sessionReporter.d.ts +17 -11
  189. package/dist/sessionReporter.d.ts.map +1 -1
  190. package/dist/sessionReporter.js +36 -23
  191. package/dist/sessionReporter.js.map +1 -1
  192. package/dist/skillWriter.d.ts +23 -5
  193. package/dist/skillWriter.d.ts.map +1 -1
  194. package/dist/skillWriter.js +159 -16
  195. package/dist/skillWriter.js.map +1 -1
  196. package/dist/syncCycle.d.ts.map +1 -1
  197. package/dist/syncCycle.js +21 -9
  198. package/dist/syncCycle.js.map +1 -1
  199. package/dist/trajectoryMonitor.d.ts +1 -3
  200. package/dist/trajectoryMonitor.d.ts.map +1 -1
  201. package/dist/trajectoryMonitor.js +1 -4
  202. package/dist/trajectoryMonitor.js.map +1 -1
  203. package/dist/watcher/driftWatcher.d.ts +0 -1
  204. package/dist/watcher/driftWatcher.d.ts.map +1 -1
  205. package/dist/watcher/driftWatcher.js +1 -2
  206. package/dist/watcher/driftWatcher.js.map +1 -1
  207. package/dist/watcher/settingsGuard.d.ts +33 -7
  208. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  209. package/dist/watcher/settingsGuard.js +373 -61
  210. package/dist/watcher/settingsGuard.js.map +1 -1
  211. package/dist/watcher/vscodeHookSettings.d.ts +30 -0
  212. package/dist/watcher/vscodeHookSettings.d.ts.map +1 -0
  213. package/dist/watcher/vscodeHookSettings.js +170 -0
  214. package/dist/watcher/vscodeHookSettings.js.map +1 -0
  215. package/dist/wsClient.d.ts +0 -1
  216. package/dist/wsClient.d.ts.map +1 -1
  217. package/dist/wsClient.js +0 -1
  218. package/dist/wsClient.js.map +1 -1
  219. package/package.json +5 -5
  220. package/dist/harness/claudeDesktopHooks.d.ts +0 -33
  221. package/dist/harness/claudeDesktopHooks.d.ts.map +0 -1
  222. package/dist/harness/claudeDesktopHooks.js +0 -256
  223. package/dist/harness/claudeDesktopHooks.js.map +0 -1
  224. package/dist/harness/continueHooks.d.ts +0 -63
  225. package/dist/harness/continueHooks.d.ts.map +0 -1
  226. package/dist/harness/continueHooks.js +0 -250
  227. package/dist/harness/continueHooks.js.map +0 -1
  228. package/dist/harness/gateKind.d.ts +0 -164
  229. package/dist/harness/gateKind.d.ts.map +0 -1
  230. package/dist/harness/gateKind.js +0 -144
  231. package/dist/harness/gateKind.js.map +0 -1
  232. package/dist/harness/rooCodeHooks.d.ts +0 -31
  233. package/dist/harness/rooCodeHooks.d.ts.map +0 -1
  234. package/dist/harness/rooCodeHooks.js +0 -281
  235. package/dist/harness/rooCodeHooks.js.map +0 -1
@@ -5,12 +5,19 @@
5
5
  * Called by the drift watcher whenever any protected governance config file changes.
6
6
  *
7
7
  * Behaviour per path type:
8
- * - JSON hook files (Cursor, Cline, OpenHands, Goose): validate marker presence, restore.
8
+ * - Gate files (`GATE_ARTIFACTS`): restored by their writer when deleted or
9
+ * no longer the generated gate.
10
+ * - JSON hook files (Cursor, Windsurf, OpenHands, Muse, Grok): validate marker presence, restore.
11
+ * - Registrations that name the gate script (Codex, GitHub Copilot, Hermes,
12
+ * the Goose config, OpenClaw's openclaw.json): restored while the gate is installed.
9
13
  * - Immutable files (Goose plugin): log governance_override_attempt incident instead of restoring.
10
- * - Text rules (.roorules, .clinerules): restore from last-written hash.
11
- * - Settings files (VS Code, Aider, claude_desktop_config.json): detect drift, restore.
14
+ * - VS Code settings: `chat.useHooks` or a `chat.hookFilesLocations` entry that switches
15
+ * off the GitHub Copilot gate is set back, and nothing else in the file is touched.
16
+ * - The policy snapshot: restored from the last copy the daemon verified when it differs.
17
+ * - Other settings files (claude_desktop_config.json, Grok's TOML): a deletion is logged as drift.
18
+ * - Gemini CLI's ~/.gemini/settings.json and Antigravity's
19
+ * ~/.gemini/config/hooks.json: restore when the gate registration is gone.
12
20
  *
13
- * LLD #14 — settingsGuard.ts
14
21
  * HLD §3.14 — Three-Tier Defense Cascade (Tier 1 Native Gating)
15
22
  *
16
23
  * @module
@@ -22,28 +29,70 @@ import * as crypto from 'node:crypto';
22
29
  import { createLogger } from '@intutic/logger';
23
30
  import { updatePreToolUseHooks, parseSopConstraints } from '../harness/claudeCodeHooks.js';
24
31
  import { writeClineHooks } from '../harness/clineHooks.js';
25
- import { writeCursorHooks } from '../harness/cursorHooks.js';
32
+ import { writeCursorHooks, systemHooksDirFor } from '../harness/cursorHooks.js';
26
33
  import { writeOpenHandsHooks } from '../harness/openhandsHooks.js';
27
34
  import { writeGooseHooks } from '../harness/gooseHooks.js';
28
35
  import { writeWindsurfHooks, windsurfSettingsPath } from '../harness/windsurfHooks.js';
29
36
  import { writeMuseHooks } from '../harness/museHooks.js';
30
- import { writeOpenCodeHooks, OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_FILE, OPENCODE_PLUGIN_V2_FILE } from '../harness/openCodeHooks.js';
37
+ import { writeOpenCodeHooks } from '../harness/openCodeHooks.js';
31
38
  import { writeGrokHooks } from '../harness/grokHooks.js';
39
+ import { writePiHooks } from '../harness/piHooks.js';
40
+ import { writeOpenclawHooks, OPENCLAW_PLUGIN_FILE } from '../harness/openclawHooks.js';
32
41
  import { writeDshHooks, resolveDshHome, detectDshCoverageGap } from '../harness/dshHooks.js';
42
+ import { writeAntigravityHooks } from '../harness/antigravityHooks.js';
43
+ import { writeAntigravityCliHooks, antigravityHooksPath, ANTIGRAVITY_HOOK_NAME, ANTIGRAVITY_CLI_GATE } from '../harness/antigravityCliHooks.js';
44
+ import { writeCodexHooks, codexUserDir } from '../harness/codexHooks.js';
45
+ import { writeGithubCopilotHooks } from '../harness/githubCopilotHooks.js';
46
+ import { writeHermesHooks } from '../harness/hermesHooks.js';
47
+ import { writeN8nHooks } from '../harness/n8nHooks.js';
48
+ import { writeOpenWebuiHooks } from '../harness/openWebuiHooks.js';
49
+ import { GATE_ARTIFACTS } from '../harness/gateArtifacts.js';
33
50
  import { isImmutable } from '../harness/gooseHardener.js';
51
+ import { readOriginal } from '../disconnect/originals.js';
52
+ import { DEFAULT_SNAPSHOT_DIR, SNAPSHOT_JSON, SNAPSHOT_RULES, restoreVerifiedSnapshot } from '../lib/policySnapshot.js';
53
+ import { repairHookSettings } from './vscodeHookSettings.js';
34
54
  const log = createLogger('sync-settings-guard');
35
55
  /** Marker embedded in every Intutic-generated hook command. */
36
56
  const HOOK_MARKER = '.intutic/hooks/';
57
+ /** Stamped into every gate gateBody.ts emits, whatever its language. */
58
+ const GATE_BODY_MARKER = 'Intutic gate body';
37
59
  const home = os.homedir();
60
+ /** Goose's plugin directory, whose files are hardened rather than restored. */
61
+ const GOOSE_PLUGIN_DIR = path.join(home, '.agents', 'plugins', 'intutic-governance');
62
+ const GOOSE_GATE_SCRIPT = path.join(GOOSE_PLUGIN_DIR, 'scripts', 'intutic-check.sh');
63
+ /** Cursor's machine-wide hooks.json, written by `intutic enterprise install`. */
64
+ const cursorSystemHooks = () => path.join(systemHooksDirFor(process.platform), 'hooks.json');
65
+ /** Harnesses whose writer installs the gate in the home directory rather than the workspace. */
66
+ const PER_USER_GATES = new Set(['goose', 'hermes', 'n8n', 'open-webui', 'openclaw', 'pi']);
67
+ /** Every gate file in {@link GATE_ARTIFACTS}, where its writer puts it. */
68
+ function gateArtifactPaths(workspaceRoot) {
69
+ return Object.entries(GATE_ARTIFACTS).flatMap(([harness, rels]) => (rels ?? []).map((rel) => ({ harness, file: path.join(PER_USER_GATES.has(harness) ? home : workspaceRoot, rel) })));
70
+ }
38
71
  // ─── All protected paths, grouped by harness ─────────────────────────
72
+ /** The live policy snapshot every gate reads, both artifacts. */
73
+ function policySnapshotFiles(snapshotDir = DEFAULT_SNAPSHOT_DIR) {
74
+ return [path.join(snapshotDir, SNAPSHOT_RULES), path.join(snapshotDir, SNAPSHOT_JSON)];
75
+ }
76
+ /** VS Code's user settings (both platforms' locations) and the workspace's. */
77
+ function vscodeSettingsFiles(workspaceRoot) {
78
+ return [
79
+ path.join(home, '.config', 'Code', 'User', 'settings.json'),
80
+ path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
81
+ path.join(workspaceRoot, '.vscode', 'settings.json'),
82
+ ];
83
+ }
39
84
  /** Returns the full list of protected paths to watch. */
40
85
  export function buildProtectedPaths(workspaceRoot) {
41
86
  return [
87
+ // ── Every harness's gate file ────────────────────────────────────
88
+ ...gateArtifactPaths(workspaceRoot).map((g) => g.file),
89
+ // ── The policy snapshot every gate reads ─────────────────────────
90
+ ...policySnapshotFiles(),
42
91
  // ── Claude Code ──────────────────────────────────────────────────
43
92
  path.join(home, '.claude', 'settings.json'),
44
93
  path.join(workspaceRoot, '.claude', 'settings.json'),
45
94
  // ── Cursor (3 levels) ────────────────────────────────────────────
46
- '/etc/cursor/hooks.json',
95
+ cursorSystemHooks(),
47
96
  path.join(home, '.cursor', 'hooks.json'),
48
97
  path.join(workspaceRoot, '.cursor', 'hooks.json'),
49
98
  // ── Windsurf ─────────────────────────────────────────────────────
@@ -53,30 +102,40 @@ export function buildProtectedPaths(workspaceRoot) {
53
102
  // subdirectory) — see windsurfHooks.ts's module doc comment.
54
103
  path.join(home, '.codeium', 'hooks.json'),
55
104
  path.join(workspaceRoot, '.windsurf', 'hooks.json'),
56
- // ── Cline ────────────────────────────────────────────────────────
57
- path.join(workspaceRoot, '.clinerules', 'hooks', 'hooks.json'),
58
105
  // ── OpenHands ────────────────────────────────────────────────────
59
106
  path.join(workspaceRoot, '.openhands', 'hooks.json'),
60
107
  // ── Goose (immutable — incident on tamper, not silent restore) ───
61
- path.join(home, '.agents', 'plugins', 'intutic-governance', 'hooks', 'hooks.json'),
62
- // ── Roo Code ─────────────────────────────────────────────────────
63
- path.join(workspaceRoot, '.roorules'),
64
- // ── VS Code settings (Cline / Roo Code) ──────────────────────────
65
- path.join(home, '.config', 'Code', 'User', 'settings.json'),
66
- path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
67
- path.join(workspaceRoot, '.vscode', 'settings.json'),
108
+ // The script is a gate artifact, listed above. The config names it
109
+ // too, as `hooks.pre_tool_use`.
110
+ path.join(GOOSE_PLUGIN_DIR, 'plugin.json'),
111
+ path.join(GOOSE_PLUGIN_DIR, 'hooks', 'hooks.json'),
112
+ path.join(home, '.config', 'goose', 'config.yaml'),
113
+ // ── Codex: the gate's registration at both levels ───────────────
114
+ path.join(workspaceRoot, '.codex', 'hooks.json'),
115
+ path.join(codexUserDir(), 'hooks.json'),
116
+ // ── GitHub Copilot: the hook file at both levels ────────────────
117
+ path.join(workspaceRoot, '.github', 'hooks', 'intutic-governance.json'),
118
+ path.join(home, '.copilot', 'hooks', 'intutic-governance.json'),
119
+ // ── Hermes: `hooks.pre_tool_call` registers the gate ────────────
120
+ path.join(home, '.hermes', 'config.yaml'),
121
+ // ── VS Code settings (Cline, Roo Code, the GitHub Copilot hooks) ──
122
+ ...vscodeSettingsFiles(workspaceRoot),
68
123
  // ── Claude Desktop ────────────────────────────────────────────────
69
124
  path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
70
125
  path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
71
- // ── Antigravity ───────────────────────────────────────────────────
72
- path.join(workspaceRoot, '.gemini', 'settings.json'),
126
+ // ── Antigravity and Gemini CLI ────────────────────────────────────
127
+ // The gates are registered at user level: Gemini CLI's in
128
+ // ~/.gemini/settings.json, Antigravity's in ~/.gemini/config/hooks.json.
129
+ // The rules are in the workspace's GEMINI.md, which the drift watcher
130
+ // watches as the harness's rules file.
131
+ path.join(home, '.gemini', 'settings.json'),
132
+ path.join(home, '.gemini', 'config', 'hooks.json'),
73
133
  // ── Muse Code ────────────────────────────────────────────────────
74
134
  path.join(home, '.config', 'muse', 'settings.json'),
75
135
  path.join(home, '.config', 'muse', 'intutic-managed-hooks.json'),
76
136
  path.join(workspaceRoot, '.muse', 'hooks.json'),
77
- // ── OpenCode ─────────────────────────────────────────────────────
78
- path.join(workspaceRoot, OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_FILE),
79
- path.join(workspaceRoot, OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_V2_FILE),
137
+ // ── OpenClaw: the config that lists the plugin ──────────────────
138
+ path.join(home, '.openclaw', 'openclaw.json'),
80
139
  // ── Grok Build ───────────────────────────────────────────────────
81
140
  path.join(home, '.grok', 'hooks', 'intutic-governance.json'),
82
141
  path.join(workspaceRoot, '.grok', 'hooks', 'intutic-governance.json'),
@@ -103,11 +162,20 @@ export function buildProtectedPaths(workspaceRoot) {
103
162
  * once dsh creates it; `driftWatcher.ts` needs to know to react to `addDir`
104
163
  * for this ONE path — every other protected path only reacts to
105
164
  * `change`/`unlink` (see that module's own comment) — so this predicate is
106
- * exported for it to check against. TD-370.
165
+ * exported for it to check against.
107
166
  */
108
167
  export function isDshProfilesRoot(changedPath) {
109
168
  return changedPath === path.join(resolveDshHome(), 'profiles');
110
169
  }
170
+ /**
171
+ * Whether a change to `changedPath` is one {@link guardSettingsFile} judges:
172
+ * a path {@link buildProtectedPaths} lists, or a file under one it lists as a
173
+ * directory (dsh's profiles). The watcher's callback routes on this, so a
174
+ * path added to that list reaches the guard without a second edit there.
175
+ */
176
+ export function isGuardedPath(changedPath, workspaceRoot) {
177
+ return buildProtectedPaths(workspaceRoot).some((p) => changedPath === p || changedPath.startsWith(p + path.sep));
178
+ }
111
179
  /**
112
180
  * Resolves the workspace id for an incident or a restore.
113
181
  *
@@ -137,6 +205,61 @@ async function resolveWorkspaceId(workspaceRoot) {
137
205
  }
138
206
  return 'unknown';
139
207
  }
208
+ /**
209
+ * Appends a `config_tamper` event to the workspace's hook-events log, the file
210
+ * the daemon drains to `POST /api/v1/hook-events`: the control plane files it
211
+ * as an incident (on the audit timeline) and exports it to SIEM as a
212
+ * `TAMPER` gate decision. Never throws.
213
+ */
214
+ async function reportTamper(workspaceRoot, event) {
215
+ try {
216
+ const line = JSON.stringify({
217
+ event: 'config_tamper',
218
+ ...event,
219
+ workspaceId: await resolveWorkspaceId(workspaceRoot),
220
+ timestamp: new Date().toISOString(),
221
+ incidentId: crypto.createHash('sha1').update(event.filePath + Date.now()).digest('hex').slice(0, 16),
222
+ // Made once and resent with this line: processed once however often the drain retries.
223
+ eventId: crypto.randomBytes(16).toString('hex'),
224
+ }) + '\n';
225
+ const file = path.join(workspaceRoot, '.intutic', 'events', 'hook-events.jsonl');
226
+ await fs.mkdir(path.dirname(file), { recursive: true });
227
+ await fs.appendFile(file, line);
228
+ }
229
+ catch (err) {
230
+ log.warn({ err, filePath: event.filePath }, 'Failed to write a tamper event to the hook-events log');
231
+ }
232
+ }
233
+ /**
234
+ * The policy snapshot's self-heal. When the live snapshot differs from the
235
+ * last one the daemon verified, the verified copy goes back in force at once;
236
+ * when there is no verified copy and the live one fails its digest, `resync`
237
+ * fetches a fresh one. Either way the tamper is reported. A gate reading the
238
+ * damaged snapshot in between refuses every MCP call
239
+ * (`POLICY_SNAPSHOT_UNVERIFIED`) and drops its other dynamic rules. Returns
240
+ * whether it found tampering.
241
+ */
242
+ export async function guardPolicySnapshot(workspaceRoot, resync, snapshotDir = DEFAULT_SNAPSHOT_DIR) {
243
+ const outcome = await restoreVerifiedSnapshot(snapshotDir);
244
+ if (outcome === 'intact')
245
+ return false;
246
+ if (outcome === 'unverifiable') {
247
+ try {
248
+ await resync();
249
+ }
250
+ catch (err) {
251
+ log.error({ action: 'policy_snapshot_resync_failed', err }, 'Could not fetch a fresh policy snapshot');
252
+ }
253
+ }
254
+ await reportTamper(workspaceRoot, {
255
+ toolName: 'policy_snapshot',
256
+ filePath: path.join(snapshotDir, SNAPSHOT_RULES),
257
+ reason: outcome === 'restored'
258
+ ? 'The policy snapshot was changed outside the sync daemon; the last verified snapshot was restored.'
259
+ : 'The policy snapshot failed its digest check and no verified copy was kept to restore; a fresh one was requested.',
260
+ });
261
+ return true;
262
+ }
140
263
  // ─── Public entry point ──────────────────────────────────────────────
141
264
  /**
142
265
  * Inspect any protected governance config file and restore if tampered.
@@ -149,33 +272,40 @@ async function resolveWorkspaceId(workspaceRoot) {
149
272
  * @param skip - Harnesses `intutic disconnect --harness` took out:
150
273
  * their files are no longer governed, so a change to
151
274
  * one is not tampering and is not restored.
275
+ * @param resyncPolicy - Fetches a fresh policy snapshot, for a damaged one
276
+ * with no verified copy to restore.
152
277
  * @returns true if tampering was detected.
153
278
  */
154
- export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyUrl = '', settings, skip = new Set()) {
279
+ export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyUrl = '', settings, skip = new Set(), resyncPolicy = async () => { }) {
280
+ // ── The policy snapshot: the last verified copy, back in force ────
281
+ if (policySnapshotFiles().includes(changedPath)) {
282
+ return guardPolicySnapshot(workspaceRoot, resyncPolicy);
283
+ }
284
+ // ── VS Code settings: the keys that switch off the Copilot gate ───
285
+ // Only while the gate is installed, and only those keys; anything else in
286
+ // the file is the person's own. A deletion falls through to drift below.
287
+ if (vscodeSettingsFiles(workspaceRoot).includes(changedPath) && !skip.has('github-copilot')) {
288
+ const repaired = await guardVsCodeHookSettings(changedPath, workspaceRoot);
289
+ if (repaired !== null)
290
+ return repaired;
291
+ }
155
292
  // ── Goose plugin: immutable file tamper → incident, not restore ───
156
- if (changedPath.includes('intutic-governance')) {
293
+ // The plugin directory, not the bare name: the OpenCode, Pi and OpenClaw
294
+ // gate files are called intutic-governance too, and a name match sent
295
+ // their tampering here, to the Goose writer.
296
+ if (changedPath.includes(path.join('.agents', 'plugins', 'intutic-governance'))) {
157
297
  if (skip.has('goose'))
158
298
  return false;
159
299
  if (await isImmutable(changedPath)) {
160
300
  log.error({ action: 'governance_override_attempt', path: changedPath }, 'SECURITY: Immutable Goose governance file was modified — OS immutable flag bypassed. Emitting incident.');
161
- const workspaceId = await resolveWorkspaceId(workspaceRoot);
162
- // Emit incident to control plane via local hook-events queue
163
- try {
164
- const tamperEntry = JSON.stringify({
165
- event: 'config_tamper',
166
- toolName: 'goose_plugin',
167
- reason: 'SECURITY: Immutable Goose governance file was modified — OS immutable flag bypassed.',
168
- workspaceId,
169
- filePath: changedPath,
170
- timestamp: new Date().toISOString(),
171
- incidentId: crypto.createHash('sha1').update(changedPath + Date.now()).digest('hex').slice(0, 16),
172
- }) + '\n';
173
- const hookEventsJsonl = path.join(os.homedir(), '.intutic', 'events', 'hook-events.jsonl');
174
- await fs.appendFile(hookEventsJsonl, tamperEntry, { flag: 'a' });
175
- }
176
- catch (err) {
177
- log.warn({ err }, 'Failed to write Goose tamper incident to hook-events log');
178
- }
301
+ // The workspace's log, which the daemon drains; one under the home
302
+ // directory was never read unless the workspace was the home directory.
303
+ await reportTamper(workspaceRoot, {
304
+ toolName: 'goose_plugin',
305
+ harnessType: 'goose',
306
+ reason: 'SECURITY: Immutable Goose governance file was modified — OS immutable flag bypassed.',
307
+ filePath: changedPath,
308
+ });
179
309
  return true;
180
310
  }
181
311
  // Not immutable (install in progress or first write) — restore normally.
@@ -190,18 +320,48 @@ export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyU
190
320
  await writeGooseHooks(proxyUrl, workspaceRoot, await resolveWorkspaceId(workspaceRoot));
191
321
  return true;
192
322
  }
323
+ // ── A gate file: restored by the writer that generates it ─────────
324
+ const gate = gateArtifactPaths(workspaceRoot).find((g) => g.file === changedPath);
325
+ if (gate) {
326
+ if (skip.has(gate.harness))
327
+ return false;
328
+ // Cline's writer leaves a PreToolUse it did not write alone, as the
329
+ // user's own hook; the ledger says whether it wrote this one.
330
+ if (gate.harness === 'cline' && !(await readOriginal(changedPath, workspaceRoot))?.writtenSha256)
331
+ return false;
332
+ return guardGateFile(changedPath, gate.harness, async () => GATE_WRITERS[gate.harness]({ workspaceRoot, proxyUrl, sops, settings, workspaceId: await resolveWorkspaceId(workspaceRoot), file: changedPath }));
333
+ }
193
334
  // ── Claude Code settings.json ─────────────────────────────────────
194
335
  if (changedPath.includes('.claude') && changedPath.endsWith('settings.json')) {
195
336
  if (skip.has('claude-code'))
196
337
  return false;
197
338
  return guardClaudeCodeSettings(changedPath, workspaceRoot, sops, settings);
198
339
  }
340
+ // ── Gemini CLI and Antigravity: the user-level gate registrations ──
341
+ // Restored whatever the hand-edit setting says, like every gate file:
342
+ // they are the gates, not the user's config.
343
+ if (changedPath === path.join(os.homedir(), '.gemini', 'settings.json')) {
344
+ if (skip.has('antigravity'))
345
+ return false;
346
+ return guardParsedFile(changedPath, 'antigravity', hasGeminiGate, async () => {
347
+ await writeAntigravityHooks(workspaceRoot, proxyUrl, await resolveWorkspaceId(workspaceRoot));
348
+ });
349
+ }
350
+ if (changedPath === antigravityHooksPath()) {
351
+ if (skip.has('antigravity'))
352
+ return false;
353
+ return guardParsedFile(changedPath, 'antigravity', hasAntigravityGate, async () => {
354
+ await writeAntigravityCliHooks(workspaceRoot, proxyUrl, await resolveWorkspaceId(workspaceRoot));
355
+ });
356
+ }
199
357
  // ── Cursor hooks.json ─────────────────────────────────────────────
200
- if (changedPath.includes('.cursor') && changedPath.endsWith('hooks.json')) {
358
+ // The machine-wide file is in no `.cursor` directory (`systemHooksDirFor`).
359
+ const systemLevel = changedPath === cursorSystemHooks();
360
+ if ((changedPath.includes('.cursor') || systemLevel) && changedPath.endsWith('hooks.json')) {
201
361
  if (skip.has('cursor'))
202
362
  return false;
203
363
  return guardJsonHookFile(changedPath, 'cursor', async () => {
204
- await writeCursorHooks(workspaceRoot, proxyUrl, '', changedPath.startsWith('/etc'));
364
+ await writeCursorHooks(workspaceRoot, proxyUrl, '', systemLevel);
205
365
  });
206
366
  }
207
367
  // ── Windsurf hooks.json ───────────────────────────────────────────
@@ -212,14 +372,6 @@ export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyU
212
372
  await writeWindsurfHooks(workspaceRoot, proxyUrl);
213
373
  });
214
374
  }
215
- // ── Cline hooks.json ──────────────────────────────────────────────
216
- if (changedPath.includes('.clinerules') && changedPath.endsWith('hooks.json')) {
217
- if (skip.has('cline'))
218
- return false;
219
- return guardJsonHookFile(changedPath, 'cline', async () => {
220
- await writeClineHooks(workspaceRoot, proxyUrl);
221
- });
222
- }
223
375
  // ── OpenHands hooks.json ──────────────────────────────────────────
224
376
  if (changedPath.includes('.openhands') && changedPath.endsWith('hooks.json')) {
225
377
  if (skip.has('openhands'))
@@ -257,7 +409,7 @@ export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyU
257
409
  // --profile <name>` run on this machine) ──────────────────────────────
258
410
  // driftWatcher.ts forwards this here on chokidar's `addDir` event (see
259
411
  // isDshProfilesRoot) — register governance into whichever profile(s) now
260
- // exist immediately, closing TD-370's silent window at the moment it
412
+ // exist immediately, closing dsh's silent no-profile window the moment it
261
413
  // closes itself, rather than waiting for an unrelated file change or the
262
414
  // next poll cycle to notice.
263
415
  if (isDshProfilesRoot(changedPath)) {
@@ -283,13 +435,15 @@ export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyU
283
435
  return false;
284
436
  return guardDshFile(changedPath, ['intutic-governance', proxyUrl], workspaceRoot, proxyUrl);
285
437
  }
286
- // ── OpenCode plugin: marker-substring guard, same shape as dsh ────
287
- // The gate body stamps itself; a file without the stamp is not ours.
288
- if (changedPath === path.join(workspaceRoot, OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_FILE) ||
289
- changedPath === path.join(workspaceRoot, OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_V2_FILE)) {
290
- if (skip.has('opencode'))
438
+ // ── Configs that name the gate ────────────────────────────────────
439
+ // Each is the user's file as much as ours, and exists whether or not the
440
+ // harness is connected: only while the gate is installed is a config that
441
+ // no longer names it tampered with. Its marker is the gate's file name.
442
+ const registration = gateRegistrations(workspaceRoot).find((r) => r.file === changedPath);
443
+ if (registration) {
444
+ if (skip.has(registration.harness) || !(await fileExists(registration.gate)))
291
445
  return false;
292
- return guardMarkedFile(changedPath, 'Intutic gate body', 'opencode', () => writeOpenCodeHooks(workspaceRoot, proxyUrl, ''));
446
+ return guardMarkedFile(changedPath, registration.marker, registration.harness, async () => GATE_WRITERS[registration.harness]({ workspaceRoot, proxyUrl, sops, settings, workspaceId: await resolveWorkspaceId(workspaceRoot), file: changedPath }));
293
447
  }
294
448
  // ── All other paths: file deleted or corrupted → log drift incident
295
449
  const exists = await fileExists(changedPath);
@@ -302,6 +456,122 @@ export async function guardSettingsFile(changedPath, workspaceRoot, sops, proxyU
302
456
  return false;
303
457
  }
304
458
  // ─── Internal helpers ────────────────────────────────────────────────
459
+ /**
460
+ * Sets `chat.useHooks`, and any `chat.hookFilesLocations` entry that drops the
461
+ * Copilot gate's location, back to `true` (vscodeHookSettings.ts). Null when
462
+ * this is not the guard's to judge: the Copilot gate is not installed, or the
463
+ * file is gone or not a settings object.
464
+ */
465
+ async function guardVsCodeHookSettings(filePath, workspaceRoot) {
466
+ const copilotGate = path.join(workspaceRoot, '.intutic', 'hooks', 'github-copilot-check.js');
467
+ if (!(await fileExists(copilotGate)))
468
+ return null;
469
+ let raw;
470
+ try {
471
+ raw = await fs.readFile(filePath, 'utf-8');
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ const repaired = repairHookSettings(raw, workspaceRoot);
477
+ if (!repaired)
478
+ return false;
479
+ try {
480
+ await fs.writeFile(filePath, repaired.text, 'utf-8');
481
+ }
482
+ catch (err) {
483
+ log.error({ action: 'vscode_hook_settings_restore_failed', path: filePath, err }, 'Could not restore the VS Code hook settings');
484
+ }
485
+ log.warn({ action: 'vscode_hook_settings_restored', path: filePath, keys: repaired.keys }, 'A VS Code setting switched off the GitHub Copilot gate — set back');
486
+ await reportTamper(workspaceRoot, {
487
+ toolName: 'vscode_settings',
488
+ harnessType: 'github-copilot',
489
+ filePath,
490
+ reason: `VS Code setting ${repaired.keys.join(', ')} switched off the GitHub Copilot governance hook; set back to true.`,
491
+ });
492
+ return true;
493
+ }
494
+ /** The writer that regenerates each harness's gate and its registrations. */
495
+ const GATE_WRITERS = {
496
+ 'claude-code': (c) => updatePreToolUseHooks(c.workspaceRoot, c.sops, c.settings),
497
+ 'cursor': (c) => writeCursorHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
498
+ 'windsurf': (c) => writeWindsurfHooks(c.workspaceRoot, c.proxyUrl, undefined, c.workspaceId),
499
+ 'codex': (c) => writeCodexHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
500
+ 'antigravity': async (c) => {
501
+ await writeAntigravityHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId);
502
+ await writeAntigravityCliHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId);
503
+ },
504
+ 'openhands': (c) => writeOpenHandsHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
505
+ 'hermes': (c) => writeHermesHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
506
+ 'pi': (c) => writePiHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
507
+ 'openclaw': (c) => writeOpenclawHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
508
+ 'muse-code': (c) => writeMuseHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
509
+ 'github-copilot': (c) => writeGithubCopilotHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
510
+ 'grok': (c) => writeGrokHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
511
+ 'cline': async (c) => {
512
+ // Only a PreToolUse Intutic wrote is restored (see the gate branch), so
513
+ // whatever is there now replaced the gate. The writer would keep it as
514
+ // the user's own hook.
515
+ await fs.rm(c.file, { force: true });
516
+ await writeClineHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId);
517
+ },
518
+ 'goose': (c) => writeGooseHooks(c.proxyUrl, c.workspaceRoot, c.workspaceId),
519
+ 'opencode': (c) => writeOpenCodeHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
520
+ 'n8n': (c) => writeN8nHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
521
+ 'open-webui': (c) => writeOpenWebuiHooks(c.workspaceRoot, c.proxyUrl, c.workspaceId),
522
+ };
523
+ function gateRegistrations(workspaceRoot) {
524
+ const codexGate = path.join(workspaceRoot, '.intutic', 'hooks', 'codex-check.js');
525
+ const copilotGate = path.join(workspaceRoot, '.intutic', 'hooks', 'github-copilot-check.js');
526
+ const openclawPlugin = path.join(home, OPENCLAW_PLUGIN_FILE);
527
+ return [
528
+ { harness: 'codex', file: path.join(workspaceRoot, '.codex', 'hooks.json'), gate: codexGate, marker: 'codex-check.js' },
529
+ { harness: 'codex', file: path.join(codexUserDir(), 'hooks.json'), gate: codexGate, marker: 'codex-check.js' },
530
+ {
531
+ harness: 'github-copilot',
532
+ file: path.join(workspaceRoot, '.github', 'hooks', 'intutic-governance.json'),
533
+ gate: copilotGate,
534
+ marker: 'github-copilot-check.js',
535
+ },
536
+ {
537
+ harness: 'github-copilot',
538
+ file: path.join(home, '.copilot', 'hooks', 'intutic-governance.json'),
539
+ gate: copilotGate,
540
+ marker: 'github-copilot-check.js',
541
+ },
542
+ {
543
+ harness: 'hermes',
544
+ file: path.join(home, '.hermes', 'config.yaml'),
545
+ gate: path.join(home, '.intutic', 'hooks', 'hermes-check.sh'),
546
+ marker: 'hermes-check.sh',
547
+ },
548
+ { harness: 'goose', file: path.join(home, '.config', 'goose', 'config.yaml'), gate: GOOSE_GATE_SCRIPT, marker: GOOSE_GATE_SCRIPT },
549
+ // OpenClaw rewrites its own config; its marker is the listed plugin path.
550
+ { harness: 'openclaw', file: path.join(home, '.openclaw', 'openclaw.json'), gate: openclawPlugin, marker: openclawPlugin },
551
+ ];
552
+ }
553
+ /**
554
+ * A gate file: deleted, or no longer the generated gate, and its writer runs
555
+ * again. Cline runs its hook only while the file is executable, so for Cline
556
+ * a cleared execute bit is tampering too.
557
+ */
558
+ async function guardGateFile(filePath, harness, restore) {
559
+ let raw;
560
+ try {
561
+ raw = await fs.readFile(filePath, 'utf-8');
562
+ }
563
+ catch {
564
+ log.warn({ action: 'gate_file_deleted', harness, path: filePath }, `${harness} gate deleted — restoring`);
565
+ await safeRestore(harness, restore);
566
+ return true;
567
+ }
568
+ const disabled = harness === 'cline' && ((await fs.stat(filePath)).mode & 0o111) === 0;
569
+ if (raw.includes(GATE_BODY_MARKER) && !disabled)
570
+ return false;
571
+ log.warn({ action: 'gate_file_tampered', harness, path: filePath }, `${harness} gate tampered — restoring`);
572
+ await safeRestore(harness, restore);
573
+ return true;
574
+ }
305
575
  async function guardClaudeCodeSettings(settingsFilePath, workspaceRoot, sops, settings) {
306
576
  let raw;
307
577
  try {
@@ -420,7 +690,7 @@ async function guardDshFile(filePath, markers, workspaceRoot, proxyUrl) {
420
690
  }
421
691
  /**
422
692
  * Side-effecting wrapper around `dshHooks.ts`'s pure `detectDshCoverageGap` —
423
- * logs TD-370's "silent no-profile window" as a `dsh_coverage_gap` warning
693
+ * logs dsh's "silent no-profile window" as a `dsh_coverage_gap` warning
424
694
  * when dsh is present on this machine but has zero profiles, so the window
425
695
  * is at least documented in the logs even though there is nothing to
426
696
  * restore into yet. Called once at `intutic connect` startup — see
@@ -433,11 +703,53 @@ export async function warnIfDshCoverageGap() {
433
703
  const result = await detectDshCoverageGap();
434
704
  if (result.gap) {
435
705
  log.warn({ action: 'dsh_coverage_gap', dshDetected: result.dshDetected, profileCount: result.profileCount }, 'dsh is present on this machine but has zero profiles yet — nothing is governed until the ' +
436
- 'first `dsh --profile <name>` run creates one (TD-370). The next sync after that run picks ' +
706
+ 'first `dsh --profile <name>` run creates one. The next sync after that run picks ' +
437
707
  'it up automatically; this warning is visibility for the window before it, not a fix for it.');
438
708
  }
439
709
  return result.gap;
440
710
  }
711
+ /**
712
+ * A file whose integrity is a fact about its parsed content: deleted,
713
+ * unparseable, or parsed without the gate → restored. A marker-substring
714
+ * check is not enough here: these are the user's own settings files, and
715
+ * the MCP writer adds an `intutic` server to the same settings.json. The
716
+ * writers leave a file that is not a plain JSON object alone, so for one of
717
+ * those the restore is a logged no-op and the drift report surfaces it.
718
+ */
719
+ async function guardParsedFile(filePath, harness, intact, restore) {
720
+ let doc;
721
+ try {
722
+ doc = JSON.parse(await fs.readFile(filePath, 'utf-8'));
723
+ }
724
+ catch {
725
+ log.warn({ action: `${harness}_file_unreadable`, path: filePath }, `${harness} governance file deleted or not JSON — restoring`);
726
+ await safeRestore(harness, restore);
727
+ return true;
728
+ }
729
+ if (intact(doc))
730
+ return false;
731
+ log.warn({ action: `${harness}_hook_missing`, path: filePath }, `${harness} governance hook missing — restoring`);
732
+ await safeRestore(harness, restore);
733
+ return true;
734
+ }
735
+ function isObj(v) {
736
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
737
+ }
738
+ /** Whether a `{matcher, hooks: [{command}]}` list has an entry running `script`. */
739
+ function runsScript(entries, script) {
740
+ return Array.isArray(entries) && entries.some((e) => isObj(e) && Array.isArray(e.hooks) && e.hooks.some((h) => isObj(h) && typeof h.command === 'string' && h.command.includes(script)));
741
+ }
742
+ /** Gemini CLI's settings.json still registers the `BeforeTool` gate. */
743
+ function hasGeminiGate(doc) {
744
+ return isObj(doc) && isObj(doc.hooks) && runsScript(doc.hooks.BeforeTool, 'antigravity-check.sh');
745
+ }
746
+ /** Antigravity's hooks.json still carries Intutic's entry, enabled, running the gate. */
747
+ function hasAntigravityGate(doc) {
748
+ if (!isObj(doc))
749
+ return false;
750
+ const entry = doc[ANTIGRAVITY_HOOK_NAME];
751
+ return isObj(entry) && entry.enabled !== false && runsScript(entry.PreToolUse, ANTIGRAVITY_CLI_GATE);
752
+ }
441
753
  async function safeRestore(harness, restore) {
442
754
  try {
443
755
  await restore();