@rubytech/create-sitedesk-code 0.1.512 → 0.1.514

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 (194) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2028-declared-file-write-deny.md +303 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2052-account-machinery-write-fence.md +250 -0
  4. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +127 -0
  5. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2052-account-machinery-write-fence-design.md +150 -0
  6. package/payload/platform/lib/account-schema-regions/dist/index.d.ts +5 -0
  7. package/payload/platform/lib/account-schema-regions/dist/index.d.ts.map +1 -1
  8. package/payload/platform/lib/account-schema-regions/dist/index.js +5 -0
  9. package/payload/platform/lib/account-schema-regions/dist/index.js.map +1 -1
  10. package/payload/platform/lib/account-schema-regions/src/index.ts +5 -0
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +14 -0
  12. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +79 -0
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +19 -0
  14. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +64 -1
  15. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  16. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  17. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +1 -1
  18. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  19. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
  20. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +26 -0
  21. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +40 -4
  22. package/payload/platform/scripts/logs-read.sh +47 -24
  23. package/payload/platform/scripts/logs-read.test.sh +73 -31
  24. package/payload/platform/scripts/provision-worktree.sh +80 -0
  25. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  26. package/payload/server/{chunk-2WAXM5N2.js → chunk-S6HYTIU3.js} +2 -3
  27. package/payload/server/maxy-edge.js +1 -1
  28. package/payload/server/public/activity.html +5 -5
  29. package/payload/server/public/agents.html +4 -4
  30. package/payload/server/public/assets/{AdminLoginScreens-bksN0XPe.js → AdminLoginScreens-Cs1jSnOF.js} +1 -1
  31. package/payload/server/public/assets/AdminLoginScreens-Cs1jSnOF.js.br +0 -0
  32. package/payload/server/public/assets/AdminLoginScreens-Cs1jSnOF.js.gz +0 -0
  33. package/payload/server/public/assets/{AdminShell-BMPEWNZk.js → AdminShell-DkrURZnF.js} +1 -1
  34. package/payload/server/public/assets/AdminShell-DkrURZnF.js.br +0 -0
  35. package/payload/server/public/assets/AdminShell-DkrURZnF.js.gz +0 -0
  36. package/payload/server/public/assets/{activity-ByWYF5dC.js → activity-BgZISMuE.js} +1 -1
  37. package/payload/server/public/assets/activity-BgZISMuE.js.br +0 -0
  38. package/payload/server/public/assets/activity-BgZISMuE.js.gz +0 -0
  39. package/payload/server/public/assets/{admin-6QN-k3zQ.js → admin-DjFysc3-.js} +1 -1
  40. package/payload/server/public/assets/admin-DjFysc3-.js.br +0 -0
  41. package/payload/server/public/assets/admin-DjFysc3-.js.gz +0 -0
  42. package/payload/server/public/assets/{agents-C0rTfrYd.js → agents-Bx-MSZW6.js} +1 -1
  43. package/payload/server/public/assets/agents-Bx-MSZW6.js.br +0 -0
  44. package/payload/server/public/assets/agents-Bx-MSZW6.js.gz +0 -0
  45. package/payload/server/public/assets/{browser-4tTaem8N.js → browser-1I7syhHu.js} +1 -1
  46. package/payload/server/public/assets/browser-1I7syhHu.js.br +0 -0
  47. package/payload/server/public/assets/browser-1I7syhHu.js.gz +0 -0
  48. package/payload/server/public/assets/{calendar-BEjnkKG1.js → calendar-D_04JIo1.js} +1 -1
  49. package/payload/server/public/assets/calendar-D_04JIo1.js.br +0 -0
  50. package/payload/server/public/assets/calendar-D_04JIo1.js.gz +0 -0
  51. package/payload/server/public/assets/chat-BFH3FM5a.js +1 -0
  52. package/payload/server/public/assets/chat-BFH3FM5a.js.br +0 -0
  53. package/payload/server/public/assets/chat-BFH3FM5a.js.gz +0 -0
  54. package/payload/server/public/assets/chevron-left-Ux9-qLkk.js +1 -0
  55. package/payload/server/public/assets/chevron-right-BwIuCfyG.js +1 -0
  56. package/payload/server/public/assets/chevron-right-BwIuCfyG.js.br +0 -0
  57. package/payload/server/public/assets/clock-DhJdZRhC.js +1 -0
  58. package/payload/server/public/assets/clock-DhJdZRhC.js.br +0 -0
  59. package/payload/server/public/assets/data-ChY_uSIk.js +1 -0
  60. package/payload/server/public/assets/data-ChY_uSIk.js.br +0 -0
  61. package/payload/server/public/assets/data-ChY_uSIk.js.gz +0 -0
  62. package/payload/server/public/assets/{file-text-DMKw3nIk.js → file-text-DkvKqHMx.js} +1 -1
  63. package/payload/server/public/assets/file-text-DkvKqHMx.js.br +0 -0
  64. package/payload/server/public/assets/file-text-DkvKqHMx.js.gz +0 -0
  65. package/payload/server/public/assets/{graph-C3vW8lvw.js → graph-CCZhrwZX.js} +1 -1
  66. package/payload/server/public/assets/graph-CCZhrwZX.js.br +0 -0
  67. package/payload/server/public/assets/graph-CCZhrwZX.js.gz +0 -0
  68. package/payload/server/public/assets/{graph-labels-BZgd0L6n.js → graph-labels-BJ7puQkX.js} +1 -1
  69. package/payload/server/public/assets/graph-labels-BJ7puQkX.js.br +0 -0
  70. package/payload/server/public/assets/graph-labels-BJ7puQkX.js.gz +0 -0
  71. package/payload/server/public/assets/{maximize-2-la3IBA3M.js → maximize-2-Xdj804U1.js} +1 -1
  72. package/payload/server/public/assets/maximize-2-Xdj804U1.js.br +0 -0
  73. package/payload/server/public/assets/maximize-2-Xdj804U1.js.gz +0 -0
  74. package/payload/server/public/assets/{operator-D9NgVL9_.js → operator-BkfNpBs7.js} +1 -1
  75. package/payload/server/public/assets/operator-BkfNpBs7.js.br +0 -0
  76. package/payload/server/public/assets/operator-BkfNpBs7.js.gz +0 -0
  77. package/payload/server/public/assets/{page-DC11gesX.js → page-BoYc9c4a.js} +1 -1
  78. package/payload/server/public/assets/page-BoYc9c4a.js.br +0 -0
  79. package/payload/server/public/assets/page-BoYc9c4a.js.gz +0 -0
  80. package/payload/server/public/assets/{page-BWFHRIAH.js → page-D5n8ShE7.js} +1 -1
  81. package/payload/server/public/assets/page-D5n8ShE7.js.br +0 -0
  82. package/payload/server/public/assets/page-D5n8ShE7.js.gz +0 -0
  83. package/payload/server/public/assets/{public-DQbbLHQN.js → public-CX5XSrxD.js} +1 -1
  84. package/payload/server/public/assets/public-CX5XSrxD.js.br +0 -0
  85. package/payload/server/public/assets/public-CX5XSrxD.js.gz +0 -0
  86. package/payload/server/public/assets/{rotate-ccw-OQD5si8N.js → rotate-ccw-dh_OhL23.js} +1 -1
  87. package/payload/server/public/assets/rotate-ccw-dh_OhL23.js.br +0 -0
  88. package/payload/server/public/assets/rotate-ccw-dh_OhL23.js.gz +0 -0
  89. package/payload/server/public/assets/{routines-asrBGZUR.js → routines-bb7dxcrr.js} +1 -1
  90. package/payload/server/public/assets/routines-bb7dxcrr.js.br +0 -0
  91. package/payload/server/public/assets/routines-bb7dxcrr.js.gz +0 -0
  92. package/payload/server/public/assets/{skills-D4ECwoxa.js → skills-TeMwxXmJ.js} +1 -1
  93. package/payload/server/public/assets/skills-TeMwxXmJ.js.br +0 -0
  94. package/payload/server/public/assets/skills-TeMwxXmJ.js.gz +0 -0
  95. package/payload/server/public/assets/{tasks-U3QdbUsO.js → tasks-BqBBJlkg.js} +1 -1
  96. package/payload/server/public/assets/tasks-BqBBJlkg.js.br +0 -0
  97. package/payload/server/public/assets/tasks-BqBBJlkg.js.gz +0 -0
  98. package/payload/server/public/assets/{time-entry-format-BgdTKgYr.js → time-entry-format-Cy_T-wiD.js} +1 -1
  99. package/payload/server/public/assets/time-entry-format-Cy_T-wiD.js.br +3 -0
  100. package/payload/server/public/assets/time-entry-format-Cy_T-wiD.js.gz +0 -0
  101. package/payload/server/public/assets/{triangle-alert-Ck_3VhT9.js → triangle-alert-D-bSbPnX.js} +1 -1
  102. package/payload/server/public/assets/triangle-alert-D-bSbPnX.js.br +1 -0
  103. package/payload/server/public/assets/triangle-alert-D-bSbPnX.js.gz +0 -0
  104. package/payload/server/public/assets/{useCopyFeedback-BdUwyNpa.js → useCopyFeedback-L7L55nFl.js} +1 -1
  105. package/payload/server/public/assets/useCopyFeedback-L7L55nFl.js.br +0 -0
  106. package/payload/server/public/assets/useCopyFeedback-L7L55nFl.js.gz +0 -0
  107. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css +1 -0
  108. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css.br +0 -0
  109. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css.gz +0 -0
  110. package/payload/server/public/assets/{useVoiceRecorder-ChnBrzbZ.js → useVoiceRecorder-MuZ7lpet.js} +1 -1
  111. package/payload/server/public/assets/useVoiceRecorder-MuZ7lpet.js.br +0 -0
  112. package/payload/server/public/assets/useVoiceRecorder-MuZ7lpet.js.gz +0 -0
  113. package/payload/server/public/assets/{wrench-Cpee3C3J.js → wrench-DUG1SZQB.js} +1 -1
  114. package/payload/server/public/assets/wrench-DUG1SZQB.js.br +0 -0
  115. package/payload/server/public/assets/wrench-DUG1SZQB.js.gz +0 -0
  116. package/payload/server/public/brand-defaults.css +2 -0
  117. package/payload/server/public/browser.html +4 -4
  118. package/payload/server/public/calendar.html +7 -7
  119. package/payload/server/public/chat.html +13 -13
  120. package/payload/server/public/data.html +11 -11
  121. package/payload/server/public/graph.html +9 -9
  122. package/payload/server/public/index.html +14 -14
  123. package/payload/server/public/operator.html +14 -14
  124. package/payload/server/public/public.html +13 -13
  125. package/payload/server/public/routines.html +6 -6
  126. package/payload/server/public/skills.html +5 -5
  127. package/payload/server/public/tasks.html +6 -6
  128. package/payload/server/server.js +126 -47
  129. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js.br +0 -0
  130. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js.gz +0 -0
  131. package/payload/server/public/assets/AdminShell-BMPEWNZk.js.br +0 -0
  132. package/payload/server/public/assets/AdminShell-BMPEWNZk.js.gz +0 -0
  133. package/payload/server/public/assets/activity-ByWYF5dC.js.br +0 -0
  134. package/payload/server/public/assets/activity-ByWYF5dC.js.gz +0 -0
  135. package/payload/server/public/assets/admin-6QN-k3zQ.js.br +0 -0
  136. package/payload/server/public/assets/admin-6QN-k3zQ.js.gz +0 -0
  137. package/payload/server/public/assets/agents-C0rTfrYd.js.br +0 -0
  138. package/payload/server/public/assets/agents-C0rTfrYd.js.gz +0 -0
  139. package/payload/server/public/assets/browser-4tTaem8N.js.br +0 -0
  140. package/payload/server/public/assets/browser-4tTaem8N.js.gz +0 -0
  141. package/payload/server/public/assets/calendar-BEjnkKG1.js.br +0 -0
  142. package/payload/server/public/assets/calendar-BEjnkKG1.js.gz +0 -0
  143. package/payload/server/public/assets/chat-DBgzjDIE.js +0 -1
  144. package/payload/server/public/assets/chat-DBgzjDIE.js.br +0 -0
  145. package/payload/server/public/assets/chat-DBgzjDIE.js.gz +0 -0
  146. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +0 -1
  147. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +0 -1
  148. package/payload/server/public/assets/chevron-right-CQfPGsFb.js.br +0 -0
  149. package/payload/server/public/assets/clock-Dn6FHB51.js +0 -1
  150. package/payload/server/public/assets/clock-Dn6FHB51.js.br +0 -0
  151. package/payload/server/public/assets/clock-Dn6FHB51.js.gz +0 -0
  152. package/payload/server/public/assets/data-D_e6Vdfd.js +0 -1
  153. package/payload/server/public/assets/data-D_e6Vdfd.js.br +0 -0
  154. package/payload/server/public/assets/data-D_e6Vdfd.js.gz +0 -0
  155. package/payload/server/public/assets/file-text-DMKw3nIk.js.br +0 -0
  156. package/payload/server/public/assets/file-text-DMKw3nIk.js.gz +0 -0
  157. package/payload/server/public/assets/graph-C3vW8lvw.js.br +0 -0
  158. package/payload/server/public/assets/graph-C3vW8lvw.js.gz +0 -0
  159. package/payload/server/public/assets/graph-labels-BZgd0L6n.js.br +0 -0
  160. package/payload/server/public/assets/graph-labels-BZgd0L6n.js.gz +0 -0
  161. package/payload/server/public/assets/maximize-2-la3IBA3M.js.br +0 -0
  162. package/payload/server/public/assets/maximize-2-la3IBA3M.js.gz +0 -0
  163. package/payload/server/public/assets/operator-D9NgVL9_.js.br +0 -0
  164. package/payload/server/public/assets/operator-D9NgVL9_.js.gz +0 -0
  165. package/payload/server/public/assets/page-BWFHRIAH.js.br +0 -0
  166. package/payload/server/public/assets/page-BWFHRIAH.js.gz +0 -0
  167. package/payload/server/public/assets/page-DC11gesX.js.br +0 -0
  168. package/payload/server/public/assets/page-DC11gesX.js.gz +0 -0
  169. package/payload/server/public/assets/public-DQbbLHQN.js.br +0 -0
  170. package/payload/server/public/assets/public-DQbbLHQN.js.gz +0 -0
  171. package/payload/server/public/assets/rotate-ccw-OQD5si8N.js.br +0 -0
  172. package/payload/server/public/assets/rotate-ccw-OQD5si8N.js.gz +0 -0
  173. package/payload/server/public/assets/routines-asrBGZUR.js.br +0 -0
  174. package/payload/server/public/assets/routines-asrBGZUR.js.gz +0 -0
  175. package/payload/server/public/assets/skills-D4ECwoxa.js.br +0 -0
  176. package/payload/server/public/assets/skills-D4ECwoxa.js.gz +0 -0
  177. package/payload/server/public/assets/tasks-U3QdbUsO.js.br +0 -0
  178. package/payload/server/public/assets/tasks-U3QdbUsO.js.gz +0 -0
  179. package/payload/server/public/assets/time-entry-format-BgdTKgYr.js.br +0 -0
  180. package/payload/server/public/assets/time-entry-format-BgdTKgYr.js.gz +0 -0
  181. package/payload/server/public/assets/triangle-alert-Ck_3VhT9.js.br +0 -0
  182. package/payload/server/public/assets/triangle-alert-Ck_3VhT9.js.gz +0 -0
  183. package/payload/server/public/assets/useCopyFeedback-BdUwyNpa.js.br +0 -0
  184. package/payload/server/public/assets/useCopyFeedback-BdUwyNpa.js.gz +0 -0
  185. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css +0 -1
  186. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css.br +0 -0
  187. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css.gz +0 -0
  188. package/payload/server/public/assets/useVoiceRecorder-ChnBrzbZ.js.br +0 -0
  189. package/payload/server/public/assets/useVoiceRecorder-ChnBrzbZ.js.gz +0 -0
  190. package/payload/server/public/assets/wrench-Cpee3C3J.js.br +0 -2
  191. package/payload/server/public/assets/wrench-Cpee3C3J.js.gz +0 -0
  192. /package/payload/server/public/assets/{useSubAccountSwitcher-CJKjEPNN.js → useSubAccountSwitcher-DH0P6QMV.js} +0 -0
  193. /package/payload/server/public/assets/{useSubAccountSwitcher-CJKjEPNN.js.br → useSubAccountSwitcher-DH0P6QMV.js.br} +0 -0
  194. /package/payload/server/public/assets/{useSubAccountSwitcher-CJKjEPNN.js.gz → useSubAccountSwitcher-DH0P6QMV.js.gz} +0 -0
