@rubytech/create-realagent-code 0.1.579 → 0.1.580

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 (206) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/telegram-media/dist/index.d.ts +45 -0
  3. package/payload/platform/lib/telegram-media/dist/index.d.ts.map +1 -0
  4. package/payload/platform/lib/telegram-media/dist/index.js +89 -0
  5. package/payload/platform/lib/telegram-media/dist/index.js.map +1 -0
  6. package/payload/platform/lib/telegram-media/src/__tests__/telegram-media.test.ts +104 -0
  7. package/payload/platform/lib/telegram-media/src/index.ts +102 -0
  8. package/payload/platform/lib/telegram-media/tsconfig.json +9 -0
  9. package/payload/platform/lib/telegram-media/vitest.config.ts +9 -0
  10. package/payload/platform/package.json +2 -2
  11. package/payload/platform/plugins/admin/PLUGIN.md +2 -2
  12. package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-surface-gate.test.sh +22 -0
  13. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +34 -0
  14. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +25 -0
  15. package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +11 -4
  16. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +2 -2
  17. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +6 -3
  18. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +7 -0
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +23 -0
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +175 -3
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +101 -0
  22. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +35 -1
  23. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +73 -8
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +59 -8
  25. package/payload/platform/scripts/__tests__/hook-matcher-dispatch.test.sh +163 -0
  26. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +6 -1
  28. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  29. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  30. package/payload/server/public/activity.html +6 -6
  31. package/payload/server/public/agents.html +5 -5
  32. package/payload/server/public/assets/{AdminLoginScreens-Bus-Y-rG.js → AdminLoginScreens-Dxv5t1YY.js} +1 -1
  33. package/payload/server/public/assets/AdminLoginScreens-Dxv5t1YY.js.br +0 -0
  34. package/payload/server/public/assets/AdminLoginScreens-Dxv5t1YY.js.gz +0 -0
  35. package/payload/server/public/assets/{AdminShell-Bsm7q3CW.js → AdminShell-BMslfZxN.js} +1 -1
  36. package/payload/server/public/assets/AdminShell-BMslfZxN.js.br +0 -0
  37. package/payload/server/public/assets/AdminShell-BMslfZxN.js.gz +0 -0
  38. package/payload/server/public/assets/{activity-BWqrCyYM.js → activity-kQ3w70LJ.js} +1 -1
  39. package/payload/server/public/assets/activity-kQ3w70LJ.js.br +0 -0
  40. package/payload/server/public/assets/activity-kQ3w70LJ.js.gz +0 -0
  41. package/payload/server/public/assets/{admin-zly7Y6EZ.js → admin-DapcMKCG.js} +1 -1
  42. package/payload/server/public/assets/admin-DapcMKCG.js.br +0 -0
  43. package/payload/server/public/assets/admin-DapcMKCG.js.gz +0 -0
  44. package/payload/server/public/assets/{agents-BsxGD1V1.js → agents-Cn_bZgnD.js} +1 -1
  45. package/payload/server/public/assets/agents-Cn_bZgnD.js.br +0 -0
  46. package/payload/server/public/assets/agents-Cn_bZgnD.js.gz +0 -0
  47. package/payload/server/public/assets/{browser-CRLxTaw2.js → browser-BE-GDk5-.js} +1 -1
  48. package/payload/server/public/assets/browser-BE-GDk5-.js.br +0 -0
  49. package/payload/server/public/assets/browser-BE-GDk5-.js.gz +0 -0
  50. package/payload/server/public/assets/{calendar-Bmp6qI_P.js → calendar-Di7BEHHN.js} +1 -1
  51. package/payload/server/public/assets/calendar-Di7BEHHN.js.br +0 -0
  52. package/payload/server/public/assets/calendar-Di7BEHHN.js.gz +0 -0
  53. package/payload/server/public/assets/chat-DLmikhie.js +1 -0
  54. package/payload/server/public/assets/chat-DLmikhie.js.br +1 -0
  55. package/payload/server/public/assets/chat-DLmikhie.js.gz +0 -0
  56. package/payload/server/public/assets/chevron-left-Id9VAZAz.js +1 -0
  57. package/payload/server/public/assets/chevron-left-Id9VAZAz.js.br +0 -0
  58. package/payload/server/public/assets/clock-DKpWWJbJ.js +1 -0
  59. package/payload/server/public/assets/clock-DKpWWJbJ.js.br +0 -0
  60. package/payload/server/public/assets/{copy-Bcjpxqzn.js → copy-nkxspu2J.js} +1 -1
  61. package/payload/server/public/assets/copy-nkxspu2J.js.br +0 -0
  62. package/payload/server/public/assets/copy-nkxspu2J.js.gz +0 -0
  63. package/payload/server/public/assets/data-C1_m9Xyf.js +1 -0
  64. package/payload/server/public/assets/data-C1_m9Xyf.js.br +0 -0
  65. package/payload/server/public/assets/data-C1_m9Xyf.js.gz +0 -0
  66. package/payload/server/public/assets/{field-ChKB2l_d.js → field-2rIXxGMt.js} +1 -1
  67. package/payload/server/public/assets/field-2rIXxGMt.js.br +0 -0
  68. package/payload/server/public/assets/field-2rIXxGMt.js.gz +0 -0
  69. package/payload/server/public/assets/{file-text-COZcf7mU.js → file-text-CdSqrscg.js} +1 -1
  70. package/payload/server/public/assets/file-text-CdSqrscg.js.br +0 -0
  71. package/payload/server/public/assets/file-text-CdSqrscg.js.gz +0 -0
  72. package/payload/server/public/assets/{graph-nnV47iqM.js → graph-BfBHHXaC.js} +1 -1
  73. package/payload/server/public/assets/graph-BfBHHXaC.js.br +0 -0
  74. package/payload/server/public/assets/graph-BfBHHXaC.js.gz +0 -0
  75. package/payload/server/public/assets/{graph-labels-x-1MT-3c.js → graph-labels-CHW2aBke.js} +1 -1
  76. package/payload/server/public/assets/graph-labels-CHW2aBke.js.br +0 -0
  77. package/payload/server/public/assets/graph-labels-CHW2aBke.js.gz +0 -0
  78. package/payload/server/public/assets/jsx-runtime-D43jgaWP.css +1 -0
  79. package/payload/server/public/assets/jsx-runtime-D43jgaWP.css.br +0 -0
  80. package/payload/server/public/assets/jsx-runtime-D43jgaWP.css.gz +0 -0
  81. package/payload/server/public/assets/{operator-BAO0-mjU.js → operator-BOiL8sxx.js} +1 -1
  82. package/payload/server/public/assets/operator-BOiL8sxx.js.br +0 -0
  83. package/payload/server/public/assets/operator-BOiL8sxx.js.gz +0 -0
  84. package/payload/server/public/assets/page-CZ6tyH8t.js +1 -0
  85. package/payload/server/public/assets/page-CZ6tyH8t.js.br +0 -0
  86. package/payload/server/public/assets/page-CZ6tyH8t.js.gz +0 -0
  87. package/payload/server/public/assets/{page-B-F08pod.js → page-Cd36Y0h6.js} +1 -1
  88. package/payload/server/public/assets/page-Cd36Y0h6.js.br +0 -0
  89. package/payload/server/public/assets/page-Cd36Y0h6.js.gz +0 -0
  90. package/payload/server/public/assets/play-7LTxCuLX.js +1 -0
  91. package/payload/server/public/assets/play-7LTxCuLX.js.br +0 -0
  92. package/payload/server/public/assets/play-7LTxCuLX.js.gz +0 -0
  93. package/payload/server/public/assets/{public-_HJsUohA.js → public-CVBav4FQ.js} +1 -1
  94. package/payload/server/public/assets/public-CVBav4FQ.js.br +0 -0
  95. package/payload/server/public/assets/public-CVBav4FQ.js.gz +0 -0
  96. package/payload/server/public/assets/{rotate-ccw-DJeAnYLQ.js → rotate-ccw-J9f-GWjm.js} +1 -1
  97. package/payload/server/public/assets/rotate-ccw-J9f-GWjm.js.br +0 -0
  98. package/payload/server/public/assets/rotate-ccw-J9f-GWjm.js.gz +0 -0
  99. package/payload/server/public/assets/{routines-CoIVXRCB.js → routines-C1AB-3FR.js} +1 -1
  100. package/payload/server/public/assets/routines-C1AB-3FR.js.br +0 -0
  101. package/payload/server/public/assets/routines-C1AB-3FR.js.gz +0 -0
  102. package/payload/server/public/assets/{skills-BjHt5lDG.js → skills-C4EfNKEx.js} +1 -1
  103. package/payload/server/public/assets/skills-C4EfNKEx.js.br +0 -0
  104. package/payload/server/public/assets/skills-C4EfNKEx.js.gz +0 -0
  105. package/payload/server/public/assets/{tasks-iUFfeA9_.js → tasks-DszD3aZl.js} +1 -1
  106. package/payload/server/public/assets/tasks-DszD3aZl.js.br +0 -0
  107. package/payload/server/public/assets/tasks-DszD3aZl.js.gz +0 -0
  108. package/payload/server/public/assets/{triangle-alert-Dwa986Gg.js → triangle-alert-B5WAyAkC.js} +1 -1
  109. package/payload/server/public/assets/triangle-alert-B5WAyAkC.js.br +4 -0
  110. package/payload/server/public/assets/triangle-alert-B5WAyAkC.js.gz +0 -0
  111. package/payload/server/public/assets/{useCopyFeedback-B1Jp6CsE.js → useCopyFeedback-B2nuNNJw.js} +1 -1
  112. package/payload/server/public/assets/useCopyFeedback-B2nuNNJw.js.br +0 -0
  113. package/payload/server/public/assets/useCopyFeedback-B2nuNNJw.js.gz +0 -0
  114. package/payload/server/public/assets/{useMediaQuery-CVZYjRf6.js → useMediaQuery-DUPJPHQt.js} +1 -1
  115. package/payload/server/public/assets/useMediaQuery-DUPJPHQt.js.br +0 -0
  116. package/payload/server/public/assets/useMediaQuery-DUPJPHQt.js.gz +0 -0
  117. package/payload/server/public/assets/{useVoiceRecorder-D5b4r4z0.js → useVoiceRecorder-BEgr0nHN.js} +1 -1
  118. package/payload/server/public/assets/useVoiceRecorder-BEgr0nHN.js.br +0 -0
  119. package/payload/server/public/assets/useVoiceRecorder-BEgr0nHN.js.gz +0 -0
  120. package/payload/server/public/assets/{wrench-BD7xwbV1.js → wrench-DZJZm17j.js} +1 -1
  121. package/payload/server/public/assets/wrench-DZJZm17j.js.br +0 -0
  122. package/payload/server/public/assets/wrench-DZJZm17j.js.gz +0 -0
  123. package/payload/server/public/browser.html +5 -5
  124. package/payload/server/public/calendar.html +8 -8
  125. package/payload/server/public/chat.html +14 -14
  126. package/payload/server/public/data.html +12 -12
  127. package/payload/server/public/field.html +3 -3
  128. package/payload/server/public/graph.html +10 -10
  129. package/payload/server/public/index.html +14 -14
  130. package/payload/server/public/operator.html +15 -15
  131. package/payload/server/public/public.html +14 -14
  132. package/payload/server/public/routines.html +8 -8
  133. package/payload/server/public/skills.html +6 -6
  134. package/payload/server/public/tasks.html +7 -7
  135. package/payload/server/server.js +965 -687
  136. package/payload/server/public/assets/AdminLoginScreens-Bus-Y-rG.js.br +0 -0
  137. package/payload/server/public/assets/AdminLoginScreens-Bus-Y-rG.js.gz +0 -0
  138. package/payload/server/public/assets/AdminShell-Bsm7q3CW.js.br +0 -0
  139. package/payload/server/public/assets/AdminShell-Bsm7q3CW.js.gz +0 -0
  140. package/payload/server/public/assets/activity-BWqrCyYM.js.br +0 -0
  141. package/payload/server/public/assets/activity-BWqrCyYM.js.gz +0 -0
  142. package/payload/server/public/assets/admin-zly7Y6EZ.js.br +0 -0
  143. package/payload/server/public/assets/admin-zly7Y6EZ.js.gz +0 -0
  144. package/payload/server/public/assets/agents-BsxGD1V1.js.br +0 -0
  145. package/payload/server/public/assets/agents-BsxGD1V1.js.gz +0 -0
  146. package/payload/server/public/assets/browser-CRLxTaw2.js.br +0 -0
  147. package/payload/server/public/assets/browser-CRLxTaw2.js.gz +0 -0
  148. package/payload/server/public/assets/calendar-Bmp6qI_P.js.br +0 -0
  149. package/payload/server/public/assets/calendar-Bmp6qI_P.js.gz +0 -0
  150. package/payload/server/public/assets/chat-ChObDKHy.js +0 -1
  151. package/payload/server/public/assets/chat-ChObDKHy.js.br +0 -0
  152. package/payload/server/public/assets/chat-ChObDKHy.js.gz +0 -0
  153. package/payload/server/public/assets/chevron-left-D2l4jMcd.js +0 -1
  154. package/payload/server/public/assets/chevron-left-D2l4jMcd.js.br +0 -0
  155. package/payload/server/public/assets/clock-CMdfsroW.js +0 -1
  156. package/payload/server/public/assets/clock-CMdfsroW.js.br +0 -0
  157. package/payload/server/public/assets/clock-CMdfsroW.js.gz +0 -0
  158. package/payload/server/public/assets/copy-Bcjpxqzn.js.br +0 -0
  159. package/payload/server/public/assets/copy-Bcjpxqzn.js.gz +0 -0
  160. package/payload/server/public/assets/data-CFB5Lgw-.js +0 -1
  161. package/payload/server/public/assets/data-CFB5Lgw-.js.br +0 -0
  162. package/payload/server/public/assets/data-CFB5Lgw-.js.gz +0 -0
  163. package/payload/server/public/assets/field-ChKB2l_d.js.br +0 -0
  164. package/payload/server/public/assets/field-ChKB2l_d.js.gz +0 -0
  165. package/payload/server/public/assets/file-text-COZcf7mU.js.br +0 -0
  166. package/payload/server/public/assets/file-text-COZcf7mU.js.gz +0 -0
  167. package/payload/server/public/assets/graph-labels-x-1MT-3c.js.br +0 -0
  168. package/payload/server/public/assets/graph-labels-x-1MT-3c.js.gz +0 -0
  169. package/payload/server/public/assets/graph-nnV47iqM.js.br +0 -0
  170. package/payload/server/public/assets/graph-nnV47iqM.js.gz +0 -0
  171. package/payload/server/public/assets/jsx-runtime-CeNZasfC.css +0 -1
  172. package/payload/server/public/assets/jsx-runtime-CeNZasfC.css.br +0 -0
  173. package/payload/server/public/assets/jsx-runtime-CeNZasfC.css.gz +0 -0
  174. package/payload/server/public/assets/operator-BAO0-mjU.js.br +0 -0
  175. package/payload/server/public/assets/operator-BAO0-mjU.js.gz +0 -0
  176. package/payload/server/public/assets/page-B-F08pod.js.br +0 -0
  177. package/payload/server/public/assets/page-B-F08pod.js.gz +0 -0
  178. package/payload/server/public/assets/page-BMOCy2aD.js +0 -1
  179. package/payload/server/public/assets/page-BMOCy2aD.js.br +0 -0
  180. package/payload/server/public/assets/page-BMOCy2aD.js.gz +0 -0
  181. package/payload/server/public/assets/play-DFtlk8Zs.js +0 -1
  182. package/payload/server/public/assets/play-DFtlk8Zs.js.br +0 -0
  183. package/payload/server/public/assets/play-DFtlk8Zs.js.gz +0 -0
  184. package/payload/server/public/assets/public-_HJsUohA.js.br +0 -0
  185. package/payload/server/public/assets/public-_HJsUohA.js.gz +0 -0
  186. package/payload/server/public/assets/rotate-ccw-DJeAnYLQ.js.br +0 -0
  187. package/payload/server/public/assets/rotate-ccw-DJeAnYLQ.js.gz +0 -0
  188. package/payload/server/public/assets/routines-CoIVXRCB.js.br +0 -0
  189. package/payload/server/public/assets/routines-CoIVXRCB.js.gz +0 -0
  190. package/payload/server/public/assets/skills-BjHt5lDG.js.br +0 -0
  191. package/payload/server/public/assets/skills-BjHt5lDG.js.gz +0 -0
  192. package/payload/server/public/assets/tasks-iUFfeA9_.js.br +0 -0
  193. package/payload/server/public/assets/tasks-iUFfeA9_.js.gz +0 -0
  194. package/payload/server/public/assets/triangle-alert-Dwa986Gg.js.br +0 -0
  195. package/payload/server/public/assets/triangle-alert-Dwa986Gg.js.gz +0 -0
  196. package/payload/server/public/assets/useCopyFeedback-B1Jp6CsE.js.br +0 -0
  197. package/payload/server/public/assets/useCopyFeedback-B1Jp6CsE.js.gz +0 -0
  198. package/payload/server/public/assets/useMediaQuery-CVZYjRf6.js.br +0 -0
  199. package/payload/server/public/assets/useMediaQuery-CVZYjRf6.js.gz +0 -0
  200. package/payload/server/public/assets/useVoiceRecorder-D5b4r4z0.js.br +0 -0
  201. package/payload/server/public/assets/useVoiceRecorder-D5b4r4z0.js.gz +0 -0
  202. package/payload/server/public/assets/wrench-BD7xwbV1.js.br +0 -0
  203. package/payload/server/public/assets/wrench-BD7xwbV1.js.gz +0 -0
  204. /package/payload/server/public/assets/{jsx-runtime-CjJ246F7.js → jsx-runtime-Cjug_Cyb.js} +0 -0
  205. /package/payload/server/public/assets/{jsx-runtime-CjJ246F7.js.br → jsx-runtime-Cjug_Cyb.js.br} +0 -0
  206. /package/payload/server/public/assets/{jsx-runtime-CjJ246F7.js.gz → jsx-runtime-Cjug_Cyb.js.gz} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent-code",
