@rubytech/create-maxy-code 0.1.568 → 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 (37) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +4 -2
  3. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  7. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  8. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  9. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  10. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  11. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
  13. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  14. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  15. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
  16. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  17. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
  18. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  19. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  20. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  21. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
  22. package/payload/platform/scripts/setup-account.sh +13 -0
  23. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
  24. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
  26. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/index.js +87 -0
  28. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  30. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  31. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  32. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  33. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  34. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  36. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  37. package/payload/platform/templates/account-settings.json +153 -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
@@ -1,18 +1,30 @@
1
1
  #!/usr/bin/env bash
2
2
  # quote-render-gate — PreToolUse gate on browser-pdf-save.
3
3
  #
4
- # A customer only ever receives the two client-facing quote documents from the
5
- # customer documents scope (memory/users/<phone>/documents/). This gate refuses
6
- # to print one to PDF unless the deterministic quote-render tool actually
7
- # produced it proven by the render receipt quote-render writes on success
8
- # (quoting/jobs/<jobId>/render-receipt.json). No receipt for the job means the
9
- # quote was hand-authored off the deterministic path, so the fixed-layout
10
- # guarantee and the reconcile/margin/link gates never ran; the PDF is refused and
11
- # the bypass is surfaced as [quote-render] op=bypass, not delivered.
4
+ # A client-facing quote reaches a customer only when the deterministic
5
+ # quote-render tool produced that exact document. The tool writes a render
6
+ # receipt on success (quoting/jobs/<jobId>/render-receipt.json) naming the
7
+ # documents it filed; this gate resolves the PDF's source HTML and requires its
8
+ # content to match one of the client-facing documents that receipt names.
12
9
  #
13
- # Scope: only a .pdf under memory/users/<phone>/documents/ named
14
- # quote-<jobId>.pdf or quote-<jobId>-breakdown.pdf is gated. Every other PDF —
15
- # brochures, invoices, the admin-scope internal view — passes untouched.
10
+ # Scope three shapes, all resolved against the account dir:
11
+ # memory/users/<phone>/documents/quote-<jobId>[-breakdown].pdf
12
+ # jobs/<jobId>/quote/<anything>.pdf
13
+ # jobs/<jobId>/Quotations/<anything>.pdf
14
+ # jobs/<jobId>/pricing/ is deliberately absent: it holds the internal priced
15
+ # breakdown, always excluded with the admin-scope internal view. Every other PDF
16
+ # — brochures, works orders, invoices — passes untouched.
17
+ #
18
+ # Why identity rather than existence. A receipt proves a render happened for the
19
+ # job, not that this file is its output. On the 2026-08-05 bypass the job's
20
+ # figures were rewritten after the render and the delivered document was written
21
+ # later still, so an existence check would have authorised the print. Comparing
22
+ # content, not paths, keeps an operator copy of a rendered document passing while
23
+ # hand-authored content fails. filed.internal is not client-facing and is never a
24
+ # permitted match.
25
+ #
26
+ # A missing source HTML blocks: the pipeline files the PDF beside the HTML it
27
+ # printed from, so no local source means no proof of renderer origin.
16
28
  #
17
29
  # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
18
30
  # uninspectable call (browser-pdf-save is a general-purpose tool; the bypass we
@@ -27,48 +39,104 @@ if [ -t 0 ]; then exit 0; fi
27
39
  INPUT=$(cat)
28
40
  [ -z "$INPUT" ] && exit 0
29
41
 