@@ -0,0 +1,127 @@
1
+ # Task 2028 — the write guard tells a declared file from an operator bucket
2
+
3
+ Design, 2026-07-27. Lane: Platform / account schema · write guard.
4
+
5
+ ## The fault
6
+
7
+ `fs-schema-guard.sh` is the PreToolUse guard for `Write`, `Edit` and `NotebookEdit`.
8
+ It parses the account's ```` ```allowed-top-level ```` fence and blocks a write whose
9
+ first path segment is absent from it. The standing reconcile parses the same fence
10
+ to decide what is not a stray. One list, two meanings.
11
+
12
+ Task 1902 declared eight control-plane files so the reconcile would stop naming
13
+ them. Because names union into that one fence, the guard began allowing
14
+ agent writes to them: measured, the same envelope returns exit 2 against the
15
+ pre-merge schema and exit 0 against the merged one for `wa-channel-bindings.json`,
16
+ `telegram-channel-bindings.json`, `webchat-channel-bindings.json`,
17
+ `canonical-webchat-session.json`, `session-titles.json`, `agents-disabled.json`,
18
+ `calendar-availability.json` and `data-portal.json`.
19
+
20
+ The fence already permits `account.json`, `SCHEMA.md`, `secrets/` and `.claude/` on
21
+ the untouched shipped template, so it is a layout guard, not a trust boundary. This
22
+ task makes the guard enforce the intent the generated region already states: a
23
+ declared file "is written whole by its owner. Neither is operator data."
24
+
25
+ ## Design
26
+
27
+ ### 1. A second fence, emitted by the merge
28
+
29
+ `account-schema-owned-dirs.py` `merge()` gains a third generated region, written
30
+ with the same strip-then-append shape as the two that exist, so a re-merge is
31
+ idempotent:
32
+
33
+ ```
34
+ <!-- declared-files:start -->
35
+ ## Declared files (written by platform code, owned by a plugin)
36
+
37
+ ...one paragraph of prose...
38
+
39
+ ```declared-files
40
+ webchat-channel-bindings.json admin
41
+ wa-channel-bindings.json admin
42
+ data-portal.json cloudflare
43
+ ```
44
+ <!-- declared-files:end -->
45
+ ```
46
+
47
+ One line per platform-written declared file, in `resolve()` order, as
48
+ `name<TAB>owning-plugin`. The region is emitted only when at least one such file
49
+ is declared.
50
+
51
+ **Two of the eight declared files are agent-written, and are excluded.** The
52
+ review established that `calendar-availability.json` and `data-portal.json` have
53
+ no writer in platform code: `publish-availability.ts:151` and
54
+ `portal-index-push.mjs:52,380` only read them, and the `calendar-site` and
55
+ `data-portal` skills instruct the agent to author them with `Write`
56
+ ("there is no separate editor"). Denying them would break the only path that
57
+ creates them. A file declaration may therefore carry `"writtenBy": "agent"`,
58
+ which keeps the name in `allowed-top-level` (so the reconcile still never calls
59
+ it a stray) and out of the declared-files fence (so the guard never denies it).
60
+ The two declarations gain that key, and their descriptions are corrected: both
61
+ named a writer that only reads.
62
+
63
+ The owner column names the *declaring* plugin, which is where the declaration
64
+ lives rather than necessarily the module that writes the file — Task 1902 moved
65
+ declarations onto plugins no brand excludes for reach, so a shared service may
66
+ write a file declared on `admin`. The block message therefore points at the
67
+ owning code and does not promise a plugin tool. `allowed-top-level` is
68
+ not touched, so the reconcile keeps reading exactly what it reads today, and the two
69
+ existing region readers (`platform/lib/account-schema-regions`, the cloudflare
70
+ `schema-exposed-dirs.mjs` copy) both anchor on their own markers and on the
71
+ `allowed-top-level` fence by name, so an appended region is invisible to them.
72
+
73
+ The owning-plugin column exists because the block message has to name the owner,
74
+ and the descriptive region carries the plugin's own description, not its name.
75
+
76
+ ### 2. The guard denies
77
+
78
+ `fs-schema-guard.sh` parses the new fence with one awk expression, the same shape it
79
+ already uses for the allowed set. After the top-level check passes and before the
80
+ depth check, a target whose first path segment matches a declared name is blocked:
81
+
82
+ ```
83
+ [fs-guard] blocked path=<rel> reason=declared-file
84
+ ```
85
+
86
+ plus an operator-visible line naming the owning plugin and pointing the agent at it.
87
+ An account whose SCHEMA.md carries no `declared-files` fence denies nothing, which is
88
+ the same fail-open posture the guard already takes on a missing or empty schema.
89
+
90
+ The match is on the first path segment, not on the whole relative path, so a write to
91
+ `wa-channel-bindings.json/anything` is blocked for the same reason.
92
+
93
+ ### 3. The Bash post-hook stays quiet
94
+
95
+ A Bash command that creates a declared file emits no stray feedback. That is the
96
+ behaviour Task 1902 produced and it is kept deliberately, not inherited: the post
97
+ hook fires only on a name that appeared during the command, and a declared file
98
+ already exists on any live account, so a Bash edit to one is invisible there
99
+ regardless of the allowed set. A signal that catches only the first creation and
100
+ misses every subsequent edit is not a boundary. The decision is written into the
101
+ hook's header comment and pinned by a test, so a future edit has to argue with it
102
+ rather than flip it silently.
103
+
104
+ ## Testing
105
+
106
+ - `fs-schema-guard.test.sh`: one case per declared file asserting exit 2 with
107
+ `reason=declared-file` and the owner name in the message; an operator bucket write
108
+ under the same schema asserting exit 0; a declared name under an account that
109
+ carries it in `allowed-top-level` but has no `declared-files` fence asserting
110
+ exit 0, which pins the deny to the fence rather than to a list baked into the
111
+ hook.
112
+ - `fs-schema-guard-bash.test.sh`: a Bash command that creates a declared file
113
+ asserting exit 0 and no `[fs-guard-bash]` line.
114
+ - `account-schema-owned-dirs.test.sh`: the merge emits the fence with
115
+ `name<TAB>plugin` for each declared file, emits nothing when no file is declared,
116
+ and a second merge leaves the file byte-identical.
117
+ - Mutation check: remove the guard's deny branch and confirm the per-file cases fail.
118
+ - The reconcile's own suite still reports no declared file as a stray.
119
+
120
+ ## Out of scope
121
+
122
+ - The fence's pre-existing permissiveness for `account.json`, `secrets/` and
123
+ `.claude/`. It predates Task 1902 and is a separate decision, filed as
124
+ `.tasks/archive/2052-the-allowed-fence-permits-agent-writes-to-the-account-s-own-machinery.md`
125
+ (renumbered from 2048; landed 2026-07-27).
126
+ - The reconcile. It reads the fence correctly and needs no change.
127
+ - Adding or removing declarations. Task 1902 settled the set.
@@ -0,0 +1,150 @@
1
+ # Task 2052 — the write guard denies the account's own machinery
2
+
3
+ Design, 2026-07-27. Lane: Platform / account schema · write guard.
4
+
5
+ ## The fault
6
+
7
+ `fs-schema-guard.sh` allows a `Write`, `Edit` or `NotebookEdit` whose first path
8
+ segment is in the account's ```` ```allowed-top-level ```` fence. The shipped
9
+ template lists `account.json`, `SCHEMA.md`, `secrets/`, `.claude/` and `.git`, so
10
+ each is agent-writable. Measured against the untouched shipped template, an
11
+ envelope targeting `account.json` returns exit 0.
12
+
13
+ None of the five is operator data:
14
+
15
+ - `account.json` carries the account identity, role, tier and enabled plugins.
16
+ `admin-user-management/SKILL.md:39` states the position already: "Direct `Edit`
17
+ or `Write` on `account.json` is forbidden by IDENTITY.md doctrine — there is no
18
+ server-side gate, so the doctrine line is the only thing standing between an
19
+ agent slip and a silently corrupted account file." Mutations have dedicated
20
+ tools (`account-update`, `plugin-toggle-enabled`, `admin-add`, `admin-remove`).
21
+ - `SCHEMA.md` is the schema the guard itself parses. A write to it widens the
22
+ fence the guard reads on the next call.
23
+ - `.claude/` holds the account's project-level `settings.json`, which is where
24
+ `provision-account-dir.sh:62,77-143` registers this guard and both Bash hooks. A
25
+ write to it can delete the guard's own registration.
26
+ - `secrets/` holds provisioned credentials.
27
+ - `.git` is the account directory's git repository, created at
28
+ `provision-account-dir.sh:59` so Claude Code discovers `.claude/`.
29
+
30
+ ## Decision per name
31
+
32
+ All five are denied to the agent's write tools. No legitimate agent
33
+ `Write`/`Edit`/`NotebookEdit` path exists for any of them:
34
+
35
+ | Name | Who writes it |
36
+ |---|---|
37
+ | `account.json` | `provision-account-dir.sh:310-349` and the account MCP tools |
38
+ | `SCHEMA.md` | `provision-account-dir.sh:40-41` and the owned-dirs merge |
39
+ | `secrets/` | the code that mints each credential; the one skill-instructed path is a Bash append (`cloudflare/skills/data-portal/SKILL.md:161`), which this guard never sees |
40
+ | `.claude/` | `provision-account-dir.sh:26,61-143` |
41
+ | `.git` | git itself |
42
+
43
+ ## Design
44
+
45
+ ### 1. A third fence, shipped in the template
46
+
47
+ `platform/templates/account-schema/SCHEMA.md` gains a fenced block below the
48
+ allowed-top-level section:
49
+
50
+ ````
51
+ ```agent-denied-top-level
52
+ account.json the account's identity and settings, changed through the account and admin tools
53
+ SCHEMA.md the schema this guard reads, so a hand edit widens the guard's own fence
54
+ secrets provisioned credentials, written by the code that mints each one
55
+ .claude the agent's own settings and hooks, seeded when the account is provisioned
56
+ .git the account directory's git internals
57
+ ```
58
+ ````
59
+
60
+ One line per name as `name<TAB>reason`, the same shape as the `declared-files`
61
+ fence. The reason column exists because there is no owning plugin to name here,
62
+ so the block message needs the schema to supply its own explanation.
63
+
64
+ The block is authored in the template rather than generated. The owned-dirs merge
65
+ (`account-schema-owned-dirs.py`) rewrites only its three marker-delimited regions
66
+ and the `allowed-top-level` fence in place, so a fence in the template body
67
+ survives a re-merge untouched. `allowed-top-level` is not modified, so the
68
+ standing reconcile keeps reading exactly what it reads today and still never
69
+ names any of the five as a stray — the one-list-two-meanings fault Task 2028
70
+ fixed is not reintroduced.
71
+
72
+ ### 2. The guard denies
73
+
74
+ `fs-schema-guard.sh` parses the new fence with one awk expression, the same shape
75
+ it uses for the other two. The check sits after the top-level check and before
76
+ the declared-file check, and matches on the first path segment, so
77
+ `.claude/settings.json` and `secrets/cloudflare.env` are both blocked. No name is
78
+ in both fences (the declared set is the six platform-written `*.json` files the
79
+ merge emits), so the ordering never changes which reason is reported.
80
+
81
+ ```
82
+ [fs-guard] blocked path=<rel> reason=account-machinery
83
+ ```
84
+
85
+ The operator-visible line names the segment and carries that name's reason from
86
+ the fence, then points at the code that owns it. No task numbers or internal refs
87
+ appear in it, per the guard's existing rule.
88
+
89
+ An account whose `SCHEMA.md` carries no `agent-denied-top-level` fence denies
90
+ nothing. That is the same fail-open posture the guard already takes on a missing
91
+ schema and on a missing `declared-files` fence.
92
+
93
+ Which accounts lack it is not uniform, and the split matters. The fence lives in
94
+ the template body, and only `provision_account_dir` copies that body
95
+ (`provision-account-dir.sh:40-41`, an unconditional `cp` of the whole template).
96
+ `setup-account.sh:59` calls it once, for the house account. The standing
97
+ all-accounts reconcile merges only the marker-delimited regions. So the house
98
+ account gains the fence on the next install, and a client sub-account, which is
99
+ provisioned once at creation, keeps a fence-less schema indefinitely. Closing
100
+ that is filed as `.tasks/pending/2056-the-account-machinery-deny-fence-never-reaches-an-existing-sub-account.md`,
101
+ following the durable-backfill pattern Task 1683 and Task 1929 already
102
+ established in `setup-account.sh`.
103
+
104
+ ### 3. Provision and reconcile are unaffected
105
+
106
+ Both write `SCHEMA.md` and `account.json`, and neither goes through the agent's
107
+ write tools: provisioning is bash (`provision-account-dir.sh`) and the standing
108
+ reconcile is Node (`account-dir-schema-reconcile.ts`). The guard is a PreToolUse
109
+ hook on `Write`/`Edit`/`NotebookEdit` only, so it never sees either writer.
110
+
111
+ ### 4. The Bash post-hook stays unchanged
112
+
113
+ `fs-schema-guard-bash-post.sh` reads `allowed-top-level` and the `declared-files`
114
+ fence. This change touches neither, and all five names stay in `allowed-top-level`,
115
+ so the post hook keeps not flagging them. That is the deliberate choice, not an
116
+ inherited one: the post hook fires only on a top-level name that appeared during
117
+ the command, and `account.json`, `SCHEMA.md`, `.claude` and `.git` all exist from
118
+ provision onward, so a signal there would catch nothing on a live account.
119
+
120
+ ## Testing
121
+
122
+ `fs-schema-guard.test.sh` gains:
123
+
124
+ - one case per name asserting exit 2 with `reason=account-machinery` and that
125
+ name's reason text present in the message;
126
+ - a nested case for `.claude/settings.json` and `secrets/cloudflare.env`
127
+ asserting exit 2, pinning the segment-0 match;
128
+ - an operator bucket write under the same schema asserting exit 0;
129
+ - a case under a schema that lists the five in `allowed-top-level` but carries no
130
+ `agent-denied-top-level` fence asserting exit 0, which pins the deny to the
131
+ fence rather than to a list baked into the hook;
132
+ - the existing template-parity assertion extended so the shipped fence contents
133
+ are pinned.
134
+
135
+ Mutation check: remove the deny branch and confirm the per-name cases fail.
136
+ Revert.
137
+
138
+ The reconcile suite and `fs-schema-guard-bash.test.sh` must both still pass
139
+ unchanged, which is the evidence that `allowed-top-level` was not disturbed.
140
+
141
+ ## Out of scope
142
+
143
+ - The `declared-files` fence and the plugin-declaration mechanism. Task 2028
144
+ settled both.
145
+ - `.quarantine`, which is historical and already documented as never authored
146
+ into directly.
147
+ - `AGENTS.md`, which is in the allowed fence but outside the five names this task
148
+ names. The same overwritten-on-provision rationale may apply to it; that
149
+ decision is filed as
150
+ `.tasks/backlog/2057-agents-md-is-refreshed-every-provision-but-stays-agent-writable.md`.
@@ -15,6 +15,11 @@
15
15
  * ontology roots: - `jobs/` - one folder per Job record. (ASCII hyphen)