3
- "version": "0.1.579",
3
+ "version": "0.1.580",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent-code": "./dist/index.js"
@@ -0,0 +1,45 @@
1
+ /** Telegram's own reason when it gives one, else a description of the HTTP
2
+ * outcome. Never a limit this code restates: `getFile` refuses above 20 MB and
3
+ * says so, and a size pre-check here would be a second authority for that. */
4
+ export declare function refusalReason(body: unknown, status: number): string;
5
+ /** Extension from Telegram's own `file_path`, else the mimetype's subtype, else
6
+ * `bin`. Telegram's path already carries the real extension for every type that
7
+ * has one, so it leads. */
8
+ export declare function extensionFor(filePath: string, mimetype: string | undefined): string;
9
+ /** An identifier is not a path component: a value carrying `../` escapes the
10
+ * media directory, and the agent is then told to Read whatever came back.
11
+ * Escape here rather than reason about who can reach the endpoint. */
12
+ export declare function pathSafe(s: string): string;
13
+ export type FileResolution = {
14
+ ok: true;
15
+ filePath: string;
16
+ } | {
17
+ ok: false;
18
+ failure: string;
19
+ };
20
+ /** Hop one. Exchanges a `file_id` for Telegram's server-side `file_path`.
21
+ * Never throws: a rejected fetch becomes a failure reason, because both
22
+ * callers treat a missing file as a fact to report, not an exception. */
23
+ export declare function resolveTelegramFile(input: {
24
+ token: string;
25
+ fileId: string;
26
+ fetchImpl?: typeof fetch;
27
+ signal?: AbortSignal;
28
+ }): Promise<FileResolution>;
29
+ export type FileDownload = {
30
+ ok: true;
31
+ bytes: number;
32
+ } | {
33
+ ok: false;
34
+ failure: string;
35
+ };
36
+ /** Hop two. Fetches the bytes from the file host and writes them to `destPath`,
37
+ * creating its directory. Never throws, for the same reason as hop one. */
38
+ export declare function downloadTelegramFile(input: {
39
+ token: string;
40
+ filePath: string;
41
+ destPath: string;
42
+ fetchImpl?: typeof fetch;
43
+ signal?: AbortSignal;
44
+ }): Promise<FileDownload>;
45
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAqBA;;+EAE+E;AAC/E,wBAAgB,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAInE;AAED;;4BAE4B;AAC5B,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAMnF;AAED;;uEAEuE;AACvE,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1C;AAED,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7F;;0EAE0E;AAC1E,wBAAsB,mBAAmB,CAAC,KAAK,EAAE;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,cAAc,CAAC,CAgB1B;AAED,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAExF;4EAC4E;AAC5E,wBAAsB,oBAAoB,CAAC,KAAK,EAAE;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,YAAY,CAAC,CAexB"}
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refusalReason = refusalReason;
4
+ exports.extensionFor = extensionFor;
5
+ exports.pathSafe = pathSafe;
6
+ exports.resolveTelegramFile = resolveTelegramFile;
7
+ exports.downloadTelegramFile = downloadTelegramFile;
8
+ /**
9
+ * The two HTTPS hops Telegram requires to fetch a file, extracted from Task
10
+ * 2489's inbound downloader so both trees run one implementation.
11
+ *
12
+ * `platform/plugins/telegram/mcp/src/lib/account-write.ts:21-24` records that
13
+ * the plugin cannot import from `platform/ui/app`, and that the standing
14
+ * workaround is deliberate duplication plus an agreement test. `platform/lib/`
15
+ * is the surface both sides already import, so a shared module removes the need
16
+ * for a third duplicate.
17
+ *
18
+ * This module holds NO item types, NO loop and NO logging. The channel layer
19
+ * and the MCP tool name their files differently and log differently; only the
20
+ * transfer is common.
21
+ *
22
+ * NEVER log a token. Both functions take one and neither returns it.
23
+ */
24
+ const promises_1 = require("node:fs/promises");
25
+ const node_path_1 = require("node:path");
26
+ const TELEGRAM_API = "https://api.telegram.org";
27
+ /** Telegram's own reason when it gives one, else a description of the HTTP
28
+ * outcome. Never a limit this code restates: `getFile` refuses above 20 MB and
29
+ * says so, and a size pre-check here would be a second authority for that. */
30
+ function refusalReason(body, status) {
31
+ const b = body;
32
+ if (typeof b?.description === "string" && b.description.length > 0)
33
+ return b.description;
34
+ return `telegram returned ${status}`;
35
+ }
36
+ /** Extension from Telegram's own `file_path`, else the mimetype's subtype, else
37
+ * `bin`. Telegram's path already carries the real extension for every type that
38
+ * has one, so it leads. */
39
+ function extensionFor(filePath, mimetype) {
40
+ const fromPath = filePath.split("/").pop()?.split(".").slice(1).pop();
41
+ if (fromPath && /^[a-z0-9]+$/i.test(fromPath))
42
+ return fromPath;
43
+ const sub = mimetype?.split("/").pop()?.split(";")[0]?.trim();
44
+ if (sub && /^[a-z0-9]+$/i.test(sub))
45
+ return sub;
46
+ return "bin";
47
+ }
48
+ /** An identifier is not a path component: a value carrying `../` escapes the
49
+ * media directory, and the agent is then told to Read whatever came back.
50
+ * Escape here rather than reason about who can reach the endpoint. */
51
+ function pathSafe(s) {
52
+ return s.replace(/[^A-Za-z0-9_-]/g, "_");
53
+ }
54
+ /** Hop one. Exchanges a `file_id` for Telegram's server-side `file_path`.
55
+ * Never throws: a rejected fetch becomes a failure reason, because both
56
+ * callers treat a missing file as a fact to report, not an exception. */
57
+ async function resolveTelegramFile(input) {
58
+ const doFetch = input.fetchImpl ?? fetch;
59
+ try {
60
+ const res = await doFetch(`${TELEGRAM_API}/bot${input.token}/getFile?file_id=${encodeURIComponent(input.fileId)}`, input.signal ? { signal: input.signal } : {});
61
+ const body = (await res.json());
62
+ const filePath = body?.result?.file_path;
63
+ if (!res.ok || body?.ok !== true || typeof filePath !== "string" || filePath.length === 0) {
64
+ return { ok: false, failure: refusalReason(body, res.status) };
65
+ }
66
+ return { ok: true, filePath };
67
+ }
68
+ catch (err) {
69
+ return { ok: false, failure: err instanceof Error ? err.message : String(err) };
70
+ }
71
+ }
72
+ /** Hop two. Fetches the bytes from the file host and writes them to `destPath`,
73
+ * creating its directory. Never throws, for the same reason as hop one. */
74
+ async function downloadTelegramFile(input) {
75
+ const doFetch = input.fetchImpl ?? fetch;
76
+ try {
77
+ const res = await doFetch(`${TELEGRAM_API}/file/bot${input.token}/${input.filePath}`, input.signal ? { signal: input.signal } : {});
78
+ if (!res.ok)
79
+ return { ok: false, failure: `telegram returned ${res.status}` };
80
+ const body = Buffer.from(await res.arrayBuffer());
81
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(input.destPath), { recursive: true });
82
+ await (0, promises_1.writeFile)(input.destPath, body);
83
+ return { ok: true, bytes: body.length };
84
+ }
85
+ catch (err) {
86
+ return { ok: false, failure: err instanceof Error ? err.message : String(err) };
87
+ }
88
+ }
89
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AAwBA,sCAIC;AAKD,oCAMC;AAKD,4BAEC;AAOD,kDAqBC;AAMD,oDAqBC;AArGD;;;;;;;;;;;;;;;GAeG;AACH,+CAAoD;AACpD,yCAAoC;AAEpC,MAAM,YAAY,GAAG,0BAA0B,CAAC;AAEhD;;+EAE+E;AAC/E,SAAgB,aAAa,CAAC,IAAa,EAAE,MAAc;IACzD,MAAM,CAAC,GAAG,IAAwC,CAAC;IACnD,IAAI,OAAO,CAAC,EAAE,WAAW,KAAK,QAAQ,IAAI,CAAC,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC,WAAW,CAAC;IACzF,OAAO,qBAAqB,MAAM,EAAE,CAAC;AACvC,CAAC;AAED;;4BAE4B;AAC5B,SAAgB,YAAY,CAAC,QAAgB,EAAE,QAA4B;IACzE,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;IACtE,IAAI,QAAQ,IAAI,cAAc,CAAC,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC/D,MAAM,GAAG,GAAG,QAAQ,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IAC9D,IAAI,GAAG,IAAI,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IAChD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;uEAEuE;AACvE,SAAgB,QAAQ,CAAC,CAAS;IAChC,OAAO,CAAC,CAAC,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,CAAC;AAC3C,CAAC;AAID;;0EAE0E;AACnE,KAAK,UAAU,mBAAmB,CAAC,KAKzC;IACC,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC;IACzC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,OAAO,CACvB,GAAG,YAAY,OAAO,KAAK,CAAC,KAAK,oBAAoB,kBAAkB,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,EACvF,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAC7C,CAAC;QACF,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAsD,CAAC;QACrF,MAAM,QAAQ,GAAG,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC;QACzC,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,IAAI,EAAE,EAAE,KAAK,IAAI,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC1F,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QACjE,CAAC;QACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IAChC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;IAClF,CAAC;AACH,CAAC;AAID;4EAC4E;AACrE,KAAK,UAAU,oBAAoB,CAAC,KAM1C;IACC,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC;IACzC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,OAAO,CACvB,GAAG,YAAY,YAAY,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,QAAQ,EAAE,EAC1D,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAC7C,CAAC;QACF,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,qBAAqB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;QAC9E,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;QAClD,MAAM,IAAA,gBAAK,EAAC,IAAA,mBAAO,EAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC1D,MAAM,IAAA,oBAAS,EAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QACtC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;IAC1C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;IAClF,CAAC;AACH,CAAC"}
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ resolveTelegramFile,
7
+ downloadTelegramFile,
8
+ extensionFor,
9
+ pathSafe,
10
+ refusalReason,
11
+ } from "../index.js";
12
+
13
+ function jsonFetch(status: number, body: unknown): typeof fetch {
14
+ return (async () =>
15
+ new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
16
+ }
17
+
18
+ describe("resolveTelegramFile", () => {
19
+ it("returns Telegram's file_path on success", async () => {
20
+ const out = await resolveTelegramFile({
21
+ token: "T",
22
+ fileId: "F",
23
+ fetchImpl: jsonFetch(200, { ok: true, result: { file_path: "photos/f.jpg" } }),
24
+ });
25
+ expect(out).toEqual({ ok: true, filePath: "photos/f.jpg" });
26
+ });
27
+
28
+ it("returns Telegram's own description when it refuses", async () => {
29
+ const out = await resolveTelegramFile({
30
+ token: "T",
31
+ fileId: "F",
32
+ fetchImpl: jsonFetch(400, { ok: false, description: "file is too big" }),
33
+ });
34
+ expect(out).toEqual({ ok: false, failure: "file is too big" });
35
+ });
36
+
37
+ it("describes the HTTP outcome when Telegram gives no description", async () => {
38
+ const out = await resolveTelegramFile({
39
+ token: "T",
40
+ fileId: "F",
41
+ fetchImpl: jsonFetch(502, {}),
42
+ });
43
+ expect(out).toEqual({ ok: false, failure: "telegram returned 502" });
44
+ });
45
+
46
+ it("puts the file_id in the query, never the token in the result", async () => {
47
+ let seen = "";
48
+ const spy = (async (url: string) => {
49
+ seen = url;
50
+ return new Response(JSON.stringify({ ok: true, result: { file_path: "p/f.jpg" } }));
51
+ }) as unknown as typeof fetch;
52
+ await resolveTelegramFile({ token: "SECRET", fileId: "a b/c", fetchImpl: spy });
53
+ expect(seen).toContain("file_id=a%20b%2Fc");
54
+ });
55
+ });
56
+
57
+ describe("downloadTelegramFile", () => {
58
+ it("writes the bytes and reports the count", async () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "tgm-"));
60
+ const dest = join(dir, "out.jpg");
61
+ const fetchImpl = (async () =>
62
+ new Response(new Uint8Array([1, 2, 3, 4]))) as unknown as typeof fetch;
63
+ const out = await downloadTelegramFile({
64
+ token: "T",
65
+ filePath: "p/f.jpg",
66
+ destPath: dest,
67
+ fetchImpl,
68
+ });
69
+ expect(out).toEqual({ ok: true, bytes: 4 });
70
+ expect(readFileSync(dest).length).toBe(4);
71
+ });
72
+
73
+ it("reports the status when the file host refuses", async () => {
74
+ const dir = mkdtempSync(join(tmpdir(), "tgm-"));
75
+ const fetchImpl = (async () => new Response("", { status: 404 })) as unknown as typeof fetch;
76
+ const out = await downloadTelegramFile({
77
+ token: "T",
78
+ filePath: "p/f.jpg",
79
+ destPath: join(dir, "out.jpg"),
80
+ fetchImpl,
81
+ });
82
+ expect(out).toEqual({ ok: false, failure: "telegram returned 404" });
83
+ });
84
+ });
85
+
86
+ describe("helpers", () => {
87
+ it("prefers the extension Telegram's own path carries", () => {
88
+ expect(extensionFor("photos/file_1.jpg", "image/png")).toBe("jpg");
89
+ });
90
+
91
+ it("falls back to the mimetype subtype, then to bin", () => {
92
+ expect(extensionFor("photos/file_1", "image/png")).toBe("png");
93
+ expect(extensionFor("photos/file_1", undefined)).toBe("bin");
94
+ });
95
+
96
+ it("strips every character that is not a safe path component", () => {
97
+ expect(pathSafe("../../etc/passwd")).toBe("______etc_passwd");
98
+ });
99
+
100
+ it("prefers Telegram's description over the status", () => {
101
+ expect(refusalReason({ description: "nope" }, 400)).toBe("nope");
102
+ expect(refusalReason(null, 500)).toBe("telegram returned 500");
103
+ });
104
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The two HTTPS hops Telegram requires to fetch a file, extracted from Task
3
+ * 2489's inbound downloader so both trees run one implementation.
4
+ *
5
+ * `platform/plugins/telegram/mcp/src/lib/account-write.ts:21-24` records that
6
+ * the plugin cannot import from `platform/ui/app`, and that the standing
7
+ * workaround is deliberate duplication plus an agreement test. `platform/lib/`
8
+ * is the surface both sides already import, so a shared module removes the need
9
+ * for a third duplicate.
10
+ *
11
+ * This module holds NO item types, NO loop and NO logging. The channel layer
12
+ * and the MCP tool name their files differently and log differently; only the
13
+ * transfer is common.
14
+ *
15
+ * NEVER log a token. Both functions take one and neither returns it.
16
+ */
17
+ import { mkdir, writeFile } from "node:fs/promises";
18
+ import { dirname } from "node:path";
19
+
20
+ const TELEGRAM_API = "https://api.telegram.org";
21
+
22
+ /** Telegram's own reason when it gives one, else a description of the HTTP
23
+ * outcome. Never a limit this code restates: `getFile` refuses above 20 MB and
24
+ * says so, and a size pre-check here would be a second authority for that. */
25
+ export function refusalReason(body: unknown, status: number): string {
26
+ const b = body as { description?: unknown } | null;
27
+ if (typeof b?.description === "string" && b.description.length > 0) return b.description;
28
+ return `telegram returned ${status}`;
29
+ }
30
+
31
+ /** Extension from Telegram's own `file_path`, else the mimetype's subtype, else
32
+ * `bin`. Telegram's path already carries the real extension for every type that
33
+ * has one, so it leads. */
34
+ export function extensionFor(filePath: string, mimetype: string | undefined): string {
35
+ const fromPath = filePath.split("/").pop()?.split(".").slice(1).pop();
36
+ if (fromPath && /^[a-z0-9]+$/i.test(fromPath)) return fromPath;
37
+ const sub = mimetype?.split("/").pop()?.split(";")[0]?.trim();
38
+ if (sub && /^[a-z0-9]+$/i.test(sub)) return sub;
39
+ return "bin";
40
+ }
41
+
42
+ /** An identifier is not a path component: a value carrying `../` escapes the
43
+ * media directory, and the agent is then told to Read whatever came back.
44
+ * Escape here rather than reason about who can reach the endpoint. */
45
+ export function pathSafe(s: string): string {
46
+ return s.replace(/[^A-Za-z0-9_-]/g, "_");
47
+ }
48
+
49
+ export type FileResolution = { ok: true; filePath: string } | { ok: false; failure: string };
50
+
51
+ /** Hop one. Exchanges a `file_id` for Telegram's server-side `file_path`.
52
+ * Never throws: a rejected fetch becomes a failure reason, because both
53
+ * callers treat a missing file as a fact to report, not an exception. */
54
+ export async function resolveTelegramFile(input: {
55
+ token: string;
56
+ fileId: string;
57
+ fetchImpl?: typeof fetch;
58
+ signal?: AbortSignal;
59
+ }): Promise<FileResolution> {
60
+ const doFetch = input.fetchImpl ?? fetch;
61
+ try {
62
+ const res = await doFetch(
63
+ `${TELEGRAM_API}/bot${input.token}/getFile?file_id=${encodeURIComponent(input.fileId)}`,
64
+ input.signal ? { signal: input.signal } : {},
65
+ );
66
+ const body = (await res.json()) as { ok?: boolean; result?: { file_path?: string } };
67
+ const filePath = body?.result?.file_path;
68
+ if (!res.ok || body?.ok !== true || typeof filePath !== "string" || filePath.length === 0) {
69
+ return { ok: false, failure: refusalReason(body, res.status) };
70
+ }
71
+ return { ok: true, filePath };
72
+ } catch (err) {
73
+ return { ok: false, failure: err instanceof Error ? err.message : String(err) };
74
+ }
75
+ }
76
+
77
+ export type FileDownload = { ok: true; bytes: number } | { ok: false; failure: string };
78
+
79
+ /** Hop two. Fetches the bytes from the file host and writes them to `destPath`,
80
+ * creating its directory. Never throws, for the same reason as hop one. */
81
+ export async function downloadTelegramFile(input: {
82
+ token: string;
83
+ filePath: string;
84
+ destPath: string;
85
+ fetchImpl?: typeof fetch;
86
+ signal?: AbortSignal;
87
+ }): Promise<FileDownload> {
88
+ const doFetch = input.fetchImpl ?? fetch;
89
+ try {
90
+ const res = await doFetch(
91
+ `${TELEGRAM_API}/file/bot${input.token}/${input.filePath}`,
92
+ input.signal ? { signal: input.signal } : {},
93
+ );
94
+ if (!res.ok) return { ok: false, failure: `telegram returned ${res.status}` };
95
+ const body = Buffer.from(await res.arrayBuffer());
96
+ await mkdir(dirname(input.destPath), { recursive: true });
97
+ await writeFile(input.destPath, body);
98
+ return { ok: true, bytes: body.length };
99
+ } catch (err) {
100
+ return { ok: false, failure: err instanceof Error ? err.message : String(err) };
101
+ }
102
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ globals: false,
7
+ include: ["src/__tests__/**/*.test.ts"],
8
+ },
9
+ });
@@ -10,8 +10,8 @@
10
10
  "pdf-lib": "^1.17.1"
