@rubytech/create-maxy-code 0.1.491 → 0.1.493

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 (73) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +46 -0
  3. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  4. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.d.ts +2 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.d.ts.map +1 -0
  6. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.js +135 -0
  7. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.js.map +1 -0
  8. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +7 -1
  9. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.js +26 -14
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/pages-output-dir.d.ts +19 -0
  13. package/payload/platform/lib/storage-broker/dist/pages-output-dir.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/pages-output-dir.js +185 -0
  15. package/payload/platform/lib/storage-broker/dist/pages-output-dir.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +65 -0
  17. package/payload/platform/lib/storage-broker/src/__tests__/pages-output-dir.test.ts +167 -0
  18. package/payload/platform/lib/storage-broker/src/cf-exec.ts +40 -16
  19. package/payload/platform/lib/storage-broker/src/pages-output-dir.ts +194 -0
  20. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  21. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
  22. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
  23. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
  24. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +13 -1
  25. package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
  26. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
  29. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +8 -4
  30. package/payload/platform/plugins/cloudflare/references/hosting-sites.md +1 -1
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +4 -4
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
  34. package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
  35. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
  37. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
  39. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  41. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
  43. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
  45. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
  46. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
  47. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
  48. package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
  49. package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
  50. package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
  51. package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
  52. package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
  53. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
  54. package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
  55. package/payload/platform/templates/specialists/agents/data-manager.md +1 -0
  56. package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
  57. package/payload/platform/templates/specialists/agents/librarian.md +1 -0
  58. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
  59. package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
  60. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
  61. package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
  62. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
  63. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +3 -0
  64. package/payload/server/{chunk-KZFE3MOY.js → chunk-RPUTKSAJ.js} +134 -16
  65. package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
  66. package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
  67. package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
  68. package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
  69. package/payload/server/public/chat.html +2 -2
  70. package/payload/server/public/operator.html +2 -2
  71. package/payload/server/public/public.html +2 -2
  72. package/payload/server/server.js +40 -31
  73. package/payload/server/{src-UYDFMWF4.js → src-JN6PIAJN.js} +1 -1