16
16
  * plugin-owned: - `pages/` — Owned by the … plugin. (em dash)
17
17
  *
18
+ * merge() writes a fourth structure, the ```declared-files fence, which is not
19
+ * parsed here: its only reader is the write guard (fs-schema-guard.sh), which
20
+ * needs the declared-FILE set apart from the bucket set. Both consumers of this
21
+ * module group and audit directories, so the fence is deliberately out of scope.
22
+ *
18
23
  * .docs/data-portal-folder-index.md records that parsing the ontology region on
19
24
  * the wrong separator matches nothing and reads as "this account has no
20
25
  * deliverables". This parser sidesteps that failure entirely by anchoring on the
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,UAAU,EAAE,SAAS,MAAM,EAEtC,CAAA;AAiBF,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,iEAAiE;IACjE,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,gEAAgE;IAChE,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB;;;;;;;OAOG;IACH,MAAM,EAAE,OAAO,CAAA;IACf;;;;OAIG;IACH,MAAM,EAAE,IAAI,GAAG,eAAe,GAAG,cAAc,CAAA;CAChD;AAiCD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,aAAa,CAazE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,UAAU,EAAE,SAAS,MAAM,EAEtC,CAAA;AAiBF,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,iEAAiE;IACjE,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,gEAAgE;IAChE,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB;;;;;;;OAOG;IACH,MAAM,EAAE,OAAO,CAAA;IACf;;;;OAIG;IACH,MAAM,EAAE,IAAI,GAAG,eAAe,GAAG,cAAc,CAAA;CAChD;AAiCD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,aAAa,CAazE"}
@@ -16,6 +16,11 @@
16
16
  * ontology roots: - `jobs/` - one folder per Job record. (ASCII hyphen)
17
17
  * plugin-owned: - `pages/` — Owned by the … plugin. (em dash)
18
18
  *
19
+ * merge() writes a fourth structure, the ```declared-files fence, which is not
20
+ * parsed here: its only reader is the write guard (fs-schema-guard.sh), which
21
+ * needs the declared-FILE set apart from the bucket set. Both consumers of this
22
+ * module group and audit directories, so the fence is deliberately out of scope.
23
+ *
19
24
  * .docs/data-portal-folder-index.md records that parsing the ontology region on
