@aitne/daemon 0.1.4 → 0.1.7

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 (272) hide show
  1. package/dist/adapters/notification-manager.d.ts +12 -0
  2. package/dist/adapters/notification-manager.d.ts.map +1 -1
  3. package/dist/adapters/notification-manager.js +39 -1
  4. package/dist/adapters/notification-manager.js.map +1 -1
  5. package/dist/api/routes/agent.d.ts.map +1 -1
  6. package/dist/api/routes/agent.js +7 -0
  7. package/dist/api/routes/agent.js.map +1 -1
  8. package/dist/api/routes/commands.d.ts.map +1 -1
  9. package/dist/api/routes/commands.js +16 -13
  10. package/dist/api/routes/commands.js.map +1 -1
  11. package/dist/api/routes/context.d.ts.map +1 -1
  12. package/dist/api/routes/context.js +13 -2
  13. package/dist/api/routes/context.js.map +1 -1
  14. package/dist/api/routes/dashboard.d.ts.map +1 -1
  15. package/dist/api/routes/dashboard.js +28 -0
  16. package/dist/api/routes/dashboard.js.map +1 -1
  17. package/dist/api/routes/fs.d.ts +23 -0
  18. package/dist/api/routes/fs.d.ts.map +1 -0
  19. package/dist/api/routes/fs.js +156 -0
  20. package/dist/api/routes/fs.js.map +1 -0
  21. package/dist/api/routes/fs.logic.d.ts +62 -0
  22. package/dist/api/routes/fs.logic.d.ts.map +1 -0
  23. package/dist/api/routes/fs.logic.js +137 -0
  24. package/dist/api/routes/fs.logic.js.map +1 -0
  25. package/dist/api/routes/health.d.ts.map +1 -1
  26. package/dist/api/routes/health.js +4 -2
  27. package/dist/api/routes/health.js.map +1 -1
  28. package/dist/api/routes/integrations.d.ts.map +1 -1
  29. package/dist/api/routes/integrations.js +8 -6
  30. package/dist/api/routes/integrations.js.map +1 -1
  31. package/dist/api/routes/metrics.d.ts +1 -0
  32. package/dist/api/routes/metrics.d.ts.map +1 -1
  33. package/dist/api/routes/metrics.js +24 -0
  34. package/dist/api/routes/metrics.js.map +1 -1
  35. package/dist/api/routes/observations.d.ts.map +1 -1
  36. package/dist/api/routes/observations.js +538 -25
  37. package/dist/api/routes/observations.js.map +1 -1
  38. package/dist/api/routes/skills.d.ts +9 -1
  39. package/dist/api/routes/skills.d.ts.map +1 -1
  40. package/dist/api/routes/skills.js +38 -16
  41. package/dist/api/routes/skills.js.map +1 -1
  42. package/dist/api/routes/wiki.d.ts +4 -0
  43. package/dist/api/routes/wiki.d.ts.map +1 -0
  44. package/dist/api/routes/wiki.js +1075 -0
  45. package/dist/api/routes/wiki.js.map +1 -0
  46. package/dist/api/server.d.ts +13 -0
  47. package/dist/api/server.d.ts.map +1 -1
  48. package/dist/api/server.js +27 -1
  49. package/dist/api/server.js.map +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +26 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/core/agent-core.d.ts +25 -0
  54. package/dist/core/agent-core.d.ts.map +1 -1
  55. package/dist/core/agent-core.js.map +1 -1
  56. package/dist/core/backends/backend-router.d.ts +5 -1
  57. package/dist/core/backends/backend-router.d.ts.map +1 -1
  58. package/dist/core/backends/backend-router.js +10 -1
  59. package/dist/core/backends/backend-router.js.map +1 -1
  60. package/dist/core/backends/claude-code-core.d.ts.map +1 -1
  61. package/dist/core/backends/claude-code-core.js +62 -4
  62. package/dist/core/backends/claude-code-core.js.map +1 -1
  63. package/dist/core/backends/claude-tool-collection.d.ts +1 -1
  64. package/dist/core/backends/claude-tool-collection.d.ts.map +1 -1
  65. package/dist/core/backends/claude-tool-collection.js +327 -65
  66. package/dist/core/backends/claude-tool-collection.js.map +1 -1
  67. package/dist/core/backends/codex-core.d.ts.map +1 -1
  68. package/dist/core/backends/codex-core.js +36 -0
  69. package/dist/core/backends/codex-core.js.map +1 -1
  70. package/dist/core/backends/gemini-cli-core.d.ts +24 -5
  71. package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
  72. package/dist/core/backends/gemini-cli-core.js +62 -30
  73. package/dist/core/backends/gemini-cli-core.js.map +1 -1
  74. package/dist/core/backends/plan-presets.d.ts +3 -1
  75. package/dist/core/backends/plan-presets.d.ts.map +1 -1
  76. package/dist/core/backends/plan-presets.js +42 -2
  77. package/dist/core/backends/plan-presets.js.map +1 -1
  78. package/dist/core/bang-commands/commands-help.d.ts +5 -0
  79. package/dist/core/bang-commands/commands-help.d.ts.map +1 -0
  80. package/dist/core/bang-commands/commands-help.js +69 -0
  81. package/dist/core/bang-commands/commands-help.js.map +1 -0
  82. package/dist/core/bang-commands/commands-wiki.d.ts +75 -0
  83. package/dist/core/bang-commands/commands-wiki.d.ts.map +1 -0
  84. package/dist/core/bang-commands/commands-wiki.js +574 -0
  85. package/dist/core/bang-commands/commands-wiki.js.map +1 -0
  86. package/dist/core/bang-commands/index.d.ts +4 -2
  87. package/dist/core/bang-commands/index.d.ts.map +1 -1
  88. package/dist/core/bang-commands/index.js +15 -1
  89. package/dist/core/bang-commands/index.js.map +1 -1
  90. package/dist/core/bang-commands/registry.d.ts +47 -4
  91. package/dist/core/bang-commands/registry.d.ts.map +1 -1
  92. package/dist/core/bang-commands/registry.js +85 -15
  93. package/dist/core/bang-commands/registry.js.map +1 -1
  94. package/dist/core/context-builder.d.ts +17 -0
  95. package/dist/core/context-builder.d.ts.map +1 -1
  96. package/dist/core/context-builder.js +64 -6
  97. package/dist/core/context-builder.js.map +1 -1
  98. package/dist/core/daemon-api-cli.d.ts.map +1 -1
  99. package/dist/core/daemon-api-cli.js +50 -2
  100. package/dist/core/daemon-api-cli.js.map +1 -1
  101. package/dist/core/dispatcher-message-handler.d.ts.map +1 -1
  102. package/dist/core/dispatcher-message-handler.js +10 -0
  103. package/dist/core/dispatcher-message-handler.js.map +1 -1
  104. package/dist/core/dispatcher-morning-routine.d.ts.map +1 -1
  105. package/dist/core/dispatcher-morning-routine.js +17 -2
  106. package/dist/core/dispatcher-morning-routine.js.map +1 -1
  107. package/dist/core/dispatcher-result-processor.d.ts +23 -0
  108. package/dist/core/dispatcher-result-processor.d.ts.map +1 -1
  109. package/dist/core/dispatcher-result-processor.js +124 -5
  110. package/dist/core/dispatcher-result-processor.js.map +1 -1
  111. package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -1
  112. package/dist/core/dispatcher-scheduled-tasks.js +114 -80
  113. package/dist/core/dispatcher-scheduled-tasks.js.map +1 -1
  114. package/dist/core/dispatcher-types.d.ts +116 -1
  115. package/dist/core/dispatcher-types.d.ts.map +1 -1
  116. package/dist/core/dispatcher-types.js.map +1 -1
  117. package/dist/core/dispatcher.d.ts +36 -0
  118. package/dist/core/dispatcher.d.ts.map +1 -1
  119. package/dist/core/dispatcher.js +94 -1
  120. package/dist/core/dispatcher.js.map +1 -1
  121. package/dist/core/integration-lifecycle.d.ts.map +1 -1
  122. package/dist/core/integration-lifecycle.js +6 -8
  123. package/dist/core/integration-lifecycle.js.map +1 -1
  124. package/dist/core/metrics.d.ts +127 -0
  125. package/dist/core/metrics.d.ts.map +1 -1
  126. package/dist/core/metrics.js +256 -1
  127. package/dist/core/metrics.js.map +1 -1
  128. package/dist/core/prompts.d.ts +2 -1
  129. package/dist/core/prompts.d.ts.map +1 -1
  130. package/dist/core/prompts.js +40 -0
  131. package/dist/core/prompts.js.map +1 -1
  132. package/dist/core/roadmap-validate.js +13 -1
  133. package/dist/core/roadmap-validate.js.map +1 -1
  134. package/dist/core/routine-acquisition-plan.d.ts +51 -0
  135. package/dist/core/routine-acquisition-plan.d.ts.map +1 -1
  136. package/dist/core/routine-acquisition-plan.js +111 -12
  137. package/dist/core/routine-acquisition-plan.js.map +1 -1
  138. package/dist/core/routine-fetch-window-retry.d.ts +109 -0
  139. package/dist/core/routine-fetch-window-retry.d.ts.map +1 -0
  140. package/dist/core/routine-fetch-window-retry.js +210 -0
  141. package/dist/core/routine-fetch-window-retry.js.map +1 -0
  142. package/dist/core/routine-fetch-window-runner.d.ts +258 -32
  143. package/dist/core/routine-fetch-window-runner.d.ts.map +1 -1
  144. package/dist/core/routine-fetch-window-runner.js +1115 -185
  145. package/dist/core/routine-fetch-window-runner.js.map +1 -1
  146. package/dist/core/routine-windows.d.ts +19 -4
  147. package/dist/core/routine-windows.d.ts.map +1 -1
  148. package/dist/core/routine-windows.js +47 -0
  149. package/dist/core/routine-windows.js.map +1 -1
  150. package/dist/core/scheduler.d.ts +50 -2
  151. package/dist/core/scheduler.d.ts.map +1 -1
  152. package/dist/core/scheduler.js +88 -7
  153. package/dist/core/scheduler.js.map +1 -1
  154. package/dist/core/skill-curation/declarations.d.ts.map +1 -1
  155. package/dist/core/skill-curation/declarations.js +11 -12
  156. package/dist/core/skill-curation/declarations.js.map +1 -1
  157. package/dist/core/skill-source-paths.d.ts +14 -0
  158. package/dist/core/skill-source-paths.d.ts.map +1 -0
  159. package/dist/core/skill-source-paths.js +82 -0
  160. package/dist/core/skill-source-paths.js.map +1 -0
  161. package/dist/core/skills-compiler.d.ts +18 -0
  162. package/dist/core/skills-compiler.d.ts.map +1 -1
  163. package/dist/core/skills-compiler.js +65 -18
  164. package/dist/core/skills-compiler.js.map +1 -1
  165. package/dist/core/skills-manifest.d.ts.map +1 -1
  166. package/dist/core/skills-manifest.js +46 -0
  167. package/dist/core/skills-manifest.js.map +1 -1
  168. package/dist/core/system-reset.d.ts +25 -0
  169. package/dist/core/system-reset.d.ts.map +1 -1
  170. package/dist/core/system-reset.js +47 -0
  171. package/dist/core/system-reset.js.map +1 -1
  172. package/dist/core/wiki/approval-queue.d.ts +31 -0
  173. package/dist/core/wiki/approval-queue.d.ts.map +1 -0
  174. package/dist/core/wiki/approval-queue.js +44 -0
  175. package/dist/core/wiki/approval-queue.js.map +1 -0
  176. package/dist/core/wiki/bridge.d.ts +74 -0
  177. package/dist/core/wiki/bridge.d.ts.map +1 -0
  178. package/dist/core/wiki/bridge.js +405 -0
  179. package/dist/core/wiki/bridge.js.map +1 -0
  180. package/dist/core/wiki/compile-lock.d.ts +42 -0
  181. package/dist/core/wiki/compile-lock.d.ts.map +1 -0
  182. package/dist/core/wiki/compile-lock.js +55 -0
  183. package/dist/core/wiki/compile-lock.js.map +1 -0
  184. package/dist/core/wiki/compile-preview.d.ts +8 -0
  185. package/dist/core/wiki/compile-preview.d.ts.map +1 -0
  186. package/dist/core/wiki/compile-preview.js +200 -0
  187. package/dist/core/wiki/compile-preview.js.map +1 -0
  188. package/dist/core/wiki/cost-estimate.d.ts +30 -0
  189. package/dist/core/wiki/cost-estimate.d.ts.map +1 -0
  190. package/dist/core/wiki/cost-estimate.js +243 -0
  191. package/dist/core/wiki/cost-estimate.js.map +1 -0
  192. package/dist/core/wiki/dispatcher.d.ts +48 -0
  193. package/dist/core/wiki/dispatcher.d.ts.map +1 -0
  194. package/dist/core/wiki/dispatcher.js +92 -0
  195. package/dist/core/wiki/dispatcher.js.map +1 -0
  196. package/dist/core/wiki/git-precompile.d.ts +86 -0
  197. package/dist/core/wiki/git-precompile.d.ts.map +1 -0
  198. package/dist/core/wiki/git-precompile.js +96 -0
  199. package/dist/core/wiki/git-precompile.js.map +1 -0
  200. package/dist/core/wiki/import-migrate.d.ts +38 -0
  201. package/dist/core/wiki/import-migrate.d.ts.map +1 -0
  202. package/dist/core/wiki/import-migrate.js +310 -0
  203. package/dist/core/wiki/import-migrate.js.map +1 -0
  204. package/dist/core/wiki/import-probe.d.ts +76 -0
  205. package/dist/core/wiki/import-probe.d.ts.map +1 -0
  206. package/dist/core/wiki/import-probe.js +245 -0
  207. package/dist/core/wiki/import-probe.js.map +1 -0
  208. package/dist/core/wiki/index-cache.d.ts +39 -0
  209. package/dist/core/wiki/index-cache.d.ts.map +1 -0
  210. package/dist/core/wiki/index-cache.js +152 -0
  211. package/dist/core/wiki/index-cache.js.map +1 -0
  212. package/dist/core/wiki/multi-url-dispatch.d.ts +52 -0
  213. package/dist/core/wiki/multi-url-dispatch.d.ts.map +1 -0
  214. package/dist/core/wiki/multi-url-dispatch.js +72 -0
  215. package/dist/core/wiki/multi-url-dispatch.js.map +1 -0
  216. package/dist/core/wiki/wiki-fts.d.ts +75 -0
  217. package/dist/core/wiki/wiki-fts.d.ts.map +1 -0
  218. package/dist/core/wiki/wiki-fts.js +265 -0
  219. package/dist/core/wiki/wiki-fts.js.map +1 -0
  220. package/dist/core/wiki/workspaces.d.ts +101 -0
  221. package/dist/core/wiki/workspaces.d.ts.map +1 -0
  222. package/dist/core/wiki/workspaces.js +352 -0
  223. package/dist/core/wiki/workspaces.js.map +1 -0
  224. package/dist/core/wiki/write-strategy.d.ts +70 -0
  225. package/dist/core/wiki/write-strategy.d.ts.map +1 -0
  226. package/dist/core/wiki/write-strategy.js +112 -0
  227. package/dist/core/wiki/write-strategy.js.map +1 -0
  228. package/dist/core/workdir.d.ts +8 -1
  229. package/dist/core/workdir.d.ts.map +1 -1
  230. package/dist/core/workdir.js +4 -1
  231. package/dist/core/workdir.js.map +1 -1
  232. package/dist/db/schema.d.ts.map +1 -1
  233. package/dist/db/schema.js +122 -0
  234. package/dist/db/schema.js.map +1 -1
  235. package/dist/db/wiki-store.d.ts +3 -0
  236. package/dist/db/wiki-store.d.ts.map +1 -0
  237. package/dist/db/wiki-store.js +7 -0
  238. package/dist/db/wiki-store.js.map +1 -0
  239. package/dist/index.js +87 -4
  240. package/dist/index.js.map +1 -1
  241. package/dist/messaging/setup-welcome-dm.d.ts +30 -0
  242. package/dist/messaging/setup-welcome-dm.d.ts.map +1 -0
  243. package/dist/messaging/setup-welcome-dm.js +86 -0
  244. package/dist/messaging/setup-welcome-dm.js.map +1 -0
  245. package/dist/messaging/url-extract.d.ts +8 -0
  246. package/dist/messaging/url-extract.d.ts.map +1 -0
  247. package/dist/messaging/url-extract.js +41 -0
  248. package/dist/messaging/url-extract.js.map +1 -0
  249. package/dist/observers/delegated-sync-worker.d.ts +33 -25
  250. package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
  251. package/dist/observers/delegated-sync-worker.js +38 -31
  252. package/dist/observers/delegated-sync-worker.js.map +1 -1
  253. package/dist/observers/imminent-event-scheduler.d.ts +20 -7
  254. package/dist/observers/imminent-event-scheduler.d.ts.map +1 -1
  255. package/dist/observers/imminent-event-scheduler.js +134 -29
  256. package/dist/observers/imminent-event-scheduler.js.map +1 -1
  257. package/dist/safety/always-disallowed.d.ts +65 -0
  258. package/dist/safety/always-disallowed.d.ts.map +1 -1
  259. package/dist/safety/always-disallowed.js +106 -10
  260. package/dist/safety/always-disallowed.js.map +1 -1
  261. package/dist/safety/audit.d.ts +46 -1
  262. package/dist/safety/audit.d.ts.map +1 -1
  263. package/dist/safety/audit.js +79 -16
  264. package/dist/safety/audit.js.map +1 -1
  265. package/dist/safety/risk-classifier.d.ts.map +1 -1
  266. package/dist/safety/risk-classifier.js +29 -0
  267. package/dist/safety/risk-classifier.js.map +1 -1
  268. package/dist/settings/runtime-settings.d.ts +12 -1
  269. package/dist/settings/runtime-settings.d.ts.map +1 -1
  270. package/dist/settings/runtime-settings.js +59 -1
  271. package/dist/settings/runtime-settings.js.map +1 -1
  272. package/package.json +2 -2
