@rubytech/create-maxy-code 0.1.567 → 0.1.569

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/__tests__/brand-agent-root.test.js +91 -0
  2. package/dist/brand-agent-root.js +58 -0
  3. package/dist/index.js +25 -28
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/PLUGIN.md +4 -2
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  8. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  9. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  10. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  11. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  12. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  13. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  14. package/payload/platform/plugins/admin/mcp/dist/index.js +7 -1
  15. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts +10 -1
  17. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  18. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +52 -1
  19. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  20. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +9 -0
  21. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -1
  22. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +1 -1
  23. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -1
  24. package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +1 -1
  25. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +44 -1
  26. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +15 -0
  27. package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +43 -0
  28. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
  29. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  30. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
  31. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  32. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  33. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  34. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -146
  35. package/payload/platform/scripts/setup-account.sh +13 -0
  36. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +39 -0
  37. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -0
  38. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +223 -0
  39. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -0
  40. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +5 -0
  41. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +5 -5
  43. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +11 -1
  45. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/http-server.js +58 -12
  47. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/index.js +112 -13
  49. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  50. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +6 -1
  51. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/pty-census.js +6 -3
  53. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -8
  55. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  56. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +38 -15
  57. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  58. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  59. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  60. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  61. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  62. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +2 -0
  63. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  64. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +4 -1
  65. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  66. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +3 -0
  67. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  68. package/payload/platform/services/claude-session-manager/dist/reaper.js +2 -1
  69. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts +2 -0
  71. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js +5 -2
  73. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +2 -0
  75. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +5 -3
  77. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -1
  78. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  79. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  80. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  81. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  82. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +26 -16
  83. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  84. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +84 -31
  85. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  86. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +7 -0
  87. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +6 -0
  89. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
  90. package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -2
  91. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  92. package/payload/platform/services/telegram-channel/dist/notification.js +15 -3
  93. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  94. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  95. package/payload/platform/services/telegram-channel/dist/server.js +35 -3
  96. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  97. package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts +10 -0
  98. package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts.map +1 -1
  99. package/payload/platform/services/telegram-channel/dist/turn-follow.js +38 -1
  100. package/payload/platform/services/telegram-channel/dist/turn-follow.js.map +1 -1
  101. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  102. package/payload/platform/services/webchat-channel/dist/server.js +31 -2
  103. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  104. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts +10 -0
  105. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
  106. package/payload/platform/services/webchat-channel/dist/turn-follow.js +38 -1
  107. package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
  108. package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts +9 -0
  109. package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts.map +1 -0
  110. package/payload/platform/services/whatsapp-channel/dist/instructions.js +22 -0
  111. package/payload/platform/services/whatsapp-channel/dist/instructions.js.map +1 -0
  112. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +3 -1
  113. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  114. package/payload/platform/services/whatsapp-channel/dist/notification.js +10 -2
  115. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  116. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  117. package/payload/platform/services/whatsapp-channel/dist/server.js +26 -2
  118. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  119. package/payload/platform/templates/account-settings.json +153 -0
  120. package/payload/server/server.js +254 -96