11
11
  },
12
12
  "scripts": {
13
- "build": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/learning-constants/tsconfig.json && tsc -p services/claude-session-manager/tsconfig.json && tsc -p services/whatsapp-channel/tsconfig.json && tsc -p services/webchat-channel/tsconfig.json && tsc -p services/telegram-channel/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
14
- "build:lib": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json",
13
+ "build": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/learning-constants/tsconfig.json && tsc -p lib/telegram-media/tsconfig.json && tsc -p services/claude-session-manager/tsconfig.json && tsc -p services/whatsapp-channel/tsconfig.json && tsc -p services/webchat-channel/tsconfig.json && tsc -p services/telegram-channel/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
14
+ "build:lib": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/telegram-media/tsconfig.json",
15
15
  "gen:canonical-tools": "node scripts/generate-canonical-tool-names.mjs",
16
16
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
17
17
  "build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
@@ -275,9 +275,9 @@ Tools are available via the `admin` MCP server.
275
275
  - `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. **Scope** is three path shapes, all resolved against the account dir: `memory/users/<phone>/documents/quote-<jobId>[-breakdown].pdf`, `jobs/<jobId>/quote/<anything>.pdf`, and `jobs/<jobId>/Quotations/<anything>.pdf`. `jobs/<jobId>/pricing/` is excluded — it holds the internal priced breakdown, excluded with the admin-scope internal view. **Decision** is receipt *identity*, not receipt existence: the gate resolves the PDF's sibling HTML (`.pdf`→`.html`), sha256s it, and requires a match against the sha256 of a file named by `filed.quote` or `filed.breakdown` in `quoting/jobs/<jobId>/render-receipt.json`. `filed.internal` is not client-facing and never matches. Comparing content rather than paths keeps an operator copy of a rendered document passing while hand-authored content fails. A missing sibling HTML blocks: the pipeline files the PDF beside the HTML it printed from, so no local source is no proof of renderer origin. Existence alone was not enough — on the 2026-08-05 bypass the job's figures were rewritten after the render and the delivered document was written later still, so a stale receipt would have authorised the print. **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=<not-receipt-filed|no-source-html|bad-jobid>`. Every non-quote PDF — brochures, works orders, invoices, the admin-scope internal view — passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
276
276
  - `hooks/quote-engine-gate.sh` — **PreToolUse matcher=`Bash`** (a third command on the same `Bash` matcher as `archive-ingest-surface-gate` and `fs-schema-guard-bash-pre`, any exit 2 blocks). Makes the deterministic `quote-engine-run` / `quote-render` tools the only route the account's persisted quoting scripts can run. Blocks (exit 2) a `Bash` `command` that executes `quoting/engine.mjs` or `quoting/render.mjs` directly — the pricing and render scripts must run through their MCP tools, which apply the reconcile / margin-leak / link-liveness gates and write the figures and render receipts. A hand-run `node quoting/engine.mjs` (the SiteDesk 2026-07-25 bypass: a repricing dispatched to content-producer that ran the engine by hand and re-rendered off the deterministic path) skips all of that, so it is refused. It fires only on an execution (a JS runtime invoking the script, or the script run at command position); a read or inspect of the file (`cat`/`grep`/`git diff quoting/engine.mjs`) and every unrelated `Bash` command pass at exit 0. A `mode=verify` reproduction is gated too — it must also run through `quote-engine-run`, so a hand-run `node quoting/engine.mjs mode=verify` is refused. Known fail-open gap (accepted, consistent with the soft posture): a `cd quoting && node engine.mjs` drops the `quoting/` prefix and is not matched. **Block message:** `Blocked: the quoting engine and render scripts must run through their tools, not directly. Use the quote-engine-run tool to price a job (it files the figures) and the quote-render tool to produce the documents (it files them behind the reconcile, margin-leak and link-liveness gates). Running quoting/engine.mjs or quoting/render.mjs by hand skips those gates and files no receipt.` **Log line** (stderr, on a block): `[quote-engine] op=bypass reason=direct-script-exec`. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `Bash` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated commands.
277
277
  - `hooks/quote-pdf-bash-gate.sh` — **PreToolUse matcher=`Bash`.** Closes the Bash route to a client quote PDF. `quote-render-gate` refuses a hand-authored quote at `browser-pdf-save`, but an agent holding `Bash` reaches the same outcome with any HTML-to-PDF converter and meets no control at all (`content-producer` holds `Bash`, `Write`, `Edit` and `browser-pdf-save`; `quote-engine-gate` refuses only execution of the two persisted quoting scripts). Blocks (exit 2) a command that names both an `.html` under a gated quote path — `memory/users/<phone>/documents/`, `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` — and a `.pdf`. The match is structural on the paths, never on the converter's name, so `wkhtmltopdf`, `chromium --print-to-pdf` and `weasyprint` are covered by the same rule. A read that names no PDF (`cat q.html`) passes, and `jobs/<jobId>/pricing/` is never gated. **Block message:** `Blocked: a client quote PDF must come from the quote-render tool, not from a converter run by hand. Run quote-render for this job — it files the document and its render receipt — then print the client HTML path it returns.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=bash-pdf-production`. **Fail-open** on an uninspectable call.
278
- - `hooks/quote-path-write-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit` / `NotebookEdit`.** The roster safeguard expressed as a capability rather than a removal. Blocks (exit 2) a write whose target is under `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` or `quoting/templates/` when the calling agent's card does not grant `quote-render`. `jobs/<jobId>/pricing/` is excluded — the internal priced breakdown is not a client quote artefact — and every other deliverable the agent owns (works orders, heritage statements, survey sheets, drawing registers, trial-pit records, invoice stamps) is untouched. **Caller identity is `agent_type` from the hook envelope**, which Claude Code sets when a hook fires inside a subagent; `MAXY_SPECIALIST` is not usable, since it is stamped only when a specialist is spawned as its own PTY process and reads empty for the native Agent-tool subagents this gate exists to stop. **Card lookup spans both registration families** — the per-account registry (`<accountDir>/.claude/agents/`, `<accountDir>/specialists/agents/`), `$CLAUDE_CONFIG_DIR/agents/`, and the plugin roots (`$(dirname $PLATFORM_ROOT)/premium-plugins/*/agents/`, `$PLATFORM_ROOT/plugins/*/agents/`) — matching on frontmatter `name:` as well as filename, because the quoter's card is filed as `sitedesk--quoter.md` and declares `name: quoter`. Omitting the premium root would block the quoter itself. A write from the main conversation carries no `agent_type` and the admin seat holds no `quote-render`, so it is refused too: no part of a client quote document is another agent's work, the admin's included. **Block message:** `Blocked: quote documents and quote templates are the quoter's work. Dispatch the quoter for this change; it produces the document through the quote-render tool, which files it with its render receipt.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=<quote-path-write-without-render-tool|quote-path-write-unresolved-agent>`. **Fail-open** on an uninspectable envelope.
278
+ - `hooks/quote-path-write-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit` / `NotebookEdit`, and it acts on all four.** A notebook edit is read from `notebook_path`; every other write tool from `file_path`. The roster safeguard expressed as a capability rather than a removal. Blocks (exit 2) a write whose target is under `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` or `quoting/templates/` when the calling agent's card does not grant `quote-render`. `jobs/<jobId>/pricing/` is excluded — the internal priced breakdown is not a client quote artefact — and every other deliverable the agent owns (works orders, heritage statements, survey sheets, drawing registers, trial-pit records, invoice stamps) is untouched. **Caller identity is `agent_type` from the hook envelope**, which Claude Code sets when a hook fires inside a subagent; `MAXY_SPECIALIST` is not usable, since it is stamped only when a specialist is spawned as its own PTY process and reads empty for the native Agent-tool subagents this gate exists to stop. **Card lookup spans both registration families** — the per-account registry (`<accountDir>/.claude/agents/`, `<accountDir>/specialists/agents/`), `$CLAUDE_CONFIG_DIR/agents/`, and the plugin roots (`$(dirname $PLATFORM_ROOT)/premium-plugins/*/agents/`, `$PLATFORM_ROOT/plugins/*/agents/`) — matching on frontmatter `name:` as well as filename, because the quoter's card is filed as `sitedesk--quoter.md` and declares `name: quoter`. Omitting the premium root would block the quoter itself. A write from the main conversation carries no `agent_type` and the admin seat holds no `quote-render`, so it is refused too: no part of a client quote document is another agent's work, the admin's included. **Block message:** `Blocked: quote documents and quote templates are the quoter's work. Dispatch the quoter for this change; it produces the document through the quote-render tool, which files it with its render receipt.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=<quote-path-write-without-render-tool|quote-path-write-unresolved-agent>`. **Fail-open** on an uninspectable envelope.
279
279
  - `hooks/html-structure-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit`.** Refuses a write that would produce an HTML document with a `<style>` element opened inside another one, or with more openers than closers. The fault is not cosmetic: HTML parses style content as raw text, so the **first** `</style>` closes the outer element — everything after it becomes body content and renders as visible text, and the rule immediately after the stray opener is eaten as part of an invalid selector. One fault, two symptoms, both seen on a client quotation that went out on 2026-07-24 and was noticed on 2026-08-07. **Detector, three rules:** HTML comments are stripped first, each replaced by its own newlines, so a `<style>` inside `<!-- … -->` is not scanned and line numbers do not shift (two account templates carry one and are clean; a comment-blind detector reports both); `<style…>`/`</style…>` are then walked case-insensitively tracking depth, and an opener seen at depth > 0 is the fault; a CSS comment protects an **opener** but never a **closer**, because style content is raw text to the HTML parser so `</style>` terminates the element wherever it appears, while an opener is never a parse event inside a stylesheet — it is text the CSS parser reads as an invalid selector, and inside `/* … */` it is discarded (the shipped property-brochure `template.html` and `index.html` document their brand-token override slot exactly that way and are correct); at end of input openers must equal closers, and an unmatched opener is reported at its own line. The same three rules are implemented in the session manager's `html-structure-census`, which walks what is already on disk. **It walks the document the call would produce, not the fragment** — for `Write` that is `content`; for `Edit`/`MultiEdit` the on-disk file is read and the replacements applied in memory first, because the balance rule would otherwise refuse every partial edit and a nested opener whose outer `<style>` is already on disk would pass. **Scope is the whole account with an exclusion list, never a bucket list**: every `.html`/`.htm` resolving inside the account dir (this hook's cwd, as in `fs-schema-guard.sh`) except under `node_modules/`, `.wrangler/`, `.tool/`, `.trash/`. A bucket list is what let the live document through — it sat under `sites/<project>/public/`, which no quote bucket covers. `NotebookEdit` is deliberately not registered: a notebook is not a client document. **Block message:** `Blocked: this document opens a <style> element inside another one (line N). The browser closes the outer element at the first </style>, so the rest of the stylesheet renders as visible text on the page and the rule after the stray opener is discarded. Put the block in the body as its own <style> element, or merge its rules into the one already open.` **Log line:** `[html-structure-gate] op=refuse account=<id8> file=<json-quoted-path> line=<n>`, a direct append to `<logDir>/server.log` resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `<cwd>/logs` ladder, not a stderr echo. There is deliberately **no hard-coded brand fallback**: four brands are co-resident on the laptop, each with its own `~/.<brand>/logs`, so a literal would file this account's path in a sibling brand's `server.log`; when no rung resolves the line is dropped and the refusal still blocks. The path is JSON-quoted because these filenames carry spaces and a bare `key=value` line truncates at the first space. **Fail-open** on an uninspectable envelope (tty / empty stdin / unreadable target / unparseable edit).
