@rubytech/create-maxy-code 0.1.286 → 0.1.287

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 (100) hide show
  1. package/dist/__tests__/samba-provision.test.js +84 -16
  2. package/dist/index.js +17 -8
  3. package/dist/samba-provision.js +81 -24
  4. package/dist/uninstall.js +5 -1
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  7. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
  8. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
  9. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
  10. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
  11. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
  12. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  13. package/payload/platform/plugins/docs/references/deployment.md +1 -1
  14. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  15. package/payload/platform/plugins/docs/references/samba.md +25 -12
  16. package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
  17. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
  18. package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
  19. package/payload/platform/scripts/installer-device-verify.sh +74 -3
  20. package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
  21. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
  22. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  23. package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
  24. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  25. package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
  26. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  27. package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
  28. package/payload/server/maxy-edge.js +1 -1
  29. package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
  30. package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
  31. package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
  32. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
  33. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
  34. package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
  35. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
  36. package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
  37. package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
  38. package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
  39. package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
  40. package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
  41. package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
  42. package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
  43. package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
  44. package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
  45. package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
  46. package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
  47. package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
  48. package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
  49. package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
  50. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
  51. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
  52. package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
  53. package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
  54. package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
  55. package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
  56. package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
  57. package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
  58. package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
  59. package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
  60. package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
  61. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
  62. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
  63. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
  64. package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
  65. package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
  66. package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
  67. package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
  68. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
  69. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
  70. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
  71. package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
  72. package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
  73. package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
  74. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
  75. package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
  76. package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
  77. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
  78. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
  79. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
  80. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
  81. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
  82. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
  83. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
  84. package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
  85. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
  86. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
  87. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
  88. package/payload/server/public/browser.html +4 -4
  89. package/payload/server/public/data.html +5 -5
  90. package/payload/server/public/graph.html +6 -6
  91. package/payload/server/public/index.html +5 -5
  92. package/payload/server/public/public.html +4 -4
  93. package/payload/server/server.js +269 -81
  94. package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
  95. package/payload/server/public/assets/channel-yIp47StE.js +0 -1
  96. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
  97. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
  98. package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
  99. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
  100. /package/payload/server/public/assets/{useSelectionMode-DMNfbxHW.js → useSelectionMode-hZ5bdL8b.js} +0 -0
@@ -6,7 +6,7 @@
6
6
  // style and runs under `node --test dist/__tests__/*.test.js` after build.
7
7
  import test from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, pickLanInterface, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, } from "../samba-provision.js";
9
+ import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Fixtures
12
12
  // ---------------------------------------------------------------------------
@@ -34,29 +34,77 @@ const PI_USB0_FALLBACK = {
34
34
  lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
35
35
  usb0: [{ address: "172.17.0.2", netmask: "255.255.255.0", family: "IPv4", mac: "22:22:22:22:22:22", internal: false, cidr: "172.17.0.2/24" }],
36
36
  };