20
25
  * the wrong separator matches nothing and reads as "this account has no
21
26
  * deliverables". This parser sidesteps that failure entirely by anchoring on the
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,11 @@
15
15
  * ontology roots: - `jobs/` - one folder per Job record. (ASCII hyphen)
16
16
  * plugin-owned: - `pages/` — Owned by the … plugin. (em dash)
17
17
  *
18
+ * merge() writes a fourth structure, the ```declared-files fence, which is not
19
+ * parsed here: its only reader is the write guard (fs-schema-guard.sh), which
20
+ * needs the declared-FILE set apart from the bucket set. Both consumers of this
21
+ * module group and audit directories, so the fence is deliberately out of scope.
22
+ *
18
23
  * .docs/data-portal-folder-index.md records that parsing the ontology region on
19
24
  * the wrong separator matches nothing and reads as "this account has no
20
25
  * deliverables". This parser sidesteps that failure entirely by anchoring on the
@@ -154,6 +154,20 @@ mkdir -p "$K/contacts/acme"; : > "$K/contacts/.DS_Store"
154
154
  run_post "$K" "$(env_json PostToolUse Bash "$SID")" "$ef"
155
155
  check "post ignores dot-prefixed new-bucket child" 0 $? "" "$(cat "$ef")"
156
156
 
157
+ # --- 14. Bash creating a plugin-declared file -> silent, by decision ---------
158
+ # A declared file is in the allowed set, so the diff never flags it. That is
159
+ # deliberate, not inherited: this hook fires only on a name that APPEARED during
160
+ # the command, and a declared file already exists on any live account, so a Bash
161
+ # edit to one is invisible here regardless. Catching only the first creation and
162
+ # missing every edit is not a boundary. The Write/Edit guard blocks the writes it
163
+ # can actually identify; this hook stays quiet. This case pins the silence.
164
+ L=$(new_acct)
165
+ awk '1; /^```allowed-top-level$/{print "wa-channel-bindings.json"}' "$L/SCHEMA.md" > "$L/SCHEMA.md.tmp" && mv "$L/SCHEMA.md.tmp" "$L/SCHEMA.md"
166
+ run_pre "$L" "$(env_json PreToolUse Bash "$SID")" "$ef"
167
+ printf '{}' > "$L/wa-channel-bindings.json"
168
+ run_post "$L" "$(env_json PostToolUse Bash "$SID")" "$ef"
169
+ check "post silent on declared file" 0 $? "" "$(cat "$ef")"
170
+
157
171
  rm -f "$ef"