@@ -31,7 +31,7 @@ import { dirname, resolve as resolvePath, isAbsolute } from "node:path";
31
31
  import { getContextDir } from "../../config.js";
32
32
  import { readIntegrations } from "../../db/integrations-store.js";
33
33
  import { recordAbsoluteBlockAudit } from "../../safety/absolute-block-audit.js";
34
- import { classifyAbsoluteBlock } from "../../safety/always-disallowed.js";
34
+ import { classifyAbsoluteBlock, stripBashHeredocs, stripBashStringContent, } from "../../safety/always-disallowed.js";
35
35
  import { createLogger } from "../../logging.js";
36
36
  import { computeDelegatedClaudeTools, computeNativeClaudeTools } from "./claude-probe.js";
37
37
  import { isPathInsideOrEqual, shellPathForms } from "../path-compat.js";
@@ -107,6 +107,66 @@ function expandHomeForms(token, home) {
107
107
  return home;
108
108
  return token;
109
109
  }
110
+ /**
111
+ * Decide whether a shell token (after `expandHomeForms` normalisation)
112
+ * resembles a filesystem path argument. Replaces the older "contains
113
+ * `/` or `\`" filter, which false-positived on quoted JSON bodies and
114
+ * HTTP header values whenever the session cwd was inside the data dir
115
+ * (production cwd is `<dataDir>/agent-sessions/<id>`):
116
+ *
117
+ * - `Content-Type: application/json` → `/` inside `application/json`
118
+ * was treated as a path separator, the token was resolved relative
119
+ * to the (data-dir-internal) cwd, the resulting candidate landed
120
+ * inside `absDataDir`, and the hook blocked an otherwise benign
121
+ * `curl -X PATCH -H '...'` invocation.
122
+ * - `'{"content":"line1\nline2"}'` → the literal `\` in `\n` triggered
123
+ * the same data-dir resolution path even though the token is a JSON
124
+ * payload, not a filename.
125
+ *
126
+ * The shape rules below are deliberately positive (a token must look
127
+ * like a path) rather than negative (skip if it contains JSON chars):
128
+ *
129
+ * 1. Absolute on POSIX (`/foo`) — also catches tokens that
130
+ * `expandHomeForms` rewrote from `~` / `$HOME` / `${HOME}` forms.
131
+ * 2. Explicit relative anchor (`./foo`, `../foo`, exactly `.` / `..`).
132
+ * 3. Unresolved home / env-var prefix that survived `expandHomeForms`
133
+ * (e.g. `~user/foo`, `$OTHER/foo` when the variable is unknown).
134
+ * Treating these as path candidates is a defensive belt — the
135
+ * static analysis can't know what they expand to at runtime, so
136
+ * err on the side of forwarding them through the data-dir check.
137
+ * 4. Bare multi-segment path made of filename-safe characters
138
+ * (`context/today.md`, `agent-sessions/foo/bar`). The character
139
+ * class deliberately excludes whitespace, `:`, `=`, `{`, `}`,
140
+ * `"`, `'`, `` ` ``, `?`, `*`, `<`, `>` — all of which appear in
141
+ * header values, JSON bodies, and query strings but never in
142
+ * well-formed filename segments.
143
+ *
144
+ * URL-shaped tokens (`http://...`) are filtered by the caller before
145
+ * this helper runs, so rule 1 / rule 4 cannot misfire on them.
146
+ */
147
+ function looksLikePathArg(token) {
148
+ if (token.length === 0)
149
+ return false;
150
+ // Rules 1 + 2 — POSIX-absolute or anchored-relative.
151
+ if (token.startsWith("/"))
152
+ return true;
153
+ if (token === "." || token === ".." || token.startsWith("./") || token.startsWith("../")) {
154
+ return true;
155
+ }
156
+ // Rule 3 — unresolved home / env-var prefix.
157
+ if (token.startsWith("~") || token.startsWith("$"))
158
+ return true;
159
+ // Rule 4 — bare relative path with filename-safe segments only.
160
+ // `[A-Za-z0-9_.\-+@]` is the segment alphabet — broad enough to
161
+ // cover typical project filenames (dashes, underscores, dots,
162
+ // version suffixes, `@scope/pkg` style) without admitting tokens
163
+ // that came from a JSON body or header value. The trailing `/?`
164
+ // tolerates a directory-shape suffix (`context/`).
165
+ if (/^[A-Za-z0-9_.\-+@]+(?:\/[A-Za-z0-9_.\-+@]+)+\/?$/.test(token)) {
166
+ return true;
167
+ }
168
+ return false;
169
+ }
110
170
  const logger = createLogger("claude-tool-collection");
