@aitne/daemon 0.1.3 → 0.1.4

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 (249) hide show
  1. package/dist/adapters/whatsapp-adapter.d.ts.map +1 -1
  2. package/dist/adapters/whatsapp-adapter.js +0 -1
  3. package/dist/adapters/whatsapp-adapter.js.map +1 -1
  4. package/dist/api/integration-route-gate.d.ts +15 -11
  5. package/dist/api/integration-route-gate.d.ts.map +1 -1
  6. package/dist/api/integration-route-gate.js +60 -23
  7. package/dist/api/integration-route-gate.js.map +1 -1
  8. package/dist/api/json-body.d.ts +22 -7
  9. package/dist/api/json-body.d.ts.map +1 -1
  10. package/dist/api/json-body.js +27 -8
  11. package/dist/api/json-body.js.map +1 -1
  12. package/dist/api/routes/agent.d.ts.map +1 -1
  13. package/dist/api/routes/agent.js +18 -0
  14. package/dist/api/routes/agent.js.map +1 -1
  15. package/dist/api/routes/backends.d.ts.map +1 -1
  16. package/dist/api/routes/backends.js +96 -1
  17. package/dist/api/routes/backends.js.map +1 -1
  18. package/dist/api/routes/books.js +1 -1
  19. package/dist/api/routes/books.js.map +1 -1
  20. package/dist/api/routes/context.d.ts.map +1 -1
  21. package/dist/api/routes/context.js +13 -1
  22. package/dist/api/routes/context.js.map +1 -1
  23. package/dist/api/routes/dashboard.d.ts.map +1 -1
  24. package/dist/api/routes/dashboard.js +75 -5
  25. package/dist/api/routes/dashboard.js.map +1 -1
  26. package/dist/api/routes/github.d.ts.map +1 -1
  27. package/dist/api/routes/github.js +38 -5
  28. package/dist/api/routes/github.js.map +1 -1
  29. package/dist/api/routes/integrations.d.ts +35 -6
  30. package/dist/api/routes/integrations.d.ts.map +1 -1
  31. package/dist/api/routes/integrations.js +191 -16
  32. package/dist/api/routes/integrations.js.map +1 -1
  33. package/dist/api/routes/mail.d.ts.map +1 -1
  34. package/dist/api/routes/mail.js +112 -46
  35. package/dist/api/routes/mail.js.map +1 -1
  36. package/dist/api/routes/observations.d.ts.map +1 -1
  37. package/dist/api/routes/observations.js +161 -8
  38. package/dist/api/routes/observations.js.map +1 -1
  39. package/dist/api/routes/setup-migrate.d.ts +9 -1
  40. package/dist/api/routes/setup-migrate.d.ts.map +1 -1
  41. package/dist/api/routes/setup-migrate.js +4 -2
  42. package/dist/api/routes/setup-migrate.js.map +1 -1
  43. package/dist/api/routes/skills.d.ts.map +1 -1
  44. package/dist/api/routes/skills.js +39 -1
  45. package/dist/api/routes/skills.js.map +1 -1
  46. package/dist/api/routes/voice.d.ts.map +1 -1
  47. package/dist/api/routes/voice.js +62 -4
  48. package/dist/api/routes/voice.js.map +1 -1
  49. package/dist/bootstrap/adapters.d.ts +109 -0
  50. package/dist/bootstrap/adapters.d.ts.map +1 -0
  51. package/dist/bootstrap/adapters.js +237 -0
  52. package/dist/bootstrap/adapters.js.map +1 -0
  53. package/dist/bootstrap/catchup.d.ts +23 -0
  54. package/dist/bootstrap/catchup.d.ts.map +1 -0
  55. package/dist/bootstrap/catchup.js +124 -0
  56. package/dist/bootstrap/catchup.js.map +1 -0
  57. package/dist/bootstrap/schedule-helpers.d.ts +18 -0
  58. package/dist/bootstrap/schedule-helpers.d.ts.map +1 -0
  59. package/dist/bootstrap/schedule-helpers.js +96 -0
  60. package/dist/bootstrap/schedule-helpers.js.map +1 -0
  61. package/dist/bootstrap/services.d.ts +60 -0
  62. package/dist/bootstrap/services.d.ts.map +1 -0
  63. package/dist/bootstrap/services.js +209 -0
  64. package/dist/bootstrap/services.js.map +1 -0
  65. package/dist/core/backends/backend-router.d.ts +23 -0
  66. package/dist/core/backends/backend-router.d.ts.map +1 -1
  67. package/dist/core/backends/backend-router.js +48 -3
  68. package/dist/core/backends/backend-router.js.map +1 -1
  69. package/dist/core/backends/claude-auth.d.ts +70 -0
  70. package/dist/core/backends/claude-auth.d.ts.map +1 -0
  71. package/dist/core/backends/claude-auth.js +198 -0
  72. package/dist/core/backends/claude-auth.js.map +1 -0
  73. package/dist/core/backends/claude-code-core.d.ts +47 -119
  74. package/dist/core/backends/claude-code-core.d.ts.map +1 -1
  75. package/dist/core/backends/claude-code-core.js +112 -1565
  76. package/dist/core/backends/claude-code-core.js.map +1 -1
  77. package/dist/core/backends/claude-delegated.d.ts +86 -0
  78. package/dist/core/backends/claude-delegated.d.ts.map +1 -0
  79. package/dist/core/backends/claude-delegated.js +801 -0
  80. package/dist/core/backends/claude-delegated.js.map +1 -0
  81. package/dist/core/backends/claude-errors.d.ts +39 -0
  82. package/dist/core/backends/claude-errors.d.ts.map +1 -0
  83. package/dist/core/backends/claude-errors.js +71 -0
  84. package/dist/core/backends/claude-errors.js.map +1 -0
  85. package/dist/core/backends/claude-probe.d.ts +103 -0
  86. package/dist/core/backends/claude-probe.d.ts.map +1 -0
  87. package/dist/core/backends/claude-probe.js +336 -0
  88. package/dist/core/backends/claude-probe.js.map +1 -0
  89. package/dist/core/backends/claude-tool-collection.d.ts +135 -0
  90. package/dist/core/backends/claude-tool-collection.d.ts.map +1 -0
  91. package/dist/core/backends/claude-tool-collection.js +831 -0
  92. package/dist/core/backends/claude-tool-collection.js.map +1 -0
  93. package/dist/core/backends/gemini-cli-core.d.ts +21 -0
  94. package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
  95. package/dist/core/backends/gemini-cli-core.js +84 -6
  96. package/dist/core/backends/gemini-cli-core.js.map +1 -1
  97. package/dist/core/backends/prompt-utils.d.ts +1 -0
  98. package/dist/core/backends/prompt-utils.d.ts.map +1 -1
  99. package/dist/core/backends/prompt-utils.js +60 -3
  100. package/dist/core/backends/prompt-utils.js.map +1 -1
  101. package/dist/core/context-builder.d.ts +36 -12
  102. package/dist/core/context-builder.d.ts.map +1 -1
  103. package/dist/core/context-builder.js +179 -89
  104. package/dist/core/context-builder.js.map +1 -1
  105. package/dist/core/dispatcher-date-utils.d.ts +49 -0
  106. package/dist/core/dispatcher-date-utils.d.ts.map +1 -0
  107. package/dist/core/dispatcher-date-utils.js +132 -0
  108. package/dist/core/dispatcher-date-utils.js.map +1 -0
  109. package/dist/core/dispatcher-error-handling.d.ts +159 -0
  110. package/dist/core/dispatcher-error-handling.d.ts.map +1 -0
  111. package/dist/core/dispatcher-error-handling.js +393 -0
  112. package/dist/core/dispatcher-error-handling.js.map +1 -0
  113. package/dist/core/dispatcher-hourly-check.d.ts +150 -0
  114. package/dist/core/dispatcher-hourly-check.d.ts.map +1 -0
  115. package/dist/core/dispatcher-hourly-check.js +665 -0
  116. package/dist/core/dispatcher-hourly-check.js.map +1 -0
  117. package/dist/core/dispatcher-message-handler.d.ts +170 -0
  118. package/dist/core/dispatcher-message-handler.d.ts.map +1 -0
  119. package/dist/core/dispatcher-message-handler.js +1054 -0
  120. package/dist/core/dispatcher-message-handler.js.map +1 -0
  121. package/dist/core/dispatcher-morning-routine.d.ts +169 -0
  122. package/dist/core/dispatcher-morning-routine.d.ts.map +1 -0
  123. package/dist/core/dispatcher-morning-routine.js +434 -0
  124. package/dist/core/dispatcher-morning-routine.js.map +1 -0
  125. package/dist/core/dispatcher-prompt.d.ts +107 -0
  126. package/dist/core/dispatcher-prompt.d.ts.map +1 -0
  127. package/dist/core/dispatcher-prompt.js +227 -0
  128. package/dist/core/dispatcher-prompt.js.map +1 -0
  129. package/dist/core/dispatcher-repository-helpers.d.ts +39 -0
  130. package/dist/core/dispatcher-repository-helpers.d.ts.map +1 -0
  131. package/dist/core/dispatcher-repository-helpers.js +86 -0
  132. package/dist/core/dispatcher-repository-helpers.js.map +1 -0
  133. package/dist/core/dispatcher-result-processor.d.ts +145 -0
  134. package/dist/core/dispatcher-result-processor.d.ts.map +1 -0
  135. package/dist/core/dispatcher-result-processor.js +414 -0
  136. package/dist/core/dispatcher-result-processor.js.map +1 -0
  137. package/dist/core/dispatcher-scheduled-tasks.d.ts +406 -0
  138. package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -0
  139. package/dist/core/dispatcher-scheduled-tasks.js +998 -0
  140. package/dist/core/dispatcher-scheduled-tasks.js.map +1 -0
  141. package/dist/core/dispatcher-types.d.ts +296 -0
  142. package/dist/core/dispatcher-types.d.ts.map +1 -0
  143. package/dist/core/dispatcher-types.js +106 -0
  144. package/dist/core/dispatcher-types.js.map +1 -0
  145. package/dist/core/dispatcher.d.ts +86 -610
  146. package/dist/core/dispatcher.d.ts.map +1 -1
  147. package/dist/core/dispatcher.js +293 -3542
  148. package/dist/core/dispatcher.js.map +1 -1
  149. package/dist/core/integration-health.d.ts +18 -10
  150. package/dist/core/integration-health.d.ts.map +1 -1
  151. package/dist/core/integration-health.js +31 -1
  152. package/dist/core/integration-health.js.map +1 -1
  153. package/dist/core/integration-lifecycle.d.ts +65 -0
  154. package/dist/core/integration-lifecycle.d.ts.map +1 -1
  155. package/dist/core/integration-lifecycle.js +167 -16
  156. package/dist/core/integration-lifecycle.js.map +1 -1
  157. package/dist/core/integration-main-backend.d.ts +40 -0
  158. package/dist/core/integration-main-backend.d.ts.map +1 -1
  159. package/dist/core/integration-main-backend.js +89 -2
  160. package/dist/core/integration-main-backend.js.map +1 -1
  161. package/dist/core/management-md.d.ts +51 -17
  162. package/dist/core/management-md.d.ts.map +1 -1
  163. package/dist/core/management-md.js +233 -56
  164. package/dist/core/management-md.js.map +1 -1
  165. package/dist/core/output-language-policy.d.ts +74 -0
  166. package/dist/core/output-language-policy.d.ts.map +1 -0
  167. package/dist/core/output-language-policy.js +194 -0
  168. package/dist/core/output-language-policy.js.map +1 -0
  169. package/dist/core/prompts.d.ts +1 -0
  170. package/dist/core/prompts.d.ts.map +1 -1
  171. package/dist/core/prompts.js +121 -3
  172. package/dist/core/prompts.js.map +1 -1
  173. package/dist/core/repository-management-docs.d.ts +24 -0
  174. package/dist/core/repository-management-docs.d.ts.map +1 -1
  175. package/dist/core/repository-management-docs.js +210 -26
  176. package/dist/core/repository-management-docs.js.map +1 -1
  177. package/dist/core/routine-acquisition-plan.d.ts +131 -0
  178. package/dist/core/routine-acquisition-plan.d.ts.map +1 -0
  179. package/dist/core/routine-acquisition-plan.js +268 -0
  180. package/dist/core/routine-acquisition-plan.js.map +1 -0
  181. package/dist/core/routine-fetch-window-runner.d.ts +201 -0
  182. package/dist/core/routine-fetch-window-runner.d.ts.map +1 -0
  183. package/dist/core/routine-fetch-window-runner.js +661 -0
  184. package/dist/core/routine-fetch-window-runner.js.map +1 -0
  185. package/dist/core/routine-windows.d.ts +156 -0
  186. package/dist/core/routine-windows.d.ts.map +1 -0
  187. package/dist/core/routine-windows.js +330 -0
  188. package/dist/core/routine-windows.js.map +1 -0
  189. package/dist/core/skills-compiler.d.ts +11 -0
  190. package/dist/core/skills-compiler.d.ts.map +1 -1
  191. package/dist/core/skills-compiler.js +102 -13
  192. package/dist/core/skills-compiler.js.map +1 -1
  193. package/dist/core/skills-manifest.d.ts.map +1 -1
  194. package/dist/core/skills-manifest.js +26 -0
  195. package/dist/core/skills-manifest.js.map +1 -1
  196. package/dist/core/system-reset.d.ts.map +1 -1
  197. package/dist/core/system-reset.js +25 -2
  198. package/dist/core/system-reset.js.map +1 -1
  199. package/dist/db/observations.d.ts +45 -2
  200. package/dist/db/observations.d.ts.map +1 -1
  201. package/dist/db/observations.js +112 -14
  202. package/dist/db/observations.js.map +1 -1
  203. package/dist/db/schema.d.ts.map +1 -1
  204. package/dist/db/schema.js +13 -25
  205. package/dist/db/schema.js.map +1 -1
  206. package/dist/index.js +83 -610
  207. package/dist/index.js.map +1 -1
  208. package/dist/observers/delegated-sync-worker.d.ts +45 -2
  209. package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
  210. package/dist/observers/delegated-sync-worker.js +71 -21
  211. package/dist/observers/delegated-sync-worker.js.map +1 -1
  212. package/dist/observers/mail-poller.d.ts +12 -5
  213. package/dist/observers/mail-poller.d.ts.map +1 -1
  214. package/dist/observers/mail-poller.js +36 -14
  215. package/dist/observers/mail-poller.js.map +1 -1
  216. package/dist/observers/manager.d.ts +37 -5
  217. package/dist/observers/manager.d.ts.map +1 -1
  218. package/dist/observers/manager.js +28 -10
  219. package/dist/observers/manager.js.map +1 -1
  220. package/dist/services/delegated-backend-invoker.d.ts +1 -51
  221. package/dist/services/delegated-backend-invoker.d.ts.map +1 -1
  222. package/dist/services/delegated-backend-invoker.js +41 -480
  223. package/dist/services/delegated-backend-invoker.js.map +1 -1
  224. package/dist/services/delegated-invoker-audit.d.ts +94 -0
  225. package/dist/services/delegated-invoker-audit.d.ts.map +1 -0
  226. package/dist/services/delegated-invoker-audit.js +238 -0
  227. package/dist/services/delegated-invoker-audit.js.map +1 -0
  228. package/dist/services/delegated-invoker-cache-hits.d.ts +34 -0
  229. package/dist/services/delegated-invoker-cache-hits.d.ts.map +1 -0
  230. package/dist/services/delegated-invoker-cache-hits.js +104 -0
  231. package/dist/services/delegated-invoker-cache-hits.js.map +1 -0
  232. package/dist/services/delegated-invoker-janitors.d.ts +28 -0
  233. package/dist/services/delegated-invoker-janitors.d.ts.map +1 -0
  234. package/dist/services/delegated-invoker-janitors.js +104 -0
  235. package/dist/services/delegated-invoker-janitors.js.map +1 -0
  236. package/dist/services/delegated-invoker-utils.d.ts +42 -0
  237. package/dist/services/delegated-invoker-utils.d.ts.map +1 -0
  238. package/dist/services/delegated-invoker-utils.js +100 -0
  239. package/dist/services/delegated-invoker-utils.js.map +1 -0
  240. package/dist/services/delegated-task-runtime.d.ts +1 -1
  241. package/dist/services/delegated-task-runtime.js +1 -1
  242. package/dist/services/integrations/snapshot-partitions.d.ts +5 -0
  243. package/dist/services/integrations/snapshot-partitions.d.ts.map +1 -1
  244. package/dist/services/integrations/snapshot-partitions.js +12 -0
  245. package/dist/services/integrations/snapshot-partitions.js.map +1 -1
  246. package/dist/services/voice/transcriber-impl.d.ts.map +1 -1
  247. package/dist/services/voice/transcriber-impl.js +7 -8
  248. package/dist/services/voice/transcriber-impl.js.map +1 -1
  249. package/package.json +2 -2