158
172
  echo "----- $PASS passed, $FAIL failed -----"
159
173
  [ $FAIL -eq 0 ]
@@ -87,11 +87,90 @@ run_case "domain bucket deep allow" "$(mkenv Write file_path 'jobs/5-oaktree/Quo
87
87
  run_case "domain bucket file allow" "$(mkenv Write file_path 'jobs/5-oaktree/notes.md')" 0 "" "$ACCT_D"
88
88
  run_case "undeclared still blocks" "$(mkenv Write file_path 'invoices/x.pdf')" 2 "reason=top-level" "$ACCT_D"
89
89
 
90
+ # Declared files: a plugin-declared account-root file is written whole by its
91
+ # owner, so an agent write to it blocks even though the merge unions its name
92
+ # into the allowed set. $ACCT_F models a merged schema (names in the allowed set
93
+ # AND in the declared-files fence); $ACCT_L models the state before this guard
94
+ # existed (names in the allowed set, no fence), which pins the deny to the fence
95
+ # rather than to a list baked into the hook.
96
+ DECLARED_FILES="webchat-channel-bindings.json wa-channel-bindings.json telegram-channel-bindings.json canonical-webchat-session.json session-titles.json agents-disabled.json"
97
+ # Declared but AGENT-written: the calendar-site and data-portal skills author
98
+ # these two with Write and there is no other writer, so they are in the allowed
99
+ # set (and out of the reconcile's stray report) but never in the deny fence.
100
+ AGENT_WRITTEN_FILES="data-portal.json calendar-availability.json"
101
+ owner_of() { echo admin; }
102
+ seed_declared() { # $1=acct $2=emit fence (yes|no)
103
+ cp "$TEMPLATE" "$1/SCHEMA.md"
104
+ for f in $DECLARED_FILES $AGENT_WRITTEN_FILES; do
105
+ awk -v n="$f" '1; /^```allowed-top-level$/{print n}' "$1/SCHEMA.md" > "$1/SCHEMA.md.tmp" && mv "$1/SCHEMA.md.tmp" "$1/SCHEMA.md"
106
+ done
107
+ [ "$2" = "yes" ] || return 0
108
+ { echo; echo '<!-- declared-files:start -->'; echo '```declared-files'
109
+ for f in $DECLARED_FILES; do printf '%s\t%s\n' "$f" "$(owner_of "$f")"; done
110
+ echo '```'; echo '<!-- declared-files:end -->'; } >> "$1/SCHEMA.md"
111
+ }
112
+ ACCT_F=$(mktemp -d); ACCT_L=$(mktemp -d)
113
+ trap 'rm -rf "$ACCT" "$ACCT_D" "$ACCT_F" "$ACCT_L"' EXIT
114
+ seed_declared "$ACCT_F" yes
115
+ seed_declared "$ACCT_L" no
116
+ mkdir -p "$ACCT_F/projects/acme"
117
+ for f in $DECLARED_FILES; do
118
+ run_case "declared block $f" "$(mkenv Write file_path "$f")" 2 "fs-guard. blocked path=$f reason=declared-file" "$ACCT_F"
119
+ run_case "declared owner $f" "$(mkenv Write file_path "$f")" 2 "$(owner_of "$f") plugin" "$ACCT_F"
120
+ done
121
+ for f in $AGENT_WRITTEN_FILES; do
122
+ run_case "agent-written allow $f" "$(mkenv Write file_path "$f")" 0 "" "$ACCT_F"
123
+ done
124
+ run_case "declared edit blocks" "$(mkenv Edit file_path 'wa-channel-bindings.json')" 2 "reason=declared-file" "$ACCT_F"
125
+ run_case "operator bucket allow" "$(mkenv Write file_path 'projects/acme/a.txt')" 0 "" "$ACCT_F"
126
+ run_case "no fence no deny" "$(mkenv Write file_path 'wa-channel-bindings.json')" 0 "" "$ACCT_L"
127
+
128
+ # Account machinery: five entries in the allowed set are the account's own
129
+ # control plane, denied by name in the agent-denied-top-level fence. $ACCT is
130
+ # the pristine template copy, so it carries the fence. $ACCT_M models a schema
131
+ # written before the fence existed (names in the allowed set, fence stripped),
132
+ # which pins the deny to the fence rather than to a list baked into the hook.
133
+ ACCT_M=$(mktemp -d)
134
+ trap 'rm -rf "$ACCT" "$ACCT_D" "$ACCT_F" "$ACCT_L" "$ACCT_M"' EXIT
135
+ awk '/^```agent-denied-top-level$/{skip=1;next} skip && /^```$/{skip=0;next} !skip' "$TEMPLATE" > "$ACCT_M/SCHEMA.md"
136
+
137
+ DENIED_NAMES="account.json SCHEMA.md secrets .claude .git"
138
+ for n in $DENIED_NAMES; do
139
+ run_case "machinery block $n" "$(mkenv Write file_path "$n")" 2 "fs-guard. blocked path=$n reason=account-machinery"
140
+ done
141
+ # Segment-0 match: a nested path under a denied entry blocks for the same reason.
142
+ run_case "machinery nested settings" "$(mkenv Write file_path '.claude/settings.json')" 2 "fs-guard. blocked path=.claude/settings.json reason=account-machinery"
143
+ run_case "machinery nested secret" "$(mkenv Write file_path 'secrets/cloudflare.env')" 2 "reason=account-machinery"
144
+ # Every write tool, not just Write.
145
+ run_case "machinery edit blocks" "$(mkenv Edit file_path 'account.json')" 2 "reason=account-machinery"
146
+ run_case "machinery notebook blocks" "$(mkenv NotebookEdit notebook_path '.claude/n.ipynb')" 2 "reason=account-machinery"
147
+ # The schema's own reason reaches the operator-visible message.
148
+ run_case "machinery reason text" "$(mkenv Write file_path 'SCHEMA.md')" 2 "widens its own fence"
149
+ # An operator bucket under the same schema is unaffected.
150
+ run_case "machinery bucket allow" "$(mkenv Write file_path 'projects/acme/a.txt')" 0 ""
151
+ # No fence, no deny.
152
+ for n in $DENIED_NAMES; do
153
+ run_case "no deny fence allows $n" "$(mkenv Write file_path "$n")" 0 "" "$ACCT_M"
154
+ done
155
+
90
156
  # Allowed-set parse == documented set.
