@intutic/sync-daemon 1.10.0 → 1.10.1

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 (200) hide show
  1. package/README.md +2 -2
  2. package/dist/agentReporter.d.ts +205 -0
  3. package/dist/agentReporter.d.ts.map +1 -0
  4. package/dist/agentReporter.js +394 -0
  5. package/dist/agentReporter.js.map +1 -0
  6. package/dist/configReader.d.ts +39 -2
  7. package/dist/configReader.d.ts.map +1 -1
  8. package/dist/configReader.js +56 -2
  9. package/dist/configReader.js.map +1 -1
  10. package/dist/configWriter.d.ts +51 -1
  11. package/dist/configWriter.d.ts.map +1 -1
  12. package/dist/configWriter.js +490 -63
  13. package/dist/configWriter.js.map +1 -1
  14. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  15. package/dist/harness/antigravityHooks.js +33 -55
  16. package/dist/harness/antigravityHooks.js.map +1 -1
  17. package/dist/harness/claudeCodeHooks.d.ts +47 -13
  18. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  19. package/dist/harness/claudeCodeHooks.js +316 -54
  20. package/dist/harness/claudeCodeHooks.js.map +1 -1
  21. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  22. package/dist/harness/claudeDesktopHooks.js +35 -48
  23. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  24. package/dist/harness/clineHooks.d.ts.map +1 -1
  25. package/dist/harness/clineHooks.js +42 -34
  26. package/dist/harness/clineHooks.js.map +1 -1
  27. package/dist/harness/codexHooks.d.ts +36 -0
  28. package/dist/harness/codexHooks.d.ts.map +1 -0
  29. package/dist/harness/codexHooks.js +227 -0
  30. package/dist/harness/codexHooks.js.map +1 -0
  31. package/dist/harness/continueHooks.d.ts +45 -5
  32. package/dist/harness/continueHooks.d.ts.map +1 -1
  33. package/dist/harness/continueHooks.js +225 -5
  34. package/dist/harness/continueHooks.js.map +1 -1
  35. package/dist/harness/cursorHooks.d.ts +31 -0
  36. package/dist/harness/cursorHooks.d.ts.map +1 -1
  37. package/dist/harness/cursorHooks.js +78 -49
  38. package/dist/harness/cursorHooks.js.map +1 -1
  39. package/dist/harness/dshHooks.d.ts +198 -0
  40. package/dist/harness/dshHooks.d.ts.map +1 -0
  41. package/dist/harness/dshHooks.js +569 -0
  42. package/dist/harness/dshHooks.js.map +1 -0
  43. package/dist/harness/gateBody.d.ts +422 -0
  44. package/dist/harness/gateBody.d.ts.map +1 -0
  45. package/dist/harness/gateBody.js +1816 -0
  46. package/dist/harness/gateBody.js.map +1 -0
  47. package/dist/harness/gateKind.d.ts +164 -0
  48. package/dist/harness/gateKind.d.ts.map +1 -0
  49. package/dist/harness/gateKind.js +143 -0
  50. package/dist/harness/gateKind.js.map +1 -0
  51. package/dist/harness/githubCopilotHooks.d.ts +54 -0
  52. package/dist/harness/githubCopilotHooks.d.ts.map +1 -0
  53. package/dist/harness/githubCopilotHooks.js +218 -0
  54. package/dist/harness/githubCopilotHooks.js.map +1 -0
  55. package/dist/harness/gooseHooks.d.ts.map +1 -1
  56. package/dist/harness/gooseHooks.js +67 -37
  57. package/dist/harness/gooseHooks.js.map +1 -1
  58. package/dist/harness/grokHooks.d.ts +77 -0
  59. package/dist/harness/grokHooks.d.ts.map +1 -0
  60. package/dist/harness/grokHooks.js +350 -0
  61. package/dist/harness/grokHooks.js.map +1 -0
  62. package/dist/harness/hermesHooks.d.ts.map +1 -1
  63. package/dist/harness/hermesHooks.js +26 -55
  64. package/dist/harness/hermesHooks.js.map +1 -1
  65. package/dist/harness/holdRedaction.d.ts +69 -0
  66. package/dist/harness/holdRedaction.d.ts.map +1 -0
  67. package/dist/harness/holdRedaction.js +211 -0
  68. package/dist/harness/holdRedaction.js.map +1 -0
  69. package/dist/harness/jetbrainsXmlConfig.d.ts +51 -0
  70. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -0
  71. package/dist/harness/jetbrainsXmlConfig.js +151 -0
  72. package/dist/harness/jetbrainsXmlConfig.js.map +1 -0
  73. package/dist/harness/mcpAutoWrite.d.ts +100 -3
  74. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  75. package/dist/harness/mcpAutoWrite.js +712 -90
  76. package/dist/harness/mcpAutoWrite.js.map +1 -1
  77. package/dist/harness/museHooks.d.ts +53 -0
  78. package/dist/harness/museHooks.d.ts.map +1 -0
  79. package/dist/harness/museHooks.js +273 -0
  80. package/dist/harness/museHooks.js.map +1 -0
  81. package/dist/harness/n8nHooks.d.ts +25 -9
  82. package/dist/harness/n8nHooks.d.ts.map +1 -1
  83. package/dist/harness/n8nHooks.js +174 -9
  84. package/dist/harness/n8nHooks.js.map +1 -1
  85. package/dist/harness/openCodeHooks.d.ts +93 -0
  86. package/dist/harness/openCodeHooks.d.ts.map +1 -0
  87. package/dist/harness/openCodeHooks.js +248 -0
  88. package/dist/harness/openCodeHooks.js.map +1 -0
  89. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  90. package/dist/harness/openWebuiHooks.js +56 -11
  91. package/dist/harness/openWebuiHooks.js.map +1 -1
  92. package/dist/harness/openclawHooks.d.ts.map +1 -1
  93. package/dist/harness/openclawHooks.js +30 -54
  94. package/dist/harness/openclawHooks.js.map +1 -1
  95. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  96. package/dist/harness/openhandsHooks.js +35 -41
  97. package/dist/harness/openhandsHooks.js.map +1 -1
  98. package/dist/harness/piHooks.d.ts.map +1 -1
  99. package/dist/harness/piHooks.js +31 -58
  100. package/dist/harness/piHooks.js.map +1 -1
  101. package/dist/harness/protectedPaths.d.ts +353 -0
  102. package/dist/harness/protectedPaths.d.ts.map +1 -0
  103. package/dist/harness/protectedPaths.js +805 -0
  104. package/dist/harness/protectedPaths.js.map +1 -0
  105. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  106. package/dist/harness/rooCodeHooks.js +34 -48
  107. package/dist/harness/rooCodeHooks.js.map +1 -1
  108. package/dist/harness/windsurfHooks.d.ts +72 -0
  109. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  110. package/dist/harness/windsurfHooks.js +172 -58
  111. package/dist/harness/windsurfHooks.js.map +1 -1
  112. package/dist/harness/windsurfJetBrainsProxy.d.ts +78 -0
  113. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -0
  114. package/dist/harness/windsurfJetBrainsProxy.js +159 -0
  115. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -0
  116. package/dist/hashReporter.js +1 -1
  117. package/dist/hashReporter.js.map +1 -1
  118. package/dist/index.d.ts +12 -6
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +14 -6
  121. package/dist/index.js.map +1 -1
  122. package/dist/integrityStore.d.ts +0 -8
  123. package/dist/integrityStore.d.ts.map +1 -1
  124. package/dist/integrityStore.js +6 -29
  125. package/dist/integrityStore.js.map +1 -1
  126. package/dist/lib/approvedBypasses.d.ts +76 -0
  127. package/dist/lib/approvedBypasses.d.ts.map +1 -0
  128. package/dist/lib/approvedBypasses.js +140 -0
  129. package/dist/lib/approvedBypasses.js.map +1 -0
  130. package/dist/lib/complianceProbes.d.ts.map +1 -1
  131. package/dist/lib/complianceProbes.js +5 -0
  132. package/dist/lib/complianceProbes.js.map +1 -1
  133. package/dist/lib/decisionsDigest.d.ts +99 -0
  134. package/dist/lib/decisionsDigest.d.ts.map +1 -0
  135. package/dist/lib/decisionsDigest.js +199 -0
  136. package/dist/lib/decisionsDigest.js.map +1 -0
  137. package/dist/lib/egressPolicy.d.ts +69 -0
  138. package/dist/lib/egressPolicy.d.ts.map +1 -0
  139. package/dist/lib/egressPolicy.js +126 -0
  140. package/dist/lib/egressPolicy.js.map +1 -0
  141. package/dist/lib/gitWorktrees.d.ts +140 -0
  142. package/dist/lib/gitWorktrees.d.ts.map +1 -0
  143. package/dist/lib/gitWorktrees.js +191 -0
  144. package/dist/lib/gitWorktrees.js.map +1 -0
  145. package/dist/lib/policySnapshot.d.ts +224 -0
  146. package/dist/lib/policySnapshot.d.ts.map +1 -0
  147. package/dist/lib/policySnapshot.js +637 -0
  148. package/dist/lib/policySnapshot.js.map +1 -0
  149. package/dist/lib/processPoller.d.ts +44 -0
  150. package/dist/lib/processPoller.d.ts.map +1 -1
  151. package/dist/lib/processPoller.js +176 -1
  152. package/dist/lib/processPoller.js.map +1 -1
  153. package/dist/lib/runtimeEnv.d.ts +8 -0
  154. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  155. package/dist/lib/runtimeEnv.js +3 -0
  156. package/dist/lib/runtimeEnv.js.map +1 -1
  157. package/dist/localTrajectoryHeuristics.d.ts +51 -0
  158. package/dist/localTrajectoryHeuristics.d.ts.map +1 -0
  159. package/dist/localTrajectoryHeuristics.js +90 -0
  160. package/dist/localTrajectoryHeuristics.js.map +1 -0
  161. package/dist/sessionReporter.d.ts +70 -0
  162. package/dist/sessionReporter.d.ts.map +1 -0
  163. package/dist/sessionReporter.js +140 -0
  164. package/dist/sessionReporter.js.map +1 -0
  165. package/dist/skillWriter.d.ts +1 -1
  166. package/dist/skillWriter.d.ts.map +1 -1
  167. package/dist/skillWriter.js +5 -2
  168. package/dist/skillWriter.js.map +1 -1
  169. package/dist/syncLoop.d.ts +105 -3
  170. package/dist/syncLoop.d.ts.map +1 -1
  171. package/dist/syncLoop.js +397 -65
  172. package/dist/syncLoop.js.map +1 -1
  173. package/dist/trajectoryMonitor.d.ts +2 -0
  174. package/dist/trajectoryMonitor.d.ts.map +1 -1
  175. package/dist/trajectoryMonitor.js +57 -0
  176. package/dist/trajectoryMonitor.js.map +1 -1
  177. package/dist/watcher/driftWatcher.d.ts.map +1 -1
  178. package/dist/watcher/driftWatcher.js +12 -3
  179. package/dist/watcher/driftWatcher.js.map +1 -1
  180. package/dist/watcher/settingsGuard.d.ts +23 -0
  181. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  182. package/dist/watcher/settingsGuard.js +202 -17
  183. package/dist/watcher/settingsGuard.js.map +1 -1
  184. package/package.json +37 -6
  185. package/dist/brainIndexer.d.ts +0 -51
  186. package/dist/brainIndexer.d.ts.map +0 -1
  187. package/dist/brainIndexer.js +0 -148
  188. package/dist/brainIndexer.js.map +0 -1
  189. package/dist/harness/cursorHooksJson.d.ts +0 -29
  190. package/dist/harness/cursorHooksJson.d.ts.map +0 -1
  191. package/dist/harness/cursorHooksJson.js +0 -130
  192. package/dist/harness/cursorHooksJson.js.map +0 -1
  193. package/dist/harness/gooseHooksWriter.d.ts +0 -27
  194. package/dist/harness/gooseHooksWriter.d.ts.map +0 -1
  195. package/dist/harness/gooseHooksWriter.js +0 -147
  196. package/dist/harness/gooseHooksWriter.js.map +0 -1
  197. package/dist/harness/openhandsHooksWriter.d.ts +0 -23
  198. package/dist/harness/openhandsHooksWriter.d.ts.map +0 -1
  199. package/dist/harness/openhandsHooksWriter.js +0 -146
  200. package/dist/harness/openhandsHooksWriter.js.map +0 -1