37
+ // Hetzner shape: the only non-loopback interface is eth0 carrying the public
38
+ // IP directly (/32, no private LAN). Binding `lo eth0` here would expose smbd
39
+ // to the public internet — the defect Task 755 fixes by binding loopback-only.
40
+ const HETZNER_PUBLIC_ETH0_ONLY = {
41
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
42
+ eth0: [{ address: "167.233.21.246", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:01", internal: false, cidr: "167.233.21.246/32" }],
43
+ };
44
+ // Dual-homed box: a public eth0 plus a private wireguard/LAN interface. The
45
+ // private interface exists, so the bind stays LAN (operator confirmation,
46
+ // Task 755): loopback-only fires only when NO private interface exists.
47
+ const MIXED_PUBLIC_AND_PRIVATE = {
48
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
49
+ eth0: [{ address: "178.105.251.82", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:02", internal: false, cidr: "178.105.251.82/32" }],
50
+ wg0: [{ address: "10.8.0.3", netmask: "255.255.255.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: false, cidr: "10.8.0.3/24" }],
51
+ };
52
+ // ---------------------------------------------------------------------------
53
+ // isPrivateIPv4 — RFC1918 / CGNAT / link-local classification
54
+ // ---------------------------------------------------------------------------
55
+ test("isPrivateIPv4: RFC1918 ranges are private", () => {
56
+ assert.equal(isPrivateIPv4("10.0.0.5"), true);
57
+ assert.equal(isPrivateIPv4("172.16.0.1"), true);
58
+ assert.equal(isPrivateIPv4("172.31.255.254"), true);
59
+ assert.equal(isPrivateIPv4("192.168.1.50"), true);
60
+ });
61
+ test("isPrivateIPv4: CGNAT (100.64/10) and link-local (169.254/16) are private", () => {
62
+ assert.equal(isPrivateIPv4("100.64.0.1"), true);
63
+ assert.equal(isPrivateIPv4("100.127.255.254"), true);
64
+ assert.equal(isPrivateIPv4("169.254.1.1"), true);
65
+ });
66
+ test("isPrivateIPv4: routable public addresses are not private", () => {
67
+ assert.equal(isPrivateIPv4("167.233.21.246"), false);
68
+ assert.equal(isPrivateIPv4("178.105.251.82"), false);
69
+ assert.equal(isPrivateIPv4("8.8.8.8"), false);
70
+ // Boundary misses: 172.15/172.32 are outside the /12; 100.63/100.128 outside
71
+ // the CGNAT /10; 169.253/169.255 outside link-local.
72
+ assert.equal(isPrivateIPv4("172.15.0.1"), false);
73
+ assert.equal(isPrivateIPv4("172.32.0.1"), false);
74
+ assert.equal(isPrivateIPv4("100.63.255.255"), false);
75
+ assert.equal(isPrivateIPv4("100.128.0.0"), false);
76
+ assert.equal(isPrivateIPv4("169.253.0.1"), false);
77
+ });
37
78
  // ---------------------------------------------------------------------------
38
- // pickLanInterface
79
+ // pickBindDecision — private→LAN, public-only→loopback-only, none→none
39
80
  // ---------------------------------------------------------------------------
40
- test("pickLanInterface: wlan0 wins over eth0 when both present", () => {
41
- assert.equal(pickLanInterface(PI_BOTH_IFACES), "wlan0");
81
+ test("pickBindDecision: wlan0 wins over eth0 when both private", () => {
82
+ assert.deepEqual(pickBindDecision(PI_BOTH_IFACES), { kind: "lan", lanInterface: "wlan0" });
83
+ });
84
+ test("pickBindDecision: private wlan0 alone is a LAN bind", () => {
85
+ assert.deepEqual(pickBindDecision(PI_WLAN0_ONLY), { kind: "lan", lanInterface: "wlan0" });
42
86
  });
43
- test("pickLanInterface: wlan0 alone is picked", () => {
44
- assert.equal(pickLanInterface(PI_WLAN0_ONLY), "wlan0");
87
+ test("pickBindDecision: private eth0 alone is a LAN bind", () => {
88
+ assert.deepEqual(pickBindDecision(PI_ETH0_ONLY), { kind: "lan", lanInterface: "eth0" });
45
89
  });
46
- test("pickLanInterface: eth0 alone is picked", () => {
47
- assert.equal(pickLanInterface(PI_ETH0_ONLY), "eth0");
90
+ test("pickBindDecision: fallback to any non-loopback private IPv4 (usb0)", () => {
91
+ assert.deepEqual(pickBindDecision(PI_USB0_FALLBACK), { kind: "lan", lanInterface: "usb0" });
48
92
  });
49
- test("pickLanInterface: fallback to any non-loopback IPv4 (usb0)", () => {
50
- assert.equal(pickLanInterface(PI_USB0_FALLBACK), "usb0");
93
+ test("pickBindDecision: public-only host (Hetzner eth0 /32) binds loopback-only", () => {
94
+ assert.deepEqual(pickBindDecision(HETZNER_PUBLIC_ETH0_ONLY), { kind: "loopback-only" });
51
95
  });
52
- test("pickLanInterface: returns null when only loopback is present", () => {
53
- assert.equal(pickLanInterface(PI_LO_ONLY), null);
96
+ test("pickBindDecision: dual-homed (public eth0 + private wg0) binds to the private interface", () => {
97
+ assert.deepEqual(pickBindDecision(MIXED_PUBLIC_AND_PRIVATE), { kind: "lan", lanInterface: "wg0" });
54
98
  });
55
- test("pickLanInterface: returns null when the only non-loopback iface has no IPv4", () => {
99
+ test("pickBindDecision: none when only loopback is present", () => {
100
+ assert.deepEqual(pickBindDecision(PI_LO_ONLY), { kind: "none" });
101
+ });
102
+ test("pickBindDecision: none when the only non-loopback iface has no IPv4", () => {
56
103
  // smbd cannot bind to an IPv6-only address with `interfaces = lo eth0` syntax
57
- // unmodified; treat as no usable LAN interface so caller loud-fails rather
58
- // than writing a broken smb.conf.
59
- assert.equal(pickLanInterface(PI_IPV6_ONLY_LAN), null);
104
+ // unmodified; treat as no usable interface so the caller loud-fails rather
105
+ // than writing a broken smb.conf. Distinct from loopback-only, which still
106
+ // provisions a working (host-local) share.
107
+ assert.deepEqual(pickBindDecision(PI_IPV6_ONLY_LAN), { kind: "none" });
60
108
  });
61
109
  // ---------------------------------------------------------------------------
62
110
  // renderBrandStanza — exact spec match
@@ -115,6 +163,15 @@ test("renderGlobalSection binds to lo + LAN interface only", () => {
115
163
  // No `map to guest = ok` — guests are explicitly rejected as bad-user.
116
164
  assert.match(globals, /\n map to guest = bad user\n/);
117
165
  });
166
+ test("renderGlobalSection: loopback-only bind emits `interfaces = lo` with no second token", () => {
167
+ // Public-only host (Hetzner): lanInterface=null means bind smbd to loopback
168
+ // only, so the share is reachable solely via the box itself (SSH tunnel).
169
+ // `bind interfaces only = yes` still holds, so nothing else can connect.
170
+ const globals = renderGlobalSection({ lanInterface: null });
171
+ assert.match(globals, /\n interfaces = lo\n/);
172
+ assert.doesNotMatch(globals, /\n interfaces = lo \S/, "loopback-only must not name a second interface");
173
+ assert.match(globals, /\n bind interfaces only = yes\n/);
174
+ });
118
175
  // ---------------------------------------------------------------------------
119
176
  // renderFullSmbConf — composition order
120
177
  // ---------------------------------------------------------------------------
@@ -224,3 +281,14 @@ test("formatSambaMarker emits the `[install-invariant] samba-provision-<step> <s
224
281
  assert.equal(formatSambaMarker("user", "deferred reason=no-plaintext-pin"), "[install-invariant] samba-provision-user deferred reason=no-plaintext-pin");
225
282
  assert.equal(formatSambaMarker("units", "fail: Job for smbd.service failed"), "[install-invariant] samba-provision-units fail: Job for smbd.service failed");
226
283
  });
284
+ // ---------------------------------------------------------------------------
285
+ // SAMBA_ENABLE_UNITS — the units step must never enable nmbd (Task 755)
286
+ // ---------------------------------------------------------------------------
287
+ test("SAMBA_ENABLE_UNITS enables smbd only — nmbd is never started", () => {
288
+ // nmbd is the NetBIOS name service BSI flagged as a DDoS-reflection vector;
289
+ // nothing mounts the share by NetBIOS name (mDNS/LAN-IP only), so it is dead
290
+ // weight. The units step enables smbd alone. Uninstall still *disables* nmbd
291
+ // defensively to clean pre-fix boxes — that is a separate command vector.
292
+ assert.deepEqual([...SAMBA_ENABLE_UNITS], ["smbd"]);
293
+ assert.ok(!SAMBA_ENABLE_UNITS.includes("nmbd"), "nmbd must not be in the enable --now unit list");
294
+ });
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
18
18
  import { classifyPortHolder } from "./preflight-port-classifier.js";
19
19
  import { parsePluginList, computeInstallActions, parseExternalPlugins, } from "./lib/plugin-install.js";
20
20
  import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
21
- import { pickLanInterface, mergeSmbConf, formatSambaMarker, } from "./samba-provision.js";
21
+ import { pickBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
22
22
  import { networkInterfaces, userInfo } from "node:os";
23
23
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
24
24
  // Brand manifest — read from payload to derive all brand-specific installation values.
@@ -3512,7 +3512,7 @@ WantedBy=multi-user.target
3512
3512
  // conf — write the brand stanza + LAN-only globals to /etc/samba/smb.conf
3513
3513
  // user — smbpasswd the install-owner Linux user; deferred at install time
3514
3514
  // on fresh Pi installs (no plaintext PIN until the operator runs set-pin)
3515
- // units — systemctl enable --now smbd nmbd
3515
+ // units — systemctl enable --now smbd (nmbd is never enabled; Task 755)
3516
3516
  //
3517
3517
  // The platform's set-pin route handler closes the deferral loop by running
3518
3518
  // `smbpasswd -a -s <install-owner>` inline whenever the PIN is set or
@@ -3563,12 +3563,19 @@ function provisionSamba() {
3563
3563
  emitSambaMarker("apt", `fail: ${stderr}`);
3564
3564
  throw err;
3565
3565
  }
3566
- // Step 2 — write the brand stanza into /etc/samba/smb.conf.
3567
- const lanInterface = pickLanInterface(networkInterfaces());
3568
- if (!lanInterface) {
3566
+ // Step 2 — write the brand stanza into /etc/samba/smb.conf. Classify the
3567
+ // host's interfaces (Task 755): a private LAN interface → bind `lo <iface>`;
3568
+ // a public-only host (Hetzner) → bind loopback-only (`lanInterface = null`)
3569
+ // so the share is reachable only from the box itself; no non-loopback IPv4
3570
+ // interface at all → hard-fail, as before. Loopback-only is a distinct third
3571
+ // outcome, NOT the null/throw case.
3572
+ const bind = pickBindDecision(networkInterfaces());
3573
+ if (bind.kind === "none") {
3569
3574
  emitSambaMarker("conf", "fail: no non-loopback IPv4 interface");
3570
3575
  throw new Error("samba-provision: no LAN interface with IPv4 — cannot bind smbd safely");
3571
3576
  }
3577
+ const lanInterface = bind.kind === "lan" ? bind.lanInterface : null;
3578
+ const bindPosture = bind.kind === "lan" ? `bind=lan iface=${bind.lanInterface}` : "bind=loopback-only";
3572
3579
  const SMB_CONF = "/etc/samba/smb.conf";
3573
3580
  let existing = "";
3574
3581
  if (existsSync(SMB_CONF)) {
@@ -3610,7 +3617,7 @@ function provisionSamba() {
3610
3617
  if (visudoCheck.status !== 0) {
3611
3618
  throw new Error(`visudo rejected ${SUDOERS}: ${(visudoCheck.stderr ?? "").trim()}`);
3612
3619
  }
3613
- emitSambaMarker("conf", `ok lan=${lanInterface} owner=${installOwner}`);
3620
+ emitSambaMarker("conf", `ok ${bindPosture} owner=${installOwner}`);
3614
3621
  }
3615
3622
  catch (err) {
3616
3623
  const stderr = err instanceof Error ? err.message : String(err);
@@ -3624,9 +3631,11 @@ function provisionSamba() {
3624
3631
  // unbroken. Owner is recorded in the marker state so the install log shows
3625
3632
  // which Unix user the smbpasswd entry will target. Task 534.
3626
3633
  emitSambaMarker("user", `deferred reason=no-plaintext-pin-at-install owner=${installOwner} (set-pin route syncs)`);
3627
- // Step 4 — enable + start smbd and nmbd.
3634
+ // Step 4 — enable + start smbd. nmbd (NetBIOS) is deliberately excluded —
3635
+ // nothing mounts the share by NetBIOS name and it is a public DDoS-reflection
3636
+ // vector (Task 755). The unit list is locked in samba-provision.ts.
3628
3637
  try {
3629
- shell("systemctl", ["enable", "--now", "smbd", "nmbd"], { sudo: true, timeout: 30_000 });
3638
+ shell("systemctl", ["enable", "--now", ...SAMBA_ENABLE_UNITS], { sudo: true, timeout: 30_000 });
3630
3639
  emitSambaMarker("units", "ok");
3631
3640
  }
3632
3641
  catch (err) {
@@ -40,19 +40,31 @@ export function renderBrandStanza(input) {
40
40
  ].join("\n");
41
41
  }
42
42
  /**
43
- * Render the `[global]` section. `interfaces = lo <lan>` plus `bind interfaces
44
- * only = yes` is the LAN-only posture: smbd accepts connections on the LAN
45
- * interface and loopback, nothing else. Cloudflare tunnels carry HTTPS only,
46
- * so this is the structural guarantee that SMB never leaves the LAN even if
47
- * the firewall is misconfigured upstream.
43
+ * Render the `[global]` section. `bind interfaces only = yes` is always set; it
44
+ * is what makes `interfaces` an allow-list rather than a hint. The `interfaces`
45
+ * line has two shapes, keyed by `lanInterface`:
46
+ *
47
+ * - `lanInterface = "<iface>"` `interfaces = lo <iface>`. LAN-only posture:
48
+ * smbd accepts connections on that interface and loopback, nothing else.
49
+ * Used when the host has a private LAN interface (Pi).
50
+ * - `lanInterface = null` → `interfaces = lo`. Loopback-only posture: smbd is
51
+ * reachable solely from the box itself (SSH tunnel to `localhost:445`).
52
+ * Used when the only non-loopback interface carries a public address
53
+ * (Hetzner), so `bind interfaces only` would otherwise expose the share to
54
+ * the public internet (Task 755).
55
+ *
56
+ * Cloudflare tunnels carry HTTPS only, so this is the structural guarantee that
57
+ * SMB never leaves the box (loopback-only) or the LAN (LAN-only) even if the
58
+ * firewall is misconfigured upstream.
48
59
  */
49
60
  export function renderGlobalSection(input) {
61
+ const interfaces = input.lanInterface === null ? `lo` : `lo ${input.lanInterface}`;
50
62
  return [
51
63
  `[global]`,
52
64
  ` workgroup = WORKGROUP`,
53
65
  ` server string = %h Samba`,
54
66
  ` server role = standalone server`,
55
- ` interfaces = lo ${input.lanInterface}`,
67
+ ` interfaces = ${interfaces}`,
56
68
  ` bind interfaces only = yes`,
57
69
  ` log file = /var/log/samba/log.%m`,
58
70
  ` max log size = 1000`,
@@ -75,35 +87,70 @@ export function renderFullSmbConf(input) {
75
87
  renderBrandStanza({ brand: input.brand, sharePath: input.sharePath, installOwner: input.installOwner });
76
88
  }
77
89
  // ---------------------------------------------------------------------------
78
- // LAN interface detection
90
+ // Interface classification and bind decision
79
91
  // ---------------------------------------------------------------------------
80
92
  /**
81
- * Pick the LAN interface to bind smbd to. Preference order: wlan0, eth0, then
82
- * the first non-loopback interface with a non-internal IPv4 address. Returns
83
- * null when no such interface exists the caller treats that as a hard
84
- * failure (the Pi has no LAN connectivity; SMB has nothing to bind to).
93
+ * True when `address` is a private IPv4 address: RFC1918 (`10/8`, `172.16/12`,
94
+ * `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). These are
95
+ * the address spaces a Pi's LAN interface lives in. A routable public address
96
+ * (a Hetzner box's `eth0`) is not private binding smbd to it would expose the
97
+ * share to the internet, which is the defect Task 755 fixes.
85
98
  *
86
- * Input is the shape returned by `os.networkInterfaces()` so the test can pass
87
- * realistic fixtures without spinning up real interfaces.
99
+ * Loopback (`127/8`) is not classified here because `os.networkInterfaces()`
100
+ * marks it `internal: true` and the caller excludes it before ever asking.
101
+ */
102
+ export function isPrivateIPv4(address) {
103
+ const parts = address.split(".");
104
+ if (parts.length !== 4)
105
+ return false;
106
+ const octets = parts.map((p) => Number(p));
107
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
108
+ return false;
109
+ const [a, b] = octets;
110
+ if (a === 10)
111
+ return true; // 10.0.0.0/8
112
+ if (a === 172 && b >= 16 && b <= 31)
113
+ return true; // 172.16.0.0/12
114
+ if (a === 192 && b === 168)
115
+ return true; // 192.168.0.0/16
116
+ if (a === 100 && b >= 64 && b <= 127)
117
+ return true; // 100.64.0.0/10 (CGNAT)
118
+ if (a === 169 && b === 254)
119
+ return true; // 169.254.0.0/16 (link-local)
120
+ return false;
121
+ }
122
+ /**
123
+ * Decide how smbd should bind, given the shape returned by
124
+ * `os.networkInterfaces()` (so tests pass realistic fixtures without real
125
+ * interfaces). A private-addressed interface yields a LAN bind, in preference
126
+ * order wlan0, eth0, then the first other; this keeps the Pi path byte-
127
+ * identical to before. If non-loopback IPv4 interfaces exist but none is
128
+ * private, the host is public-only and the decision is loopback-only. If no
129
+ * non-loopback IPv4 interface exists, the decision is none.
88
130
  */
89
- export function pickLanInterface(ifaces) {
90
- const hasIPv4 = (name) => {
131
+ export function pickBindDecision(ifaces) {
132
+ const ipv4Addrs = (name) => {
91
133
  const addrs = ifaces[name];
92
134
  if (!addrs)
93
- return false;
94
- return addrs.some((a) => a.family === "IPv4" && !a.internal);
135
+ return [];
136
+ return addrs.filter((a) => a.family === "IPv4" && !a.internal).map((a) => a.address);
95
137
  };
96
- if (hasIPv4("wlan0"))
97
- return "wlan0";
98
- if (hasIPv4("eth0"))
99
- return "eth0";
138
+ const isPrivateIface = (name) => ipv4Addrs(name).some(isPrivateIPv4);
139
+ // LAN bind: a private-addressed interface, in the established preference order.
140
+ if (isPrivateIface("wlan0"))
141
+ return { kind: "lan", lanInterface: "wlan0" };
142
+ if (isPrivateIface("eth0"))
143
+ return { kind: "lan", lanInterface: "eth0" };
100
144
  for (const name of Object.keys(ifaces)) {
101
145
  if (name === "lo")
102
146
  continue;
103
- if (hasIPv4(name))
104
- return name;
147
+ if (isPrivateIface(name))
148
+ return { kind: "lan", lanInterface: name };
105
149
  }
106
- return null;
150
+ // No private interface. If any non-loopback IPv4 interface exists at all, the
151
+ // host is public-only → loopback-only. Otherwise there is nothing to bind to.
152
+ const hasNonLoopbackIPv4 = Object.keys(ifaces).some((name) => name !== "lo" && ipv4Addrs(name).length > 0);
153
+ return hasNonLoopbackIPv4 ? { kind: "loopback-only" } : { kind: "none" };
107
154
  }
108
155
  // ---------------------------------------------------------------------------
109
156
  // smb.conf merge / remove
@@ -213,6 +260,16 @@ export function hasAnyBrandStanza(conf) {
213
260
  * intentionally skipped because a precondition wasn't met).
214
261
  */
215
262
  export const SAMBA_STEPS = ["apt", "conf", "user", "units"];
263
+ /**
264
+ * The systemd units the install's `units` step runs `enable --now` against.
265
+ * `smbd` only — `nmbd` (NetBIOS name service) is never enabled: nothing mounts
266
+ * the share by NetBIOS name (mDNS / LAN-IP only), and on a public-facing host
267
+ * nmbd answers on `:137`/`:138` as a DDoS-reflection vector (BSI/CERT-Bund,
268
+ * Task 755). Locked here, in the tested pure layer, so the install wrapper
269
+ * cannot silently reintroduce nmbd. Uninstall still *disables* nmbd defensively
270
+ * to converge pre-fix boxes — a separate command, see uninstall.ts.
271
+ */
272
+ export const SAMBA_ENABLE_UNITS = ["smbd"];
216
273
  export function formatSambaMarker(step, state) {
217
274
  return `[install-invariant] samba-provision-${step} ${state}`;
218
275
  }
package/dist/uninstall.js CHANGED
@@ -754,7 +754,11 @@ function removeSamba() {
754
754
  console.log(` Leaving smbd/nmbd + samba package in place — ${reason}`);
755
755
  return;
756
756
  }
757
- // No brand stanza, no peer brand — full device-wide teardown.
757
+ // No brand stanza, no peer brand — full device-wide teardown. nmbd is kept
758
+ // in the disable list even though install (Task 755) no longer enables it:
759
+ // disabling an absent unit is a no-op, and this defensively converges a box
760
+ // that was provisioned by a pre-fix installer (where nmbd was enabled) to the
761
+ // intended nmbd-off end state. Do not "simplify" nmbd out of this list.
758
762
  try {
759
763
  spawnSync("sudo", ["systemctl", "disable", "--now", "smbd", "nmbd"], { stdio: "pipe", timeout: 30_000 });
760
764
  console.log(" Stopped + disabled smbd, nmbd");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.286",
3
+ "version": "0.1.287",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -147,7 +147,7 @@ Tools are available via the `admin` MCP server.
147
147
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (Task 502, directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the Task 502 name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
148
148
  - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
149
149
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
150
- - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook (Task 698).** Injects the standing prompt-optimiser restatement directive plus the per-turn three-tier routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool — the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if neither fits, author inline — freestyle is the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). **Task 719:** the directive now carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only.
150
+ - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook (Task 698).** Injects the standing prompt-optimiser restatement directive plus the per-turn three-tier routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool — the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if neither fits, author inline — freestyle is the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). **Task 719:** the directive now carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only. **Task 753:** the directive is **suppressed on native channel turns** — when the parsed `.prompt` starts with the `<channel source=` event marker, the hook logs `[prompt-optimiser-directive] skipped reason=channel-turn session=<id>` to stderr and exits without injecting, because the channel service already reframes the inbound into a select-and-dispatch turn (`composeAdminContent`, see `.docs/whatsapp-inbound-lifeline.md`). Marker-matched at start-of-prompt only, so an admin/Terminal prompt that merely mentions "channel" still gets the directive; fail-open injects if the prompt cannot be parsed.
151
151
  - `hooks/prompt-optimiser-compliance.sh` — **Stop hook (Task 719).** After each admin turn, reads the just-finished turn from `transcript_path` and appends `<ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"` to `$LOG_DIR/prompt-optimiser-directive.log` (and stderr) when the routing directive fired, the prompt was non-trivial (not a slash-command, not a one-word confirmation), and the turn took **no route** — no `Agent` dispatch, no `Skill` load, no `ToolSearch`, no `mcp__*` tool call. This is the standing compliance signal that surfaces the session-`da0b12d4` failure class (agent answers a capability question from memory) as a visible event instead of a silent stale answer. Directive-fired is detected by the marker `PROMPT-OPTIMISER DIRECTIVE` in the turn slice, so it is robust to the CC-version difference in how `UserPromptSubmit` `additionalContext` is recorded (`attachment`/`hook_success` vs `hook_additional_context`). **Known limitation:** "direct continuation of the prior turn" is not detectable from the transcript, so a continuation turn that legitimately needs no route can be flagged; treat the log as a review signal, not a gate. **Fail-open:** no python3, no `transcript_path`, or an unreadable transcript → exit 0, no output. Lives in the same log as the directive breadcrumb, so a single `grep` interleaves "fired" and "no-route" into one per-session timeline; cross-check via `platform/scripts/logs-read.sh <sessionKey>`. This is a lightweight transcript read, not a per-turn spawn (contrast the Task-626 turn recorder below).
152
152
  - **Turn recorder — removed entirely (Task 626).** The `turn-completed-graph-write.sh` Stop hook, the `/api/admin/claude-sessions` loopback bypass it relied on, the `[turn-recorder]` emitters, the envelope walker, and the recorder-auto-archive subscriber are deleted. It had been dormant since Task 214 (never re-registered in settings.json); the admin now writes to the graph by delegating to `database-operator` via the Task tool inside the live session, and the on-demand `/insight` pass (`skills/insight/SKILL.md`, a registered admin skill — Task 641) is the per-session review. There is no per-turn spawn.
153
153
 
@@ -41,6 +41,10 @@ ASSISTANT_AGENT='{"type":"assistant","message":{"role":"assistant","content":[{"
41
41
  ASSISTANT_SKILL='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Skill","input":{}}]}}'
42
42
  ASSISTANT_MCP='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_email__email-provider-info","input":{}}]}}'
43
43
  ASSISTANT_TOOLSEARCH='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"ToolSearch","input":{}}]}}'
44
+ ASSISTANT_REPLY='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__whatsapp-channel__reply","input":{}}]}}'
45
+ ASSISTANT_REPLYDOC='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__whatsapp-channel__reply-document","input":{}}]}}'
46
+ ASSISTANT_SKILLLOAD='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__admin__skill-load","input":{}}]}}'
47
+ ASSISTANT_TASK='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Task","input":{}}]}}'
44
48
 
45
49
  assert_flagged() {
46
50
  local name="$1" t="$2" out
@@ -72,15 +76,37 @@ assert_flagged "attachment-shape directive + no route -> flagged" \
72
76
  assert_flagged "pi-shape directive + no route -> flagged" \
73
77
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_PI" "$ASSISTANT_TEXT")"
74
78
 
75
- # 3–6. Each route surface suppresses the flag.
79
+ # 3–4. Genuine routes suppress the flag.
76
80
  assert_not_flagged "Agent dispatch -> not flagged" \
77
81
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_AGENT")"
78
82
  assert_not_flagged "Skill load -> not flagged" \
79
83
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_SKILL")"
80
- assert_not_flagged "mcp__ tool -> not flagged" \
84
+
85
+ # 5. A non-owning direct mcp__ call is NOT a route -> flagged (kills the
86
+ # "any direct admin MCP call satisfies it" defect; Task 747).
87
+ assert_flagged "non-route mcp__ tool only -> flagged" \
81
88
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_MCP")"
82
- assert_not_flagged "ToolSearch -> not flagged" \
89
+ # 6. ToolSearch is a schema load, not a route -> flagged (Task 747).
90
+ assert_flagged "ToolSearch only -> flagged" \
83
91
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TOOLSEARCH")"
92
+ # 6a. Channel transport reply alone is mandatory transport, not a route -> flagged.
93
+ assert_flagged "channel reply transport only -> flagged" \
94
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY")"
95
+ # 6b. reply-document transport alone -> flagged.
96
+ assert_flagged "channel reply-document transport only -> flagged" \
97
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLYDOC")"
98
+ # 6c. The e48c2a5e shape: reply + ToolSearch only -> flagged.
99
+ assert_flagged "reply + ToolSearch only (e48c2a5e shape) -> flagged" \
100
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_TOOLSEARCH")"
101
+ # 6d. Task dispatch is a route -> not flagged.
102
+ assert_not_flagged "Task dispatch -> not flagged" \
103
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TASK")"
104
+ # 6e. The mcp__admin__skill-load tool is a route -> not flagged.
105
+ assert_not_flagged "skill-load MCP tool -> not flagged" \
106
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_SKILLLOAD")"
107
+ # 6f. reply transport then a genuine Agent dispatch -> not flagged (route present).
108
+ assert_not_flagged "reply then Agent dispatch -> not flagged" \
109
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"
84
110
 
85
111
  # 7. No directive in the slice -> not flagged.
86
112
  assert_not_flagged "no directive -> not flagged" \
@@ -149,6 +175,29 @@ else
149
175
  fi
150
176
  rm -rf "$LD" "$t13"
151
177
 
178
+ # 14. The emitted flag carries the tools= CSV of tool names seen in the slice,
179
+ # in order (Task 747 observability). A future false-negative shows which tool was
180
+ # (mis)counted.
181
+ t14=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_TOOLSEARCH")
182
+ out14=$(run_hook "$t14")
183
+ if printf '%s' "$out14" | grep -q 'tools=mcp__whatsapp-channel__reply,ToolSearch'; then
184
+ echo "PASS: tools= CSV lists the slice's tool names in order"; PASS=$((PASS+1))
185
+ else
186
+ echo "FAIL: tools= CSV wrong (got: $out14)" >&2; FAIL=$((FAIL+1))
187
+ fi
188
+ rm -f "$t14"
189
+
190
+ # 15. A no-mcp freestyle turn (the 45617005 shape) still flags, with an empty
191
+ # tools= field (no tool calls in the slice).
192
+ t15=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")
193
+ out15=$(run_hook "$t15")
194
+ if printf '%s' "$out15" | grep -q "no-route-taken" && printf '%s' "$out15" | grep -q 'tools= prompt='; then
195
+ echo "PASS: no-tool freestyle turn flags with empty tools="; PASS=$((PASS+1))
196
+ else
197
+ echo "FAIL: no-tool freestyle tools= wrong (got: $out15)" >&2; FAIL=$((FAIL+1))
198
+ fi
199
+ rm -f "$t15"
200
+
152
201
  echo "----"
153
202
  echo "PASS=$PASS FAIL=$FAIL"
154
203
  [[ "$FAIL" -eq 0 ]]
@@ -131,6 +131,61 @@ else
131
131
  fi
132
132
  rm -rf "$LD"
133
133
 
134
+ # Case 7 — Task 746: full directive persisted per turn. With LOG_DIR + a
135
+ # session id, the hook writes a content file whose bytes equal the emitted
136
+ # additionalContext, and the breadcrumb carries file= pointing at it plus a
137
+ # sha256 matching that file's bytes.
138
+ P746=$(mktemp -d)
139
+ out746=$(printf '%s' '{"prompt":"x","session_id":"sess-746-a"}' | LOG_DIR="$P746" bash "$HOOK" 2>/dev/null)
140
+ printf '%s' "$out746" | python3 -c 'import json,sys; sys.stdout.write(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])' > "$P746/expected.txt" 2>/dev/null
141
+ crumb="$P746/prompt-optimiser-directive.log"
142
+ dfile=$(grep -o 'file=[^ ]*' "$crumb" 2>/dev/null | head -1 | cut -d= -f2-)
143
+ slog=$(grep -o 'sha256=[0-9a-f]*' "$crumb" 2>/dev/null | head -1 | cut -d= -f2)
144
+ sfile=$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$dfile" 2>/dev/null)
145
+ if [[ -f "$dfile" ]] && cmp -s "$dfile" "$P746/expected.txt" \
146
+ && [[ -n "$slog" && "$slog" == "$sfile" ]] \
147
+ && [[ "$dfile" == "$P746/prompt-optimiser-directives/sess-746-a/"*.txt ]]; then
148
+ echo "PASS: full directive persisted; breadcrumb file=/sha256 match content"; PASS=$((PASS+1))
149
+ else
150
+ echo "FAIL: directive content store wrong (file=$dfile slog=$slog sfile=$sfile)" >&2; FAIL=$((FAIL+1))
151
+ fi
152
+
153
+ # Case 8 — two injections in one session produce two distinct content files
154
+ # (one file per injection, so the audit's injected==stored invariant holds).
155
+ printf '%s' '{"prompt":"y","session_id":"sess-746-b"}' | LOG_DIR="$P746" bash "$HOOK" >/dev/null 2>&1
156
+ sleep 1
157
+ printf '%s' '{"prompt":"z","session_id":"sess-746-b"}' | LOG_DIR="$P746" bash "$HOOK" >/dev/null 2>&1
158
+ nfiles=$(ls "$P746/prompt-optimiser-directives/sess-746-b/"*.txt 2>/dev/null | wc -l | tr -d ' ')
159
+ if [[ "$nfiles" -eq 2 ]]; then
160
+ echo "PASS: two injections -> two distinct content files"; PASS=$((PASS+1))
161
+ else
162
+ echo "FAIL: expected 2 content files for sess-746-b, got $nfiles" >&2; FAIL=$((FAIL+1))
163
+ fi
164
+ rm -rf "$P746"
165
+
166
+ # Case 9 — Task 753: a native channel turn (the prompt is a `<channel source=`
167
+ # event) suppresses the directive — the event-text reframe replaces it there.
168
+ # Exit 0, EMPTY stdout (nothing injected), and a visible stderr breadcrumb.
169
+ chan_in='{"hook_event_name":"UserPromptSubmit","prompt":"<channel source=\"whatsapp\" sender_id=\"x\">\nprice the Willow House job\n</channel>","session_id":"chan-1"}'
170
+ chan_out=$(printf '%s' "$chan_in" | bash "$HOOK" 2>/dev/null); chan_exit=$?
171
+ chan_err=$(printf '%s' "$chan_in" | bash "$HOOK" 2>&1 >/dev/null)
172
+ if [[ "$chan_exit" -eq 0 && -z "$chan_out" ]] \
173
+ && printf '%s' "$chan_err" | grep -q "skipped reason=channel-turn"; then
174
+ echo "PASS: channel turn -> directive suppressed (exit 0, empty stdout, breadcrumb)"; PASS=$((PASS+1))
175
+ else
176
+ echo "FAIL: channel-turn suppression wrong (exit=$chan_exit stdout=$chan_out err=$chan_err)" >&2; FAIL=$((FAIL+1))
177
+ fi
178
+
179
+ # Case 10 — Task 753: a normal admin turn that merely MENTIONS the word channel
180
+ # is NOT suppressed — only the `<channel source=` event marker suppresses, so a
181
+ # Terminal/admin prompt still gets the directive.
182
+ norm_out=$(printf '%s' '{"prompt":"please open a support channel for acme"}' | bash "$HOOK" 2>/dev/null); norm_exit=$?
183
+ if [[ "$norm_exit" -eq 0 ]] && printf '%s' "$norm_out" | grep -q "hookSpecificOutput"; then
184
+ echo "PASS: non-channel prompt mentioning 'channel' still gets the directive"; PASS=$((PASS+1))
185
+ else
186
+ echo "FAIL: false-suppression on a normal prompt (exit=$norm_exit stdout=$norm_out)" >&2; FAIL=$((FAIL+1))
187
+ fi
188
+
134
189
  echo "----"
135
190
  echo "PASS=$PASS FAIL=$FAIL"
136
191
  [[ "$FAIL" -eq 0 ]]
@@ -12,14 +12,20 @@
12
12
  # UserPromptSubmit additionalContext is recorded: a
13
13
  # type:"attachment"/hook_success on 2.1.x, a
14
14
  # type:"hook_additional_context" on the Pi).
15
- # route taken = the turn contains a tool_use named Agent, Skill, ToolSearch,
16
- # or matching mcp__*.
15
+ # route taken = the turn contains a tool_use that is a genuine route:
16
+ # Agent/Task dispatch, native Skill, or a *__skill-load MCP
17
+ # tool. ToolSearch (a schema load) and the channel transport
18
+ # reply tools (mcp__*-channel__reply/-document) are NOT routes
19
+ # — counting them was the Task 747 bug that blinded the
20
+ # detector on every channel turn (the reply is mandatory).
17
21
  # trivial = the prompt is a slash-command or a one-word confirmation.
18
22
  # "Direct continuation" is NOT detectable from the transcript and is a stated
19
23
  # known limitation, not guessed.
20
24
  #
21
25
  # Emits, only on (directive fired AND not trivial AND no route):
22
- # <ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"
26
+ # <ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> tools=<csv> prompt="<clip>"
27
+ # tools=<csv> is the comma-joined tool names seen in the slice (empty on a
28
+ # no-tool freestyle turn), so a future false-negative shows what was (mis)counted.
23
29
  # to $LOG_DIR/prompt-optimiser-directive.log (same log as the fired breadcrumb,
24
30
  # so the two interleave into one greppable timeline) and to stderr.
25
31
  #
@@ -138,12 +144,20 @@ is_one_word = len(tokens) <= 1
138
144
  marker = "PROMPT-OPTIMISER DIRECTIVE"
139
145
  directive_fired = any(marker in json.dumps(r, ensure_ascii=False) for r in slice_rows)
140
146
 
141
- # Route taken = an assistant tool_use with a routing tool name.
142
- ROUTE_TOOLS = ("Agent", "Skill", "ToolSearch")
147
+ # Route taken = an assistant tool_use that is a genuine route: specialist
148
+ # dispatch (Agent/Task) or skill load (native Skill, or the *__skill-load MCP
149
+ # tool under either admin namespace). ToolSearch is a schema load, and the channel
150
+ # transport reply tools (mcp__*-channel__reply / reply-document) are mandatory
151
+ # transport, not routes — neither counts. Every tool name in the slice is
152
+ # collected for the tools= breadcrumb so a future false-negative shows what was
153
+ # (mis)counted.
154
+ ROUTE_TOOLS = ("Agent", "Task", "Skill")
143
155
  def is_route(name):
144
- return name in ROUTE_TOOLS or (isinstance(name, str) and name.startswith("mcp__"))
156
+ if not isinstance(name, str):
157
+ return False
158
+ return name in ROUTE_TOOLS or name.endswith("__skill-load")
145
159
 
146
- route_taken = False
160
+ tool_names = []
147
161
  for r in slice_rows:
148
162
  if not isinstance(r, dict) or r.get("type") != "assistant":
149
163
  continue
@@ -151,17 +165,19 @@ for r in slice_rows:
151
165
  content = msg.get("content")
152
166
  if isinstance(content, list):
153
167
  for b in content:
154
- if isinstance(b, dict) and b.get("type") == "tool_use" and is_route(b.get("name")):
155
- route_taken = True
156
- break
157
- if route_taken:
158
- break
168
+ if isinstance(b, dict) and b.get("type") == "tool_use":
169
+ nm = b.get("name")
170
+ if isinstance(nm, str):
171
+ tool_names.append(nm)
172
+
173
+ route_taken = any(is_route(n) for n in tool_names)
159
174
 
160
175
  if directive_fired and not is_slash and not is_one_word and not route_taken:
161
176
  clip = prompt_text.replace("\n", " ")[:80]
162
177
  ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
163
178
  sid8 = session[:8] if isinstance(session, str) else "?"
164
- print("%s [prompt-optimiser-compliance] directive-fired no-route-taken session=%s prompt=\"%s\"" % (ts, sid8, clip))
179
+ tools_csv = ",".join(tool_names)
180
+ print("%s [prompt-optimiser-compliance] directive-fired no-route-taken session=%s tools=%s prompt=\"%s\"" % (ts, sid8, tools_csv, clip))
165
181
  ' 2>/dev/null) || exit 0
166
182
 
167
183
  [ -n "$RESULT" ] || exit 0