91
157
  EXPECT="projects contacts documents url-get output generated extracted uploads agents specialists sites public cache secrets state logs tmp .quarantine SCHEMA.md account.json AGENTS.md .claude .git"
92
158
  GOT=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | tr '\n' ' ' | sed 's/ *$//')
93
159
  if [ "$GOT" = "$EXPECT" ]; then echo "PASS: allowed-set parse"; PASS=$((PASS+1));
94
160
  else echo "FAIL: allowed-set parse: got [$GOT]" >&2; FAIL=$((FAIL+1)); fi
95
161
 
162
+ # Deny-fence parse == documented set. The guard reads this fence, so pinning its
163
+ # contents here means a name cannot be added or dropped without a test edit.
164
+ EXPECT_D="account.json SCHEMA.md secrets .claude .git"
165
+ GOT_D=$(awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | cut -f1 | tr '\n' ' ' | sed 's/ *$//')
166
+ if [ "$GOT_D" = "$EXPECT_D" ]; then echo "PASS: deny-set parse"; PASS=$((PASS+1));
167
+ else echo "FAIL: deny-set parse: got [$GOT_D]" >&2; FAIL=$((FAIL+1)); fi
168
+
169
+ # Every deny entry carries a reason column: the block message has no owning
170
+ # plugin to name, so the schema must supply the explanation.
171
+ NOREASON=$(awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | awk -F'\t' 'NF<2 || $2==""{print $1}')
172
+ if [ -z "$NOREASON" ]; then echo "PASS: deny-set reasons"; PASS=$((PASS+1));
173
+ else echo "FAIL: deny-set reasons missing for [$NOREASON]" >&2; FAIL=$((FAIL+1)); fi
174
+
96
175
  echo "----- $PASS passed, $FAIL failed -----"
97
176
  [ $FAIL -eq 0 ]
@@ -17,6 +17,25 @@
17
17
  # Diffing against the pre-snapshot (not flagging every non-allowed entry) is what
18
18
  # keeps the hook from firing on the legacy backlog on every unrelated Bash call.
19
19
  #
