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