@rubytech/create-sitedesk-code 0.1.511 → 0.1.513

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 (48) 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/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +126 -0
  4. package/payload/platform/lib/account-schema-regions/dist/index.d.ts +5 -0
  5. package/payload/platform/lib/account-schema-regions/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/account-schema-regions/dist/index.js +5 -0
  7. package/payload/platform/lib/account-schema-regions/dist/index.js.map +1 -1
  8. package/payload/platform/lib/account-schema-regions/src/index.ts +5 -0
  9. package/payload/platform/package.json +3 -0
  10. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +14 -0
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +38 -0
  12. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +11 -0
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +25 -1
  14. package/payload/platform/plugins/admin/skills/deck-pages/SKILL.md +2 -2
  15. package/payload/platform/plugins/admin/skills/skill-builder/SKILL.md +1 -1
  16. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  17. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +15 -34
  18. package/payload/platform/plugins/business-assistant/skills/e-sign/stamp.mjs +269 -0
  19. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +1 -1
  21. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +5 -5
  22. package/payload/platform/plugins/memory/skills/conversation-archive-enrich/SKILL.md +2 -2
  23. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +1 -1
  24. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  25. package/payload/platform/plugins/x-import/skills/x-import/SKILL.md +1 -1
  26. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +26 -0
  27. package/payload/platform/scripts/check-no-esm-require.mjs +6 -0
  28. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +40 -4
  29. package/payload/platform/scripts/logs-read.sh +154 -50
  30. package/payload/platform/scripts/logs-read.test.sh +276 -0
  31. package/payload/platform/scripts/provision-worktree.sh +80 -0
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +13 -0
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +7 -3
  36. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +17 -11
  38. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -1
  39. package/payload/server/{chunk-2WAXM5N2.js → chunk-S6HYTIU3.js} +2 -3
  40. package/payload/server/maxy-edge.js +7 -3
  41. package/payload/server/public/assets/admin-6QN-k3zQ.js +1 -0
  42. package/payload/server/public/assets/admin-6QN-k3zQ.js.br +0 -0
  43. package/payload/server/public/assets/admin-6QN-k3zQ.js.gz +0 -0
  44. package/payload/server/public/index.html +1 -1
  45. package/payload/server/server.js +860 -384
  46. package/payload/server/public/assets/admin-Do53K90C.js +0 -1
  47. package/payload/server/public/assets/admin-Do53K90C.js.br +0 -0
  48. package/payload/server/public/assets/admin-Do53K90C.js.gz +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.511",