@@ -0,0 +1,831 @@
1
+ /**
2
+ * Claude tool surface — pure helpers split out of `claude-code-core.ts` as
3
+ * part of the file-split plan (Tier 2, §8). Owns five responsibilities:
4
+ *
5
+ * - `getAllowedTools` — assemble the SDK `allowedTools` list from the
6
+ * configured default + the runtime override + any delegated- and native-
7
+ * integration tools the registry exposes.
8
+ * - `getDelegatedClaudeTools` — read the current `integrations` registry
9
+ * state and project it through `computeDelegatedClaudeTools`. Returns
10
+ * `[]` when the MCP context is not yet wired or on DB read failure.
11
+ * - `getNativeClaudeTools` — same shape as `getDelegatedClaudeTools` but
12
+ * projects through `computeNativeClaudeTools` (native-mode parallel).
13
+ * - `getSessionDeniedTools` — DELEGATED-MODE-V2-DESIGN.md §4.3.3 — expand
14
+ * per-integration `deniedTools` into namespaced tool names that the SDK
15
+ * rejects via `disallowedTools` regardless of the allow list.
16
+ * - `buildSecurityHooks` — build the PreToolUse hook record that enforces
17
+ * curl localhost-only, jq env/file-flag denials, context-dir chokepoint,
18
+ * vault write attribution, and the absolute-block audit layer.
19
+ *
20
+ * Pattern A (file-split-plan §5): each function reads its dependencies via
21
+ * an explicit argument record rather than `this.<field>`. The pure shape
22
+ * means these can be unit tested without instantiating `ClaudeCodeCore`,
23
+ * and lets tests inspect the hook closures directly. Thin shims on
24
+ * `ClaudeCodeCore` (`private getAllowedTools(...) { return ... }`) remain
25
+ * for the transitional period (file-split-plan §15).
26
+ */
27
+ import { collectSessionDeniedTools } from "@aitne/shared";
28
+ import { realpathSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { dirname, resolve as resolvePath, isAbsolute } from "node:path";
31
+ import { getContextDir } from "../../config.js";
32
+ import { readIntegrations } from "../../db/integrations-store.js";
33
+ import { recordAbsoluteBlockAudit } from "../../safety/absolute-block-audit.js";
34
+ import { classifyAbsoluteBlock } from "../../safety/always-disallowed.js";
35
+ import { createLogger } from "../../logging.js";
36
+ import { computeDelegatedClaudeTools, computeNativeClaudeTools } from "./claude-probe.js";
37
+ import { isPathInsideOrEqual, shellPathForms } from "../path-compat.js";
38
+ /**
39
+ * Resolve a path through symlinks, even when the leaf does not yet exist.
40
+ *
41
+ * `fs.realpathSync` throws ENOENT on a non-existent leaf, which is the
42
+ * common case for a Write hook (the target file is the *next* write).
43
+ * Walk upwards until an existing ancestor is found, realpath that, then
44
+ * rejoin the missing suffix. Used by both `fileWriteHook` and
45
+ * `bashContextWriteHook` to defeat symlink-based bypasses that point
46
+ * back into the context dir.
47
+ */
48
+ function realpathLenient(absPath) {
49
+ const segments = [];
50
+ let current = absPath;
51
+ // Hard ceiling on iterations so a pathological path never spins forever.
52
+ for (let i = 0; i < 64; i++) {
53
+ try {
54
+ const real = realpathSync(current);
55
+ return segments.length === 0
56
+ ? real
57
+ : resolvePath(real, ...segments.reverse());
58
+ }
59
+ catch {
60
+ const parent = dirname(current);
61
+ if (parent === current)
62
+ return absPath;
63
+ segments.push(current.slice(parent.length).replace(/^[/\\]+/, ""));
64
+ current = parent;
65
+ }
66
+ }
67
+ return absPath;
68
+ }
69
+ /**
70
+ * Best-effort shell tokenizer for path-token scanning. Splits on
71
+ * whitespace while honouring single, double, and back-tick quotes; ignores
72
+ * shell operators (`|`, `;`, `&`, `<`, `>`, parentheses). Returns tokens
73
+ * with their quote wrappers stripped.
74
+ *
75
+ * Not a full shell parser — it cannot resolve variable expansions,
76
+ * subshells, or function definitions. Exists to surface *literal* path
77
+ * arguments so that an obvious form like
78
+ * `echo > /Users/shuto/.personal-agent/context/today.md` is caught. The
79
+ * absolute-block layer is the authoritative defence for the things this
80
+ * heuristic misses.
81
+ */
82
+ function tokenizeShellCommand(cmd) {
83
+ const tokens = [];
84
+ const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|\$\(([^)]*)\)|([^\s|;&<>()]+)/g;
85
+ let match;
86
+ while ((match = re.exec(cmd)) !== null) {
87
+ const tok = match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "";
88
+ if (tok.length > 0)
89
+ tokens.push(tok);
90
+ }
91
+ return tokens;
92
+ }
93
+ /**
94
+ * Expand the leading `~`, `$HOME`, and `${HOME}` segments of a token
95
+ * to the supplied home directory. No other shell expansion is performed.
96
+ */
97
+ function expandHomeForms(token, home) {
98
+ if (token === "~")
99
+ return home;
100
+ if (token.startsWith("~/"))
101
+ return home + token.slice(1);
102
+ if (token.startsWith("$HOME/"))
103
+ return home + token.slice(5);
104
+ if (token.startsWith("${HOME}/"))
105
+ return home + token.slice(7);
106
+ if (token === "$HOME" || token === "${HOME}")
107
+ return home;
108
+ return token;
109
+ }
110
+ const logger = createLogger("claude-tool-collection");
111
+ /** Default allowed-tools list when the dashboard override is unset. */
112
+ export const CLAUDE_DEFAULT_ALLOWED_TOOLS = [
113
+ "Read",
114
+ "Glob",
115
+ "Grep",
116
+ "Write",
117
+ "Edit",
118
+ "Skill", // user skills (external-services, obsidian-*, observations, ...)
119
+ "Bash(curl *)", // curl broadly allowed; hooks restrict to localhost
120
+ "Bash(git *)", // Git operations
121
+ "Bash(jq *)", // safe JSON post-processor for curl pipelines
122
+ ];
123
+ /**
124
+ * Allowed tools whitelist for dontAsk permission mode.
125
+ *
126
+ * `delegatedTools` and `nativeTools` are UNION'd onto the returned list —
127
+ * even when `allowedToolsOverride` is set. This is a deliberate deviation
128
+ * from the override's otherwise-absolute "replace everything" contract (see
129
+ * `CRITICAL_OVERRIDE_TOOLS` in `claude-code-core.ts`, which warns but does
130
+ * not union). Rationale: delegated / native modes are runtime-configurable
131
+ * axes orthogonal to the dashboard's tool-customization override. If a user
132
+ * set the override before flipping an integration, silently dropping the
133
+ * registry-declared connector tools would break mail/calendar with a
134
+ * misleading "permission denied" DM. Union semantics keep the override's
135
+ * curation intent while letting either mode widen the surface to whatever
136
+ * the registry already advertised.
137
+ *
138
+ * Native and delegated lists are accepted separately (rather than a single
139
+ * `extraMcpTools` parameter) so callers — and tests — surface the
140
+ * provenance of every widening: an audit log entry with
141
+ * `delegatedToolCount` and `nativeToolCount` makes a misconfigured flip
142
+ * diagnosable without re-running the resolver.
143
+ */
144
+ export function getAllowedTools(config, webSearchEnabled, delegatedTools = [], nativeTools = []) {
145
+ const base = config.allowedToolsOverride ?? [...CLAUDE_DEFAULT_ALLOWED_TOOLS];
146
+ const merged = new Set(base);
147
+ if (!config.allowedToolsOverride && webSearchEnabled) {
148
+ merged.add("WebSearch");
149
+ }
150
+ for (const tool of delegatedTools)
151
+ merged.add(tool);
152
+ for (const tool of nativeTools)
153
+ merged.add(tool);
154
+ return Array.from(merged);
155
+ }
156
+ /**
157
+ * Read the integrations record from the wired MCP context and project it
158
+ * through the `computeDelegatedClaudeTools` allowlist computation. Returns
159
+ * `[]` when the context is not yet wired (tests / startup ordering) or on
160
+ * DB read failure — the latter is logged as a warning so a corrupt
161
+ * integrations table is visible without halting the session.
162
+ */
163
+ export function getDelegatedClaudeTools(mcpContext) {
164
+ if (!mcpContext)
165
+ return [];
166
+ try {
167
+ const integrations = readIntegrations(mcpContext.db);
168
+ return computeDelegatedClaudeTools(integrations);
169
+ }
170
+ catch (err) {
171
+ logger.warn({ err }, "Failed to read integrations for delegated-tool allowlist — proceeding without delegated tools");
172
+ return [];
173
+ }
174
+ }
175
+ /**
176
+ * Sibling of `getDelegatedClaudeTools` — projects integrations record
177
+ * through `computeNativeClaudeTools`. Returns `[]` when the context is
178
+ * not yet wired or on DB read failure, matching the conservative pattern
179
+ * used by the delegated counterpart.
180
+ *
181
+ * Required because the SDK's `dontAsk` permission mode silently denies
182
+ * tools not in `allowedTools`. Native-mode skill bodies instruct the
183
+ * agent to call connector MCP tools directly (e.g.
184
+ * `mcp__claude_ai_Gmail__search_threads`), so the registry-declared tool
185
+ * names for every `mode === "native" && nativeBackend === "claude"` row
186
+ * must be pre-authorized.
187
+ */
188
+ export function getNativeClaudeTools(mcpContext) {
189
+ if (!mcpContext)
190
+ return [];
191
+ try {
192
+ const integrations = readIntegrations(mcpContext.db);
193
+ return computeNativeClaudeTools(integrations);
194
+ }
195
+ catch (err) {
196
+ logger.warn({ err }, "Failed to read integrations for native-tool allowlist — proceeding without native tools");
197
+ return [];
198
+ }
199
+ }
200
+ /**
201
+ * DELEGATED-MODE-V2-DESIGN.md §4.3.3 — same-backend deny enforcement at
202
+ * the SDK boundary. For every integration whose `delegatedBackend === "claude"`,
203
+ * expand `state.deniedTools` against the connector's known tools and emit
204
+ * the namespaced names (`mcp__claude_ai_<X>__<tool>`). The SDK refuses any
205
+ * tool listed in `disallowedTools` regardless of `allowedTools` — hard
206
+ * enforcement.
207
+ *
208
+ * Returns `[]` when context isn't wired (tests / pre-startup) and on read
209
+ * failures, matching the conservative pattern used by
210
+ * `getDelegatedClaudeTools`.
211
+ */
212
+ export function getSessionDeniedTools(mcpContext) {
213
+ if (!mcpContext)
214
+ return [];
215
+ try {
216
+ const integrations = readIntegrations(mcpContext.db);
217
+ const map = collectSessionDeniedTools(integrations, "claude");
218
+ const out = [];
219
+ for (const names of map.values()) {
220
+ for (const n of names)
221
+ out.push(n);
222
+ }
223
+ return out;
224
+ }
225
+ catch (err) {
226
+ logger.warn({ err }, "Failed to read integrations for same-backend denied-tools — proceeding without per-integration deny");
227
+ return [];
228
+ }
229
+ }
230
+ /**
231
+ * Security hooks:
232
+ * 1. Bash(curl *) — restrict to localhost Daemon API, block connection-override flags. (strict only)
233
+ * 2. Bash(jq *) — block file-access flags and the `env` filter (process env exfiltration). (strict only)
234
+ * 3. Write/Edit — block writes into the session helper dir and context dir, mark vault writes.
235
+ *
236
+ * In allow mode the curl and jq hooks are dropped, but the Write/Edit hook
237
+ * stays: the context-dir chokepoint exists for memory integrity (today-write
238
+ * lock, md_file_snapshots, CONTEXT_WRITE_PERMISSIONS), not permissions.
239
+ */
240
+ export function buildSecurityHooks(deps, allowMode = false) {
241
+ const { config, writeTracker, getMcpContext } = deps;
242
+ const bashCurlHook = async (input) => {
243
+ const toolInput = input.tool_input;
244
+ const cmd = toolInput?.command ?? "";
245
+ if (/\bcurl\b/.test(cmd)) {
246
+ // ── Multi-request defenses (run BEFORE host/port loop) ─────────
247
+ // The SDK `allowedTools` glob is a prefix match against the full
248
+ // command, so a permitted `Bash(curl http://localhost:<port>/api/x/*)`
249
+ // entry still matches a chained `curl http://localhost/api/x/y ;
250
+ // curl http://localhost/api/notify -d @evil`. The URL host/port
251
+ // loop below validates every URL but does NOT count invocations
252
+ // or request transactions, so a second HTTP request slips through.
253
+ // The three rules below cap a curl-bearing command to a single
254
+ // HTTP request.
255
+ //
256
+ // 1. Chained curl invocations — mirrors the `cmdStart` anchor
257
+ // pattern in `safety/always-disallowed.ts`. Count `curl`
258
+ // tokens at start-of-string / after `;` / `&&` / `||` / `|` /
259
+ // newline / backtick / `$(`. A single `jq -n '…' | curl URL`
260
+ // pipeline counts as ONE curl (only the `curl` token itself
261
+ // is matched; the leading `jq` is not). Two or more anchored
262
+ // `curl` tokens → chained invocation → block.
263
+ const chainedCurlMatches = cmd.match(/(?:^|[;&|`\n]|\$\()\s*curl\b/g) ?? [];
264
+ if (chainedCurlMatches.length > 1) {
265
+ return {
266
+ decision: "block",
267
+ reason: `Chained curl invocations are not allowed `
268
+ + `(detected ${chainedCurlMatches.length} curl commands; `
269
+ + `one curl per Bash invocation).`,
270
+ };
271
+ }
272
+ // 2. `--next` / `-:` URL multiplexing — curl's `--next` (short
273
+ // form `-:`) starts a new transaction with reset option state
274
+ // inside the same invocation. The URL loop below still passes
275
+ // because both URLs hit the same host:port, but curl issues
276
+ // one HTTP request per `--next` separator. Same exfil shape
277
+ // as chained curl, different syntax.
278
+ if (/(?:^|\s)--next(?:[\s=]|$)/.test(cmd)
279
+ || /(?:^|\s)-:(?:\s|$)/.test(cmd)) {
280
+ return {
281
+ decision: "block",
282
+ reason: "curl --next / -: (URL multiplexing) is not allowed "
283
+ + "— one HTTP request per Bash invocation.",
284
+ };
285
+ }
286
+ // 3. Multi-positional URL targets — `curl URL1 URL2 -X PUT -d
287
+ // @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`.
294
+ //
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).
305
+ const topLevelTokenRe = /'[^']*'|"[^"]*"|[^'"\s]+/g;
306
+ const topLevelUrls = [];
307
+ let tokenMatch;
308
+ while ((tokenMatch = topLevelTokenRe.exec(cmd)) !== null) {
309
+ if (/^https?:\/\//.test(tokenMatch[0])) {
310
+ topLevelUrls.push(tokenMatch[0]);
311
+ }
312
+ }
313
+ if (topLevelUrls.length > 1) {
314
+ return {
315
+ decision: "block",
316
+ reason: `Multiple URL targets in a single curl invocation are not allowed `
317
+ + `(detected ${topLevelUrls.length} top-level URL tokens; quote `
318
+ + `body URLs inside -d/-H string args).`,
319
+ };
320
+ }
321
+ if (topLevelUrls.length === 0) {
322
+ return {
323
+ decision: "block",
324
+ reason: "curl command must contain an explicit localhost URL",
325
+ };
326
+ }
327
+ for (const url of topLevelUrls) {
328
+ try {
329
+ const parsed = new URL(url);
330
+ if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
331
+ return {
332
+ decision: "block",
333
+ reason: `curl target not allowed: ${url} (host: ${parsed.hostname})`,
334
+ };
335
+ }
336
+ const effectivePort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
337
+ if (effectivePort !== String(config.apiPort)) {
338
+ return {
339
+ decision: "block",
340
+ reason: `curl target port not allowed: ${effectivePort}`,
341
+ };
342
+ }
343
+ }
344
+ catch {
345
+ return {
346
+ decision: "block",
347
+ reason: `curl target URL is malformed: ${url}`,
348
+ };
349
+ }
350
+ }
351
+ // Connection-override flags — host/proxy/socket redirection that
352
+ // would let curl reach something other than the configured loopback
353
+ // 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)) {
355
+ return {
356
+ decision: "block",
357
+ reason: "curl connection override flags not allowed " +
358
+ "(--connect-to, --resolve, --config, --proxy, " +
359
+ "--unix-socket, --abstract-unix-socket, " +
360
+ "--interface, --local-port)",
361
+ };
362
+ }
363
+ // File-read exfil flags. curl can read arbitrary files into the
364
+ // request body via `@<path>` in -d / --data / --form, or via the
365
+ // upload-file flag. The daemon API is loopback so the request
366
+ // body would land in `agent_actions` / notification surfaces that
367
+ // the agent reads back — a confused-deputy exfil.
368
+ //
369
+ // --upload-file / -T — PUT a local file as the body
370
+ // -d @path / --data @path — body literal from file
371
+ // --data-binary @path — same, raw bytes
372
+ // --data-raw @path — same, no escape
373
+ // --data-urlencode @path — same, urlencoded
374
+ // --data-ascii @path — same, ascii
375
+ // -F name=@path / --form …=@ — multipart file part
376
+ // -F name=<path / --form …=< — multipart text from file
377
+ // Short-flag combined forms (`curl -fsT /etc/passwd`) must be
378
+ // caught alongside the single-flag form (`curl -T /etc/passwd`).
379
+ // The leading `-[a-zA-Z]*` permits zero-or-more other short flags
380
+ // before the dangerous letter, mirroring the pattern proven for
381
+ // `-L`. Same shape applied to every short-flag below — without it
382
+ // an attacker can stuff the dangerous letter into a benign-looking
383
+ // flag bundle like `-fs<X>` and bypass the deny rule entirely.
384
+ if (/(?:^|\s)(?:--upload-file\b|-[a-zA-Z]*T(?:\s|=|$))/.test(cmd)) {
385
+ return {
386
+ decision: "block",
387
+ reason: "curl --upload-file / -T not allowed — would read arbitrary files",
388
+ };
389
+ }
390
+ // `@-` is curl's stdin marker (canonical: `-d @-` reads the body
391
+ // from stdin, used by pipelines like `echo $body | curl ... -d @-`).
392
+ // Block `@<anything-other-than-stdin-marker>`. The lookahead
393
+ // `(?!-["']?(?:\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
+ };
406
+ }
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
+ };
412
+ }
413
+ // File-write flags. The agent can land bytes anywhere on disk —
414
+ // overwriting shims, ssh keys, shell rc files, etc. Daemon API is
415
+ // the sole sanctioned write path; Bash curl writes are denied.
416
+ //
417
+ // -o / --output FILE — write response to FILE
418
+ // -O / --remote-name — write to basename-of-URL
419
+ // --remote-name-all — same, for every URL
420
+ // -D / --dump-header FILE — write response headers
421
+ // -c / --cookie-jar FILE — write Set-Cookie state
422
+ // --trace / --trace-ascii F — write protocol trace
423
+ // -w / --write-out FORMAT — format-string output
424
+ // (`%{stderr}` writes to stderr;
425
+ // combined with shell redirect
426
+ // it's another write channel)
427
+ // `-o <file>` / `--output <file>` — used to download binary
428
+ // payloads from the daemon API (e.g. `curl -o receipt.pdf
429
+ // /api/receipts/1/download`). Permit only simple relative
430
+ // filenames so absolute (`-o /etc/passwd`) and parent-escape
431
+ // (`-o ../../foo`) forms are still blocked. Tilde / env-var
432
+ // prefixes are likewise refused because they bypass cwd
433
+ // containment. Quoted paths with spaces (`-o "my file"`) are
434
+ // ALSO rejected so a denylist regex that stops at the space
435
+ // inside the quotes cannot be smuggled past.
436
+ const hasOutputFlag = /(?:^|\s)(?:--output(?:\b|=)|-[a-zA-Z]*o(?:\s|=|$))/.test(cmd);
437
+ if (hasOutputFlag) {
438
+ // Three capture-group alternatives so quoted paths with spaces
439
+ // are caught — `[^\s'"]+` alone fails on `"my file"`.
440
+ const valueMatch = cmd.match(/(?:^|\s)(?:--output(?:\s+|=)|-o(?:\s+|=))(?:"([^"]*)"|'([^']*)'|([^\s'"]+))/);
441
+ const target = valueMatch?.[1] ?? valueMatch?.[2] ?? valueMatch?.[3] ?? "";
442
+ const isSafeRelative = target.length > 0 &&
443
+ !target.startsWith("/") &&
444
+ !target.startsWith("~") &&
445
+ !target.startsWith("$") &&
446
+ !target.split("/").includes("..") &&
447
+ !target.split("\\").includes("..");
448
+ if (!isSafeRelative) {
449
+ return {
450
+ decision: "block",
451
+ reason: `curl --output/-o target must be a simple relative path; ` +
452
+ `got: ${target || "<unparseable>"} ` +
453
+ `(no absolute paths, parent-dir escapes, or shell expansions).`,
454
+ };
455
+ }
456
+ }
457
+ if (/(?:^|\s)(?:--remote-name(?:-all)?\b|-[a-zA-Z]*O(?:\s|=|$))/.test(cmd)) {
458
+ return {
459
+ decision: "block",
460
+ reason: "curl --remote-name/-O not allowed — would write to URL-derived path",
461
+ };
462
+ }
463
+ if (/(?:^|\s)(?:--dump-header\b|-[a-zA-Z]*D(?:\s|=|$))/.test(cmd)) {
464
+ return {
465
+ decision: "block",
466
+ reason: "curl --dump-header/-D not allowed — writes response headers to disk",
467
+ };
468
+ }
469
+ if (/(?:^|\s)(?:--cookie-jar\b|-[a-zA-Z]*c(?:\s|=|$))/.test(cmd)) {
470
+ return {
471
+ decision: "block",
472
+ reason: "curl --cookie-jar/-c not allowed — writes cookie state to disk",
473
+ };
474
+ }
475
+ // `--cookie` / `-b` reads cookies from a file when the value
476
+ // is a filename (curl's documented semantics: `-b "FILE"` if
477
+ // the value has no `=`). Same exfil shape as `-d @file` — the
478
+ // file content is sent in the request header. Allowing
479
+ // `-b name=value` would require parsing the value; the simpler
480
+ // safe stance is to refuse the flag outright since the daemon
481
+ // API uses bearer tokens, not cookies.
482
+ if (/(?:^|\s)(?:--cookie\b|-[a-zA-Z]*b(?:\s|=|$))/.test(cmd)) {
483
+ return {
484
+ decision: "block",
485
+ reason: "curl --cookie/-b not allowed — when the value is a path, " +
486
+ "the file contents are sent as the Cookie header (file read).",
487
+ };
488
+ }
489
+ if (/(?:^|\s)--trace(?:-ascii)?\b/.test(cmd)) {
490
+ return {
491
+ decision: "block",
492
+ reason: "curl --trace / --trace-ascii not allowed — writes protocol trace to disk",
493
+ };
494
+ }
495
+ if (/(?:^|\s)(?:--write-out\b|-[a-zA-Z]*w(?:\s|=|$))/.test(cmd)) {
496
+ return {
497
+ decision: "block",
498
+ reason: "curl --write-out/-w not allowed — format strings include file/stderr sinks",
499
+ };
500
+ }
501
+ // Cert / key file references. The daemon API is plain HTTP on
502
+ // loopback; none of these flags are needed for legitimate
503
+ // 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)) {
505
+ return {
506
+ decision: "block",
507
+ reason: "curl --cert/--key/--cacert/--capath/-E not allowed — read arbitrary files",
508
+ };
509
+ }
510
+ // Follow-redirect flags. The localhost URL check above is
511
+ // bypass-able if curl follows a 3xx off-localhost. The daemon
512
+ // never emits redirects so this flag has no legitimate use.
513
+ //
514
+ // Combined-short-flag forms (`-fsSL`, `-vL`) are caught by the
515
+ // `[a-zA-Z]*L` alternation; the literal `--location` and
516
+ // `--location-trusted` long forms are matched explicitly.
517
+ if (/(?:^|\s)(?:-[a-zA-Z]*L(?:\s|=|$)|--location(?:-trusted)?\b)/.test(cmd)) {
518
+ return {
519
+ decision: "block",
520
+ reason: "curl -L / --location not allowed — would follow redirects off localhost",
521
+ };
522
+ }
523
+ }
524
+ return { continue: true };
525
+ };
526
+ const bashJqHook = async (input) => {
527
+ const toolInput = input.tool_input;
528
+ const cmd = toolInput?.command ?? "";
529
+ if (!/\bjq\b/.test(cmd))
530
+ return { continue: true };
531
+ // Narrow to THIS jq invocation's own args (up to the next pipe / chain op)
532
+ // so that later pipeline stages are not inspected by the jq rules.
533
+ //
534
+ // Known approximation: `[^|;&]*` does not respect shell quoting, so a
535
+ // jq filter with a `|` INSIDE a quoted expression (e.g. `jq 'env | keys'`)
536
+ // will truncate `jqPart` at the first `|` regardless of whether that `|`
537
+ // is a jq pipe inside quotes or an actual shell pipeline break. This is
538
+ // intentionally conservative on the safe side: the env-filter check
539
+ // below still fires on the truncated left half (`jq 'env `), so attack
540
+ // payloads are still blocked. The downside is slightly reduced precision
541
+ // on benign expressions containing the jq `|` operator — those get
542
+ // scanned only up to the first pipe, not their full extent.
543
+ const jqMatch = cmd.match(/\bjq\b([^|;&]*)/);
544
+ if (!jqMatch)
545
+ return { continue: true };
546
+ const jqPart = jqMatch[0];
547
+ // (a) Block file-access flags — --slurpfile / --rawfile read arbitrary
548
+ // files, which would bypass the Read deny list (~/.ssh/**, .env, etc.).
549
+ if (/(?:^|\s)--slurpfile\b/.test(jqPart) || /(?:^|\s)--rawfile\b/.test(jqPart)) {
550
+ return {
551
+ decision: "block",
552
+ reason: "jq --slurpfile and --rawfile are not allowed " +
553
+ "(would bypass Read(.env) / Read(~/.ssh/**) disallow rules).",
554
+ };
555
+ }
556
+ // (b) Block module loading — -L <dir> + import can load filter code from
557
+ // the filesystem, effectively RCE inside the jq process.
558
+ if (/(?:^|\s)-L(?:\s|=|$)/.test(jqPart)) {
559
+ return {
560
+ decision: "block",
561
+ reason: "jq -L (module load path) is not allowed.",
562
+ };
563
+ }
564
+ // (c) Block the `env` filter. `jq env`, `jq -n env`, `jq 'env.FOO'`,
565
+ // `jq '. , env'` all dump the daemon's process.env to stdout. Process.env
566
+ // on this daemon is expected to be clean (secrets live in the keychain),
567
+ // but defense-in-depth: if OPENAI_API_KEY or similar is ever exported at
568
+ // launch, the env filter is the shortest exfil path.
569
+ //
570
+ // Heuristic: match bare `env` NOT preceded by a field-access dot or word
571
+ // char, and NOT followed by a word char. This matches jq's env filter
572
+ // (`env`, `env.HOME`, `(env)`, `env|keys`) while leaving field access
573
+ // like `.env`, `.env_var`, `.data.environments` untouched.
574
+ if (/(?:^|[^\w.])env(?!\w)/.test(jqPart)) {
575
+ return {
576
+ decision: "block",
577
+ reason: "jq env filter is not allowed — it dumps the daemon process " +
578
+ "environment, which is a known exfiltration vector for any " +
579
+ "secrets loaded via .env at startup.",
580
+ };
581
+ }
582
+ return { continue: true };
583
+ };
584
+ /**
585
+ * Block any Bash command that references the context-directory path.
586
+ *
587
+ * Rationale: the daemon API is the ONLY sanctioned write channel for
588
+ * context files — it enforces today-write-lock, md_file_snapshots,
589
+ * CONTEXT_WRITE_PERMISSIONS, and onPromptContextChanged. In strict mode,
590
+ * the allowlist (Bash narrowed to curl/git/jq) + fileWriteHook keeps
591
+ * this chokepoint intact. In allow mode Bash is unrestricted, so an
592
+ * agent could bypass via `echo > today.md`, `tee`, `python -c 'open…'`,
593
+ * `git log … > context/…`, etc. The defence here is layered:
594
+ *
595
+ * 1. Original substring match against `shellPathForms`. Cheap and
596
+ * catches the obvious literal form an honest model would emit.
597
+ * 2. Best-effort shell tokenizer + `~`/`$HOME` expansion + symlink
598
+ * realpath. Catches `cd ~/.personal-agent && echo > ./context/X`
599
+ * (the `./context/X` token, once joined to the cwd or after a
600
+ * separate `cd` token is detected, lands in the context dir),
601
+ * `ln -s ~/.personal-agent/context /tmp/x` followed by writes
602
+ * to `/tmp/x/today.md`, and `~/.personal-agent/./context/X`.
603
+ * 3. Hard block on interpreter escape hatches (`python -c`, `node
604
+ * -e`, `bash -c`, etc.). Static analysis cannot see what these
605
+ * will do; in allow-mode Bash they are the most direct route
606
+ * around the chokepoint.
607
+ *
608
+ * Defence-in-depth, not authoritative: a prompt-injection-driven
609
+ * variable-construction attack (`P=context; D=today; cd ~/.personal-agent;
610
+ * echo > "$P/$D.md"`) can still slip past static analysis. The static
611
+ * absolute-block layer covers the highest-risk patterns; if a new
612
+ * shape of bypass is observed in audit, codify it here.
613
+ */
614
+ const bashContextWriteHook = async (input) => {
615
+ const hookInput = input;
616
+ const toolInput = hookInput.tool_input;
617
+ const cmd = toolInput?.command ?? "";
618
+ if (typeof cmd !== "string" || cmd.length === 0)
619
+ return { continue: true };
620
+ const absContextDir = resolvePath(getContextDir(config));
621
+ const home = homedir();
622
+ const realContextDir = realpathLenient(absContextDir);
623
+ // The data dir is the context dir's parent. `cd ~/.personal-agent`
624
+ // followed by `echo > context/today.md` lands in context via a
625
+ // post-cd relative path that Layer 2 cannot resolve (the hook only
626
+ // sees the *initial* cwd). Treating any reference to the data dir
627
+ // as out-of-bounds preempts that bypass — the agent has no
628
+ // legitimate reason to touch the data dir directly when the daemon
629
+ // API is the sanctioned write channel.
630
+ const absDataDir = resolvePath(config.dataDir);
631
+ const realDataDir = realpathLenient(absDataDir);
632
+ // ── Layer 1: substring match against well-known path forms ──
633
+ const pathForms = shellPathForms(absContextDir, home);
634
+ for (const form of pathForms) {
635
+ if (cmd.includes(form)) {
636
+ return blockContextWrite(absContextDir, `substring match: ${form}`);
637
+ }
638
+ }
639
+ // ── Layer 2: tokenized realpath check ──
640
+ //
641
+ // Resolve every path-looking token to its absolute form (relative
642
+ // to the hook-provided cwd) and to its realpath. If either lands
643
+ // inside the context dir OR the data dir, block.
644
+ const cwd = hookInput.cwd ?? "/";
645
+ const tokens = tokenizeShellCommand(cmd);
646
+ for (const rawTok of tokens) {
647
+ const tok = expandHomeForms(rawTok, home);
648
+ if (!tok.includes("/") && !tok.includes("\\"))
649
+ continue;
650
+ // Skip URL-shaped tokens; they are not filesystem paths.
651
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(tok))
652
+ continue;
653
+ const candidate = isAbsolute(tok) ? tok : resolvePath(cwd, tok);
654
+ const real = realpathLenient(candidate);
655
+ const landsInsideContext = isPathInsideOrEqual(absContextDir, candidate) ||
656
+ isPathInsideOrEqual(realContextDir, real);
657
+ const landsInsideData = isPathInsideOrEqual(absDataDir, candidate) ||
658
+ isPathInsideOrEqual(realDataDir, real);
659
+ if (landsInsideContext || landsInsideData) {
660
+ return blockContextWrite(absContextDir, landsInsideContext
661
+ ? `path token resolves into context dir: ${rawTok} → ${real}`
662
+ : `path token resolves into the data dir (${absDataDir}); ` +
663
+ `the agent should never reference the data dir directly: ${rawTok} → ${real}`);
664
+ }
665
+ }
666
+ // ── Layer 3: interpreter escape hatches ──
667
+ //
668
+ // `bash -c "..."`, `python -c "..."`, etc. tunnel arbitrary code
669
+ // through an opaque argument that static analysis cannot see into.
670
+ // Even in allow-mode Bash the agent should never need these — the
671
+ // SDK Write/Edit tools and the daemon API cover legitimate
672
+ // file-touching use cases. Blocking the patterns themselves is the
673
+ // 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)) {
676
+ return {
677
+ decision: "block",
678
+ reason: `Bash commands that invoke an interpreter with -c / -e are not ` +
679
+ `allowed. Their argument is opaque to static analysis, which ` +
680
+ `defeats the context-write chokepoint. Use the Write/Edit tools ` +
681
+ `or the daemon API at http://localhost:${config.apiPort}/api/context/.`,
682
+ };
683
+ }
684
+ return { continue: true };
685
+ };
686
+ function blockContextWrite(absContextDir, reasonDetail) {
687
+ return {
688
+ decision: "block",
689
+ reason: `Bash commands that reference the context directory (${absContextDir}) are ` +
690
+ `not allowed. Use the daemon API: ` +
691
+ `GET/PUT/PATCH http://localhost:${config.apiPort}/api/context/<path>. ` +
692
+ `The API enforces today-write-lock, md_file_snapshots, CONTEXT_WRITE_PERMISSIONS, ` +
693
+ `and onPromptContextChanged — bypassing it via shell redirects or script ` +
694
+ `engines leaves the memory layer inconsistent. ${reasonDetail}.`,
695
+ };
696
+ }
697
+ const fileWriteHook = async (input) => {
698
+ const hookInput = input;
699
+ const toolInput = hookInput.tool_input;
700
+ const rawFilePath = toolInput?.file_path;
701
+ if (typeof rawFilePath !== "string" || rawFilePath.length === 0) {
702
+ return { continue: true };
703
+ }
704
+ const filePath = rawFilePath;
705
+ const cwd = hookInput.cwd;
706
+ if (!cwd && !isAbsolute(filePath))
707
+ return { continue: true };
708
+ const absFile = resolvePath(cwd ?? "/", filePath);
709
+ // Resolve symlinks. A lexical containment check accepts a symlink
710
+ // whose target lives inside a forbidden dir, because the link
711
+ // itself sits outside. The kernel write follows the link, so the
712
+ // forbidden bytes land anyway. Realpath both sides of every
713
+ // comparison closes that bypass.
714
+ const realFile = realpathLenient(absFile);
715
+ // (a) Block writes into the session-local helper dir. The `curl` shim in
716
+ // `.pa/bin/` carries daemon-auth env at execution time; letting the model
717
+ // rewrite it would turn the helper into a secret exfiltration vector.
718
+ const absHelperDir = resolvePath(cwd ?? "/", ".pa");
719
+ const realHelperDir = realpathLenient(absHelperDir);
720
+ const withinHelperDir = isPathInsideOrEqual(absHelperDir, absFile) ||
721
+ isPathInsideOrEqual(realHelperDir, realFile);
722
+ if (withinHelperDir) {
723
+ return {
724
+ decision: "block",
725
+ reason: "Direct Write/Edit to .pa is forbidden. " +
726
+ "Session helper binaries are managed by the daemon.",
727
+ };
728
+ }
729
+ // (b) Block writes into the context dir.
730
+ const contextDir = getContextDir(config);
731
+ const absContextDir = resolvePath(contextDir);
732
+ const realContextDir = realpathLenient(absContextDir);
733
+ const withinContext = isPathInsideOrEqual(absContextDir, absFile) ||
734
+ isPathInsideOrEqual(realContextDir, realFile);
735
+ if (withinContext) {
736
+ return {
737
+ decision: "block",
738
+ reason: `Direct Write/Edit to context dir is forbidden. ` +
739
+ `Use the daemon API instead: ` +
740
+ `PUT http://localhost:${config.apiPort}/api/context/<path> (full replace) or ` +
741
+ `PATCH http://localhost:${config.apiPort}/api/context/<path> (section op). ` +
742
+ `The API enforces CONTEXT_WRITE_PERMISSIONS, morningRoutineLock, md_file_snapshots, ` +
743
+ `onPromptContextChanged, and expectedMtime concurrency. Path: ${absFile}` +
744
+ (realFile !== absFile ? ` (realpath: ${realFile})` : ""),
745
+ };
746
+ }
747
+ // (c) Mark vault-scoped writes for observer attribution.
748
+ // Targets the EXTERNAL Obsidian vault; the ObsidianWatcher observer
749
+ // watches that path and would otherwise misattribute agent writes
750
+ // as user writes.
751
+ if (!writeTracker)
752
+ return { continue: true };
753
+ const vaultPath = config.externalObsidianVaultPath;
754
+ if (!vaultPath)
755
+ return { continue: true };
756
+ const absVault = resolvePath(vaultPath);
757
+ const realVault = realpathLenient(absVault);
758
+ const withinVault = isPathInsideOrEqual(absVault, absFile) ||
759
+ isPathInsideOrEqual(realVault, realFile);
760
+ if (!withinVault)
761
+ return { continue: true };
762
+ // Mark BOTH paths so the observer can match whichever form the
763
+ // ObsidianWatcher emits. Most filesystems report the lexical path;
764
+ // the realpath form is belt-and-braces.
765
+ writeTracker.markWriting(absFile);
766
+ if (realFile !== absFile)
767
+ writeTracker.markWriting(realFile);
768
+ logger.debug({ filePath: absFile, realPath: realFile }, "vault write pre-marked for observer attribution");
769
+ return { continue: true };
770
+ };
771
+ // EXECUTION-MODE-DESIGN.md §6 — absolute-block audit hook. Runs ahead
772
+ // of every other Bash/Read/Write/Edit hook in both modes. The SDK-level
773
+ // `disallowedTools` rejection is the authoritative block; this hook is
774
+ // redundant defense-in-depth that also writes the `blocked_absolute`
775
+ // audit row so the owner can see the layer is active.
776
+ const makeAbsoluteBlockHook = (toolName, argField) => async (input) => {
777
+ const toolInput = input.tool_input;
778
+ const raw = toolInput?.[argField];
779
+ if (typeof raw !== "string")
780
+ return { continue: true };
781
+ const match = classifyAbsoluteBlock(toolName, raw);
782
+ if (!match)
783
+ return { continue: true };
784
+ recordAbsoluteBlockAudit({
785
+ db: getMcpContext?.()?.db,
786
+ backend: "claude",
787
+ mode: config.claudeExecutionPermissionMode,
788
+ match,
789
+ toolName,
790
+ });
791
+ return {
792
+ decision: "block",
793
+ reason: `Absolute-block layer denied this ${toolName} call ` +
794
+ `(category: ${match.category}). This rule holds in both Safe ` +
795
+ `and Allow modes — see EXECUTION-MODE-DESIGN.md §6.`,
796
+ };
797
+ };
798
+ const bashAbsoluteBlockHook = makeAbsoluteBlockHook("Bash", "command");
799
+ const readAbsoluteBlockHook = makeAbsoluteBlockHook("Read", "file_path");
800
+ const writeAbsoluteBlockHook = makeAbsoluteBlockHook("Write", "file_path");
801
+ const editAbsoluteBlockHook = makeAbsoluteBlockHook("Edit", "file_path");
802
+ // The context-write hook is always attached to Bash — it is the only
803
+ // guarantee that the daemon-API chokepoint for memory files survives
804
+ // allow mode (where curl/jq restrictions are dropped and Bash can
805
+ // otherwise redirect into context/*.md freely).
806
+ //
807
+ // The absolute-block audit hook is appended LAST on every matcher
808
+ // (§6.3). Appended rather than prepended so existing per-index hook
809
+ // tests keep pointing at the same functions; semantically it is a
810
+ // fallback defense whose practical effect is duplicating the SDK's
811
+ // `disallowedTools` rejection into an `agent_actions` row.
812
+ return {
813
+ PreToolUse: [
814
+ {
815
+ matcher: "Bash",
816
+ hooks: allowMode
817
+ ? [bashContextWriteHook, bashAbsoluteBlockHook]
818
+ : [
819
+ bashCurlHook,
820
+ bashJqHook,
821
+ bashContextWriteHook,
822
+ bashAbsoluteBlockHook,
823
+ ],
824
+ },
825
+ { matcher: "Write", hooks: [fileWriteHook, writeAbsoluteBlockHook] },
826
+ { matcher: "Edit", hooks: [fileWriteHook, editAbsoluteBlockHook] },
827
+ { matcher: "Read", hooks: [readAbsoluteBlockHook] },
828
+ ],
829
+ };
830
+ }
831
+ //# sourceMappingURL=claude-tool-collection.js.map