20
+ # A plugin-declared file (a name a plugin claims as its own in its PLUGIN.md) is
21
+ # in the allowed set, so this hook never flags one. That silence is a decision,
22
+ # not an accident. This hook fires only on a name that APPEARED during the
23
+ # command: a declared file already exists on any live account, so a Bash edit to
24
+ # one is invisible here no matter what the allowed set says. A signal that catches
25
+ # only the first creation and misses every subsequent edit is not a boundary, and
26
+ # paying for it in feedback noise buys nothing. The Write/Edit guard
27
+ # (fs-schema-guard.sh) blocks the writes it can actually identify, on the
28
+ # declared-files fence; this hook stays quiet, and a case in the test suite pins
29
+ # the silence.
30
+ #
31
+ # The same reasoning covers the ```agent-denied-top-level fence, and for the same
32
+ # reason it needs no code here: those five names are in the allowed set too, and
33
+ # account.json, SCHEMA.md, .claude and .git all exist from provision onward, so
34
+ # they can never APPEAR during a command on a live account. secrets/ can appear
35
+ # once, and is deliberately left alone — the data-portal skill creates it with a
36
+ # shell redirect as its sanctioned path, so flagging that first creation would
37
+ # fire on the one legitimate writer and stay blind to every edit after it.
38
+ #
20
39
  # Exit codes: 0 = allow, 2 = feedback to the agent (stderr shown). Fail open
21
40
  # (exit 0) when the snapshot is missing or the allowed set is empty. Every block
22
41
  # logs: [fs-guard-bash] stray path=<name> reason=<top-level|bad-name>
@@ -5,6 +5,16 @@
5
5
  # - the target's first path segment must be in the allowed top-level set
6
6
  # (parsed from the fenced ```allowed-top-level block of the account's
7
7
  # SCHEMA.md — our own structured data, not CLI prose);
8
+ # - a target whose first path segment is named in the fenced
9
+ # ```agent-denied-top-level block is blocked even though that name is in the
10
+ # allowed set: those entries are the account's control plane (identity,
11
+ # schema, credentials, agent settings, git dir), and none of them is ever
12
+ # authored by an agent file edit. The block message quotes the reason the
13
+ # fence carries for that name;
14
+ # - a target whose first path segment is a plugin-declared file (parsed from
15
+ # the fenced ```declared-files block of the same SCHEMA.md) is blocked even
16
+ # though that name is in the allowed set: the allowed set is a layout list,
17
+ # and a declared file is written whole by its owning plugin;
8
18
  # - a target under an operator-data bucket (projects/ contacts/) may be at
9
19
  # most <bucket>/<entity>/<file> deep; documents/ may be at most
10
20
  # <bucket>/<folder>/<file> deep. Tool-owned dirs pass at any depth.
@@ -14,7 +24,7 @@
14
24
  #
15
25
  # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail closed when
16
26
  # the tool call cannot be inspected (tty or empty stdin). Every block logs
17
- # [fs-guard] blocked path=<rel> reason=<top-level|over-deep|bad-name>
27
+ # [fs-guard] blocked path=<rel> reason=<top-level|account-machinery|declared-file|over-deep|bad-name>
18
28
  # No task numbers / internal refs in any operator-visible string.
19
29
 
20
30
  set -uo pipefail
@@ -84,6 +94,59 @@ if ! printf '%s\n' "$ALLOWED" | grep -qxF "$SEG0"; then
84
94
  exit 2
85
95
  fi
86
96
 
