@intentius/chant 0.90.0 → 0.91.0
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/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +6 -4
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -2
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/workspace-tools.test.ts +14 -1
- package/src/cli/mcp/workspace-tools.ts +49 -5
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +33 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records.schema.json +17 -0
- package/src/workspace/records.ts +33 -3
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box isolation (#2727): the ports, state paths and cookie names each box
|
|
3
|
+
* uses, derived from its identity and its host, never chosen by a runtime.
|
|
4
|
+
*
|
|
5
|
+
* A box is a member with a `box` block (#2726), and its name is the
|
|
6
|
+
* member's. A box's identity is its host, its name and its slot, from the
|
|
7
|
+
* block's `host` and `slot`. Everything else
|
|
8
|
+
* follows from those and the host's declaration, and nothing else goes in:
|
|
9
|
+
*
|
|
10
|
+
* - port `p` is `host.ports.from + slot * host.ports.perBox + offset(p)`;
|
|
11
|
+
* - the state directory is `<host.stateRoot>/<box name>`, and state entry
|
|
12
|
+
* `k` is that directory joined with the entry's relative path;
|
|
13
|
+
* - cookie `c` is `<c>_<box name>`.
|
|
14
|
+
*
|
|
15
|
+
* So the same declaration gives the same values on every machine and every
|
|
16
|
+
* run, and adding or removing a box changes no other box's values. The state
|
|
17
|
+
* paths keep their environment reference (`${XDG_STATE_HOME}` by default):
|
|
18
|
+
* the runtime that sets them expands it on its machine, and chant never
|
|
19
|
+
* reads the machine.
|
|
20
|
+
*
|
|
21
|
+
* Distinct names on one host give distinct state paths and cookie names by
|
|
22
|
+
* construction (a member name has no `_`, so `<c>_<box>` splits one way).
|
|
23
|
+
* Ports are the only value two boxes can share: two boxes on one host with
|
|
24
|
+
* the same slot, or two ports in one box at the same offset. `chant
|
|
25
|
+
* workspace check` compares every value anyway (WSP123), so an entry whose
|
|
26
|
+
* path escapes its directory is caught too.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { DEFAULT_STATE_ROOT, type BoxIsolationDeclaration, type Declaration, type Host, type Member } from "./declaration";
|
|
30
|
+
import { pointerToken } from "./jsonc";
|
|
31
|
+
|
|
32
|
+
/** A box's isolation, resolved: what `chant workspace status --json` prints in the member's box block. */
|
|
33
|
+
export interface ResolvedIsolation {
|
|
34
|
+
host: string;
|
|
35
|
+
slot: number;
|
|
36
|
+
/** The box's block of its host's range, inclusive. */
|
|
37
|
+
portRange: { from: number; to: number };
|
|
38
|
+
/** Each declared port's number, in file order. */
|
|
39
|
+
ports: Record<string, number>;
|
|
40
|
+
/** `<stateRoot>/<box name>`, with the environment reference unexpanded. */
|
|
41
|
+
stateDir: string;
|
|
42
|
+
/** Each declared state entry's path, under `stateDir`. */
|
|
43
|
+
state: Record<string, string>;
|
|
44
|
+
/** Each declared cookie's name for this box. */
|
|
45
|
+
cookies: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The host's state root as written, or the default. */
|
|
49
|
+
export function stateRootOf(host: Host): string {
|
|
50
|
+
return host.stateRoot ?? DEFAULT_STATE_ROOT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trimSlash = (s: string) => s.replace(/\/+$/, "");
|
|
54
|
+
|
|
55
|
+
/** Resolve the isolation of the box named `name` on its host. */
|
|
56
|
+
export function resolveIsolation(name: string, box: BoxIsolationDeclaration, host: Host): ResolvedIsolation {
|
|
57
|
+
const first = host.ports.from + box.slot * host.ports.perBox;
|
|
58
|
+
const stateDir = `${trimSlash(stateRootOf(host))}/${name}`;
|
|
59
|
+
return {
|
|
60
|
+
host: box.host,
|
|
61
|
+
slot: box.slot,
|
|
62
|
+
portRange: { from: first, to: first + host.ports.perBox - 1 },
|
|
63
|
+
ports: Object.fromEntries(Object.entries(box.ports).map(([name, offset]) => [name, first + offset])),
|
|
64
|
+
stateDir,
|
|
65
|
+
state: Object.fromEntries(Object.entries(box.state).map(([name, path]) => [name, `${stateDir}/${path.replace(/^\.\/+/, "")}`])),
|
|
66
|
+
cookies: Object.fromEntries(box.cookies.map((c) => [c, `${c}_${name}`])),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A member whose box block declares its isolation, with it resolved. */
|
|
71
|
+
export interface IsolatedBox {
|
|
72
|
+
member: Member;
|
|
73
|
+
isolation: ResolvedIsolation;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every box that declares its isolation, resolved, in member order. The declaration read already checked each box's host exists. */
|
|
77
|
+
export function resolveBoxes(declaration: Declaration): IsolatedBox[] {
|
|
78
|
+
const hosts = new Map(declaration.hosts.map((h) => [h.name, h]));
|
|
79
|
+
const out: IsolatedBox[] = [];
|
|
80
|
+
for (const m of declaration.members) {
|
|
81
|
+
const iso = m.box?.isolation;
|
|
82
|
+
if (iso) out.push({ member: m, isolation: resolveIsolation(m.name, iso, hosts.get(iso.host)!) });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Literal machine paths (WSP124) ───────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const ENV_REF = /^\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)/;
|
|
90
|
+
const HOME_REF = /^\$(\{HOME\}|HOME)(?![A-Za-z0-9_])/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Why a host's `stateRoot` is a literal machine path, or undefined when it is
|
|
94
|
+
* not. A state root starts with an environment reference the runtime expands,
|
|
95
|
+
* and that reference is not `$HOME`: a path under a home directory names one
|
|
96
|
+
* person's machine layout (`${HOME}/<user>/...`), where `${XDG_STATE_HOME}`
|
|
97
|
+
* names the place the machine keeps state.
|
|
98
|
+
*/
|
|
99
|
+
export function stateRootLiteral(root: string): string | undefined {
|
|
100
|
+
if (HOME_REF.test(root)) return "starts at $HOME, which names one person's machine layout; use ${XDG_STATE_HOME} or another variable the runtime sets";
|
|
101
|
+
if (!ENV_REF.test(root)) return "does not start with an environment reference such as ${XDG_STATE_HOME}, so it names one machine's layout";
|
|
102
|
+
if (root.split("/").includes("..")) return "has a .. segment";
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why a state entry's path is not one relative to the box's state directory,
|
|
108
|
+
* or undefined when it is: absolute, home-relative, starting with an
|
|
109
|
+
* environment reference, a Windows drive path, or with a `..` segment.
|
|
110
|
+
*/
|
|
111
|
+
export function statePathLiteral(path: string): string | undefined {
|
|
112
|
+
if (path.startsWith("/")) return "is absolute";
|
|
113
|
+
if (path.startsWith("~")) return "starts at a home directory";
|
|
114
|
+
if (path.startsWith("$")) return "starts with an environment reference";
|
|
115
|
+
if (/^[A-Za-z]:[\\/]/.test(path) || path.includes("\\")) return "is a Windows path";
|
|
116
|
+
if (path.split("/").includes("..")) return "has a .. segment, which leaves the box's state directory";
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface BoxLiteral {
|
|
121
|
+
/** The box, or null for a host's state root. */
|
|
122
|
+
box: string | null;
|
|
123
|
+
message: string;
|
|
124
|
+
pointer: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every literal machine path in the hosts and boxes. */
|
|
128
|
+
export function boxLiterals(declaration: Declaration): BoxLiteral[] {
|
|
129
|
+
const out: BoxLiteral[] = [];
|
|
130
|
+
for (const h of declaration.hosts) {
|
|
131
|
+
if (h.stateRoot === null) continue;
|
|
132
|
+
const why = stateRootLiteral(h.stateRoot);
|
|
133
|
+
if (why) out.push({ box: null, message: `host ${h.name}'s stateRoot ${JSON.stringify(h.stateRoot)} ${why}`, pointer: `${h.pointer}/stateRoot` });
|
|
134
|
+
}
|
|
135
|
+
for (const m of declaration.members) {
|
|
136
|
+
for (const [name, path] of Object.entries(m.box?.isolation?.state ?? {})) {
|
|
137
|
+
const why = statePathLiteral(path);
|
|
138
|
+
if (why) {
|
|
139
|
+
out.push({
|
|
140
|
+
box: m.name,
|
|
141
|
+
message: `member ${m.name}'s box gives state ${name} the path ${JSON.stringify(path)}, which ${why}; state paths are relative to the box's state directory, which chant derives from the host's stateRoot and the member's name`,
|
|
142
|
+
pointer: `${m.box!.pointer}/state/${pointerToken(name)}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Collisions (WSP123) ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export interface BoxCollision {
|
|
153
|
+
host: string;
|
|
154
|
+
what: "port" | "state" | "cookie";
|
|
155
|
+
/** The shared value. */
|
|
156
|
+
value: string;
|
|
157
|
+
/** Each holder as `<box>.<name>`, in declaration order. */
|
|
158
|
+
holders: { box: string; name: string; pointer: string }[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Collapse `.`, `..` and repeated slashes, so two spellings of one path compare equal. */
|
|
162
|
+
function normalizePath(path: string): string {
|
|
163
|
+
const out: string[] = [];
|
|
164
|
+
for (const seg of path.split("/")) {
|
|
165
|
+
if (seg === "" || seg === ".") continue;
|
|
166
|
+
if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
167
|
+
else out.push(seg);
|
|
168
|
+
}
|
|
169
|
+
return (path.startsWith("/") ? "/" : "") + out.join("/");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every value two holders on one host resolve to. Ports collide within a box
|
|
174
|
+
* too, since two listeners can't share a port; state entries in one box may
|
|
175
|
+
* name one file on purpose (a runtime and the door both reading hud's
|
|
176
|
+
* identity), and cookies are unique in a box by the schema.
|
|
177
|
+
*/
|
|
178
|
+
export function boxCollisions(declaration: Declaration): BoxCollision[] {
|
|
179
|
+
const resolved = resolveBoxes(declaration);
|
|
180
|
+
const seen = new Map<string, BoxCollision>();
|
|
181
|
+
const add = (host: string, what: BoxCollision["what"], value: string, box: string, name: string, pointer: string) => {
|
|
182
|
+
const key = `${host}\u0000${what}\u0000${value}`;
|
|
183
|
+
let c = seen.get(key);
|
|
184
|
+
if (!c) {
|
|
185
|
+
c = { host, what, value, holders: [] };
|
|
186
|
+
seen.set(key, c);
|
|
187
|
+
}
|
|
188
|
+
c.holders.push({ box, name, pointer });
|
|
189
|
+
};
|
|
190
|
+
for (const { member, isolation: r } of resolved) {
|
|
191
|
+
const at = member.box!.pointer;
|
|
192
|
+
const box = member.name;
|
|
193
|
+
for (const [name, port] of Object.entries(r.ports)) add(r.host, "port", String(port), box, name, `${at}/ports/${pointerToken(name)}`);
|
|
194
|
+
const files = new Set<string>();
|
|
195
|
+
for (const [name, path] of Object.entries(r.state)) {
|
|
196
|
+
const file = normalizePath(path);
|
|
197
|
+
if (files.has(file)) continue;
|
|
198
|
+
files.add(file);
|
|
199
|
+
add(r.host, "state", file, box, name, `${at}/state/${pointerToken(name)}`);
|
|
200
|
+
}
|
|
201
|
+
Object.entries(r.cookies).forEach(([name, cookie], i) => add(r.host, "cookie", cookie, box, name, `${at}/cookies/${i}`));
|
|
202
|
+
}
|
|
203
|
+
// A state file is counted once per box above, so two holders are two boxes.
|
|
204
|
+
return [...seen.values()].filter((c) => c.holders.length > 1);
|
|
205
|
+
}
|
|
@@ -10,6 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
import { afterAll, describe, expect, test } from "vitest";
|
|
11
11
|
import { cleanScratch, commitAll, contract, declaration, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
|
|
12
12
|
import schema from "./check.schema.json";
|
|
13
|
+
import { BOX_FINDING_CODES } from "./checks/boxes";
|
|
13
14
|
import { WORKSPACE_ERROR_CODES } from "./declaration";
|
|
14
15
|
import { CHECK_CODES, CHECK_CONTRACT_VERSION, CHECK_ERROR_CODES, CHECK_OUTPUT_SCHEMA_ID, runChecks, type CheckDocument } from "./lineage-check";
|
|
15
16
|
|
|
@@ -35,7 +36,8 @@ describe("check output schema", () => {
|
|
|
35
36
|
test("lists exactly the codes the code can return", () => {
|
|
36
37
|
expect(schema.$defs.lockFinding.properties.code.enum).toEqual([...CHECK_CODES]);
|
|
37
38
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...CHECK_ERROR_CODES]);
|
|
38
|
-
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES]);
|
|
39
|
+
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES]);
|
|
40
|
+
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES]);
|
|
39
41
|
});
|
|
40
42
|
});
|
|
41
43
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/check/v1/check.schema.json",
|
|
4
4
|
"title": "chant workspace check output",
|
|
5
|
-
"description": "What `chant workspace check --format json` prints, version 1 of the read contract for checking a workspace (#2524 D9, D15, D16, #2535, #2536). The lineage lock findings carry reason codes; the declaration findings carry WSP ids from the check catalog,
|
|
5
|
+
"description": "What `chant workspace check --format json` prints, version 1 of the read contract for checking a workspace (#2524 D9, D15, D16, #2535, #2536). The lineage lock findings carry reason codes; the declaration findings carry WSP ids from the check catalog, WSP001, a declaration that can't be read, also carries the declaration's reason code, and the box checks WSP121 to WSP124 carry theirs. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
|
|
6
6
|
"oneOf": [
|
|
7
7
|
{
|
|
8
8
|
"$ref": "#/$defs/result"
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
},
|
|
211
211
|
"entity": {
|
|
212
212
|
"type": "string",
|
|
213
|
-
"description": "The member or group the finding is about."
|
|
213
|
+
"description": "The member or group the finding is about. For a box check, the box member."
|
|
214
214
|
},
|
|
215
215
|
"code": {
|
|
216
216
|
"enum": [
|
|
@@ -222,9 +222,13 @@
|
|
|
222
222
|
"reader-too-old",
|
|
223
223
|
"root-chant-required",
|
|
224
224
|
"not-a-git-repository",
|
|
225
|
-
"revision-unknown"
|
|
225
|
+
"revision-unknown",
|
|
226
|
+
"box-credential-declared",
|
|
227
|
+
"box-capability-unbrokered",
|
|
228
|
+
"box-isolation-collision",
|
|
229
|
+
"box-isolation-literal"
|
|
226
230
|
],
|
|
227
|
-
"description": "For WSP001: why the declaration can't be read."
|
|
231
|
+
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP124, the box finding (#2726, #2727)."
|
|
228
232
|
}
|
|
229
233
|
}
|
|
230
234
|
},
|
|
@@ -277,7 +281,7 @@
|
|
|
277
281
|
},
|
|
278
282
|
"entity": {
|
|
279
283
|
"type": "string",
|
|
280
|
-
"description": "The member or group the finding is about."
|
|
284
|
+
"description": "The member or group the finding is about. For a box check, the box member."
|
|
281
285
|
},
|
|
282
286
|
"code": {
|
|
283
287
|
"enum": [
|
|
@@ -289,9 +293,13 @@
|
|
|
289
293
|
"reader-too-old",
|
|
290
294
|
"root-chant-required",
|
|
291
295
|
"not-a-git-repository",
|
|
292
|
-
"revision-unknown"
|
|
296
|
+
"revision-unknown",
|
|
297
|
+
"box-credential-declared",
|
|
298
|
+
"box-capability-unbrokered",
|
|
299
|
+
"box-isolation-collision",
|
|
300
|
+
"box-isolation-literal"
|
|
293
301
|
],
|
|
294
|
-
"description": "For WSP001: why the declaration can't be read."
|
|
302
|
+
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP124, the box finding (#2726, #2727)."
|
|
295
303
|
},
|
|
296
304
|
"reason": {
|
|
297
305
|
"type": "string"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The box isolation checks, WSP123 and WSP124 (#2727).
|
|
3
|
+
*
|
|
4
|
+
* | Id | Code | Fails when |
|
|
5
|
+
* |---|---|---|
|
|
6
|
+
* | WSP123 | `box-isolation-collision` | two boxes on one host, or two ports in one box, resolve to the same port, state path or cookie name |
|
|
7
|
+
* | WSP124 | `box-isolation-literal` | a host's state root or a box's state entry is a literal machine path |
|
|
8
|
+
*
|
|
9
|
+
* Both are fixed errors. A collision means one box signs another's people out
|
|
10
|
+
* or reads another's identity (arugula-salad/studio#39), and a literal path
|
|
11
|
+
* means the declaration only holds on one machine. Neither has a case where
|
|
12
|
+
* turning the check down is the fix.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { WorkspaceCheck } from "../checks";
|
|
16
|
+
import { boxCollisions, boxLiterals } from "../box-isolation";
|
|
17
|
+
|
|
18
|
+
export const WSP_BOX_COLLISION = "WSP123";
|
|
19
|
+
export const WSP_BOX_LITERAL = "WSP124";
|
|
20
|
+
|
|
21
|
+
const WHAT = { port: "port", state: "state path", cookie: "cookie name" } as const;
|
|
22
|
+
|
|
23
|
+
export const BOX_ISOLATION_CHECKS: readonly WorkspaceCheck[] = [
|
|
24
|
+
{
|
|
25
|
+
id: WSP_BOX_COLLISION,
|
|
26
|
+
name: "box-isolation-collision",
|
|
27
|
+
description: "No two boxes on one host resolve to the same port, state path or cookie name, and no two ports in one box share an offset.",
|
|
28
|
+
severity: "error",
|
|
29
|
+
configurable: false,
|
|
30
|
+
check(ctx) {
|
|
31
|
+
return boxCollisions(ctx.declaration).map((c) => {
|
|
32
|
+
const [first, ...rest] = c.holders;
|
|
33
|
+
const names = c.holders.map((h) => `${h.box}.${h.name}`).join(" and ");
|
|
34
|
+
const hint =
|
|
35
|
+
c.what === "port"
|
|
36
|
+
? new Set(c.holders.map((h) => h.box)).size > 1
|
|
37
|
+
? "; give each box on a host its own slot"
|
|
38
|
+
: "; give each port in a box its own offset"
|
|
39
|
+
: "";
|
|
40
|
+
return {
|
|
41
|
+
checkId: this.id,
|
|
42
|
+
severity: this.severity,
|
|
43
|
+
code: "box-isolation-collision" as const,
|
|
44
|
+
message: `box-isolation-collision: ${names} on host ${c.host} resolve to the same ${WHAT[c.what]} ${c.value}${hint}`,
|
|
45
|
+
entity: rest.find((h) => h.box !== first.box)?.box ?? first.box,
|
|
46
|
+
pointer: (rest[0] ?? first).pointer,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: WSP_BOX_LITERAL,
|
|
53
|
+
name: "box-isolation-literal",
|
|
54
|
+
description: "No host's state root and no box's state entry is a literal machine path: state paths derive from an environment reference and the member's name.",
|
|
55
|
+
severity: "error",
|
|
56
|
+
configurable: false,
|
|
57
|
+
check(ctx) {
|
|
58
|
+
return boxLiterals(ctx.declaration).map((l) => ({
|
|
59
|
+
checkId: this.id,
|
|
60
|
+
severity: this.severity,
|
|
61
|
+
code: "box-isolation-literal" as const,
|
|
62
|
+
message: `box-isolation-literal: ${l.message}`,
|
|
63
|
+
...(l.box !== null ? { entity: l.box } : {}),
|
|
64
|
+
pointer: l.pointer,
|
|
65
|
+
}));
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import * as ts from "typescript";
|
|
6
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
7
|
+
import { runDeclarationChecks } from "../checks";
|
|
8
|
+
import { parseDeclaration, WorkspaceReadError } from "../declaration";
|
|
9
|
+
import { gitTree } from "../tree";
|
|
10
|
+
import { isCredentialKey, isSecretReference, literalSecretsInCode, literalSecretsInText } from "./boxes";
|
|
11
|
+
|
|
12
|
+
const scratch: string[] = [];
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
function repo(files: Record<string, string>): string {
|
|
17
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-box-")));
|
|
18
|
+
scratch.push(root);
|
|
19
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
20
|
+
for (const [path, text] of Object.entries(files)) {
|
|
21
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
22
|
+
writeFileSync(join(root, path), text);
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Built at run time so no credential-shaped literal sits in this file.
|
|
28
|
+
const ANTHROPIC_KEY = ["sk", "ant", "api03", "A".repeat(40)].join("-");
|
|
29
|
+
const GITHUB_TOKEN = ["ghp", "B".repeat(36)].join("_");
|
|
30
|
+
|
|
31
|
+
const BROKERED = { capabilities: [{ name: "inference", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] }] };
|
|
32
|
+
|
|
33
|
+
const declaration = (box: unknown, extra: Record<string, unknown> = {}) =>
|
|
34
|
+
JSON.stringify({ name: "acme", schema: 1, members: [{ name: "spec", dir: "spec", kind: "other", because: "the box's declarations", box, ...extra }] }, null, 2);
|
|
35
|
+
|
|
36
|
+
/** A chaff-style box declaration: a service whose env has `apiKey`. */
|
|
37
|
+
const spec = (apiKey: string) => `import { Service, Capability } from "@intentius/chant-lexicon-chaff";
|
|
38
|
+
|
|
39
|
+
export const app = new Service({
|
|
40
|
+
start: ["node", "app/server.mjs"],
|
|
41
|
+
env: {
|
|
42
|
+
HUD_SRC: "\${HOME}/alecraso/hud-live",
|
|
43
|
+
ANTHROPIC_API_KEY: ${JSON.stringify(apiKey)},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const inference = new Capability({ provider: "brokered", credential: "$INFERENCE_TOKEN", endpoint: "$INFERENCE_URL" });
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/** The findings, less WSP009, which every member of kind other gets. */
|
|
51
|
+
const found = async (root: string) => (await runDeclarationChecks(root, (f) => f, { gather: false })).diagnostics.filter((d) => d.ruleId !== "WSP009");
|
|
52
|
+
|
|
53
|
+
describe("box-credential-declared (WSP121)", () => {
|
|
54
|
+
test("a literal API key in a service's env fails the box", async () => {
|
|
55
|
+
const root = repo({ "chant.workspace.json": declaration(BROKERED), "spec/box.ts": spec(ANTHROPIC_KEY) });
|
|
56
|
+
const d = await found(root);
|
|
57
|
+
expect(d.map((x) => [x.ruleId, x.code, x.entity, x.file, x.line])).toEqual([["WSP121", "box-credential-declared", "spec", "spec/box.ts", 7]]);
|
|
58
|
+
expect(d[0].message).toContain("an Anthropic API key");
|
|
59
|
+
// The message names what was found, never the value.
|
|
60
|
+
expect(d[0].message).not.toContain(ANTHROPIC_KEY);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the same box with the key as a reference and the capability brokered passes", async () => {
|
|
64
|
+
for (const ref of ["${ANTHROPIC_API_KEY}", "$ANTHROPIC_API_KEY", "op://box/anthropic/key", "bws://0f5b/anthropic", "infisical:///dev/ANTHROPIC_API_KEY"]) {
|
|
65
|
+
const root = repo({ "chant.workspace.json": declaration(BROKERED), "spec/box.ts": spec(ref) });
|
|
66
|
+
expect(await found(root), ref).toEqual([]);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a literal of no known shape under a credential key fails, and so does a vault secret's literal value", async () => {
|
|
71
|
+
const root = repo({
|
|
72
|
+
"chant.workspace.json": declaration(BROKERED),
|
|
73
|
+
"spec/box.ts": `export const box = Box({
|
|
74
|
+
envVars: { GITHUB_TOKEN: "hunter2hunter2" },
|
|
75
|
+
vault: { secrets: [{ key: "STUDIO_SECRET", value: "plain-text" }, { key: "OK", value: "\${STUDIO_SECRET}" }] },
|
|
76
|
+
repo: { url: "https://github.com/x/y", secretKey: "GIT_TOKEN" },
|
|
77
|
+
names: { credential: "INFERENCE_TOKEN", tokenPath: "/run/secrets/t", token: "~/box/llm-token", endpoint: { apiKey: "https://broker.local/key" } },
|
|
78
|
+
process: { token: process.env.TOKEN! },
|
|
79
|
+
});
|
|
80
|
+
`,
|
|
81
|
+
});
|
|
82
|
+
const d = await found(root);
|
|
83
|
+
expect(d.map((x) => `${x.line}: ${x.message.split(" holds ")[1].split(", and")[0]}`)).toEqual([
|
|
84
|
+
"2: a literal value for GITHUB_TOKEN",
|
|
85
|
+
"3: a literal vault secret value for STUDIO_SECRET",
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an env file and a shell script in the box's directory are read too; prose only for credential shapes", async () => {
|
|
90
|
+
const root = repo({
|
|
91
|
+
"chant.workspace.json": declaration(BROKERED),
|
|
92
|
+
"spec/.env": `FOUNTAIN_API_KEY=abc123def456\nPORT=8080\nGIT_TOKEN=\${CHUD_GIT_TOKEN}\n`,
|
|
93
|
+
"spec/run.sh": `#!/bin/sh\nexport GITHUB_TOKEN=${GITHUB_TOKEN}\necho "password=$GIT_TOKEN"\nTOKEN=$1\ntoken=~/box/llm-token\nSECRET=/run/secrets/x\n`,
|
|
94
|
+
"spec/README.md": `Token: rotate it monthly.\n`,
|
|
95
|
+
"spec/node_modules/pkg/index.js": `export const k = ${JSON.stringify(ANTHROPIC_KEY)};\n`,
|
|
96
|
+
});
|
|
97
|
+
const d = await found(root);
|
|
98
|
+
expect(d.map((x) => `${x.file}:${x.line}`)).toEqual(["spec/.env:1", "spec/run.sh:2"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a member without a box block is not read, and a box's finding can't be suppressed", async () => {
|
|
102
|
+
const plain = repo({
|
|
103
|
+
"chant.workspace.json": JSON.stringify({ name: "acme", schema: 1, members: [{ name: "spec", dir: "spec", kind: "other", because: "x" }] }),
|
|
104
|
+
"spec/box.ts": spec(ANTHROPIC_KEY),
|
|
105
|
+
});
|
|
106
|
+
expect(await found(plain)).toEqual([]);
|
|
107
|
+
const suppressed = repo({
|
|
108
|
+
"chant.workspace.json": declaration(BROKERED, { suppress: [{ check: "WSP121", because: "a test key" }] }),
|
|
109
|
+
"spec/box.ts": spec(ANTHROPIC_KEY),
|
|
110
|
+
});
|
|
111
|
+
expect((await found(suppressed)).map((x) => x.ruleId).sort()).toEqual(["WSP011", "WSP121"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("under --at the revision's files are read", async () => {
|
|
115
|
+
const root = repo({ "chant.workspace.json": declaration(BROKERED), "spec/box.ts": spec(ANTHROPIC_KEY) });
|
|
116
|
+
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
117
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "box"], { cwd: root });
|
|
118
|
+
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
119
|
+
writeFileSync(join(root, "spec/box.ts"), spec("${ANTHROPIC_API_KEY}"));
|
|
120
|
+
expect(await found(root)).toEqual([]);
|
|
121
|
+
const at = await runDeclarationChecks(root, (f) => f, { tree: gitTree(root, commit) });
|
|
122
|
+
expect(at.diagnostics.map((x) => x.ruleId).filter((id) => id !== "WSP009")).toEqual(["WSP121"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("box-capability-unbrokered (WSP122)", () => {
|
|
127
|
+
test("a capability with no broker fails, at the capability", async () => {
|
|
128
|
+
const root = repo({
|
|
129
|
+
"chant.workspace.json": declaration({ capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent"] }, { name: "inference" }] }),
|
|
130
|
+
"spec/box.ts": spec("${ANTHROPIC_API_KEY}"),
|
|
131
|
+
});
|
|
132
|
+
const d = await found(root);
|
|
133
|
+
expect(d.map((x) => [x.ruleId, x.code, x.entity])).toEqual([["WSP122", "box-capability-unbrokered", "spec"]]);
|
|
134
|
+
expect(d[0].message).toContain("needs inference and names no broker");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("it can be turned down, since a box may be moving to a broker", async () => {
|
|
138
|
+
const root = repo({
|
|
139
|
+
"chant.workspace.json": JSON.stringify({
|
|
140
|
+
name: "acme",
|
|
141
|
+
schema: 1,
|
|
142
|
+
checks: { WSP122: "warning" },
|
|
143
|
+
members: [{ name: "spec", dir: "spec", kind: "other", because: "x", box: { capabilities: [{ name: "inference" }] } }],
|
|
144
|
+
}),
|
|
145
|
+
"spec/box.ts": "",
|
|
146
|
+
});
|
|
147
|
+
const report = await runDeclarationChecks(root, (f) => f, { gather: false });
|
|
148
|
+
expect(report.ok).toBe(true);
|
|
149
|
+
expect(report.diagnostics.filter((x) => x.ruleId !== "WSP009").map((x) => `${x.ruleId}:${x.severity}`)).toEqual(["WSP122:warning"]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("the box block in the declaration", () => {
|
|
154
|
+
test("parses, with the broker null and the scope empty when left out", () => {
|
|
155
|
+
const d = parseDeclaration(declaration({ capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault"] }, { name: "inference" }] }), "chant.workspace.json");
|
|
156
|
+
expect(d.members[0].box).toEqual({
|
|
157
|
+
pointer: "/members/0/box",
|
|
158
|
+
isolation: null,
|
|
159
|
+
capabilities: [
|
|
160
|
+
{ name: "fountain", broker: "lobby", scope: ["agent", "vault"], pointer: "/members/0/box/capabilities/0" },
|
|
161
|
+
{ name: "inference", broker: null, scope: [], pointer: "/members/0/box/capabilities/1" },
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
expect(parseDeclaration(JSON.stringify({ name: "a", schema: 1, members: [{ name: "m", dir: "m", kind: "chant" }] }), "chant.workspace.json").members[0].box).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a capability named twice, or an unknown field, is declaration-invalid", () => {
|
|
168
|
+
const twice = () => parseDeclaration(declaration({ capabilities: [{ name: "fountain", broker: "lobby" }, { name: "fountain", broker: "door" }] }), "chant.workspace.json");
|
|
169
|
+
expect(twice).toThrow(WorkspaceReadError);
|
|
170
|
+
expect(twice).toThrow(/lists the capability fountain twice/);
|
|
171
|
+
expect(() => parseDeclaration(declaration({ capabilities: [{ name: "fountain", credential: "x" }] }), "chant.workspace.json")).toThrow(/unknown field "credential"/);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("what counts as a literal secret", () => {
|
|
176
|
+
test("references are not secrets", () => {
|
|
177
|
+
for (const v of ["${X}", "${HOME}/.config", "$X", "$1", "$(cat f)", "op://v/i/f", "bws://id", "infisical:///dev/X", "{{chant:name}}", ""]) {
|
|
178
|
+
expect(isSecretReference(v), v).toBe(true);
|
|
179
|
+
}
|
|
180
|
+
for (const v of ["hunter2", "abc${", "sk-live"]) expect(isSecretReference(v), v).toBe(v === "abc${");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("keys that name a credential, and keys that name where one is", () => {
|
|
184
|
+
for (const k of ["API_KEY", "ANTHROPIC_API_KEY", "GITHUB_TOKEN", "apiKey", "token", "credential", "password", "AWS_SECRET_ACCESS_KEY", "authToken", "clientSecret"]) {
|
|
185
|
+
expect(isCredentialKey(k), k).toBe(true);
|
|
186
|
+
}
|
|
187
|
+
for (const k of ["secretKey", "secret_key", "TOKEN_FILE", "tokenHash", "HUD_IDENTITY_PATH", "CHAFF_AGENT_VAULT", "key", "value", "maxTokens"]) {
|
|
188
|
+
expect(isCredentialKey(k), k).toBe(false);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a credential shape is found anywhere in code, and not in a template with a substitution", () => {
|
|
193
|
+
const code = `const a = [${JSON.stringify(GITHUB_TOKEN)}];\nconst b = \`\${prefix}${"x".repeat(3)}\`;\n`;
|
|
194
|
+
expect(literalSecretsInCode(code, "a.ts", ts.ScriptKind.TS).map((s) => [s.line, s.what])).toEqual([[1, "a GitHub token"]]);
|
|
195
|
+
expect(literalSecretsInText(`# nothing\nkey: ${GITHUB_TOKEN}\n`).map((s) => s.line)).toEqual([2]);
|
|
196
|
+
});
|
|
197
|
+
});
|