280
- - `hooks/fs-schema-guard.sh` — **PreToolUse, registered on `Write` / `Edit` / `MultiEdit` / `NotebookEdit`; acts on `Write`, `Edit` and `NotebookEdit` only** — its tool dispatch has no `MultiEdit` arm, so a `MultiEdit` exits 0 unread and reaches every path this hook otherwise fences (verified by direct invocation, 2026-08-07; `archive-ingest-surface-gate.sh` has the same registered-but-unhandled gap and is tracked with it). The account-dir path guard. It enforces the seeded file schema (`<accountDir>/SCHEMA.md`) on this account's own layout — allowed top-level set, the account-machinery and declared-file deny fences, bucket depth, and the lowercase name convention — and, before any of that, refuses a target that resolves inside a **sibling account** under the same accounts root, for every session including a house-scoped one. Containment is decided on the resolved path, so a symlink planted under this account that points into a sibling is refused rather than read as an own-account path, and a sibling counts as an account only when it carries `account.json`. **Log line** (stderr, on a block): `[fs-guard] blocked path=<path> reason=<top-level|account-machinery|declared-file|over-deep|bad-name|cross-account>`, with `target=<acct8>` added on the cross-account reason. A cross-account block **also records the refused absolute path** in a per-session ledger under the tmpdir (`maxy-fsguard-refused-<sessionId>.txt`), which `fs-schema-guard-bash-post.sh` reads; recording is best-effort and never changes the verdict, and a call carrying no `session_id` records nothing. **Fail-closed** on an uninspectable call (tty / empty stdin); **fail-open** on a missing or empty schema, which governs this account's own layout only and never opens the cross-account path.
280
+ - `hooks/fs-schema-guard.sh` — **PreToolUse, registered on and acting on `Write` / `Edit` / `MultiEdit` / `NotebookEdit`.** A notebook edit is read from `notebook_path`; every other write tool from `file_path`. The account-dir path guard. It enforces the seeded file schema (`<accountDir>/SCHEMA.md`) on this account's own layout — allowed top-level set, the account-machinery and declared-file deny fences, bucket depth, and the lowercase name convention — and, before any of that, refuses a target that resolves inside a **sibling account** under the same accounts root, for every session including a house-scoped one. Containment is decided on the resolved path, so a symlink planted under this account that points into a sibling is refused rather than read as an own-account path, and a sibling counts as an account only when it carries `account.json`. **Log line** (stderr, on a block): `[fs-guard] blocked path=<path> reason=<top-level|account-machinery|declared-file|over-deep|bad-name|cross-account>`, with `target=<acct8>` added on the cross-account reason. A cross-account block **also records the refused absolute path** in a per-session ledger under the tmpdir (`maxy-fsguard-refused-<sessionId>.txt`), which `fs-schema-guard-bash-post.sh` reads; recording is best-effort and never changes the verdict, and a call carrying no `session_id` records nothing. **Fail-closed** on an uninspectable call (tty / empty stdin); **fail-open** on a missing or empty schema, which governs this account's own layout only and never opens the cross-account path.
281
281
  - `hooks/fs-schema-guard-bash-pre.sh` — **PreToolUse matcher=`Bash`.** Records the account root's immediate top-level entry names before the command runs, into `maxy-fsguard-snapshot-<sessionId>.txt` under the tmpdir, so the PostToolUse twin can flag only the entries **this** command created rather than the legacy backlog. It inspects account-root state and never the command string. Always exits 0 — a snapshot that cannot be taken must not block a command, and blocking is the post hook's job.