97
+ # Account-machinery check. Some entries in the allowed set are the account's own
98
+ # control plane rather than operator data: the identity file, the schema this
99
+ # hook parses, the credential store, the agent's own settings and hooks, and the
100
+ # git dir. The allowed set says which names may EXIST at the account root, not
101
+ # which an agent may author, so each of these is named in a second fence together
102
+ # with the reason it is off-limits. None of the five is authored through
103
+ # Write/Edit/NotebookEdit: platform bash and Node write four of them, and git
104
+ # itself writes .git, so denying them costs no legitimate writer. This denies
105
+ # the agent's FILE-EDIT tools only, which is the whole surface the hook governs
106
+ # — a Bash command still reaches these paths, and one sanctioned path relies on
107
+ # that (the data-portal skill appends its fetch secret to secrets/ with a shell
108
+ # redirect). The set and its reasons come from the account's own SCHEMA.md, so
109
+ # the hook hard-codes no list. There is no owning plugin to name here, which is
110
+ # why this fence carries a reason column the declared-files fence does not need.
111
+ #
112
+ # Not to be confused with the same phrase in account-dir-schema-reconcile.ts,
113
+ # whose "account's own machinery" is the never-a-stray set and covers a
114
+ # different membership (.quarantine and AGENTS.md are in it; secrets/ is not).
115
+ #
116
+ # No fence means nothing to deny, the same fail-open posture as the
117
+ # missing-schema case above. Which accounts lack it is not uniform: setup-account.sh
118
+ # re-provisions only the house account, and provision copies the template whole,
119
+ # so the house account gains the fence on the next install while a client
120
+ # sub-account — provisioned once, at creation — keeps a fence-less schema until a
121
+ # durable backfill lands.
122
+ DENIED=$(awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
123
+ if [ -n "$DENIED" ] && printf '%s\n' "$DENIED" | awk -F'\t' -v n="$SEG0" '$1==n{found=1} END{exit !found}'; then
124
+ WHY=$(printf '%s\n' "$DENIED" | awk -F'\t' -v n="$SEG0" '$1==n{print $2; exit}')
125
+ echo "[fs-guard] blocked path=$REL reason=account-machinery" >&2
126
+ echo "Blocked: '$SEG0' is this account's own machinery, not operator data: $WHY. Change it through the code that owns it." >&2
127
+ exit 2
128
+ fi
129
+
130
+ # Declared-file check. A file declared in the fence is written whole by platform
131
+ # code, so an agent write to it is blocked even though the name is in the allowed
132
+ # set: that set says which names may exist at the account root, not which an
133
+ # agent may author. The owner column names the declaring plugin, which is where
134
+ # the declaration lives, not necessarily the module that does the writing (a
135
+ # shared service may write a file declared on the plugin that no brand excludes),
136
+ # so the message points at the owning code rather than promising a plugin tool.
137
+ # A file the merge marks as agent-written is not in this fence at all. The set
138
+ # comes from the account's own SCHEMA.md, so the hook hard-codes no list. No
139
+ # fence (a brand declaring no owned file, or a schema written before the fence
140
+ # existed) means nothing to deny — the same fail-open posture as the
141
+ # missing-schema case above.
142
+ DECLARED=$(awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
143
+ if [ -n "$DECLARED" ] && printf '%s\n' "$DECLARED" | awk -F'\t' -v n="$SEG0" '$1==n{found=1} END{exit !found}'; then
144
+ OWNER=$(printf '%s\n' "$DECLARED" | awk -F'\t' -v n="$SEG0" '$1==n{print $2; exit}')
145
+ echo "[fs-guard] blocked path=$REL reason=declared-file" >&2
146
+ echo "Blocked: '$SEG0' is a declared file owned by the $OWNER plugin. The platform writes it whole, so it is control-plane state, not operator data, and a hand-written copy is overwritten by the next platform write. Change it through the code that owns it, never by hand." >&2
147
+ exit 2
148
+ fi
149
+
87
150
  # Over-deep check for operator-data buckets. Count path segments.
88
151
  depth=$(printf '%s' "$REL" | awk -F/ '{print NF}')
89
152
  case "$SEG0" in
@@ -9,6 +9,18 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-27 (0.1.514)
13
+
14
+ - Three delete and danger buttons had no visible hover state, because their hover colour resolved to white. They now have a real hover step on the colour ramp.
15
+ - The assistant can no longer write to your account's own configuration and machinery files, which are now fenced off at the write guard.
16
+ - WhatsApp escalation checks now compare against the account a message was actually routed to, rather than a folder name, so an escalation is judged against the right account.
17
+
18
+ ## 2026-07-27 (0.1.513)
19
+
20
+ - Fixed sign-in for sub-accounts: the browser session bridge now checks that you belong to the account you asked for, instead of only ever accepting the first one it found.
21
+ - WhatsApp account checks now use the account's real identifier rather than a folder name, so conversations are attributed to the right account.
22
+ - The saved session-title cache is now pruned as the sessions list is walked, so it no longer grows without limit.
23
+
12
24
  ## 2026-07-27 (0.1.512)
13
25
 
14
26
  - Signed PDFs keep their clickable links and selectable text. The e-sign stamp no longer falls back to flattening each page into an image, which had been discarding every link in the document.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: cloudflare
3
3
  description: Cloudflare operations — tunnel setup/reset, DNS, Pages hosting, D1 data capture, and dashboard guidance. Zero agent-facing MCP tools; every operation is the agent invoking `cloudflared` or `wrangler` directly via Bash, calling the Cloudflare API with a reused per-scope narrow token, or quoting a dashboard click-path the operator performs themselves.
4
- account-owned-files: [{"file": "data-portal.json", "description": "The account's data-portal folder index, written by bin/portal-index-push.mjs on every push."}]
4
+ account-owned-files: [{"file": "data-portal.json", "writtenBy": "agent", "description": "The account's portal config and folder index. Authored by the agent under the data-portal skill, which is also the only path that changes it; bin/portal-index-push.mjs reads it on every push."}]
5
5
  tools: []
6
6
  mcp-manifest: skip
7
7
  ---
@@ -78,7 +78,7 @@ export function resolveExposedDirs(schemaText, exposeFolders = []) {
78
78
  collisions: /** @type {string[]} */ ([]),
79
79
  exposed: /** @type {string[]} */ ([]),
80
80
  }
81
- // Fail CLOSED. fs-schema-guard.sh:76-78 fails open on a missing schema so an
81
+ // Fail CLOSED. fs-schema-guard.sh:83-86 fails open on a missing schema so an
82
82
  // unseeded legacy account is not write-blocked; a read surface facing a
83
83
  // client must do the opposite. Do not "fix" this to match the guard.
84
84
  if (typeof schemaText !== 'string' || schemaText.length === 0) return empty
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: scheduling
3
3
  description: "Calendar and scheduling — create events, manage appointments, set up recurring triggers."
4
- account-owned-files: [{"file": "calendar-availability.json", "description": "The booking page's availability config, written by the publish-availability script and read by the live booking page."}]
4
+ account-owned-files: [{"file": "calendar-availability.json", "writtenBy": "agent", "description": "The booking page's availability config. Authored by the agent under the calendar-site skill, which is also the only path that changes it; the publish-availability script reads it to refresh the live booking page."}]
5
5
  tools:
6
6
  - name: schedule-event
7
7
  publicAllowlist: false
@@ -137,7 +137,7 @@ A passive-bound phone that DMs a requirement gets exactly one `:Task` filed unde
137
137
 
138
138
  **Recall surfaces the attachment, not just the text.** A recalled message that carried media now includes an `attachment` reference on its read-tool row (`whatsapp-messages` and both modes of `whatsapp-conversation-graph-state`): `attachmentId`, `filename`, `mimeType`, a `sizeBytes` (bytes, as stored — the line renders it as KB/MB), an absolute on-disk `path` to the stored bytes, and a `readableTextPath` when an extracted-text sibling exists. The reference resolves against the same store account the route read the thread from (the house account when projecting a sub-account), so the path always points at the house-stored bytes, never the sub-account's own empty uploads dir. The read-tool output renders it as an indented `[attachment: <name> (<mime>, <size>); Read: <path>]` line, and the agent reads that path directly (an image renders when Read). Inbound WhatsApp media runs best-effort text extraction (the earlier `skipDocToText` opt-out was retired): a convertible container (xlsx, docx, ods, and the rest of `CONVERTIBLE_DOC_MIME`) gets an `.extracted.txt` sibling, so `readableTextPath` is populated for those; an image or other non-convertible type has no sibling and surfaces the bytes `path` alone. Observable on each read route's result line as `withAttachments=<n>`; a record that names an attachment whose bytes are missing under the store account logs `[whatsapp-read-tool] op=attachment-miss attachmentId=<8> storeAccount=<id>`, which separates a genuine house-store gap from a sub-account mis-resolution.
139
139
 
140
- **Single source of truth + fail-closed.** The effective account is resolved **exactly once**, in the gate: `checkDmAccess` returns `effectiveAccountId` (the house account for owner/`adminPhones`/public, the bound sub-account for a manager), and that value threads through the inbound payload → gateway → `ensureChannelSession`, which spawns into it **without re-reading** the `accountManagers` map. There is no second resolution and no `?? accountId` house fallback: if a manager's bound sub-account is not a valid account, the inbound is **rejected** (no session spawned, no reply), never routed to the house. This closes the escalation where a divergence between two independent map reads handed a scoped manager a house-owner admin session. Observable signals: `op=account-manager-route … effectiveAccount=… source=gate` on a routed manager inbound; `op=account-manager-reject … reason=unresolved-effective-account` on the fail-closed drop; a standing `op=escalation-tripwire` belt that can only fire if a future change reintroduces the divergence.
140
+ **Single source of truth + fail-closed.** The effective account is resolved **exactly once**, in the gate: `checkDmAccess` returns `effectiveAccountId` (the house account for owner/`adminPhones`/public, the bound sub-account for a manager), and that value threads through the inbound payload → gateway → `ensureChannelSession`, which spawns into it **without re-reading** the `accountManagers` map. There is no second resolution and no `?? accountId` house fallback: if a manager's bound sub-account is not a valid account, the inbound is **rejected** (no session spawned, no reply), never routed to the house. This closes the escalation where a divergence between two independent map reads handed a scoped manager a house-owner admin session. Observable signals: `op=account-manager-route … effectiveAccount=… source=gate` on a routed manager inbound; `op=account-manager-reject … reason=unresolved-effective-account` on the fail-closed drop; a standing `op=escalation-tripwire` belt that can only fire if a future change reintroduces the divergence. The belt compares the manager's effective account against the **house** UUID, not against the account inbound persists to — on an install that sets `channelRoutingAccountId` those differ, and a manager bound to the routing target is a legitimate binding that must admit. The binding write path now refuses a house-account binding at write time, so no new one can be created; a binding written before that refusal existed is still on disk and is what the belt catches.
141
141
 
142
142
  **The scheduler path fails closed too.** A scheduled dispatch (`POST /api/channel/schedule-inject`) reaches the same spawn machine without going through the gate, resolving its own effective account via `effectiveAccountFor`. That resolver carries the same single-source + fail-closed shape: a non-manager destination scopes to the house (an owner/admin's real scope), a valid manager scopes to the bound sub-account, and a manager whose bound sub-account is not a valid account resolves to nothing — the route **rejects** (`op=schedule-account-manager-reject … reason=unresolved-effective-account`, HTTP 403, no spawn, no reply), never routing to the house. There is no `?? accountId` fallback on this path either. Telegram scheduled dispatch is house-only by construction (no account-manager routing), so there is nothing to fail closed there.
143
143