@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.
- package/dist/__tests__/samba-provision.test.js +84 -16
- package/dist/index.js +17 -8
- package/dist/samba-provision.js +81 -24
- package/dist/uninstall.js +5 -1
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
- package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
- package/payload/platform/plugins/docs/references/deployment.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/docs/references/samba.md +25 -12
- package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
- package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
- package/payload/platform/scripts/installer-device-verify.sh +74 -3
- package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
- package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
- package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
- package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
- package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
- package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
- package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
- package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
- package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
- package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
- package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
- package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
- package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
- package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +5 -5
- package/payload/server/public/public.html +4 -4
- package/payload/server/server.js +269 -81
- package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
- package/payload/server/public/assets/channel-yIp47StE.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
- package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
- /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,
|
|
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
|
-
//
|
|
79
|
+
// pickBindDecision — private→LAN, public-only→loopback-only, none→none
|
|
39
80
|
// ---------------------------------------------------------------------------
|
|
40
|
-
test("
|
|
41
|
-
assert.
|
|
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("
|
|
44
|
-
assert.
|
|
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("
|
|
47
|
-
assert.
|
|
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("
|
|
50
|
-
assert.
|
|
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("
|
|
53
|
-
assert.
|
|
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("
|
|
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
|
|
58
|
-
// than writing a broken smb.conf.
|
|
59
|
-
|
|
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 {
|
|
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
|
-
|
|
3568
|
-
|
|
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
|
|
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
|
|
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",
|
|
3638
|
+
shell("systemctl", ["enable", "--now", ...SAMBA_ENABLE_UNITS], { sudo: true, timeout: 30_000 });
|
|
3630
3639
|
emitSambaMarker("units", "ok");
|
|
3631
3640
|
}
|
|
3632
3641
|
catch (err) {
|
package/dist/samba-provision.js
CHANGED
|
@@ -40,19 +40,31 @@ export function renderBrandStanza(input) {
|
|
|
40
40
|
].join("\n");
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* Render the `[global]` section. `interfaces =
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
90
|
+
// Interface classification and bind decision
|
|
79
91
|
// ---------------------------------------------------------------------------
|
|
80
92
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
|
90
|
-
const
|
|
131
|
+
export function pickBindDecision(ifaces) {
|
|
132
|
+
const ipv4Addrs = (name) => {
|
|
91
133
|
const addrs = ifaces[name];
|
|
92
134
|
if (!addrs)
|
|
93
|
-
return
|
|
94
|
-
return addrs.
|
|
135
|
+
return [];
|
|
136
|
+
return addrs.filter((a) => a.family === "IPv4" && !a.internal).map((a) => a.address);
|
|
95
137
|
};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
return "
|
|
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 (
|
|
104
|
-
return name;
|
|
147
|
+
if (isPrivateIface(name))
|
|
148
|
+
return { kind: "lan", lanInterface: name };
|
|
105
149
|
}
|
|
106
|
-
|
|
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
|
@@ -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–
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
# or
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
156
|
+
if not isinstance(name, str):
|
|
157
|
+
return False
|
|
158
|
+
return name in ROUTE_TOOLS or name.endswith("__skill-load")
|
|
145
159
|
|
|
146
|
-
|
|
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"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|