282
282
  - `hooks/fs-schema-guard-bash-post.sh` — **PostToolUse matcher=`Bash`.** Two independent checks under one exit. **Strays:** diffing the account root against the pre-snapshot, an entry that appeared this command and is not in the allowed set is `[fs-guard-bash] stray path=<name> reason=top-level`, and a badly-named new child of a bucket created this command is `reason=bad-name`. **Evasion:** every path `fs-schema-guard.sh` refused as cross-account in this session that now **exists on disk** was written by some other route after the refusal, and is reported as `[fs-guard-bash] evaded path=<path> reason=cross-account target=<acct8>` — the pair to the guard's own `blocked` line on the same path and target, so a bypass is two grep-able lines rather than a transcript to read. A reported path is dropped from the ledger, so one evasion reports once. The path was refused in this session by this session's own hook, so existence afterwards is attribution: no sibling directory is walked and no mtime is read. The stray fail-opens (missing snapshot, empty allowed set) govern the stray check alone and do not gate the evasion check, because the path guard refuses a sibling target before it parses any schema — an account with no `SCHEMA.md`, and a session whose pre-hook failed open, can each still hold a recorded refusal. A missing or unreadable ledger reports nothing. **No filesystem mutation:** PostToolUse runs after the command, so it reports and never repairs; the standing account-dir reconcile is the net for anything the agent ignores.
