@rubytech/create-maxy-code 0.1.567 → 0.1.569

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 (120) hide show
  1. package/dist/__tests__/brand-agent-root.test.js +91 -0
  2. package/dist/brand-agent-root.js +58 -0
  3. package/dist/index.js +25 -28
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/PLUGIN.md +4 -2
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  8. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  9. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  10. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  11. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  12. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  13. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  14. package/payload/platform/plugins/admin/mcp/dist/index.js +7 -1
  15. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts +10 -1
  17. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  18. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +52 -1
  19. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  20. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +9 -0
  21. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -1
  22. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +1 -1
  23. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -1
  24. package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +1 -1
  25. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +44 -1
  26. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +15 -0
  27. package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +43 -0
  28. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
  29. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  30. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
  31. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  32. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  33. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  34. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -146
  35. package/payload/platform/scripts/setup-account.sh +13 -0
  36. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +39 -0
  37. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -0
  38. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +223 -0
  39. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -0
  40. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +5 -0
  41. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +5 -5
  43. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +11 -1
  45. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/http-server.js +58 -12
  47. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/index.js +112 -13
  49. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  50. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +6 -1
  51. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/pty-census.js +6 -3
  53. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -8
  55. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  56. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +38 -15
  57. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  58. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  59. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  60. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  61. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  62. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +2 -0
  63. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  64. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +4 -1
  65. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  66. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +3 -0
  67. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  68. package/payload/platform/services/claude-session-manager/dist/reaper.js +2 -1
  69. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts +2 -0
  71. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js +5 -2
  73. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +2 -0
  75. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +5 -3
  77. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -1
  78. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  79. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  80. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  81. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  82. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +26 -16
  83. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  84. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +84 -31
  85. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  86. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +7 -0
  87. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +6 -0
  89. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
  90. package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -2
  91. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  92. package/payload/platform/services/telegram-channel/dist/notification.js +15 -3
  93. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  94. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  95. package/payload/platform/services/telegram-channel/dist/server.js +35 -3
  96. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  97. package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts +10 -0
  98. package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts.map +1 -1
  99. package/payload/platform/services/telegram-channel/dist/turn-follow.js +38 -1
  100. package/payload/platform/services/telegram-channel/dist/turn-follow.js.map +1 -1
  101. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  102. package/payload/platform/services/webchat-channel/dist/server.js +31 -2
  103. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  104. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts +10 -0
  105. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
  106. package/payload/platform/services/webchat-channel/dist/turn-follow.js +38 -1
  107. package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
  108. package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts +9 -0
  109. package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts.map +1 -0
  110. package/payload/platform/services/whatsapp-channel/dist/instructions.js +22 -0
  111. package/payload/platform/services/whatsapp-channel/dist/instructions.js.map +1 -0
  112. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +3 -1
  113. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  114. package/payload/platform/services/whatsapp-channel/dist/notification.js +10 -2
  115. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  116. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  117. package/payload/platform/services/whatsapp-channel/dist/server.js +26 -2
  118. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  119. package/payload/platform/templates/account-settings.json +153 -0
  120. package/payload/server/server.js +254 -96
@@ -2,22 +2,44 @@
2
2
  # Regression test for quote-render-gate.sh.
3
3
  #
4
4
  # The gate is a PreToolUse hook on browser-pdf-save. It refuses a client-facing
5
- # quote PDF (memory/users/<phone>/documents/quote-<jobId>.pdf or
6
- # ...-breakdown.pdf) unless a render receipt exists for that jobId proof the
7
- # deterministic quote-render path produced it. Everything else passes.
5
+ # quote PDF unless the deterministic quote-render path produced that exact
6
+ # document proven by comparing the PDF's source HTML against the documents
7
+ # the job's render receipt says were filed.
8
+ #
9
+ # Two facts the earlier version of this gate missed, both from a quote that
10
+ # reached a customer hand-authored:
11
+ #
12
+ # 1. Scope. Client quotes are filed under the jobs bucket
13
+ # (jobs/<jobId>/quote/, jobs/<jobId>/Quotations/), not only under the
14
+ # customer documents scope. Both paths were printed that evening; the
15
+ # unmatched one was the one that was sent.
16
+ # 2. Identity. A receipt proves a render happened for the job, not that this
17
+ # file is its output. The job's figures were rewritten after the render and
18
+ # the delivered document was written later still, so an existence check
19
+ # would have authorised the print.
8
20
  #
9
21
  # Covers:
10
- # A. client quote PDF, no receipt → BLOCKED (exit 2)
11
- # B. client quote PDF, receipt present ALLOWED (exit 0)
12
- # C. client breakdown PDF, no receipt → BLOCKED
13
- # D. client breakdown PDF, receipt present ALLOWED
14
- # E. non-quote PDF in the documents scope → ALLOWED (not a quote artefact)
15
- # F. internal quote PDF (admin scope) → ALLOWED (not client-facing)
16
- # G. quote-named PDF outside the client scope → ALLOWED (only the documents scope is gated)
17
- # H. empty stdin → ALLOWED (general-purpose tool, fail-open)
18
- # I. a non-browser tool → ALLOWED (matcher defence-in-depth)
19
- # J. jobId carrying a path traversal → BLOCKED (never resolves a stray receipt)
20
- # K. a block emits the op=bypass fault line
22
+ # A. jobs-bucket PDF whose source matches filed.quote → ALLOWED
23
+ # B. jobs-bucket PDF, hand-authored source BLOCKED not-receipt-filed
24
+ # C. jobs-bucket PDF with no source HTML beside it → BLOCKED no-source-html
25
+ # D. jobs-bucket PDF whose source matches filed.internal BLOCKED (not client-facing)
26
+ # E. Quotations bucket, source matches filed.quote → ALLOWED
27
+ # F. jobs-bucket PDF whose source matches filed.breakdown → ALLOWED
28
+ # G. the internal pricing bucket → ALLOWED (never gated)
29
+ # H. customer documents scope, source matches filed.quote → ALLOWED
30
+ # I. customer documents scope, no receipt for the job → BLOCKED
31
+ # J. a non-quote PDF in the documents scope → ALLOWED
32
+ # K. the admin-scope internal view → ALLOWED
33
+ # L. empty stdin → ALLOWED (fail-open)
34
+ # M. a non-browser tool → ALLOWED
35
+ # N. jobId carrying a path traversal → BLOCKED bad-jobid
36
+ # N2. a PDF nested one level deeper in the quote bucket → BLOCKED
37
+ # (the write gate and the Bash gate both match the bucket recursively;
38
+ # a print gate that matched only direct children would disagree with
39
+ # them, and one mkdir would walk a hand-authored quote straight out)
40
+ # O. the recorded incident: a hand-authored quote carrying no marker at all,
41
+ # which a marker check would have passed
42
+ # P. a block emits the op=bypass fault line with its reason
21
43
 
22
44
  set -u
23
45
 
@@ -31,17 +53,34 @@ ACCT=$(mktemp -d)
31
53
  cleanup() { rm -rf "$ACCT"; }
32
54
  trap cleanup EXIT
33
55
 
34
- # A receipt for job-1 (present) — jobs job-2 / job-3 have none.
35
- mkdir -p "$ACCT/quoting/jobs/job-1"
36
- printf '{"jobId":"job-1"}' > "$ACCT/quoting/jobs/job-1/render-receipt.json"
37
-
38
56
  DOCS="$ACCT/memory/users/44700900000/documents"
39
57
  ADMIN="$ACCT/memory/admin/customers/44700900000"
58
+ JOB="$ACCT/jobs/job-1"
59
+
60
+ mkdir -p "$DOCS" "$ADMIN" "$JOB/quote" "$JOB/Quotations" "$JOB/pricing" "$ACCT/quoting/jobs/job-1"
61
+
62
+ # The three documents a successful render files, each with distinct content.
63
+ printf '<html data-quote-total="1">rendered client quote</html>' > "$DOCS/quote-job-1.html"
64
+ printf '<html>rendered breakdown</html>' > "$DOCS/quote-job-1-breakdown.html"
65
+ printf '<html data-internal-only="1">rendered internal</html>' > "$ADMIN/quote-job-1-internal.html"
66
+
67
+ cat > "$ACCT/quoting/jobs/job-1/render-receipt.json" <<'RECEIPT'
68
+ {
69
+ "jobId": "job-1",
70
+ "engineTotal": 1000,
71
+ "filed": {
72
+ "internal": "memory/admin/customers/44700900000/quote-job-1-internal.html",
73
+ "breakdown": "memory/users/44700900000/documents/quote-job-1-breakdown.html",
74
+ "quote": "memory/users/44700900000/documents/quote-job-1.html"
75
+ },
76
+ "renderedAt": "2026-08-06T00:00:00.000Z"
77
+ }
78
+ RECEIPT
40
79
 
41
80
  PASS=0
42
81
  FAIL=0
43
82
 
