@rubytech/create-maxy-code 0.1.451 → 0.1.453

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 (140) hide show
  1. package/dist/__tests__/preserve-house-cloudflare.test.js +81 -2
  2. package/dist/index.js +10 -1
  3. package/dist/preserve-house-cloudflare.js +49 -2
  4. package/package.json +1 -1
  5. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +24 -0
  6. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  7. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.d.ts +2 -0
  8. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.d.ts.map +1 -0
  9. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.js +48 -0
  10. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.js.map +1 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/remediate.test.js +6 -0
  12. package/payload/platform/lib/storage-broker/dist/__tests__/remediate.test.js.map +1 -1
  13. package/payload/platform/lib/storage-broker/dist/bin/remediate-run.d.ts.map +1 -1
  14. package/payload/platform/lib/storage-broker/dist/bin/remediate-run.js +14 -4
  15. package/payload/platform/lib/storage-broker/dist/bin/remediate-run.js.map +1 -1
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  17. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/cf-exec.js +18 -0
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/house-scoped-token.d.ts +9 -0
  21. package/payload/platform/lib/storage-broker/dist/house-scoped-token.d.ts.map +1 -0
  22. package/payload/platform/lib/storage-broker/dist/house-scoped-token.js +60 -0
  23. package/payload/platform/lib/storage-broker/dist/house-scoped-token.js.map +1 -0
  24. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  25. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  27. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/remediate.d.ts.map +1 -1
  29. package/payload/platform/lib/storage-broker/dist/remediate.js +14 -1
  30. package/payload/platform/lib/storage-broker/dist/remediate.js.map +1 -1
  31. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +28 -1
  32. package/payload/platform/lib/storage-broker/src/__tests__/house-scoped-token.test.ts +48 -0
  33. package/payload/platform/lib/storage-broker/src/__tests__/remediate.test.ts +8 -0
  34. package/payload/platform/lib/storage-broker/src/bin/remediate-run.ts +17 -5
  35. package/payload/platform/lib/storage-broker/src/cf-exec.ts +22 -0
  36. package/payload/platform/lib/storage-broker/src/house-scoped-token.ts +72 -0
  37. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  38. package/payload/platform/lib/storage-broker/src/remediate.ts +14 -1
  39. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  40. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +107 -0
  41. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +74 -0
  42. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -4
  43. package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +25 -1
  44. package/payload/platform/plugins/cloudflare/bin/cf-token.sh +9 -1
  45. package/payload/platform/plugins/cloudflare/references/api.md +3 -1
  46. package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -1
  47. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  48. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  49. package/payload/platform/plugins/outlook/PLUGIN.md +3 -3
  50. package/payload/platform/plugins/outlook/mcp/dist/__tests__/complete-registration.test.d.ts +2 -0
  51. package/payload/platform/plugins/outlook/mcp/dist/__tests__/complete-registration.test.d.ts.map +1 -0
  52. package/payload/platform/plugins/outlook/mcp/dist/__tests__/complete-registration.test.js +131 -0
  53. package/payload/platform/plugins/outlook/mcp/dist/__tests__/complete-registration.test.js.map +1 -0
  54. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client-paged.test.d.ts +2 -0
  55. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client-paged.test.d.ts.map +1 -0
  56. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client-paged.test.js +55 -0
  57. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client-paged.test.js.map +1 -0
  58. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list-paging.test.d.ts +2 -0
  59. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list-paging.test.d.ts.map +1 -0
  60. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list-paging.test.js +120 -0
  61. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list-paging.test.js.map +1 -0
  62. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-search-paging.test.d.ts +2 -0
  63. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-search-paging.test.d.ts.map +1 -0
  64. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-search-paging.test.js +66 -0
  65. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-search-paging.test.js.map +1 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pending-scan.test.d.ts +2 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pending-scan.test.d.ts.map +1 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pending-scan.test.js +48 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pending-scan.test.js.map +1 -0
  70. package/payload/platform/plugins/outlook/mcp/dist/auth/complete-registration.d.ts +31 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/auth/complete-registration.d.ts.map +1 -0
  72. package/payload/platform/plugins/outlook/mcp/dist/auth/complete-registration.js +47 -0
  73. package/payload/platform/plugins/outlook/mcp/dist/auth/complete-registration.js.map +1 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/auth/pending-scan.d.ts +11 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/auth/pending-scan.d.ts.map +1 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/auth/pending-scan.js +31 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/auth/pending-scan.js.map +1 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/index.js +13 -9
  79. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  80. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +19 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
  82. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +14 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
  84. package/payload/platform/plugins/outlook/mcp/dist/scripts/complete-registration.d.ts +2 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/scripts/complete-registration.d.ts.map +1 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/scripts/complete-registration.js +111 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/scripts/complete-registration.js.map +1 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +16 -1
  89. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +37 -7
  91. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
  92. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts +12 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts.map +1 -1
  94. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js +16 -12
  95. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js.map +1 -1
  96. package/payload/platform/plugins/outlook/references/auth.md +2 -0
  97. package/payload/platform/plugins/outlook/references/graph-surfaces.md +20 -9
  98. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +7 -7
  99. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  100. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.d.ts +2 -0
  101. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.d.ts.map +1 -0
  102. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.js +36 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.js.map +1 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.d.ts +9 -0
  105. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.d.ts.map +1 -0
  106. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.js +51 -0
  107. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.js.map +1 -0
  108. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +16 -2
  109. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
  110. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +16 -2
  111. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  112. package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
  113. package/payload/platform/scripts/smoke-boot-services.sh +1 -0
  114. package/payload/platform/scripts/voice-mirror-audit.sh +70 -0
  115. package/payload/platform/services/claude-session-manager/dist/config.d.ts +5 -0
  116. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  117. package/payload/platform/services/claude-session-manager/dist/config.js +7 -1
  118. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  119. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +15 -0
  120. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  121. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +38 -3
  122. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  123. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  124. package/payload/platform/services/claude-session-manager/dist/http-server.js +21 -10
  125. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  126. package/payload/platform/services/claude-session-manager/dist/index.js +20 -0
  127. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  128. package/payload/platform/services/claude-session-manager/dist/plugin-enum.d.ts +34 -0
  129. package/payload/platform/services/claude-session-manager/dist/plugin-enum.d.ts.map +1 -0
  130. package/payload/platform/services/claude-session-manager/dist/plugin-enum.js +66 -0
  131. package/payload/platform/services/claude-session-manager/dist/plugin-enum.js.map +1 -0
  132. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  133. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -1
  134. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  135. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  136. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +7 -0
  137. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  138. package/payload/server/{chunk-TTKYHVA7.js → chunk-SDYRKIYY.js} +70 -3
  139. package/payload/server/server.js +59 -10
  140. package/payload/server/{src-FBUXV2FV.js → src-554BYJMN.js} +5 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,mEAAmE;AACnE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,2EAA2E;AAC3E,uEAAuE;AACvE,4DAA4D;;;;;;;;;;;;;;;;AAE5D,iDAA+B;AAC/B,gDAA8B;AAC9B,wDAAsC;AACtC,+CAA6B;AAC7B,6CAA2B;AAC3B,iDAA+B"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,mEAAmE;AACnE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,2EAA2E;AAC3E,uEAAuE;AACvE,4DAA4D;;;;;;;;;;;;;;;;AAE5D,iDAA+B;AAC/B,gDAA8B;AAC9B,wDAAsC;AACtC,+CAA6B;AAC7B,0DAAwC;AACxC,6CAA2B;AAC3B,iDAA+B"}
