@rubytech/create-realagent-code 0.1.568 → 0.1.570
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +7 -5
- package/payload/platform/plugins/admin/hooks/__tests__/pdf-text-layer-inject.test.sh +177 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
- package/payload/platform/plugins/admin/hooks/pdf-text-layer-inject.sh +137 -0
- package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
- package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
- package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
- package/payload/platform/plugins/admin/skills/a4-print-documents/SKILL.md +12 -1
- package/payload/platform/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs +113 -0
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -0
- package/payload/platform/plugins/docs/references/admin-session.md +15 -0
- package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +19 -43
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
- package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +22 -37
- package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +12 -35
- package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
- package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
- package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
- package/payload/platform/scripts/setup-account.sh +11 -53
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts +129 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js +344 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +107 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
- package/payload/platform/templates/account-settings.json +159 -0
- package/payload/server/server.js +750 -656
- package/payload/platform/scripts/lib/__tests__/account-settings-askgate.test.sh +0 -144
- package/payload/platform/scripts/lib/__tests__/account-settings-mailbox-inject.test.sh +0 -166
- package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +0 -151
- package/payload/platform/scripts/lib/__tests__/account-settings-public-surface.test.sh +0 -109
- package/payload/platform/scripts/lib/account-settings-askgate.sh +0 -98
- package/payload/platform/scripts/lib/account-settings-mailbox-inject.sh +0 -91
- package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +0 -98
- package/payload/platform/scripts/lib/account-settings-public-surface.sh +0 -109
package/package.json
CHANGED
|
@@ -268,21 +268,23 @@ 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
|
-
- `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 `
|
|
278
|
-
- `hooks/public-tool-surface.sh` — **PreToolUse matcher-less (every tool) + UserPromptSubmit + Stop.** Records the tool surface a `role='public'` spawn REALISES, which nothing recorded before: `tool-allowlist` records the surface a spawn was given, and `[public-surface] op=audit` reconciles MCP server names from spawn-tee filenames, so it is blind to `Read`, `Bash`, `Skill` and `ToolSearch` (no MCP child, no tee file). On 2026-08-01 five such tools ran in a session spawned with `--allowed-tools mcp__whatsapp-channel__reply --strict-mcp-config --permission-mode dontAsk` (that reply tool is now retired; a public spawn's allowlist is the deny-basis anchor alone) and produced no line. **Input:** `MAXY_PUBLIC_ALLOWED_TOOLS`, stamped by both spawn paths (`http-server.ts` rc-spawn, `pty-spawner.ts`) for `role='public'` only, carrying that spawn's final `--allowed-tools` argv set comma-joined. Absent means not a public spawn: the hook exits 0 in silence. **Log lines** (server.log only — a visitor's PTY gains no output): `[public-surface] op=tool-realised sessionId=<id8> tool=<name> allowed=<y|n>` per call, `allowed` being exact membership of the stamped set; `[public-surface] op=realised-census sessionId=<id8> calls=<n> disallowed=<n>` at Stop, emitted including zero so a session with no disallowed call is distinguishable from one the hook never saw. Per-call tallies are appends to `<logdir>/public-surface/<sanitised-session-id>`, removed by the census, so no counter mutex is needed. The `UserPromptSubmit` registration is the turn-start reset and emits nothing: the stuck-turn recycler kills a wedged PTY and resumes the SAME session id, so that turn's `Stop` never fires and its tally would otherwise be counted into the next turn's census (measured: three calls in a killed turn plus one call in the next reported `calls=4 disallowed=3`). **Never blocks:** exit 0 on every path, including unparseable stdin, absent `session_id`, missing `python3` and an unwritable log. **Reach on upgrade:** `provision-account-dir.sh` writes both entries only at account-provision time, so `setup-account.sh` runs `
|
|
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_settings` (`platform/scripts/lib/account-settings-reconcile.sh`) on every install, converging every existing account's `settings.json` against `platform/templates/account-settings.json` — which carries this matcher — with one `[settings-reconcile] account=<id> status=<already-set|updated|absent-seeded|rewrite-failed|no-template>` line per account and one `[settings-reconcile] accounts=<n> divergent=<n>` summary, contents never echoed. (The per-hook backfill lib this converge replaced has been retired.)
|
|
280
|
+
- `hooks/public-tool-surface.sh` — **PreToolUse matcher-less (every tool) + UserPromptSubmit + Stop.** Records the tool surface a `role='public'` spawn REALISES, which nothing recorded before: `tool-allowlist` records the surface a spawn was given, and `[public-surface] op=audit` reconciles MCP server names from spawn-tee filenames, so it is blind to `Read`, `Bash`, `Skill` and `ToolSearch` (no MCP child, no tee file). On 2026-08-01 five such tools ran in a session spawned with `--allowed-tools mcp__whatsapp-channel__reply --strict-mcp-config --permission-mode dontAsk` (that reply tool is now retired; a public spawn's allowlist is the deny-basis anchor alone) and produced no line. **Input:** `MAXY_PUBLIC_ALLOWED_TOOLS`, stamped by both spawn paths (`http-server.ts` rc-spawn, `pty-spawner.ts`) for `role='public'` only, carrying that spawn's final `--allowed-tools` argv set comma-joined. Absent means not a public spawn: the hook exits 0 in silence. **Log lines** (server.log only — a visitor's PTY gains no output): `[public-surface] op=tool-realised sessionId=<id8> tool=<name> allowed=<y|n>` per call, `allowed` being exact membership of the stamped set; `[public-surface] op=realised-census sessionId=<id8> calls=<n> disallowed=<n>` at Stop, emitted including zero so a session with no disallowed call is distinguishable from one the hook never saw. Per-call tallies are appends to `<logdir>/public-surface/<sanitised-session-id>`, removed by the census, so no counter mutex is needed. The `UserPromptSubmit` registration is the turn-start reset and emits nothing: the stuck-turn recycler kills a wedged PTY and resumes the SAME session id, so that turn's `Stop` never fires and its tally would otherwise be counted into the next turn's census (measured: three calls in a killed turn plus one call in the next reported `calls=4 disallowed=3`). **Never blocks:** exit 0 on every path, including unparseable stdin, absent `session_id`, missing `python3` and an unwritable log. **Reach on upgrade:** `provision-account-dir.sh` writes both entries only at account-provision time, so `setup-account.sh` runs `reconcile_all_accounts_settings` on every install, converging every existing account's `settings.json` against `platform/templates/account-settings.json` — which carries all three entries — with one `[settings-reconcile] account=<id> status=…` line per account, contents never echoed. (The per-hook backfill lib this converge replaced has been retired.)
|
|
279
281
|
- `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
|
|
280
282
|
- `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent`.** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone. Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
|
|
281
283
|
- `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit.** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal this hook was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
|
|
282
284
|
- `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook.** Injects the standing prompt-optimiser restatement directive plus the per-turn four-rung routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool, where the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if the deliverable recurs and no specialist or skill owns it, author its owning skill via `skill-builder` (or `agent-builder` for a worker agent) and produce through it; **(4)** only if it is a genuine one-off, author inline as the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). The directive also carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only. The directive is also **suppressed on native channel turns** — when the parsed `.prompt` starts with the `<channel source=` event marker, the hook logs `[prompt-optimiser-directive] skipped reason=channel-turn session=<id>` to stderr and exits without injecting, because the channel service already reframes the inbound into a select-and-dispatch turn (`composeAdminContent`, see `.docs/whatsapp-inbound-lifeline.md`). Marker-matched at start-of-prompt only, so an admin/Terminal prompt that merely mentions "channel" still gets the directive; fail-open injects if the prompt cannot be parsed.
|
|
283
285
|
- `hooks/preference-consult-directive.sh` — **UserPromptSubmit hook.** Injects a standing `<preference-adherence>` block naming the two-layer preference architecture (layer 1 the fixed block already in front of the agent every turn; layer 2 the account's own saved `Preference` records, read with `profile-read`, holding signature policy, header, naming and styling) and two directives: consult the relevant layer-2 preferences with `profile-read` before any customer-facing document deliverable, and store finished deliverables under `documents/` or the project folder because `output/` is scratch of last resort. Prompt-independent and always exit 0 (fail-open). Breadcrumb (stderr): `[pref-wrapper] op=inject`. This is the advisory companion to the `preference-consult-gate` hard gate.
|
|
284
286
|
- `hooks/datetime-inject.sh` — **UserPromptSubmit hook.** Injects a fresh `<datetime>` block as `additionalContext` on every turn (`refresh=per-turn`), so the agent always has the current instant — date, time, timezone, weekday, and the raw UTC ISO — without hand-computing it and without depending on Claude Code's spawn-time "Today's date" line going stale. The instant is computed fresh each turn; the timezone is **not** computed here — it is the operator's own zone, resolved from their `UserProfile.timezone` on the graph once per admin spawn and stamped into `MAXY_ACCOUNT_TZ` (see `platform/services/claude-session-manager/src/account-timezone.ts`). When `MAXY_ACCOUNT_TZ` is empty (a non-admin turn, an unset profile timezone, or a neo4j hiccup at spawn) the block falls back to the server's own zone and labels the source, so a reader can always tell which clock the agent saw. This is the current-time surface the `datetime` skill points at. **Fail-open** on every path (missing `python3` or `node`, any formatting error → exit 0, empty stdout — the turn is never blocked). The hook appends `<ts> [datetime-inject] injected tz=<zone> source=<graph|server-fallback> refresh=per-turn session=<id>` to `$LOG_DIR/datetime-inject.log` so "did the agent have the right time this turn, and from which clock?" is answerable from logs without reproduction.
|
|
285
|
-
- `hooks/mailbox-inject.sh` — **UserPromptSubmit hook.** Injects a `<mailboxes>` block as `additionalContext` on every turn (`refresh=per-turn`), naming every Outlook mailbox attached to this account and the plugin that holds it, so a session never learns attachment state by polling one mail plugin and reading that plugin's miss as an account-wide fact (the incident it exists for: a WhatsApp session called `email-status`, got the IMAP plugin's miss, and told a customer the account had no access to a mailbox the outlook plugin had read that morning). Reads `$ACCOUNT_DIR/secrets/outlook/` directly as a **read-only observer** — it never writes there, and never performs the rename `MailboxRegistry.migrateLegacy()` does. Signed-in mailboxes come from `mailboxes/<key>/tokens.enc` plus the pre-migration top-level `tokens.enc`, keeping only directories that actually contain a blob (the registry's own gate — the revocation path unlinks the blob and leaves the directory). Delegated mailboxes come from the plaintext `delegated.json`, keyed by normalised address so two keys differing only in case or space collapse to one line exactly as `DelegatedStore.read()` collapses them, with the delegator resolved against the **readable** set exactly as `MailboxRegistry.resolve()` does, so a delegated line never promises a mailbox every `outlook-*` call would refuse. A legacy blob whose `graphUserId` already has a `mailboxes/` directory is skipped rather than listed twice, mirroring the branch of `migrateLegacy()` that unlinks it. Every rendered subject is stripped of control characters and angle brackets and length-bounded, so the block's frame holds by construction rather than by the upstream invariants that currently keep them out. Three fields leave the decrypt — `mail`, `graphUserId`, `refreshTokenExpiry` — and **no token value is ever emitted, logged or written**; the `node` reader carries `2>/dev/null` and try/catches every read and parse, because node prints the entire parsed source text on an uncaught exception. **Fail-open** on every path. **Env asymmetry:** `ACCOUNT_DIR` is stamped unconditionally on the rc-spawn path (`buildRcChildEnv`) but only for admin and specialist spawns on the pty-spawner path, and `LOG_DIR` exists on the rc path only — so the hook resolves the account dir from `$PWD` when the stamp is absent (the spawn cwd is the account dir on both paths) and walks `$LOG_DIR` → `$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `<accountDir>/logs` for its breadcrumb. **Reach on upgrade:** the provision-time writer re-runs for the house account only, so `platform/
|
|
287
|
+
- `hooks/mailbox-inject.sh` — **UserPromptSubmit hook.** Injects a `<mailboxes>` block as `additionalContext` on every turn (`refresh=per-turn`), naming every Outlook mailbox attached to this account and the plugin that holds it, so a session never learns attachment state by polling one mail plugin and reading that plugin's miss as an account-wide fact (the incident it exists for: a WhatsApp session called `email-status`, got the IMAP plugin's miss, and told a customer the account had no access to a mailbox the outlook plugin had read that morning). Reads `$ACCOUNT_DIR/secrets/outlook/` directly as a **read-only observer** — it never writes there, and never performs the rename `MailboxRegistry.migrateLegacy()` does. Signed-in mailboxes come from `mailboxes/<key>/tokens.enc` plus the pre-migration top-level `tokens.enc`, keeping only directories that actually contain a blob (the registry's own gate — the revocation path unlinks the blob and leaves the directory). Delegated mailboxes come from the plaintext `delegated.json`, keyed by normalised address so two keys differing only in case or space collapse to one line exactly as `DelegatedStore.read()` collapses them, with the delegator resolved against the **readable** set exactly as `MailboxRegistry.resolve()` does, so a delegated line never promises a mailbox every `outlook-*` call would refuse. A legacy blob whose `graphUserId` already has a `mailboxes/` directory is skipped rather than listed twice, mirroring the branch of `migrateLegacy()` that unlinks it. Every rendered subject is stripped of control characters and angle brackets and length-bounded, so the block's frame holds by construction rather than by the upstream invariants that currently keep them out. Three fields leave the decrypt — `mail`, `graphUserId`, `refreshTokenExpiry` — and **no token value is ever emitted, logged or written**; the `node` reader carries `2>/dev/null` and try/catches every read and parse, because node prints the entire parsed source text on an uncaught exception. **Fail-open** on every path. **Env asymmetry:** `ACCOUNT_DIR` is stamped unconditionally on the rc-spawn path (`buildRcChildEnv`) but only for admin and specialist spawns on the pty-spawner path, and `LOG_DIR` exists on the rc path only — so the hook resolves the account dir from `$PWD` when the stamp is absent (the spawn cwd is the account dir on both paths) and walks `$LOG_DIR` → `$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `<accountDir>/logs` for its breadcrumb. **Reach on upgrade:** the provision-time writer re-runs for the house account only, so `reconcile_all_accounts_settings` converges every account against `platform/templates/account-settings.json` — which carries this `UserPromptSubmit` entry — from `setup-account.sh` on every install, one `[settings-reconcile] account=<id> status=…` line per account. (The four per-hook backfill libs this converge replaced have been retired.) The breadcrumb is `<ts> [mailbox-inject] injected account=<id> outlook=<n> delegated=<n> usable=<n> expired=<n> unreadable=<n> degraded=<yes|no> imap=not-read source=disk refresh=per-turn session=<id>` in `<logDir>/mailbox-inject.log`, with `op=skip reason=<no-account-dir|unreadable-source|reader-failed>` and `op=degrade reason=delegated-unreadable` on the degraded paths. `imap=not-read`, never `imap=0`: IMAP mailboxes are not surveyed at all by this hook, and a literal zero in a field shaped like the two measured counts would be read in a later incident review as a census. No address is written to the log, because `/api/admin/logs` serves every account's log dir to any LAN caller without auth. A missing `node` or `python3` exits 0 before the breadcrumb block, so a PATH regression produces **no line at all** rather than a `reason=no-node` line.
|
|
286
288
|
- `hooks/prompt-optimiser-compliance.sh` — **Stop hook.** After each admin turn, reads the just-finished turn from `transcript_path` and appends `<ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"` to `$LOG_DIR/prompt-optimiser-directive.log` (and stderr) when the routing directive fired, the prompt was non-trivial (not a slash-command, not a one-word confirmation), and the turn took **no route** — no `Agent` dispatch, no `Skill` load, no `ToolSearch`, no `mcp__*` tool call. This is the standing compliance signal that surfaces the session-`da0b12d4` failure class (agent answers a capability question from memory) as a visible event instead of a silent stale answer. Directive-fired is detected by the marker `PROMPT-OPTIMISER DIRECTIVE` in the turn slice, so it is robust to the CC-version difference in how `UserPromptSubmit` `additionalContext` is recorded (`attachment`/`hook_success` vs `hook_additional_context`). **Known limitation:** "direct continuation of the prior turn" is not detectable from the transcript, so a continuation turn that legitimately needs no route can be flagged; treat the log as a review signal, not a gate. **Fail-open:** no python3, no `transcript_path`, or an unreadable transcript → exit 0, no output. Lives in the same log as the directive breadcrumb, so a single `grep` interleaves "fired" and "no-route" into one per-session timeline; cross-check via `platform/scripts/logs-read.sh <sessionKey>`. This is a lightweight transcript read, not a per-turn spawn (contrast the turn recorder below).
|
|
287
289
|
- **Turn recorder — removed entirely.** The `turn-completed-graph-write.sh` Stop hook, the `/api/admin/claude-sessions` loopback bypass it relied on, the `[turn-recorder]` emitters, the envelope walker, and the recorder-auto-archive subscriber are deleted. It had been dormant for a long time (never re-registered in settings.json); the admin now writes to the graph by delegating to `database-operator` via the Task tool inside the live session, and the on-demand `/insight` pass (`skills/insight/SKILL.md`, a registered admin skill) is the per-session review. There is no per-turn spawn.
|
|
288
290
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for pdf-text-layer-inject.sh.
|
|
3
|
+
#
|
|
4
|
+
# Read on a PDF returns page images and no text, so a value copied out of a PDF
|
|
5
|
+
# is read off a picture. This hook puts the PDF's own text layer on stderr so
|
|
6
|
+
# the characters are in context alongside the images.
|
|
7
|
+
#
|
|
8
|
+
# Covers:
|
|
9
|
+
# A. a text-layer PDF → exit 2, text on stderr, injected=yes
|
|
10
|
+
# B. the injected text is the real content → stderr carries the postcode
|
|
11
|
+
# C. a PDF with no text layer → exit 0, silent, scanned-no-text
|
|
12
|
+
# D. a non-PDF Read → exit 0, NO log line at all
|
|
13
|
+
# E. a path that does not exist → exit 0, unreadable
|
|
14
|
+
# F. an uppercase .PDF extension → exit 2 (match is case-insensitive)
|
|
15
|
+
# G. a PreToolUse envelope → exit 0, no log line
|
|
16
|
+
# H. a non-Read tool → exit 0, no log line
|
|
17
|
+
# I. empty stdin → exit 0 (fail-open)
|
|
18
|
+
# J. malformed JSON → exit 0 (fail-open)
|
|
19
|
+
# K. an extraction over the cap → exit 2, payload capped, omission line
|
|
20
|
+
# L. pdftotext absent from PATH → exit 0, no-poppler
|
|
21
|
+
# M. the log line names the account → per-account counting is possible
|
|
22
|
+
|
|
23
|
+
set -u
|
|
24
|
+
|
|
25
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/pdf-text-layer-inject.sh"
|
|
26
|
+
if [[ ! -f "$HOOK" ]]; then
|
|
27
|
+
echo "FAIL: $HOOK not found" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
PASS=0
|
|
32
|
+
FAIL=0
|
|
33
|
+
TMP="$(mktemp -d)"
|
|
34
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
35
|
+
LOGD="$TMP/logs"
|
|
36
|
+
mkdir -p "$LOGD"
|
|
37
|
+
LOGFILE="$LOGD/pdf-text-layer.log"
|
|
38
|
+
|
|
39
|
+
# --- fixtures ---------------------------------------------------------------
|
|
40
|
+
# Hand-built PDFs: no external generator, so the test is deterministic on any
|
|
41
|
+
# box that has pdftotext. An empty content stream is the no-text-layer case.
|
|
42
|
+
mkpdf() { # <path> <text>
|
|
43
|
+
python3 - "$1" "$2" <<'PY'
|
|
44
|
+
import sys
|
|
45
|
+
path, text = sys.argv[1], sys.argv[2]
|
|
46
|
+
stream = (b"BT /F1 12 Tf 72 700 Td (" + text.encode() + b") Tj ET") if text else b""
|
|
47
|
+
objs = [
|
|
48
|
+
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
49
|
+
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
50
|
+
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R "
|
|
51
|
+
b"/Resources << /Font << /F1 5 0 R >> >> >>",
|
|
52
|
+
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
|
|
53
|
+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
54
|
+
]
|
|
55
|
+
out = b"%PDF-1.4\n"
|
|
56
|
+
offs = []
|
|
57
|
+
for i, o in enumerate(objs, 1):
|
|
58
|
+
offs.append(len(out))
|
|
59
|
+
out += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
|
|
60
|
+
xref = len(out)
|
|
61
|
+
out += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
|
|
62
|
+
for off in offs:
|
|
63
|
+
out += ("%010d 00000 n \n" % off).encode()
|
|
64
|
+
out += (b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\n"
|
|
65
|
+
b"startxref\n" + str(xref).encode() + b"\n%%EOF\n")
|
|
66
|
+
open(path, "wb").write(out)
|
|
67
|
+
PY
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
mkpdf "$TMP/text.pdf" "Address: 20 Station Road, Dunmow, CM6 1EJ"
|
|
71
|
+
mkpdf "$TMP/scan.pdf" ""
|
|
72
|
+
mkpdf "$TMP/UPPER.PDF" "Address: 20 Station Road, Dunmow, CM6 1EJ"
|
|
73
|
+
mkpdf "$TMP/big.pdf" "$(python3 -c 'print("A"*60)')"
|
|
74
|
+
printf 'not a pdf\n' > "$TMP/notes.txt"
|
|
75
|
+
|
|
76
|
+
envelope() { # <event> <tool> <file_path>
|
|
77
|
+
python3 -c '
|
|
78
|
+
import json, sys
|
|
79
|
+
print(json.dumps({
|
|
80
|
+
"hook_event_name": sys.argv[1],
|
|
81
|
+
"tool_name": sys.argv[2],
|
|
82
|
+
"tool_input": {"file_path": sys.argv[3]},
|
|
83
|
+
"session_id": "test-session",
|
|
84
|
+
}))' "$1" "$2" "$3"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
run() { # <name> <stdin> <expected_exit> → sets ERR (stderr) and RC
|
|
88
|
+
local name="$1" stdin="$2" want="$3"
|
|
89
|
+
ERR="$(printf '%s' "$stdin" | bash "$HOOK" 2>&1 1>/dev/null)"
|
|
90
|
+
RC=$?
|
|
91
|
+
if [[ "$RC" -eq "$want" ]]; then
|
|
92
|
+
echo "PASS: $name (exit=$RC)"
|
|
93
|
+
PASS=$((PASS + 1))
|
|
94
|
+
else
|
|
95
|
+
echo "FAIL: $name (expected exit=$want, got=$RC)" >&2
|
|
96
|
+
FAIL=$((FAIL + 1))
|
|
97
|
+
fi
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
check() { # <name> <condition-result 0|1>
|
|
101
|
+
if [[ "$2" -eq 0 ]]; then
|
|
102
|
+
echo "PASS: $1"
|
|
103
|
+
PASS=$((PASS + 1))
|
|
104
|
+
else
|
|
105
|
+
echo "FAIL: $1" >&2
|
|
106
|
+
FAIL=$((FAIL + 1))
|
|
107
|
+
fi
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export LOG_DIR="$LOGD"
|
|
111
|
+
|
|
112
|
+
# A + B: a text-layer PDF injects, and the injected text is the real content.
|
|
113
|
+
run "A: a text-layer PDF → exit 2" "$(envelope PostToolUse Read "$TMP/text.pdf")" 2
|
|
114
|
+
grep -q "CM6 1EJ" <<<"$ERR"; check "B: stderr carries the postcode from the text layer" $?
|
|
115
|
+
grep -q "file=text.pdf .*injected=yes reason=text-layer" "$LOGFILE"; check "B2: log line records injected=yes" $?
|
|
116
|
+
|
|
117
|
+
# C: no text layer → silent, but still logged.
|
|
118
|
+
: > "$LOGFILE"
|
|
119
|
+
run "C: a PDF with no text layer → exit 0" "$(envelope PostToolUse Read "$TMP/scan.pdf")" 0
|
|
120
|
+
[[ -z "$ERR" ]]; check "C2: no text layer emits nothing on stderr" $?
|
|
121
|
+
grep -q "file=scan.pdf chars=0 injected=no reason=scanned-no-text" "$LOGFILE"; check "C3: log line records scanned-no-text" $?
|
|
122
|
+
|
|
123
|
+
# D: a non-PDF Read is not this hook's business and writes no line.
|
|
124
|
+
: > "$LOGFILE"
|
|
125
|
+
run "D: a non-PDF Read → exit 0" "$(envelope PostToolUse Read "$TMP/notes.txt")" 0
|
|
126
|
+
[[ ! -s "$LOGFILE" ]]; check "D2: a non-PDF Read writes no log line" $?
|
|
127
|
+
|
|
128
|
+
# E: an absent path.
|
|
129
|
+
: > "$LOGFILE"
|
|
130
|
+
run "E: a path that does not exist → exit 0" "$(envelope PostToolUse Read "$TMP/missing.pdf")" 0
|
|
131
|
+
grep -q "file=missing.pdf chars=0 injected=no reason=unreadable" "$LOGFILE"; check "E2: log line records unreadable" $?
|
|
132
|
+
|
|
133
|
+
# F: extension match is case-insensitive.
|
|
134
|
+
run "F: an uppercase .PDF → exit 2" "$(envelope PostToolUse Read "$TMP/UPPER.PDF")" 2
|
|
135
|
+
|
|
136
|
+
# G + H: wrong event, wrong tool.
|
|
137
|
+
: > "$LOGFILE"
|
|
138
|
+
run "G: a PreToolUse envelope → exit 0" "$(envelope PreToolUse Read "$TMP/text.pdf")" 0
|
|
139
|
+
run "H: a non-Read tool → exit 0" "$(envelope PostToolUse Write "$TMP/text.pdf")" 0
|
|
140
|
+
[[ ! -s "$LOGFILE" ]]; check "H2: neither writes a log line" $?
|
|
141
|
+
|
|
142
|
+
# I + J: fail-open on unusable input.
|
|
143
|
+
run "I: empty stdin → exit 0" "" 0
|
|
144
|
+
run "J: malformed JSON → exit 0" "{not json" 0
|
|
145
|
+
|
|
146
|
+
# K: the cap. CAP is lowered by env so the test does not build a 100KB PDF.
|
|
147
|
+
: > "$LOGFILE"
|
|
148
|
+
ERR="$(printf '%s' "$(envelope PostToolUse Read "$TMP/big.pdf")" \
|
|
149
|
+
| PDF_TEXT_LAYER_CAP=10 bash "$HOOK" 2>&1 1>/dev/null)"
|
|
150
|
+
RC=$?
|
|
151
|
+
[[ "$RC" -eq 2 ]]; check "K: an extraction over the cap → exit 2" $?
|
|
152
|
+
grep -q "characters omitted" <<<"$ERR"; check "K2: the omission line names the shortfall" $?
|
|
153
|
+
grep -q "pdftotext $TMP/big.pdf -" <<<"$ERR"; check "K3: the omission line gives the command for the rest" $?
|
|
154
|
+
|
|
155
|
+
# L: pdftotext missing. A PATH holding only what the hook itself needs — and no
|
|
156
|
+
# pdftotext — reproduces a box that never installed poppler-utils, without
|
|
157
|
+
# depending on which directory pdftotext lives in on this host.
|
|
158
|
+
: > "$LOGFILE"
|
|
159
|
+
mkdir -p "$TMP/bin"
|
|
160
|
+
for b in python3 date cat; do ln -sf "$(command -v "$b")" "$TMP/bin/$b"; done
|
|
161
|
+
ERR="$(printf '%s' "$(envelope PostToolUse Read "$TMP/text.pdf")" \
|
|
162
|
+
| PATH="$TMP/bin" "$BASH" "$HOOK" 2>&1 1>/dev/null)"
|
|
163
|
+
RC=$?
|
|
164
|
+
[[ "$RC" -eq 0 ]]; check "L: pdftotext absent → exit 0" $?
|
|
165
|
+
grep -q "file=text.pdf chars=0 injected=no reason=no-poppler" "$LOGFILE"; check "L2: log line records no-poppler" $?
|
|
166
|
+
|
|
167
|
+
# M: the log carries the account. The log directory is install-wide, shared by
|
|
168
|
+
# every account on the box, so a line without an account cannot be counted per
|
|
169
|
+
# account — which is the whole point of the count.
|
|
170
|
+
: > "$LOGFILE"
|
|
171
|
+
printf '%s' "$(envelope PostToolUse Read "$TMP/text.pdf")" \
|
|
172
|
+
| ACCOUNT_ID=acct-under-test bash "$HOOK" >/dev/null 2>/dev/null
|
|
173
|
+
grep -q "account=acct-under-test" "$LOGFILE"; check "M: the log line names the account" $?
|
|
174
|
+
|
|
175
|
+
echo "──────── pdf-text-layer-inject test summary ────────"
|
|
176
|
+
echo "PASS=$PASS FAIL=$FAIL"
|
|
177
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -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 ]]
|