3
+ "version": "0.1.513",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -0,0 +1,303 @@
1
+ # Task 2028 — declared-file write deny: implementation plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** The write guard blocks an agent `Write`/`Edit`/`NotebookEdit` to a plugin-declared account-root file, naming the owning plugin, while an operator bucket in the same fence still passes.
6
+
7
+ **Architecture:** The merge emits a third generated region carrying `name<TAB>owning-plugin` inside a ```` ```declared-files ```` fence. The guard parses that fence with the same one-line awk it already uses for `allowed-top-level`, and denies after the top-level check passes. `allowed-top-level` itself is untouched, so the standing reconcile is unaffected.
8
+
9
+ **Tech Stack:** bash (PreToolUse/PostToolUse hooks), python3 (schema merge), bash test suites.
10
+
11
+ ## Global Constraints
12
+
13
+ - No task numbers or internal refs in any operator-visible string (existing hook rule, stated in both hook headers).
14
+ - The guard never hard-codes a file list; the account's own `SCHEMA.md` is the only source.
15
+ - Fail-open posture is preserved: no `declared-files` fence means nothing to deny.
16
+ - The block log keeps the existing shape: `[fs-guard] blocked path=<rel> reason=<...>`.
17
+ - Spec: `platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md`.
18
+
19
+ ---
20
+
21
+ ### Task 1: The merge emits the declared-files region
22
+
23
+ **Files:**
24
+ - Modify: `platform/scripts/lib/account-schema-owned-dirs.py` (module constants; `merge()`)
25
+ - Test: `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
26
+
27
+ **Interfaces:**
28
+ - Produces: a region delimited by `<!-- declared-files:start -->` / `<!-- declared-files:end -->` containing a ```` ```declared-files ```` fence, one `name<TAB>plugin` line per declared file in `resolve()` order. Task 2's guard consumes exactly that fence.
29
+
30
+ - [ ] **Step 1: Write the failing test**
31
+
32
+ Append to `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`, after the existing platform-plugin declarations block:
33
+
34
+ ```bash
35
+ # --- declared-files fence: one name<TAB>plugin line per declared file -------
36
+ DECL="$(awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$A_MX/SCHEMA.md")"
37
+ assert_grep "$(printf 'wa-channel-bindings.json\tadmin')" "$DECL" "mx-decl-wa"
38
+ assert_grep "$(printf 'agents-disabled.json\tadmin')" "$DECL" "mx-decl-agents-disabled"
39
+ assert_grep "$(printf 'data-portal.json\tcloudflare')" "$DECL" "mx-decl-portal"
40
+ assert_grep "$(printf 'calendar-availability.json\tscheduling')" "$DECL" "mx-decl-availability"
41
+ assert_nogrep "e-sign" "$DECL" "mx-decl-excludes-dirs"
42
+ assert_grep "declared-files:start" "$(cat "$A_MX/SCHEMA.md")" "mx-decl-marker"
43
+ ```
44
+
45
+ - [ ] **Step 2: Run the test to verify it fails**
46
+
47
+ Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
48
+ Expected: FAIL on `mx-decl-wa` and the other new assertions (no such fence yet).
49
+
50
+ - [ ] **Step 3: Write the implementation**
51
+
52
+ In `platform/scripts/lib/account-schema-owned-dirs.py`, beside the existing marker constants:
53
+
54
+ ```python
55
+ DECL_START = "<!-- declared-files:start -->"
56
+ DECL_END = "<!-- declared-files:end -->"
57
+ ```
58
+
59
+ In `merge()`, add the strip beside the two existing strips:
60
+
61
+ ```python
62
+ text = _strip_region(text, DECL_START, DECL_END)
63
+ ```
64
+
65
+ and append the region after the ontology region is appended:
66
+
67
+ ```python
68
+ declared = [e for e in owned if e["kind"] == "file"]
69
+ if declared:
70
+ region = [DECL_START, "## Declared files (written by their owning plugin)", "",
71
+ "Each file below is written whole by the plugin named beside it. It is",
72
+ "control-plane state, not operator data, and is not hand-edited: change",
73
+ "it through the owning plugin's own tool. The write guard reads this",
74
+ "block and blocks a write whose first path segment is one of these",
75
+ "names, which the allowed-top-level set above cannot express — that set",
76
+ "lists what may exist at the root, not what may be authored by hand.", "",
77
+ "```declared-files"]
78
+ for e in declared:
79
+ region.append(f"{e['name']}\t{e['plugin']}")
80
+ region.append("```")
81
+ region.append(DECL_END)
82
+ text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
83
+ ```
84
+
85
+ - [ ] **Step 4: Run the tests to verify they pass**
86
+
87
+ Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
88
+ Expected: PASS, including the pre-existing `idempotent-second-merge` assertion.
89
+
90
+ - [ ] **Step 5: Commit**
91
+
92
+ ```bash
93
+ git add platform/scripts/lib/account-schema-owned-dirs.py platform/scripts/__tests__/account-schema-owned-dirs.test.sh
94
+ git commit -m "feat(2028): merge emits a declared-files fence carrying each file's owning plugin"
95
+ ```
96
+
97
+ ---
98
+
99
+ ### Task 2: The guard denies a write to a declared file
100
+
101
+ **Files:**
102
+ - Modify: `platform/plugins/admin/hooks/fs-schema-guard.sh` (header comment; new check after the top-level check)
103
+ - Test: `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
104
+
105
+ **Interfaces:**
106
+ - Consumes: the ```` ```declared-files ```` fence from Task 1, lines `name<TAB>plugin`.
107
+ - Produces: `[fs-guard] blocked path=<rel> reason=declared-file` on stderr, exit 2.
108
+
109
+ - [ ] **Step 1: Write the failing test**
110
+
111
+ Append to `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`, after the `ACCT_D` setup:
112
+
113
+ ```bash
114
+ # A third account modelling a merged schema: the eight declared files are in the
115
+ # allowed set (as the merge unions them) AND in the declared-files fence.
116
+ ACCT_F=$(mktemp -d)
117
+ trap 'rm -rf "$ACCT" "$ACCT_D" "$ACCT_F" "$ACCT_L"' EXIT
118
+ DECLARED_FILES="webchat-channel-bindings.json wa-channel-bindings.json telegram-channel-bindings.json canonical-webchat-session.json session-titles.json agents-disabled.json data-portal.json calendar-availability.json"
119
+ seed_declared() { # $1=acct $2=with-fence(yes|no)
120
+ cp "$TEMPLATE" "$1/SCHEMA.md"
121
+ for f in $DECLARED_FILES; do
122
+ 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"
123
+ done
124
+ [ "$2" = "yes" ] || return 0
125
+ { echo; echo '<!-- declared-files:start -->'; echo '```declared-files'
126
+ for f in $DECLARED_FILES; do
127
+ case "$f" in
128
+ data-portal.json) printf '%s\tcloudflare\n' "$f" ;;
129
+ calendar-availability.json) printf '%s\tscheduling\n' "$f" ;;
130
+ *) printf '%s\tadmin\n' "$f" ;;
131
+ esac
132
+ done
133
+ echo '```'; echo '<!-- declared-files:end -->'; } >> "$1/SCHEMA.md"
134
+ }
135
+ seed_declared "$ACCT_F" yes
136
+ mkdir -p "$ACCT_F/projects/acme"
137
+
138
+ # A fourth account in the pre-2028 state: the names are in the allowed set, but
139
+ # no declared-files fence exists. Pins that the deny comes from the fence and
140
+ # not from a list baked into the hook.
141
+ ACCT_L=$(mktemp -d)
142
+ seed_declared "$ACCT_L" no
143
+
144
+ for f in $DECLARED_FILES; do
145
+ case "$f" in
146
+ data-portal.json) owner=cloudflare ;;
147
+ calendar-availability.json) owner=scheduling ;;
148
+ *) owner=admin ;;
149
+ esac
150
+ run_case "declared block $f" "$(mkenv Write file_path "$f")" 2 "fs-guard. blocked path=$f reason=declared-file" "$ACCT_F"
151
+ run_case "declared owner $f" "$(mkenv Write file_path "$f")" 2 "$owner plugin" "$ACCT_F"
152
+ done
153
+ run_case "operator bucket allow" "$(mkenv Write file_path 'projects/acme/a.txt')" 0 "" "$ACCT_F"
154
+ run_case "declared edit block" "$(mkenv Edit file_path 'wa-channel-bindings.json')" 2 "reason=declared-file" "$ACCT_F"
155
+ run_case "no fence no deny" "$(mkenv Write file_path 'wa-channel-bindings.json')" 0 "" "$ACCT_L"
156
+ ```
157
+
158
+ - [ ] **Step 2: Run the test to verify it fails**
159
+
160
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
161
+ Expected: FAIL on every `declared block`/`declared owner` case with `exit=0, want 2`; `no fence no deny` already passes.
162
+
163
+ - [ ] **Step 3: Write the implementation**
164
+
165
+ In `platform/plugins/admin/hooks/fs-schema-guard.sh`, immediately after the top-level check block:
166
+
167
+ ```bash
168
+ # Declared-file check. A file a plugin declares in its PLUGIN.md is written
169
+ # whole by that plugin, so an agent write to it is blocked even though its name
170
+ # is in the allowed set: that set lists what may exist at the account root, not
171
+ # what may be authored by hand. The set and its owner come from the account's
172
+ # own SCHEMA.md — the hook hard-codes no list. No fence (a brand declaring no
173
+ # files, or a schema predating the block) means nothing to deny, the same
174
+ # fail-open posture as the missing-schema case above.
175
+ DECLARED=""
176
+ if [ -f "$ACCOUNT_DIR/SCHEMA.md" ]; then
177
+ DECLARED=$(awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
178
+ fi
179
+ if [ -n "$DECLARED" ] && printf '%s\n' "$DECLARED" | awk -F'\t' -v n="$SEG0" '$1==n{found=1} END{exit !found}'; then
180
+ OWNER=$(printf '%s\n' "$DECLARED" | awk -F'\t' -v n="$SEG0" '$1==n{print $2; exit}')
181
+ [ -z "$OWNER" ] && OWNER="owning"
182
+ echo "[fs-guard] blocked path=$REL reason=declared-file" >&2
183
+ echo "Blocked: '$SEG0' is written whole by the $OWNER plugin. It is control-plane state, not operator data, and a hand-written copy is overwritten by its owner's next write. Change it through the $OWNER plugin's own tool." >&2
184
+ exit 2
185
+ fi
186
+ ```
187
+
188
+ Update the header comment's reason list to `reason=<top-level|declared-file|over-deep|bad-name>` and add one line to the enforcement summary: a target whose first segment is a declared file is blocked.
189
+
190
+ - [ ] **Step 4: Run the tests to verify they pass**
191
+
192
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
193
+ Expected: PASS on all cases, including the pre-existing ten.
194
+
195
+ - [ ] **Step 5: Mutation check**
196
+
197
+ Comment out the `exit 2` inside the new block, re-run the suite, confirm the 16 declared cases fail, then restore.
198
+
199
+ - [ ] **Step 6: Commit**
200
+
201
+ ```bash
202
+ git add platform/plugins/admin/hooks/fs-schema-guard.sh platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh
203
+ git commit -m "feat(2028): the write guard denies a write to a plugin-declared file"
204
+ ```
205
+
206
+ ---
207
+
208
+ ### Task 3: The Bash post-hook decision is stated and pinned
209
+
210
+ **Files:**
211
+ - Modify: `platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh` (header comment only)
212
+ - Test: `platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh`
213
+
214
+ **Interfaces:**
215
+ - Consumes: nothing new. The behaviour under test is the existing allowed-set diff.
216
+
217
+ - [ ] **Step 1: Write the failing test**
218
+
219
+ Append to `platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh`:
220
+
221
+ ```bash
222
+ # --- 10. Bash creating a declared file -> silent, by decision ---------------
223
+ # A declared file is in the allowed set, so the diff never flags it. Kept
224
+ # deliberately: this hook sees only names that appeared during the command and
225
+ # cannot tell a hand-written copy from the owner's own script creating it.
226
+ D=$(new_acct)
227
+ awk '1; /^```allowed-top-level$/{print "wa-channel-bindings.json"}' "$D/SCHEMA.md" > "$D/SCHEMA.md.tmp" && mv "$D/SCHEMA.md.tmp" "$D/SCHEMA.md"
228
+ run_pre "$D" "$(env_json PreToolUse Bash "$SID")" "$ef"
229
+ printf '{}' > "$D/wa-channel-bindings.json"
230
+ run_post "$D" "$(env_json PostToolUse Bash "$SID")" "$ef"
231
+ check "post silent on declared file" 0 $? "" "$(cat "$ef")"
232
+ ```
233
+
234
+ - [ ] **Step 2: Run the test**
235
+
236
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh`
237
+ Expected: PASS. This test pins existing behaviour rather than driving a change; it fails only if a future edit makes the post hook flag declared files.
238
+
239
+ - [ ] **Step 3: State the decision in the hook**
240
+
241
+ Add to the header comment of `platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh`:
242
+
243
+ ```
244
+ # A declared file (a name a plugin declares as its own in PLUGIN.md) is in the
245
+ # allowed set, so this hook never flags one. That silence is a decision, not an
246
+ # accident: the diff sees only names that appeared during this command, so it
247
+ # cannot tell the agent hand-writing the file from the agent running the owner's
248
+ # own script (the portal index push, the availability publish), and feedback
249
+ # would fire on the legitimate path. The Write/Edit guard blocks the hand-write
250
+ # it can actually identify; this hook stays quiet. A test pins the silence.
251
+ ```
252
+
253
+ - [ ] **Step 4: Re-run both hook suites**
254
+
255
+ Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh && bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
256
+ Expected: PASS on both.
257
+
258
+ - [ ] **Step 5: Commit**
259
+
260
+ ```bash
261
+ git add platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh
262
+ git commit -m "docs(2028): state and pin the Bash post-hook's silence on declared files"
263
+ ```
264
+
265
+ ---
266
+
267
+ ### Task 4: End-to-end against the real brand tree
268
+
269
+ **Files:** none modified. Verification only.
270
+
271
+ - [ ] **Step 1: Merge a real schema and read the fence**
272
+
273
+ ```bash
274
+ ACCT=$(mktemp -d) && cp platform/templates/account-schema/SCHEMA.md "$ACCT/SCHEMA.md"
275
+ PROJECT_DIR="$PWD/platform" python3 platform/scripts/lib/account-schema-owned-dirs.py merge "$ACCT"
276
+ awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md"
277
+ ```
278
+
279
+ Expected: eight `name<TAB>plugin` lines.
280
+
281
+ - [ ] **Step 2: Drive the real hook against that merged account**
282
+
283
+ ```bash
284
+ ( cd "$ACCT" && printf '{"tool_name":"Write","tool_input":{"file_path":"wa-channel-bindings.json"}}' \
285
+ | bash "$OLDPWD/platform/plugins/admin/hooks/fs-schema-guard.sh"; echo "exit=$?" )
286
+ ```
287
+
288
+ Expected: `reason=declared-file` on stderr and `exit=2`, against the same envelope that returned exit 0 before this change.
289
+
290
+ - [ ] **Step 3: Confirm the reconcile still names no declared file as a stray**
291
+
292
+ ```bash
293
+ PROJECT_DIR="$PWD/platform" python3 platform/scripts/lib/account-schema-owned-dirs.py reconcile "$ACCT" | grep ownedFile
294
+ ```
295
+
296
+ Expected: `present=true` on every line.
297
+
298
+ ---
299
+
300
+ ## Self-review
301
+
302
+ - Spec coverage: emission (Task 1), deny (Task 2), Bash decision (Task 3), the spec's testing section (Tasks 1-3 plus the mutation check in Task 2 and the end-to-end in Task 4).
303
+ - One spec sentence is tightened here: the "no fence" case uses an account whose allowed set already carries the names, because under the pristine template those names block as `top-level` and would not prove anything about the new branch.
@@ -0,0 +1,126 @@
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/pending/2048-the-allowed-fence-permits-agent-writes-to-the-account-s-own-machinery.md`.
125
+ - The reconcile. It reads the fence correctly and needs no change.
126
+ - Adding or removing declarations. Task 1902 settled the set.
@@ -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":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;;;AAmGH,gDAaC;AA9GD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACU,QAAA,UAAU,GAAsB,MAAM,CAAC,MAAM,CAAC;IACzD,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS;CAC5F,CAAC,CAAA;AAEF,MAAM,SAAS,GAAG,iCAAiC,CAAA;AACnD,MAAM,OAAO,GAAG,+BAA+B,CAAA;AAC/C,MAAM,YAAY,GAAG,kCAAkC,CAAA;AACvD,MAAM,UAAU,GAAG,gCAAgC,CAAA;AACnD,MAAM,aAAa,GAAG,sBAAsB,CAAA;AAE5C;;;;;;GAMG;AACH,MAAM,QAAQ,GAAG,mBAAmB,CAAA;AA0BpC,SAAS,WAAW,CAAC,IAAY,EAAE,KAAa,EAAE,GAAW;IAC3D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;IAChC,IAAI,IAAI,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IAC1B,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;IAClC,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IACxB,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;AACxD,CAAC;AAED,SAAS,MAAM,CAAC,KAAe;IAC7B,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;QACpC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IAC9C,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,SAAS,YAAY,CAAC,IAAY;IAChC,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,CAAA;IACxC,IAAI,IAAI,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IAC1B,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;IAC1C,IAAI,SAAS,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IAC/B,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC,CAAA;IACzC,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IACxB,OAAO,IAAI;SACR,KAAK,CAAC,SAAS,GAAG,CAAC,EAAE,EAAE,CAAC;SACxB,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AAChC,CAAC;AAED,SAAgB,kBAAkB,CAAC,QAAuB;IACxD,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/C,OAAO,EAAE,aAAa,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,eAAe,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAA;IAC5G,CAAC;IACD,MAAM,eAAe,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAA;IAC9C,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,GAAG,CAAC,CAAA;IACzC,OAAO;QACL,aAAa,EAAE,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;QAChE,WAAW,EAAE,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,YAAY,EAAE,UAAU,CAAC,CAAC;QACpE,eAAe;QACf,MAAM;QACN,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc;KACvC,CAAA;AACH,CAAC"}
@@ -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
@@ -5,6 +5,9 @@
5
5
  "plugins/*/mcp",
6
6
  "services/*"
7
7
  ],
8
+ "dependencies": {
9
+ "pdf-lib": "^1.17.1"
10
+ },
8
11
  "scripts": {
9
12
  "build": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p services/claude-session-manager/tsconfig.json && tsc -p services/whatsapp-channel/tsconfig.json && tsc -p services/webchat-channel/tsconfig.json && tsc -p services/telegram-channel/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
13
  "build:lib": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json",
@@ -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,6 +87,44 @@ 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
+
90
128
  # Allowed-set parse == documented set.
91
129
  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
130
  GOT=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | tr '\n' ' ' | sed 's/ *$//')
@@ -17,6 +17,17 @@
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
+ #
20
31
  # Exit codes: 0 = allow, 2 = feedback to the agent (stderr shown). Fail open
21
32
  # (exit 0) when the snapshot is missing or the allowed set is empty. Every block
22
33
  # logs: [fs-guard-bash] stray path=<name> reason=<top-level|bad-name>
@@ -5,6 +5,10 @@
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 a plugin-declared file (parsed from
9
+ # the fenced ```declared-files block of the same SCHEMA.md) is blocked even
10
+ # though that name is in the allowed set: the allowed set is a layout list,
11
+ # and a declared file is written whole by its owning plugin;
8
12
  # - a target under an operator-data bucket (projects/ contacts/) may be at
