@rubytech/create-maxy-code 0.1.568 → 0.1.569

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +4 -2
  3. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  7. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  8. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  9. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  10. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  11. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
  13. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  14. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  15. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
  16. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  17. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
  18. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  19. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  20. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  21. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
  22. package/payload/platform/scripts/setup-account.sh +13 -0
  23. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
  24. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
  26. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/index.js +87 -0
  28. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  30. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  31. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  32. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  33. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  34. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  36. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  37. package/payload/platform/templates/account-settings.json +153 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.568",
3
+ "version": "0.1.569",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -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. Blocks (exit 2) a `browser-pdf-save` whose output path is a client-quote artefact (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) when no render receipt exists at `quoting/jobs/<jobId>/render-receipt.json` the receipt `quote-render` writes only after its reconcile / margin-leak / link-liveness gates all pass. A hand-authored quote (the SiteDesk 2026-07-14 bypass: `Write` HTML `browser-pdf-save`, `quote-render` never called, an internal back-test caveat naming another client folded into the free-text region) has no receipt, so the print is refused rather than delivered. **Block message:** `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.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> detail=client quote PDF has no render receipt` (or `reason=bad-jobid` when the filename does not resolve to a job). Every non-quote PDF — brochures, invoices, the admin-scope internal view — and the deterministic path itself (receipt present) pass 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.
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 (memory/users/<phone>/documents/quote-<jobId>.pdf or
6
- # ...-breakdown.pdf) unless a render receipt exists for that jobId proof the
7
- # deterministic quote-render path produced it. Everything else passes.
5
+ # quote PDF unless the deterministic quote-render path produced that exact
6
+ # document proven by comparing the PDF's source HTML against the documents
7
+ # the job's render receipt says were filed.
8
+ #
9
+ # Two facts the earlier version of this gate missed, both from a quote that
10
+ # reached a customer hand-authored:
11
+ #
12
+ # 1. Scope. Client quotes are filed under the jobs bucket
13
+ # (jobs/<jobId>/quote/, jobs/<jobId>/Quotations/), not only under the
14
+ # customer documents scope. Both paths were printed that evening; the
15
+ # unmatched one was the one that was sent.
16
+ # 2. Identity. A receipt proves a render happened for the job, not that this
17
+ # file is its output. The job's figures were rewritten after the render and
18
+ # the delivered document was written later still, so an existence check
19
+ # would have authorised the print.
8
20
  #
9
21
  # Covers:
10
- # A. client quote PDF, no receipt → BLOCKED (exit 2)
11
- # B. client quote PDF, receipt present ALLOWED (exit 0)
12
- # C. client breakdown PDF, no receipt → BLOCKED
13
- # D. client breakdown PDF, receipt present ALLOWED
14
- # E. non-quote PDF in the documents scope → ALLOWED (not a quote artefact)
15
- # F. internal quote PDF (admin scope) → ALLOWED (not client-facing)
16
- # G. quote-named PDF outside the client scope → ALLOWED (only the documents scope is gated)
17
- # H. empty stdin → ALLOWED (general-purpose tool, fail-open)
18
- # I. a non-browser tool → ALLOWED (matcher defence-in-depth)
19
- # J. jobId carrying a path traversal → BLOCKED (never resolves a stray receipt)
20
- # K. a block emits the op=bypass fault line
22
+ # A. jobs-bucket PDF whose source matches filed.quote → ALLOWED
23
+ # B. jobs-bucket PDF, hand-authored source BLOCKED not-receipt-filed
24
+ # C. jobs-bucket PDF with no source HTML beside it → BLOCKED no-source-html
25
+ # D. jobs-bucket PDF whose source matches filed.internal BLOCKED (not client-facing)
26
+ # E. Quotations bucket, source matches filed.quote → ALLOWED
27
+ # F. jobs-bucket PDF whose source matches filed.breakdown → ALLOWED
28
+ # G. the internal pricing bucket → ALLOWED (never gated)
29
+ # H. customer documents scope, source matches filed.quote → ALLOWED
30
+ # I. customer documents scope, no receipt for the job → BLOCKED
31
+ # J. a non-quote PDF in the documents scope → ALLOWED
32
+ # K. the admin-scope internal view → ALLOWED
33
+ # L. empty stdin → ALLOWED (fail-open)
34
+ # M. a non-browser tool → ALLOWED
35
+ # N. jobId carrying a path traversal → BLOCKED bad-jobid
36
+ # N2. a PDF nested one level deeper in the quote bucket → BLOCKED
37
+ # (the write gate and the Bash gate both match the bucket recursively;
38
+ # a print gate that matched only direct children would disagree with
39
+ # them, and one mkdir would walk a hand-authored quote straight out)
40
+ # O. the recorded incident: a hand-authored quote carrying no marker at all,
41
+ # which a marker check would have passed
42
+ # P. a block emits the op=bypass fault line with its reason
21
43
 
22
44
  set -u
23
45
 
@@ -31,17 +53,34 @@ ACCT=$(mktemp -d)
31
53
  cleanup() { rm -rf "$ACCT"; }
32
54
  trap cleanup EXIT
33
55
 
34
- # A receipt for job-1 (present) — jobs job-2 / job-3 have none.
35
- mkdir -p "$ACCT/quoting/jobs/job-1"
36
- printf '{"jobId":"job-1"}' > "$ACCT/quoting/jobs/job-1/render-receipt.json"
37
-
38
56
  DOCS="$ACCT/memory/users/44700900000/documents"
39
57
  ADMIN="$ACCT/memory/admin/customers/44700900000"
58
+ JOB="$ACCT/jobs/job-1"
59
+
60
+ mkdir -p "$DOCS" "$ADMIN" "$JOB/quote" "$JOB/Quotations" "$JOB/pricing" "$ACCT/quoting/jobs/job-1"
61
+
62
+ # The three documents a successful render files, each with distinct content.
63
+ printf '<html data-quote-total="1">rendered client quote</html>' > "$DOCS/quote-job-1.html"
64
+ printf '<html>rendered breakdown</html>' > "$DOCS/quote-job-1-breakdown.html"
65
+ printf '<html data-internal-only="1">rendered internal</html>' > "$ADMIN/quote-job-1-internal.html"
66
+
67
+ cat > "$ACCT/quoting/jobs/job-1/render-receipt.json" <<'RECEIPT'
68
+ {
69
+ "jobId": "job-1",
70
+ "engineTotal": 1000,
71
+ "filed": {
72
+ "internal": "memory/admin/customers/44700900000/quote-job-1-internal.html",
73
+ "breakdown": "memory/users/44700900000/documents/quote-job-1-breakdown.html",
74
+ "quote": "memory/users/44700900000/documents/quote-job-1.html"
75
+ },
76
+ "renderedAt": "2026-08-06T00:00:00.000Z"
77
+ }
78
+ RECEIPT
40
79
 
41
80
  PASS=0
42
81
  FAIL=0
43
82
 
44
- # Run the hook with cwd = the account dir (as the PTY does), capturing exit + stderr.
83
+ # Run the hook with cwd = the account dir (as the PTY does), capturing exit.
45
84
  run_case() {
46
85
  local name="$1" stdin="$2" expected_exit="$3"
47
86
  local actual_exit
@@ -62,46 +101,103 @@ envelope() { # tool_name, path
62
101
 
63
102
  BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
64
103
 
65
- run_case "A: client quote PDF, no receipt BLOCKED" \
66
- "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" 2
67
-
68
- run_case "B: client quote PDF, receipt present → ALLOWED" \
104
+ # A a rendered document copied into the jobs bucket reconciles by content.
105
+ cp "$DOCS/quote-job-1.html" "$JOB/quote/Q 1879 - Client.html"
106
+ run_case "A: jobs bucket, source matches filed.quote → ALLOWED" \
107
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 0
108
+
109
+ # B — the same path, hand-authored.
110
+ printf '<html>hand-authored quote</html>' > "$JOB/quote/Q 1879 - Client.html"
111
+ run_case "B: jobs bucket, hand-authored source → BLOCKED" \
112
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 2
113
+
114
+ # C — no source beside the PDF at all.
115
+ rm -f "$JOB/quote/Q 1879 - Client.html"
116
+ run_case "C: jobs bucket, no source HTML → BLOCKED" \
117
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 2
118
+
119
+ # D — the internal view is filed, but it is not client-facing.
120
+ cp "$ADMIN/quote-job-1-internal.html" "$JOB/quote/Q 1879 - Client.html"
121
+ run_case "D: jobs bucket, source matches filed.internal → BLOCKED" \
122
+ "$(envelope "$BROWSER" "$JOB/quote/Q 1879 - Client.pdf")" 2
123
+
124
+ # E — the Quotations bucket is the same convention.
125
+ cp "$DOCS/quote-job-1.html" "$JOB/Quotations/Q 1879 - Client.html"
126
+ run_case "E: Quotations bucket, source matches filed.quote → ALLOWED" \
127
+ "$(envelope "$BROWSER" "$JOB/Quotations/Q 1879 - Client.pdf")" 0
128
+
129
+ # F — the breakdown is client-facing too.
130
+ cp "$DOCS/quote-job-1-breakdown.html" "$JOB/quote/breakdown.html"
131
+ run_case "F: jobs bucket, source matches filed.breakdown → ALLOWED" \
132
+ "$(envelope "$BROWSER" "$JOB/quote/breakdown.pdf")" 0
133
+
134
+ # G — the internal priced breakdown is never gated.
135
+ printf '<html>internal pricing</html>' > "$JOB/pricing/breakdown.html"
136
+ run_case "G: internal pricing bucket → ALLOWED" \
137
+ "$(envelope "$BROWSER" "$JOB/pricing/breakdown.pdf")" 0
138
+
139
+ run_case "H: documents scope, source matches filed.quote → ALLOWED" \
69
140
  "$(envelope "$BROWSER" "$DOCS/quote-job-1.pdf")" 0
70
141
 
71
- run_case "C: client breakdown PDF, no receipt → BLOCKED" \
72
- "$(envelope "$BROWSER" "$DOCS/quote-job-3-breakdown.pdf")" 2
73
-
74
- run_case "D: client breakdown PDF, receipt present → ALLOWED" \
75
- "$(envelope "$BROWSER" "$DOCS/quote-job-1-breakdown.pdf")" 0
142
+ run_case "I: documents scope, no receipt for the job → BLOCKED" \
143
+ "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" 2
76
144
 
77
- run_case "E: non-quote PDF in documents scope → ALLOWED" \
145
+ printf '<html>brochure</html>' > "$DOCS/brochure.html"
146
+ run_case "J: non-quote PDF in documents scope → ALLOWED" \
78
147
  "$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
79
148
 
80
- run_case "F: internal quote PDF (admin scope) → ALLOWED" \
81
- "$(envelope "$BROWSER" "$ADMIN/quote-job-2-internal.pdf")" 0
149
+ run_case "K: admin-scope internal view → ALLOWED" \
150
+ "$(envelope "$BROWSER" "$ADMIN/quote-job-1-internal.pdf")" 0
82
151
 
83
- run_case "G: quote-named PDF outside client scope → ALLOWED" \
84
- "$(envelope "$BROWSER" "$ACCT/quote-job-2.pdf")" 0
152
+ run_case "L: empty stdin → ALLOWED (fail-open)" "" 0
85
153
 
86
- run_case "H: empty stdin → ALLOWED (fail-open)" \
87
- "" 0
88
-
89
- run_case "I: non-browser tool → ALLOWED" \
154
+ run_case "M: non-browser tool → ALLOWED" \
90
155
  '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/x/memory/users/44700900000/documents/quote-job-2.pdf"}}' 0
91
156
 
92
- run_case "J: jobId path traversal → BLOCKED" \
157
+ run_case "N: jobId path traversal → BLOCKED" \
93
158
  "$(envelope "$BROWSER" "$DOCS/quote-...pdf")" 2
94
159
 
95
- # K: a block emits the op=bypass fault line on stderr.
96
- STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
97
- if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=job-2'; then
98
- echo "PASS: K: block emits op=bypass jobId=job-2"
99
- PASS=$((PASS + 1))
100
- else
101
- echo "FAIL: K: op=bypass line absent (got: $STDERR)" >&2
160
+ # N2 nesting must not escape the bucket.
161
+ mkdir -p "$JOB/quote/final"
162
+ printf '<html>hand-authored</html>' > "$JOB/quote/final/Q 1879 - Client.html"
163
+ run_case "N2: a PDF nested deeper in the quote bucket → BLOCKED" \
164
+ "$(envelope "$BROWSER" "$JOB/quote/final/Q 1879 - Client.pdf")" 2
165
+
166
+ cp "$DOCS/quote-job-1.html" "$JOB/quote/final/Q 1879 - Client.html"
167
+ run_case "N2: a nested rendered copy still reconciles → ALLOWED" \
168
+ "$(envelope "$BROWSER" "$JOB/quote/final/Q 1879 - Client.pdf")" 0
169
+
170
+ # O — the recorded incident. The delivered document carried zero
171
+ # data-quote-total, so a marker check would have passed it; only a content
172
+ # comparison against the receipt-filed documents rejects it.
173
+ printf '<html>Q 1879 Woolgrove hand-authored, no markers</html>' > "$JOB/quote/Woolgrove.html"
174
+ run_case "O: recorded incident, hand-authored and markerless → BLOCKED" \
175
+ "$(envelope "$BROWSER" "$JOB/quote/Woolgrove.pdf")" 2
176
+ if grep -q 'data-quote-total' "$JOB/quote/Woolgrove.html"; then
177
+ echo "FAIL: O: the incident fixture must carry no marker" >&2
102
178
  FAIL=$((FAIL + 1))
179
+ else
180
+ echo "PASS: O: the incident fixture carries no marker, so a marker check would pass it"
181
+ PASS=$((PASS + 1))
103
182
  fi
104
183
 
184
+ # P — a block names its reason on stderr.
185
+ check_reason() { # name, path, expected reason
186
+ local out
187
+ out=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$2")" | bash "$HOOK" 2>&1 >/dev/null )
188
+ if printf '%s' "$out" | grep -q "\[quote-render\] op=bypass jobId=.* reason=$3"; then
189
+ echo "PASS: P: $1 emits reason=$3"
190
+ PASS=$((PASS + 1))
191
+ else
192
+ echo "FAIL: P: $1 did not emit reason=$3 (got: $out)" >&2
193
+ FAIL=$((FAIL + 1))
194
+ fi
195
+ }
196
+ check_reason "hand-authored source" "$JOB/quote/Woolgrove.pdf" "not-receipt-filed"
197
+ rm -f "$JOB/quote/Woolgrove.html"
198
+ check_reason "missing source" "$JOB/quote/Woolgrove.pdf" "no-source-html"
199
+ check_reason "path traversal" "$DOCS/quote-...pdf" "bad-jobid"
200
+
105
201
  echo "──────── quote-render-gate test summary ────────"
106
202
  echo "PASS=$PASS FAIL=$FAIL"
107
203
  [[ "$FAIL" -eq 0 ]]