30
- # Print the jobId of a gated client-quote artefact, or nothing for any other
31
- # call. The customer documents scope is a path convention, so match on it
32
- # structurally rather than resolving symlinks.
33
- JOBID=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
34
- import os, json, re, sys
42
+ # Prints nothing for an out-of-scope call, "allow" for a reconciling document,
43
+ # or "block <reason> <jobId>".
44
+ DECISION=$(INPUT="$INPUT" ACCT="$PWD" python3 - <<'PY' 2>/dev/null || true
45
+ import os, json, re, sys, hashlib
46
+
35
47
  try:
36
48
  d = json.loads(os.environ["INPUT"])
37
49
  except Exception:
38
50
  sys.exit(0)
39
- tool = d.get("tool_name", "") or ""
40
- if not tool.endswith("browser-pdf-save"):
51
+ if not (d.get("tool_name", "") or "").endswith("browser-pdf-save"):
41
52
  sys.exit(0)
42
53
  path = (d.get("tool_input", {}) or {}).get("path", "") or ""
43
54
  if not path:
44
55
  sys.exit(0)
45
56
  norm = os.path.normpath(path)
57
+
58
+ # The customer documents scope and the jobs bucket are path conventions, so
59
+ # match on them structurally rather than resolving symlinks.
60
+ jobid = None
46
61
  m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(?:-breakdown)?\.pdf$', norm)
47
- if not m:
62
+ if m:
63
+ jobid = m.group(1)
64
+ else:
65
+ # `.+` rather than `[^/]+`: the bucket is matched recursively, so one mkdir
66
+ # cannot walk a hand-authored quote out of scope. The write gate and the
67
+ # Bash gate match the same buckets recursively; all three must agree.
68
+ m = re.search(r'(?:^|/)jobs/([^/]+)/(?:quote|Quotations)/.+\.pdf$', norm)
69
+ if m:
70
+ jobid = m.group(1)
71
+ if jobid is None:
72
+ sys.exit(0)
73
+
74
+ # A jobId is a single path segment. A separator or traversal in it never keys a
75
+ # legitimate receipt, so treat it as a bypass rather than resolving a stray file.
76
+ if "/" in jobid or ".." in jobid:
77
+ print("block bad-jobid " + jobid)
48
78
  sys.exit(0)
49
- print(m.group(1))
79
+
80
+ base = os.environ.get("ACCT") or os.getcwd()
81
+ abs_pdf = norm if os.path.isabs(norm) else os.path.join(base, norm)
82
+ sibling = abs_pdf[:-4] + ".html"
83
+ if not os.path.isfile(sibling):
84
+ print("block no-source-html " + jobid)
85
+ sys.exit(0)
86
+
87
+
88
+ def sha256(p):
89
+ h = hashlib.sha256()
90
+ with open(p, "rb") as fh:
91
+ for chunk in iter(lambda: fh.read(1 << 16), b""):
92
+ h.update(chunk)
93
+ return h.hexdigest()
94
+
95
+
96
+ try:
97
+ with open(os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json")) as fh:
98
+ filed = (json.load(fh) or {}).get("filed") or {}
99
+ except Exception:
100
+ print("block not-receipt-filed " + jobid)
101
+ sys.exit(0)
102
+
103
+ try:
104
+ want = sha256(sibling)
105
+ except Exception:
106
+ print("block no-source-html " + jobid)
107
+ sys.exit(0)
108
+
109
+ for key in ("quote", "breakdown"):
110
+ cand = filed.get(key)
111
+ if not isinstance(cand, str) or not cand:
112
+ continue
113
+ cpath = cand if os.path.isabs(cand) else os.path.join(base, cand)
114
+ try:
115
+ if sha256(cpath) == want:
116
+ print("allow")
117
+ sys.exit(0)
118
+ except Exception:
119
+ continue
120
+ print("block not-receipt-filed " + jobid)
50
121
  PY
51
122
  )
52
123
 
53
- # Not a gated client-quote artefact → allow.
54
- [ -z "$JOBID" ] && exit 0
124
+ case "$DECISION" in
125
+ ""|allow) exit 0 ;;
126
+ esac
55
127
 
56
- # A jobId is a single path segment. A separator or traversal in it never keys a
57
- # legitimate receipt, so treat it as a bypass rather than resolving a stray file.
58
- case "$JOBID" in
59
- */*|*..*)
60
- echo "[quote-render] op=bypass jobId=$JOBID reason=bad-jobid" >&2
128
+ REASON=$(printf '%s' "$DECISION" | cut -d' ' -f2)
129
+ JOBID=$(printf '%s' "$DECISION" | cut -d' ' -f3-)
130
+ echo "[quote-render] op=bypass jobId=$JOBID reason=$REASON" >&2
131
+ case "$REASON" in
132
+ no-source-html)
133
+ echo "Blocked: this client quote PDF has no source document beside it, so there is nothing to check it against. Produce the quote with the quote-render tool, which files the document and its render receipt, then print the path it returns." >&2
134
+ ;;
135
+ bad-jobid)
61
136
  echo "Blocked: this looks like a client quote PDF but its name does not resolve to a job. Produce the quote with the quote-render tool, which files the document and its render receipt, then print the path it returns." >&2
62
- exit 2
137
+ ;;
138
+ *)
139
+ echo "Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF." >&2
63
140
  ;;
64
141
  esac
65
-
66
- # The receipt lives beside the job's figures, written by quote-render only after
67
- # every gate passed. Its presence is the proof the deterministic path ran.
68
- if [ -f "$PWD/quoting/jobs/$JOBID/render-receipt.json" ]; then
69
- exit 0
70
- fi
71
-
72
- echo "[quote-render] op=bypass jobId=$JOBID detail=client quote PDF has no render receipt" >&2
73
- echo "Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF." >&2
74
142
  exit 2