@rubytech/create-sitedesk-code 0.1.513 → 0.1.515

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 (199) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2016-disabled-agent-routing.md +624 -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-2016-disabled-agent-routing-design.md +139 -0
  5. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +2 -1
  6. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2052-account-machinery-write-fence-design.md +150 -0
  7. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +41 -0
  8. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +8 -0
  9. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +40 -1
  10. package/payload/platform/plugins/admin/mcp/dist/index.js +14 -16
  11. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.d.ts +14 -0
  13. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.d.ts.map +1 -0
  14. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.js +57 -0
  15. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.js.map +1 -0
  16. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +15 -3
  17. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  18. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +1 -1
  19. package/payload/platform/plugins/docs/references/admin-ui.md +14 -2
  20. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +3 -3
  21. package/payload/platform/scripts/__tests__/agents-md-bootstrap.test.sh +47 -0
  22. package/payload/platform/scripts/lib/agents-md-bootstrap.sh +58 -1
  23. package/payload/platform/services/claude-session-manager/dist/config.d.ts +48 -12
  24. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/config.js +65 -15
  26. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/index.js +7 -4
  28. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/rootless-client-audit.d.ts.map +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/rootless-client-audit.js +17 -2
  31. package/payload/platform/services/claude-session-manager/dist/rootless-client-audit.js.map +1 -1
  32. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  33. package/payload/server/public/activity.html +5 -5
  34. package/payload/server/public/agents.html +4 -4
  35. package/payload/server/public/assets/{AdminLoginScreens-bksN0XPe.js → AdminLoginScreens-Cs1jSnOF.js} +1 -1
  36. package/payload/server/public/assets/AdminLoginScreens-Cs1jSnOF.js.br +0 -0
  37. package/payload/server/public/assets/AdminLoginScreens-Cs1jSnOF.js.gz +0 -0
  38. package/payload/server/public/assets/{AdminShell-BMPEWNZk.js → AdminShell-DkrURZnF.js} +1 -1
  39. package/payload/server/public/assets/AdminShell-DkrURZnF.js.br +0 -0
  40. package/payload/server/public/assets/AdminShell-DkrURZnF.js.gz +0 -0
  41. package/payload/server/public/assets/{activity-ByWYF5dC.js → activity-BgZISMuE.js} +1 -1
  42. package/payload/server/public/assets/activity-BgZISMuE.js.br +0 -0
  43. package/payload/server/public/assets/activity-BgZISMuE.js.gz +0 -0
  44. package/payload/server/public/assets/{admin-6QN-k3zQ.js → admin-DjFysc3-.js} +1 -1
  45. package/payload/server/public/assets/admin-DjFysc3-.js.br +0 -0
  46. package/payload/server/public/assets/admin-DjFysc3-.js.gz +0 -0
  47. package/payload/server/public/assets/{agents-C0rTfrYd.js → agents-Bx-MSZW6.js} +1 -1
  48. package/payload/server/public/assets/agents-Bx-MSZW6.js.br +0 -0
  49. package/payload/server/public/assets/agents-Bx-MSZW6.js.gz +0 -0
  50. package/payload/server/public/assets/{browser-4tTaem8N.js → browser-1I7syhHu.js} +1 -1
  51. package/payload/server/public/assets/browser-1I7syhHu.js.br +0 -0
  52. package/payload/server/public/assets/browser-1I7syhHu.js.gz +0 -0
  53. package/payload/server/public/assets/{calendar-BEjnkKG1.js → calendar-D_04JIo1.js} +1 -1
  54. package/payload/server/public/assets/calendar-D_04JIo1.js.br +0 -0
  55. package/payload/server/public/assets/calendar-D_04JIo1.js.gz +0 -0
  56. package/payload/server/public/assets/chat-BFH3FM5a.js +1 -0
  57. package/payload/server/public/assets/chat-BFH3FM5a.js.br +0 -0
  58. package/payload/server/public/assets/chat-BFH3FM5a.js.gz +0 -0
  59. package/payload/server/public/assets/chevron-left-Ux9-qLkk.js +1 -0
  60. package/payload/server/public/assets/chevron-right-BwIuCfyG.js +1 -0
  61. package/payload/server/public/assets/chevron-right-BwIuCfyG.js.br +0 -0
  62. package/payload/server/public/assets/clock-DhJdZRhC.js +1 -0
  63. package/payload/server/public/assets/clock-DhJdZRhC.js.br +0 -0
  64. package/payload/server/public/assets/data-ChY_uSIk.js +1 -0
  65. package/payload/server/public/assets/data-ChY_uSIk.js.br +0 -0
  66. package/payload/server/public/assets/data-ChY_uSIk.js.gz +0 -0
  67. package/payload/server/public/assets/{file-text-DMKw3nIk.js → file-text-DkvKqHMx.js} +1 -1
  68. package/payload/server/public/assets/file-text-DkvKqHMx.js.br +0 -0
  69. package/payload/server/public/assets/file-text-DkvKqHMx.js.gz +0 -0
  70. package/payload/server/public/assets/{graph-C3vW8lvw.js → graph-CCZhrwZX.js} +1 -1
  71. package/payload/server/public/assets/graph-CCZhrwZX.js.br +0 -0
  72. package/payload/server/public/assets/graph-CCZhrwZX.js.gz +0 -0
  73. package/payload/server/public/assets/{graph-labels-BZgd0L6n.js → graph-labels-BJ7puQkX.js} +1 -1
  74. package/payload/server/public/assets/graph-labels-BJ7puQkX.js.br +0 -0
  75. package/payload/server/public/assets/graph-labels-BJ7puQkX.js.gz +0 -0
  76. package/payload/server/public/assets/{maximize-2-la3IBA3M.js → maximize-2-Xdj804U1.js} +1 -1
  77. package/payload/server/public/assets/maximize-2-Xdj804U1.js.br +0 -0
  78. package/payload/server/public/assets/maximize-2-Xdj804U1.js.gz +0 -0
  79. package/payload/server/public/assets/{operator-D9NgVL9_.js → operator-BkfNpBs7.js} +1 -1
  80. package/payload/server/public/assets/operator-BkfNpBs7.js.br +0 -0
  81. package/payload/server/public/assets/operator-BkfNpBs7.js.gz +0 -0
  82. package/payload/server/public/assets/{page-DC11gesX.js → page-BoYc9c4a.js} +1 -1
  83. package/payload/server/public/assets/page-BoYc9c4a.js.br +0 -0
  84. package/payload/server/public/assets/page-BoYc9c4a.js.gz +0 -0
  85. package/payload/server/public/assets/{page-BWFHRIAH.js → page-D5n8ShE7.js} +1 -1
  86. package/payload/server/public/assets/page-D5n8ShE7.js.br +0 -0
  87. package/payload/server/public/assets/page-D5n8ShE7.js.gz +0 -0
  88. package/payload/server/public/assets/{public-DQbbLHQN.js → public-CX5XSrxD.js} +1 -1
  89. package/payload/server/public/assets/public-CX5XSrxD.js.br +0 -0
  90. package/payload/server/public/assets/public-CX5XSrxD.js.gz +0 -0
  91. package/payload/server/public/assets/{rotate-ccw-OQD5si8N.js → rotate-ccw-dh_OhL23.js} +1 -1
  92. package/payload/server/public/assets/rotate-ccw-dh_OhL23.js.br +0 -0
  93. package/payload/server/public/assets/rotate-ccw-dh_OhL23.js.gz +0 -0
  94. package/payload/server/public/assets/{routines-asrBGZUR.js → routines-bb7dxcrr.js} +1 -1
  95. package/payload/server/public/assets/routines-bb7dxcrr.js.br +0 -0
  96. package/payload/server/public/assets/routines-bb7dxcrr.js.gz +0 -0
  97. package/payload/server/public/assets/{skills-D4ECwoxa.js → skills-TeMwxXmJ.js} +1 -1
  98. package/payload/server/public/assets/skills-TeMwxXmJ.js.br +0 -0
  99. package/payload/server/public/assets/skills-TeMwxXmJ.js.gz +0 -0
  100. package/payload/server/public/assets/{tasks-U3QdbUsO.js → tasks-BqBBJlkg.js} +1 -1
  101. package/payload/server/public/assets/tasks-BqBBJlkg.js.br +0 -0
  102. package/payload/server/public/assets/tasks-BqBBJlkg.js.gz +0 -0
  103. package/payload/server/public/assets/{time-entry-format-BgdTKgYr.js → time-entry-format-Cy_T-wiD.js} +1 -1
  104. package/payload/server/public/assets/time-entry-format-Cy_T-wiD.js.br +3 -0
  105. package/payload/server/public/assets/time-entry-format-Cy_T-wiD.js.gz +0 -0
  106. package/payload/server/public/assets/{triangle-alert-Ck_3VhT9.js → triangle-alert-D-bSbPnX.js} +1 -1
  107. package/payload/server/public/assets/triangle-alert-D-bSbPnX.js.br +1 -0
  108. package/payload/server/public/assets/triangle-alert-D-bSbPnX.js.gz +0 -0
  109. package/payload/server/public/assets/{useCopyFeedback-BdUwyNpa.js → useCopyFeedback-L7L55nFl.js} +1 -1
  110. package/payload/server/public/assets/useCopyFeedback-L7L55nFl.js.br +0 -0
  111. package/payload/server/public/assets/useCopyFeedback-L7L55nFl.js.gz +0 -0
  112. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css +1 -0
  113. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css.br +0 -0
  114. package/payload/server/public/assets/useSubAccountSwitcher-BYl2c7xA.css.gz +0 -0
  115. package/payload/server/public/assets/{useVoiceRecorder-ChnBrzbZ.js → useVoiceRecorder-MuZ7lpet.js} +1 -1
  116. package/payload/server/public/assets/useVoiceRecorder-MuZ7lpet.js.br +0 -0
  117. package/payload/server/public/assets/useVoiceRecorder-MuZ7lpet.js.gz +0 -0
  118. package/payload/server/public/assets/{wrench-Cpee3C3J.js → wrench-DUG1SZQB.js} +1 -1
  119. package/payload/server/public/assets/wrench-DUG1SZQB.js.br +0 -0
  120. package/payload/server/public/assets/wrench-DUG1SZQB.js.gz +0 -0
  121. package/payload/server/public/brand-defaults.css +2 -0
  122. package/payload/server/public/browser.html +4 -4
  123. package/payload/server/public/calendar.html +7 -7
  124. package/payload/server/public/chat.html +13 -13
  125. package/payload/server/public/data.html +11 -11
  126. package/payload/server/public/graph.html +9 -9
  127. package/payload/server/public/index.html +14 -14
  128. package/payload/server/public/operator.html +14 -14
  129. package/payload/server/public/public.html +13 -13
  130. package/payload/server/public/routines.html +6 -6
  131. package/payload/server/public/skills.html +5 -5
  132. package/payload/server/public/tasks.html +6 -6
  133. package/payload/server/server.js +138 -19
  134. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js.br +0 -0
  135. package/payload/server/public/assets/AdminLoginScreens-bksN0XPe.js.gz +0 -0
  136. package/payload/server/public/assets/AdminShell-BMPEWNZk.js.br +0 -0
  137. package/payload/server/public/assets/AdminShell-BMPEWNZk.js.gz +0 -0
  138. package/payload/server/public/assets/activity-ByWYF5dC.js.br +0 -0
  139. package/payload/server/public/assets/activity-ByWYF5dC.js.gz +0 -0
  140. package/payload/server/public/assets/admin-6QN-k3zQ.js.br +0 -0
  141. package/payload/server/public/assets/admin-6QN-k3zQ.js.gz +0 -0
  142. package/payload/server/public/assets/agents-C0rTfrYd.js.br +0 -0
  143. package/payload/server/public/assets/agents-C0rTfrYd.js.gz +0 -0
  144. package/payload/server/public/assets/browser-4tTaem8N.js.br +0 -0
  145. package/payload/server/public/assets/browser-4tTaem8N.js.gz +0 -0
  146. package/payload/server/public/assets/calendar-BEjnkKG1.js.br +0 -0
  147. package/payload/server/public/assets/calendar-BEjnkKG1.js.gz +0 -0
  148. package/payload/server/public/assets/chat-DBgzjDIE.js +0 -1
  149. package/payload/server/public/assets/chat-DBgzjDIE.js.br +0 -0
  150. package/payload/server/public/assets/chat-DBgzjDIE.js.gz +0 -0
  151. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +0 -1
  152. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +0 -1
  153. package/payload/server/public/assets/chevron-right-CQfPGsFb.js.br +0 -0
  154. package/payload/server/public/assets/clock-Dn6FHB51.js +0 -1
  155. package/payload/server/public/assets/clock-Dn6FHB51.js.br +0 -0
  156. package/payload/server/public/assets/clock-Dn6FHB51.js.gz +0 -0
  157. package/payload/server/public/assets/data-D_e6Vdfd.js +0 -1
  158. package/payload/server/public/assets/data-D_e6Vdfd.js.br +0 -0
  159. package/payload/server/public/assets/data-D_e6Vdfd.js.gz +0 -0
  160. package/payload/server/public/assets/file-text-DMKw3nIk.js.br +0 -0
  161. package/payload/server/public/assets/file-text-DMKw3nIk.js.gz +0 -0
  162. package/payload/server/public/assets/graph-C3vW8lvw.js.br +0 -0
  163. package/payload/server/public/assets/graph-C3vW8lvw.js.gz +0 -0
  164. package/payload/server/public/assets/graph-labels-BZgd0L6n.js.br +0 -0
  165. package/payload/server/public/assets/graph-labels-BZgd0L6n.js.gz +0 -0
  166. package/payload/server/public/assets/maximize-2-la3IBA3M.js.br +0 -0
  167. package/payload/server/public/assets/maximize-2-la3IBA3M.js.gz +0 -0
  168. package/payload/server/public/assets/operator-D9NgVL9_.js.br +0 -0
  169. package/payload/server/public/assets/operator-D9NgVL9_.js.gz +0 -0
  170. package/payload/server/public/assets/page-BWFHRIAH.js.br +0 -0
  171. package/payload/server/public/assets/page-BWFHRIAH.js.gz +0 -0
  172. package/payload/server/public/assets/page-DC11gesX.js.br +0 -0
  173. package/payload/server/public/assets/page-DC11gesX.js.gz +0 -0
  174. package/payload/server/public/assets/public-DQbbLHQN.js.br +0 -0
  175. package/payload/server/public/assets/public-DQbbLHQN.js.gz +0 -0
  176. package/payload/server/public/assets/rotate-ccw-OQD5si8N.js.br +0 -0
  177. package/payload/server/public/assets/rotate-ccw-OQD5si8N.js.gz +0 -0
  178. package/payload/server/public/assets/routines-asrBGZUR.js.br +0 -0
  179. package/payload/server/public/assets/routines-asrBGZUR.js.gz +0 -0
  180. package/payload/server/public/assets/skills-D4ECwoxa.js.br +0 -0
  181. package/payload/server/public/assets/skills-D4ECwoxa.js.gz +0 -0
  182. package/payload/server/public/assets/tasks-U3QdbUsO.js.br +0 -0
  183. package/payload/server/public/assets/tasks-U3QdbUsO.js.gz +0 -0
  184. package/payload/server/public/assets/time-entry-format-BgdTKgYr.js.br +0 -0
  185. package/payload/server/public/assets/time-entry-format-BgdTKgYr.js.gz +0 -0
  186. package/payload/server/public/assets/triangle-alert-Ck_3VhT9.js.br +0 -0
  187. package/payload/server/public/assets/triangle-alert-Ck_3VhT9.js.gz +0 -0
  188. package/payload/server/public/assets/useCopyFeedback-BdUwyNpa.js.br +0 -0
  189. package/payload/server/public/assets/useCopyFeedback-BdUwyNpa.js.gz +0 -0
  190. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css +0 -1
  191. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css.br +0 -0
  192. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css.gz +0 -0
  193. package/payload/server/public/assets/useVoiceRecorder-ChnBrzbZ.js.br +0 -0
  194. package/payload/server/public/assets/useVoiceRecorder-ChnBrzbZ.js.gz +0 -0
  195. package/payload/server/public/assets/wrench-Cpee3C3J.js.br +0 -2
  196. package/payload/server/public/assets/wrench-Cpee3C3J.js.gz +0 -0
  197. /package/payload/server/public/assets/{useSubAccountSwitcher-CJKjEPNN.js → useSubAccountSwitcher-DH0P6QMV.js} +0 -0
  198. /package/payload/server/public/assets/{useSubAccountSwitcher-CJKjEPNN.js.br → useSubAccountSwitcher-DH0P6QMV.js.br} +0 -0
  199. /package/payload/server/public/assets/{useSubAccountSwitcher-CJKjEPNN.js.gz → useSubAccountSwitcher-DH0P6QMV.js.gz} +0 -0