@@ -0,0 +1,91 @@
1
+ // Task 2460 — the brand root is no longer a registration surface. It was
2
+ // written once at install and never pruned, so a retired template stayed
3
+ // registered forever: clare-craig carries `archive-ingest-operator.md` as a
4
+ // dangling symlink into a template that no longer exists, and Task 2416 had to
5
+ // strip its roster line by hand on five installs while the link survived.
6
+ // Ceasing to write it is not enough, because every existing install already
7
+ // carries the links.
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, readdirSync, existsSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { clearBrandAgentRoot } from "../brand-agent-root.js";
14
+ /** A temp brand root with an empty `agents/` under it, plus a collector for the
15
+ * one line the function emits. */
16
+ function makeRoot() {
17
+ const root = mkdtempSync(join(tmpdir(), "t2460-brand-root-"));
18
+ const agentsDir = join(root, "agents");
19
+ mkdirSync(agentsDir, { recursive: true });
20
+ const lines = [];
21
+ return { root, agentsDir, lines, log: (l) => lines.push(l) };
22
+ }
23
+ test("removes every markdown symlink and counts them", () => {
24
+ const { root, agentsDir, lines, log } = makeRoot();
25
+ try {
26
+ const target = join(root, "database-operator.md");
27
+ writeFileSync(target, "---\n");
28
+ symlinkSync(target, join(agentsDir, "database-operator.md"));
29
+ // The dangling case, which is what clare-craig actually carries.
30
+ symlinkSync(join(root, "gone.md"), join(agentsDir, "archive-ingest-operator.md"));
31
+ const result = clearBrandAgentRoot(agentsDir, log);
32
+ assert.deepEqual(result, { removed: 2, kept: [] });
33
+ assert.deepEqual(readdirSync(agentsDir), []);
34
+ // The link goes, never the template it pointed at.
35
+ assert.ok(existsSync(target));
36
+ assert.deepEqual(lines, [" [brand-agent-root] op=brand-agent-root-cleared removed=2 kept="]);
37
+ }
38
+ finally {
39
+ rmSync(root, { recursive: true, force: true });
40
+ }
41
+ });
42
+ test("keeps a regular file and names it", () => {
43
+ const { root, agentsDir, lines, log } = makeRoot();
44
+ try {
45
+ writeFileSync(join(agentsDir, "hand-written.md"), "---\n");
46
+ const result = clearBrandAgentRoot(agentsDir, log);
47
+ assert.deepEqual(result, { removed: 0, kept: ["hand-written.md"] });
48
+ assert.ok(existsSync(join(agentsDir, "hand-written.md")));
49
+ assert.deepEqual(lines, [" [brand-agent-root] op=brand-agent-root-cleared removed=0 kept=hand-written.md"]);
50
+ }
51
+ finally {
52
+ rmSync(root, { recursive: true, force: true });
53
+ }
54
+ });
55
+ test("leaves non-markdown entries alone", () => {
56
+ const { root, agentsDir, log } = makeRoot();
57
+ try {
58
+ writeFileSync(join(agentsDir, "notes.txt"), "scratch");
59
+ assert.deepEqual(clearBrandAgentRoot(agentsDir, log), { removed: 0, kept: [] });
60
+ assert.ok(existsSync(join(agentsDir, "notes.txt")));
61
+ }
62
+ finally {
63
+ rmSync(root, { recursive: true, force: true });
64
+ }
65
+ });
66
+ test("reports removed=0 on a directory that does not exist", () => {
67
+ const { root, agentsDir, lines, log } = makeRoot();
68
+ try {
69
+ rmSync(agentsDir, { recursive: true, force: true });
70
+ assert.deepEqual(clearBrandAgentRoot(agentsDir, log), { removed: 0, kept: [] });
71
+ assert.deepEqual(lines, [" [brand-agent-root] op=brand-agent-root-cleared removed=0 kept="]);
72
+ }
73
+ finally {
74
+ rmSync(root, { recursive: true, force: true });
75
+ }
76
+ });
77
+ test("is idempotent: a second run reports removed=0", () => {
78
+ const { root, agentsDir, lines, log } = makeRoot();
79
+ try {
80
+ const target = join(root, "database-operator.md");
81
+ writeFileSync(target, "---\n");
82
+ symlinkSync(target, join(agentsDir, "database-operator.md"));
83
+ clearBrandAgentRoot(agentsDir, log);
84
+ lines.length = 0;
85
+ assert.deepEqual(clearBrandAgentRoot(agentsDir, log), { removed: 0, kept: [] });
86
+ assert.deepEqual(lines, [" [brand-agent-root] op=brand-agent-root-cleared removed=0 kept="]);
87
+ }
88
+ finally {
89
+ rmSync(root, { recursive: true, force: true });
90
+ }
91
+ });
@@ -0,0 +1,58 @@
1
+ // Task 2460 — the brand-wide agent root is retired.
2
+ //
3
+ // `$CLAUDE_CONFIG_DIR/agents/` was written once, by the installer, from
4
+ // `platform/templates/specialists/agents/`, and never pruned. Three things
5
+ // followed. A retired template stayed registered forever: on clare-craig,
6
+ // `archive-ingest-operator.md` is a dangling symlink into a template that no
7
+ // longer exists, and Task 2416 had to strip its roster line by hand on five
8
+ // installs while the link survived. A specialist disabled on one account stayed
9
+ // resolvable by name in that account and in every other account on the install,
10
+ // because disabling touches only `<accountDir>/specialists/agents/`. And an
11
+ // authored specialist could never be registered here at all without becoming
12
+ // dispatchable inside every other account on the box.
13
+ //
14
+ // The replacement is one registry per account, mirrored at spawn by
15
+ // `syncAccountAgentRegistry`. Dispatch is unaffected by emptying this root:
16
+ // provisioning copies every template specialist into each account's
17
+ // `specialists/agents/`, which that registry mirrors, and cwd at every spawn is
18
+ // the account whose source is mirrored.
19
+ //
20
+ // This function removes what the old surface left behind. Removal rather than
21
+ // merely ceasing to write, because every existing install already carries the
22
+ // links.
23
+ import { existsSync, lstatSync, readdirSync, unlinkSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ /** Remove every `*.md` symlink under `agentsDir`. A regular file is the
26
+ * operator's own and is kept and named, never deleted. Returns the counts and
27
+ * logs `op=brand-agent-root-cleared removed=<N> kept=<names>` once, which is
28
+ * the per-box record the fleet remediation is tracked against. */
29
+ export function clearBrandAgentRoot(agentsDir, log = () => { }) {
30
+ let removed = 0;
31
+ const kept = [];
32
+ // A missing directory is an install that never had the old surface. Nothing
33
+ // to clear is a real result, not an error, so it still logs its zero.
34
+ if (existsSync(agentsDir)) {
35
+ for (const entry of readdirSync(agentsDir).sort()) {
36
+ if (!entry.endsWith(".md"))
37
+ continue;
38
+ const path = join(agentsDir, entry);
39
+ let stat;
40
+ try {
41
+ // lstat, not stat: the links this clears are frequently dangling, which
42
+ // is the whole of symptom 2, and stat would throw on exactly those.
43
+ stat = lstatSync(path);
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ if (!stat.isSymbolicLink()) {
49
+ kept.push(entry);
50
+ continue;
51
+ }
52
+ unlinkSync(path);
53
+ removed++;
54
+ }
55
+ }
56
+ log(` [brand-agent-root] op=brand-agent-root-cleared removed=${removed} kept=${kept.join(",")}`);
57
+ return { removed, kept };
58
+ }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync, accessSync, constants as fsConstants } from "node:fs";
4
- import { registerSpecialistAgentsAt, SpecialistSymlinkCollision } from "./specialist-registration.js";
4
+ import { clearBrandAgentRoot } from "./brand-agent-root.js";
5
5
  import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./permissions-seed.js";
6
6
  import { resolve, join, dirname } from "node:path";
7
7
  import { randomBytes } from "node:crypto";
@@ -3419,14 +3419,13 @@ function setupAccount() {
3419
3419
  env: { ...neo4jEnv, PLATFORM_ROOT: join(INSTALL_DIR, "platform") },
3420
3420
  });
