@intentius/chant-lexicon-cedar 0.44.9 → 0.44.12
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/README.md +48 -2
- package/dist/agentcore/embed.d.ts +189 -0
- package/dist/agentcore/embed.d.ts.map +1 -0
- package/dist/agentcore/enforcement.d.ts +76 -0
- package/dist/agentcore/enforcement.d.ts.map +1 -0
- package/dist/agentcore/scan.d.ts +46 -0
- package/dist/agentcore/scan.d.ts.map +1 -0
- package/dist/avp/client.d.ts +85 -8
- package/dist/avp/client.d.ts.map +1 -1
- package/dist/codegen/docs-dogwood.d.ts +21 -0
- package/dist/codegen/docs-dogwood.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/dogwood/cli.d.ts +41 -0
- package/dist/dogwood/cli.d.ts.map +1 -1
- package/dist/dogwood/index.d.ts +10 -4
- package/dist/dogwood/index.d.ts.map +1 -1
- package/dist/dogwood/replay-activity.d.ts +196 -0
- package/dist/dogwood/replay-activity.d.ts.map +1 -0
- package/dist/dogwood/replay-op.d.ts +165 -0
- package/dist/dogwood/replay-op.d.ts.map +1 -0
- package/dist/dogwood/serialize.d.ts +20 -0
- package/dist/dogwood/serialize.d.ts.map +1 -1
- package/dist/dogwood/trace.d.ts +215 -0
- package/dist/dogwood/trace.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/dwdc013.d.ts +33 -0
- package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/DWDC013.md +15 -0
- package/dist/okf/types/Policy.md +1 -0
- package/dist/op/activities/index.d.ts +18 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/dwdc013.ts +64 -0
- package/dist/skills/chant-cedar-dogwood.md +327 -0
- package/package.json +7 -2
- package/src/agentcore/embed.test.ts +254 -0
- package/src/agentcore/embed.ts +399 -0
- package/src/agentcore/enforcement.test.ts +43 -0
- package/src/agentcore/enforcement.ts +92 -0
- package/src/agentcore/scan.ts +119 -0
- package/src/avp/OWNERSHIP.md +38 -0
- package/src/avp/client.test.ts +271 -0
- package/src/avp/client.ts +150 -16
- package/src/codegen/docs-dogwood.ts +1119 -0
- package/src/codegen/docs.ts +66 -1
- package/src/dogwood/cli.test.ts +122 -1
- package/src/dogwood/cli.ts +122 -1
- package/src/dogwood/index.ts +74 -1
- package/src/dogwood/replay-activity.test.ts +481 -0
- package/src/dogwood/replay-activity.ts +506 -0
- package/src/dogwood/replay-op.ts +242 -0
- package/src/dogwood/serialize.ts +37 -0
- package/src/dogwood/trace.test.ts +231 -0
- package/src/dogwood/trace.ts +471 -0
- package/src/index.ts +52 -0
- package/src/lint/audit-catalog.ts +8 -0
- package/src/lint/post-synth/dwd-post-synth.test.ts +119 -1
- package/src/lint/post-synth/dwdc013.ts +64 -0
- package/src/lint/post-synth/dwde-post-synth.test.ts +1 -1
- package/src/lint/post-synth/index.ts +2 -0
- package/src/op/activities/index.ts +27 -0
- package/src/plugin.test.ts +3 -2
- package/src/plugin.ts +30 -0
- package/src/skills/chant-cedar-dogwood.md +327 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay activity and the PolicyReplayOp composite (#1661).
|
|
3
|
+
*
|
|
4
|
+
* Everything here injects a runner, per the epic's rule that nothing in gating
|
|
5
|
+
* CI executes the dogwood binary — including on a machine that happens to have
|
|
6
|
+
* one, which is why the binary is forced rather than resolved. The bundle
|
|
7
|
+
* under test is the shipped `examples/policy-replay` build output, so the
|
|
8
|
+
* example and the surface cannot drift apart: if the example stops emitting a
|
|
9
|
+
* `.dw` file, this fails.
|
|
10
|
+
*
|
|
11
|
+
* The recorded verdicts (`@0 DENY`, `@10 ALLOW [rules: 0]`, `@7200 DENY`,
|
|
12
|
+
* exit 0) are the ones the #1657 verification observed from a real run against
|
|
13
|
+
* `dogwood-docs/examples/read_after_login`.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
16
|
+
import { existsSync, readFileSync } from "fs";
|
|
17
|
+
import { mkdtemp, readFile, rm } from "fs/promises";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
import { build } from "@intentius/chant/build";
|
|
22
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
23
|
+
import { cedarSerializer } from "../serializer";
|
|
24
|
+
import { configureDogwoodCli, findDogwoodBinary, resetDogwoodCli, type DogwoodRun } from "./cli";
|
|
25
|
+
import {
|
|
26
|
+
compareVerdicts,
|
|
27
|
+
dogwoodReplay,
|
|
28
|
+
dogwoodReplayReport,
|
|
29
|
+
renderReplaySummary,
|
|
30
|
+
resolveReplayInputs,
|
|
31
|
+
} from "./replay-activity";
|
|
32
|
+
import { PolicyReplayOp, dogwoodReplayStep } from "./replay-op";
|
|
33
|
+
import { renderTrace } from "./trace";
|
|
34
|
+
import {
|
|
35
|
+
readAfterLoginExpectations,
|
|
36
|
+
readAfterLoginTrace,
|
|
37
|
+
} from "../../examples/policy-replay/trace/read-after-login";
|
|
38
|
+
|
|
39
|
+
const EXAMPLE = join(fileURLToPath(new URL("../..", import.meta.url)), "examples/policy-replay");
|
|
40
|
+
const TRACE_TEXT = readFileSync(join(EXAMPLE, "trace/read-after-login.log"), "utf-8");
|
|
41
|
+
const ACTION_SCHEMA = readFileSync(join(EXAMPLE, "schema.cedarschema"), "utf-8");
|
|
42
|
+
|
|
43
|
+
afterEach(() => resetDogwoodCli());
|
|
44
|
+
|
|
45
|
+
/** The example's own emitted artifacts — built, not transcribed. */
|
|
46
|
+
async function exampleArtifacts(): Promise<Record<string, string>> {
|
|
47
|
+
const result = await build(join(EXAMPLE, "src"), [cedarSerializer]);
|
|
48
|
+
expect(result.errors).toEqual([]);
|
|
49
|
+
const output = result.outputs.get("cedar") as SerializerResult;
|
|
50
|
+
return output.files ?? {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The verdict stream #1657 recorded for this bundle. */
|
|
54
|
+
const RECORDED = {
|
|
55
|
+
verdicts: [
|
|
56
|
+
{ index: 0, timestamp: 0, verdict: "deny", determining_rules: [], errors: [] },
|
|
57
|
+
{ index: 1, timestamp: 10, verdict: "allow", determining_rules: [0], errors: [] },
|
|
58
|
+
{ index: 2, timestamp: 7200, verdict: "deny", determining_rules: [], errors: [] },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function runOf(over: Partial<DogwoodRun>): DogwoodRun {
|
|
63
|
+
return { status: 0, stdout: "", stderr: "", ...over };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A runner that answers with `body` and records what it was asked. */
|
|
67
|
+
function fakeRunner(body: unknown, status = 0) {
|
|
68
|
+
const calls: Array<{ args: string[]; files: Record<string, string> }> = [];
|
|
69
|
+
const runner = (_binary: string, args: string[]): DogwoodRun => {
|
|
70
|
+
// The bundle files exist for the duration of the call and are removed in
|
|
71
|
+
// `runBundle`'s finally, so reading them has to happen here.
|
|
72
|
+
const files: Record<string, string> = {};
|
|
73
|
+
for (let i = 0; i < args.length; i++) {
|
|
74
|
+
if (args[i].startsWith("--") && args[i + 1] && existsSync(args[i + 1])) {
|
|
75
|
+
files[args[i]] = readFileSync(args[i + 1], "utf-8");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (existsSync(args[1])) files.policies = readFileSync(args[1], "utf-8");
|
|
79
|
+
calls.push({ args, files });
|
|
80
|
+
return runOf({ status, stdout: typeof body === "string" ? body : JSON.stringify(body) });
|
|
81
|
+
};
|
|
82
|
+
return { runner, calls };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function replayExample(over: Record<string, unknown> = {}, body: unknown = RECORDED, status = 0) {
|
|
86
|
+
const files = await exampleArtifacts();
|
|
87
|
+
const { runner, calls } = fakeRunner(body, status);
|
|
88
|
+
configureDogwoodCli({ binary: "/nonexistent/dogwood", runner });
|
|
89
|
+
const report = await dogwoodReplay({
|
|
90
|
+
policies: files["policies.dw"],
|
|
91
|
+
policySchema: ACTION_SCHEMA,
|
|
92
|
+
eventSchema: files["events.dwschema"],
|
|
93
|
+
trace: TRACE_TEXT,
|
|
94
|
+
expect: readAfterLoginExpectations,
|
|
95
|
+
...over,
|
|
96
|
+
});
|
|
97
|
+
return { report, calls, files };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── The fixture is what the builder renders ────────────────────────
|
|
101
|
+
|
|
102
|
+
describe("the checked-in trace fixture", () => {
|
|
103
|
+
test("is byte-for-byte what the typed builder produces", () => {
|
|
104
|
+
expect(renderTrace(readAfterLoginTrace)).toBe(TRACE_TEXT);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("has four lines and three decision points", () => {
|
|
108
|
+
expect(TRACE_TEXT.trim().split("\n")).toHaveLength(4);
|
|
109
|
+
// `Login::response` is history-only, so the expectations are three, and
|
|
110
|
+
// they are written against timestamps for exactly that reason.
|
|
111
|
+
expect(readAfterLoginExpectations).toHaveLength(3);
|
|
112
|
+
expect(readAfterLoginExpectations.every((e) => e.timestamp !== undefined)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Invocation ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("dogwoodReplay — the invocation", () => {
|
|
119
|
+
test("runs `replay` with the trace and asks for JSON", async () => {
|
|
120
|
+
const { calls } = await replayExample();
|
|
121
|
+
expect(calls).toHaveLength(1);
|
|
122
|
+
const [call] = calls;
|
|
123
|
+
expect(call.args[0]).toBe("replay");
|
|
124
|
+
expect(call.args).toContain("--policy-schema");
|
|
125
|
+
expect(call.args).toContain("--event-schema");
|
|
126
|
+
expect(call.args).toContain("--trace");
|
|
127
|
+
expect(call.args.slice(-2)).toEqual(["--format", "json"]);
|
|
128
|
+
// No `--emit`: it is ignored under `--format json`.
|
|
129
|
+
expect(call.args).not.toContain("--emit");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("hands the CLI the example's own emitted policy set and the trace text", async () => {
|
|
133
|
+
const { calls, files } = await replayExample();
|
|
134
|
+
expect(calls[0].files.policies).toBe(files["policies.dw"]);
|
|
135
|
+
expect(calls[0].files["--trace"]).toBe(TRACE_TEXT);
|
|
136
|
+
expect(calls[0].files["--policy-schema"]).toBe(ACTION_SCHEMA);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("typed events are rendered and audited on the way in", async () => {
|
|
140
|
+
const { calls, report } = await replayExample({ trace: undefined, traceEvents: readAfterLoginTrace });
|
|
141
|
+
expect(calls[0].files["--trace"]).toBe(TRACE_TEXT);
|
|
142
|
+
expect(report.traceIssues).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a trace that populates one bag is reported, not swallowed", async () => {
|
|
146
|
+
const weakened = readAfterLoginTrace.map((event) =>
|
|
147
|
+
event.kind === "request" ? { ...event, requestContext: undefined } : event,
|
|
148
|
+
);
|
|
149
|
+
const { report } = await replayExample({ trace: undefined, traceEvents: weakened });
|
|
150
|
+
// The replay still ran and still returned verdicts — that is the trap.
|
|
151
|
+
expect(report.verdicts).toHaveLength(3);
|
|
152
|
+
expect(report.traceIssues.map((i) => i.kind)).toContain("no-request-context");
|
|
153
|
+
expect(report.summary).toMatch(/Trace weaknesses/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("optional service-schema flags are omitted when not supplied", async () => {
|
|
157
|
+
const files = await exampleArtifacts();
|
|
158
|
+
const { runner, calls } = fakeRunner(RECORDED);
|
|
159
|
+
configureDogwoodCli({ binary: "/nonexistent/dogwood", runner });
|
|
160
|
+
await dogwoodReplay({
|
|
161
|
+
policies: files["policies.dw"],
|
|
162
|
+
policySchema: ACTION_SCHEMA,
|
|
163
|
+
trace: TRACE_TEXT,
|
|
164
|
+
});
|
|
165
|
+
expect(calls[0].args).not.toContain("--event-schema");
|
|
166
|
+
expect(calls[0].args).not.toContain("--macros");
|
|
167
|
+
expect(calls[0].args).not.toContain("--providers");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("reads artifacts from paths as readily as inline", async () => {
|
|
171
|
+
const resolved = await resolveReplayInputs({
|
|
172
|
+
cwd: EXAMPLE,
|
|
173
|
+
policies: "permit(principal, action, resource);",
|
|
174
|
+
policySchemaPath: "schema.cedarschema",
|
|
175
|
+
tracePath: "trace/read-after-login.log",
|
|
176
|
+
});
|
|
177
|
+
expect(resolved.bundle.policySchema).toBe(ACTION_SCHEMA);
|
|
178
|
+
expect(resolved.trace).toBe(TRACE_TEXT);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("names the missing artifact rather than failing at the CLI", async () => {
|
|
182
|
+
await expect(resolveReplayInputs({ policies: "x", trace: "y" })).rejects.toThrow(
|
|
183
|
+
/--policy-schema` is not optional/,
|
|
184
|
+
);
|
|
185
|
+
await expect(resolveReplayInputs({ policies: "x", policySchema: "y" })).rejects.toThrow(/no trace/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── Verdicts ───────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
describe("dogwoodReplay — verdicts", () => {
|
|
192
|
+
test("the recorded stream matches the declared expectations", async () => {
|
|
193
|
+
const { report } = await replayExample();
|
|
194
|
+
expect(report.ok).toBe(true);
|
|
195
|
+
expect(report.findings).toBe(0);
|
|
196
|
+
expect(report.verdicts.map((v) => v.verdict)).toEqual(["deny", "allow", "deny"]);
|
|
197
|
+
expect(report.summary).toMatch(/3 decision point\(s\) replayed; 0 divergence/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("an all-DENY stream at exit 0 is a result, not a failure", async () => {
|
|
201
|
+
// Upstream exits 0 even when every verdict is DENY; a nonzero exit means
|
|
202
|
+
// the trace or the policy set failed to load. Reading the exit code as a
|
|
203
|
+
// verdict would report a working deny-by-default set as broken.
|
|
204
|
+
const allDeny = {
|
|
205
|
+
verdicts: RECORDED.verdicts.map((v) => ({ ...v, verdict: "deny", determining_rules: [] })),
|
|
206
|
+
};
|
|
207
|
+
const { report } = await replayExample({ expect: [] }, allDeny);
|
|
208
|
+
expect(report.ok).toBe(true);
|
|
209
|
+
expect(report.verdicts.every((v) => v.verdict === "deny")).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a nonzero exit carrying a verdict report is still read — the JSON decides", async () => {
|
|
213
|
+
const { report } = await replayExample({}, RECORDED, 2);
|
|
214
|
+
expect(report.findings).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a flipped verdict is a divergence naming both sides", async () => {
|
|
218
|
+
const flipped = {
|
|
219
|
+
verdicts: RECORDED.verdicts.map((v) => (v.timestamp === 10 ? { ...v, verdict: "deny" } : v)),
|
|
220
|
+
};
|
|
221
|
+
const { report } = await replayExample({}, flipped);
|
|
222
|
+
expect(report.ok).toBe(false);
|
|
223
|
+
expect(report.findings).toBe(1);
|
|
224
|
+
const [divergence] = report.divergences;
|
|
225
|
+
expect(divergence).toMatchObject({ timestamp: 10, expected: "allow", actual: "deny" });
|
|
226
|
+
expect(report.summary).toMatch(/\| @10 \|/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("the right verdict for the wrong reason is a divergence too", async () => {
|
|
230
|
+
const otherRule = {
|
|
231
|
+
verdicts: RECORDED.verdicts.map((v) => (v.timestamp === 10 ? { ...v, determining_rules: [3] } : v)),
|
|
232
|
+
};
|
|
233
|
+
const { report } = await replayExample({}, otherRule);
|
|
234
|
+
expect(report.findings).toBe(1);
|
|
235
|
+
expect(report.divergences[0].detail).toMatch(/right for a different reason/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("an expected decision point that never happened is a divergence", async () => {
|
|
239
|
+
const short = { verdicts: RECORDED.verdicts.slice(0, 2) };
|
|
240
|
+
const { report } = await replayExample({}, short);
|
|
241
|
+
expect(report.divergences.map((d) => d.timestamp)).toContain(7200);
|
|
242
|
+
expect(report.divergences[report.divergences.length - 1].detail).toMatch(/no decision point at @7200/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a decision point nothing expected is a divergence — new decisions are drift", async () => {
|
|
246
|
+
const extra = {
|
|
247
|
+
verdicts: [
|
|
248
|
+
...RECORDED.verdicts,
|
|
249
|
+
{ index: 3, timestamp: 9000, verdict: "allow", determining_rules: [0], errors: [] },
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
const { report } = await replayExample({}, extra);
|
|
253
|
+
expect(report.divergences).toHaveLength(1);
|
|
254
|
+
expect(report.divergences[0].detail).toMatch(/unexpected decision point/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a per-evaluation error is a divergence even when the verdict is right", async () => {
|
|
258
|
+
const errored = {
|
|
259
|
+
verdicts: RECORDED.verdicts.map((v) =>
|
|
260
|
+
v.timestamp === 10 ? { ...v, errors: ["rhai implementation has no script"] } : v,
|
|
261
|
+
),
|
|
262
|
+
};
|
|
263
|
+
const { report } = await replayExample({}, errored);
|
|
264
|
+
expect(report.findings).toBe(1);
|
|
265
|
+
expect(report.divergences[0].errors).toEqual(["rhai implementation has no script"]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("…and with no expectations at all, an error is still reported", async () => {
|
|
269
|
+
const errored = {
|
|
270
|
+
verdicts: [{ ...RECORDED.verdicts[0], errors: ["provider Risk::score failed"] }],
|
|
271
|
+
};
|
|
272
|
+
const { report } = await replayExample({ expect: [] }, errored);
|
|
273
|
+
expect(report.findings).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("compareVerdicts", () => {
|
|
278
|
+
const verdicts = [
|
|
279
|
+
{ index: 0, timestamp: 5, verdict: "allow" as const, determiningRules: [0], errors: [] },
|
|
280
|
+
{ index: 1, timestamp: 5, verdict: "deny" as const, determiningRules: [], errors: [] },
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
test("two expectations at the same timestamp address the first and second decision", () => {
|
|
284
|
+
expect(
|
|
285
|
+
compareVerdicts(verdicts, [
|
|
286
|
+
{ timestamp: 5, verdict: "allow" },
|
|
287
|
+
{ timestamp: 5, verdict: "deny" },
|
|
288
|
+
]),
|
|
289
|
+
).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("an index expectation addresses the decision stream, not the trace line", () => {
|
|
293
|
+
expect(compareVerdicts(verdicts, [{ index: 1, verdict: "deny" }])).toHaveLength(1);
|
|
294
|
+
expect(compareVerdicts(verdicts, [{ index: 1, verdict: "deny" }])[0].detail).toMatch(
|
|
295
|
+
/unexpected decision point/,
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("an expectation with neither a timestamp nor an index is a caller error", () => {
|
|
300
|
+
expect(() => compareVerdicts(verdicts, [{ verdict: "allow" }])).toThrow(/needs a `timestamp` or an `index`/);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ── A run that did not happen ──────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe("dogwoodReplay — a run that could not happen", () => {
|
|
307
|
+
test("no JSON on stdout throws instead of reporting zero divergences", async () => {
|
|
308
|
+
await expect(replayExample({}, "")).rejects.toThrow(/without JSON on stdout/);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a fatal — a malformed trace line — throws with upstream's message", async () => {
|
|
312
|
+
const fatal = {
|
|
313
|
+
severity: "error",
|
|
314
|
+
message: "trace parse: line 1: timepoint must start with `@`",
|
|
315
|
+
spanned: false,
|
|
316
|
+
};
|
|
317
|
+
await expect(replayExample({}, fatal, 2)).rejects.toThrow(/timepoint must start with/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("no binary is a failure that says where chant looked", async () => {
|
|
321
|
+
configureDogwoodCli({ binary: null });
|
|
322
|
+
await expect(
|
|
323
|
+
dogwoodReplay({ policies: "x", policySchema: "y", trace: "z" }),
|
|
324
|
+
).rejects.toThrow(/CHANT_DOGWOOD_BINARY/);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ── The report phase ───────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
describe("dogwoodReplayReport", () => {
|
|
331
|
+
test("the Replay phase writes the report the Report phase reads", async () => {
|
|
332
|
+
const dir = await mkdtemp(join(tmpdir(), "chant-replay-"));
|
|
333
|
+
try {
|
|
334
|
+
await replayExample({ reportPath: join(dir, "nested/report.json") });
|
|
335
|
+
const written = JSON.parse(await readFile(join(dir, "nested/report.json"), "utf-8")) as {
|
|
336
|
+
findings: number;
|
|
337
|
+
};
|
|
338
|
+
expect(written.findings).toBe(0);
|
|
339
|
+
|
|
340
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
341
|
+
const dispatch = await dogwoodReplayReport({ reportPath: join(dir, "nested/report.json") });
|
|
342
|
+
expect(dispatch.mode).toBe("report");
|
|
343
|
+
expect(dispatch.actionable).toBe(false);
|
|
344
|
+
expect(log).toHaveBeenCalled();
|
|
345
|
+
log.mockRestore();
|
|
346
|
+
} finally {
|
|
347
|
+
await rm(dir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("issue and pull-request modes render a body and open nothing", async () => {
|
|
352
|
+
const { report } = await replayExample({ mode: "issue" }, {
|
|
353
|
+
verdicts: RECORDED.verdicts.map((v) => (v.timestamp === 10 ? { ...v, verdict: "deny" } : v)),
|
|
354
|
+
});
|
|
355
|
+
const dispatch = await dogwoodReplayReport({ report, title: "Policy replay diverged" });
|
|
356
|
+
expect(dispatch.mode).toBe("issue");
|
|
357
|
+
expect(dispatch.actionable).toBe(true);
|
|
358
|
+
expect(dispatch.title).toBe("Policy replay diverged");
|
|
359
|
+
expect(dispatch.body).toContain("## Policy replay");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("failOnDivergence turns a finding into a failed step", async () => {
|
|
363
|
+
const { report } = await replayExample({}, {
|
|
364
|
+
verdicts: RECORDED.verdicts.map((v) => (v.timestamp === 10 ? { ...v, verdict: "deny" } : v)),
|
|
365
|
+
});
|
|
366
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
367
|
+
await expect(dogwoodReplayReport({ report, failOnDivergence: true })).rejects.toThrow(
|
|
368
|
+
/1 divergence/,
|
|
369
|
+
);
|
|
370
|
+
log.mockRestore();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a Report phase with no written report says so, rather than reporting clean", async () => {
|
|
374
|
+
await expect(dogwoodReplayReport({ reportPath: "/nonexistent/report.json" })).rejects.toThrow(
|
|
375
|
+
/has nothing to report on/,
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("the clean summary says what was replayed", () => {
|
|
380
|
+
const summary = renderReplaySummary({
|
|
381
|
+
ok: true,
|
|
382
|
+
mode: "report",
|
|
383
|
+
verdicts: [],
|
|
384
|
+
divergences: [],
|
|
385
|
+
findings: 0,
|
|
386
|
+
traceIssues: [],
|
|
387
|
+
});
|
|
388
|
+
expect(summary).toMatch(/replayed to the verdict it was expected to reach/);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ── The composite ──────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
describe("PolicyReplayOp", () => {
|
|
395
|
+
function phasesOf(op: unknown) {
|
|
396
|
+
return (op as { props: { phases: Array<{ name: string; steps: Array<Record<string, unknown>> }> } })
|
|
397
|
+
.props.phases;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test("assembles gather → replay → report", () => {
|
|
401
|
+
const { op } = PolicyReplayOp({ name: "policy-replay", tracePath: "trace/t.log" });
|
|
402
|
+
expect(phasesOf(op).map((p) => p.name)).toEqual(["Artifacts", "Replay", "Report"]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("the phases resolve the activities by name, the flyApply way", () => {
|
|
406
|
+
const { op } = PolicyReplayOp({ name: "policy-replay", tracePath: "trace/t.log" });
|
|
407
|
+
const [artifacts, replay, report] = phasesOf(op);
|
|
408
|
+
expect(artifacts.steps[0].fn).toBe("chantBuild");
|
|
409
|
+
expect(replay.steps[0].fn).toBe("dogwoodReplay");
|
|
410
|
+
expect(report.steps[0].fn).toBe("dogwoodReplayReport");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("the divergence count rides out as a search attribute", () => {
|
|
414
|
+
const { op } = PolicyReplayOp({ name: "policy-replay", tracePath: "trace/t.log" });
|
|
415
|
+
expect(phasesOf(op)[1].steps[0].outcomeAttribute).toEqual({ name: "Divergences", from: "findings" });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("checked-in artifacts drop the Artifacts phase rather than run it empty", () => {
|
|
419
|
+
const { op } = PolicyReplayOp({ name: "r", tracePath: "t.log", buildScript: false });
|
|
420
|
+
expect(phasesOf(op).map((p) => p.name)).toEqual(["Replay", "Report"]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("the finding mode reaches both the replay and the report", () => {
|
|
424
|
+
const { op } = PolicyReplayOp({ name: "r", tracePath: "t.log", onFinding: "pull-request" });
|
|
425
|
+
const [, replay, report] = phasesOf(op);
|
|
426
|
+
expect((replay.steps[0].args as Record<string, unknown>).mode).toBe("pull-request");
|
|
427
|
+
expect((report.steps[0].args as Record<string, unknown>).mode).toBe("pull-request");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("the report file is the seam between the two phases", () => {
|
|
431
|
+
const { op } = PolicyReplayOp({ name: "r", tracePath: "t.log", reportPath: "dist/r.json" });
|
|
432
|
+
const [, replay, report] = phasesOf(op);
|
|
433
|
+
expect((replay.steps[0].args as Record<string, unknown>).reportPath).toBe("dist/r.json");
|
|
434
|
+
expect((report.steps[0].args as Record<string, unknown>).reportPath).toBe("dist/r.json");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("the step builder carries the policyCheck profile by default", () => {
|
|
438
|
+
expect(dogwoodReplayStep({ tracePath: "t.log" }).profile).toBe("policyCheck");
|
|
439
|
+
expect(dogwoodReplayStep({ tracePath: "t.log", profile: "longInfra" }).profile).toBe("longInfra");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("the example's Op is the composite, wired to the shipped fixture", async () => {
|
|
443
|
+
const mod = (await import("../../examples/policy-replay/ops/policy-replay.op")) as {
|
|
444
|
+
default: unknown;
|
|
445
|
+
};
|
|
446
|
+
const [, replay] = phasesOf(mod.default);
|
|
447
|
+
const args = replay.steps[0].args as Record<string, unknown>;
|
|
448
|
+
expect(args.tracePath).toBe("trace/read-after-login.log");
|
|
449
|
+
expect(args.expect).toEqual(readAfterLoginExpectations);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ── Opt-in: a real binary ──────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Runs only when `CHANT_DOGWOOD_E2E=1` *and* a binary resolves. Never gates —
|
|
457
|
+
* the epic's rule is that nothing in gating CI executes the dogwood binary.
|
|
458
|
+
* This is where someone who has built one confirms the recorded verdicts are
|
|
459
|
+
* still what the real thing produces after an upstream sync.
|
|
460
|
+
*/
|
|
461
|
+
const haveRealBinary = process.env.CHANT_DOGWOOD_E2E === "1" && findDogwoodBinary() !== undefined;
|
|
462
|
+
|
|
463
|
+
describe.skipIf(!haveRealBinary)("against a real dogwood binary (opt-in)", () => {
|
|
464
|
+
test("the shipped example replays to the verdicts #1657 recorded", async () => {
|
|
465
|
+
resetDogwoodCli();
|
|
466
|
+
const files = await exampleArtifacts();
|
|
467
|
+
const report = await dogwoodReplay({
|
|
468
|
+
policies: files["policies.dw"],
|
|
469
|
+
policySchema: ACTION_SCHEMA,
|
|
470
|
+
eventSchema: files["events.dwschema"],
|
|
471
|
+
trace: TRACE_TEXT,
|
|
472
|
+
expect: readAfterLoginExpectations,
|
|
473
|
+
});
|
|
474
|
+
expect(report.divergences).toEqual([]);
|
|
475
|
+
expect(report.verdicts.map((v) => [v.timestamp, v.verdict])).toEqual([
|
|
476
|
+
[0, "deny"],
|
|
477
|
+
[10, "allow"],
|
|
478
|
+
[7200, "deny"],
|
|
479
|
+
]);
|
|
480
|
+
});
|
|
481
|
+
});
|