@@ -0,0 +1,250 @@
1
+ # Account-machinery write fence Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Stop the account agent writing the five entries in the shipped account schema that are the account's own machinery rather than operator data.
6
+
7
+ **Architecture:** A new fenced `agent-denied-top-level` block in the shipped `SCHEMA.md` template lists the five names with a per-name reason. `fs-schema-guard.sh` parses it with the same awk shape it already uses for the other two fences and blocks a write whose first path segment matches, reporting `reason=account-machinery`. The `allowed-top-level` fence is not touched, so the standing reconcile keeps reading exactly what it reads today.
8
+
9
+ **Tech Stack:** bash, awk, python3. The hook and its tests are plain bash; no build and no `npm install`.
10
+
11
+ ## Global Constraints
12
+
13
+ - The deny set and each name's reason come from the account's own `SCHEMA.md`. The hook hard-codes no list.
14
+ - `allowed-top-level` is not modified. All five names stay in it, so the standing reconcile never reports them as strays.
15
+ - No fence means nothing is denied. An account whose `SCHEMA.md` predates this change keeps today's behaviour, matching the guard's existing fail-open posture on a missing schema and on a missing `declared-files` fence.
16
+ - No task numbers or internal refs in any operator-visible string.
17
+ - Block log line shape is fixed: `[fs-guard] blocked path=<rel> reason=account-machinery`.
18
+ - The separator inside the fence is a literal TAB, matching the `declared-files` fence.
19
+ - `fs-schema-guard-bash-post.sh` is not modified.
20
+ - Baseline before any change: `fs-schema-guard.test.sh` is `39 passed, 0 failed`; `fs-schema-guard-bash.test.sh` is `15 passed, 0 failed`.
21
+
22
+ ---
23
+
24
+ ### Task 1: Ship the deny fence in the account schema template
25
+
26
+ **Files:**
27
+ - Modify: `platform/templates/account-schema/SCHEMA.md` (append a section after the "## Allowed top-level entries" section)
28
+ - Test: `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
29
+
30
+ **Interfaces:**
31
+ - Consumes: nothing.
32
+ - Produces: a fenced block whose header line is exactly three backticks followed by `agent-denied-top-level`, one line per name as `name<TAB>reason`, in the order `account.json`, `SCHEMA.md`, `secrets`, `.claude`, `.git`. Task 2 parses it.
33
+
34
+ This task is data only. After it the guard's behaviour is unchanged, and every pre-existing case must still pass — that is the evidence the appended fence disturbs no existing reader.
35
+
36
+ - [ ] **Step 1: Write the failing assertions**
37
+
38
+ Append these two assertions to `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`, immediately after the existing `allowed-set parse` assertion and before the `echo "----- $PASS passed, $FAIL failed -----"` line:
39
+
40
+ ````bash
41
+ # Deny-fence parse == documented set. The guard reads this fence, so pinning its
42
+ # contents here means a name cannot be added or dropped without a test edit.
43
+ EXPECT_D="account.json SCHEMA.md secrets .claude .git"
44
+ GOT_D=$(awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | cut -f1 | tr '\n' ' ' | sed 's/ *$//')
45
+ if [ "$GOT_D" = "$EXPECT_D" ]; then echo "PASS: deny-set parse"; PASS=$((PASS+1));
46
+ else echo "FAIL: deny-set parse: got [$GOT_D]" >&2; FAIL=$((FAIL+1)); fi
47
+
48
+ # Every deny entry carries a reason column: the block message has no owning
49
+ # plugin to name, so the schema must supply the explanation.
50
+ NOREASON=$(awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | awk -F'\t' 'NF<2 || $2==""{print $1}')
51
+ if [ -z "$NOREASON" ]; then echo "PASS: deny-set reasons"; PASS=$((PASS+1));
52
+ else echo "FAIL: deny-set reasons missing for [$NOREASON]" >&2; FAIL=$((FAIL+1)); fi
53
+ ````
54
+
55
+ - [ ] **Step 2: Run the suite to verify the new assertion fails**
56
+
57
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
58
+
59
+ Expected: `FAIL: deny-set parse: got []` on stderr, and the run ends `----- 40 passed, 1 failed -----` with a non-zero exit. `deny-set reasons` passes at this point only because an absent fence yields no rows; it becomes meaningful after Step 3.
60
+
61
+ - [ ] **Step 3: Add the fence to the template**
62
+
63
+ Append this section to the end of `platform/templates/account-schema/SCHEMA.md`, after the closing fence of the `allowed-top-level` block. **The separator between each name and its reason is a literal TAB character, not spaces.**
64
+
65
+ `````markdown
66
+ ## Entries you may not write
67
+
68
+ Five of the entries above are this account's own machinery rather than operator
69
+ data. Each is listed below with the reason it is off-limits. The write guard
70
+ blocks a write whose first path segment matches one of them and quotes that
71
+ reason back. Change any of them through the code that owns it: platform code
72
+ writes all five, and none is written through an agent file edit.
73
+
74
+ ```agent-denied-top-level
75
+ account.json the account's identity and settings, changed through the account and admin tools
76
+ SCHEMA.md the layout rules the write guard itself reads, so editing it widens its own fence
77
+ secrets provisioned credentials, written by the code that mints each one
78
+ .claude the agent's own settings and hooks, seeded when the account is provisioned
79
+ .git the account directory's git internals, managed by git
80
+ ```
81
+ `````
82
+
83
+ - [ ] **Step 4: Verify the tab separator landed**
84
+
85
+ Run:
86
+
87
+ ````bash
88
+ awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' platform/templates/account-schema/SCHEMA.md | cat -A | head -5
89
+ ````
90
+
91
+ Expected: each of the five lines shows `^I` between the name and the reason, and ends `$`. If any line shows spaces instead, fix it before continuing — the guard splits on TAB and a space-separated line parses as a name with no reason.
92
+
93
+ - [ ] **Step 5: Run the full guard suite**
94
+
95
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
96
+
97
+ Expected: `----- 41 passed, 0 failed -----`. Every pre-existing case must still pass, which is the proof the appended fence does not disturb the `allowed-top-level` parse, the `declared-files` parse, or any existing account fixture.
98
+
99
+ - [ ] **Step 6: Run the neighbouring suites that read the same schema**
100
+
101
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh`
102
+
103
+ Expected: `----- 15 passed, 0 failed -----`
104
+
105
+ Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
106
+
107
+ Expected: the suite's own pass line with zero failures. This is the regression check that the owned-dirs merge still finds and rewrites the `allowed-top-level` fence and its three marker-delimited regions with an extra fence present in the file.
108
+
109
+ - [ ] **Step 7: Commit**
110
+
111
+ ````bash
112
+ git add maxy-code/platform/templates/account-schema/SCHEMA.md maxy-code/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh
113
+ git commit -m "feat(2052): ship the agent-denied-top-level fence in the account schema template"
114
+ ````
115
+
116
+ ---
117
+
118
+ ### Task 2: The guard denies a write to a fenced name
119
+
120
+ **Files:**
121
+ - Modify: `platform/plugins/admin/hooks/fs-schema-guard.sh` (header comment block at lines 2-22; new branch inserted after the top-level check's closing `fi` at line 89)
122
+ - Test: `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
123
+
124
+ **Interfaces:**
125
+ - Consumes: the `agent-denied-top-level` fence from Task 1, `name<TAB>reason` per line.
126
+ - Produces: exit 2 with `[fs-guard] blocked path=<rel> reason=account-machinery` on stderr, plus an operator-visible line quoting that name's reason. Nothing later depends on it.
127
+
128
+ - [ ] **Step 1: Write the failing tests**
129
+
130
+ Add this block to `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`, immediately after the `run_case "no fence no deny" ...` line and before the `# Allowed-set parse == documented set.` comment:
131
+
132
+ ````bash
133
+ # Account machinery: five entries in the allowed set are the account's own
134
+ # control plane, denied by name in the agent-denied-top-level fence. $ACCT is
135
+ # the pristine template copy, so it carries the fence. $ACCT_M models a schema
136
+ # written before the fence existed (names in the allowed set, fence stripped),
137
+ # which pins the deny to the fence rather than to a list baked into the hook.
138
+ ACCT_M=$(mktemp -d)
139
+ trap 'rm -rf "$ACCT" "$ACCT_D" "$ACCT_F" "$ACCT_L" "$ACCT_M"' EXIT
140
+ awk '/^```agent-denied-top-level$/{skip=1;next} skip && /^```$/{skip=0;next} !skip' "$TEMPLATE" > "$ACCT_M/SCHEMA.md"
141
+
142
+ DENIED_NAMES="account.json SCHEMA.md secrets .claude .git"
143
+ for n in $DENIED_NAMES; do
144
+ run_case "machinery block $n" "$(mkenv Write file_path "$n")" 2 "fs-guard. blocked path=$n reason=account-machinery"
145
+ done
146
+ # Segment-0 match: a nested path under a denied entry blocks for the same reason.
147
+ run_case "machinery nested settings" "$(mkenv Write file_path '.claude/settings.json')" 2 "fs-guard. blocked path=.claude/settings.json reason=account-machinery"
148
+ run_case "machinery nested secret" "$(mkenv Write file_path 'secrets/cloudflare.env')" 2 "reason=account-machinery"
149
+ # Every write tool, not just Write.
150
+ run_case "machinery edit blocks" "$(mkenv Edit file_path 'account.json')" 2 "reason=account-machinery"
151
+ run_case "machinery notebook blocks" "$(mkenv NotebookEdit notebook_path '.claude/n.ipynb')" 2 "reason=account-machinery"
152
+ # The schema's own reason reaches the operator-visible message.
153
+ run_case "machinery reason text" "$(mkenv Write file_path 'SCHEMA.md')" 2 "widens its own fence"
154
+ # An operator bucket under the same schema is unaffected.
155
+ run_case "machinery bucket allow" "$(mkenv Write file_path 'projects/acme/a.txt')" 0 ""
156
+ # No fence, no deny.
157
+ for n in $DENIED_NAMES; do
158
+ run_case "no deny fence allows $n" "$(mkenv Write file_path "$n")" 0 "" "$ACCT_M"
159
+ done
160
+ ````
161
+
162
+ - [ ] **Step 2: Run the suite to verify the new cases fail**
163
+
164
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
165
+
166
+ Expected: the ten `machinery *` cases FAIL with `(exit=0, want 2; ...)`. The five `no deny fence allows *` cases and `machinery bucket allow` already pass. The run ends `----- 47 passed, 10 failed -----` with a non-zero exit.
167
+
168
+ - [ ] **Step 3: Add the deny branch to the guard**
169
+
170
+ In `platform/plugins/admin/hooks/fs-schema-guard.sh`, insert this block immediately after the top-level check's closing `fi` (line 89) and before the `# Declared-file check.` comment:
171
+
172
+ ````bash
173
+ # Account-machinery check. Some entries in the allowed set are the account's own
174
+ # control plane rather than operator data: the identity file, the schema this
175
+ # hook parses, the credential store, the agent's own settings and hooks, and the
176
+ # git dir. The allowed set says which names may EXIST at the account root, not
177
+ # which an agent may author, so each of these is named in a second fence together
178
+ # with the reason it is off-limits. Every one of them is written by platform code
179
+ # from bash or Node, never through Write/Edit/NotebookEdit, so denying them costs
180
+ # no legitimate writer. The set and its reasons come from the account's own
181
+ # SCHEMA.md, so the hook hard-codes no list. There is no owning plugin to name
182
+ # here, which is why this fence carries a reason column the declared-files fence
183
+ # does not need. No fence (a schema written before the fence existed) means
184
+ # nothing to deny — the same fail-open posture as the missing-schema case above.
185
+ DENIED=$(awk '/^```agent-denied-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
186
+ if [ -n "$DENIED" ] && printf '%s\n' "$DENIED" | awk -F'\t' -v n="$SEG0" '$1==n{found=1} END{exit !found}'; then
187
+ WHY=$(printf '%s\n' "$DENIED" | awk -F'\t' -v n="$SEG0" '$1==n{print $2; exit}')
188
+ echo "[fs-guard] blocked path=$REL reason=account-machinery" >&2
189
+ echo "Blocked: '$SEG0' is this account's own machinery, not operator data: $WHY. Change it through the code that owns it, never by hand." >&2
190
+ exit 2
191
+ fi
192
+ ````
193
+
194
+ - [ ] **Step 4: Update the hook's header comment**
195
+
196
+ In the same file, in the header comment block, insert this bullet after the `allowed top-level set` bullet and before the `plugin-declared file` bullet, so the header lists all three rules the hook enforces:
197
+
198
+ ````bash
199
+ # - a target whose first path segment is named in the fenced
200
+ # ```agent-denied-top-level block is blocked even though that name is in the
201
+ # allowed set: those entries are the account's own machinery (identity,
202
+ # schema, credentials, agent settings, git dir), written by platform code
203
+ # and never by an agent file edit. The block message quotes the reason the
204
+ # fence carries for that name;
205
+ ````
206
+
207
+ Then extend the exit-codes paragraph's log-line list so it reads:
208
+
209
+ ````bash
210
+ # [fs-guard] blocked path=<rel> reason=<top-level|account-machinery|declared-file|over-deep|bad-name>
211
+ ````
212
+
213
+ - [ ] **Step 5: Run the suite to verify it passes**
214
+
215
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
216
+
217
+ Expected: `----- 57 passed, 0 failed -----`
218
+
219
+ - [ ] **Step 6: Mutation check**
220
+
221
+ Comment out the `exit 2` inside the new branch and re-run the suite.
222
+
223
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
224
+
225
+ Expected: the ten `machinery *` cases FAIL. Then restore the line and re-run.
226
+
227
+ Expected after restore: `----- 57 passed, 0 failed -----`
228
+
229
+ - [ ] **Step 7: Confirm the untouched surfaces still pass**
230
+
231
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh`
232
+
233
+ Expected: `----- 15 passed, 0 failed -----`. The Bash post-hook still reads only `allowed-top-level` and `declared-files`, and still stays silent on all five names.
234
+
235
+ Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
236
+
237
+ Expected: zero failures.
238
+
239
+ - [ ] **Step 8: Commit**
240
+
241
+ ````bash
242
+ git add maxy-code/platform/plugins/admin/hooks/fs-schema-guard.sh maxy-code/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh
243
+ git commit -m "feat(2052): deny agent writes to the account's own machinery"
244
+ ````
245
+
246
+ ---
247
+
248
+ ## Reach note for the debrief
249
+
250
+ `provision-account-dir.sh:40-41` copies the template to `<accountDir>/SCHEMA.md`, and `setup-account.sh:59` calls that for the house account only. The standing all-accounts reconcile merges just the marker-delimited regions, so it never carries the template body. The house account therefore gains the fence on the next install, while a client sub-account, provisioned once at creation, keeps a fence-less schema indefinitely and the guard stays fail-open there. That gap is filed as `.tasks/pending/2056-the-account-machinery-deny-fence-never-reaches-an-existing-sub-account.md`, following the durable-backfill pattern of Task 1683 and Task 1929. Separately, `platform/` ships inside the bundled `create-*-code` payload, so reaching a device at all needs an operator-gated installer publish plus an `npx` upgrade. Both belong in the debrief's known-limitations section.
@@ -0,0 +1,139 @@
1
+ # Task 2016 — a disabled agent must leave the admin's routing table, and read as disabled rather than missing
2
+
3
+ Design, 2026-07-27. Depends on Task 1996 (landed).
4
+
5
+ ## The defect
6
+
7
+ Task 1996 stops dispatch by moving an agent file out of `<accountDir>/specialists/agents/`,
8
+ the directory `spawn-context.ts` reads to build the spawn manifest. Two surfaces still
9
+ believe the file is there.
10
+
11
+ `platform/scripts/lib/agents-md-bootstrap.sh:42-47` only ever appends. A line matching
12
+ `^- \*\*specialists:<name>\*\*:` in `agents/admin/AGENTS.md` is preserved verbatim and a
13
+ missing one is appended; nothing is ever removed. So the admin's routing prose keeps
14
+ advertising a specialist the operator switched off, and dispatch fails inside a turn
15
+ against a file that is gone.
16
+
17
+ `platform/plugins/admin/mcp/src/index.ts:520-533` walks the AGENTS.md entries against
18
+ `specialists/agents/` and calls a name with no file `missing file`. An operator action is
19
+ rendered as an install fault.
20
+
21
+ ## What the fix delivers
22
+
23
+ 1. A name the operator disabled has no routing line.
24
+ 2. Re-enabling restores it.
25
+ 3. `system-status` reports `disabled` and `missing file` as different things, and counts
26
+ them separately.
27
+
28
+ ## Surface 1 — `agents-md-bootstrap.sh` reconciles
29
+
30
+ The store is `<accountDir>/agents-disabled.json`, `{ "disabled": ["<basename>.md", …] }`,
31
+ written whole at 0600 by `platform/ui/server/routes/admin/agents.ts`. The bootstrap reads
32
+ it with the same python3 extractor `provision-account-dir.sh:264-276` already uses, which
33
+ accepts only strings ending `.md` with no `/` and no `..`.
34
+
35
+ **The name rule.** A store entry maps to a routing line by stripping `.md`. One rule, no
36
+ branch: all twelve files under `platform/templates/specialists/agents/` have `name:` equal
37
+ to their filename, the disable route keys on `slug` equal to the basename, and the status
38
+ walker at `index.ts:526` already maps a name back to `<name>.md`.
39
+
40
+ **The reconcile rule.** A name in the store gets no routing line. It applies at both points
41
+ in the script: an existing line for that name is removed, and the append loop skips that
42
+ name. Stating it once and applying it uniformly is what makes the invariant hold in the
43
+ drift state the standing `op=agent-parity` check exists to catch (store names it, file is
44
+ still live) rather than only on the happy path.
45
+
46
+ **An unreadable store withholds nothing.** `provision-account-dir.sh:293` already prints
47
+ `specialists agents-disabled-store-unreadable — withholding nothing` and carries on, so
48
+ the prose posture matches the file-withholding posture that runs immediately before it.
49
+
50
+ **Observability.** The existing line stays byte-identical, because the current suite
51
+ asserts its counts:
52
+
53
+ ```
54
+ [admin-bootstrap] AGENTS.md specialists=<n> appended=<m> existing=<k>
55
+ ```
56
+
57
+ A second line is added:
58
+
59
+ ```
60
+ [agents-md] op=specialist-lines account=<id8> kept=<n> withheld=<comma-list|none|store-unreadable>
61
+ ```
62
+
63
+ `id8` is the first eight characters of the account directory's basename. `kept` counts the
64
+ `- **specialists:` lines in AGENTS.md after the run. `withheld` names the store's entries,
65
+ sorted, as names rather than basenames; `none` when the store is absent or empty; the
66
+ literal `store-unreadable` when it is present but does not parse.
67
+
68
+ ## Surface 2 — `system-status` separates disabled from missing
69
+
70
+ The classification at `index.ts:520-533` moves into a new
71
+ `platform/plugins/admin/mcp/src/specialist-registry.ts`, beside `skill-resolution.ts` and
72
+ `store-skill-core.ts`, which are the same shape: a module `index.ts` imports and a
73
+ `node:test` suite covers. It exports one function taking the AGENTS.md text, the
74
+ specialists directory, the account directory and whether Chrome is up, and returning the
75
+ per-name lines plus the two counts.
76
+
77
+ Status, in order:
78
+
79
+ - the file exists in `specialists/agents/` → `ok`, except `personal-assistant` with Chrome
80
+ down, which keeps today's `degraded — chrome not running`;
81
+ - otherwise the name is in the store → `disabled (operator)`;
82
+ - otherwise → `missing file`.
83
+
84
+ The header becomes `<n> registered, missing=<x> disabled=<y>`. An unreadable store is
85
+ tolerated the same way the bootstrap tolerates it: no names classify as disabled, and the
86
+ absent file stays `missing file`, which is the honest answer when the store cannot be read.
87
+
88
+ With surface 1 in place a disabled agent has no AGENTS.md line at all, so in the steady
89
+ state `disabled=0`. The classification earns its place in the drift window: an account
90
+ disabled before this change carries the stale line until a provisioning run or a
91
+ disable/enable call reconciles it, and that is exactly when the operator needs the line to
92
+ read `disabled` rather than `missing file`.
93
+
94
+ ## Surface 3 — the disable path reconciles immediately
95
+
96
+ `agents.ts` currently leaves AGENTS.md untouched, so prose is only correct after the next
97
+ provisioning run. After the file move and the store write, both handlers exec
98
+ `bash <PLATFORM_ROOT>/scripts/lib/agents-md-bootstrap.sh <accountDir>` with a timeout.
99
+ `platform/scripts` ships in the payload (`packages/create-maxy-code/scripts/bundle.js:318`
100
+ copies the whole `platform` tree minus the listed exclusions), and `claude-info.ts:33`
101
+ is the precedent for `execFileSync` in an admin route.
102
+
103
+ Enable needs no separate restore path. The file is live again by the time the script runs,
104
+ so the append loop puts the line back and the store no longer names it.
105
+
106
+ **Failure posture.** A failed exec does not turn the request into a 500: the file move and
107
+ the store write already happened, and dispatch is already stopped, so reporting the whole
108
+ disable as failed would be false. The handler logs
109
+ `[admin/agents] op=agents-md-reconcile accountId=<id8> slug=<slug> outcome=failed reason="…"`
110
+ and the response carries `routingReconciled: false`, so no caller is told the prose is
111
+ correct when it is not.
112
+
113
+ ## Tests
114
+
115
+ **Shell, `platform/scripts/__tests__/agents-md-bootstrap.test.sh`.** Against the existing
116
+ three-specialist temp account: a store naming one of them leaves no line for it and keeps
117
+ the other two, and the `op=specialist-lines` line reports `kept=2 withheld=<that name>`;
118
+ clearing the store restores the line on the next run; a malformed store removes nothing and
119
+ reports `withheld=store-unreadable`. The pre-existing cases run unchanged with no store on
120
+ disk.
121
+
122
+ **Route, `platform/ui/server/routes/admin/__tests__/agents.test.ts`.** The harness mocks
123
+ `PLATFORM_ROOT` to a temp tree; the real bootstrap script is copied into
124
+ `<platformRoot>/scripts/lib/` so the exec runs the shipped code rather than a stub. A
125
+ disable leaves no `specialists:<name>` line in the account's AGENTS.md; the following
126
+ enable puts it back.
127
+
128
+ **MCP, `platform/plugins/admin/mcp/src/__tests__/specialist-registry.test.ts`
129
+ (`node:test`).** A temp account with three AGENTS.md entries: one live file, one named by
130
+ the store, one neither. Asserts `ok`, `disabled (operator)`, `missing file`, and
131
+ `missing=1 disabled=1`. A `missing file` line for a name in the store is a failure.
132
+
133
+ ## Out of scope
134
+
135
+ - The disable mechanism, the quarantine directory, and the store format (Task 1996).
136
+ - What AGENTS.md is for, and its append-only posture for anything that is not a specialist
137
+ routing line.
138
+ - The `op=agent-parity` check, which already covers store-versus-directory drift.
139
+ - `AGENTS.md` write permissions, which is Task 2057.
@@ -121,6 +121,7 @@ rather than flip it silently.
121
121
 
122
122
  - The fence's pre-existing permissiveness for `account.json`, `secrets/` and
123
123
  `.claude/`. It predates Task 1902 and is a separate decision, filed as
124
- `.tasks/pending/2048-the-allowed-fence-permits-agent-writes-to-the-account-s-own-machinery.md`.
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).
125
126
  - The reconcile. It reads the fence correctly and needs no change.
126
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`.
@@ -125,11 +125,52 @@ run_case "declared edit blocks" "$(mkenv Edit file_path 'wa-channel-bindings.j
125
125
  run_case "operator bucket allow" "$(mkenv Write file_path 'projects/acme/a.txt')" 0 "" "$ACCT_F"
126
126
  run_case "no fence no deny" "$(mkenv Write file_path 'wa-channel-bindings.json')" 0 "" "$ACCT_L"
127
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
+
128
156
  # Allowed-set parse == documented set.
129
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"
130
158
  GOT=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | tr '\n' ' ' | sed 's/ *$//')
131
159
  if [ "$GOT" = "$EXPECT" ]; then echo "PASS: allowed-set parse"; PASS=$((PASS+1));
132
160
  else echo "FAIL: allowed-set parse: got [$GOT]" >&2; FAIL=$((FAIL+1)); fi
133
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
+
134
175
  echo "----- $PASS passed, $FAIL failed -----"
135
176
  [ $FAIL -eq 0 ]
@@ -28,6 +28,14 @@
28
28
  # declared-files fence; this hook stays quiet, and a case in the test suite pins
29
29
  # the silence.
30
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
+ #
31
39
  # Exit codes: 0 = allow, 2 = feedback to the agent (stderr shown). Fail open
32
40
  # (exit 0) when the snapshot is missing or the allowed set is empty. Every block
33
41
  # logs: [fs-guard-bash] stray path=<name> reason=<top-level|bad-name>