3421
3421
  }
3422
- // Task 165register every bundled specialist subagent at
3423
- // $CLAUDE_CONFIG_DIR/agents/<name>.md. Claude Code's `--agent <name>`
3424
- // discovery only resolves under this path; without these symlinks,
3425
- // every `--agent database-operator` etc. silently runs as the admin
3426
- // agent on admin IDENTITY/SOUL scaffolding. Symlinks (not copies) so
3427
- // a `npx -y @rubytech/create-maxy-code` upgrade picks up template
3428
- // changes without a second seed run.
3429
- registerSpecialistAgents();
3422
+ // Task 2460clear the brand-wide agent root this step used to write.
3423
+ // Every bundled specialist now reaches a session through the per-account
3424
+ // registry that `syncAccountAgentRegistry` mirrors at spawn from
3425
+ // `<accountDir>/specialists/agents/`, which provisioning fills from the same
3426
+ // templates. Runs on every install, so an upgrade removes the links an
3427
+ // earlier install left behind.
3428
+ clearBrandAgentRegistrations();
3430
3429
  // install-time defaults. Stamp `account.json` with the
3431
3430
  // brand's default plugin set + default outputStyle / thinkingView, and
3432
3431
  // write a minimal admin SOUL.md. The agent boots into a working state
@@ -3595,25 +3594,23 @@ function writeEntitlementFromFlag(payload) {
3595
3594
  console.log(line);
3596
3595
  logFile(line);
3597
3596
  }