@@ -1 +1 @@
1
- {"version":3,"file":"remediate.d.ts","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAOD,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,EAAE,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,aAAa,CAGf;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM/D"}
1
+ {"version":3,"file":"remediate.d.ts","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAoBD,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,EAAE,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,aAAa,CAGf;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM/D"}
@@ -10,7 +10,20 @@ exports.stripAccountWideTokens = stripAccountWideTokens;
10
10
  // Account-wide credentials stripped from a sub-account's cloudflare.env.
11
11
  // CLOUDFLARE_ACCOUNT_ID is intentionally kept: it is an identifier, not a
12
12
  // credential, and does not grant access on its own.
13
- const STRIP_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
13
+ // Account-wide-reach tokens that must never persist in a sub-account secrets
14
+ // file: the master and every scoped DATA token that reaches the whole shared
15
+ // account. CF_STORAGE_TOKEN (D1+R2) and CF_CALENDAR_D1_TOKEN (D1) are house-code
16
+ // data scopes (Task 1670) minted account-scoped, so a copy in a sub-account file
17
+ // would let that tenant reach every other tenant's D1/R2 — strip them too. The
18
+ // account's own zone/pages hosting tokens (CF_DNS_TOKEN, etc.) are narrow and
19
+ // stay.
20
+ const STRIP_KEYS = [
21
+ "CLOUDFLARE_API_TOKEN",
22
+ "CF_PAGES_D1_TOKEN",
23
+ "CF_PAGES_TOKEN",
24
+ "CF_STORAGE_TOKEN",
25
+ "CF_CALENDAR_D1_TOKEN",
26
+ ];
14
27
  function validateMapping(enumerated, mapping) {
15
28
  const missing = enumerated.filter((name) => !(name in mapping));
16
29
  return { ok: missing.length === 0, missing };
@@ -1 +1 @@
1
- {"version":3,"file":"remediate.js","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":";AAAA,+EAA+E;AAC/E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,wEAAwE;;AAYxE,0CAMC;AAED,wDAMC;AAnBD,yEAAyE;AACzE,0EAA0E;AAC1E,oDAAoD;AACpD,MAAM,UAAU,GAAG,CAAC,sBAAsB,EAAE,mBAAmB,EAAE,gBAAgB,CAAC,CAAC;AAEnF,SAAgB,eAAe,CAC7B,UAAoB,EACpB,OAA+B;IAE/B,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC;IAChE,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;AAC/C,CAAC;AAED,SAAgB,sBAAsB,CAAC,QAAgB;IACrD,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE;QAChD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,+BAA+B,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7D,OAAO,CAAC,CAAC,GAAG,IAAI,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"remediate.js","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":";AAAA,+EAA+E;AAC/E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,wEAAwE;;AAyBxE,0CAMC;AAED,wDAMC;AAhCD,yEAAyE;AACzE,0EAA0E;AAC1E,oDAAoD;AACpD,6EAA6E;AAC7E,6EAA6E;AAC7E,iFAAiF;AACjF,iFAAiF;AACjF,+EAA+E;AAC/E,8EAA8E;AAC9E,QAAQ;AACR,MAAM,UAAU,GAAG;IACjB,sBAAsB;IACtB,mBAAmB;IACnB,gBAAgB;IAChB,kBAAkB;IAClB,sBAAsB;CACvB,CAAC;AAEF,SAAgB,eAAe,CAC7B,UAAoB,EACpB,OAA+B;IAE/B,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC;IAChE,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;AAC/C,CAAC;AAED,SAAgB,sBAAsB,CAAC,QAAgB;IACrD,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE;QAChD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,+BAA+B,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7D,OAAO,CAAC,CAAC,GAAG,IAAI,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACzB,CAAC"}
@@ -1,6 +1,9 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { makeCfExec, type RunFn, type FetchFn } from "../cf-exec.js";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { makeCfExec, makeHouseCfExec, type RunFn, type FetchFn } from "../cf-exec.js";
4
7
 
5
8
  const cred = { apiToken: "cfat_x", accountId: "acc-h" };
6
9
 
@@ -158,3 +161,27 @@ test("non-JSON output throws with the raw body", async () => {
158
161
  const cf = makeCfExec(cred, run);
159
162
  await assert.rejects(() => cf.d1List(), /not json/);
160
163
  });
164
+
165
+ // Task 1670 — makeHouseCfExec resolves a scoped storage token from the house
166
+ // minter via cf-token.sh and uses THAT token (never the minter) for wrangler.
167
+ test("makeHouseCfExec runs wrangler with the scoped token, not the minter", async () => {
168
+ const platformRoot = mkdtempSync(join(tmpdir(), "house-cfexec-"));
169
+ const cfg = join(platformRoot, "config");
170
+ mkdirSync(cfg, { recursive: true });
171
+ const houseEnv = join(cfg, "cloudflare-house.env");
172
+ writeFileSync(houseEnv, "CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc-h\n");
173
+
174
+ let sawToken = "";
175
+ const run: RunFn = async (_cmd, args, env) => {
176
+ if (args.some((a) => a.endsWith("cf-token.sh"))) {
177
+ appendFileSync(houseEnv, "CF_STORAGE_TOKEN=scoped_tok\n");
178
+ return { stdout: "CF_STORAGE_TOKEN\n" };
179
+ }
180
+ sawToken = env.CLOUDFLARE_API_TOKEN;
181
+ return { stdout: "[]" };
182
+ };
183
+
184
+ const cf = await makeHouseCfExec(platformRoot, run);
185
+ await cf.d1List();
186
+ assert.equal(sawToken, "scoped_tok", "wrangler saw the scoped token, not the minter");
187
+ });
@@ -0,0 +1,48 @@
1
+ // Task 1670 — resolveHouseScopedToken shells cf-token.sh against the house env
2
+ // file and reads back the persisted key value. The cf-token.sh invocation is
3
+ // mocked via the injected RunFn (it appends the key and echoes the key name).
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, writeFileSync, appendFileSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { resolveHouseScopedToken } from "../house-scoped-token.js";
10
+ import type { RunFn } from "../cf-exec.js";
11
+
12
+ function houseFile(contents: string): string {
13
+ const dir = mkdtempSync(join(tmpdir(), "hst-"));
14
+ const p = join(dir, "cloudflare-house.env");
15
+ writeFileSync(p, contents);
16
+ return p;
17
+ }
18
+
19
+ test("mints and reads back the scoped token value", async () => {
20
+ const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\n");
21
+ const run: RunFn = async (_cmd, args) => {
22
+ assert.equal(args[1], "storage", "scope passed to cf-token.sh");
23
+ appendFileSync(p, "CF_STORAGE_TOKEN=minted_value\n");
24
+ return { stdout: "CF_STORAGE_TOKEN\n" };
25
+ };
26
+ const tok = await resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run);
27
+ assert.equal(tok, "minted_value");
28
+ });
29
+
30
+ test("reuse: a pre-existing key resolves to its value", async () => {
31
+ const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\nCF_STORAGE_TOKEN=already_here\n");
32
+ // cf-token.sh reuse path writes nothing new, still echoes the key.
33
+ const run: RunFn = async () => ({ stdout: "CF_STORAGE_TOKEN\n" });
34
+ const tok = await resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run);
35
+ assert.equal(tok, "already_here");
36
+ });
37
+
38
+ test("throws when the persisted key is absent after the run", async () => {
39
+ const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\n");
40
+ const run: RunFn = async () => ({ stdout: "CF_STORAGE_TOKEN\n" }); // writes nothing
41
+ await assert.rejects(() => resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run));
42
+ });
43
+
44
+ test("throws when cf-token.sh returns no key name", async () => {
45
+ const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\n");
46
+ const run: RunFn = async () => ({ stdout: "\n" });
47
+ await assert.rejects(() => resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run));
48
+ });
@@ -19,3 +19,11 @@ test("strips only account-wide token lines, keeps the account id and scoped toke
19
19
  "CLOUDFLARE_ACCOUNT_ID=acc\nCLOUDFLARE_API_TOKEN=cfat_x\nCF_PAGES_D1_TOKEN=y\nCF_DNS_TOKEN=keep\n";
20
20
  assert.equal(stripAccountWideTokens(before), "CLOUDFLARE_ACCOUNT_ID=acc\nCF_DNS_TOKEN=keep\n");
21
21
  });