283
283
  - `hooks/preference-consult-gate.sh` — **PreToolUse gate on customer-facing document deliverables.** Registered against `mcp__plugin_browser_browser__browser-pdf-save` (a second command on the same matcher as `quote-render-gate`, either exit 2 blocks) and against the email/Outlook send tools plus `SendUserFile`. It fires only in the document-deliverable shape: a `browser-pdf-save` or `SendUserFile` whose path is under `memory/users/<phone>/documents/` (or a `SendUserFile` of a `.pdf`/`.html`/`.docx`), and `email-send`/`email-reply`/`email-draft-send`/`outlook-mail-send`/`outlook-mail-reply`/`outlook-draft-send` carrying a non-empty `attachments`. For a gated call it reads the session transcript (`transcript_path`) and blocks (exit 2) when no `profile-read` tool call ran after the last user message, because the account's layer-2 `Preference` records (signature policy, header, naming, styling) were not consulted this turn, the exact miss that dropped a customer signature on a delivered document. **Block message:** `Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again.` **Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block, `op=allow tool=<name> consulted=true` on an allowed document deliverable. Every non-document tool/shape, and every casual channel reply or plain-text email, passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / transcript missing or unreadable). Scope decision: the gate fires on document deliverables only, not on every send, so it never blocks routine chat. The standing reconcile counterpart for stranded `output/` deliverables is filed as a follow-up task.
