@rubytech/create-maxy-code 0.1.568 → 0.1.570
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 +7 -5
- package/payload/platform/plugins/admin/hooks/__tests__/pdf-text-layer-inject.test.sh +177 -0
- 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/pdf-text-layer-inject.sh +137 -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/a4-print-documents/SKILL.md +12 -1
- package/payload/platform/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs +113 -0
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -0
- package/payload/platform/plugins/docs/references/admin-session.md +15 -0
- package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +19 -43
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
- package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +22 -37
- package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +12 -35
- 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 +11 -53
- 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/blocked-tool-census.d.ts +129 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js +344 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +107 -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 +159 -0
- package/payload/server/server.js +750 -656
- package/payload/platform/scripts/lib/__tests__/account-settings-askgate.test.sh +0 -144
- package/payload/platform/scripts/lib/__tests__/account-settings-mailbox-inject.test.sh +0 -166
- package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +0 -151
- package/payload/platform/scripts/lib/__tests__/account-settings-public-surface.test.sh +0 -109
- package/payload/platform/scripts/lib/account-settings-askgate.sh +0 -98
- package/payload/platform/scripts/lib/account-settings-mailbox-inject.sh +0 -91
- package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +0 -98
- package/payload/platform/scripts/lib/account-settings-public-surface.sh +0 -109
|
@@ -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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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.
|
|
11
|
-
# B.
|
|
12
|
-
# C.
|
|
13
|
-
# D.
|
|
14
|
-
# E.
|
|
15
|
-
# F.
|
|
16
|
-
# G.
|
|
17
|
-
# H.
|
|
18
|
-
# I.
|
|
19
|
-
# J.
|
|
20
|
-
# K.
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 "
|
|
72
|
-
"$(envelope "$BROWSER" "$DOCS/quote-job-
|
|
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
|
-
|
|
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 "
|
|
81
|
-
"$(envelope "$BROWSER" "$ADMIN/quote-job-
|
|
149
|
+
run_case "K: admin-scope internal view → ALLOWED" \
|
|
150
|
+
"$(envelope "$BROWSER" "$ADMIN/quote-job-1-internal.pdf")" 0
|
|
82
151
|
|
|
83
|
-
run_case "
|
|
84
|
-
"$(envelope "$BROWSER" "$ACCT/quote-job-2.pdf")" 0
|
|
152
|
+
run_case "L: empty stdin → ALLOWED (fail-open)" "" 0
|
|
85
153
|
|
|
86
|
-
run_case "
|
|
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 "
|
|
157
|
+
run_case "N: jobId path traversal → BLOCKED" \
|
|
93
158
|
"$(envelope "$BROWSER" "$DOCS/quote-...pdf")" 2
|
|
94
159
|
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
# pdf-text-layer-inject — PostToolUse companion to Read on a .pdf.
|
|
3
|
+
#
|
|
4
|
+
# Read on a PDF returns rasterised page images and no text, so an agent that
|
|
5
|
+
# needs a value out of a PDF reads it off a picture of the page. A job sheet
|
|
6
|
+
# reached a field engineer with a postcode one letter wrong for exactly that
|
|
7
|
+
# reason, while the correct characters sat in the PDF's own text layer. This
|
|
8
|
+
# hook puts that text layer into the agent's context alongside the images, so
|
|
9
|
+
# no value is transcribed by eye when the characters exist.
|
|
10
|
+
#
|
|
11
|
+
# Exit codes: 2 = the extraction is on stderr and the model reads it (the
|
|
12
|
+
# PostToolUse feedback mechanism mcp-tool-missing.sh already relies on);
|
|
13
|
+
# 0 = nothing to inject. Fail OPEN on every uninspectable path — a hook that
|
|
14
|
+
# cannot read a PDF must never disturb the Read that already succeeded.
|
|
15
|
+
#
|
|
16
|
+
# One line per .pdf Read to <logDir>/pdf-text-layer.log, including the reads
|
|
17
|
+
# that inject nothing: a scanned PDF and a hook that never fired are different
|
|
18
|
+
# states and only the log tells them apart.
|
|
19
|
+
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
|
|
22
|
+
if [ -t 0 ]; then exit 0; fi
|
|
23
|
+
INPUT=$(cat)
|
|
24
|
+
[ -z "$INPUT" ] && exit 0
|
|
25
|
+
|
|
26
|
+
# --- log sink ---------------------------------------------------------------
|
|
27
|
+
# A hook's stderr on exit 0 reaches no file on this platform. The ladder mirrors
|
|
28
|
+
# quote-render-pdf-conformance.sh: LOG_DIR is stamped only on the rc-spawn path,
|
|
29
|
+
# CLAUDE_SESSION_MANAGER_PERSIST_DIR is on the systemd unit and resolves to the
|
|
30
|
+
# same directory, ACCOUNT_DIR/logs is the last resort.
|
|
31
|
+
LOGD=""
|
|
32
|
+
if [ -n "${LOG_DIR:-}" ] && [ -d "${LOG_DIR}" ]; then
|
|
33
|
+
LOGD="$LOG_DIR"
|
|
34
|
+
elif [ -n "${CLAUDE_SESSION_MANAGER_PERSIST_DIR:-}" ] && [ -d "${CLAUDE_SESSION_MANAGER_PERSIST_DIR}/logs" ]; then
|
|
35
|
+
LOGD="$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs"
|
|
36
|
+
elif [ -n "${ACCOUNT_DIR:-}" ] && [ -d "${ACCOUNT_DIR}/logs" ]; then
|
|
37
|
+
LOGD="$ACCOUNT_DIR/logs"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
INPUT="$INPUT" LOGD="$LOGD" \
|
|
41
|
+
NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '?')" \
|
|
42
|
+
CAP="${PDF_TEXT_LAYER_CAP:-100000}" \
|
|
43
|
+
python3 - <<'PY'
|
|
44
|
+
import json, os, subprocess, sys
|
|
45
|
+
|
|
46
|
+
MIN_TEXT = 20
|
|
47
|
+
|
|
48
|
+
logd = os.environ.get("LOGD", "")
|
|
49
|
+
now = os.environ.get("NOW_ISO", "?")
|
|
50
|
+
try:
|
|
51
|
+
cap = int(os.environ.get("CAP", "100000"))
|
|
52
|
+
except Exception:
|
|
53
|
+
cap = 100000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# The log directory is install-wide: LOG_DIR resolves to <persistDir>/logs, one
|
|
57
|
+
# file shared by every account on the box. Without an account field a per-account
|
|
58
|
+
# count is not computable, and a per-account count is the signal that says the
|
|
59
|
+
# settings reconcile never reached an account. ACCOUNT_ID is stamped on both the
|
|
60
|
+
# pty-spawner and rc-daemon spawn paths; ACCOUNT_DIR's basename is the fallback.
|
|
61
|
+
account = os.environ.get("ACCOUNT_ID") or ""
|
|
62
|
+
if not account:
|
|
63
|
+
acct_dir = os.environ.get("ACCOUNT_DIR") or ""
|
|
64
|
+
account = os.path.basename(acct_dir.rstrip("/")) if acct_dir else ""
|
|
65
|
+
account = account or "?"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def emit_log(base, chars, injected, reason, sid):
|
|
69
|
+
if not logd:
|
|
70
|
+
return
|
|
71
|
+
line = ("%s [pdf-text-layer] op=pdf-text-layer file=%s chars=%d "
|
|
72
|
+
"injected=%s reason=%s account=%s session=%s"
|
|
73
|
+
% (now, base, chars, injected, reason, account, sid))
|
|
74
|
+
try:
|
|
75
|
+
with open(os.path.join(logd, "pdf-text-layer.log"), "a") as fh:
|
|
76
|
+
fh.write(line + "\n")
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
d = json.loads(os.environ["INPUT"])
|
|
83
|
+
except Exception:
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
|
|
86
|
+
if (d.get("hook_event_name") or "") != "PostToolUse":
|
|
87
|
+
sys.exit(0)
|
|
88
|
+
if (d.get("tool_name") or "") != "Read":
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
path = (d.get("tool_input") or {}).get("file_path") or ""
|
|
91
|
+
if not path.lower().endswith(".pdf"):
|
|
92
|
+
sys.exit(0)
|
|
93
|
+
|
|
94
|
+
sid = d.get("session_id") or "?"
|
|
95
|
+
base = os.path.basename(path)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
proc = subprocess.run(["pdftotext", path, "-"], capture_output=True, timeout=60)
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
emit_log(base, 0, "no", "no-poppler", sid)
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
except Exception:
|
|
103
|
+
emit_log(base, 0, "no", "unreadable", sid)
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
|
|
106
|
+
if proc.returncode != 0:
|
|
107
|
+
emit_log(base, 0, "no", "unreadable", sid)
|
|
108
|
+
sys.exit(0)
|
|
109
|
+
|
|
110
|
+
text = proc.stdout.decode("utf-8", "replace")
|
|
111
|
+
chars = len("".join(text.split()))
|
|
112
|
+
|
|
113
|
+
if chars < MIN_TEXT:
|
|
114
|
+
emit_log(base, chars, "no", "scanned-no-text", sid)
|
|
115
|
+
sys.exit(0)
|
|
116
|
+
|
|
117
|
+
# Log before writing the payload, so a failure mid-write still leaves the record.
|
|
118
|
+
emit_log(base, chars, "yes", "text-layer", sid)
|
|
119
|
+
|
|
120
|
+
payload = text
|
|
121
|
+
omitted = 0
|
|
122
|
+
if len(payload) > cap:
|
|
123
|
+
omitted = len(payload) - cap
|
|
124
|
+
payload = payload[:cap]
|
|
125
|
+
|
|
126
|
+
sys.stderr.write(
|
|
127
|
+
"Text layer of %s, extracted from the PDF itself. Copy values from here, "
|
|
128
|
+
"not from the page images.\n\n%s" % (base, payload))
|
|
129
|
+
if omitted:
|
|
130
|
+
sys.stderr.write(
|
|
131
|
+
"\n\n[%d characters omitted. Run: pdftotext %s -]\n" % (omitted, path))
|
|
132
|
+
sys.stderr.write("\n")
|
|
133
|
+
sys.exit(2)
|
|
134
|
+
PY
|
|
135
|
+
rc=$?
|
|
136
|
+
[ "$rc" -eq 2 ] && exit 2
|
|
137
|
+
exit 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
|