22
+
23
+ // Task 1670 — the house-code data scopes reach the whole shared account, so a
24
+ // stray copy in a sub-account file is a cross-tenant leak and must be stripped.
25
+ test("strips the account-wide data scopes (storage, calendar-d1), keeps zone hosting tokens", () => {
26
+ const before =
27
+ "CLOUDFLARE_ACCOUNT_ID=acc\nCF_STORAGE_TOKEN=cfat_d1r2\nCF_CALENDAR_D1_TOKEN=cfat_d1\nCF_DNS_TOKEN=keep\n";
28
+ assert.equal(stripAccountWideTokens(before), "CLOUDFLARE_ACCOUNT_ID=acc\nCF_DNS_TOKEN=keep\n");
29
+ });
@@ -14,6 +14,7 @@ import { validateMapping, stripAccountWideTokens } from "../remediate.js";
14
14
  import { registerResource, type StorageResource } from "../registry.js";
15
15
  import { readHouseCredential } from "../house-credential.js";
16
16
  import { makeCfExec } from "../cf-exec.js";
17
+ import { resolveHouseScopedToken } from "../house-scoped-token.js";
17
18
 
18
19
  export interface RemediationDeps {
19
20
  cfD1(): Promise<Array<{ name: string; cfResourceId: string }>>;
@@ -77,7 +78,9 @@ export async function runRemediation(deps: RemediationDeps): Promise<Remediation
77
78
 
78
79
  // --- on-device CLI (untested glue) ----------------------------------------
79
80
 
80
- function findMasterFromAccounts(accountsDir: string): { apiToken: string; accountId: string } | null {
81
+ function findMasterFromAccounts(
82
+ accountsDir: string,
83
+ ): { apiToken: string; accountId: string; path: string } | null {
81
84
  if (!existsSync(accountsDir)) return null;
82
85
  for (const accountId of readdirSync(accountsDir)) {
83
86
  const p = join(accountsDir, accountId, "secrets", "cloudflare.env");
@@ -88,7 +91,7 @@ function findMasterFromAccounts(accountsDir: string): { apiToken: string; accoun
88
91
  if (m) env[m[1]] = m[2].trim();
89
92
  }
90
93
  if (env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID) {
91
- return { apiToken: env.CLOUDFLARE_API_TOKEN, accountId: env.CLOUDFLARE_ACCOUNT_ID };
94
+ return { apiToken: env.CLOUDFLARE_API_TOKEN, accountId: env.CLOUDFLARE_ACCOUNT_ID, path: p };
92
95
  }
93
96
  }
94
97
  return null;
@@ -110,10 +113,13 @@ async function main(): Promise<void> {
110
113
  const mapping = JSON.parse(readFileSync(mappingFile, "utf8")) as Record<string, string>;
111
114
 
112
115
  // The master is wherever it lives today: the house file if already relocated,
113
- // else the first sub-account secrets file that still carries it.
116
+ // else the first sub-account secrets file that still carries it. masterFile is
117
+ // the file cf-token.sh mints the scoped enumeration token against.
114
118
  let master: { apiToken: string; accountId: string };
119
+ let masterFile: string;
115
120
  try {
116
121
  master = readHouseCredential(platformRoot);
122
+ masterFile = configPath;
117
123
  } catch {
118
124
  const found = findMasterFromAccounts(accountsDir);
119
125
  if (!found) {
@@ -121,10 +127,16 @@ async function main(): Promise<void> {
121
127
  process.exit(2);
122
128
  return;
123
129
  }
124
- master = found;
130
+ master = { apiToken: found.apiToken, accountId: found.accountId };
131
+ masterFile = found.path;
125
132
  }
126
133
 
127
- const cf = makeCfExec(master);
134
+ // Task 1670 — the master is a minter; enumerate D1/R2 through a scoped storage
135
+ // token minted from it, never the minter directly. `master` (the raw minter)
136
+ // is still what runRemediation relocates into the house file below.
137
+ const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
138
+ const scoped = await resolveHouseScopedToken("storage", masterFile, cfTokenSh);
139
+ const cf = makeCfExec({ apiToken: scoped, accountId: master.accountId });
128
140
  const neo4j = (await import("neo4j-driver")) as any;
129
141
  const driver = neo4j.default.driver(
130
142
  process.env.NEO4J_URI,
@@ -3,7 +3,10 @@
3
3
  // wrangler. `run` is injectable so the wiring is unit-testable without wrangler.
4
4
 
5
5
  import { execFile } from "node:child_process";
6
+ import { join } from "node:path";
6
7
  import type { HouseCredential } from "./house-credential.js";
8
+ import { readHouseCredential } from "./house-credential.js";
9
+ import { resolveHouseScopedToken } from "./house-scoped-token.js";
7
10
 
8
11
  export type RunFn = (
9
12
  cmd: string,
@@ -137,3 +140,22 @@ export function makeCfExec(
137
140
  },
138
141
  };
139
142
  }
143
+
144
+ // Task 1670 — build a CfExec whose token is a D1+R2 "storage" scope minted from
145
+ // the house minter, never the minter used directly (a minter cannot run
146
+ // wrangler d1/r2 or hit the D1/R2 API — it returns 10000). cf-token.sh
147
+ // mints-or-reuses and persists the scope house-side; the resolved value is used
148
+ // for both the wrangler run env and the API Bearer. The two UI-route broker
149
+ // sites and remediate-run construct their CfExec through the house minter this
150
+ // way instead of `makeCfExec(readHouseCredential(...))`.
151
+ export async function makeHouseCfExec(
152
+ platformRoot: string,
153
+ run: RunFn = defaultRun,
154
+ fetchFn: FetchFn = defaultFetch,
155
+ ): Promise<CfExec> {
156
+ const cred = readHouseCredential(platformRoot);
157
+ const houseEnvPath = join(platformRoot, "config", "cloudflare-house.env");
158
+ const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
159
+ const scoped = await resolveHouseScopedToken("storage", houseEnvPath, cfTokenSh, run);
160
+ return makeCfExec({ apiToken: scoped, accountId: cred.accountId }, run, fetchFn);
161
+ }
@@ -0,0 +1,72 @@
1
+ // Task 1670 — the house credential is a minter. Each broker/calendar consumer
2
+ // mints (and thereafter reuses) a scoped data token from it via cf-token.sh, the
3
+ // single Cloudflare mint/reuse state machine. This wrapper shells that script
4
+ // against the house env file and reads back the persisted key value. The mint
5
+ // logic itself is not duplicated here — it lives only in cf-token.sh.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { execFile } from "node:child_process";
9
+ import type { RunFn } from "./cf-exec.js";
10
+
11
+ const defaultRun: RunFn = (cmd, args, env) =>
12
+ new Promise((resolve, reject) => {
13
+ execFile(cmd, args, { env }, (err, stdout, stderr) => {
14
+ if (err) {
15
+ reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}\n${stderr}`));
16
+ return;
17
+ }
18
+ resolve({ stdout: stdout.toString() });
19
+ });
20
+ });
21
+
22
+ function readKey(path: string, key: string): string | undefined {
23
+ for (const line of readFileSync(path, "utf8").split("\n")) {
24
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
25
+ if (m && m[1] === key) return m[2].trim();
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ /**
31
+ * Resolve a scoped data token (e.g. "storage" = D1+R2) from the house minter.
32
+ * Shells cf-token.sh, which mints or reuses the scope and persists the key into
33
+ * the house env file, echoing the key name. Reads that key back and returns its
34
+ * value. Throws if the key is absent after the run (never returns the minter).
35
+ */
36
+ export async function resolveHouseScopedToken(
37
+ scope: string,
38
+ houseEnvPath: string,
39
+ cfTokenShPath: string,
40
+ run: RunFn = defaultRun,
41
+ ): Promise<string> {
42
+ const preexisting = readKey(houseEnvPath, keyForScope(scope)) !== undefined;
43
+ const { stdout } = await run(
44
+ "bash",
45
+ [cfTokenShPath, scope, houseEnvPath],
46
+ { ...process.env } as Record<string, string>,
47
+ );
48
+ const keyName = stdout.trim().split("\n").pop()?.trim() ?? "";
49
+ if (!keyName) throw new Error(`storage-broker: cf-token.sh ${scope} returned no key name`);
50
+ const value = readKey(houseEnvPath, keyName);
51
+ if (!value) {
52
+ throw new Error(`storage-broker: scoped token ${keyName} absent in ${houseEnvPath} after mint`);
53
+ }
54
+ console.error(
55
+ `[storage-broker] op=house-scoped-token src=house scope=${scope} action=${preexisting ? "reuse" : "mint"} result=active`,
56
+ );
57
+ return value;
58
+ }
59
+
60
+ // The persisted key name for each scope, mirroring cf-token.sh's scope table.
61
+ // Used only to derive the mint/reuse action for the observability line; the
62
+ // authoritative key is whatever cf-token.sh echoes.
63
+ function keyForScope(scope: string): string {
64
+ switch (scope) {
65
+ case "storage":
66
+ return "CF_STORAGE_TOKEN";
67
+ case "calendar-d1":
68
+ return "CF_CALENDAR_D1_TOKEN";
69
+ default:
70
+ return "";
71
+ }
72
+ }
@@ -10,5 +10,6 @@ export * from "./authorize.js";
10
10
  export * from "./registry.js";
11
11
  export * from "./house-credential.js";
12
12
  export * from "./cf-exec.js";
13
+ export * from "./house-scoped-token.js";
13
14
  export * from "./audit.js";
14
15
  export * from "./remediate.js";
@@ -12,7 +12,20 @@ export interface MappingResult {
12
12
  // Account-wide credentials stripped from a sub-account's cloudflare.env.
13
13
  // CLOUDFLARE_ACCOUNT_ID is intentionally kept: it is an identifier, not a
14
14
  // credential, and does not grant access on its own.
15
- const STRIP_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
15
+ // Account-wide-reach tokens that must never persist in a sub-account secrets
16
+ // file: the master and every scoped DATA token that reaches the whole shared
17
+ // account. CF_STORAGE_TOKEN (D1+R2) and CF_CALENDAR_D1_TOKEN (D1) are house-code
18
+ // data scopes (Task 1670) minted account-scoped, so a copy in a sub-account file
19
+ // would let that tenant reach every other tenant's D1/R2 — strip them too. The
20
+ // account's own zone/pages hosting tokens (CF_DNS_TOKEN, etc.) are narrow and
21
+ // stay.
22
+ const STRIP_KEYS = [
23
+ "CLOUDFLARE_API_TOKEN",
24
+ "CF_PAGES_D1_TOKEN",
25
+ "CF_PAGES_TOKEN",
26
+ "CF_STORAGE_TOKEN",
27
+ "CF_CALENDAR_D1_TOKEN",
28
+ ];
16
29
 
17
30
  export function validateMapping(
18
31
  enumerated: string[],
@@ -180,6 +180,7 @@ Tools are available via the `admin` MCP server.
180
180
 
181
181
  ## Hooks
182
182
 
183
+ - `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.
183
184
  - `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.
184
185
  - `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.
185
186
  - `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.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for quote-render-gate.sh.
3
+ #
4
+ # The gate is a PreToolUse hook on browser-pdf-save. It refuses a client-facing
5
+ # quote PDF (memory/users/<phone>/documents/quote-<jobId>.pdf or
6
+ # ...-breakdown.pdf) unless a render receipt exists for that jobId — proof the
7
+ # deterministic quote-render path produced it. Everything else passes.
8
+ #
9
+ # Covers:
10
+ # A. client quote PDF, no receipt → BLOCKED (exit 2)
11
+ # B. client quote PDF, receipt present → ALLOWED (exit 0)
12
+ # C. client breakdown PDF, no receipt → BLOCKED
13
+ # D. client breakdown PDF, receipt present → ALLOWED
14
+ # E. non-quote PDF in the documents scope → ALLOWED (not a quote artefact)
15
+ # F. internal quote PDF (admin scope) → ALLOWED (not client-facing)
16
+ # G. quote-named PDF outside the client scope → ALLOWED (only the documents scope is gated)
17
+ # H. empty stdin → ALLOWED (general-purpose tool, fail-open)
18
+ # I. a non-browser tool → ALLOWED (matcher defence-in-depth)
19
+ # J. jobId carrying a path traversal → BLOCKED (never resolves a stray receipt)
20
+ # K. a block emits the op=bypass fault line
21
+
22
+ set -u
23
+
24
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-render-gate.sh"
25
+ if [[ ! -x "$HOOK" ]]; then
26
+ echo "FAIL: $HOOK not executable" >&2
27
+ exit 1
28
+ fi
29
+
30
+ ACCT=$(mktemp -d)
31
+ cleanup() { rm -rf "$ACCT"; }
32
+ trap cleanup EXIT
33
+
34
+ # A receipt for job-1 (present) — jobs job-2 / job-3 have none.
35
+ mkdir -p "$ACCT/quoting/jobs/job-1"
36
+ printf '{"jobId":"job-1"}' > "$ACCT/quoting/jobs/job-1/render-receipt.json"
37
+
38
+ DOCS="$ACCT/memory/users/44700900000/documents"
39
+ ADMIN="$ACCT/memory/admin/customers/44700900000"
40
+
41
+ PASS=0
42
+ FAIL=0
43
+
44
+ # Run the hook with cwd = the account dir (as the PTY does), capturing exit + stderr.
45
+ run_case() {
46
+ local name="$1" stdin="$2" expected_exit="$3"
47
+ local actual_exit
48
+ ( cd "$ACCT" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
49
+ actual_exit=$?
50
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
51
+ echo "PASS: $name (exit=$actual_exit)"
52
+ PASS=$((PASS + 1))
53
+ else
54
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
55
+ FAIL=$((FAIL + 1))
56
+ fi
57
+ }
58
+
59
+ envelope() { # tool_name, path
60
+ printf '{"hook_event_name":"PreToolUse","tool_name":"%s","tool_input":{"path":"%s"}}' "$1" "$2"
61
+ }
62
+
63
+ BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
64
+
65
+ run_case "A: client quote PDF, no receipt → BLOCKED" \
66
+ "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" 2
67
+
68
+ run_case "B: client quote PDF, receipt present → ALLOWED" \
69
+ "$(envelope "$BROWSER" "$DOCS/quote-job-1.pdf")" 0
70
+
71
+ run_case "C: client breakdown PDF, no receipt → BLOCKED" \
72
+ "$(envelope "$BROWSER" "$DOCS/quote-job-3-breakdown.pdf")" 2
73
+
74
+ run_case "D: client breakdown PDF, receipt present → ALLOWED" \
75
+ "$(envelope "$BROWSER" "$DOCS/quote-job-1-breakdown.pdf")" 0
76
+
77
+ run_case "E: non-quote PDF in documents scope → ALLOWED" \
78
+ "$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
79
+
80
+ run_case "F: internal quote PDF (admin scope) → ALLOWED" \
81
+ "$(envelope "$BROWSER" "$ADMIN/quote-job-2-internal.pdf")" 0
82
+
83
+ run_case "G: quote-named PDF outside client scope → ALLOWED" \
84
+ "$(envelope "$BROWSER" "$ACCT/quote-job-2.pdf")" 0
85
+
86
+ run_case "H: empty stdin → ALLOWED (fail-open)" \
87
+ "" 0
88
+
89
+ run_case "I: non-browser tool → ALLOWED" \
90
+ '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/x/memory/users/44700900000/documents/quote-job-2.pdf"}}' 0
91
+
92
+ run_case "J: jobId path traversal → BLOCKED" \
93
+ "$(envelope "$BROWSER" "$DOCS/quote-...pdf")" 2
94
+
95
+ # K: a block emits the op=bypass fault line on stderr.
96
+ STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-job-2.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
97
+ if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=job-2'; then
98
+ echo "PASS: K: block emits op=bypass jobId=job-2"
99
+ PASS=$((PASS + 1))
100
+ else
101
+ echo "FAIL: K: op=bypass line absent (got: $STDERR)" >&2
102
+ FAIL=$((FAIL + 1))
103
+ fi
104
+
105
+ echo "──────── quote-render-gate test summary ────────"
106
+ echo "PASS=$PASS FAIL=$FAIL"
107
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # quote-render-gate — PreToolUse gate on browser-pdf-save.
3
+ #
4
+ # A customer only ever receives the two client-facing quote documents from the
5
+ # customer documents scope (memory/users/<phone>/documents/). This gate refuses
6
+ # to print one to PDF unless the deterministic quote-render tool actually
7
+ # produced it — proven by the render receipt quote-render writes on success
8
+ # (quoting/jobs/<jobId>/render-receipt.json). No receipt for the job means the
9
+ # quote was hand-authored off the deterministic path, so the fixed-layout
10
+ # guarantee and the reconcile/margin/link gates never ran; the PDF is refused and
11
+ # the bypass is surfaced as [quote-render] op=bypass, not delivered.
12
+ #
13
+ # Scope: only a .pdf under memory/users/<phone>/documents/ named
14
+ # quote-<jobId>.pdf or quote-<jobId>-breakdown.pdf is gated. Every other PDF —
15
+ # brochures, invoices, the admin-scope internal view — passes untouched.
16
+ #
17
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
18
+ # uninspectable call (browser-pdf-save is a general-purpose tool; the bypass we
19
+ # guard is an agent skipping quote-render, and the agent never controls this
20
+ # hook's stdin, so failing open here opens no bypass). No task numbers or
21
+ # internal refs in any operator-visible string.
22
+
23
+ set -uo pipefail
24
+
25
+ # No envelope (tty or empty stdin) → allow: cannot inspect, must not brick PDFs.
26
+ if [ -t 0 ]; then exit 0; fi
27
+ INPUT=$(cat)
28
+ [ -z "$INPUT" ] && exit 0
29
+
30
+ # Print the jobId of a gated client-quote artefact, or nothing for any other
31
+ # call. The customer documents scope is a path convention, so match on it
32
+ # structurally rather than resolving symlinks.
33
+ JOBID=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
34
+ import os, json, re, sys
35
+ try:
36
+ d = json.loads(os.environ["INPUT"])
37
+ except Exception:
38
+ sys.exit(0)
39
+ tool = d.get("tool_name", "") or ""
40
+ if not tool.endswith("browser-pdf-save"):
41
+ sys.exit(0)
42
+ path = (d.get("tool_input", {}) or {}).get("path", "") or ""
43
+ if not path:
44
+ sys.exit(0)
45
+ norm = os.path.normpath(path)
46
+ m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(?:-breakdown)?\.pdf$', norm)
47
+ if not m:
48
+ sys.exit(0)
49
+ print(m.group(1))
50
+ PY
51
+ )
52
+
53
+ # Not a gated client-quote artefact → allow.
54
+ [ -z "$JOBID" ] && exit 0
55
+
56
+ # A jobId is a single path segment. A separator or traversal in it never keys a
57
+ # legitimate receipt, so treat it as a bypass rather than resolving a stray file.
58
+ case "$JOBID" in
59
+ */*|*..*)
60
+ echo "[quote-render] op=bypass jobId=$JOBID reason=bad-jobid" >&2
61
+ echo "Blocked: this looks like a client quote PDF but its name does not resolve to a job. Produce the quote with the quote-render tool, which files the document and its render receipt, then print the path it returns." >&2
62
+ exit 2
63
+ ;;
64
+ esac
65
+
66
+ # The receipt lives beside the job's figures, written by quote-render only after
67
+ # every gate passed. Its presence is the proof the deterministic path ran.
68
+ if [ -f "$PWD/quoting/jobs/$JOBID/render-receipt.json" ]; then
69
+ exit 0
70
+ fi
71
+
72
+ echo "[quote-render] op=bypass jobId=$JOBID detail=client quote PDF has no render receipt" >&2
73
+ echo "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." >&2
74
+ exit 2
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:c170bc722cc54b4882abbe20267c10b8b007ace08d916b4e857ce0868f3769cb
4
+ content-hash: sha256:8718b7135f79829576ed6a21c6a0bb39a62eaf8a8e772cec37124c3aa44dfdc7
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -2044,7 +2044,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
2044
2044
 
2045
2045
  1. **No app to register.** The client ID ships with the brand (`brand.json#outlookClientId`), and the installer stamps it onto the session-manager env, so there is zero operator Azure work. On a brand that ships an empty value the plugin stays inert until its vendor app exists — see `platform/plugins/outlook/references/auth.md` for the one-time-per-brand vendor provisioning runbook (a vendor step, not an operator or end-user step), including setting **Allow public client flows** to Yes.
2046
2046
  2. **Per account: register the Outlook account** — in admin chat, ask the agent to "register my Outlook account". The agent runs `outlook-account-register`, which returns a short user code and the URL `microsoft.com/devicelogin` and relays both to you.
2047
- 3. **Consent on any device** — open `microsoft.com/devicelogin` on your phone or laptop, enter the code, sign in to your Microsoft account, and consent to the requested scopes (`offline_access`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`, `Contacts.Read`). `Mail.Send` enables sending and `Calendars.ReadWrite` enables calendar control; a mailbox last consented on an older read-only scope set must re-register before send or any calendar write succeeds. The agent finishes by calling `outlook-account-register-poll`.
2047
+ 3. **Consent on any device** — open `microsoft.com/devicelogin` on your phone or laptop, enter the code, sign in to your Microsoft account, and consent to the requested scopes (`offline_access`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`, `Contacts.Read`). `Mail.Send` enables sending and `Calendars.ReadWrite` enables calendar control; a mailbox last consented on an older read-only scope set must re-register before send or any calendar write succeeds. Nothing else to do: the server finishes the sign-in automatically once you consent, usually within about half a minute, and the mailbox is then connected.
2048
2048
  4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.
2049
2049
  5. **Several mailboxes on one account.** Register again to add a second mailbox; it is stored alongside the first, not in place of it. Every operational tool then takes an optional `mailbox` argument — an email address or graphUserId. With one mailbox, omit it. With two or more, name it; omitting it returns a refusal listing the connected mailboxes. `outlook-mailbox-list` shows every connected mailbox. Only one registration runs at a time per account; starting another while one is live returns an in-progress signal.
2050
2050
 
@@ -2054,8 +2054,8 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
2054
2054
  |------|---------|
2055
2055
  | `outlook-account-register` | Start the device-code flow for this account. Returns a user code + `microsoft.com/devicelogin` URL immediately; does not block. |
2056
2056
  | `outlook-account-register-poll` | Finish the device-code flow. One check per call: returns pending, registered, expired, or denied. |
2057
- | `outlook-mail-list` | Recent mail. Default top=25, folder=Inbox. `folder` takes a well-known name or alias (case/space-insensitive): Inbox, Sent Items/sent, Drafts, Deleted Items/trash, Junk Email/junk/spam, Archive, Outbox — or a raw folder id. Custom folder names are not resolved. |
2058
- | `outlook-mail-search` | Microsoft Graph `$search` over the mailbox. Preview-only, like `outlook-mail-list`. |
2057
+ | `outlook-mail-list` | Mail newest-first. Default top=25, folder=Inbox. `folder` takes a well-known name or alias (case/space-insensitive): Inbox, Sent Items/sent, Drafts, Deleted Items/trash, Junk Email/junk/spam, Archive, Outbox — or a raw folder id. Custom folder names are not resolved. `since`/`before` (ISO date-times) narrow by receipt date. The 250-per-page figure is not a ceiling: the reply carries `nextCursor` when older mail remains — pass it back as `cursor` to page further back. Filter by sender/subject via `outlook-mail-search`. |
2058
+ | `outlook-mail-search` | Microsoft Graph `$search` (KQL) over the mailbox. Preview-only, like `outlook-mail-list`, and pages the same way via `nextCursor`/`cursor`. Put sender/subject/recipient/date filters in the query: `from:`, `to:`, `subject:`, `received>=`. Graph forbids `$search`+`$filter`, so a strict date window is on `outlook-mail-list`. |
2059
2059
  | `outlook-mail-fetch-body` | Complete body of one message by id (`GET /me/messages/{id}?$select=body`). HTML decoded to text, no preview cap. The full-read path — list/search return Microsoft's ~255-char preview only. Returns the envelope plus the whole body. |
2060
2060
  | `outlook-mail-reply` | Reply in-thread (`createReply` / `createReplyAll` + send). Threads natively via conversationId. `replyAll`, added cc/bcc, and account-scoped file attachments (up to 25 MB per file; files over Graph's ~3 MB inline limit use a chunked upload session). Body is plain text, prepended above the quote. Needs `Mail.ReadWrite` + `Mail.Send`. |
2061
2061
  | `outlook-mail-delete` | Move messages to Deleted Items (`POST /me/messages/{id}/move`). Recoverable — never hard-deletes. An id already gone is counted not-moved. Needs `Mail.ReadWrite`. |
@@ -48,7 +48,7 @@ echo "\$@" >> "\$SB/curl.log"
48
48
  url=""; for a in "\$@"; do case "\$a" in https://*) url="\$a";; esac; done
49
49
  case "\$url" in
50
50
  *"/tokens/permission_groups"*)
51
- echo '{"success":true,"result":[{"id":"pg_pages","name":"Pages Write"},{"id":"pg_d1","name":"D1 Write"},{"id":"pg_dns","name":"DNS Write"},{"id":"pg_acc1","name":"Access: Apps and Policies Write"},{"id":"pg_acc2","name":"Access: Apps and Policies Write"}]}'
51
+ echo '{"success":true,"result":[{"id":"pg_pages","name":"Pages Write"},{"id":"pg_d1","name":"D1 Write"},{"id":"pg_r2","name":"Workers R2 Storage Write"},{"id":"pg_dns","name":"DNS Write"},{"id":"pg_acc1","name":"Access: Apps and Policies Write"},{"id":"pg_acc2","name":"Access: Apps and Policies Write"}]}'
52
52
  ;;
53
53
  *"/tokens/verify"*) echo '{"success":true,"result":{"status":"active"}}';;
54
54
  *"/tokens"*) echo '{"success":true,"result":{"id":"tok1","value":"'"$SECRET_VALUE"'"}}';;
@@ -353,5 +353,29 @@ grep -q "^CF_PAGES_D1_TOKEN=" "$OVERRIDE" && ok "override env received the minte
353
353
  echo "$ERR" | grep -q "src=house" && ok "override reports src=house" || bad "override src: $ERR"
354
354
  teardown
355
355
 
356
+ # 17. storage scope (Task 1670): the broker's data token needs BOTH D1 Write and
357
+ # Workers R2 Storage Write, persisted under CF_STORAGE_TOKEN.
358
+ setup "cfat_master_abc"
359
+ run "storage mint D1+R2" storage 0
360
+ [ "$OUT" = "CF_STORAGE_TOKEN" ] && ok "storage stdout is CF_STORAGE_TOKEN" || bad "storage out='$OUT'"
361
+ POSTLINE=$(grep -- "-X POST" "$SANDBOX/curl.log")
362
+ echo "$POSTLINE" | grep -q "pg_d1" && ok "storage minted D1 id" || bad "storage no D1 id: $POSTLINE"
363
+ echo "$POSTLINE" | grep -q "pg_r2" && ok "storage minted R2 id" || bad "storage no R2 id: $POSTLINE"
364
+ grep -q "^CF_STORAGE_TOKEN=" "$SECRETS" && ok "storage token persisted" || bad "storage not persisted"
365
+ no_leak "storage mint"
366
+ teardown
367
+
368
+ # 18. calendar-d1 scope (Task 1670): the calendar's data token needs D1 Write
369
+ # ONLY (no R2), persisted under CF_CALENDAR_D1_TOKEN.
370
+ setup "cfat_master_abc"
371
+ run "calendar-d1 mint D1 only" calendar-d1 0
372
+ [ "$OUT" = "CF_CALENDAR_D1_TOKEN" ] && ok "calendar-d1 stdout is CF_CALENDAR_D1_TOKEN" || bad "calendar-d1 out='$OUT'"
373
+ POSTLINE=$(grep -- "-X POST" "$SANDBOX/curl.log")
374
+ echo "$POSTLINE" | grep -q "pg_d1" && ok "calendar-d1 minted D1 id" || bad "calendar-d1 no D1 id: $POSTLINE"
375
+ echo "$POSTLINE" | grep -q "pg_r2" && bad "calendar-d1 wrongly minted R2 id: $POSTLINE" || ok "calendar-d1 no R2 id"
376
+ grep -q "^CF_CALENDAR_D1_TOKEN=" "$SECRETS" && ok "calendar-d1 token persisted" || bad "calendar-d1 not persisted"
377
+ no_leak "calendar-d1 mint"
378
+ teardown
379
+
356
380
  echo "----"; echo "PASS=$PASS FAIL=$FAIL"
357
381
  [ "$FAIL" -eq 0 ]