@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
package/package.json
CHANGED
|
@@ -268,10 +268,12 @@ Tools are available via the `admin` MCP server.
|
|
|
268
268
|
|
|
269
269
|
## Hooks
|
|
270
270
|
|
|
271
|
-
- `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer.
|
|
271
|
+
- `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. **Scope** is three path shapes, all resolved against the account dir: `memory/users/<phone>/documents/quote-<jobId>[-breakdown].pdf`, `jobs/<jobId>/quote/<anything>.pdf`, and `jobs/<jobId>/Quotations/<anything>.pdf`. `jobs/<jobId>/pricing/` is excluded — it holds the internal priced breakdown, excluded with the admin-scope internal view. **Decision** is receipt *identity*, not receipt existence: the gate resolves the PDF's sibling HTML (`.pdf`→`.html`), sha256s it, and requires a match against the sha256 of a file named by `filed.quote` or `filed.breakdown` in `quoting/jobs/<jobId>/render-receipt.json`. `filed.internal` is not client-facing and never matches. Comparing content rather than paths keeps an operator copy of a rendered document passing while hand-authored content fails. A missing sibling HTML blocks: the pipeline files the PDF beside the HTML it printed from, so no local source is no proof of renderer origin. Existence alone was not enough — on the 2026-08-05 bypass the job's figures were rewritten after the render and the delivered document was written later still, so a stale receipt would have authorised the print. **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=<not-receipt-filed|no-source-html|bad-jobid>`. Every non-quote PDF — brochures, works orders, invoices, the admin-scope internal view — passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
|
|
272
272
|
- `hooks/quote-engine-gate.sh` — **PreToolUse matcher=`Bash`** (a third command on the same `Bash` matcher as `archive-ingest-surface-gate` and `fs-schema-guard-bash-pre`, any exit 2 blocks). Makes the deterministic `quote-engine-run` / `quote-render` tools the only route the account's persisted quoting scripts can run. Blocks (exit 2) a `Bash` `command` that executes `quoting/engine.mjs` or `quoting/render.mjs` directly — the pricing and render scripts must run through their MCP tools, which apply the reconcile / margin-leak / link-liveness gates and write the figures and render receipts. A hand-run `node quoting/engine.mjs` (the SiteDesk 2026-07-25 bypass: a repricing dispatched to content-producer that ran the engine by hand and re-rendered off the deterministic path) skips all of that, so it is refused. It fires only on an execution (a JS runtime invoking the script, or the script run at command position); a read or inspect of the file (`cat`/`grep`/`git diff quoting/engine.mjs`) and every unrelated `Bash` command pass at exit 0. A `mode=verify` reproduction is gated too — it must also run through `quote-engine-run`, so a hand-run `node quoting/engine.mjs mode=verify` is refused. Known fail-open gap (accepted, consistent with the soft posture): a `cd quoting && node engine.mjs` drops the `quoting/` prefix and is not matched. **Block message:** `Blocked: the quoting engine and render scripts must run through their tools, not directly. Use the quote-engine-run tool to price a job (it files the figures) and the quote-render tool to produce the documents (it files them behind the reconcile, margin-leak and link-liveness gates). Running quoting/engine.mjs or quoting/render.mjs by hand skips those gates and files no receipt.` **Log line** (stderr, on a block): `[quote-engine] op=bypass reason=direct-script-exec`. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `Bash` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated commands.
|
|
273
|
+
- `hooks/quote-pdf-bash-gate.sh` — **PreToolUse matcher=`Bash`.** Closes the Bash route to a client quote PDF. `quote-render-gate` refuses a hand-authored quote at `browser-pdf-save`, but an agent holding `Bash` reaches the same outcome with any HTML-to-PDF converter and meets no control at all (`content-producer` holds `Bash`, `Write`, `Edit` and `browser-pdf-save`; `quote-engine-gate` refuses only execution of the two persisted quoting scripts). Blocks (exit 2) a command that names both an `.html` under a gated quote path — `memory/users/<phone>/documents/`, `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` — and a `.pdf`. The match is structural on the paths, never on the converter's name, so `wkhtmltopdf`, `chromium --print-to-pdf` and `weasyprint` are covered by the same rule. A read that names no PDF (`cat q.html`) passes, and `jobs/<jobId>/pricing/` is never gated. **Block message:** `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.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=bash-pdf-production`. **Fail-open** on an uninspectable call.
|
|
274
|
+
- `hooks/quote-path-write-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit` / `NotebookEdit`.** The roster safeguard expressed as a capability rather than a removal. Blocks (exit 2) a write whose target is under `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` or `quoting/templates/` when the calling agent's card does not grant `quote-render`. `jobs/<jobId>/pricing/` is excluded — the internal priced breakdown is not a client quote artefact — and every other deliverable the agent owns (works orders, heritage statements, survey sheets, drawing registers, trial-pit records, invoice stamps) is untouched. **Caller identity is `agent_type` from the hook envelope**, which Claude Code sets when a hook fires inside a subagent; `MAXY_SPECIALIST` is not usable, since it is stamped only when a specialist is spawned as its own PTY process and reads empty for the native Agent-tool subagents this gate exists to stop. **Card lookup spans both registration families** — the per-account registry (`<accountDir>/.claude/agents/`, `<accountDir>/specialists/agents/`), `$CLAUDE_CONFIG_DIR/agents/`, and the plugin roots (`$(dirname $PLATFORM_ROOT)/premium-plugins/*/agents/`, `$PLATFORM_ROOT/plugins/*/agents/`) — matching on frontmatter `name:` as well as filename, because the quoter's card is filed as `sitedesk--quoter.md` and declares `name: quoter`. Omitting the premium root would block the quoter itself. A write from the main conversation carries no `agent_type` and the admin seat holds no `quote-render`, so it is refused too: no part of a client quote document is another agent's work, the admin's included. **Block message:** `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.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=<quote-path-write-without-render-tool|quote-path-write-unresolved-agent>`. **Fail-open** on an uninspectable envelope.
|
|
273
275
|
- `hooks/preference-consult-gate.sh` — **PreToolUse gate on customer-facing document deliverables.** Registered against `mcp__plugin_browser_browser__browser-pdf-save` (a second command on the same matcher as `quote-render-gate`, either exit 2 blocks) and against the email/Outlook send tools plus `SendUserFile`. It fires only in the document-deliverable shape: a `browser-pdf-save` or `SendUserFile` whose path is under `memory/users/<phone>/documents/` (or a `SendUserFile` of a `.pdf`/`.html`/`.docx`), and `email-send`/`email-reply`/`email-draft-send`/`outlook-mail-send`/`outlook-mail-reply`/`outlook-draft-send` carrying a non-empty `attachments`. For a gated call it reads the session transcript (`transcript_path`) and blocks (exit 2) when no `profile-read` tool call ran after the last user message, because the account's layer-2 `Preference` records (signature policy, header, naming, styling) were not consulted this turn, the exact miss that dropped a customer signature on a delivered document. **Block message:** `Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again.` **Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block, `op=allow tool=<name> consulted=true` on an allowed document deliverable. Every non-document tool/shape, and every casual channel reply or plain-text email, passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / transcript missing or unreadable). Scope decision: the gate fires on document deliverables only, not on every send, so it never blocks routine chat. The standing reconcile counterpart for stranded `output/` deliverables is filed as a follow-up task.
|
|
274
|
-
- `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
|
|
276
|
+
- `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Third check — a running header on every physical page.** Chrome decides physical pagination at print time, so a document whose header is a hand-placed `<div>` next to a hand-placed page break loses that header on any page the content bled onto (job 1854, 2026-08-04: 8 headerless pages out of 21, with `run-head` and `page-break` counts matching at 20 apiece, which is why counting the two against each other is blind to the fault). This check is deliberately **receipt-free and path-agnostic**, because template-seeded documents — budget estimates, valuations, works orders, survey sheets — never pass through `quote-render` and are exactly the ones that reproduce it. It resolves the source HTML from the render receipt when the PDF is a gated client quote, else from the sibling `<same-basename>.html` beside the PDF (the documented `browser-pdf-save` convention in `plugins/business-assistant/references/invoicing.md` step 4 and `plugins/business-assistant/references/document-management.md` step 4). The **header marker** is the literal string `content` of the first `@page` top margin box that carries one, tried `@top-center` → `@top-right` → `@top-left`, with CSS escapes and HTML entities resolved; a `content:url()` logo box carries no extractable text and is skipped, and a marker under 4 characters counts as absent. Pages are read one at a time with `pdftotext -f N -l N` **cropped to the top 12% of the page** and compared on whitespace-collapsed text. The crop is load-bearing, not an optimisation: a running footer routinely repeats the same reference as the header (the GLS footer is `… Builders Est. 1987 · Quote <ref>`), so a whole-page match reports a header on a page that carries only a footer — measured on a real 10-page print, where page 1 matched on its footer alone while its header was correctly suppressed. Twelve percent covers a top margin as deep as 30mm on A4 and excludes the footer. Checking starts at **page 2** when the source declares `@page :first` (which suppresses the boxes so page 1 keeps its own masthead) and from page 1 otherwise. It blocks (exit 2) when any checked page lacks the marker. **Only a document that declares a running header is ever gated** — a brochure or any document without one passes untouched. **Block message:** `Blocked: this printed document has physical pages carrying no running header, so it would go out with unbranded pages (…). The header must be a property of the page itself — a page margin box — not a header block placed next to a declared page break, because the browser decides the real page boundaries at print time. Fix the document's print CSS, print it again, and check every page.` **Log line:** `<ISO> [quote-render] op=pdf-header-conformance file=<basename> pages=<N> pagesChecked=<n> pagesHeaderless=<k> session=<sid>`, emitted on **every** decisive call including a clean one (`pagesHeaderless=0`), and on each skip path as `pagesChecked=0 reason=<no-source-html|no-running-header|single-page|no-poppler>` — so a clean run is distinguishable from a check that never ran. A block additionally emits `op=bypass file=<basename> reason=pdf-header-conformance detail=pagesHeaderless=<k> pages=<comma list>`. **Sink:** `<logDir>/quote-render-conformance.log`, resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `ACCOUNT_DIR/logs` ladder that `mailbox-inject.sh` uses, **not** `server.log` — a hook's stderr on exit 0 reaches no sink at all (measured 2026-08-06 on the laptop's `sitedesk-code` install: no bracketed hook line appears in `server.log` live or rotated, nor in the JSONL transcripts), so a stderr-only line would have been an audit nothing reads. The line is echoed to stderr as well, so a blocking run still shows it to the agent. **Fail-open** additionally on absent `pdfinfo`/`pdftotext`, an unresolvable source HTML, and a source that declares no running header. **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
|
|
275
277
|
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
276
278
|
- `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
|
|
277
279
|
- `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your answer and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_askgate` (`platform/scripts/lib/account-settings-askgate.sh`) on every install to retrofit the carrier gate onto every existing account's `settings.json` — idempotent surgical jq patch of the `AskUserQuestion` matcher, one `[backfill-1683] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>` line per account, contents never echoed.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for quote-path-write-gate.sh.
|
|
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. This
|
|
6
|
+
# gate expresses the same rule at the write, refusing an agent that does not
|
|
7
|
+
# hold quote-render from authoring a quote artefact or the account's quote
|
|
8
|
+
# templates, while leaving every other deliverable that agent owns untouched.
|
|
9
|
+
#
|
|
10
|
+
# The caller is identified by `agent_type` in the hook envelope, which Claude
|
|
11
|
+
# Code sets when a hook fires inside a subagent. The MAXY_SPECIALIST env is not
|
|
12
|
+
# usable: it is stamped only when a specialist is spawned as its own PTY
|
|
13
|
+
# process, and the dispatches this gate exists to stop were native Agent-tool
|
|
14
|
+
# subagents that inherit the admin process env.
|
|
15
|
+
#
|
|
16
|
+
# Cards are resolved across BOTH registration families. Bundled specialists
|
|
17
|
+
# reach a session through the per-account registry; premium-bundle agents
|
|
18
|
+
# register through plugin manifests and are never written to that registry, so
|
|
19
|
+
# a lookup that skipped the premium root would block the quoter itself.
|
|
20
|
+
#
|
|
21
|
+
# Covers:
|
|
22
|
+
# A. the quoter writing a quote document → ALLOWED
|
|
23
|
+
# B. an agent without the tool → BLOCKED without-render-tool
|
|
24
|
+
# C. no agent_type at all (the main conversation) → BLOCKED without-render-tool
|
|
25
|
+
# D. an agent_type no card resolves → BLOCKED unresolved-agent
|
|
26
|
+
# E. the internal pricing bucket → ALLOWED (never gated)
|
|
27
|
+
# F. an agent without the tool, quote templates → BLOCKED
|
|
28
|
+
# G. the quoter, quote templates → ALLOWED
|
|
29
|
+
# H. an agent without the tool, its own output → ALLOWED
|
|
30
|
+
# I. the Quotations bucket → BLOCKED
|
|
31
|
+
# J. empty stdin → ALLOWED (fail-open)
|
|
32
|
+
# K. a read of a gated path → ALLOWED
|
|
33
|
+
# L. MultiEdit is gated like Write and Edit → BLOCKED
|
|
34
|
+
# M. a block emits the op=bypass fault line
|
|
35
|
+
|
|
36
|
+
set -u
|
|
37
|
+
|
|
38
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-path-write-gate.sh"
|
|
39
|
+
if [[ ! -x "$HOOK" ]]; then
|
|
40
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
FAKE=$(mktemp -d)
|
|
45
|
+
cleanup() { rm -rf "$FAKE"; }
|
|
46
|
+
trap cleanup EXIT
|
|
47
|
+
|
|
48
|
+
ACCT="$FAKE/accounts/acct-1"
|
|
49
|
+
mkdir -p "$ACCT/.claude/agents" "$ACCT/jobs/job-1/quote" "$ACCT/jobs/job-1/pricing" \
|
|
50
|
+
"$ACCT/quoting/templates" "$ACCT/output" \
|
|
51
|
+
"$FAKE/premium-plugins/sitedesk/agents" "$FAKE/platform/plugins/admin/hooks"
|
|
52
|
+
|
|
53
|
+
# The per-account registry: a bundled specialist that holds no quote tool.
|
|
54
|
+
cat > "$ACCT/.claude/agents/content-producer.md" <<'CARD'
|
|
55
|
+
---
|
|
56
|
+
name: content-producer
|
|
57
|
+
description: "Produces documents."
|
|
58
|
+
tools: Skill, Read, Write, Edit, MultiEdit, Bash, mcp__plugin_browser_browser__browser-pdf-save
|
|
59
|
+
---
|
|
60
|
+
CARD
|
|
61
|
+
|
|
62
|
+
# The premium bundle: the quoter, whose card filename does not match its name.
|
|
63
|
+
cat > "$FAKE/premium-plugins/sitedesk/agents/sitedesk--quoter.md" <<'CARD'
|
|
64
|
+
---
|
|
65
|
+
name: quoter
|
|
66
|
+
description: "The quoter owns the client quote document entire."
|
|
67
|
+
tools: Skill, Read, Write, Edit, Bash, mcp__plugin_sitedesk-job_sitedesk-job__quote-engine-run, mcp__plugin_sitedesk-job_sitedesk-job__quote-render
|
|
68
|
+
---
|
|
69
|
+
CARD
|
|
70
|
+
|
|
71
|
+
PASS=0
|
|
72
|
+
FAIL=0
|
|
73
|
+
|
|
74
|
+
envelope() { # tool_name, file_path, agent_type ("" = absent)
|
|
75
|
+
python3 -c '
|
|
76
|
+
import json, sys
|
|
77
|
+
d = {"hook_event_name": "PreToolUse", "tool_name": sys.argv[1],
|
|
78
|
+
"tool_input": {"file_path": sys.argv[2]}}
|
|
79
|
+
if sys.argv[3]:
|
|
80
|
+
d["agent_type"] = sys.argv[3]
|
|
81
|
+
d["agent_id"] = "agent_01"
|
|
82
|
+
print(json.dumps(d))' "$1" "$2" "$3"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
run_case() {
|
|
86
|
+
local name="$1" stdin="$2" expected_exit="$3"
|
|
87
|
+
local actual_exit
|
|
88
|
+
( cd "$ACCT" && export PLATFORM_ROOT="$FAKE/platform" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
|
|
89
|
+
actual_exit=$?
|
|
90
|
+
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
|
|
91
|
+
echo "PASS: $name (exit=$actual_exit)"
|
|
92
|
+
PASS=$((PASS + 1))
|
|
93
|
+
else
|
|
94
|
+
echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
|
|
95
|
+
FAIL=$((FAIL + 1))
|
|
96
|
+
fi
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
QUOTE="jobs/job-1/quote/Q 1879 - Client.html"
|
|
100
|
+
|
|
101
|
+
run_case "A: the quoter writing a quote document → ALLOWED" \
|
|
102
|
+
"$(envelope Write "$QUOTE" sitedesk:quoter)" 0
|
|
103
|
+
|
|
104
|
+
run_case "B: an agent without the tool → BLOCKED" \
|
|
105
|
+
"$(envelope Write "$QUOTE" content-producer)" 2
|
|
106
|
+
|
|
107
|
+
run_case "C: no agent_type at all → BLOCKED" \
|
|
108
|
+
"$(envelope Write "$QUOTE" '')" 2
|
|
109
|
+
|
|
110
|
+
run_case "D: an agent_type no card resolves → BLOCKED" \
|
|
111
|
+
"$(envelope Write "$QUOTE" ghost-agent)" 2
|
|
112
|
+
|
|
113
|
+
run_case "E: the internal pricing bucket → ALLOWED" \
|
|
114
|
+
"$(envelope Write 'jobs/job-1/pricing/breakdown.md' content-producer)" 0
|
|
115
|
+
|
|
116
|
+
run_case "F: an agent without the tool, quote templates → BLOCKED" \
|
|
117
|
+
"$(envelope Edit 'quoting/templates/house-style.html' content-producer)" 2
|
|
118
|
+
|
|
119
|
+
run_case "G: the quoter, quote templates → ALLOWED" \
|
|
120
|
+
"$(envelope Edit 'quoting/templates/house-style.html' sitedesk:quoter)" 0
|
|
121
|
+
|
|
122
|
+
run_case "H: an agent without the tool, its own output → ALLOWED" \
|
|
123
|
+
"$(envelope Write 'output/works-order.md' content-producer)" 0
|
|
124
|
+
|
|
125
|
+
run_case "I: the Quotations bucket → BLOCKED" \
|
|
126
|
+
"$(envelope Write 'jobs/job-1/Quotations/Q 1879 - Client.html' content-producer)" 2
|
|
127
|
+
|
|
128
|
+
run_case "J: empty stdin → ALLOWED (fail-open)" "" 0
|
|
129
|
+
|
|
130
|
+
run_case "K: a read of a gated path → ALLOWED" \
|
|
131
|
+
"$(envelope Read "$QUOTE" content-producer)" 0
|
|
132
|
+
|
|
133
|
+
run_case "L: MultiEdit is gated like Write and Edit → BLOCKED" \
|
|
134
|
+
"$(envelope MultiEdit "$QUOTE" content-producer)" 2
|
|
135
|
+
|
|
136
|
+
check_reason() { # name, agent_type, expected reason
|
|
137
|
+
local out
|
|
138
|
+
out=$( cd "$ACCT" && export PLATFORM_ROOT="$FAKE/platform" && printf '%s' "$(envelope Write "$QUOTE" "$2")" | bash "$HOOK" 2>&1 >/dev/null )
|
|
139
|
+
if printf '%s' "$out" | grep -q "\[quote-render\] op=bypass reason=$3"; then
|
|
140
|
+
echo "PASS: M: $1 emits reason=$3"
|
|
141
|
+
PASS=$((PASS + 1))
|
|
142
|
+
else
|
|
143
|
+
echo "FAIL: M: $1 did not emit reason=$3 (got: $out)" >&2
|
|
144
|
+
FAIL=$((FAIL + 1))
|
|
145
|
+
fi
|
|
146
|
+
}
|
|
147
|
+
check_reason "an agent without the tool" content-producer quote-path-write-without-render-tool
|
|
148
|
+
check_reason "an unresolvable agent" ghost-agent quote-path-write-unresolved-agent
|
|
149
|
+
|
|
150
|
+
echo "──────── quote-path-write-gate test summary ────────"
|
|
151
|
+
echo "PASS=$PASS FAIL=$FAIL"
|
|
152
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for quote-pdf-bash-gate.sh.
|
|
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. This gate refuses a Bash
|
|
8
|
+
# command that produces a PDF from an HTML under a gated quote path, matching
|
|
9
|
+
# structurally on the paths so the rule holds for any converter binary.
|
|
10
|
+
#
|
|
11
|
+
# Covers:
|
|
12
|
+
# A. converting an HTML in a job's quote bucket → BLOCKED
|
|
13
|
+
# B. converting an HTML in a job's Quotations bucket → BLOCKED
|
|
14
|
+
# C. converting an HTML in the customer documents scope → BLOCKED
|
|
15
|
+
# D. a quoted path carrying spaces → BLOCKED
|
|
16
|
+
# E. the same converter on an unrelated HTML → ALLOWED
|
|
17
|
+
# F. a read of a gated HTML, no PDF named → ALLOWED
|
|
18
|
+
# G. the internal pricing bucket → ALLOWED (never gated)
|
|
19
|
+
# H. empty stdin → ALLOWED (fail-open)
|
|
20
|
+
# I. a non-Bash tool → ALLOWED
|
|
21
|
+
# J. a block emits the op=bypass fault line
|
|
22
|
+
|
|
23
|
+
set -u
|
|
24
|
+
|
|
25
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-pdf-bash-gate.sh"
|
|
26
|
+
if [[ ! -x "$HOOK" ]]; then
|
|
27
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
PASS=0
|
|
32
|
+
FAIL=0
|
|
33
|
+
|
|
34
|
+
run_case() {
|
|
35
|
+
local name="$1" stdin="$2" expected_exit="$3"
|
|
36
|
+
local actual_exit
|
|
37
|
+
printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
|
|
38
|
+
actual_exit=$?
|
|
39
|
+
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
|
|
40
|
+
echo "PASS: $name (exit=$actual_exit)"
|
|
41
|
+
PASS=$((PASS + 1))
|
|
42
|
+
else
|
|
43
|
+
echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
|
|
44
|
+
FAIL=$((FAIL + 1))
|
|
45
|
+
fi
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
envelope() { # command
|
|
49
|
+
python3 -c '
|
|
50
|
+
import json, sys
|
|
51
|
+
print(json.dumps({
|
|
52
|
+
"hook_event_name": "PreToolUse",
|
|
53
|
+
"tool_name": "Bash",
|
|
54
|
+
"tool_input": {"command": sys.argv[1]},
|
|
55
|
+
}))' "$1"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
run_case "A: convert an HTML in a job quote bucket → BLOCKED" \
|
|
59
|
+
"$(envelope 'wkhtmltopdf jobs/job-1/quote/quote.html jobs/job-1/quote/quote.pdf')" 2
|
|
60
|
+
|
|
61
|
+
run_case "B: convert an HTML in a job Quotations bucket → BLOCKED" \
|
|
62
|
+
"$(envelope 'chromium --headless --print-to-pdf=out.pdf jobs/job-1/Quotations/q.html')" 2
|
|
63
|
+
|
|
64
|
+
run_case "C: convert an HTML in the customer documents scope → BLOCKED" \
|
|
65
|
+
"$(envelope 'weasyprint memory/users/44700900000/documents/quote-job-1.html q.pdf')" 2
|
|
66
|
+
|
|
67
|
+
run_case "D: a quoted path carrying spaces → BLOCKED" \
|
|
68
|
+
"$(envelope 'wkhtmltopdf "jobs/job-1/quote/Q 1879 - Client.html" "jobs/job-1/quote/Q 1879 - Client.pdf"')" 2
|
|
69
|
+
|
|
70
|
+
run_case "E: the same converter on an unrelated HTML → ALLOWED" \
|
|
71
|
+
"$(envelope 'wkhtmltopdf notes/readme.html readme.pdf')" 0
|
|
72
|
+
|
|
73
|
+
run_case "F: a read of a gated HTML, no PDF named → ALLOWED" \
|
|
74
|
+
"$(envelope 'cat "jobs/job-1/quote/Q 1879 - Client.html"')" 0
|
|
75
|
+
|
|
76
|
+
run_case "G: the internal pricing bucket → ALLOWED" \
|
|
77
|
+
"$(envelope 'wkhtmltopdf jobs/job-1/pricing/breakdown.html breakdown.pdf')" 0
|
|
78
|
+
|
|
79
|
+
run_case "H: empty stdin → ALLOWED (fail-open)" "" 0
|
|
80
|
+
|
|
81
|
+
run_case "I: a non-Bash tool → ALLOWED" \
|
|
82
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"jobs/job-1/quote/q.html"}}' 0
|
|
83
|
+
|
|
84
|
+
STDERR=$(printf '%s' "$(envelope 'wkhtmltopdf jobs/job-1/quote/quote.html jobs/job-1/quote/quote.pdf')" | bash "$HOOK" 2>&1 >/dev/null)
|
|
85
|
+
if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass reason=bash-pdf-production'; then
|
|
86
|
+
echo "PASS: J: block emits op=bypass reason=bash-pdf-production"
|
|
87
|
+
PASS=$((PASS + 1))
|
|
88
|
+
else
|
|
89
|
+
echo "FAIL: J: op=bypass line absent (got: $STDERR)" >&2
|
|
90
|
+
FAIL=$((FAIL + 1))
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
echo "──────── quote-pdf-bash-gate test summary ────────"
|
|
94
|
+
echo "PASS=$PASS FAIL=$FAIL"
|
|
95
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -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 ]]
|