9
13
  # most <bucket>/<entity>/<file> deep; documents/ may be at most
10
14
  # <bucket>/<folder>/<file> deep. Tool-owned dirs pass at any depth.
@@ -14,7 +18,7 @@
14
18
  #
15
19
  # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail closed when
16
20
  # 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>
21
+ # [fs-guard] blocked path=<rel> reason=<top-level|declared-file|over-deep|bad-name>
18
22
  # No task numbers / internal refs in any operator-visible string.
19
23
 
20
24
  set -uo pipefail
@@ -84,6 +88,26 @@ if ! printf '%s\n' "$ALLOWED" | grep -qxF "$SEG0"; then
84
88
  exit 2
85
89
  fi
86
90
 
91
+ # Declared-file check. A file declared in the fence is written whole by platform
92
+ # code, so an agent write to it is blocked even though the name is in the allowed
93
+ # set: that set says which names may exist at the account root, not which an
94
+ # agent may author. The owner column names the declaring plugin, which is where
95
+ # the declaration lives, not necessarily the module that does the writing (a
96
+ # shared service may write a file declared on the plugin that no brand excludes),
97
+ # so the message points at the owning code rather than promising a plugin tool.
98
+ # A file the merge marks as agent-written is not in this fence at all. The set
99
+ # comes from the account's own SCHEMA.md, so the hook hard-codes no list. No
100
+ # fence (a brand declaring no owned file, or a schema written before the fence
101
+ # existed) means nothing to deny — the same fail-open posture as the
102
+ # missing-schema case above.
103
+ DECLARED=$(awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
104
+ if [ -n "$DECLARED" ] && printf '%s\n' "$DECLARED" | awk -F'\t' -v n="$SEG0" '$1==n{found=1} END{exit !found}'; then
105
+ OWNER=$(printf '%s\n' "$DECLARED" | awk -F'\t' -v n="$SEG0" '$1==n{print $2; exit}')
106
+ echo "[fs-guard] blocked path=$REL reason=declared-file" >&2
107
+ 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
108
+ exit 2
109
+ fi
110
+
87
111
  # Over-deep check for operator-data buckets. Count path segments.
88
112
  depth=$(printf '%s' "$REL" | awk -F/ '{print NF}')
89
113
  case "$SEG0" in