@@ -18,6 +18,8 @@
18
18
  # D. PreToolUse mcp__memory__memory-archive-write w/ archiveType=linkedin-connections → ALLOWED
19
19
  # E. PreToolUse Bash invoking conversation-archive-ingest.sh → ALLOWED
20
20
  # F. Default-allow emits a [archive-ingest-gate] decision=allow log line
21
+ # G. PreToolUse MultiEdit on a plugin lib/ path → BLOCKED
22
+ # H. PreToolUse NotebookEdit on a plugin lib/ path → BLOCKED (notebook_path)
21
23
 
22
24
  set -u
23
25
 
@@ -112,6 +114,26 @@ run_case "BYPASS: nested file_path in old_string + top-level file_path in lib/*
112
114
  '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/repo/platform/plugins/memory/mcp/src/lib/conversation-normalisers/whatsapp-text.ts","old_string":"file_path:/safe/path","new_string":"x"}}' \
113
115
  2
114
116
 
117
+ # The gate is registered on the MultiEdit matcher, so the plugin-source fence
118
+ # must reach it. Until this arm existed a MultiEdit exited 0 unread.
119
+ run_case "MultiEdit on platform/plugins/*/lib/* → BLOCKED" \
120
+ '{"hook_event_name":"PreToolUse","tool_name":"MultiEdit","tool_input":{"file_path":"/Users/x/repo/platform/plugins/memory/mcp/src/lib/conversation-normalisers/whatsapp-text.ts","edits":[{"old_string":"a","new_string":"b"}]}}' \
121
+ 2
122
+
123
+ run_case "MultiEdit on README.md → ALLOWED" \
124
+ '{"hook_event_name":"PreToolUse","tool_name":"MultiEdit","tool_input":{"file_path":"/Users/x/repo/README.md","edits":[{"old_string":"a","new_string":"b"}]}}' \
125
+ 0
126
+
127
+ # A notebook edit carries notebook_path, not file_path. The arm named the tool
128
+ # and read the wrong field, so the fence never reached a single one of them.
129
+ run_case "NotebookEdit on platform/plugins/*/lib/* → BLOCKED" \
130
+ '{"hook_event_name":"PreToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/Users/x/repo/platform/plugins/memory/mcp/src/lib/probe.ipynb","new_source":"x"}}' \
131
+ 2
132
+
133
+ run_case "NotebookEdit on a benign path → ALLOWED" \
134
+ '{"hook_event_name":"PreToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/Users/x/repo/notes/scratch.ipynb","new_source":"x"}}' \
135
+ 0
136
+
115
137
  run_case "PreToolUse Bash invoking conversation-archive-ingest.sh → ALLOWED" \
116
138
  '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bash platform/plugins/memory/bin/conversation-archive-ingest.sh /tmp/chat.zip --source whatsapp --owner-element-id 4:abc:1 --participant-person-ids 4:abc:2 --scope admin"}}' \