3598
- // Task 394premium-bundle agents register through the native Claude Code
3599
- // subagent registry via `.claude-plugin/plugin.json` manifests emitted by
3600
- // scripts/generate-plugin-manifests.mjs at bundle time, not through a
3601
- // parallel symlink channel. This function still wires the bundled platform
3602
- // specialists (database-operator, project-manager, …) under
3603
- // platform/templates/specialists/agents/, which are not part of a
3604
- // registered plugin and rely on $CLAUDE_CONFIG_DIR/agents/ resolution.
3605
- function registerSpecialistAgents() {
3606
- try {
3607
- registerSpecialistAgentsAt(join(INSTALL_DIR, "platform/templates/specialists/agents"), join(CLAUDE_CONFIG_DIR, "agents"), (line) => { console.log(line); logFile(line); });
3608
- }
3609
- catch (err) {
3610
- if (err instanceof SpecialistSymlinkCollision) {
3611
- console.error(`Setup failed: ${err.message}`);
3612
- console.error(` Remove the file and re-run.`);
3613
- process.exit(1);
3614
- }
3615
- throw err;
3616
- }
3597
+ // Task 2460the brand root is no longer a registration surface. It was
3598
+ // written once and never pruned, which left retired templates registered
3599
+ // forever and left a specialist disabled on one account dispatchable in every
3600
+ // other account on the install. Bundled platform specialists reach a session
3601
+ // through the per-account registry instead; this step removes the links the old
3602
+ // surface left behind, which is what closes those two symptoms on an existing
3603
+ // install rather than only on a fresh one.
3604
+ //
3605
+ // Task 394 — premium-bundle agents were never registered here. They resolve
3606
+ // through the native Claude Code subagent registry via `.claude-plugin/plugin.json`
3607
+ // manifests emitted by scripts/generate-plugin-manifests.mjs at bundle time, so
3608
+ // clearing this directory does not touch them.
3609
+ function clearBrandAgentRegistrations() {
3610
+ clearBrandAgentRoot(join(CLAUDE_CONFIG_DIR, "agents"), (line) => {
3611
+ console.log(line);
3612
+ logFile(line);
3613
+ });
3617
3614
  }
3618
3615
  // ---------------------------------------------------------------------------
3619
3616
  // Account discovery (shared by installService + the post-install summary)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.567",
3
+ "version": "0.1.569",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -268,10 +268,12 @@ Tools are available via the `admin` MCP server.
268
268
 
269
269
  ## Hooks
270
270
 
271
- - `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. Blocks (exit 2) a `browser-pdf-save` whose output path is a client-quote artefact (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) when no render receipt exists at `quoting/jobs/<jobId>/render-receipt.json` the receipt `quote-render` writes only after its reconcile / margin-leak / link-liveness gates all pass. A hand-authored quote (the SiteDesk 2026-07-14 bypass: `Write` HTML `browser-pdf-save`, `quote-render` never called, an internal back-test caveat naming another client folded into the free-text region) has no receipt, so the print is refused rather than delivered. **Block message:** `Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> detail=client quote PDF has no render receipt` (or `reason=bad-jobid` when the filename does not resolve to a job). Every non-quote PDF — brochures, invoices, the admin-scope internal view — and the deterministic path itself (receipt present) pass at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
271
+ - `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. **Scope** is three path shapes, all resolved against the account dir: `memory/users/<phone>/documents/quote-<jobId>[-breakdown].pdf`, `jobs/<jobId>/quote/<anything>.pdf`, and `jobs/<jobId>/Quotations/<anything>.pdf`. `jobs/<jobId>/pricing/` is excluded it holds the internal priced breakdown, excluded with the admin-scope internal view. **Decision** is receipt *identity*, not receipt existence: the gate resolves the PDF's sibling HTML (`.pdf`→`.html`), sha256s it, and requires a match against the sha256 of a file named by `filed.quote` or `filed.breakdown` in `quoting/jobs/<jobId>/render-receipt.json`. `filed.internal` is not client-facing and never matches. Comparing content rather than paths keeps an operator copy of a rendered document passing while hand-authored content fails. A missing sibling HTML blocks: the pipeline files the PDF beside the HTML it printed from, so no local source is no proof of renderer origin. Existence alone was not enough — on the 2026-08-05 bypass the job's figures were rewritten after the render and the delivered document was written later still, so a stale receipt would have authorised the print. **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=<not-receipt-filed|no-source-html|bad-jobid>`. Every non-quote PDF — brochures, works orders, invoices, the admin-scope internal view — passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
272
272
  - `hooks/quote-engine-gate.sh` — **PreToolUse matcher=`Bash`** (a third command on the same `Bash` matcher as `archive-ingest-surface-gate` and `fs-schema-guard-bash-pre`, any exit 2 blocks). Makes the deterministic `quote-engine-run` / `quote-render` tools the only route the account's persisted quoting scripts can run. Blocks (exit 2) a `Bash` `command` that executes `quoting/engine.mjs` or `quoting/render.mjs` directly — the pricing and render scripts must run through their MCP tools, which apply the reconcile / margin-leak / link-liveness gates and write the figures and render receipts. A hand-run `node quoting/engine.mjs` (the SiteDesk 2026-07-25 bypass: a repricing dispatched to content-producer that ran the engine by hand and re-rendered off the deterministic path) skips all of that, so it is refused. It fires only on an execution (a JS runtime invoking the script, or the script run at command position); a read or inspect of the file (`cat`/`grep`/`git diff quoting/engine.mjs`) and every unrelated `Bash` command pass at exit 0. A `mode=verify` reproduction is gated too — it must also run through `quote-engine-run`, so a hand-run `node quoting/engine.mjs mode=verify` is refused. Known fail-open gap (accepted, consistent with the soft posture): a `cd quoting && node engine.mjs` drops the `quoting/` prefix and is not matched. **Block message:** `Blocked: the quoting engine and render scripts must run through their tools, not directly. Use the quote-engine-run tool to price a job (it files the figures) and the quote-render tool to produce the documents (it files them behind the reconcile, margin-leak and link-liveness gates). Running quoting/engine.mjs or quoting/render.mjs by hand skips those gates and files no receipt.` **Log line** (stderr, on a block): `[quote-engine] op=bypass reason=direct-script-exec`. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `Bash` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated commands.
273
+ - `hooks/quote-pdf-bash-gate.sh` — **PreToolUse matcher=`Bash`.** Closes the Bash route to a client quote PDF. `quote-render-gate` refuses a hand-authored quote at `browser-pdf-save`, but an agent holding `Bash` reaches the same outcome with any HTML-to-PDF converter and meets no control at all (`content-producer` holds `Bash`, `Write`, `Edit` and `browser-pdf-save`; `quote-engine-gate` refuses only execution of the two persisted quoting scripts). Blocks (exit 2) a command that names both an `.html` under a gated quote path — `memory/users/<phone>/documents/`, `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` — and a `.pdf`. The match is structural on the paths, never on the converter's name, so `wkhtmltopdf`, `chromium --print-to-pdf` and `weasyprint` are covered by the same rule. A read that names no PDF (`cat q.html`) passes, and `jobs/<jobId>/pricing/` is never gated. **Block message:** `Blocked: a client quote PDF must come from the quote-render tool, not from a converter run by hand. Run quote-render for this job — it files the document and its render receipt — then print the client HTML path it returns.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=bash-pdf-production`. **Fail-open** on an uninspectable call.
274
+ - `hooks/quote-path-write-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit` / `NotebookEdit`.** The roster safeguard expressed as a capability rather than a removal. Blocks (exit 2) a write whose target is under `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` or `quoting/templates/` when the calling agent's card does not grant `quote-render`. `jobs/<jobId>/pricing/` is excluded — the internal priced breakdown is not a client quote artefact — and every other deliverable the agent owns (works orders, heritage statements, survey sheets, drawing registers, trial-pit records, invoice stamps) is untouched. **Caller identity is `agent_type` from the hook envelope**, which Claude Code sets when a hook fires inside a subagent; `MAXY_SPECIALIST` is not usable, since it is stamped only when a specialist is spawned as its own PTY process and reads empty for the native Agent-tool subagents this gate exists to stop. **Card lookup spans both registration families** — the per-account registry (`<accountDir>/.claude/agents/`, `<accountDir>/specialists/agents/`), `$CLAUDE_CONFIG_DIR/agents/`, and the plugin roots (`$(dirname $PLATFORM_ROOT)/premium-plugins/*/agents/`, `$PLATFORM_ROOT/plugins/*/agents/`) — matching on frontmatter `name:` as well as filename, because the quoter's card is filed as `sitedesk--quoter.md` and declares `name: quoter`. Omitting the premium root would block the quoter itself. A write from the main conversation carries no `agent_type` and the admin seat holds no `quote-render`, so it is refused too: no part of a client quote document is another agent's work, the admin's included. **Block message:** `Blocked: quote documents and quote templates are the quoter's work. Dispatch the quoter for this change; it produces the document through the quote-render tool, which files it with its render receipt.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=<quote-path-write-without-render-tool|quote-path-write-unresolved-agent>`. **Fail-open** on an uninspectable envelope.
273
275
  - `hooks/preference-consult-gate.sh` — **PreToolUse gate on customer-facing document deliverables.** Registered against `mcp__plugin_browser_browser__browser-pdf-save` (a second command on the same matcher as `quote-render-gate`, either exit 2 blocks) and against the email/Outlook send tools plus `SendUserFile`. It fires only in the document-deliverable shape: a `browser-pdf-save` or `SendUserFile` whose path is under `memory/users/<phone>/documents/` (or a `SendUserFile` of a `.pdf`/`.html`/`.docx`), and `email-send`/`email-reply`/`email-draft-send`/`outlook-mail-send`/`outlook-mail-reply`/`outlook-draft-send` carrying a non-empty `attachments`. For a gated call it reads the session transcript (`transcript_path`) and blocks (exit 2) when no `profile-read` tool call ran after the last user message, because the account's layer-2 `Preference` records (signature policy, header, naming, styling) were not consulted this turn, the exact miss that dropped a customer signature on a delivered document. **Block message:** `Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again.` **Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block, `op=allow tool=<name> consulted=true` on an allowed document deliverable. Every non-document tool/shape, and every casual channel reply or plain-text email, passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / transcript missing or unreadable). Scope decision: the gate fires on document deliverables only, not on every send, so it never blocks routine chat. The standing reconcile counterpart for stranded `output/` deliverables is filed as a follow-up task.
274
- - `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
276
+ - `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Third check — a running header on every physical page.** Chrome decides physical pagination at print time, so a document whose header is a hand-placed `<div>` next to a hand-placed page break loses that header on any page the content bled onto (job 1854, 2026-08-04: 8 headerless pages out of 21, with `run-head` and `page-break` counts matching at 20 apiece, which is why counting the two against each other is blind to the fault). This check is deliberately **receipt-free and path-agnostic**, because template-seeded documents — budget estimates, valuations, works orders, survey sheets — never pass through `quote-render` and are exactly the ones that reproduce it. It resolves the source HTML from the render receipt when the PDF is a gated client quote, else from the sibling `<same-basename>.html` beside the PDF (the documented `browser-pdf-save` convention in `plugins/business-assistant/references/invoicing.md` step 4 and `plugins/business-assistant/references/document-management.md` step 4). The **header marker** is the literal string `content` of the first `@page` top margin box that carries one, tried `@top-center` → `@top-right` → `@top-left`, with CSS escapes and HTML entities resolved; a `content:url()` logo box carries no extractable text and is skipped, and a marker under 4 characters counts as absent. Pages are read one at a time with `pdftotext -f N -l N` **cropped to the top 12% of the page** and compared on whitespace-collapsed text. The crop is load-bearing, not an optimisation: a running footer routinely repeats the same reference as the header (the GLS footer is `… Builders Est. 1987 · Quote <ref>`), so a whole-page match reports a header on a page that carries only a footer — measured on a real 10-page print, where page 1 matched on its footer alone while its header was correctly suppressed. Twelve percent covers a top margin as deep as 30mm on A4 and excludes the footer. Checking starts at **page 2** when the source declares `@page :first` (which suppresses the boxes so page 1 keeps its own masthead) and from page 1 otherwise. It blocks (exit 2) when any checked page lacks the marker. **Only a document that declares a running header is ever gated** — a brochure or any document without one passes untouched. **Block message:** `Blocked: this printed document has physical pages carrying no running header, so it would go out with unbranded pages (…). The header must be a property of the page itself — a page margin box — not a header block placed next to a declared page break, because the browser decides the real page boundaries at print time. Fix the document's print CSS, print it again, and check every page.` **Log line:** `<ISO> [quote-render] op=pdf-header-conformance file=<basename> pages=<N> pagesChecked=<n> pagesHeaderless=<k> session=<sid>`, emitted on **every** decisive call including a clean one (`pagesHeaderless=0`), and on each skip path as `pagesChecked=0 reason=<no-source-html|no-running-header|single-page|no-poppler>` — so a clean run is distinguishable from a check that never ran. A block additionally emits `op=bypass file=<basename> reason=pdf-header-conformance detail=pagesHeaderless=<k> pages=<comma list>`. **Sink:** `<logDir>/quote-render-conformance.log`, resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `ACCOUNT_DIR/logs` ladder that `mailbox-inject.sh` uses, **not** `server.log` — a hook's stderr on exit 0 reaches no sink at all (measured 2026-08-06 on the laptop's `sitedesk-code` install: no bracketed hook line appears in `server.log` live or rotated, nor in the JSONL transcripts), so a stderr-only line would have been an audit nothing reads. The line is echoed to stderr as well, so a blocking run still shows it to the agent. **Fail-open** additionally on absent `pdfinfo`/`pdftotext`, an unresolvable source HTML, and a source that declares no running header. **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
275
277
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
276
278
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
277
279
  - `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your answer and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_askgate` (`platform/scripts/lib/account-settings-askgate.sh`) on every install to retrofit the carrier gate onto every existing account's `settings.json` — idempotent surgical jq patch of the `AskUserQuestion` matcher, one `[backfill-1683] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>` line per account, contents never echoed.
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for quote-path-write-gate.sh.
3
+ #
4
+ # The client quote document has one owner, and that ownership is a capability:
5
+ # the quote-render tool is granted to the quoter's card and to no other. This
6
+ # gate expresses the same rule at the write, refusing an agent that does not
7
+ # hold quote-render from authoring a quote artefact or the account's quote
8
+ # templates, while leaving every other deliverable that agent owns untouched.
9
+ #
10
+ # The caller is identified by `agent_type` in the hook envelope, which Claude
11
+ # Code sets when a hook fires inside a subagent. The MAXY_SPECIALIST env is not
12
+ # usable: it is stamped only when a specialist is spawned as its own PTY
13
+ # process, and the dispatches this gate exists to stop were native Agent-tool
14
+ # subagents that inherit the admin process env.
15
+ #
16
+ # Cards are resolved across BOTH registration families. Bundled specialists
17
+ # reach a session through the per-account registry; premium-bundle agents
18
+ # register through plugin manifests and are never written to that registry, so
19
+ # a lookup that skipped the premium root would block the quoter itself.
20
+ #
21
+ # Covers:
22
+ # A. the quoter writing a quote document → ALLOWED
23
+ # B. an agent without the tool → BLOCKED without-render-tool
24
+ # C. no agent_type at all (the main conversation) → BLOCKED without-render-tool
25
+ # D. an agent_type no card resolves → BLOCKED unresolved-agent
26
+ # E. the internal pricing bucket → ALLOWED (never gated)
27
+ # F. an agent without the tool, quote templates → BLOCKED
28
+ # G. the quoter, quote templates → ALLOWED
29
+ # H. an agent without the tool, its own output → ALLOWED
30
+ # I. the Quotations bucket → BLOCKED
31
+ # J. empty stdin → ALLOWED (fail-open)
32
+ # K. a read of a gated path → ALLOWED
33
+ # L. MultiEdit is gated like Write and Edit → BLOCKED
34
+ # M. a block emits the op=bypass fault line
35
+
36
+ set -u
37
+
38
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-path-write-gate.sh"
39
+ if [[ ! -x "$HOOK" ]]; then
40
+ echo "FAIL: $HOOK not executable" >&2
41
+ exit 1
42
+ fi
43
+
44
+ FAKE=$(mktemp -d)
45
+ cleanup() { rm -rf "$FAKE"; }
46
+ trap cleanup EXIT
47
+
48
+ ACCT="$FAKE/accounts/acct-1"
49
+ mkdir -p "$ACCT/.claude/agents" "$ACCT/jobs/job-1/quote" "$ACCT/jobs/job-1/pricing" \
50
+ "$ACCT/quoting/templates" "$ACCT/output" \
51
+ "$FAKE/premium-plugins/sitedesk/agents" "$FAKE/platform/plugins/admin/hooks"
52
+
53
+ # The per-account registry: a bundled specialist that holds no quote tool.
54
+ cat > "$ACCT/.claude/agents/content-producer.md" <<'CARD'
55
+ ---
56
+ name: content-producer
57
+ description: "Produces documents."
58
+ tools: Skill, Read, Write, Edit, MultiEdit, Bash, mcp__plugin_browser_browser__browser-pdf-save
59
+ ---
60
+ CARD
61
+
62
+ # The premium bundle: the quoter, whose card filename does not match its name.
63
+ cat > "$FAKE/premium-plugins/sitedesk/agents/sitedesk--quoter.md" <<'CARD'
64
+ ---
65
+ name: quoter
66
+ description: "The quoter owns the client quote document entire."
67
+ tools: Skill, Read, Write, Edit, Bash, mcp__plugin_sitedesk-job_sitedesk-job__quote-engine-run, mcp__plugin_sitedesk-job_sitedesk-job__quote-render
68
+ ---
69
+ CARD
70
+
71
+ PASS=0
72
+ FAIL=0
73
+
74
+ envelope() { # tool_name, file_path, agent_type ("" = absent)
75
+ python3 -c '
76
+ import json, sys
77
+ d = {"hook_event_name": "PreToolUse", "tool_name": sys.argv[1],
78
+ "tool_input": {"file_path": sys.argv[2]}}
79
+ if sys.argv[3]:
80
+ d["agent_type"] = sys.argv[3]
81
+ d["agent_id"] = "agent_01"
82
+ print(json.dumps(d))' "$1" "$2" "$3"
83
+ }
84
+
85
+ run_case() {
86
+ local name="$1" stdin="$2" expected_exit="$3"
87
+ local actual_exit
88
+ ( cd "$ACCT" && export PLATFORM_ROOT="$FAKE/platform" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
89
+ actual_exit=$?
90
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
91
+ echo "PASS: $name (exit=$actual_exit)"
92
+ PASS=$((PASS + 1))
93
+ else
94
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
95
+ FAIL=$((FAIL + 1))
96
+ fi
97
+ }
98
+
99
+ QUOTE="jobs/job-1/quote/Q 1879 - Client.html"
100
+
101
+ run_case "A: the quoter writing a quote document → ALLOWED" \
102
+ "$(envelope Write "$QUOTE" sitedesk:quoter)" 0
103
+
104
+ run_case "B: an agent without the tool → BLOCKED" \
105
+ "$(envelope Write "$QUOTE" content-producer)" 2
106
+
107
+ run_case "C: no agent_type at all → BLOCKED" \
108
+ "$(envelope Write "$QUOTE" '')" 2
109
+
110
+ run_case "D: an agent_type no card resolves → BLOCKED" \
111
+ "$(envelope Write "$QUOTE" ghost-agent)" 2
112
+
113
+ run_case "E: the internal pricing bucket → ALLOWED" \
114
+ "$(envelope Write 'jobs/job-1/pricing/breakdown.md' content-producer)" 0
115
+
116
+ run_case "F: an agent without the tool, quote templates → BLOCKED" \
117
+ "$(envelope Edit 'quoting/templates/house-style.html' content-producer)" 2
118
+
119
+ run_case "G: the quoter, quote templates → ALLOWED" \
120
+ "$(envelope Edit 'quoting/templates/house-style.html' sitedesk:quoter)" 0
121
+
122
+ run_case "H: an agent without the tool, its own output → ALLOWED" \
123
+ "$(envelope Write 'output/works-order.md' content-producer)" 0
124
+
125
+ run_case "I: the Quotations bucket → BLOCKED" \
126
+ "$(envelope Write 'jobs/job-1/Quotations/Q 1879 - Client.html' content-producer)" 2
127
+
128
+ run_case "J: empty stdin → ALLOWED (fail-open)" "" 0
129
+
130
+ run_case "K: a read of a gated path → ALLOWED" \
131
+ "$(envelope Read "$QUOTE" content-producer)" 0
132
+
133
+ run_case "L: MultiEdit is gated like Write and Edit → BLOCKED" \
134
+ "$(envelope MultiEdit "$QUOTE" content-producer)" 2
135
+
136
+ check_reason() { # name, agent_type, expected reason
137
+ local out
138
+ out=$( cd "$ACCT" && export PLATFORM_ROOT="$FAKE/platform" && printf '%s' "$(envelope Write "$QUOTE" "$2")" | bash "$HOOK" 2>&1 >/dev/null )
139
+ if printf '%s' "$out" | grep -q "\[quote-render\] op=bypass reason=$3"; then
140
+ echo "PASS: M: $1 emits reason=$3"
141
+ PASS=$((PASS + 1))
142
+ else
143
+ echo "FAIL: M: $1 did not emit reason=$3 (got: $out)" >&2
144
+ FAIL=$((FAIL + 1))
145
+ fi
146
+ }
147
+ check_reason "an agent without the tool" content-producer quote-path-write-without-render-tool
148
+ check_reason "an unresolvable agent" ghost-agent quote-path-write-unresolved-agent
149
+
150
+ echo "──────── quote-path-write-gate test summary ────────"
151
+ echo "PASS=$PASS FAIL=$FAIL"
152
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for quote-pdf-bash-gate.sh.
3
+ #
4
+ # quote-render-gate refuses a hand-authored client quote at browser-pdf-save.
5
+ # An agent holding Bash reaches the same outcome with any HTML-to-PDF converter
6
+ # and meets no control at all: the specialist that produced the 2026-08-05
7
+ # quote holds Bash, Write, Edit and browser-pdf-save. This gate refuses a Bash
8
+ # command that produces a PDF from an HTML under a gated quote path, matching
9
+ # structurally on the paths so the rule holds for any converter binary.
10
+ #
11
+ # Covers:
12
+ # A. converting an HTML in a job's quote bucket → BLOCKED
13
+ # B. converting an HTML in a job's Quotations bucket → BLOCKED
14
+ # C. converting an HTML in the customer documents scope → BLOCKED
15
+ # D. a quoted path carrying spaces → BLOCKED
16
+ # E. the same converter on an unrelated HTML → ALLOWED
17
+ # F. a read of a gated HTML, no PDF named → ALLOWED
18
+ # G. the internal pricing bucket → ALLOWED (never gated)
19
+ # H. empty stdin → ALLOWED (fail-open)
20
+ # I. a non-Bash tool → ALLOWED
21
+ # J. a block emits the op=bypass fault line
22
+
23
+ set -u
24
+
25
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-pdf-bash-gate.sh"
26
+ if [[ ! -x "$HOOK" ]]; then
27
+ echo "FAIL: $HOOK not executable" >&2
28
+ exit 1
29
+ fi
30
+
31
+ PASS=0
32
+ FAIL=0
33
+
34
+ run_case() {
35
+ local name="$1" stdin="$2" expected_exit="$3"
36
+ local actual_exit
37
+ printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
38
+ actual_exit=$?
39
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
40
+ echo "PASS: $name (exit=$actual_exit)"
41
+ PASS=$((PASS + 1))
42
+ else
43
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
44
+ FAIL=$((FAIL + 1))
45
+ fi
46
+ }
47
+
48
+ envelope() { # command
49
+ python3 -c '
50
+ import json, sys
51
+ print(json.dumps({
52
+ "hook_event_name": "PreToolUse",
53
+ "tool_name": "Bash",
54
+ "tool_input": {"command": sys.argv[1]},
55
+ }))' "$1"
56
+ }
57
+
58
+ run_case "A: convert an HTML in a job quote bucket → BLOCKED" \
59
+ "$(envelope 'wkhtmltopdf jobs/job-1/quote/quote.html jobs/job-1/quote/quote.pdf')" 2
60
+
61
+ run_case "B: convert an HTML in a job Quotations bucket → BLOCKED" \
62
+ "$(envelope 'chromium --headless --print-to-pdf=out.pdf jobs/job-1/Quotations/q.html')" 2
63
+
64
+ run_case "C: convert an HTML in the customer documents scope → BLOCKED" \
65
+ "$(envelope 'weasyprint memory/users/44700900000/documents/quote-job-1.html q.pdf')" 2
66
+
67
+ run_case "D: a quoted path carrying spaces → BLOCKED" \
68
+ "$(envelope 'wkhtmltopdf "jobs/job-1/quote/Q 1879 - Client.html" "jobs/job-1/quote/Q 1879 - Client.pdf"')" 2
69
+
70
+ run_case "E: the same converter on an unrelated HTML → ALLOWED" \
71
+ "$(envelope 'wkhtmltopdf notes/readme.html readme.pdf')" 0
72
+
73
+ run_case "F: a read of a gated HTML, no PDF named → ALLOWED" \
74
+ "$(envelope 'cat "jobs/job-1/quote/Q 1879 - Client.html"')" 0
75
+
76
+ run_case "G: the internal pricing bucket → ALLOWED" \
77
+ "$(envelope 'wkhtmltopdf jobs/job-1/pricing/breakdown.html breakdown.pdf')" 0
78
+
79
+ run_case "H: empty stdin → ALLOWED (fail-open)" "" 0
80
+
81
+ run_case "I: a non-Bash tool → ALLOWED" \
82
+ '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"jobs/job-1/quote/q.html"}}' 0
83
+
84
+ STDERR=$(printf '%s' "$(envelope 'wkhtmltopdf jobs/job-1/quote/quote.html jobs/job-1/quote/quote.pdf')" | bash "$HOOK" 2>&1 >/dev/null)
85
+ if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass reason=bash-pdf-production'; then
86
+ echo "PASS: J: block emits op=bypass reason=bash-pdf-production"
87
+ PASS=$((PASS + 1))
88
+ else
89
+ echo "FAIL: J: op=bypass line absent (got: $STDERR)" >&2
90
+ FAIL=$((FAIL + 1))
91
+ fi
92
+
93
+ echo "──────── quote-pdf-bash-gate test summary ────────"
94
+ echo "PASS=$PASS FAIL=$FAIL"
95
+ [[ "$FAIL" -eq 0 ]]