44
- # Run the hook with cwd = the account dir (as the PTY does), capturing exit + stderr.
83
+ # Run the hook with cwd = the account dir (as the PTY does), capturing exit.
45
84
  run_case() {
46
85
  local name="$1" stdin="$2" expected_exit="$3"
47
86
  local actual_exit
@@ -62,46 +101,103 @@ envelope() { # tool_name, path
62
101
 
63
102
  BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
64
103
 
65
- run_case "A: client quote PDF, no receipt BLOCKED" \
66
- "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" 2
67
-
68
- run_case "B: client quote PDF, receipt present → ALLOWED" \
104
+ # A a rendered document copied into the jobs bucket reconciles by content.
105
+ cp "$DOCS/quote-job-1.html" "$JOB/quote/Q 1879 - Client.html"
106
+ run_case "A: jobs bucket, source matches filed.quote → ALLOWED" \
107
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 0
108
+
109
+ # B — the same path, hand-authored.
110
+ printf '<html>hand-authored quote</html>' > "$JOB/quote/Q 1879 - Client.html"
111
+ run_case "B: jobs bucket, hand-authored source → BLOCKED" \
112
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 2
113
+
114
+ # C — no source beside the PDF at all.
115
+ rm -f "$JOB/quote/Q 1879 - Client.html"
116
+ run_case "C: jobs bucket, no source HTML → BLOCKED" \
117
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 2
118
+
119
+ # D — the internal view is filed, but it is not client-facing.
120
+ cp "$ADMIN/quote-job-1-internal.html" "$JOB/quote/Q 1879 - Client.html"
121
+ run_case "D: jobs bucket, source matches filed.internal → BLOCKED" \
122
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 2
123
+
124
+ # E — the Quotations bucket is the same convention.
125
+ cp "$DOCS/quote-job-1.html" "$JOB/Quotations/Q 1879 - Client.html"
126
+ run_case "E: Quotations bucket, source matches filed.quote → ALLOWED" \
127
+ "$(envelope "$BROWSER" "$JOB/Quotations/Q 1879 - Client.pdf")" 0
128
+
129
+ # F — the breakdown is client-facing too.
130
+ cp "$DOCS/quote-job-1-breakdown.html" "$JOB/quote/breakdown.html"
131
+ run_case "F: jobs bucket, source matches filed.breakdown → ALLOWED" \
132
+ "$(envelope "$BROWSER" "$JOB/quote/breakdown.pdf")" 0
133
+
134
+ # G — the internal priced breakdown is never gated.
135
+ printf '<html>internal pricing</html>' > "$JOB/pricing/breakdown.html"
136
+ run_case "G: internal pricing bucket → ALLOWED" \
137
+ "$(envelope "$BROWSER" "$JOB/pricing/breakdown.pdf")" 0
138
+
139
+ run_case "H: documents scope, source matches filed.quote → ALLOWED" \
69
140
  "$(envelope "$BROWSER" "$DOCS/quote-job-1.pdf")" 0
70
141
 
71
- run_case "C: client breakdown PDF, no receipt → BLOCKED" \
72
- "$(envelope "$BROWSER" "$DOCS/quote-job-3-breakdown.pdf")" 2
73
-
74
- run_case "D: client breakdown PDF, receipt present → ALLOWED" \
75
- "$(envelope "$BROWSER" "$DOCS/quote-job-1-breakdown.pdf")" 0
142
+ run_case "I: documents scope, no receipt for the job → BLOCKED" \
143
+ "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" 2
76
144
 
77
- run_case "E: non-quote PDF in documents scope → ALLOWED" \
145
+ printf '<html>brochure</html>' > "$DOCS/brochure.html"
146
+ run_case "J: non-quote PDF in documents scope → ALLOWED" \
78
147
  "$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
79
148
 
80
- run_case "F: internal quote PDF (admin scope) → ALLOWED" \
81
- "$(envelope "$BROWSER" "$ADMIN/quote-job-2-internal.pdf")" 0
149
+ run_case "K: admin-scope internal view → ALLOWED" \
150
+ "$(envelope "$BROWSER" "$ADMIN/quote-job-1-internal.pdf")" 0
82
151
 
83
- run_case "G: quote-named PDF outside client scope → ALLOWED" \
84
- "$(envelope "$BROWSER" "$ACCT/quote-job-2.pdf")" 0
152
+ run_case "L: empty stdin → ALLOWED (fail-open)" "" 0
85
153
 
86
- run_case "H: empty stdin → ALLOWED (fail-open)" \
87
- "" 0
88
-
89
- run_case "I: non-browser tool → ALLOWED" \
154
+ run_case "M: non-browser tool → ALLOWED" \
90
155
  '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/x/memory/users/44700900000/documents/quote-job-2.pdf"}}' 0
91
156
 
92
- run_case "J: jobId path traversal → BLOCKED" \
157
+ run_case "N: jobId path traversal → BLOCKED" \
93
158
  "$(envelope "$BROWSER" "$DOCS/quote-...pdf")" 2
94
159
 
95
- # K: a block emits the op=bypass fault line on stderr.
96
- STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
97
- if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=job-2'; then
98
- echo "PASS: K: block emits op=bypass jobId=job-2"
99
- PASS=$((PASS + 1))
100
- else
101
- echo "FAIL: K: op=bypass line absent (got: $STDERR)" >&2
160
+ # N2 nesting must not escape the bucket.
161
+ mkdir -p "$JOB/quote/final"
162
+ printf '<html>hand-authored</html>' > "$JOB/quote/final/Q 1879 - Client.html"
163
+ run_case "N2: a PDF nested deeper in the quote bucket → BLOCKED" \
164
+ "$(envelope "$BROWSER" "$JOB/quote/final/Q 1879 - Client.pdf")" 2
165
+
166
+ cp "$DOCS/quote-job-1.html" "$JOB/quote/final/Q 1879 - Client.html"
167
+ run_case "N2: a nested rendered copy still reconciles → ALLOWED" \
168
+ "$(envelope "$BROWSER" "$JOB/quote/final/Q 1879 - Client.pdf")" 0
169
+
170
+ # O — the recorded incident. The delivered document carried zero
171
+ # data-quote-total, so a marker check would have passed it; only a content
172
+ # comparison against the receipt-filed documents rejects it.
173
+ printf '<html>Q 1879 Woolgrove hand-authored, no markers</html>' > "$JOB/quote/Woolgrove.html"
174
+ run_case "O: recorded incident, hand-authored and markerless → BLOCKED" \
175
+ "$(envelope "$BROWSER" "$JOB/quote/Woolgrove.pdf")" 2
176
+ if grep -q 'data-quote-total' "$JOB/quote/Woolgrove.html"; then
177
+ echo "FAIL: O: the incident fixture must carry no marker" >&2
102
178
  FAIL=$((FAIL + 1))
179
+ else
180
+ echo "PASS: O: the incident fixture carries no marker, so a marker check would pass it"
181
+ PASS=$((PASS + 1))
103
182
  fi
104
183
 
184
+ # P — a block names its reason on stderr.
185
+ check_reason() { # name, path, expected reason
186
+ local out
187
+ out=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$2")" | bash "$HOOK" 2>&1 >/dev/null )
188
+ if printf '%s' "$out" | grep -q "\[quote-render\] op=bypass jobId=.* reason=$3"; then
189
+ echo "PASS: P: $1 emits reason=$3"
190
+ PASS=$((PASS + 1))
191
+ else
192
+ echo "FAIL: P: $1 did not emit reason=$3 (got: $out)" >&2
193
+ FAIL=$((FAIL + 1))
194
+ fi
195
+ }
196
+ check_reason "hand-authored source" "$JOB/quote/Woolgrove.pdf" "not-receipt-filed"
197
+ rm -f "$JOB/quote/Woolgrove.html"
198
+ check_reason "missing source" "$JOB/quote/Woolgrove.pdf" "no-source-html"
199
+ check_reason "path traversal" "$DOCS/quote-...pdf" "bad-jobid"
200
+
105
201
  echo "──────── quote-render-gate test summary ────────"
106
202
  echo "PASS=$PASS FAIL=$FAIL"
107
203
  [[ "$FAIL" -eq 0 ]]
@@ -17,6 +17,18 @@
17
17
  # G. empty stdin → ALLOWED (fail-open)
18
18
  # H. non-browser tool → ALLOWED (matcher defence-in-depth)
19
19
  # I. a block emits the op=bypass fault line
20
+ #
21
+ # Per-physical-page running-header conformance (receipt-free, so it also covers
22
+ # template-seeded documents that never pass through the quote renderer):
23
+ # J. header text on every checked physical page → ALLOWED
24
+ # K. one physical page without it (a bleed page) → BLOCKED
25
+ # L. source HTML declares no running header → ALLOWED (never gated)
26
+ # M. no receipt and no sibling HTML → ALLOWED (fail-open)
27
+ # N. single-page document under @page :first → ALLOWED (nothing to check)
28
+ # O. a clean run logs pagesChecked / pagesHeaderless
29
+ # P. a block names the headerless page numbers
30
+ # Q. a running footer repeating the ref does NOT count as a header
31
+ # R. @page :first declared before @page still yields the marker
20
32
 
21
33
  set -u
22
34
 
@@ -47,6 +59,42 @@ mk_job() { # jobId, html_body, pdf_uri_repeat
47
59
  > "$DOCS/quote-$job.pdf"
48
60
  }
49
61
 
62
+ # A minimal, uncompressed, multi-page PDF. Each page argument is
63
+ # "<top line>|<bottom line>"; the bottom line is optional and stands in for a
64
+ # running footer. The /URI stubs above are not readable by pdftotext, and the
65
+ # header check needs real per-page text at real positions, so the header
66
+ # fixtures need a genuine PDF. Pure stdlib — no third-party generator, no
67
+ # network, deterministic byte-for-byte.
68
+ mk_pdf() { # out_path, then one "top|bottom" argument per page
69
+ local out="$1"; shift
70
+ python3 - "$out" "$@" <<'PY'
71
+ import sys
72
+ out, texts = sys.argv[1], sys.argv[2:]
73
+ objs = []
74
+ kids = " ".join("%d 0 R" % (4 + 2 * i) for i in range(len(texts)))
75
+ objs.append("<< /Type /Catalog /Pages 2 0 R >>")
76
+ objs.append("<< /Type /Pages /Kids [%s] /Count %d >>" % (kids, len(texts)))
77
+ objs.append("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
78
+ for i, t in enumerate(texts):
79
+ top, _, bottom = t.partition("|")
80
+ body = "BT /F1 11 Tf 72 760 Td (%s) Tj ET" % top
81
+ if bottom:
82
+ body += " BT /F1 8 Tf 72 40 Td (%s) Tj ET" % bottom
83
+ objs.append("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
84
+ "/Resources << /Font << /F1 3 0 R >> >> /Contents %d 0 R >>" % (5 + 2 * i))
85
+ objs.append("<< /Length %d >>\nstream\n%s\nendstream" % (len(body), body))
86
+ pdf, offsets = "%PDF-1.4\n", []
87
+ for i, o in enumerate(objs, start=1):
88
+ offsets.append(len(pdf))
89
+ pdf += "%d 0 obj\n%s\nendobj\n" % (i, o)
90
+ start = len(pdf)
91
+ pdf += "xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
92
+ pdf += "".join("%010d 00000 n \n" % off for off in offsets)
93
+ pdf += "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, start)
94
+ open(out, "wb").write(pdf.encode("latin-1"))
95
+ PY
96
+ }
97
+
50
98
  # C: no absolute links, PDF has none → allow.
51
99
  mk_job "clean" '<html><body><a href="#top">top</a><p>no links</p></body></html>' 0
52
100
  # D: two https links, PDF dropped them (0 /URI) → block on count.
@@ -94,6 +142,72 @@ else
94
142
  echo "FAIL: I: op=bypass line absent (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
95
143
  fi
96
144
 
145
+ # ---- per-physical-page running-header conformance ----
146
+ # A source document that declares its running header in @page top margin boxes,
147
+ # with @page :first blanking them so page 1 keeps its own masthead.
148
+ HDR_CSS='<style>@page{@top-left{content:url("data:image/png;base64,AA");}@top-center{content:"Quote 2468/1";}}@page :first{@top-center{content:"";}}</style>'
149
+ NOHDR_CSS='<style>@page{@bottom-center{content:"footer only";}}</style>'
150
+
151
+ mk_doc() { # basename, css, then one text line per physical page
152
+ local base="$1" css="$2"; shift 2
153
+ printf '<html><head>%s</head><body>doc</body></html>' "$css" > "$DOCS/$base.html"
154
+ mk_pdf "$DOCS/$base.pdf" "$@"
155
+ }
156
+
157
+ mk_doc "hdr-ok" "$HDR_CSS" "cover masthead" "Quote 2468/1 body two" "Quote 2468/1 body three"
158
+ mk_doc "hdr-bleed" "$HDR_CSS" "cover masthead" "Quote 2468/1 body two" "orphaned bleed content"
159
+ mk_doc "hdr-none" "$NOHDR_CSS" "page one" "page two"
160
+ mk_doc "hdr-single" "$HDR_CSS" "cover only"
161
+ mk_pdf "$DOCS/hdr-orphan.pdf" "page one" "page two"
162
+
163
+ # R: the same document with @page :first declared BEFORE @page. CSS does not fix
164
+ # the order of the two rules, and the suppression rule declares the same boxes
165
+ # with empty content, so reading only the first declaration of a box would report
166
+ # "no running header" and skip the check entirely.
167
+ FIRST_LAST_CSS='<style>@page :first{@top-center{content:"";}}@page{@top-left{content:url("data:image/png;base64,AA");}@top-center{content:"Quote 2468/1";}}</style>'
168
+ mk_doc "hdr-first-declared-first" "$FIRST_LAST_CSS" \
169
+ "cover masthead" "Quote 2468/1 body two" "orphaned bleed content"
170
+
171
+ # Q: the footer repeats the header's reference on every page, and page 3 has no
172
+ # header. A whole-page text match reports this document clean — measured on a
173
+ # real 10-page print, where page 1 matched on its footer alone. The check must
174
+ # look at the top band of the page, so this document is still blocked.
175
+ mk_doc "hdr-footer-decoy" "$HDR_CSS" \
176
+ "cover masthead|GLS Ltd . Est 1987 . Quote 2468/1" \
177
+ "Quote 2468/1 body two|GLS Ltd . Est 1987 . Quote 2468/1" \
178
+ "orphaned bleed content|GLS Ltd . Est 1987 . Quote 2468/1"
179
+
180
+ run_case "J: header on every checked physical page → ALLOWED" \
181
+ "$(envelope "$BROWSER" "$DOCS/hdr-ok.pdf")" 0
182
+ run_case "K: a physical page with no header → BLOCKED" \
183
+ "$(envelope "$BROWSER" "$DOCS/hdr-bleed.pdf")" 2
184
+ run_case "L: document declares no running header → ALLOWED" \
185
+ "$(envelope "$BROWSER" "$DOCS/hdr-none.pdf")" 0
186
+ run_case "M: no receipt and no sibling HTML → ALLOWED" \
187
+ "$(envelope "$BROWSER" "$DOCS/hdr-orphan.pdf")" 0
188
+ run_case "N: single-page document → ALLOWED" \
189
+ "$(envelope "$BROWSER" "$DOCS/hdr-single.pdf")" 0
190
+ run_case "Q: footer repeats the ref, page 3 headerless → BLOCKED" \
191
+ "$(envelope "$BROWSER" "$DOCS/hdr-footer-decoy.pdf")" 2
192
+ run_case "R: @page :first declared first, page 3 headerless → BLOCKED" \
193
+ "$(envelope "$BROWSER" "$DOCS/hdr-first-declared-first.pdf")" 2
194
+
195
+ # O: a clean run is distinguishable from a check that never ran.
196
+ STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/hdr-ok.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
197
+ if printf '%s' "$STDERR" | grep -q 'op=pdf-header-conformance .*pagesChecked=2 pagesHeaderless=0'; then
198
+ echo "PASS: O: clean run logs pagesChecked=2 pagesHeaderless=0"; PASS=$((PASS + 1))
199
+ else
200
+ echo "FAIL: O: conformance line absent or wrong (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
201
+ fi
202
+
203
+ # P: a block names the offending physical page.
204
+ STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/hdr-bleed.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
205
+ if printf '%s' "$STDERR" | grep -q 'reason=pdf-header-conformance detail=pagesHeaderless=1 pages=3'; then
206
+ echo "PASS: P: block names the headerless page"; PASS=$((PASS + 1))
207
+ else
208
+ echo "FAIL: P: block detail wrong (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
209
+ fi
210
+
97
211
  echo "──────── quote-render-pdf-conformance test summary ────────"
98
212
  echo "PASS=$PASS FAIL=$FAIL"
99
213
  [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # quote-path-write-gate — PreToolUse gate on Write / Edit / MultiEdit.
3
+ #
4
+ # The client quote document has one owner, and that ownership is a capability:
5
+ # the quote-render tool is granted to the quoter's card and to no other, so no
6
+ # other agent can produce a receipt-bearing quote by any route. This gate
7
+ # expresses the same rule at the write, refusing an agent that does not hold
8
+ # quote-render from authoring a quote artefact or the account's quote
9
+ # templates. It leaves untouched every other deliverable that agent owns —
10
+ # works orders, heritage statements, survey sheets, drawing registers,
11
+ # trial-pit records, invoice stamps.
12
+ #
13
+ # Scope: jobs/<jobId>/quote/, jobs/<jobId>/Quotations/, quoting/templates/.
14
+ # jobs/<jobId>/pricing/ is excluded — the internal priced breakdown is not a
15
+ # client quote artefact.
16
+ #
17
+ # Caller identity is `agent_type` from the hook envelope, which Claude Code
18
+ # sets when a hook fires inside a subagent. The MAXY_SPECIALIST env is not
19
+ # usable here: it is stamped only when a specialist is spawned as its own PTY
20
+ # process, and the dispatches this gate exists to stop were native Agent-tool
21
+ # subagents that inherit the admin process env, so it reads empty for them.
22
+ #
23
+ # Card lookup spans BOTH registration families. Bundled specialists reach a
24
+ # session through the per-account registry; premium-bundle agents register
25
+ # through plugin manifests and are never written to that registry, so a lookup
26
+ # that skipped the premium root would block the quoter itself. Cards are
27
+ # matched on frontmatter `name:` as well as filename, because the quoter's card
28
+ # is filed as sitedesk--quoter.md and declares `name: quoter`.
29
+ #
30
+ # A write from the main conversation carries no agent_type and the admin seat
31
+ # holds no quote-render, so it is refused: no part of a client quote document
32
+ # is another agent's work, the admin's included.
33
+ #
34
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
35
+ # uninspectable envelope. No task numbers or internal refs in operator-visible
36
+ # text.
37
+
38
+ set -uo pipefail
39
+
40
+ # No envelope (tty or empty stdin) → allow: cannot inspect, must not brick writes.
41
+ if [ -t 0 ]; then exit 0; fi
42
+ INPUT=$(cat)
43
+ [ -z "$INPUT" ] && exit 0
44
+
45
+ DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
46
+ import os, json, re, sys, glob
47
+
48
+ try:
49
+ d = json.loads(os.environ["INPUT"])
50
+ except Exception:
51
+ sys.exit(0)
52
+ if (d.get("tool_name", "") or "") not in ("Write", "Edit", "MultiEdit"):
53
+ sys.exit(0)
54
+ ti = d.get("tool_input", {}) or {}
55
+ target = ti.get("file_path") or ti.get("path") or ""
56
+ if not target:
57
+ sys.exit(0)
58
+ norm = os.path.normpath(target)
59
+
60
+ in_scope = bool(
61
+ re.search(r'(?:^|/)jobs/[^/]+/(?:quote|Quotations)/', norm)
62
+ or re.search(r'(?:^|/)quoting/templates/', norm)
63
+ )
64
+ if not in_scope:
65
+ sys.exit(0)
66
+
67
+ agent = (d.get("agent_type") or "").strip()
68
+ if not agent:
69
+ print("quote-path-write-without-render-tool")
70
+ sys.exit(0)
71
+ name = agent.split(":")[-1]
72
+
73
+ roots = [
74
+ os.path.join(os.getcwd(), ".claude", "agents"),
75
+ os.path.join(os.getcwd(), "specialists", "agents"),
76
+ ]
77
+ ccd = os.environ.get("CLAUDE_CONFIG_DIR", "")
78
+ if ccd:
79
+ roots.append(os.path.join(ccd, "agents"))
80
+ pr = os.environ.get("PLATFORM_ROOT", "")
81
+ if pr:
82
+ roots.extend(sorted(glob.glob(os.path.join(os.path.dirname(pr), "premium-plugins", "*", "agents"))))
83
+ roots.extend(sorted(glob.glob(os.path.join(pr, "plugins", "*", "agents"))))
84
+
85
+
86
+ def card_fields(path):
87
+ """(name, tools) from a card frontmatter block, or None when there is none."""
88
+ try:
89
+ with open(path, encoding="utf-8") as fh:
90
+ lines = fh.read().split("\n")
91
+ except Exception:
92
+ return None
93
+ if not lines or lines[0].strip() != "---":
94
+ return None
95
+ cname = ctools = None
96
+ for line in lines[1:]:
97
+ if line.strip() == "---":
98
+ break
99
+ m = re.match(r'^name:\s*(.+?)\s*$', line)
100
+ if m:
101
+ cname = m.group(1).strip().strip("\x22").strip("\x27")
102
+ m = re.match(r'^tools:\s*(.+?)\s*$', line)
103
+ if m:
104
+ ctools = m.group(1)
105
+ return (cname, ctools)
106
+
107
+
108
+ tools = None
109
+ for root in roots:
110
+ if not os.path.isdir(root):
111
+ continue
112
+ for f in sorted(glob.glob(os.path.join(root, "*.md"))):
113
+ fields = card_fields(f)
114
+ if not fields:
115
+ continue
116
+ cname, ctools = fields
117
+ if cname == name or os.path.basename(f)[:-3] == name:
118
+ tools = ctools or ""
119
+ break
120
+ if tools is not None:
121
+ break
122
+
123
+ if tools is None:
124
+ print("quote-path-write-unresolved-agent")
125
+ sys.exit(0)
126
+ # Match the tool itself, not a substring of a longer name.
127
+ if any(t.strip().endswith("quote-render") for t in tools.split(",")):
128
+ sys.exit(0)
129
+ print("quote-path-write-without-render-tool")
130
+ PY
131
+ )
132
+
133
+ [ -z "$DECISION" ] && exit 0
134
+
135
+ echo "[quote-render] op=bypass reason=$DECISION" >&2
136
+ echo "Blocked: quote documents and quote templates are the quoter's work. Dispatch the quoter for this change; it produces the document through the quote-render tool, which files it with its render receipt." >&2
137
+ exit 2
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # quote-pdf-bash-gate — PreToolUse gate on Bash.
3
+ #
4
+ # quote-render-gate refuses a hand-authored client quote at browser-pdf-save.
5
+ # An agent holding Bash reaches the same outcome with any HTML-to-PDF converter
6
+ # and meets no control at all — the specialist that produced the 2026-08-05
7
+ # quote holds Bash, Write, Edit and browser-pdf-save, and quote-engine-gate
8
+ # refuses only execution of the two persisted quoting scripts.
9
+ #
10
+ # This gate refuses a Bash command that produces a PDF from an HTML under a
11
+ # gated quote path. It matches structurally on the paths in the command, never
12
+ # on the converter's name, so the rule is binary-independent: wkhtmltopdf,
13
+ # chromium --print-to-pdf, weasyprint and anything else are all covered by the
14
+ # same two conditions.
15
+ #
16
+ # Scope mirrors quote-render-gate: the customer documents scope and a job's
17
+ # quote / Quotations buckets. jobs/<jobId>/pricing/ is excluded — it holds the
18
+ # internal priced breakdown.
19
+ #
20
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
21
+ # uninspectable call (Bash is a general-purpose tool and the agent never
22
+ # controls this hook's stdin, so failing open opens no bypass while never
23
+ # bricking unrelated commands). No task numbers or internal refs in
24
+ # operator-visible text.
25
+
26
+ set -uo pipefail
27
+
28
+ # No envelope (tty or empty stdin) → allow: cannot inspect, must not brick Bash.
29
+ if [ -t 0 ]; then exit 0; fi
30
+ INPUT=$(cat)
31
+ [ -z "$INPUT" ] && exit 0
32
+
33
+ DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
34
+ import os, json, re, sys
35
+
36
+ try:
37
+ d = json.loads(os.environ["INPUT"])
38
+ except Exception:
39
+ sys.exit(0)
40
+ if (d.get("tool_name", "") or "") != "Bash":
41
+ sys.exit(0)
42
+ cmd = (d.get("tool_input", {}) or {}).get("command", "") or ""
43
+ if not cmd:
44
+ sys.exit(0)
45
+
46
+ # A gated source: an .html under the customer documents scope or a job quote
47
+ # bucket. The filename class admits spaces (the filed quotes carry them) but
48
+ # stops at a quote character or a shell separator, so one command path cannot
49
+ # swallow the next. Quote characters are written \x22 / \x27 because this block
50
+ # sits inside a bash command substitution, which parses quotes even in a
51
+ # quoted heredoc.
52
+ GATED_HTML = re.compile(
53
+ r'(?:memory/users/[^/\x22\x27\s]+/documents/[^\x22\x27;|&]*\.html'
54
+ r'|jobs/[^/\x22\x27\s]+/(?:quote|Quotations)/[^\x22\x27;|&]*\.html)'
55
+ )
56
+ if not GATED_HTML.search(cmd):
57
+ sys.exit(0)
58
+ # Naming a PDF anywhere in the same command is what turns a read into a
59
+ # production. `cat q.html` names none and passes.
60
+ if not re.search(r'\.pdf\b', cmd):
61
+ sys.exit(0)
62
+ print("block")
63
+ PY
64
+ )
65
+
66
+ [ "$DECISION" = "block" ] || exit 0
67
+
68
+ echo "[quote-render] op=bypass reason=bash-pdf-production" >&2
69
+ echo "Blocked: a client quote PDF must come from the quote-render tool, not from a converter run by hand. Run quote-render for this job — it files the document and its render receipt — then print the client HTML path it returns." >&2
70
+ exit 2