@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +4 -2
- package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
- package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
- package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
- package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
- package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
- package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
- package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
- package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
- package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
- package/payload/platform/scripts/setup-account.sh +13 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +87 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
- 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
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
124
|
+
case "$DECISION" in
|
|
125
|
+
""|allow) exit 0 ;;
|
|
126
|
+
esac
|
|
55
127
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|