111
171
  /** Default allowed-tools list when the dashboard override is unset. */
112
172
  export const CLAUDE_DEFAULT_ALLOWED_TOOLS = [
@@ -141,16 +201,56 @@ export const CLAUDE_DEFAULT_ALLOWED_TOOLS = [
141
201
  * `delegatedToolCount` and `nativeToolCount` makes a misconfigured flip
142
202
  * diagnosable without re-running the resolver.
143
203
  */
144
- export function getAllowedTools(config, webSearchEnabled, delegatedTools = [], nativeTools = []) {
204
+ export function getAllowedTools(config, webSearchEnabled, delegatedTools = [], nativeTools = [],
205
+ // WIKI_BUILDER_DESIGN.md §4.3 — wiki.ingest_url turns need WebFetch on
206
+ // top of the default surface to read external pages (the `Bash(curl *)`
207
+ // PreToolUse hook keeps curl restricted to localhost). Gated on the
208
+ // same `!allowedToolsOverride` clause as `webSearchEnabled` so a user
209
+ // who configured a custom override gets the override verbatim — they
210
+ // are expected to add `WebFetch` themselves if they need it (matches
211
+ // the WebSearch contract; documented in /settings/wiki).
212
+ wikiUrlFetchEnabled = false,
213
+ // Wiki sessions must write only through the daemon Wiki API
214
+ // (`POST /api/wiki/<ws>/files/...`) — every wiki.* process key has a
215
+ // skill body and the wiki-agent profile both stating "no `Write` /
216
+ // `Edit` against the vault." Skill frontmatter `allowed-tools` is
217
+ // human-facing metadata and does NOT propagate into the SDK's
218
+ // session-level allowlist, so without this hard strip a wiki turn
219
+ // can bypass the API path-classifier, the agent_actions audit row,
220
+ // and the result-processor's write-verifier by Writing a vault
221
+ // path directly. Pass true for any `processKey.startsWith("wiki.")`.
222
+ wikiApiOnlyWrites = false) {
145
223
  const base = config.allowedToolsOverride ?? [...CLAUDE_DEFAULT_ALLOWED_TOOLS];
146
224
  const merged = new Set(base);
147
225
  if (!config.allowedToolsOverride && webSearchEnabled) {
148
226
  merged.add("WebSearch");
149
227
  }
228
+ if (!config.allowedToolsOverride && wikiUrlFetchEnabled) {
229
+ merged.add("WebFetch");
230
+ }
150
231
  for (const tool of delegatedTools)
151
232
  merged.add(tool);
152
233
  for (const tool of nativeTools)
153
234
  merged.add(tool);
235
+ // Claude Code 2.1+ defers large MCP manifests (`mcp__claude_ai_*`) behind
236
+ // `ToolSearch` — the tools appear by name but their schemas are not
237
+ // loaded until the agent calls `ToolSearch select:<name>`. Without
238
+ // ToolSearch allowed, the model cannot invoke any unioned MCP tool and
239
+ // silently falls back to denied surfaces (raw Bash, WebFetch), surfacing
240
+ // as "Bash and WebFetch denied" failure DMs from native/delegated-same
241
+ // routines. Mirrors the same widening already applied by
242
+ // `composePrePassAllowedTools` (pre-pass), `CLAUDE_PROBE_TOOLS_PROMPT`
243
+ // (probe), and `claude-delegated.ts` (cross-backend proxy). Unioned even
244
+ // under `allowedToolsOverride` for the same orthogonality reason the
245
+ // MCP tools themselves bypass the override above — silently dropping
246
+ // ToolSearch while keeping the MCP names defeats the widening.
247
+ if (delegatedTools.length > 0 || nativeTools.length > 0) {
248
+ merged.add("ToolSearch");
249
+ }
250
+ if (wikiApiOnlyWrites) {
251
+ merged.delete("Write");
252
+ merged.delete("Edit");
253
+ }
154
254
  return Array.from(merged);
155
255
  }
156
256
  /**
@@ -239,9 +339,60 @@ export function getSessionDeniedTools(mcpContext) {
239
339
  */
240
340
  export function buildSecurityHooks(deps, allowMode = false) {
241
341
  const { config, writeTracker, getMcpContext } = deps;
342
+ // Per-Bash-hook block logging. The SDK's `dontAsk` mode silently
343
+ // denies any Bash command that doesn't match an allowed prefix —
344
+ // no tool_result, no error feedback — and PreToolUse hooks that
345
+ // return `block` emit a generic reason that the agent often
346
+ // misinterprets as "Bash is blocked entirely." Without this log,
347
+ // diagnosing a failed wiki / context update means guessing at the
348
+ // command the model produced. The line is logged at warn level
349
+ // (one per actual block, not per call) so steady-state cost is
350
+ // negligible; the cmd is truncated to 400 chars to keep secrets
351
+ // out of logs and the entry parseable.
352
+ const wrapBashHook = (hookName, inner) => async (input) => {
353
+ const result = await inner(input);
354
+ if (result && result.decision === "block") {
355
+ const toolInput = input.tool_input;
356
+ const cmd = toolInput?.command ?? "";
357
+ logger.warn({
358
+ hook: hookName,
359
+ reason: result.reason,
360
+ cmd: cmd.slice(0, 400),
361
+ }, "Bash hook block");
362
+ }
363
+ return result;
364
+ };
242
365
  const bashCurlHook = async (input) => {
243
366
  const toolInput = input.tool_input;
244
367
  const cmd = toolInput?.command ?? "";
368
+ // Three views of the command, each used by a different class of check:
369
+ //
370
+ // - `cmd` (raw) — the initial `\bcurl\b` keyword presence test.
371
+ // Must see literal token text so a `-d
372
+ // '{"text":"see curl docs"}'` body doesn't
373
+ // suppress the hook entirely.
374
+ // - `scan` — substring scans for flag PRESENCE (chained
375
+ // curl, --next, --proxy, -L, -o, -c, -b, etc.).
376
+ // Strips single-quoted strings AND heredoc
377
+ // bodies so prose inside a JSON payload like
378
+ // "set -o pipefail in scripts" cannot trip
379
+ // the flag detectors.
380
+ // - `tokenizable` — tokenizer walks and value extractors
381
+ // (top-level URL collection, `-d @file` arg
382
+ // walker, `-o <file>` path capture). Strips
383
+ // ONLY heredoc bodies (which are stdin
384
+ // payload, never shell argv) and PRESERVES
385
+ // quoted strings so the value extractors can
386
+ // still recognise quoted URL targets and
387
+ // quoted file paths.
388
+ //
389
+ // The wiki.ingest_url skill is the canonical case where this matters:
390
+ // it POSTs an article body via `-d @- <<'JSON' … JSON`, and the body
391
+ // routinely contains the source URL ("Source: https://news.example.com/…").
392
+ // Before this layered design the URL extractor scanned `cmd`, found
393
+ // the body URL, and falsely blocked with "Multiple URL targets".
394
+ const scan = stripBashStringContent(cmd);
395
+ const tokenizable = stripBashHeredocs(cmd);
245
396
  if (/\bcurl\b/.test(cmd)) {
246
397
  // ── Multi-request defenses (run BEFORE host/port loop) ─────────
247
398
  // The SDK `allowedTools` glob is a prefix match against the full
@@ -260,7 +411,7 @@ export function buildSecurityHooks(deps, allowMode = false) {
260
411
  // pipeline counts as ONE curl (only the `curl` token itself
261
412
  // is matched; the leading `jq` is not). Two or more anchored
262
413
  // `curl` tokens → chained invocation → block.
263
- const chainedCurlMatches = cmd.match(/(?:^|[;&|`\n]|\$\()\s*curl\b/g) ?? [];
414
+ const chainedCurlMatches = scan.match(/(?:^|[;&|`\n]|\$\()\s*curl\b/g) ?? [];
264
415
  if (chainedCurlMatches.length > 1) {
265
416
  return {
266
417
  decision: "block",
@@ -275,8 +426,8 @@ export function buildSecurityHooks(deps, allowMode = false) {
275
426
  // because both URLs hit the same host:port, but curl issues
276
427
  // one HTTP request per `--next` separator. Same exfil shape
277
428
  // as chained curl, different syntax.
278
- if (/(?:^|\s)--next(?:[\s=]|$)/.test(cmd)
279
- || /(?:^|\s)-:(?:\s|$)/.test(cmd)) {
429
+ if (/(?:^|\s)--next(?:[\s=]|$)/.test(scan)
430
+ || /(?:^|\s)-:(?:\s|$)/.test(scan)) {
280
431
  return {
281
432
  decision: "block",
282
433
  reason: "curl --next / -: (URL multiplexing) is not allowed "
@@ -285,29 +436,40 @@ export function buildSecurityHooks(deps, allowMode = false) {
285
436
  }
286
437
  // 3. Multi-positional URL targets — `curl URL1 URL2 -X PUT -d
287
438
  // @body` sends the same options to BOTH URLs sequentially,
288
- // which `--next` blocking above does not catch. Tokenize at
289
- // the top level (outside paired single / double quotes) and
290
- // collect tokens whose payload starts with `http://` or
291
- // `https://`. URLs embedded inside `-d '…'` / `-H "…"`
292
- // strings are NOT counted because their token starts with
293
- // the quote, not with `http`.
439
+ // which `--next` blocking above does not catch. Tokenize the
440
+ // heredoc-stripped command and collect tokens that are URLs:
441
+ //
442
+ // - Bare URL token: `curl http://localhost:8321/api/x`
443
+ // - Fully single-quoted URL: `curl 'http://localhost:8321/api/x'`
444
+ // - Fully double-quoted URL: `curl "http://localhost:8321/api/x"`
445
+ //
446
+ // URLs that appear INSIDE a quoted body / header value
447
+ // (e.g. `-d '{"link":"https://example.com"}'` or
448
+ // `-H "X-Source: https://example.com"`) are NOT counted: the
449
+ // surrounding quoted token carries other characters, so the
450
+ // "entire content is the URL" patterns below do not match.
294
451
  //
295
- // This also becomes the *only* set of URLs the host/port
296
- // check below runs against URLs that legitimately appear
297
- // inside JSON / markdown bodies (e.g. an architecture-section
298
- // description referencing `https://github.com/foo/bar`) are
299
- // NOT host-checked. The previous broad regex
300
- // `cmd.match(/https?:\/\/[^\s'"]+/g)` would reject such
301
- // requests because the body URL was non-localhost. Heuristic
302
- // limitation: URL strings unquoted as data values are a rare
303
- // false-positive surface and would be host-checked; the
304
- // alternative is full shell tokenization (out of scope).
452
+ // Heredoc bodies (`<<'JSON' JSON`) are stripped from
453
+ // `tokenizable` above because they are stdin payload, never
454
+ // shell argv without that strip, the routine wiki.ingest_url
455
+ // shape of "store an article body that mentions other URLs"
456
+ // would trip this multi-URL rule on the body URL.
305
457
  const topLevelTokenRe = /'[^']*'|"[^"]*"|[^'"\s]+/g;
306
458
  const topLevelUrls = [];
307
459
  let tokenMatch;
308
- while ((tokenMatch = topLevelTokenRe.exec(cmd)) !== null) {
309
- if (/^https?:\/\//.test(tokenMatch[0])) {
310
- topLevelUrls.push(tokenMatch[0]);
460
+ while ((tokenMatch = topLevelTokenRe.exec(tokenizable)) !== null) {
461
+ const token = tokenMatch[0];
462
+ if (/^https?:\/\//.test(token)) {
463
+ topLevelUrls.push(token);
464
+ continue;
465
+ }
466
+ // Fully-quoted URL token: the WHOLE content between matching
467
+ // single or double quotes must be the URL — anything else
468
+ // (JSON body, header value) starts with non-URL characters
469
+ // after the quote.
470
+ const quoted = /^(['"])(https?:\/\/[^'"\s]+)\1$/.exec(token);
471
+ if (quoted) {
472
+ topLevelUrls.push(quoted[2]);
311
473
  }
312
474
  }
313
475
  if (topLevelUrls.length > 1) {
@@ -351,7 +513,7 @@ export function buildSecurityHooks(deps, allowMode = false) {
351
513
  // Connection-override flags — host/proxy/socket redirection that
352
514
  // would let curl reach something other than the configured loopback
353
515
  // HTTP endpoint.
354
- if (/--connect-to|--resolve|--config\b|(?:^|\s)-[a-zA-Z]*K|--proxy\b|(?:^|\s)-[a-zA-Z]*x|--socks|--unix-socket|--abstract-unix-socket|--interface\b|--local-port\b/.test(cmd)) {
516
+ if (/--connect-to|--resolve|--config\b|(?:^|\s)-[a-zA-Z]*K|--proxy\b|(?:^|\s)-[a-zA-Z]*x|--socks|--unix-socket|--abstract-unix-socket|--interface\b|--local-port\b/.test(scan)) {
355
517
  return {
356
518
  decision: "block",
357
519
  reason: "curl connection override flags not allowed " +
@@ -381,7 +543,7 @@ export function buildSecurityHooks(deps, allowMode = false) {
381
543
  // `-L`. Same shape applied to every short-flag below — without it
382
544
  // an attacker can stuff the dangerous letter into a benign-looking
383
545
  // flag bundle like `-fs<X>` and bypass the deny rule entirely.
384
- if (/(?:^|\s)(?:--upload-file\b|-[a-zA-Z]*T(?:\s|=|$))/.test(cmd)) {
546
+ if (/(?:^|\s)(?:--upload-file\b|-[a-zA-Z]*T(?:\s|=|$))/.test(scan)) {
385
547
  return {
386
548
  decision: "block",
387
549
  reason: "curl --upload-file / -T not allowed — would read arbitrary files",
@@ -391,24 +553,89 @@ export function buildSecurityHooks(deps, allowMode = false) {
391
553
  // from stdin, used by pipelines like `echo $body | curl ... -d @-`).
392
554
  // Block `@<anything-other-than-stdin-marker>`. The lookahead
393
555
  // `(?!-["']?(?:\s|$))` lets `@-`, `@-"`, `@-'`, `@- ` through.
394
- if (/(?:^|\s)(?:--data(?:-binary|-raw|-urlencode|-ascii)?|--data|-d)\s+["']?@(?!-["']?(?:\s|$))/.test(cmd)) {
395
- return {
396
- decision: "block",
397
- reason: "curl -d/--data with `@file` syntax not allowed reads local files",
398
- };
399
- }
400
- // Same for the `=` separator form: `-d=@/path`, `--data-binary=@/path`.
401
- if (/(?:^|\s)(?:--data(?:-binary|-raw|-urlencode|-ascii)?|--data|-d)=["']?@(?!-["']?(?:\s|$))/.test(cmd)) {
402
- return {
403
- decision: "block",
404
- reason: "curl -d=/--data= with `@file` syntax not allowed reads local files",
405
- };
556
+ // `-d / --data* / -F / --form` value-content checks. The previous
557
+ // regex `(?:^|\s)…\s+["']?@(?!-…)` matched the @-file syntax with
558
+ // the value attached, which meant a JSON BODY containing literal
559
+ // text like ` -d @<chars>` (an agent journal entry that quotes a
560
+ // shell example) also tripped it. Conversely, switching to the
561
+ // `scan` form alone loses single-quoted attack content (the
562
+ // legitimate `-d '@/etc/passwd'` form): scan strips the body and
563
+ // the regex no longer sees the `@`.
564
+ //
565
+ // The walker below is value-aware: tokenize the command (already
566
+ // quote-aware via the same regex used for URL extraction), find
567
+ // every `-d` / `--data*` / `-F` / `--form` flag token, recover the
568
+ // unquoted value (either after `=` in the same token or in the
569
+ // adjacent token), and reject if the value's FIRST CHARACTER is
570
+ // `@` (with the canonical stdin marker `@-` excluded). That
571
+ // discriminates:
572
+ // - `-d '@/etc/passwd'` → value starts with `@` and is not `@-`
573
+ // → block (matches the original protection).
574
+ // - `-d '{"content":"a -d @x b"}'` → value starts with `{` →
575
+ // allow (the body contains @ but is not an @-file argument).
576
+ //
577
+ // For `-F`/`--form`, the file-read syntax is `name=@file` /
578
+ // `name=<file` (first `=` in the value followed by `@` or `<`),
579
+ // which the same walker can test in the value once recovered.
580
+ // Adjacent-token merge: bash treats `-d='value'` as the SINGLE
581
+ // argument `-d=value` (the quote is stripped, the bare prefix and
582
+ // the quoted body are joined when there is no whitespace between
583
+ // them). The regex pass below splits the two pieces — track each
584
+ // match's start vs. the previous match's end and concatenate any
585
+ // pair with no whitespace gap. A composite token is treated as
586
+ // "effectively bare" if either constituent was bare, so the flag
587
+ // walker still recognises `-d='@/path'` as a `-d=` flag carrying
588
+ // the value `@/path` (which the regex form `["']?@` used to catch).
589
+ const argRe = /'([^']*)'|"([^"]*)"|`([^`]*)`|([^\s'"`]+)/g;
590
+ const argList = [];
591
+ let am;
592
+ let lastEnd = -1;
593
+ // Walks `tokenizable` (heredoc-stripped) so a body line like
594
+ // `prose mentioning -d @/etc/passwd` cannot be parsed as a real
595
+ // `-d` flag carrying an `@file` value. Single / double quotes are
596
+ // preserved so the `quoted` discriminator still tracks user intent
597
+ // correctly for the dataFlag / formFlag checks below.
598
+ while ((am = argRe.exec(tokenizable)) !== null) {
599
+ const value = am[1] ?? am[2] ?? am[3] ?? am[4] ?? "";
600
+ const quoted = am[4] === undefined;
601
+ if (am.index === lastEnd && argList.length > 0) {
602
+ const prev = argList[argList.length - 1];
603
+ prev.value = prev.value + value;
604
+ prev.quoted = prev.quoted && quoted;
605
+ }
606
+ else {
607
+ argList.push({ value, quoted });
608
+ }
609
+ lastEnd = argRe.lastIndex;
406
610
  }
407
- if (/(?:^|\s)(?:-F|--form)\s+\S*=[@<]/.test(cmd)) {
408
- return {
409
- decision: "block",
410
- reason: "curl -F/--form with `=@file` or `=<file` syntax not allowed — reads local files",
411
- };
611
+ const dataFlag = /^(?:--data(?:-binary|-raw|-urlencode|-ascii)?|--data|-d)(?:=(.*))?$/;
612
+ const formFlag = /^(?:--form|-F)(?:=(.*))?$/;
613
+ for (let i = 0; i < argList.length; i++) {
614
+ const tok = argList[i];
615
+ if (!tok || tok.quoted)
616
+ continue;
617
+ const dm = tok.value.match(dataFlag);
618
+ if (dm) {
619
+ const value = dm[1] ?? argList[i + 1]?.value ?? "";
620
+ if (value.length > 0 && value[0] === "@" && value !== "@-") {
621
+ return {
622
+ decision: "block",
623
+ reason: "curl -d/--data with `@file` syntax not allowed — reads local files",
624
+ };
625
+ }
626
+ continue;
627
+ }
628
+ const fm = tok.value.match(formFlag);
629
+ if (fm) {
630
+ const value = fm[1] ?? argList[i + 1]?.value ?? "";
631
+ // `name=@path` / `name=<path`: first `=` then `@` or `<`.
632
+ if (/^[^=\s]*=[@<]/.test(value)) {
633
+ return {
634
+ decision: "block",
635
+ reason: "curl -F/--form with `=@file` or `=<file` syntax not allowed — reads local files",
636
+ };
637
+ }
638
+ }
412
639
  }
413
640
  // File-write flags. The agent can land bytes anywhere on disk —
414
641
  // overwriting shims, ssh keys, shell rc files, etc. Daemon API is
@@ -433,11 +660,18 @@ export function buildSecurityHooks(deps, allowMode = false) {
433
660
  // containment. Quoted paths with spaces (`-o "my file"`) are
434
661
  // ALSO rejected so a denylist regex that stops at the space
435
662
  // inside the quotes cannot be smuggled past.
436
- const hasOutputFlag = /(?:^|\s)(?:--output(?:\b|=)|-[a-zA-Z]*o(?:\s|=|$))/.test(cmd);
663
+ // Flag PRESENCE detection runs on the scan (quote-stripped) command
664
+ // so a body containing prose like "set -o pipefail" does not falsely
665
+ // claim there is an output flag. The subsequent VALUE extraction
666
+ // reads `tokenizable` (heredoc-stripped, quotes preserved) so an
667
+ // earlier heredoc-body occurrence of `-o /etc/passwd` cannot be
668
+ // captured ahead of the real flag — while quoted paths like
669
+ // `-o "my file.pdf"` are still readable.
670
+ const hasOutputFlag = /(?:^|\s)(?:--output(?:\b|=)|-[a-zA-Z]*o(?:\s|=|$))/.test(scan);
437
671
  if (hasOutputFlag) {
438
672
  // Three capture-group alternatives so quoted paths with spaces
439
673
  // are caught — `[^\s'"]+` alone fails on `"my file"`.
440
- const valueMatch = cmd.match(/(?:^|\s)(?:--output(?:\s+|=)|-o(?:\s+|=))(?:"([^"]*)"|'([^']*)'|([^\s'"]+))/);
674
+ const valueMatch = tokenizable.match(/(?:^|\s)(?:--output(?:\s+|=)|-o(?:\s+|=))(?:"([^"]*)"|'([^']*)'|([^\s'"]+))/);
441
675
  const target = valueMatch?.[1] ?? valueMatch?.[2] ?? valueMatch?.[3] ?? "";
442
676
  const isSafeRelative = target.length > 0 &&
443
677
  !target.startsWith("/") &&
@@ -454,19 +688,19 @@ export function buildSecurityHooks(deps, allowMode = false) {
454
688
  };
455
689
  }
456
690
  }
457
- if (/(?:^|\s)(?:--remote-name(?:-all)?\b|-[a-zA-Z]*O(?:\s|=|$))/.test(cmd)) {
691
+ if (/(?:^|\s)(?:--remote-name(?:-all)?\b|-[a-zA-Z]*O(?:\s|=|$))/.test(scan)) {
458
692
  return {
459
693
  decision: "block",
460
694
  reason: "curl --remote-name/-O not allowed — would write to URL-derived path",
461
695
  };
462
696
  }
463
- if (/(?:^|\s)(?:--dump-header\b|-[a-zA-Z]*D(?:\s|=|$))/.test(cmd)) {
697
+ if (/(?:^|\s)(?:--dump-header\b|-[a-zA-Z]*D(?:\s|=|$))/.test(scan)) {
464
698
  return {
465
699
  decision: "block",
466
700
  reason: "curl --dump-header/-D not allowed — writes response headers to disk",
467
701
  };
468
702
  }
469
- if (/(?:^|\s)(?:--cookie-jar\b|-[a-zA-Z]*c(?:\s|=|$))/.test(cmd)) {
703
+ if (/(?:^|\s)(?:--cookie-jar\b|-[a-zA-Z]*c(?:\s|=|$))/.test(scan)) {
470
704
  return {
471
705
  decision: "block",
472
706
  reason: "curl --cookie-jar/-c not allowed — writes cookie state to disk",
@@ -479,20 +713,20 @@ export function buildSecurityHooks(deps, allowMode = false) {
479
713
  // `-b name=value` would require parsing the value; the simpler
480
714
  // safe stance is to refuse the flag outright since the daemon
481
715
  // API uses bearer tokens, not cookies.
482
- if (/(?:^|\s)(?:--cookie\b|-[a-zA-Z]*b(?:\s|=|$))/.test(cmd)) {
716
+ if (/(?:^|\s)(?:--cookie\b|-[a-zA-Z]*b(?:\s|=|$))/.test(scan)) {
483
717
  return {
484
718
  decision: "block",
485
719
  reason: "curl --cookie/-b not allowed — when the value is a path, " +
486
720
  "the file contents are sent as the Cookie header (file read).",
487
721
  };
488
722
  }
489
- if (/(?:^|\s)--trace(?:-ascii)?\b/.test(cmd)) {
723
+ if (/(?:^|\s)--trace(?:-ascii)?\b/.test(scan)) {
490
724
  return {
491
725
  decision: "block",
492
726
  reason: "curl --trace / --trace-ascii not allowed — writes protocol trace to disk",
493
727
  };
494
728
  }
495
- if (/(?:^|\s)(?:--write-out\b|-[a-zA-Z]*w(?:\s|=|$))/.test(cmd)) {
729
+ if (/(?:^|\s)(?:--write-out\b|-[a-zA-Z]*w(?:\s|=|$))/.test(scan)) {
496
730
  return {
497
731
  decision: "block",
498
732
  reason: "curl --write-out/-w not allowed — format strings include file/stderr sinks",
@@ -501,7 +735,7 @@ export function buildSecurityHooks(deps, allowMode = false) {
501
735
  // Cert / key file references. The daemon API is plain HTTP on
502
736
  // loopback; none of these flags are needed for legitimate
503
737
  // operation and they all read arbitrary files from disk.
504
- if (/(?:^|\s)(?:--cert\b|--key\b|--cacert\b|--capath\b|-[a-zA-Z]*E(?:\s|=|$))/.test(cmd)) {
738
+ if (/(?:^|\s)(?:--cert\b|--key\b|--cacert\b|--capath\b|-[a-zA-Z]*E(?:\s|=|$))/.test(scan)) {
505
739
  return {
506
740
  decision: "block",
507
741
  reason: "curl --cert/--key/--cacert/--capath/-E not allowed — read arbitrary files",
@@ -514,7 +748,7 @@ export function buildSecurityHooks(deps, allowMode = false) {
514
748
  // Combined-short-flag forms (`-fsSL`, `-vL`) are caught by the
515
749
  // `[a-zA-Z]*L` alternation; the literal `--location` and
516
750
  // `--location-trusted` long forms are matched explicitly.
517
- if (/(?:^|\s)(?:-[a-zA-Z]*L(?:\s|=|$)|--location(?:-trusted)?\b)/.test(cmd)) {
751
+ if (/(?:^|\s)(?:-[a-zA-Z]*L(?:\s|=|$)|--location(?:-trusted)?\b)/.test(scan)) {
518
752
  return {
519
753
  decision: "block",
520
754
  reason: "curl -L / --location not allowed — would follow redirects off localhost",
@@ -531,6 +765,14 @@ export function buildSecurityHooks(deps, allowMode = false) {
531
765
  // Narrow to THIS jq invocation's own args (up to the next pipe / chain op)
532
766
  // so that later pipeline stages are not inspected by the jq rules.
533
767
  //
768
+ // The match runs against `stripBashHeredocs(cmd)` so that prose inside
769
+ // a heredoc body (e.g. a wiki article that mentions "the jq env
770
+ // filter") cannot trip the env / -L / --slurpfile checks below.
771
+ // Quoted strings remain intact because the env-filter detector
772
+ // intentionally peers inside the single-quoted jq filter argument
773
+ // (jq syntax lives inside shell quotes, so blanket quote-stripping
774
+ // would lose the very thing we need to inspect).
775
+ //
534
776
  // Known approximation: `[^|;&]*` does not respect shell quoting, so a
535
777
  // jq filter with a `|` INSIDE a quoted expression (e.g. `jq 'env | keys'`)
536
778
  // will truncate `jqPart` at the first `|` regardless of whether that `|`
@@ -540,7 +782,7 @@ export function buildSecurityHooks(deps, allowMode = false) {
540
782
  // payloads are still blocked. The downside is slightly reduced precision
541
783
  // on benign expressions containing the jq `|` operator — those get
542
784
  // scanned only up to the first pipe, not their full extent.
543
- const jqMatch = cmd.match(/\bjq\b([^|;&]*)/);
785
+ const jqMatch = stripBashHeredocs(cmd).match(/\bjq\b([^|;&]*)/);
544
786
  if (!jqMatch)
545
787
  return { continue: true };
546
788
  const jqPart = jqMatch[0];
@@ -629,10 +871,17 @@ export function buildSecurityHooks(deps, allowMode = false) {
629
871
  // API is the sanctioned write channel.
630
872
  const absDataDir = resolvePath(config.dataDir);
631
873
  const realDataDir = realpathLenient(absDataDir);
874
+ // Use the quote/heredoc-stripped form for Layer 1 (substring) and
875
+ // Layer 3 (interpreter regex) so a JSON body or heredoc payload that
876
+ // legitimately contains the absolute context-dir path string, or the
877
+ // literal text `bash -c …`, does not trip these layers. Layer 2
878
+ // still uses `cmd` because its tokenizer is already quote-aware via
879
+ // `looksLikePathArg`.
880
+ const scan = stripBashStringContent(cmd);
632
881
  // ── Layer 1: substring match against well-known path forms ──
633
882
  const pathForms = shellPathForms(absContextDir, home);
634
883
  for (const form of pathForms) {
635
- if (cmd.includes(form)) {
884
+ if (scan.includes(form)) {
636
885
  return blockContextWrite(absContextDir, `substring match: ${form}`);
637
886
  }
638
887
  }
@@ -645,11 +894,21 @@ export function buildSecurityHooks(deps, allowMode = false) {
645
894
  const tokens = tokenizeShellCommand(cmd);
646
895
  for (const rawTok of tokens) {
647
896
  const tok = expandHomeForms(rawTok, home);
648
- if (!tok.includes("/") && !tok.includes("\\"))
649
- continue;
650
- // Skip URL-shaped tokens; they are not filesystem paths.
897
+ // Skip URL-shaped tokens; they are not filesystem paths. Must
898
+ // come before `looksLikePathArg` because `http://localhost/...`
899
+ // satisfies the "starts with `/`" rule once the scheme prefix
900
+ // is removed — and the bare-path rule too — but is never a
901
+ // filesystem reference.
651
902
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(tok))
652
903
  continue;
904
+ // The old filter (`!tok.includes("/") && !tok.includes("\\")`)
905
+ // forwarded any quoted token with a `/` or `\` into the data-dir
906
+ // resolution branch, which produced false positives on JSON
907
+ // bodies (`{"content":"a\nb"}`) and header values (`Content-Type:
908
+ // application/json`) whenever cwd lived under the data dir. See
909
+ // `looksLikePathArg` for the replacement rules.
910
+ if (!looksLikePathArg(tok))
911
+ continue;
653
912
  const candidate = isAbsolute(tok) ? tok : resolvePath(cwd, tok);
654
913
  const real = realpathLenient(candidate);
655
914
  const landsInsideContext = isPathInsideOrEqual(absContextDir, candidate) ||
@@ -671,8 +930,8 @@ export function buildSecurityHooks(deps, allowMode = false) {
671
930
  // SDK Write/Edit tools and the daemon API cover legitimate
672
931
  // file-touching use cases. Blocking the patterns themselves is the
673
932
  // only way to keep this hook's guarantees meaningful.
674
- if (/(?:^|[\s|;&])(?:bash|sh|zsh|ksh|dash|busybox)\s+-c\b/.test(cmd) ||
675
- /(?:^|[\s|;&])(?:python3?|node|ruby|perl|php|deno|bun)\s+-[ce]\b/.test(cmd)) {
933
+ if (/(?:^|[\s|;&])(?:bash|sh|zsh|ksh|dash|busybox)\s+-c\b/.test(scan) ||
934
+ /(?:^|[\s|;&])(?:python3?|node|ruby|perl|php|deno|bun)\s+-[ce]\b/.test(scan)) {
676
935
  return {
677
936
  decision: "block",
678
937
  reason: `Bash commands that invoke an interpreter with -c / -e are not ` +
@@ -814,12 +1073,15 @@ export function buildSecurityHooks(deps, allowMode = false) {
814
1073
  {
815
1074
  matcher: "Bash",
816
1075
  hooks: allowMode
817
- ? [bashContextWriteHook, bashAbsoluteBlockHook]
1076
+ ? [
1077
+ wrapBashHook("bashContextWriteHook", bashContextWriteHook),
1078
+ wrapBashHook("bashAbsoluteBlockHook", bashAbsoluteBlockHook),
1079
+ ]
818
1080
  : [
819
- bashCurlHook,
820
- bashJqHook,
821
- bashContextWriteHook,
822
- bashAbsoluteBlockHook,
1081
+ wrapBashHook("bashCurlHook", bashCurlHook),
1082
+ wrapBashHook("bashJqHook", bashJqHook),
1083
+ wrapBashHook("bashContextWriteHook", bashContextWriteHook),
1084
+ wrapBashHook("bashAbsoluteBlockHook", bashAbsoluteBlockHook),
823
1085
  ],
824
1086
  },
825
1087
  { matcher: "Write", hooks: [fileWriteHook, writeAbsoluteBlockHook] },