@intentius/chant-lexicon-cedar 0.44.8 → 0.44.10
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 +106 -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/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/codegen/package.d.ts.map +1 -1
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/dogwood/cli.d.ts +247 -0
- package/dist/dogwood/cli.d.ts.map +1 -0
- package/dist/dogwood/event-schema.d.ts +161 -0
- package/dist/dogwood/event-schema.d.ts.map +1 -0
- package/dist/dogwood/index.d.ts +39 -0
- package/dist/dogwood/index.d.ts.map +1 -0
- package/dist/dogwood/macros.d.ts +96 -0
- package/dist/dogwood/macros.d.ts.map +1 -0
- package/dist/dogwood/policy.d.ts +120 -0
- package/dist/dogwood/policy.d.ts.map +1 -0
- 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/scan.d.ts +109 -0
- package/dist/dogwood/scan.d.ts.map +1 -0
- package/dist/dogwood/serialize.d.ts +66 -0
- package/dist/dogwood/serialize.d.ts.map +1 -0
- package/dist/dogwood/temporal.d.ts +259 -0
- package/dist/dogwood/temporal.d.ts.map +1 -0
- package/dist/dogwood/trace.d.ts +215 -0
- package/dist/dogwood/trace.d.ts.map +1 -0
- package/dist/dogwood/upstream.d.ts +41 -0
- package/dist/dogwood/upstream.d.ts.map +1 -0
- package/dist/dogwood/window.d.ts +73 -0
- package/dist/dogwood/window.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +15 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
- package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc010.d.ts +25 -0
- package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc011.d.ts +19 -0
- package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc012.d.ts +21 -0
- package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
- 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/dwde010.d.ts +32 -0
- package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde011.d.ts +33 -0
- package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwds010.d.ts +24 -0
- package/dist/lint/post-synth/dwds010.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 +7 -0
- package/dist/okf/rules/DWDC010.md +11 -0
- package/dist/okf/rules/DWDC011.md +11 -0
- package/dist/okf/rules/DWDC012.md +11 -0
- package/dist/okf/rules/DWDC013.md +15 -0
- package/dist/okf/rules/DWDE010.md +11 -0
- package/dist/okf/rules/DWDE011.md +11 -0
- package/dist/okf/rules/DWDS010.md +11 -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/policy-text.d.ts +53 -0
- package/dist/policy-text.d.ts.map +1 -0
- package/dist/rules/dogwood-helpers.ts +139 -0
- package/dist/rules/dwdc010.ts +62 -0
- package/dist/rules/dwdc011.ts +61 -0
- package/dist/rules/dwdc012.ts +46 -0
- package/dist/rules/dwdc013.ts +64 -0
- package/dist/rules/dwde010.ts +130 -0
- package/dist/rules/dwde011.ts +108 -0
- package/dist/rules/dwds010.ts +46 -0
- package/dist/serializer.d.ts +10 -18
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-cedar-authoring.md +180 -0
- package/dist/skills/chant-cedar-avp-embedding.md +125 -0
- package/dist/skills/chant-cedar-dogwood.md +327 -0
- package/dist/skills/chant-cedar-meta-policy.md +119 -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/codegen/docs-dogwood.ts +1119 -0
- package/src/codegen/docs.ts +66 -1
- package/src/codegen/package.ts +3 -2
- package/src/config.test.ts +12 -0
- package/src/config.ts +28 -0
- package/src/dogwood/cli.test.ts +513 -0
- package/src/dogwood/cli.ts +666 -0
- package/src/dogwood/event-schema.test.ts +218 -0
- package/src/dogwood/event-schema.ts +318 -0
- package/src/dogwood/index.ts +271 -0
- package/src/dogwood/macros.test.ts +104 -0
- package/src/dogwood/macros.ts +229 -0
- package/src/dogwood/policy.test.ts +94 -0
- package/src/dogwood/policy.ts +141 -0
- 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/scan.ts +287 -0
- package/src/dogwood/serialize.test.ts +331 -0
- package/src/dogwood/serialize.ts +246 -0
- package/src/dogwood/temporal.test.ts +272 -0
- package/src/dogwood/temporal.ts +592 -0
- package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
- package/src/dogwood/testdata/default-macros.dw +23 -0
- package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
- package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
- package/src/dogwood/testdata/pinned.dwschema +31 -0
- package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
- package/src/dogwood/testdata/read-after-login.dw +17 -0
- package/src/dogwood/testdata/temporal-policies.dw +53 -0
- package/src/dogwood/trace.test.ts +231 -0
- package/src/dogwood/trace.ts +471 -0
- package/src/dogwood/upstream.ts +41 -0
- package/src/dogwood/window.ts +124 -0
- package/src/index.ts +76 -0
- package/src/lint/audit-catalog.ts +64 -0
- package/src/lint/post-synth/dogwood-helpers.ts +139 -0
- package/src/lint/post-synth/dwd-post-synth.test.ts +374 -0
- package/src/lint/post-synth/dwdc010.ts +62 -0
- package/src/lint/post-synth/dwdc011.ts +61 -0
- package/src/lint/post-synth/dwdc012.ts +46 -0
- package/src/lint/post-synth/dwdc013.ts +64 -0
- package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
- package/src/lint/post-synth/dwde010.ts +130 -0
- package/src/lint/post-synth/dwde011.ts +108 -0
- package/src/lint/post-synth/dwds010.ts +46 -0
- package/src/lint/post-synth/index.ts +14 -0
- package/src/lint/post-synth/post-synth.test.ts +7 -3
- package/src/op/activities/index.ts +27 -0
- package/src/plugin.test.ts +3 -2
- package/src/plugin.ts +30 -0
- package/src/policy-text.ts +128 -0
- package/src/serializer.ts +71 -109
- package/src/skills/chant-cedar-dogwood.md +327 -0
package/src/codegen/docs.ts
CHANGED
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
import { dirname, join } from "path";
|
|
22
22
|
import { fileURLToPath } from "url";
|
|
23
23
|
import { docsPipeline, writeDocsSite, type DocsConfig } from "@intentius/chant/codegen/docs";
|
|
24
|
+
import {
|
|
25
|
+
dogwoodEventSchemas,
|
|
26
|
+
dogwoodOverview,
|
|
27
|
+
dogwoodReplay,
|
|
28
|
+
dogwoodTemporalPolicies,
|
|
29
|
+
dogwoodValidation,
|
|
30
|
+
} from "./docs-dogwood";
|
|
24
31
|
|
|
25
32
|
const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
26
33
|
|
|
@@ -67,7 +74,14 @@ any evaluator with chant nowhere in sight.
|
|
|
67
74
|
There is no \`cedarGate()\` and there will not be one.
|
|
68
75
|
[Organizational policy](/chant/guide/organizational-policy/) in chant is
|
|
69
76
|
TypeScript post-synth checks; a second policy engine would duplicate the lint
|
|
70
|
-
engine. chant compiles *to* Cedar; it is not governed *by* Cedar
|
|
77
|
+
engine. chant compiles *to* Cedar; it is not governed *by* Cedar.
|
|
78
|
+
|
|
79
|
+
## The dogwood dialect
|
|
80
|
+
|
|
81
|
+
Cedar with temporal operators, shipping inside this lexicon as a pre-release
|
|
82
|
+
surface under the \`DWD\` id family: a policy that can depend on what already
|
|
83
|
+
happened in a session. Start at [The Dogwood Dialect](./dogwood), which is
|
|
84
|
+
honest about upstream's governance before it shows you a builder.`;
|
|
71
85
|
|
|
72
86
|
// ── getting-started ───────────────────────────────────────────────
|
|
73
87
|
|
|
@@ -858,6 +872,57 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise<voi
|
|
|
858
872
|
description: "The AVP statement seam, and the other Cedar evaluators.",
|
|
859
873
|
content: avpPage,
|
|
860
874
|
},
|
|
875
|
+
// The dogwood dialect (#1662). `sidebar: false` on each, because these
|
|
876
|
+
// five belong under one group rather than flat after the cedar pages —
|
|
877
|
+
// the group is declared in `sidebarExtra` below, and `buildSidebar`
|
|
878
|
+
// would otherwise list them twice.
|
|
879
|
+
{
|
|
880
|
+
slug: "dogwood",
|
|
881
|
+
title: "The Dogwood Dialect",
|
|
882
|
+
description: "Cedar with temporal operators — what ships, and what pre-release means here.",
|
|
883
|
+
content: dogwoodOverview,
|
|
884
|
+
sidebar: false,
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
slug: "dogwood-temporal-policies",
|
|
888
|
+
title: "Temporal Policies",
|
|
889
|
+
description: "The typed builders, the parser primitives, and which operators are really macros.",
|
|
890
|
+
content: dogwoodTemporalPolicies,
|
|
891
|
+
sidebar: false,
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
slug: "dogwood-event-schemas",
|
|
895
|
+
title: "Event Schemas",
|
|
896
|
+
description: "The .dwschema surface, the callerPrincipal pin, and what opting out of it widens.",
|
|
897
|
+
content: dogwoodEventSchemas,
|
|
898
|
+
sidebar: false,
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
slug: "dogwood-validation",
|
|
902
|
+
title: "Dogwood Validation",
|
|
903
|
+
description: "The DWD walls that always run, and the CLI-gated checks that need the binary.",
|
|
904
|
+
content: dogwoodValidation,
|
|
905
|
+
sidebar: false,
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
slug: "dogwood-replay",
|
|
909
|
+
title: "Replay",
|
|
910
|
+
description: "Typed traces, PolicyReplayOp, and the both-bags trap that makes half a trace pass.",
|
|
911
|
+
content: dogwoodReplay,
|
|
912
|
+
sidebar: false,
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
sidebarExtra: [
|
|
916
|
+
{
|
|
917
|
+
label: "Dogwood",
|
|
918
|
+
items: [
|
|
919
|
+
{ label: "The Dialect", slug: "dogwood" },
|
|
920
|
+
{ label: "Temporal Policies", slug: "dogwood-temporal-policies" },
|
|
921
|
+
{ label: "Event Schemas", slug: "dogwood-event-schemas" },
|
|
922
|
+
{ label: "Validation", slug: "dogwood-validation" },
|
|
923
|
+
{ label: "Replay", slug: "dogwood-replay" },
|
|
924
|
+
],
|
|
925
|
+
},
|
|
861
926
|
],
|
|
862
927
|
};
|
|
863
928
|
|
package/src/codegen/package.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Cedar lexicon packaging — delegates to core's packagePipeline.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { cedarPlugin } from "../plugin";
|
|
5
6
|
import { readFileSync } from "fs";
|
|
6
7
|
import { dirname, join } from "path";
|
|
7
8
|
import { fileURLToPath } from "url";
|
|
8
9
|
import {
|
|
10
|
+
collectSkills,
|
|
9
11
|
packagePipeline,
|
|
10
12
|
type PackageOptions,
|
|
11
13
|
type PackageResult,
|
|
@@ -42,8 +44,7 @@ export async function packageLexicon(opts: PackageOptions = {}): Promise<Package
|
|
|
42
44
|
|
|
43
45
|
srcDir: pkgDir,
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
collectSkills: () => new Map(),
|
|
47
|
+
collectSkills: () => collectSkills(cedarPlugin.skills?.() ?? []),
|
|
47
48
|
|
|
48
49
|
version: pkgJson.version ?? "0.0.0",
|
|
49
50
|
},
|
package/src/config.test.ts
CHANGED
|
@@ -37,6 +37,18 @@ describe("cedar config namespace", () => {
|
|
|
37
37
|
it("leaves everything optional, so an empty namespace is legal", () => {
|
|
38
38
|
expect(cedarConfigSchema.parse({})).toEqual({});
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it("takes the dogwood binary path (#1659)", () => {
|
|
42
|
+
const parsed = cedarConfigSchema.parse({ dogwood: { binary: "../dogwood/target/release/dogwood" } });
|
|
43
|
+
expect(parsed.dogwood?.binary).toBe("../dogwood/target/release/dogwood");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects a typo in the dogwood namespace, where a silent skip would follow", () => {
|
|
47
|
+
// A misspelled key here means "no binary found", and "no binary found" is
|
|
48
|
+
// an advisory rather than a failure — exactly the case strictObject exists
|
|
49
|
+
// to catch before it turns into a validation gap nobody notices.
|
|
50
|
+
expect(() => cedarConfigSchema.parse({ dogwood: { path: "/usr/local/bin/dogwood" } })).toThrow();
|
|
51
|
+
});
|
|
40
52
|
});
|
|
41
53
|
|
|
42
54
|
describe("schema resolution", () => {
|
package/src/config.ts
CHANGED
|
@@ -71,6 +71,34 @@ export const cedarConfigSchema = z.strictObject({
|
|
|
71
71
|
requireProjectSchema: z.boolean().optional(),
|
|
72
72
|
})
|
|
73
73
|
.optional(),
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The dogwood dialect's CLI-gated leg (#1659).
|
|
77
|
+
*
|
|
78
|
+
* `strictObject` again, for the reason the outer one is: a typo here is the
|
|
79
|
+
* difference between "full .dw validation ran" and "DWDE010 said it could
|
|
80
|
+
* not run", and the second is easy to skim past.
|
|
81
|
+
*/
|
|
82
|
+
dogwood: z
|
|
83
|
+
.strictObject({
|
|
84
|
+
/**
|
|
85
|
+
* Path to the `dogwood` binary, absolute or relative to the config file.
|
|
86
|
+
*
|
|
87
|
+
* Optional, and its absence is not an error: DWDE010 warns-and-skips
|
|
88
|
+
* with an issue-linked advisory when no binary is found, which is the
|
|
89
|
+
* epic's explicit exception. Set this when the binary is built somewhere
|
|
90
|
+
* `PATH` does not reach — the on-demand harness shape the epic borrows
|
|
91
|
+
* from forgejo-runtime-e2e.
|
|
92
|
+
*
|
|
93
|
+
* A `chant.config.ts` cannot be read from inside a post-synth check —
|
|
94
|
+
* `check()` is synchronous and evaluating project code is not — so this
|
|
95
|
+
* key is honoured from `chant.config.json` and the `CHANT_DOGWOOD_BINARY`
|
|
96
|
+
* environment variable covers the TypeScript-config case. See
|
|
97
|
+
* `src/dogwood/cli.ts`.
|
|
98
|
+
*/
|
|
99
|
+
binary: z.string().optional(),
|
|
100
|
+
})
|
|
101
|
+
.optional(),
|
|
74
102
|
});
|
|
75
103
|
|
|
76
104
|
export type CedarConfig = z.infer<typeof cedarConfigSchema>;
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dogwood` CLI adapter (#1659).
|
|
3
|
+
*
|
|
4
|
+
* Every test here injects a runner. Nothing in the gating suite executes a
|
|
5
|
+
* real binary — the epic's own rule ("nothing in gating CI executes the
|
|
6
|
+
* dogwood binary"), and the reason the adapter takes a runner at all. The
|
|
7
|
+
* JSON fixtures are upstream's two report shapes, read from the pinned
|
|
8
|
+
* `dogwood-cli/src/ops.rs` and `dogwood-cli/src/error.rs` structs rather than
|
|
9
|
+
* from the human text, which is the surface most likely to be retuned by a
|
|
10
|
+
* sync.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, afterEach } from "vitest";
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
import {
|
|
18
|
+
DOGWOOD_BINARY_ENV,
|
|
19
|
+
configureDogwoodCli,
|
|
20
|
+
findDogwoodBinary,
|
|
21
|
+
formatDogwoodDiagnostic,
|
|
22
|
+
parseLowerOutput,
|
|
23
|
+
parseReplayOutput,
|
|
24
|
+
parseValidateOutput,
|
|
25
|
+
resetDogwoodCli,
|
|
26
|
+
runDogwoodLower,
|
|
27
|
+
runDogwoodReplay,
|
|
28
|
+
runDogwoodValidate,
|
|
29
|
+
type DogwoodRun,
|
|
30
|
+
type DogwoodRunner,
|
|
31
|
+
} from "./cli";
|
|
32
|
+
|
|
33
|
+
const TESTDATA = join(fileURLToPath(new URL(".", import.meta.url)), "testdata");
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A real `dogwood lower --format json` artifact set, adapted from
|
|
37
|
+
* dogwood-policy/dogwood@5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c (Apache-2.0),
|
|
38
|
+
* `dogwood-docs/examples/read_after_login`.
|
|
39
|
+
*/
|
|
40
|
+
const LOWERED_READ_AFTER_LOGIN: unknown = JSON.parse(
|
|
41
|
+
readFileSync(join(TESTDATA, "lowered-read-after-login.json"), "utf-8"),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
resetDogwoodCli();
|
|
46
|
+
delete process.env[DOGWOOD_BINARY_ENV];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function run(over: Partial<DogwoodRun>): DogwoodRun {
|
|
50
|
+
return { status: 0, stdout: "", stderr: "", ...over };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function json(value: unknown, status = 0): DogwoodRun {
|
|
54
|
+
return run({ status, stdout: JSON.stringify(value) });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── The validate report shape ──────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe("parseValidateOutput — the ValidateReport shape", () => {
|
|
60
|
+
test("a clean report is a pass", () => {
|
|
61
|
+
const result = parseValidateOutput(
|
|
62
|
+
json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] }),
|
|
63
|
+
);
|
|
64
|
+
expect(result).toEqual({ kind: "passed", warnings: [] });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("warnings do not fail the report, and are carried through", () => {
|
|
68
|
+
const result = parseValidateOutput(
|
|
69
|
+
json({
|
|
70
|
+
passed: true,
|
|
71
|
+
passed_without_warnings: false,
|
|
72
|
+
errors: [],
|
|
73
|
+
warnings: [{ severity: "warning", message: "policy is impossible", spanned: false }],
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
expect(result.kind).toBe("passed");
|
|
77
|
+
if (result.kind !== "passed") return;
|
|
78
|
+
expect(result.warnings).toHaveLength(1);
|
|
79
|
+
expect(result.warnings[0].message).toBe("policy is impossible");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a rejection carries every finding, with its code, help and byte spans", () => {
|
|
83
|
+
const result = parseValidateOutput(
|
|
84
|
+
json(
|
|
85
|
+
{
|
|
86
|
+
passed: false,
|
|
87
|
+
passed_without_warnings: false,
|
|
88
|
+
errors: [
|
|
89
|
+
{
|
|
90
|
+
severity: "error",
|
|
91
|
+
code: "extension",
|
|
92
|
+
message:
|
|
93
|
+
'predicate `Drupe::Action::"NoSuchAction"::request` does not name a declared event',
|
|
94
|
+
labels: [{ start: 120, len: 42, message: "here" }],
|
|
95
|
+
spanned: true,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
severity: "error",
|
|
99
|
+
code: "cedar",
|
|
100
|
+
message: "attribute `input.nosuchfield` in context for Drupe::Action::\"Read\" not found",
|
|
101
|
+
labels: [],
|
|
102
|
+
help: "did you mean `user`?",
|
|
103
|
+
spanned: false,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
warnings: [],
|
|
107
|
+
},
|
|
108
|
+
2,
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
expect(result.kind).toBe("rejected");
|
|
112
|
+
if (result.kind !== "rejected") return;
|
|
113
|
+
expect(result.errors).toHaveLength(2);
|
|
114
|
+
expect(result.errors[0].code).toBe("extension");
|
|
115
|
+
expect(result.errors[0].labels).toEqual([{ start: 120, len: 42, message: "here" }]);
|
|
116
|
+
expect(result.errors[1].help).toBe("did you mean `user`?");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("the report wins over the exit code — passed:false at exit 0 is still a rejection", () => {
|
|
120
|
+
const result = parseValidateOutput(
|
|
121
|
+
json({
|
|
122
|
+
passed: false,
|
|
123
|
+
passed_without_warnings: false,
|
|
124
|
+
errors: [{ severity: "error", message: "nope", spanned: false }],
|
|
125
|
+
warnings: [],
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
expect(result.kind).toBe("rejected");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("and in the other direction — a clean report at a non-zero exit is still a pass", () => {
|
|
132
|
+
const result = parseValidateOutput(
|
|
133
|
+
json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] }, 2),
|
|
134
|
+
);
|
|
135
|
+
expect(result.kind).toBe("passed");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── The fatal OpError shape ────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe("parseValidateOutput — the OpError shape", () => {
|
|
142
|
+
test("a fatal parse error is its own arm, not an entry in errors[]", () => {
|
|
143
|
+
const result = parseValidateOutput(
|
|
144
|
+
json(
|
|
145
|
+
{
|
|
146
|
+
severity: "error",
|
|
147
|
+
message: "expected `within` after `formerly`",
|
|
148
|
+
labels: [{ start: 64, len: 8 }],
|
|
149
|
+
spanned: true,
|
|
150
|
+
related: [{ severity: "error", message: "and a second parse error", spanned: false }],
|
|
151
|
+
},
|
|
152
|
+
2,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
expect(result.kind).toBe("fatal");
|
|
156
|
+
if (result.kind !== "fatal") return;
|
|
157
|
+
expect(result.error.message).toBe("expected `within` after `formerly`");
|
|
158
|
+
expect(result.error.labels).toEqual([{ start: 64, len: 8 }]);
|
|
159
|
+
expect(result.related).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a span-less fatal (the OpError::message path) still parses", () => {
|
|
163
|
+
const result = parseValidateOutput(
|
|
164
|
+
json({ severity: "error", message: "rhai implementation has no script", spanned: false }, 2),
|
|
165
|
+
);
|
|
166
|
+
expect(result.kind).toBe("fatal");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("an internal `at line N column M` tail is not passed through", () => {
|
|
170
|
+
const result = parseValidateOutput(
|
|
171
|
+
json({ severity: "error", message: "missing field `policies` at line 1 column 42", spanned: false }, 2),
|
|
172
|
+
);
|
|
173
|
+
expect(result.kind).toBe("fatal");
|
|
174
|
+
if (result.kind !== "fatal") return;
|
|
175
|
+
expect(result.error.message).toBe("missing field `policies`");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── The exit-2 ambiguity ───────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe("parseValidateOutput — exit 2 is not a verdict", () => {
|
|
182
|
+
test("an unknown flag exits 2 with nothing on stdout, and is unusable rather than rejected", () => {
|
|
183
|
+
// clap's own usage error. The published guide claims exit 1 for a bad
|
|
184
|
+
// flag; the binary spends 2 on it, the same code a rejected policy set
|
|
185
|
+
// gets. Reading the code alone would fail the build over a flag rename.
|
|
186
|
+
const result = parseValidateOutput(
|
|
187
|
+
run({
|
|
188
|
+
status: 2,
|
|
189
|
+
stdout: "",
|
|
190
|
+
stderr: "error: unexpected argument '--nosuchflag' found\n\nUsage: dogwood validate <POLICIES> --policy-schema <FILE>",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
expect(result.kind).toBe("unusable");
|
|
194
|
+
if (result.kind !== "unusable") return;
|
|
195
|
+
expect(result.reason).toContain("without a JSON report on stdout");
|
|
196
|
+
expect(result.reason).toContain("--nosuchflag");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a spawn failure is unusable, not a verdict", () => {
|
|
200
|
+
const result = parseValidateOutput(run({ status: null, error: "spawnSync dogwood ENOENT" }));
|
|
201
|
+
expect(result.kind).toBe("unusable");
|
|
202
|
+
if (result.kind !== "unusable") return;
|
|
203
|
+
expect(result.reason).toContain("ENOENT");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("output that is not JSON at all is unusable", () => {
|
|
207
|
+
const result = parseValidateOutput(run({ status: 0, stdout: "OK: validation passed with no errors." }));
|
|
208
|
+
expect(result.kind).toBe("unusable");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("JSON in neither shape is unusable", () => {
|
|
212
|
+
const result = parseValidateOutput(json({ something: "else" }, 2));
|
|
213
|
+
expect(result.kind).toBe("unusable");
|
|
214
|
+
if (result.kind !== "unusable") return;
|
|
215
|
+
expect(result.reason).toContain("neither the report nor the error shape");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── Rendering ──────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe("formatDogwoodDiagnostic", () => {
|
|
222
|
+
test("renders code, message, help and byte ranges on one line", () => {
|
|
223
|
+
const text = formatDogwoodDiagnostic({
|
|
224
|
+
severity: "error",
|
|
225
|
+
code: "extension",
|
|
226
|
+
message: "temporal window `48h` exceeds the maximum allowed window `24h`",
|
|
227
|
+
labels: [{ start: 100, len: 20, message: "this window" }],
|
|
228
|
+
help: "raise max_window",
|
|
229
|
+
spanned: true,
|
|
230
|
+
});
|
|
231
|
+
expect(text).toBe(
|
|
232
|
+
"[extension] temporal window `48h` exceeds the maximum allowed window `24h` (raise max_window) at bytes 100-120: this window",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a span-less finding renders as just its message", () => {
|
|
237
|
+
expect(formatDogwoodDiagnostic({ severity: "error", message: "bare", labels: [], spanned: false })).toBe(
|
|
238
|
+
"bare",
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── lower ──────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("parseLowerOutput", () => {
|
|
246
|
+
test("reads the artifact shape, including the JSON schema as a string", () => {
|
|
247
|
+
const result = parseLowerOutput(run({ status: 0, stdout: JSON.stringify(LOWERED_READ_AFTER_LOGIN) }));
|
|
248
|
+
expect(result.kind).toBe("lowered");
|
|
249
|
+
if (result.kind !== "lowered") return;
|
|
250
|
+
expect(result.value.cedarPolicies).toContain("context.policy_0__temporal_0");
|
|
251
|
+
expect(result.value.temporalFields).toEqual(["policy_0__temporal_0"]);
|
|
252
|
+
// False because a temporal field was hoisted — the lowered Cedar needs
|
|
253
|
+
// dogwood at authorize time to fill the slot.
|
|
254
|
+
expect(result.value.selfContained).toBe(false);
|
|
255
|
+
expect(JSON.parse(result.value.cedarSchemaJson)).toHaveProperty("Drupe");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a failed lower comes back through the fatal channel", () => {
|
|
259
|
+
const result = parseLowerOutput(
|
|
260
|
+
json({ severity: "error", message: "unknown macro `once`", labels: [], spanned: false }, 2),
|
|
261
|
+
);
|
|
262
|
+
expect(result.kind).toBe("fatal");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── replay ─────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe("parseReplayOutput", () => {
|
|
269
|
+
/** The stream #1657 recorded for `read_after_login`, exit 0. */
|
|
270
|
+
const RECORDED = {
|
|
271
|
+
verdicts: [
|
|
272
|
+
{ index: 0, timestamp: 0, verdict: "deny", determining_rules: [], errors: [] },
|
|
273
|
+
{ index: 1, timestamp: 10, verdict: "allow", determining_rules: [0], errors: [] },
|
|
274
|
+
{ index: 2, timestamp: 7200, verdict: "deny", determining_rules: [], errors: [] },
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
test("reads the verdict stream, rules included", () => {
|
|
279
|
+
const result = parseReplayOutput(json(RECORDED));
|
|
280
|
+
expect(result.kind).toBe("replayed");
|
|
281
|
+
if (result.kind !== "replayed") return;
|
|
282
|
+
expect(result.verdicts).toHaveLength(3);
|
|
283
|
+
expect(result.verdicts[1]).toEqual({
|
|
284
|
+
index: 1,
|
|
285
|
+
timestamp: 10,
|
|
286
|
+
verdict: "allow",
|
|
287
|
+
determiningRules: [0],
|
|
288
|
+
errors: [],
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("every verdict DENY at exit 0 is a result, not a failure", () => {
|
|
293
|
+
// The sharpest edge in the replay contract: a nonzero exit means the trace
|
|
294
|
+
// or the policy set failed to load, never that a policy denied.
|
|
295
|
+
const allDeny = { verdicts: RECORDED.verdicts.map((v) => ({ ...v, verdict: "deny" })) };
|
|
296
|
+
const result = parseReplayOutput(json(allDeny));
|
|
297
|
+
expect(result.kind).toBe("replayed");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("an unrecognised verdict reads as a deny rather than being dropped", () => {
|
|
301
|
+
// Dropping it would shift every later index; treating it as a permit would
|
|
302
|
+
// be a permission upstream never granted.
|
|
303
|
+
const result = parseReplayOutput(json({ verdicts: [{ index: 0, timestamp: 0, verdict: "maybe" }] }));
|
|
304
|
+
expect(result.kind).toBe("replayed");
|
|
305
|
+
if (result.kind !== "replayed") return;
|
|
306
|
+
expect(result.verdicts[0].verdict).toBe("deny");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a malformed trace line comes back through the fatal channel", () => {
|
|
310
|
+
const result = parseReplayOutput(
|
|
311
|
+
json({ severity: "error", message: "trace parse: line 1: timepoint must start with `@`", spanned: false }, 2),
|
|
312
|
+
);
|
|
313
|
+
expect(result.kind).toBe("fatal");
|
|
314
|
+
if (result.kind !== "fatal") return;
|
|
315
|
+
expect(result.error.message).toMatch(/timepoint must start with/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("no JSON at all is unusable, never a verdict", () => {
|
|
319
|
+
const result = parseReplayOutput(run({ status: 2, stderr: "error: unexpected argument '--nope'" }));
|
|
320
|
+
expect(result.kind).toBe("unusable");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("per-evaluation errors are carried through in either shape", () => {
|
|
324
|
+
const result = parseReplayOutput(
|
|
325
|
+
json({
|
|
326
|
+
verdicts: [
|
|
327
|
+
{ index: 0, timestamp: 0, verdict: "deny", determining_rules: [], errors: ["no script"] },
|
|
328
|
+
{
|
|
329
|
+
index: 1,
|
|
330
|
+
timestamp: 1,
|
|
331
|
+
verdict: "deny",
|
|
332
|
+
determining_rules: [],
|
|
333
|
+
errors: [{ message: "provider failed at line 1 column 4" }],
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
expect(result.kind).toBe("replayed");
|
|
339
|
+
if (result.kind !== "replayed") return;
|
|
340
|
+
expect(result.verdicts[0].errors).toEqual(["no script"]);
|
|
341
|
+
// The internal position is scrubbed for the same reason as everywhere else.
|
|
342
|
+
expect(result.verdicts[1].errors).toEqual(["provider failed"]);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("runDogwoodReplay", () => {
|
|
347
|
+
test("passes the trace as --trace beside the bundle, and asks for JSON", () => {
|
|
348
|
+
let seen: { args: string[]; trace: string } | undefined;
|
|
349
|
+
const runner: DogwoodRunner = (_binary, args) => {
|
|
350
|
+
const at = args.indexOf("--trace");
|
|
351
|
+
seen = { args, trace: readFileSync(args[at + 1], "utf-8") };
|
|
352
|
+
return run({ status: 0, stdout: JSON.stringify({ verdicts: [] }) });
|
|
353
|
+
};
|
|
354
|
+
configureDogwoodCli({ runner });
|
|
355
|
+
|
|
356
|
+
const result = runDogwoodReplay("/bin/dogwood", { policies: "p", policySchema: "s" }, "@0 A::Action::\"B\"::request()");
|
|
357
|
+
expect(result.kind).toBe("replayed");
|
|
358
|
+
expect(seen?.args[0]).toBe("replay");
|
|
359
|
+
expect(seen?.args.slice(-2)).toEqual(["--format", "json"]);
|
|
360
|
+
expect(seen?.trace).toBe('@0 A::Action::"B"::request()');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("the scratch directory, trace included, does not outlive the run", () => {
|
|
364
|
+
let tracePath = "";
|
|
365
|
+
const runner: DogwoodRunner = (_binary, args) => {
|
|
366
|
+
tracePath = args[args.indexOf("--trace") + 1];
|
|
367
|
+
return run({ status: 0, stdout: JSON.stringify({ verdicts: [] }) });
|
|
368
|
+
};
|
|
369
|
+
configureDogwoodCli({ runner });
|
|
370
|
+
runDogwoodReplay("/bin/dogwood", { policies: "p", policySchema: "s" }, "@0 A::Action::\"B\"::request()");
|
|
371
|
+
expect(existsSync(tracePath)).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("a throwing runner is an unusable run, not an exception", () => {
|
|
375
|
+
configureDogwoodCli({
|
|
376
|
+
runner: () => {
|
|
377
|
+
throw new Error("spawn blew up");
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
const result = runDogwoodReplay("/bin/dogwood", { policies: "p", policySchema: "s" }, "@0 x");
|
|
381
|
+
expect(result.kind).toBe("unusable");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ── Invocation ─────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
describe("the invocation itself", () => {
|
|
388
|
+
test("materializes the bundle and passes every schema half by path", () => {
|
|
389
|
+
let seen: { args: string[]; files: Record<string, string> } | undefined;
|
|
390
|
+
const runner: DogwoodRunner = (_binary, args) => {
|
|
391
|
+
const files: Record<string, string> = {};
|
|
392
|
+
for (let i = 0; i < args.length; i++) {
|
|
393
|
+
const value = args[i];
|
|
394
|
+
if (value.startsWith("/") || value.includes("chant-dogwood-")) {
|
|
395
|
+
try {
|
|
396
|
+
files[value.replace(/^.*\//, "")] = readFileSync(value, "utf-8");
|
|
397
|
+
} catch {
|
|
398
|
+
// not a path we wrote
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
seen = { args, files };
|
|
403
|
+
return json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] });
|
|
404
|
+
};
|
|
405
|
+
configureDogwoodCli({ runner });
|
|
406
|
+
|
|
407
|
+
const result = runDogwoodValidate("/opt/dogwood", {
|
|
408
|
+
policies: "permit (principal, action, resource);",
|
|
409
|
+
policySchema: "namespace Ns {}",
|
|
410
|
+
eventSchema: "decision event <A>::request {}",
|
|
411
|
+
macros: "def temporal once(?w, ?s) { formerly within ?w ?s };",
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.kind).toBe("passed");
|
|
415
|
+
expect(seen?.args[0]).toBe("validate");
|
|
416
|
+
expect(seen?.args).toContain("--policy-schema");
|
|
417
|
+
expect(seen?.args).toContain("--event-schema");
|
|
418
|
+
expect(seen?.args).toContain("--macros");
|
|
419
|
+
expect(seen?.args.slice(-2)).toEqual(["--format", "json"]);
|
|
420
|
+
expect(seen?.files["policies.dw"]).toBe("permit (principal, action, resource);");
|
|
421
|
+
expect(seen?.files["events.dwschema"]).toBe("decision event <A>::request {}");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("omits the service-schema flags a bundle does not carry", () => {
|
|
425
|
+
let args: string[] = [];
|
|
426
|
+
configureDogwoodCli({
|
|
427
|
+
runner: (_b, a) => {
|
|
428
|
+
args = a;
|
|
429
|
+
return json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] });
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
runDogwoodValidate("dogwood", { policies: "// none", policySchema: "namespace Ns {}" });
|
|
433
|
+
expect(args).not.toContain("--event-schema");
|
|
434
|
+
expect(args).not.toContain("--macros");
|
|
435
|
+
expect(args).not.toContain("--providers");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("never passes --emit, which the CLI ignores under --format json", () => {
|
|
439
|
+
let args: string[] = [];
|
|
440
|
+
configureDogwoodCli({
|
|
441
|
+
runner: (_b, a) => {
|
|
442
|
+
args = a;
|
|
443
|
+
return run({ status: 0, stdout: JSON.stringify(LOWERED_READ_AFTER_LOGIN) });
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
runDogwoodLower("dogwood", { policies: "// none", policySchema: "namespace Ns {}" });
|
|
447
|
+
expect(args[0]).toBe("lower");
|
|
448
|
+
expect(args).not.toContain("--emit");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a throwing runner is an unusable result, not an exception", () => {
|
|
452
|
+
configureDogwoodCli({
|
|
453
|
+
runner: () => {
|
|
454
|
+
throw new Error("boom");
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
const result = runDogwoodValidate("dogwood", { policies: "", policySchema: "" });
|
|
458
|
+
expect(result.kind).toBe("unusable");
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ── Locating the binary ────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
describe("findDogwoodBinary", () => {
|
|
465
|
+
test("an explicit override wins and is reported as such", () => {
|
|
466
|
+
configureDogwoodCli({ binary: "/opt/dogwood/bin/dogwood" });
|
|
467
|
+
expect(findDogwoodBinary()).toEqual({ path: "/opt/dogwood/bin/dogwood", source: "override" });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("a null override forces absence, so a machine that has one still tests the advisory", () => {
|
|
471
|
+
configureDogwoodCli({ binary: null });
|
|
472
|
+
expect(findDogwoodBinary()).toBeUndefined();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("the environment variable is honoured when it names something executable", () => {
|
|
476
|
+
// `process.execPath` is a real executable on every platform the suite runs
|
|
477
|
+
// on, and standing in for the binary here costs nothing — resolution is an
|
|
478
|
+
// access check, not a run.
|
|
479
|
+
process.env[DOGWOOD_BINARY_ENV] = process.execPath;
|
|
480
|
+
expect(findDogwoodBinary()).toEqual({ path: process.execPath, source: "env" });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("an environment variable pointing at nothing resolves past it", () => {
|
|
484
|
+
process.env[DOGWOOD_BINARY_ENV] = "/nonexistent/dogwood";
|
|
485
|
+
const found = findDogwoodBinary();
|
|
486
|
+
expect(found?.source).not.toBe("env");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("cedar.dogwood.binary in a chant.config.json is read, walking up from the start directory", () => {
|
|
490
|
+
const root = mkdtempSync(join(tmpdir(), "chant-dogwood-config-"));
|
|
491
|
+
try {
|
|
492
|
+
writeFileSync(
|
|
493
|
+
join(root, "chant.config.json"),
|
|
494
|
+
JSON.stringify({ cedar: { dogwood: { binary: process.execPath } } }),
|
|
495
|
+
);
|
|
496
|
+
const nested = join(root, "src", "stack");
|
|
497
|
+
mkdirSync(nested, { recursive: true });
|
|
498
|
+
expect(findDogwoodBinary(nested)).toEqual({ path: process.execPath, source: "config" });
|
|
499
|
+
} finally {
|
|
500
|
+
rmSync(root, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("an unparseable chant.config.json is walked past rather than thrown from", () => {
|
|
505
|
+
const root = mkdtempSync(join(tmpdir(), "chant-dogwood-config-"));
|
|
506
|
+
try {
|
|
507
|
+
writeFileSync(join(root, "chant.config.json"), "{ not json");
|
|
508
|
+
expect(() => findDogwoodBinary(root)).not.toThrow();
|
|
509
|
+
} finally {
|
|
510
|
+
rmSync(root, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
});
|