@@ -0,0 +1,194 @@
1
+ // Where a Pages deploy's assets actually start. Task 1917 — `wrangler pages
2
+ // deploy <dir>` treats the positional argument as the asset root and overrides
3
+ // `pages_build_output_dir`, so handing it the site root publishes a declared
4
+ // output dir one level too deep and the served root 404s. Measured on glsmith
5
+ // 2026-07-22: two trees with identical shape and config, one deployed by house
6
+ // wrangler (document at the root, 200) and one by this broker (document
7
+ // reachable only under /public/, root 404).
8
+ //
9
+ // Only the one key is interpreted, in any of the three config formats. This is
10
+ // deliberately not a wrangler config parser: everything else in the file is
11
+ // wrangler's business and stays wrangler's business.
12
+
13
+ import { lstat, readFile, realpath } from "node:fs/promises";
14
+ import { isAbsolute, join, relative, resolve } from "node:path";
15
+
16
+ // The filenames `wrangler pages deploy` discovers as its own configuration, in
17
+ // wrangler's own precedence order (wrangler 4.112.0, wrangler-dist/cli.js:3189
18
+ // — json, then jsonc, then toml). The order is load-bearing twice: it decides
19
+ // which config declares the output dir here, and Task 1782 drops all three from
20
+ // the upload so a config is never served publicly at /wrangler.toml, where it
21
+ // would disclose the D1 database id and R2 bucket name.
22
+ export const WRANGLER_CONFIG_NAMES = [
23
+ "wrangler.json",
24
+ "wrangler.jsonc",
25
+ "wrangler.toml",
26
+ ] as const;
27
+
28
+ export type UploadDirSource = (typeof WRANGLER_CONFIG_NAMES)[number] | "site-root";
29
+
30
+ export type UploadDirResolution = {
31
+ /** Absolute path to stage and hand wrangler as the asset root. */
32
+ uploadDir: string;
33
+ /** Which input decided it, so the caller logs the reason and not the value alone. */
34
+ source: UploadDirSource;
35
+ /** The verbatim declared value, present only when a config decided it. */
36
+ declared?: string;
37
+ };
38
+
39
+ const KEY = "pages_build_output_dir";
40
+
41
+ /** Strips line comments and block comments that sit outside string literals.
42
+ * JSON has no comments and jsonc does, so a jsonc file is de-commented before
43
+ * JSON.parse. Scanning character-wise rather than by regex is what keeps a
44
+ * `//` inside a value (a path like "a//b") from being eaten as a comment. */
45
+ function stripJsonComments(text: string): string {
46
+ let out = "";
47
+ let inString = false;
48
+ let escaped = false;
49
+ for (let i = 0; i < text.length; i++) {
50
+ const c = text[i];
51
+ if (inString) {
52
+ out += c;
53
+ if (escaped) escaped = false;
54
+ else if (c === "\\") escaped = true;
55
+ else if (c === '"') inString = false;
56
+ continue;
57
+ }
58
+ if (c === '"') {
59
+ inString = true;
60
+ out += c;
61
+ continue;
62
+ }
63
+ if (c === "/" && text[i + 1] === "/") {
64
+ while (i < text.length && text[i] !== "\n") i++;
65
+ out += "\n";
66
+ continue;
67
+ }
68
+ if (c === "/" && text[i + 1] === "*") {
69
+ i += 2;
70
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
71
+ i++;
72
+ continue;
73
+ }
74
+ out += c;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Reads the one key from a TOML file's top level. Top level means before the
80
+ * first `[section]` header: a key under `[env.preview]` is that environment's,
81
+ * not the deploy's, and reading it would publish the wrong root. Nothing else
82
+ * in the file is interpreted. */
83
+ function readTomlKey(text: string): string | undefined {
84
+ for (const raw of text.split("\n")) {
85
+ const line = raw.trim();
86
+ if (line === "" || line.startsWith("#")) continue;
87
+ if (line.startsWith("[")) return undefined;
88
+ const m = /^pages_build_output_dir\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$/.exec(line);
89
+ if (m) return m[1] ?? m[2];
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** Reads the one key from a JSON or JSONC file's top level. A present key whose
95
+ * value is not a string is an error rather than an ignore: the tree declared an
96
+ * output dir, and publishing its parent instead is the defect this closes. */
97
+ function readJsonKey(text: string, name: string, jsonc: boolean): string | undefined {
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(jsonc ? stripJsonComments(text) : text);
101
+ } catch (err) {
102
+ throw new Error(`storage-broker: ${name} could not be parsed: ${String(err)}`);
103
+ }
104
+ if (typeof parsed !== "object" || parsed === null) {
105
+ throw new Error(`storage-broker: ${name} is not an object`);
106
+ }
107
+ const value = (parsed as Record<string, unknown>)[KEY];
108
+ if (value === undefined) return undefined;
109
+ if (typeof value !== "string") {
110
+ throw new Error(`storage-broker: ${name} declares a non-string ${KEY}`);
111
+ }
112
+ return value;
113
+ }
114
+
115
+ /** True when `child` is not `root` or a descendant of it. */
116
+ function escapes(root: string, child: string): boolean {
117
+ const rel = relative(root, child);
118
+ return rel.startsWith("..") || isAbsolute(rel);
119
+ }
120
+
121
+ function outsideError(name: string, declared: string, resolved: string, root: string): Error {
122
+ return new Error(
123
+ `storage-broker: ${name} declares ${KEY}="${declared}", which resolves to ${resolved}, outside the site root ${root}`,
124
+ );
125
+ }
126
+
127
+ /** Resolves the directory whose contents become the served root for `dir`.
128
+ *
129
+ * A tree that declares nothing keeps the site root, which is what every deploy
130
+ * did before Task 1917. A tree that declares an output dir gets that dir. A
131
+ * tree that declares one which cannot be resolved is an error naming the path:
132
+ * falling back to the site root here is exactly the defect this closes, because
133
+ * the deploy then succeeds while serving the wrong root. */
134
+ export async function resolveUploadDir(dir: string): Promise<UploadDirResolution> {
135
+ for (const name of WRANGLER_CONFIG_NAMES) {
136
+ let text: string;
137
+ try {
138
+ text = await readFile(join(dir, name), "utf8");
139
+ } catch {
140
+ continue; // absent, or not readable as a file: the next name is wrangler's next choice
141
+ }
142
+ const declared =
143
+ name === "wrangler.toml"
144
+ ? readTomlKey(text)
145
+ : readJsonKey(text, name, name === "wrangler.jsonc");
146
+ // A config that declares nothing leaves the site root as the asset root,
147
+ // which is wrangler's own behaviour for a Pages project with no output dir.
148
+ if (declared === undefined) return { uploadDir: dir, source: "site-root" };
149
+ const uploadDir = resolve(dir, declared);
150
+ // Confinement, lexically first. A declared value is file content, and
151
+ // resolving file content into a publish path through the route that holds
152
+ // the account-wide Cloudflare credential is the moment it has to be
153
+ // bounded. This pass costs no filesystem call and rejects the plain
154
+ // `"../.."` before anything is touched.
155
+ if (escapes(dir, uploadDir)) throw outsideError(name, declared, uploadDir, dir);
156
+ // lstat, not stat: the link itself is what matters here, and stat would
157
+ // report a link to a directory as a directory.
158
+ let info;
159
+ try {
160
+ info = await lstat(uploadDir);
161
+ } catch {
162
+ throw new Error(
163
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} does not exist`,
164
+ );
165
+ }
166
+ // A link is refused wherever it points. The staging copy takes the upload
167
+ // directory as its source and does not dereference, so a link source fails
168
+ // the copy outright; and a target that can change between this check and
169
+ // that copy is not something to publish from a route holding the
170
+ // account-wide credential.
171
+ if (info.isSymbolicLink()) {
172
+ throw new Error(
173
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is a symbolic link; the published root must be a real directory`,
174
+ );
175
+ }
176
+ if (!info.isDirectory()) {
177
+ throw new Error(
178
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is not a directory`,
179
+ );
180
+ }
181
+ // Confinement again, on the real paths. The lexical pass above is blind to
182
+ // symlinks in the declared value's own parent segments: a declared "a/b"
183
+ // whose `a` links out of the tree stays inside by string comparison while
184
+ // resolving anywhere on disk, and the deploy would then publish whatever it
185
+ // resolves to. Both sides are resolved because the site root itself commonly
186
+ // arrives through a symlinked prefix (macOS /var, a linked account
187
+ // directory), which would otherwise read as an escape on its own.
188
+ const realDir = await realpath(dir);
189
+ const realUpload = await realpath(uploadDir);
190
+ if (escapes(realDir, realUpload)) throw outsideError(name, declared, realUpload, realDir);
191
+ return { uploadDir, source: name, declared };
192
+ }
193
+ return { uploadDir: dir, source: "site-root" };
194
+ }
@@ -188,6 +188,7 @@ Tools are available via the `admin` MCP server.
188
188
  ## Hooks
189
189
 
190
190
  - `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.
191
+ - `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.
191
192
  - `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.
192
193
  - `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.
193
194
  - `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 channel reply tool 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,99 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for quote-render-pdf-conformance.sh.
3
+ #
4
+ # PostToolUse hook on browser-pdf-save. For a gated client-quote PDF whose render
5
+ # receipt names the source HTML, it blocks when the PDF dropped links (its /URI
6
+ # count is below the HTML's absolute-link count) or when an embedded https link
7
+ # is not live. Everything else passes; it fails open on anything uninspectable.
8
+ #
9
+ # Covers (all offline-deterministic — the one network case points at a closed
10
+ # local port so the connection refusal is immediate and reproducible):
11
+ # A. non-quote PDF → ALLOWED (not gated)
12
+ # B. quote PDF, no receipt → ALLOWED (PreToolUse owns that block)
13
+ # C. quote PDF, HTML has no absolute links → ALLOWED (0 >= 0, no fetch)
14
+ # D. quote PDF, PDF /URI < HTML links → BLOCKED (link-loss, no fetch)
15
+ # E. quote PDF, /URI ok but an https link is dead → BLOCKED (link not live)
16
+ # F. quote PDF, /URI ok and the only link is 200 → not asserted (needs a live server)
17
+ # G. empty stdin → ALLOWED (fail-open)
18
+ # H. non-browser tool → ALLOWED (matcher defence-in-depth)
19
+ # I. a block emits the op=bypass fault line
20
+
21
+ set -u
22
+
23
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-render-pdf-conformance.sh"
24
+ if [[ ! -x "$HOOK" ]]; then
25
+ echo "FAIL: $HOOK not executable" >&2
26
+ exit 1
27
+ fi
28
+
29
+ ACCT=$(mktemp -d)
30
+ cleanup() { rm -rf "$ACCT"; }
31
+ trap cleanup EXIT
32
+
33
+ DOCS="$ACCT/memory/users/44700900000/documents"
34
+ mkdir -p "$DOCS"
35
+
36
+ # Receipt + filed HTML for each scenario job. The receipt names the filed quote
37
+ # HTML; the hook reads its absolute links and compares to the PDF's /URI count.
38
+ mk_job() { # jobId, html_body, pdf_uri_repeat
39
+ local job="$1" body="$2" uris="$3"
40
+ mkdir -p "$ACCT/quoting/jobs/$job"
41
+ local html="$DOCS/quote-$job.html"
42
+ printf '%s' "$body" > "$html"
43
+ printf '{"jobId":"%s","filed":{"internal":"x","breakdown":"x","quote":"%s"}}' "$job" "$html" \
44
+ > "$ACCT/quoting/jobs/$job/render-receipt.json"
45
+ # A minimal PDF-ish file carrying `uris` copies of the /URI token.
46
+ { printf '%%PDF-1.4\n'; local i=0; while [[ $i -lt $uris ]]; do printf '/URI (x)\n'; i=$((i+1)); done; } \
47
+ > "$DOCS/quote-$job.pdf"
48
+ }
49
+
50
+ # C: no absolute links, PDF has none → allow.
51
+ mk_job "clean" '<html><body><a href="#top">top</a><p>no links</p></body></html>' 0
52
+ # D: two https links, PDF dropped them (0 /URI) → block on count.
53
+ mk_job "dropped" '<html><body><a href="https://a.example/1">a</a><a href="https://b.example/2">b</a></body></html>' 0
54
+ # E: one https link, PDF kept a /URI, but the link points at a closed local port → block on liveness.
55
+ mk_job "dead" '<html><body><a href="https://127.0.0.1:1/never">sign</a></body></html>' 1
56
+
57
+ PASS=0
58
+ FAIL=0
59
+
60
+ run_case() {
61
+ local name="$1" stdin="$2" expected_exit="$3" actual_exit
62
+ ( cd "$ACCT" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
63
+ actual_exit=$?
64
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
65
+ echo "PASS: $name (exit=$actual_exit)"; PASS=$((PASS + 1))
66
+ else
67
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2; FAIL=$((FAIL + 1))
68
+ fi
69
+ }
70
+
71
+ envelope() { printf '{"hook_event_name":"PostToolUse","tool_name":"%s","tool_input":{"path":"%s"}}' "$1" "$2"; }
72
+ BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
73
+
74
+ run_case "A: non-quote PDF → ALLOWED" \
75
+ "$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
76
+ run_case "B: quote PDF, no receipt → ALLOWED (PreToolUse owns it)" \
77
+ "$(envelope "$BROWSER" "$DOCS/quote-nojob.pdf")" 0
78
+ run_case "C: HTML has no absolute links → ALLOWED" \
79
+ "$(envelope "$BROWSER" "$DOCS/quote-clean.pdf")" 0
80
+ run_case "D: PDF /URI below HTML link count → BLOCKED" \
81
+ "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" 2
82
+ run_case "E: an embedded https link is not live → BLOCKED" \
83
+ "$(envelope "$BROWSER" "$DOCS/quote-dead.pdf")" 2
84
+ run_case "G: empty stdin → ALLOWED (fail-open)" \
85
+ "" 0
86
+ run_case "H: non-browser tool → ALLOWED" \
87
+ '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"path":"'"$DOCS"'/quote-dropped.pdf"}}' 0
88
+
89
+ # I: a block emits the op=bypass fault line on stderr.
90
+ STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
91
+ if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=dropped reason=pdf-link-conformance'; then
92
+ echo "PASS: I: block emits op=bypass jobId=dropped"; PASS=$((PASS + 1))
93
+ else
94
+ echo "FAIL: I: op=bypass line absent (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
95
+ fi
96
+
97
+ echo "──────── quote-render-pdf-conformance test summary ────────"
98
+ echo "PASS=$PASS FAIL=$FAIL"
99
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # quote-render-pdf-conformance — PostToolUse gate on browser-pdf-save.
3
+ #
4
+ # The companion to the PreToolUse quote-render-gate: that gate checks the
5
+ # rendered HTML on the deterministic path (via the render receipt) before the
6
+ # PDF is printed; the HTML the tool gated cannot yet be a PDF at PreToolUse, so
7
+ # the two PDF-only conformance checks run here, once the PDF exists.
8
+ #
9
+ # For a gated client-quote PDF (memory/users/<phone>/documents/quote-<jobId>.pdf
10
+ # or ...-breakdown.pdf) whose render receipt names the source HTML, it asserts:
11
+ # 1. the PDF's /URI annotation count >= the source HTML's absolute-link count
12
+ # (a print that dropped links — a rasterised or flattened copy — carries
13
+ # fewer /URI than the HTML had anchors);
14
+ # 2. every embedded https:// anchor in the source HTML answers 200 (a pending
15
+ # redeploy that would 404 the Sign-Online link is caught before the PDF is
16
+ # sent).
17
+ # The /URI-count check is offline and runs first; the network fetch runs only
18
+ # when the count check passes, so a link-loss regression never depends on the
19
+ # network being up.
20
+ #
21
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on any
22
+ # uninspectable call (tty / empty stdin / no python3 / no receipt / PDF not
23
+ # written): the no-receipt case is already the PreToolUse gate's to block, and
24
+ # browser-pdf-save is general-purpose, so failing open here bricks no unrelated
25
+ # PDF. No task numbers or internal refs in any operator-visible string.
26
+
27
+ set -uo pipefail
28
+
29
+ if [ -t 0 ]; then exit 0; fi
30
+ INPUT=$(cat)
31
+ [ -z "$INPUT" ] && exit 0
32
+
33
+ RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
34
+ import os, json, re, sys, urllib.request
35
+
36
+ try:
37
+ d = json.loads(os.environ["INPUT"])
38
+ except Exception:
39
+ sys.exit(0) # uninspectable envelope -> fail open
40
+
41
+ tool = d.get("tool_name", "") or ""
42
+ if not tool.endswith("browser-pdf-save"):
43
+ sys.exit(0)
44
+ path = (d.get("tool_input", {}) or {}).get("path", "") or ""
45
+ if not path:
46
+ sys.exit(0)
47
+ norm = os.path.normpath(path)
48
+ m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$', norm)
49
+ if not m:
50
+ sys.exit(0) # not a gated client-quote artefact
51
+ jobid = m.group(1)
52
+ if "/" in jobid or ".." in jobid:
53
+ sys.exit(0) # the PreToolUse gate owns bad-jobid; here fail open
54
+ variant = m.group(2) # "-breakdown" or None
55
+
56
+ base = os.environ["BASE"]
57
+ receipt_path = os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json")
58
+ try:
59
+ receipt = json.load(open(receipt_path))
60
+ except Exception:
61
+ sys.exit(0) # no receipt -> PreToolUse gate already blocks; fail open
62
+
63
+ filed = receipt.get("filed", {}) or {}
64
+ html_path = filed.get("breakdown" if variant else "quote", "")
65
+ if not html_path or not os.path.isfile(html_path) or not os.path.isfile(path):
66
+ sys.exit(0) # nothing to compare against -> fail open
67
+
68
+ html = open(html_path, encoding="utf-8", errors="replace").read()
69
+ # Anchors that become a /URI annotation in the printed PDF: absolute http(s) or
70
+ # mailto hrefs. Fragment and relative anchors do not, so they are not counted.
71
+ uri_anchors = re.findall(r'href="((?:https?|mailto):[^"]*)"', html, re.I)
72
+ https_anchors = [u for u in uri_anchors if u.lower().startswith("https:")]
73
+
74
+ # /URI occurrences in the printed PDF. This is the same primitive the e-sign
75
+ # stamp gate uses (grep -a -c '/URI'); on real browser-pdf-save output it was
76
+ # measured to carry one greppable /URI per surviving link (base PDFs counted 4
77
+ # to 6, one per anchor), so the count is a faithful per-link tally, not stream-
78
+ # compressed away. A rasterised or flattened reprint drops to a lower count.
79
+ uri_count = open(path, "rb").read().count(b"/URI")
80
+ if uri_count < len(uri_anchors):
81
+ print("BLOCK|%s|uri-count pdf=%d html_links=%d" % (jobid, uri_count, len(uri_anchors)))
82
+ sys.exit(0)
83
+
84
+ for u in https_anchors:
85
+ try:
86
+ with urllib.request.urlopen(u, timeout=6) as r:
87
+ code = r.status
88
+ except Exception as e:
89
+ code = getattr(e, "code", 0) or 0
90
+ if code != 200:
91
+ print("BLOCK|%s|link %s -> %s" % (jobid, u, code))
92
+ sys.exit(0)
93
+
94
+ sys.exit(0)
95
+ PY
96
+ )
97
+
98
+ case "$RESULT" in
99
+ BLOCK\|*)
100
+ rest=${RESULT#BLOCK|}
101
+ jobid=${rest%%|*}
102
+ detail=${rest#*|}
103
+ echo "[quote-render] op=bypass jobId=$jobid reason=pdf-link-conformance detail=$detail" >&2
104
+ echo "Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live ($detail). 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." >&2
105
+ exit 2
106
+ ;;
107
+ esac
108
+ exit 0
@@ -110,12 +110,24 @@ Two further clauses bind the Output contract itself. **Recency**, universal: a t
110
110
  claim carries the date it was observed, and a date the agent cannot see is one it flags, never
111
111
  one it infers. **Corroboration**, for agents whose deliverable is a pattern, a theme, a trend, a
112
112
  signal: the agent names the independent sources behind it and does not report one as established
113
- on a single occurrence. Corroboration is authoring guidance enforced by review, not by the gate,
114
- because the subset it applies to cannot be detected statically.
115
-
116
- `agent-grounding-drift.test.ts` fails if any shipped prompt carries either sentence anything
117
- other than exactly once, and fails if the number of prompts it inspected is not the number it
118
- expects. A new agent that skips them does not ship.
113
+ on a single occurrence.
114
+
115
+ Corroboration binds only that subset, and the subset cannot be read out of a prompt's text, so
116
+ every prompt declares it. The flat specialists carry `pattern_deliverable: true` or
117
+ `pattern_deliverable: false` as a frontmatter key; the role `IDENTITY.md` files, which have no
118
+ frontmatter and are injected raw, carry the same marker as a trailing HTML comment
119
+ `<!-- pattern_deliverable: true -->`. Absent is a gate failure, not a default. A prompt declaring
120
+ true carries this sentence, character for character, as a third paragraph of its `## Grounding`
121
+ section; a prompt declaring false does not carry it at all:
122
+
123
+ > Where what you report is a pattern, a theme, a trend, or a signal, name the independent occurrences behind it; one occurrence is a single observation you report as such, never a pattern you assert.
124
+
125
+ `agent-grounding-drift.test.ts` fails if any shipped prompt carries the provenance or
126
+ person-attribute sentence anything other than exactly once, if a prompt does not declare
127
+ `pattern_deliverable` exactly once, if a true-declaring prompt carries the corroboration sentence
128
+ anything other than exactly once, if a false-declaring prompt carries it at all, and if the
129
+ number of prompts it inspected is not the number it expects. The marker and the sentence cannot
130
+ drift apart, and a new agent that skips any of the three does not ship.
119
131
 
120
132
  ## Skills owned
121
133
 
@@ -92,6 +92,8 @@ Per-signer stamped copies (§ 6) are written alongside, in the same `<DOC_REF>`
92
92
 
93
93
  The § 1 default — an unguessable hex slug plus `noindex` — keeps a low-sensitivity document private by obscurity. For a **confidential** proposal to a **named** recipient, add a **Cloudflare Access** gate in front of the page so only allowlisted emails can load it at all. Gating is **additive**: the slug and `noindex` stay; the gate sits in front of them. It is never the default and never replaces them.
94
94
 
95
+ > **Client account:** Access gating is the house's to apply. The broker offers no Access tool, and `cf-token.sh access` denies a client account that scope with the same `reason=not-house` it denies `pages`, so mint nothing here and run none of the calls below. Instead **request the gate**, handing the house three things: the **Pages project** carrying the document, the **document path or paths** to gate, and the **signer emails** (plus the business owner's) for the policy `include`. Say in the same request that **`/api/*` on that project is gated alongside those paths**, because gating the document path alone leaves `POST /api/accept` forgeable and `GET /api/status` world-readable, the bypass this section describes below. Never ask the client or the signer for a Cloudflare token (the callout that precedes § 0). Everything that follows (the One-Time PIN pre-flight, the app and policy calls, the `gate-probe`, and the neutral root portal) stays as written and is the house's to run.
96
+
95
97
  **One-Time PIN is the only correct identity provider here — and why.** The signer is an external party who belongs to **no** Cloudflare organization, so SSO, Google, GitHub, SAML and every other org-membership IdP cannot authenticate them. Cloudflare's built-in **One-Time PIN** emails a login code to **any allowlisted address** with no org membership required — exactly the external-signer case. Prescribe `onetimepin`; do not reach for another IdP type for an external signer (a higher-assurance provider is a separate capability, not this skill).
96
98
 
97
99
  **Load-bearing pre-flight — a gate requires an enabled login method.** Creating an Access application and policy does **not** enable a login method. If the org has **zero** identity providers when the gate goes live, the login page offers **nothing** and locks out *everyone, including the operator*. Before activating any Access app, list the org's IdPs and confirm the list is non-empty:
@@ -475,7 +477,15 @@ Dispatch runs **only** inside a live operator session on the account's own devic
475
477
 
476
478
  2. **For each unswept row, in order — stamp, send, then mark that one row swept:**
477
479
 
478
- - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it all with a pure PDF library (**`pdf-lib`**, run as a small Node script; pick another pure library if the account standardises on one, but it must add no browser and no network call):
480
+ - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it, all with a PDF library that loads the base and copies its pages through intact (**`pdf-lib`**, run as a small Node script). The stamp copies existing pages; it never renders them to an image. Rasterising the base (ghostscript to PNG, or any image-then-redraw rebuild) discards every link annotation and all selectable text, so the signer receives a flattened copy with dead links; it is prohibited whatever is or is not installed. A substitute for pdf-lib is allowed only if it imports existing PDF pages: pypdf, qpdf, or pdftk. A canvas-only generator such as reportlab cannot import a base and is not a substitute. Whatever the library, it adds no browser and no network call.
481
+
482
+ **Pre-flight: the page-copying library must resolve before any stamp runs.** A stamp attempted without it is the exact history this guards against, where a prior session met the missing library by rasterising instead of installing one:
483
+
484
+ ```bash
485
+ node -e 'import("pdf-lib").then(()=>console.log("ok"),e=>{console.error("pdf-lib missing: npm i pdf-lib at the account directory root");process.exit(1)})'
486
+ ```
487
+
488
+ On failure the one remediation is that install; a fallback that renders pages to images is never one. The stamp script itself:
479
489
 
480
490
  ```js
481
491
  // stamp.mjs — node, offline. Reads the persisted base, appends a certificate page,
@@ -507,6 +517,8 @@ Dispatch runs **only** inside a live operator session on the account's own devic
507
517
 
508
518
  The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis.
509
519
 
520
+ **Post-stamp gate: prove fidelity before any send.** For both `base.pdf` and the stamped copy, count link annotations with `grep -a -c '/URI'` and measure page-1 text extent with ghostscript text extraction (`gs -q -dNOPAUSE -dBATCH -sDEVICE=txtwrite -dFirstPage=1 -dLastPage=1 -o - <file> | wc -c`). The stamped copy passes only when its `/URI` count and its page-1 text extent are each at least the base's. On either regression the stamp rasterised or otherwise flattened the base, so delete the stamped copy, skip both sends, and leave the row `swept = 0` so the next sweep re-surfaces it. This gate converts a link-stripped copy from a no-event failure (nothing logs until a signer clicks a dead link) into a rejection at stamp time. Record the base and stamped `/URI` counts alongside every dispatch, pass or fail, so a fidelity regression is a number rather than a complaint.
521
+
510
522
  - **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the **stamped** PDF (`signed-<token>.pdf`) as an attachment:
511
523
  - once to the **business owner's configured address** (read from configured business identity — never hard-code a recipient in this workflow);
512
524
  - once to the **signer's captured `email`** — this copy is the **statutory record provided to the signer**, the evidentiary copy the legal basis (intro) relies on.
@@ -18,7 +18,7 @@ set -u
18
18
 
19
19
  BIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
20
20
  SCRIPT="$BIN_DIR/portal-brand-css.mjs"
21
- TEMPLATE="$BIN_DIR/../skills/data-portal/template/public/portal.css"
21
+ TEMPLATE="$BIN_DIR/../skills/data-portal/template/portal.css"
22
22
  [ -f "$SCRIPT" ] || { echo "FAIL: $SCRIPT not found" >&2; exit 1; }
23
23
  [ -f "$TEMPLATE" ] || { echo "FAIL: $TEMPLATE not found" >&2; exit 1; }
24
24
  # Case 2 strips the :root block with perl and diffs what is left. Without perl
@@ -198,6 +198,28 @@ export async function pushAccount(opts) {
198
198
  const current = Number(state[0]?.currentGeneration ?? 0)
199
199
  const next = current + 1
200
200
 
201
+ // Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
202
+ // INSERT — leaves rows at `next` while the pointer stays at `current`. Every
203
+ // later run recomputes the same `next`, and its first colliding INSERT then
204
+ // hits UNIQUE(accountId, relPath, generation), so the index freezes at the
205
+ // stale generation until an operator hand-clears the orphans. Clearing `next`
206
+ // before staging lets a partial stage self-heal on the next cycle. Guarded on
207
+ // a non-zero count so the DELETE never precedes the flip on a clean cycle and
208
+ // the log line always means a recovered wedge. The count comes from a SELECT
209
+ // because the D1 client returns rows, not the affected-row meta.
210
+ const orphan = await client.query(
211
+ 'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
212
+ [accountId, next],
213
+ )
214
+ const orphanCount = Number(orphan[0]?.n ?? 0)
215
+ if (orphanCount > 0) {
216
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
217
+ accountId,
218
+ next,
219
+ ])
220
+ log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
221
+ }
222
+
201
223
  for (const row of rows) {
202
224
  await client.query(
203
225
  'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir, generation) ' +