@@ -0,0 +1,805 @@
1
+ /**
2
+ * The config files an agent must never write, and the command shapes it must
3
+ * never run — whichever harness it runs under.
4
+ *
5
+ * # Why this is one list
6
+ *
7
+ * It was eleven lists, and no two agreed. Measured on 2026-08-04:
8
+ *
9
+ * | path | harnesses protecting it |
10
+ * |---|---|
11
+ * | `.claude/settings.local.json` | 3 of 11 |
12
+ * | `.gemini/settings.json` | 5 of 11 |
13
+ * | `.intutic/events` | 5 of 11 |
14
+ * | `.codeium/windsurf/hooks.json` | 8 of 11 |
15
+ * | `.cursor/hooks.json` | 8 of 11 |
16
+ *
17
+ * That is not eleven partial views of one intent, it is a **cross-harness
18
+ * tamper matrix**. The Claude-family lists carried only Claude paths, so an
19
+ * agent under Claude Code could rewrite `.cursor/hooks.json`,
20
+ * `.openhands/hooks.json`, `.gemini/settings.json` or the governance plugin
21
+ * directory unopposed; the other nine could write `.claude/settings.local.json`,
22
+ * which Claude Code loads at *higher* precedence than the `settings.json` they
23
+ * all did protect. Whichever harness the agent was running under, it could
24
+ * disarm a different one.
25
+ *
26
+ * A protected-path list is a security control. Eleven copies of a security
27
+ * control is eleven chances for one to be wrong, and every one of them was.
28
+ *
29
+ * # The same was true of the command guards
30
+ *
31
+ * Nine writers hand-maintained a `BYPASS_PATTERNS` list. Measured on 2026-08-05:
32
+ *
33
+ * | pattern | writers carrying it |
34
+ * |---|---|
35
+ * | `chflags nouchg` / `chattr -i` | 9 of 9 |
36
+ * | `rm -rf .intutic` | 7 of 9 — **`cursorHooks` and `windsurfHooks` had no protected-path command guard at all** |
37
+ * | `rm -rf .gemini/settings` | 4 of 9 |
38
+ * | the harness's own env kill-switch | 1 each |
39
+ *
40
+ * That last row is the load-bearing one. `claudeCodeHooks` guarded
41
+ * `CLAUDE_CODE_HOOKS=` and nothing else; `piHooks` guarded `PI_PRE_TOOL_HOOK=`
42
+ * and nothing else. So under any harness, the *other eight* kill-switches were
43
+ * free to set — the identical cross-harness hole the path list already had.
44
+ * `GOVERNANCE_BYPASS_PATTERNS` closes it with one generic pattern rather than
45
+ * nine specific ones, so a harness added tomorrow is covered on the day it
46
+ * lands.
47
+ *
48
+ * Two writers also used `/chflags\s+nouchg/` where the other seven used a
49
+ * literal substring. `\s` does not exist in POSIX ERE — see
50
+ * {@link assertPortableEre} for why that class of divergence is now a load
51
+ * failure rather than a silent one.
52
+ *
53
+ * # Scope
54
+ *
55
+ * Universal by design. A Cursor agent has no legitimate reason to edit Claude
56
+ * Code's settings, and the reverse holds for every pair — so the union is the
57
+ * correct list for all of them, not a Cursor list plus a Claude list.
58
+ * Harness-specific additions spread this and append; `harnessProtectedPaths.test.ts`
59
+ * asserts each is a superset.
60
+ *
61
+ * @module
62
+ */
63
+ import { SECRET_VALUE_PATTERNS } from '@intutic/shared-types';
64
+ /** Governance config that must hold still, for every harness. */
65
+ export const UNIVERSAL_PROTECTED_PATHS = [
66
+ // Intutic's own enforcement surface.
67
+ '.intutic/hooks',
68
+ '.intutic/integrity.json',
69
+ '.intutic/events',
70
+ // Claude Code / Claude Desktop. `settings.local.json` loads at higher
71
+ // precedence than `settings.json`, so protecting only the latter leaves the
72
+ // override unguarded — which was the case in eight of eleven lists.
73
+ '.claude/settings.json',
74
+ '.claude/settings.local.json',
75
+ // Every other harness's hook surface. Present here even for harnesses that
76
+ // are not the one generating the script: the threat is an agent under
77
+ // harness A disabling harness B.
78
+ '.cursor/hooks.json',
79
+ '.cline/hooks',
80
+ '.codeium/windsurf/hooks.json',
81
+ // The Windsurf JetBrains plugin's SEPARATE user-level hooks.json path (no
82
+ // `windsurf` subdirectory — confirmed against docs.devin.ai/desktop/
83
+ // cascade/hooks; see windsurfHooks.ts's module doc comment). Listed
84
+ // independently of the entry above: an agent disabling the JetBrains
85
+ // plugin's copy would not touch Desktop's.
86
+ '.codeium/hooks.json',
87
+ '.windsurf/hooks.json',
88
+ '.openhands/hooks.json',
89
+ '.gemini/settings.json',
90
+ '.agents/plugins/intutic-governance',
91
+ // Muse Code — project-level hooks, user settings (managed_hooks_path lives
92
+ // here), and the Intutic-owned managed-hooks file it points at.
93
+ '.muse/hooks.json',
94
+ '.config/muse/settings.json',
95
+ '.config/muse/intutic-managed-hooks.json',
96
+ // Grok Build. `.grok/hooks` covers the whole hook directory (not just the
97
+ // one file this writer owns) — the same "the surface, not the one file"
98
+ // discipline every other harness's hooks dir gets. `trusted_folders.toml`
99
+ // is watched even though nothing here writes to it: it governs which
100
+ // projects Grok Build trusts enough to run hooks in at all, so an agent
101
+ // rewriting it could disable this harness's whole governance surface
102
+ // without touching a single hook file.
103
+ '.grok/hooks',
104
+ '.grok/config.toml',
105
+ '.grok/trusted_folders.toml',
106
+ // DeepSeek's "dsh" — $DSH_HOME defaults to ~/.dsh, so these paths are
107
+ // matched wherever they appear (command text, or a Write/Edit target) the
108
+ // same "bare-mention block" way every entry above is, not resolved against
109
+ // a fixed absolute root here. `.dsh/profiles` covers the whole profiles
110
+ // directory (every profile's cordis.patch.yml, not just one), the same
111
+ // "the surface, not the one file" discipline `.grok/hooks` above uses —
112
+ // dsh may have zero, one, or several profiles, and a tamper anywhere under
113
+ // it is the same threat. `.dsh/settings.yaml` is the LLM-egress half; both
114
+ // are the only two files `dshHooks.ts` writes.
115
+ '.dsh/profiles',
116
+ '.dsh/settings.yaml',
117
+ // OpenCode — the plugin directories OpenCode globs (`.opencode/plugin` and
118
+ // `.opencode/plugins` are both read; only the latter is written) plus the
119
+ // user-level one. The whole directory, not the one file, same as `.grok/
120
+ // hooks`: a second plugin dropped beside ours is the same surface.
121
+ '.opencode/plugins',
122
+ '.opencode/plugin',
123
+ '.config/opencode/plugins',
124
+ ];
125
+ /**
126
+ * The normalisation every gate applies before matching, stated once so the
127
+ * shell and JS emitters cannot drift.
128
+ *
129
+ * 1. Every whitespace run collapses to a single space.
130
+ * 2. The result is padded with one space at each end.
131
+ *
132
+ * Step 1 exists because the portable subset has no whitespace class: `[[:space:]]`
133
+ * is a POSIX construct JavaScript does not implement, and `\s` is a JS construct
134
+ * POSIX does not implement, so *any* pattern spelling whitespace directly would
135
+ * enforce in the five bash gates and silently enforce nothing in the eight JS
136
+ * ones. Normalising first means patterns need only a literal space. It also
137
+ * closes `rm{TAB}-rf{TAB}/` and the newline-splitting evasion, since `grep` is
138
+ * line-oriented and would otherwise never see the whole command on one line.
139
+ *
140
+ * Step 2 is what lets every pattern use a plain leading/trailing space as a word
141
+ * boundary instead of `^`, `$` or `\b`. `\b` is not POSIX; and `$` inside an
142
+ * alternation group — `( |$)` — is an anchor in GNU ERE but unspecified in
143
+ * POSIX, which is exactly the kind of "works on the CI box" difference this
144
+ * table exists to eliminate.
145
+ */
146
+ /**
147
+ * The one expression every normaliser is built from.
148
+ *
149
+ * `js` below is compiled from this string, and `jsSource` embeds the same string
150
+ * into the emitted gate — so the in-process implementation and the shipped one
151
+ * are not "kept in sync", they are the same characters. That distinction is not
152
+ * pedantic: the previous version had a hand-typed copy in each JS gate and they
153
+ * had already drifted. `NORMALISE_CONTRACT.js(undefined)` returned `" undefined "`
154
+ * while the emitted gate returned `" "`, because one wrote `String(s)` and the
155
+ * other `String(s == null ? '' : s)`.
156
+ */
157
+ const NORMALISE_JS_EXPR = "' ' + String(s == null ? '' : s).replace(/\\s+/g, ' ') + ' '";
158
+ export const NORMALISE_CONTRACT = {
159
+ /**
160
+ * JS: produce the string patterns are matched against.
161
+ *
162
+ * Compiled from {@link NORMALISE_JS_EXPR} rather than written out, so it
163
+ * cannot diverge from what the gates run. `unknown` in, because callers pass
164
+ * whatever a harness put in its payload.
165
+ */
166
+ js: new Function('s', `return ${NORMALISE_JS_EXPR}`),
167
+ /** The JS function, emitted verbatim into every JavaScript gate. */
168
+ jsSource: `function intuticNormalise(s) {\n return ${NORMALISE_JS_EXPR};\n}`,
169
+ /**
170
+ * The Python function, emitted into the Open WebUI filter.
171
+ *
172
+ * `re.sub(r"\s+", " ", …)` rather than the more idiomatic `" ".join(s.split())`,
173
+ * and the difference is the one a test caught: `split()` **strips** leading and
174
+ * trailing whitespace, while the JS `replace(/\s+/g, ' ')` collapses it to a
175
+ * single space and keeps it. So `" a b "` normalised to `" a b "` in Python
176
+ * and `" a b "` in JS and bash. Both match the same patterns, since patterns
177
+ * use single-space boundaries — but "happens to be equivalent" is not the
178
+ * property this contract claims, and the next edit would not have been so lucky.
179
+ *
180
+ * Python's `\s` on a `str` covers Unicode whitespace (U+00A0, U+3000), matching
181
+ * JS. The shell fallback cannot, which is why SHELL_EXTRACT pre-passes.
182
+ */
183
+ /** Requires `re` in scope — the emitted filter imports it. */
184
+ pySource: [
185
+ 'def _intutic_normalise(text):',
186
+ ' return " " + re.sub(r"\\s+", " ", str(text if text is not None else "")) + " "',
187
+ ].join('\n'),
188
+ /**
189
+ * Shell: pure parameter expansion — no subshell, no `tr`, no `python3`.
190
+ *
191
+ * **ASCII only, and deliberately second in line.** Bash cannot express a
192
+ * Unicode whitespace class, so this collapses only the C0 set; U+00A0 would
193
+ * survive it and a pattern written with a literal space would then miss. The
194
+ * bash gates are not exposed to that because `SHELL_EXTRACT` runs every field
195
+ * through Python's `" ".join(str(v).split())` *before* this ever sees it —
196
+ * which is Unicode-aware. This is the belt to that pair of braces, and saying
197
+ * so here is the point: an earlier version of this comment claimed the three
198
+ * normalisers were provably identical, and they were not.
199
+ */
200
+ shell: [
201
+ 'intutic_normalise() {',
202
+ ' local s="$1"',
203
+ " s=\"${s//[$'\\t\\n\\r\\v\\f']/ }\"",
204
+ ' while [ "$s" != "${s// / }" ]; do s="${s// / }"; done',
205
+ ' printf \' %s \' "$s"',
206
+ '}',
207
+ ].join('\n'),
208
+ };
209
+ /**
210
+ * Rejects any pattern that would not mean the same thing to `grep -E` and to
211
+ * JavaScript.
212
+ *
213
+ * Runs at module load rather than from a test, so a non-portable pattern breaks
214
+ * the import — every writer, every test, immediately — instead of breaking one
215
+ * test that someone might skip. The failure this prevents is not hypothetical:
216
+ * `cursorHooks` and `windsurfHooks` shipped `\s+` for a year.
217
+ *
218
+ * `\s`, `\d`, `\w` and `\b` are forbidden **even though GNU grep accepts them**,
219
+ * because GNU grep is not the only grep — busybox and BSD do not, and a customer
220
+ * running us in an Alpine image would get a gate that silently matches nothing.
221
+ */
222
+ export function assertPortableEre(source, id) {
223
+ const forbidden = [
224
+ [/\\[sSdDwWbB]/, 'GNU-only escape class (\\s, \\d, \\w, \\b) — normalisation means you can use a literal space'],
225
+ [
226
+ /\\[tnrvfxu0]/,
227
+ 'escape sequence (\\t, \\n, \\x41, …) — POSIX ERE does not define these, so ' +
228
+ 'each grep decides for itself whether \\t means a tab or the letter t. ' +
229
+ 'BSD grep happens to agree with JavaScript, which is exactly why this is ' +
230
+ 'checked rather than trusted. Normalisation removes every reason to need one.',
231
+ ],
232
+ [/\[\[:/, 'POSIX character class [[:alpha:]] — not implemented by JavaScript'],
233
+ [/\(\?/, 'lookaround or non-capturing group — not in POSIX ERE'],
234
+ [/\\[1-9]/, 'backreference — not in POSIX ERE'],
235
+ [/(^|[^\\])\{/, 'unescaped { — interval syntax differs; escape it as \\{ for a literal brace'],
236
+ [/(^|[^\\])[$^]/, 'anchor — patterns match a space-padded string, so use a literal space'],
237
+ [/\t/, 'tab — the .rules snapshot projection is tab-separated'],
238
+ [/'/, "single quote — patterns are emitted into single-quoted shell literals"],
239
+ ];
240
+ for (const [probe, why] of forbidden) {
241
+ if (probe.test(source))
242
+ throw new Error(`GuardPattern ${id}: ${why} (in ${JSON.stringify(source)})`);
243
+ }
244
+ // Must compile as a JS RegExp at all.
245
+ try {
246
+ new RegExp(source);
247
+ }
248
+ catch (err) {
249
+ throw new Error(`GuardPattern ${id}: not a valid RegExp — ${String(err)}`, { cause: err });
250
+ }
251
+ }
252
+ /** Validates the whole table at load: portable, uniquely identified, and
253
+ * carrying a real false-positive contract. */
254
+ function assertGuardTableSane(patterns) {
255
+ const seen = new Set();
256
+ for (const p of patterns) {
257
+ assertPortableEre(p.source, p.id);
258
+ if (seen.has(p.id))
259
+ throw new Error(`GuardPattern id is not unique: ${p.id}`);
260
+ seen.add(p.id);
261
+ if (p.matches.length < 1) {
262
+ throw new Error(`GuardPattern ${p.id}: needs at least one \`matches\` fixture. Nothing in ` +
263
+ `this repo checked that a pattern fires at all — a rule that matches ` +
264
+ `nothing satisfied the portability test, the false-positive test and ` +
265
+ `every gate assertion, because they only ever ask what it does NOT do.`);
266
+ }
267
+ for (const m of p.matches) {
268
+ const re = new RegExp(p.source, p.ignoreCase ? 'i' : '');
269
+ if (!re.test(NORMALISE_CONTRACT.js(m))) {
270
+ throw new Error(`GuardPattern ${p.id}: declared match ${JSON.stringify(m)} does not match ` +
271
+ `its own pattern. The fixture and the rule disagree at module load.`);
272
+ }
273
+ }
274
+ if (p.notMatches.length < 3) {
275
+ throw new Error(`GuardPattern ${p.id}: needs at least 3 notMatches. A guard with no ` +
276
+ `false-positive contract is a guard nobody has thought about failing.`);
277
+ }
278
+ }
279
+ return patterns;
280
+ }
281
+ /** Escapes a literal string for use inside a portable ERE. */
282
+ function ereLiteral(s) {
283
+ return s.replace(/[.[\]()*+?|\\{}^$]/g, (c) => '\\' + c);
284
+ }
285
+ /**
286
+ * Commands that disarm governance itself.
287
+ *
288
+ * The union of the nine hand-rolled `BYPASS_PATTERNS` lists, plus the two holes
289
+ * the union exposed: `cursorHooks`/`windsurfHooks` never guarded `.intutic` at
290
+ * all, and every list guarded only its own harness's env kill-switch.
291
+ */
292
+ export const GOVERNANCE_BYPASS_PATTERNS = assertGuardTableSane([
293
+ {
294
+ id: 'bypass.chflags_nouchg',
295
+ source: ' chflags .*nouchg',
296
+ severity: 'block',
297
+ reason: 'Governance bypass pattern: clearing the macOS immutable flag on a governance file',
298
+ rationale: 'Present in all nine writers. `chflags nouchg <hook>` is step one of ' +
299
+ 'editing a hook that was locked; step two is the edit, which the path ' +
300
+ 'guard would catch — so this is the earlier and clearer signal. Does not ' +
301
+ 'catch a `chflags` reached through a shell function or an alias.',
302
+ matches: [' chflags nouchg .intutic/hooks ', ' sudo chflags -R nouchg /x ', ' chflags nouchg a b '],
303
+ // Counter-examples must not name a protected path: those are blocked by the
304
+ // path rules, correctly, and would make this pattern look like it had a
305
+ // false positive it does not have.
306
+ notMatches: [' chflags uchg /tmp/locked.txt ', ' ls -lO /tmp ', ' echo chflags '],
307
+ },
308
+ {
309
+ id: 'bypass.chattr_immutable',
310
+ source: ' chattr .*-[a-zA-Z]*i',
311
+ severity: 'block',
312
+ reason: 'Governance bypass pattern: clearing the Linux immutable attribute on a governance file',
313
+ rationale: 'The Linux half of `chflags nouchg`. Matches `-i` in any flag cluster ' +
314
+ '(`-Ri`, `-R -i`). Deliberately does not require a path argument: the ' +
315
+ 'command is only ever run to make something writable.',
316
+ matches: [' chattr -i /x ', ' chattr -R -i /x ', ' sudo chattr -Ri /x '],
317
+ notMatches: [' chattr +i /x ', ' lsattr /x ', ' chattr -a /x '],
318
+ },
319
+ {
320
+ id: 'bypass.chmod_governance',
321
+ source: ' chmod .*(hooks|\\.intutic|\\.claude|\\.cursor)',
322
+ severity: 'block',
323
+ reason: 'Governance bypass pattern: changing permissions on a governance file',
324
+ rationale: 'Generalises the `chmod [0-9]*7[0-9]* .*hooks` regex that only ' +
325
+ '`cursorHooks` and `windsurfHooks` carried, and drops its mode-digit ' +
326
+ 'requirement — `chmod 000` disarms a hook just as well as `chmod 777`. ' +
327
+ 'Does not catch `chmod` on a governance path reached via a symlink.',
328
+ matches: [' chmod 777 .intutic/hooks/x ', ' chmod -R 000 .claude ', ' chmod +x .cursor/hooks.json '],
329
+ notMatches: [' chmod +x ./build.sh ', ' chmod 644 src/index.ts ', ' chmod -R 755 dist '],
330
+ },
331
+ {
332
+ id: 'bypass.env_kill_switch',
333
+ source: ' [A-Z][A-Z0-9_]*_HOOKS?=',
334
+ severity: 'block',
335
+ reason: 'Governance bypass pattern: setting a harness environment variable that disables its hooks',
336
+ rationale: 'One generic pattern replacing nine harness-specific ones. Every writer ' +
337
+ 'guarded only its own switch, so `CLAUDE_CODE_HOOKS=0` was blocked under ' +
338
+ 'Claude Code and free under the other eight — and a harness added ' +
339
+ 'tomorrow was guarded nowhere. Requires the underscore before HOOK so ' +
340
+ '`WEBHOOKS=` and `GITHOOKS=` stay legal. Does not catch `env -u`, ' +
341
+ '`unset`, or a switch whose name does not end in _HOOK/_HOOKS.',
342
+ matches: [' CLAUDE_CODE_HOOKS=0 x ', ' PI_PRE_TOOL_HOOK= x ', ' ANTIGRAVITY_PRE_TOOL_HOOK=/dev/null x '],
343
+ notMatches: [' WEBHOOKS=1 x ', ' GITHOOKS=off x ', ' export PATH=/usr/bin '],
344
+ },
345
+ ]);
346
+ /**
347
+ * Credential VALUES in the content an agent is about to write.
348
+ *
349
+ * The gap this closes, measured before it was built: the PreToolUse gate —
350
+ * the only enforcement point that runs before the harness executes a tool —
351
+ * matched tool name, target path and command, never content. A key the model
352
+ * had seen anywhere in context could be written into a config file and the
353
+ * gate had no rule that could notice; the proxy's DLP covers the same case
354
+ * only when LLM traffic actually routes through the proxy, so a
355
+ * hooks-installed-but-unproxied workspace had zero write-direction scanning.
356
+ * (GitGuardian's 2025 measurement of why this matters: AI-assisted commits
357
+ * shipped hardcoded secrets at 3.2% against a 1.5% human baseline.)
358
+ *
359
+ * Sources live in `@intutic/shared-types` (`secretPatterns.ts`) because the
360
+ * pre-commit scan in `tools/cli/src/lib/gitHooks.ts` greps the SAME shapes
361
+ * over the staged diff — two hand-kept copies of "what is a credential" is
362
+ * the drift this repo keeps paying for. Fixtures stay here, where the
363
+ * table's self-test runs.
364
+ *
365
+ * ## Severity: block, deliberately skipping the warn-first ramp
366
+ *
367
+ * DESTRUCTIVE_COMMAND_PATTERNS shipped at warn to earn promotion on measured
368
+ * false positives. These four ship at block because both halves of that
369
+ * trade differ: the patterns are prefix-anchored value shapes already
370
+ * hard-blocking in production on the MCP proxy's input path with no
371
+ * false-positive reports, and the failure being prevented — a credential in
372
+ * a file, then in a commit — is not undone by an undo (the key is burned;
373
+ * rotation is the only remedy). A warn that lets the write proceed IS the
374
+ * incident.
375
+ *
376
+ * ## What this deliberately does not catch
377
+ *
378
+ * A secret split across two Writes, base64/hex-encoded values, secrets
379
+ * assembled by concatenation in generated code, values reached through
380
+ * env-var indirection, and any credential format without a distinctive
381
+ * prefix. Those need entropy analysis and cross-call state that belong in
382
+ * the proxy's DLP, not in a gate that must stay fast and false-positive-free
383
+ * to keep its block tier trusted. This is the same floor-vs-DLP split the
384
+ * warn tier's own comment draws ("arguments and paths stay out — that is
385
+ * DLP's job"): the floor takes the four shapes precise enough to refuse on.
386
+ */
387
+ export const SECRET_CONTENT_PATTERNS = assertGuardTableSane((() => {
388
+ // Fixture material is runtime-assembled: the repo convention forbids
389
+ // contiguous credential-shaped literals in source, in every package.
390
+ const awsKey = 'AKIA' + 'B2C3D4E5F6G7H2J3';
391
+ const antKey = 'sk-ant-' + 'api03-abcdefghijklmnop';
392
+ const ghToken = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
393
+ const orKey = 'sk-or-v1-' + 'a1b2c3d4e5f6g7h8i9j0k1l2';
394
+ // vk_ + 32 lowercase hex (apiKeyService.ts: randomBytes(16).toString('hex'))
395
+ const vkKey = 'vk_' + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
396
+ // gwk_ + 64 lowercase hex (routes/gateways.ts: randomBytes(32).toString('hex'))
397
+ const gwkKey = 'gwk_' + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' + '9f8e7d6c5b4a39281f7e6d5c4b3a2910';
398
+ const stripeKey = 'sk_live_' + 'A1b2C3d4E5f6G7h8I9j0K1l2';
399
+ const rsaPemHeader = '-----BEGIN ' + 'RSA PRIVATE KEY-----';
400
+ const pemHeader = '-----BEGIN ' + 'PRIVATE KEY-----';
401
+ const fixtures = {
402
+ 'secrets.aws_access_key': {
403
+ matches: [`{"file_path":"config.ts","content":"const key = '${awsKey}'"}`],
404
+ notMatches: [
405
+ '{"content":"AKIA is the prefix AWS uses"}',
406
+ '{"content":"' + 'AKIA' + 'IOSFODNN7"}', // 9-char tail: too short
407
+ '{"content":"' + 'akia' + 'b2c3d4e5f6g7h2j3"}', // lowercase: not the format
408
+ ],
409
+ },
410
+ 'secrets.anthropic_api_key': {
411
+ matches: [`{"content":"ANTHROPIC_API_KEY=${antKey}"}`],
412
+ notMatches: [
413
+ '{"content":"sk-ant- keys must never be committed"}',
414
+ '{"content":"' + 'sk-ant-' + 'short"}',
415
+ '{"content":"ask-antelope-20-questions-about-keys"}',
416
+ ],
417
+ },
418
+ 'secrets.github_token': {
419
+ matches: [`{"content":"GITHUB_TOKEN: ${ghToken}"}`],
420
+ notMatches: [
421
+ '{"content":"ghp_ prefixed tokens are PATs"}',
422
+ '{"content":"' + 'ghp_' + 'tooShort123"}',
423
+ '{"content":"' + 'ghx_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"}',
424
+ ],
425
+ },
426
+ 'secrets.private_key_pem': {
427
+ matches: [`{"content":"${rsaPemHeader}"}`, `{"content":"${pemHeader}"}`],
428
+ notMatches: [
429
+ '{"content":"-----BEGIN CERTIFICATE-----"}',
430
+ '{"content":"-----BEGIN PUBLIC KEY-----"}',
431
+ '{"content":"the PRIVATE KEY header is five dashes"}',
432
+ ],
433
+ },
434
+ 'secrets.openrouter_api_key': {
435
+ matches: [`{"content":"OPENROUTER_API_KEY=${orKey}"}`],
436
+ notMatches: [
437
+ '{"content":"sk-or-v1- is the OpenRouter prefix"}',
438
+ '{"content":"' + 'sk-or-v1-' + 'short"}',
439
+ '{"content":"' + 'sk-or-v2-' + 'a1b2c3d4e5f6g7h8i9j0k1l2"}', // wrong version tag
440
+ ],
441
+ },
442
+ 'secrets.intutic_virtual_key': {
443
+ matches: [`{"content":"INTUTIC_API_KEY=${vkKey}"}`],
444
+ notMatches: [
445
+ '{"content":"vk_ prefixes every Intutic virtual key"}',
446
+ '{"content":"' + 'vk_' + 'tooshort"}',
447
+ // 31 hex chars, one short of the fixed 32 — must not match
448
+ '{"content":"' + 'vk_' + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d"}',
449
+ ],
450
+ },
451
+ 'secrets.intutic_gateway_token': {
452
+ matches: [`{"content":"gateway registered with token ${gwkKey}"}`],
453
+ notMatches: [
454
+ '{"content":"gwk_ prefixes every gateway token"}',
455
+ '{"content":"' + 'gwk_' + 'tooshort"}',
456
+ // 63 hex chars, one short of the fixed 64 — must not match
457
+ '{"content":"' + 'gwk_' + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' + '9f8e7d6c5b4a39281f7e6d5c4b3a291"}',
458
+ ],
459
+ },
460
+ 'secrets.stripe_secret_key': {
461
+ matches: [`{"content":"STRIPE_SECRET_KEY=${stripeKey}"}`],
462
+ notMatches: [
463
+ '{"content":"sk_live_ is the Stripe live-mode prefix"}',
464
+ '{"content":"' + 'sk_live_' + 'short"}',
465
+ '{"content":"' + 'sk_test_' + 'A1b2C3d4E5f6G7h8I9j0K1l2"}', // test mode, not live
466
+ ],
467
+ },
468
+ };
469
+ return SECRET_VALUE_PATTERNS.map((p) => {
470
+ const f = fixtures[p.id];
471
+ if (!f)
472
+ throw new Error(`secretPatterns entry ${p.id} has no gate fixtures`);
473
+ return {
474
+ id: p.id,
475
+ source: p.source,
476
+ subject: 'content',
477
+ severity: 'block',
478
+ reason: `${p.description} detected in the content this call would write. ` +
479
+ 'Writing a live credential into a file is one commit away from ' +
480
+ 'shipping it; reference it from the environment instead.',
481
+ rationale: 'See SECRET_CONTENT_PATTERNS. Value shape, not access shape: fires ' +
482
+ 'on the credential itself appearing in writable content. Does not ' +
483
+ 'catch encoded, split, or concatenation-assembled values — those ' +
484
+ "are the proxy DLP's job.",
485
+ matches: f.matches,
486
+ notMatches: f.notMatches,
487
+ };
488
+ });
489
+ })());
490
+ /**
491
+ * Writes and edits targeting the agent-skill surface — `.agents/skills/**`
492
+ * and `.claude/skills/**`.
493
+ *
494
+ * TD-358 (updated 2026-08-18): these two entries stay `warn` and stay
495
+ * compiled into {@link staticFloorPatterns} — that part of the original
496
+ * decision is unchanged. What changed is what that tier assignment now
497
+ * *means*: it is the DEGRADED-MODE baseline, not the intended steady state.
498
+ * The steady state, everywhere a valid policy snapshot has landed, is `block`
499
+ * — shipped as `skill_surface.*.tier` entries through
500
+ * `services/sync-daemon/src/lib/policySnapshot.ts`'s `buildSnapshotRules`,
501
+ * gated behind `SKILL_SURFACE_TIER_SEVERITY`. See that constant's doc comment
502
+ * for why skill-directory *path* writes no longer sit behind the same
503
+ * false-positive-rate justification skill-CONTENT scanning still does — this
504
+ * entry only covers the half that stayed at `warn`.
505
+ *
506
+ * The two design choices below are for different reasons and worth
507
+ * separating:
508
+ *
509
+ * - **Why the FLOOR copy stays `warn`:** a compiled-in rule ships on every
510
+ * machine at once and can only be changed by a release — no one-sync-cycle
511
+ * undo if it turns out wrong. That is exactly the property a degraded-mode
512
+ * baseline needs (safe to run forever, unconditionally, on a machine that
513
+ * has never talked to the control plane) and exactly the property the
514
+ * steady-state decision does not need to have, since it lives in the
515
+ * snapshot and can be retracted in a cycle. Blocking here as well would
516
+ * collapse that distinction and reintroduce the un-retractable blast radius
517
+ * the snapshot channel exists to avoid.
518
+ * - **Why a floor copy exists at all, unlike `DESTRUCTIVE_COMMAND_PATTERNS`:**
519
+ * this pattern set is NOT content-conditional — it does not run
520
+ * `scanSkillContent` or judge what a skill file says, only WHERE a write
521
+ * targets. That is a deterministic, zero-ambiguity match (a write's target
522
+ * path either is or is not under `.agents/skills/` or `.claude/skills/`),
523
+ * so a workspace with no control-plane connectivity (offline, pre-`connect`,
524
+ * or a snapshot that failed its digest/workspace check) still gets an
525
+ * advisory signal from the moment the gate is written, rather than falling
526
+ * back to nothing.
527
+ *
528
+ * `subject: 'target'` deliberately, not `'any'`: this matches the tool's own
529
+ * path argument (`file_path`/`path`/…, the same extraction every Write/Edit
530
+ * gate already does), not a bare mention in a shell command. A `cat` or `grep`
531
+ * of a skill file is a read, not a write, and is not what this pattern
532
+ * exists to flag — narrower than `UNIVERSAL_PROTECTED_PATHS`'s "any mention"
533
+ * rule on purpose, since that rule blocks and this one only advises.
534
+ *
535
+ * A future promotion to `block` on skill CONTENT (not path) is a different,
536
+ * later, and still-open decision — it needs the corpus measurement TD-358
537
+ * describes, which this change does not supply. It should ship through the
538
+ * same policy-snapshot channel the way this path-tier promotion and
539
+ * `DESTRUCTIVE_COMMAND_PATTERNS` both do, so it too is retractable in a sync
540
+ * cycle rather than a release if the measurement turns out wrong.
541
+ */
542
+ export const SKILL_SURFACE_PATTERNS = assertGuardTableSane([
543
+ {
544
+ id: 'skill_surface.agents_skills_write',
545
+ source: '\\.agents/skills/',
546
+ subject: 'target',
547
+ severity: 'warn',
548
+ reason: 'Write or edit targets a .agents/skills/** skill file — advisory only, pending a ' +
549
+ 'false-positive measurement of scanSkillContent against real skill markdown (TD-358)',
550
+ rationale: 'Deterministic path match, not a content judgement — see the pattern-set doc comment ' +
551
+ 'above. Does not catch a write reached through a shell redirect (subject is `target`, ' +
552
+ 'not `any`), a symlink, or a relative walk such as ../.agents/skills/x.',
553
+ matches: [
554
+ ' .agents/skills/my-skill/SKILL.md ',
555
+ ' .agents/skills/foo/resources/data.json ',
556
+ ' /Users/dev/project/.agents/skills/bar/SKILL.md ',
557
+ ],
558
+ notMatches: [
559
+ ' .agents/rules.md ',
560
+ ' src/agents/skills/legacy.ts ',
561
+ ' README.md ',
562
+ ' .claude/skills/other-skill/SKILL.md ',
563
+ ],
564
+ },
565
+ {
566
+ id: 'skill_surface.claude_skills_write',
567
+ source: '\\.claude/skills/',
568
+ subject: 'target',
569
+ severity: 'warn',
570
+ reason: 'Write or edit targets a .claude/skills/** skill file — advisory only, pending a ' +
571
+ 'false-positive measurement of scanSkillContent against real skill markdown (TD-358)',
572
+ rationale: 'The Claude Code counterpart to skill_surface.agents_skills_write; same limits.',
573
+ matches: [
574
+ ' .claude/skills/my-skill/SKILL.md ',
575
+ ' .claude/skills/foo/resources/data.json ',
576
+ ' /Users/dev/project/.claude/skills/bar/SKILL.md ',
577
+ ],
578
+ notMatches: [
579
+ ' .claude/settings.json ',
580
+ ' .claude/commands/foo.md ',
581
+ ' src/claude/skills/legacy.ts ',
582
+ ' .agents/skills/other-skill/SKILL.md ',
583
+ ],
584
+ },
585
+ ]);
586
+ /**
587
+ * Commands that destroy the machine or its data irrecoverably.
588
+ *
589
+ * TD-309: none of these were blocked by any harness. They are shipped through
590
+ * the policy snapshot rather than compiled into the gate, and at `warn` before
591
+ * `block` — see the note on `severity` below.
592
+ *
593
+ * ## What is deliberately allowed
594
+ *
595
+ * `rm -rf ./`, `rm -rf .`, `rm -rf *`, `rm -rf build/`, `rm -rf node_modules`
596
+ * and `rm -rf /home/…` are all ordinary developer commands and all pass. That
597
+ * is not an oversight to tighten later. A guard that blocks routine work
598
+ * teaches the developer to run `chflags nouchg` on the hook — the exact bypass
599
+ * `GOVERNANCE_BYPASS_PATTERNS` exists to stop. Blocking too much manufactures
600
+ * the adversary the rest of this file defends against.
601
+ *
602
+ * ## What no pattern here catches
603
+ *
604
+ * Shell redirection to a raw device (`cat /dev/zero > /dev/sda`), obfuscation
605
+ * (`eval "$(printf cm0gLXJmIC8= | base64 -d)"`), anything reached through a
606
+ * script file rather than typed as a command, and any of these run by a process
607
+ * the agent spawns rather than by the agent. This tier is a speed bump against
608
+ * accident and casual bypass. Containment is the proxy and OS permissions.
609
+ */
610
+ export const DESTRUCTIVE_COMMAND_PATTERNS = assertGuardTableSane(
611
+ // Every rule in this tier is about a shell command, so the subject is stamped
612
+ // here rather than repeated eleven times — one place to be wrong instead of
613
+ // eleven, which is the whole argument of this module.
614
+ [
615
+ {
616
+ id: 'destructive.rm_rf_root',
617
+ source: ' rm( +-[a-zA-Z-]+)+ +/( |\\*)',
618
+ severity: 'block',
619
+ reason: 'Recursive delete of the filesystem root',
620
+ rationale: 'The trailing `( |\\*)` after the root slash is what separates this from ' +
621
+ 'TD-309\'s false positive: `rm -rf /home/me/build` has a character other ' +
622
+ 'than a space or a star after the slash and is allowed. Requires at least ' +
623
+ 'one flag, so a bare `rm /` (which fails anyway) does not match.',
624
+ matches: [
625
+ ' rm -rf / ',
626
+ ' rm -rf / --no-preserve-root ',
627
+ ' sudo rm -rf / ',
628
+ ' rm -rf /* ',
629
+ ' cd /tmp && rm -fr / ',
630
+ ],
631
+ notMatches: [
632
+ ' rm -rf /home/me/project/build ',
633
+ ' rm -rf ./dist ',
634
+ ' rm -rf * ',
635
+ ' rm -rf node_modules ',
636
+ ' rm -rf /tmp/scratch ',
637
+ ' rm -rf .cache ',
638
+ ],
639
+ },
640
+ {
641
+ id: 'destructive.mkfs_device',
642
+ source: ' mkfs[a-z0-9.]* .*/dev/',
643
+ severity: 'block',
644
+ reason: 'Formatting a block device',
645
+ rationale: 'Covers `mkfs.ext4 /dev/sda1` and `mkfs -t ext4 /dev/sdb` in one pattern ' +
646
+ 'by not caring what sits between the verb and the device. Does not catch ' +
647
+ 'mkfs against a loop-mounted image file, which is a legitimate thing to do.',
648
+ matches: [' mkfs.ext4 /dev/sda1 ', ' mkfs -t ext4 /dev/sdb ', ' sudo mkfs.xfs /dev/nvme0n1 '],
649
+ notMatches: [' mkfs.ext4 disk.img ', ' ls /dev/sda ', ' cat /proc/mounts '],
650
+ },
651
+ {
652
+ id: 'destructive.dd_raw_device',
653
+ source: ' dd .*of=/dev/',
654
+ severity: 'block',
655
+ reason: 'Writing a raw image over a block device',
656
+ rationale: 'Keys on the output operand only. `dd if=/dev/sda of=backup.img` reads a ' +
657
+ 'device and is allowed; `of=/dev/sda` writes one and is not. Does not ' +
658
+ 'catch `dd` with the operands reordered onto separate normalised tokens ' +
659
+ 'in a way that puts `of=` before the device — there is no such form.',
660
+ matches: [' dd if=/dev/zero of=/dev/sda ', ' sudo dd if=x.img of=/dev/disk2 bs=1m ', ' dd bs=4M of=/dev/sdb '],
661
+ notMatches: [' dd if=/dev/sda of=backup.img ', ' dd if=/dev/urandom of=./noise ', ' dd --help '],
662
+ },
663
+ {
664
+ id: 'destructive.block_device_wipe',
665
+ source: ' (shred|wipefs|blkdiscard) .*/dev/',
666
+ severity: 'block',
667
+ reason: 'Wiping a block device',
668
+ rationale: 'The three verbs whose whole purpose is unrecoverable erasure. `shred` on ' +
669
+ 'a regular file is allowed — that is what it is for; only `/dev/` makes it ' +
670
+ 'unrecoverable at device scope.',
671
+ matches: [' shred /dev/sda ', ' wipefs -a /dev/sdb ', ' sudo blkdiscard /dev/nvme0n1 '],
672
+ notMatches: [' shred -u secret.txt ', ' wipefs --help ', ' ls /dev/ '],
673
+ },
674
+ {
675
+ id: 'destructive.chmod_recursive_root',
676
+ source: ' chmod +-[a-zA-Z]*R[a-zA-Z]* +[0-7]+ +/( |\\*)',
677
+ severity: 'block',
678
+ reason: 'Recursive permission change across the filesystem root',
679
+ rationale: 'Same root-anchoring as rm_rf_root. Requires an explicit recursive flag, ' +
680
+ 'so a non-recursive `chmod 777 /` (which affects one directory and is ' +
681
+ 'recoverable) is allowed.',
682
+ matches: [' chmod -R 777 / ', ' sudo chmod -Rv 755 / ', ' chmod -R 000 /* '],
683
+ notMatches: [' chmod -R 755 /home/me/app ', ' chmod -R 644 ./src ', ' chmod 777 / '],
684
+ },
685
+ {
686
+ id: 'destructive.chown_recursive_root',
687
+ source: ' chown +-[a-zA-Z]*R[a-zA-Z]* +[a-zA-Z0-9_.:-]+ +/( |\\*)',
688
+ severity: 'block',
689
+ reason: 'Recursive ownership change across the filesystem root',
690
+ rationale: 'The companion to chmod_recursive_root; `chown -R me /` is the more ' +
691
+ 'common accident of the two because it is what a misquoted `$HOME` ' +
692
+ 'expands to. Same limits.',
693
+ matches: [' chown -R me / ', ' sudo chown -R root:wheel / ', ' chown -Rh nobody /* '],
694
+ notMatches: [' chown -R me /home/me ', ' chown -R node:node ./app ', ' chown me file.txt '],
695
+ },
696
+ {
697
+ id: 'destructive.fork_bomb',
698
+ source: ':\\(\\) *\\{ *:\\|:',
699
+ severity: 'block',
700
+ reason: 'Fork bomb',
701
+ rationale: 'The canonical `:(){ :|:& };:` and its spacing variants. Matches the ' +
702
+ 'definition, not the invocation, so it fires before the function exists. ' +
703
+ 'Does not catch a fork bomb written with any other function name — this ' +
704
+ 'is a guard against paste, not against intent.',
705
+ matches: [':(){ :|:& };:', ' :() { :|: & }; : ', ' echo x; :(){ :|:& };: '],
706
+ notMatches: [' echo :() ', ' func() { echo hi; } ', ' test() { return 0; } '],
707
+ },
708
+ // ── warn tier ──────────────────────────────────────────────────────────
709
+ {
710
+ id: 'destructive.curl_pipe_shell',
711
+ source: ' (curl|wget) .*\\| *(sudo )?(ba|z|k|d)?sh',
712
+ severity: 'warn',
713
+ reason: 'Piping a downloaded script straight into a shell',
714
+ rationale: 'Warn, not block, and the reason is empirical rather than principled: ' +
715
+ 'this is how a large share of legitimate developer tooling installs, ' +
716
+ 'including ours. Blocking it blocks onboarding. Flagged so the shape is ' +
717
+ 'visible in telemetry.',
718
+ matches: [' curl -fsSL https://x.sh | sh ', ' wget -qO- https://x | sudo bash ', ' curl x | zsh '],
719
+ notMatches: [' curl -o x.sh https://x ', ' curl https://api/x | jq . ', ' wget https://x.tar.gz '],
720
+ },
721
+ {
722
+ id: 'destructive.sql_drop',
723
+ source: ' (drop +(table|database|schema)|truncate +table) ',
724
+ ignoreCase: true,
725
+ severity: 'warn',
726
+ reason: 'Destructive SQL statement',
727
+ rationale: 'Demoted from the hook gate\'s hard block. Against a local development ' +
728
+ 'database this is what a migration looks like, and the gate cannot tell a ' +
729
+ 'dev DSN from a production one — the proxy can, and that is where a block ' +
730
+ 'belongs. Warn keeps the signal without owning a decision it lacks the ' +
731
+ 'context to make.',
732
+ matches: [' DROP TABLE users ', ' drop database app ', ' TRUNCATE TABLE events '],
733
+ notMatches: [' SELECT * FROM users ', ' echo drop it ', ' git stash drop '],
734
+ },
735
+ {
736
+ id: 'destructive.git_history_loss',
737
+ source: ' git .*(reset +--hard|clean +-[a-zA-Z]*f|push +.*--force)',
738
+ severity: 'warn',
739
+ reason: 'Git command that discards uncommitted or remote work',
740
+ rationale: 'Recoverable from the reflog in most cases, which is why it is not a ' +
741
+ 'block — but `git clean -xfd` takes untracked files the reflog never saw. ' +
742
+ 'Warn is the honest tier for "usually recoverable".',
743
+ matches: [' git reset --hard HEAD~3 ', ' git clean -xfd ', ' git push --force origin main '],
744
+ notMatches: [' git reset HEAD~1 ', ' git clean -n ', ' git push origin main '],
745
+ },
746
+ ].map((p) => ({ ...p, subject: 'command' })));
747
+ /**
748
+ * Shell-command guards derived from {@link UNIVERSAL_PROTECTED_PATHS}.
749
+ *
750
+ * Derived rather than written out so that adding a protected path extends the
751
+ * command guard in all thirteen gates automatically. Hand-maintaining the two
752
+ * lists in parallel is how they diverged the first time.
753
+ *
754
+ * Each path becomes a bare-mention block, matching the strictest existing
755
+ * behaviour (`claudeCodeHooks` already blocked any command containing
756
+ * `.claude/settings.json`). That is stricter than a verb list would be, and it
757
+ * is chosen deliberately: narrowing to `rm|mv|chmod|…` would *shrink* the set
758
+ * of calls Claude Code blocks today, and this change is only safe to reason
759
+ * about if it never does that. The cost is that `cat .intutic/hooks/x` is
760
+ * refused as well as `rm` — documented here because it will surface as a
761
+ * support question.
762
+ */
763
+ /**
764
+ * Computed at module load, not per call.
765
+ *
766
+ * It used to build and validate on every invocation, which meant a malformed
767
+ * derived pattern surfaced at emit time rather than at import — the opposite of
768
+ * what the other two tables do, and the whole argument for validating at load.
769
+ */
770
+ const PROTECTED_PATH_SHELL_PATTERNS = (() => assertGuardTableSane(UNIVERSAL_PROTECTED_PATHS.map((p) => ({
771
+ id: `path.${p.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
772
+ source: ereLiteral(p),
773
+ severity: 'block',
774
+ reason: `Shell command references the governance-protected path ${p}`,
775
+ rationale: `Derived from UNIVERSAL_PROTECTED_PATHS. Blocks any mention, not just a ` +
776
+ `mutating verb, because that is what claudeCodeHooks already did and ` +
777
+ `narrowing it would reduce today's protection. Does not catch the path ` +
778
+ `reached through a variable, a symlink, or a relative walk such as ` +
779
+ `../${p}.`,
780
+ matches: [` rm -rf ${p} `, ` printf x > ${p} `, ` mv ${p} /tmp/x `],
781
+ notMatches: [' ls -la . ', ' git status ', ' npm run build '],
782
+ }))))();
783
+ export function protectedPathShellPatterns() {
784
+ return PROTECTED_PATH_SHELL_PATTERNS;
785
+ }
786
+ /** Every compiled-in guard: the static floor. Destructive patterns are **not**
787
+ * here — they ship through the policy snapshot so they can be retracted
788
+ * without a release. `SKILL_SURFACE_PATTERNS` IS here, deliberately, despite
789
+ * also being new: unlike the destructive tier it is a deterministic path
790
+ * match with no content judgement to get wrong, so there is no measurement
791
+ * risk to hold it back from — see its own doc comment. As of TD-358's
792
+ * block-tier promotion these two entries are the DEGRADED-MODE baseline
793
+ * only: the steady state is `block`, delivered through the policy snapshot
794
+ * (`SKILL_SURFACE_TIER_SEVERITY` in `lib/policySnapshot.ts`) and additive on
795
+ * top of these, exactly the way the destructive tier layers on top of this
796
+ * same floor. */
797
+ export function staticFloorPatterns() {
798
+ return [
799
+ ...GOVERNANCE_BYPASS_PATTERNS,
800
+ ...SECRET_CONTENT_PATTERNS,
801
+ ...SKILL_SURFACE_PATTERNS,
802
+ ...protectedPathShellPatterns(),
803
+ ];
804
+ }
805
+ //# sourceMappingURL=protectedPaths.js.map