117
139
  0
@@ -18,6 +18,7 @@
18
18
  # 14. Same, from an account with no SCHEMA.md -> BLOCK cross-account
19
19
  # 15. Write into a sibling dir with no account.json -> ALLOW (not an account)
20
20
  # 16. Symlink under this account resolving into a sibling -> BLOCK cross-account
21
+ # 17. MultiEdit reaches every fence and the refusal ledger -> BLOCK / record
21
22
  set -u
22
23
 
23
24
  HOOK="$(cd "$(dirname "$0")/.." && pwd)/fs-schema-guard.sh"
@@ -253,6 +254,29 @@ run_case "symlink into a sibling blocked" \
253
254
  "$(mkenv Edit file_path "$ACCT_OWN/documents/escape/index.html")" \
254
255
  2 'reason=cross-account target=5e99bba5' "$ACCT_OWN"
255
256
 
257
+ # A MultiEdit carries file_path exactly as an Edit does, and the hook is
258
+ # registered on that matcher, so every fence above must reach it. Until this arm
259
+ # existed a MultiEdit exited 0 unread and every one of them did not.
260
+ run_case "xacct multiedit blocked" \
261
+ "$(mkenv MultiEdit file_path "$ACCT_SIB/sites/public/index.html")" \
262
+ 2 'reason=cross-account target=5e99bba5' "$ACCT_OWN"
263
+
264
+ run_case "own account multiedit allowed" \
265
+ "$(mkenv MultiEdit file_path "$ACCT_OWN/documents/a.pdf")" \
266
+ 0 "" "$ACCT_OWN"
267
+
268
+ run_case "declared multiedit blocks" \
269
+ "$(mkenv MultiEdit file_path 'wa-channel-bindings.json')" \
270
+ 2 "reason=declared-file" "$ACCT_F"
271
+
272
+ run_case "machinery multiedit blocks" \
273
+ "$(mkenv MultiEdit file_path 'account.json')" \
274
+ 2 "reason=account-machinery"
275
+
276
+ run_case "over-deep multiedit blocks" \
277
+ "$(mkenv MultiEdit file_path 'projects/acme/deploy/sources/a.txt')" \
278
+ 2 "reason=over-deep"
279
+
256
280
  # Refusal ledger: a refused cross-account path is recorded so the Bash post-hook
257
281
  # can report it if a later shell command lands it anyway. Isolated tmp so the run
258
282
  # never collides with a live session's ledger.
@@ -311,6 +335,16 @@ run_case_ledger "ledger own-account allowed" \
311
335
  0 "" "$ACCT_OWN"
312
336
  assert_no_ledger "ledger own-account writes nothing"
313
337
 
338
+ # The refusal ledger is what the evasion report reads. A MultiEdit refusal that
339
+ # blocks but records nothing leaves that report blind to this route.
340
+ rm -f "$LEDGER_TMP"/maxy-fsguard-refused-*
341
+ run_case_ledger "ledger multiedit refusal blocks" \
342
+ "$(mkenv_sid MultiEdit "$LSID" file_path "$XTARGET")" \
343
+ 2 'reason=cross-account target=5e99bba5' "$ACCT_OWN"
344
+ if [ -f "$LEDGER" ] && grep -qxF "$(realpath_of "$XTARGET")" "$LEDGER"; then
345
+ echo "PASS: ledger holds the multiedit refusal"; PASS=$((PASS+1));
346
+ else echo "FAIL: multiedit refusal not recorded at $LEDGER" >&2; FAIL=$((FAIL+1)); fi
347
+
314
348
  # A path outside the accounts root is allowed here, so it records nothing.
315
349
  rm -f "$LEDGER_TMP"/maxy-fsguard-refused-*
316
350
  run_case_ledger "ledger out-of-tree allowed" \
@@ -32,6 +32,9 @@
32
32
  # K. a read of a gated path → ALLOWED
33
33
  # L. MultiEdit is gated like Write and Edit → BLOCKED
34
34
  # M. a block emits the op=bypass fault line
35
+ # N. NotebookEdit is gated like Write and Edit → BLOCKED
36
+ # O. the quoter with a NotebookEdit → ALLOWED
37
+ # P. NotebookEdit outside a gated bucket → ALLOWED
35
38
 
36
39
  set -u
37
40
 
@@ -82,6 +85,17 @@ if sys.argv[3]:
82
85
  print(json.dumps(d))' "$1" "$2" "$3"
83
86
  }
84
87
 
88
+ envelope_nb() { # tool_name, notebook_path, agent_type ("" = absent)
89
+ python3 -c '
90
+ import json, sys
91
+ d = {"hook_event_name": "PreToolUse", "tool_name": sys.argv[1],
92
+ "tool_input": {"notebook_path": sys.argv[2]}}
93
+ if sys.argv[3]:
94
+ d["agent_type"] = sys.argv[3]
95
+ d["agent_id"] = "agent_01"
96
+ print(json.dumps(d))' "$1" "$2" "$3"
97
+ }
98
+
85
99
  run_case() {
86
100
  local name="$1" stdin="$2" expected_exit="$3"
87
101
  local actual_exit
@@ -133,6 +147,17 @@ run_case "K: a read of a gated path → ALLOWED" \
133
147
  run_case "L: MultiEdit is gated like Write and Edit → BLOCKED" \
134
148
  "$(envelope MultiEdit "$QUOTE" content-producer)" 2
135
149
 
150
+ # The gate is registered on the NotebookEdit matcher and a notebook edit carries
151
+ # notebook_path. Until this arm existed every one of them exited 0 unread.
152
+ run_case "N: NotebookEdit is gated like Write and Edit → BLOCKED" \
153
+ "$(envelope_nb NotebookEdit 'jobs/job-1/quote/Q 1879 - Client.ipynb' content-producer)" 2
154
+
155
+ run_case "O: the quoter with a NotebookEdit → ALLOWED" \
156
+ "$(envelope_nb NotebookEdit 'jobs/job-1/quote/Q 1879 - Client.ipynb' sitedesk:quoter)" 0
157
+
158
+ run_case "P: NotebookEdit outside a gated bucket → ALLOWED" \
159
+ "$(envelope_nb NotebookEdit 'output/works-order.ipynb' content-producer)" 0
160
+
136
161
  check_reason() { # name, agent_type, expected reason
137
162
  local out
138
163
  out=$( cd "$ACCT" && export PLATFORM_ROOT="$FAKE/platform" && printf '%s' "$(envelope Write "$QUOTE" "$2")" | bash "$HOOK" 2>&1 >/dev/null )
@@ -30,7 +30,7 @@
30
30
  # and other archiveTypes pass
31
31
  # through unchanged.)
32
32
  #
33
- # 2. PreToolUse Edit/Write/NotebookEdit: deny writes under
33
+ # 2. PreToolUse Edit/Write/MultiEdit/NotebookEdit: deny writes under
34
34
  # `*platform/plugins/*/lib/*` (parser/CSV-shape source for any *-import or
35
35
  # *-export plugin). Preserved — defence in depth even though
36
36
  # the database-operator template no longer lists Edit/Write tools.
@@ -189,10 +189,17 @@ if [ "$TOOL_NAME" = "mcp__memory__memory-archive-write" ]; then
189
189
  fi
190
190
  fi
191
191
 
192
- # --- Block 4: plugin-source path block (Edit/Write/NotebookEdit) -----------
192
+ # --- Block 4: plugin-source path block (Edit/Write/MultiEdit/NotebookEdit) --
193
193
  case "$TOOL_NAME" in
194
- Edit|Write|NotebookEdit)
195
- FILE_PATH=$(extract_tool_input_field file_path)
194
+ Edit|Write|MultiEdit|NotebookEdit)
195
+ # MultiEdit carries file_path exactly as Edit does. A notebook edit carries
196
+ # notebook_path instead, so reading only file_path left every NotebookEdit
197
+ # unfenced even though the arm already named it.
198
+ if [ "$TOOL_NAME" = "NotebookEdit" ]; then
199
+ FILE_PATH=$(extract_tool_input_field notebook_path)
200
+ else
201
+ FILE_PATH=$(extract_tool_input_field file_path)
202
+ fi
196
203
  case "$FILE_PATH" in
197
204
  */platform/plugins/*/lib/*|platform/plugins/*/lib/*)
198
205
  emit_decision "block" "plugin-source-edit path=${FILE_PATH}" \