@intentius/chant-lexicon-cedar 0.124.31 → 0.124.32
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/agentcore/embed.js +250 -0
- package/dist/agentcore/enforcement.js +81 -0
- package/dist/agentcore/scan.js +93 -0
- package/dist/avp/ambient.js +100 -0
- package/dist/avp/client.js +318 -0
- package/dist/avp/describe-resources.js +144 -0
- package/dist/avp/embed.js +125 -0
- package/dist/avp/live-export.js +151 -0
- package/dist/avp/ownership.js +144 -0
- package/dist/avp/statement.js +45 -0
- package/dist/avp/store.js +105 -0
- package/dist/avp/testdata/mock-transport.js +100 -0
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +133 -0
- package/dist/codegen/emit.js +331 -0
- package/dist/codegen/generate-cli.js +18 -0
- package/dist/codegen/generate.js +122 -0
- package/dist/codegen/naming.js +90 -0
- package/dist/codegen/package.js +33 -0
- package/dist/commands.js +86 -0
- package/dist/composites/deny-by-default-set.js +51 -0
- package/dist/composites/index.js +9 -0
- package/dist/composites/owner-can-manage.js +48 -0
- package/dist/config.js +159 -0
- package/dist/coverage.js +112 -0
- package/dist/detect.js +61 -0
- package/dist/dogwood/cli.js +477 -0
- package/dist/dogwood/event-schema.js +219 -0
- package/dist/dogwood/index.js +32 -0
- package/dist/dogwood/macros.js +175 -0
- package/dist/dogwood/policy.js +58 -0
- package/dist/dogwood/replay-activity.js +313 -0
- package/dist/dogwood/replay-op.js +134 -0
- package/dist/dogwood/scan.js +234 -0
- package/dist/dogwood/serialize.js +199 -0
- package/dist/dogwood/temporal.js +354 -0
- package/dist/dogwood/trace.js +269 -0
- package/dist/dogwood/upstream.js +40 -0
- package/dist/dogwood/window.js +82 -0
- package/dist/gate-policy-pack.js +189 -0
- package/dist/gate-policy.js +168 -0
- package/dist/gate-schema.js +122 -0
- package/dist/generated/index.js +94 -0
- package/dist/generated/runtime.js +2 -0
- package/dist/import/adapter.js +54 -0
- package/dist/import/clause-text.js +166 -0
- package/dist/import/generator.js +178 -0
- package/dist/import/parser.js +273 -0
- package/dist/index.js +79 -0
- package/dist/init-templates.js +330 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +65 -0
- package/dist/lint/audit-lineage.js +23 -0
- package/dist/lint/post-synth/cedar-helpers.js +123 -0
- package/dist/lint/post-synth/cedc010.js +59 -0
- package/dist/lint/post-synth/cedc011.js +29 -0
- package/dist/lint/post-synth/cedc012.js +34 -0
- package/dist/lint/post-synth/cedc013.js +45 -0
- package/dist/lint/post-synth/cedc014.js +54 -0
- package/dist/lint/post-synth/cede010.js +51 -0
- package/dist/lint/post-synth/cede011.js +36 -0
- package/dist/lint/post-synth/ceds010.js +31 -0
- package/dist/lint/post-synth/ceds011.js +22 -0
- package/dist/lint/post-synth/ceds012.js +31 -0
- package/dist/lint/post-synth/dogwood-helpers.js +94 -0
- package/dist/lint/post-synth/dwdc010.js +38 -0
- package/dist/lint/post-synth/dwdc011.js +34 -0
- package/dist/lint/post-synth/dwdc012.js +24 -0
- package/dist/lint/post-synth/dwdc013.js +29 -0
- package/dist/lint/post-synth/dwde010.js +84 -0
- package/dist/lint/post-synth/dwde011.js +67 -0
- package/dist/lint/post-synth/dwds010.js +21 -0
- package/dist/lint/post-synth/index.js +36 -0
- package/dist/lint/post-synth/wasm-helpers.js +234 -0
- package/dist/lint/rules/index.js +9 -0
- package/dist/lint/rules/policy-shape.js +114 -0
- package/dist/lsp/completions.js +52 -0
- package/dist/lsp/hover.js +56 -0
- package/dist/lsp/registry.d.ts.map +1 -1
- package/dist/lsp/registry.js +51 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/index.js +82 -0
- package/dist/mcp/policy-coverage.js +134 -0
- package/dist/op/activities/index.js +16 -0
- package/dist/package-cli.js +18 -0
- package/dist/plugin.js +304 -0
- package/dist/policy-text.js +117 -0
- package/dist/schema-artifact.js +66 -0
- package/dist/serializer.js +203 -0
- package/dist/spec/fetch.js +96 -0
- package/dist/spec/parse.js +164 -0
- package/dist/spec/pin.js +170 -0
- package/dist/spec/wasm.js +194 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate.js +74 -0
- package/package.json +7 -7
- package/src/codegen/package.ts +2 -2
- package/src/coverage.ts +2 -2
- package/src/import/generator.ts +2 -2
- package/src/lsp/registry.ts +2 -1
- package/src/spec/fetch.ts +2 -2
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dogwood` CLI adapter (#1659, epic #1646).
|
|
3
|
+
*
|
|
4
|
+
* The epic splits validation by what needs a binary. Everything answerable in
|
|
5
|
+
* TypeScript — the DWDC walls in `../lint/post-synth/dwdc0*.ts` — runs always
|
|
6
|
+
* and gates. Full `.dw` validation needs upstream's own frontend, which ships
|
|
7
|
+
* only as a Rust CLI: no npm package, no wasm build, nothing to link against.
|
|
8
|
+
* This module is the seam to it, and everything downstream of it treats the
|
|
9
|
+
* binary as optional.
|
|
10
|
+
*
|
|
11
|
+
* Five things about that CLI decide how this is written, all from the #1657
|
|
12
|
+
* verification report and re-read from the pinned `dogwood-cli/src` sources:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Exit codes do not identify the failure.** Exit 2 is both "the policy
|
|
15
|
+
* set was rejected" and clap's own usage error for an unknown flag, and the
|
|
16
|
+
* published guide claims 1 for the latter. So nothing here branches on the
|
|
17
|
+
* exit code: the JSON on stdout decides, and a run that produced no JSON is
|
|
18
|
+
* {@link DogwoodUnusable} — reported as "the CLI could not be used", never
|
|
19
|
+
* as "your policy is bad".
|
|
20
|
+
* 2. **There are two JSON shapes, not one.** A type-check finding comes back
|
|
21
|
+
* in a `ValidateReport` (`{passed, passed_without_warnings, errors[],
|
|
22
|
+
* warnings[]}`); a fatal parse, macro or lowering error replaces the whole
|
|
23
|
+
* report with a bare `OpError` — the same diagnostic fields flattened at the
|
|
24
|
+
* top level, plus `related[]`. {@link parseValidateOutput} handles both.
|
|
25
|
+
* 3. **`--format json` writes to stdout for both channels**, success and
|
|
26
|
+
* fatal alike, and human-mode errors go to stderr. Every call here passes
|
|
27
|
+
* `--format json` and reads stdout; stderr is kept only to explain an
|
|
28
|
+
* unusable run.
|
|
29
|
+
* 4. **`--emit` is ignored under `--format json`** — the JSON always carries
|
|
30
|
+
* all three lowered artifacts — so {@link runDogwoodLower} does not pass it.
|
|
31
|
+
* 5. **Label offsets are bytes into the source**, not line/column. They are
|
|
32
|
+
* rendered as byte ranges. The fatal channel's own internal positions are
|
|
33
|
+
* never passed through verbatim, for the same reason `wasm-helpers.ts`
|
|
34
|
+
* strips cedar-wasm's `at line 1 column 42`: they locate something in a
|
|
35
|
+
* representation the reader never saw.
|
|
36
|
+
*
|
|
37
|
+
* The runner is injectable ({@link configureDogwoodCli}) so the checks are
|
|
38
|
+
* testable without the binary. Nothing in gating CI executes it.
|
|
39
|
+
*/
|
|
40
|
+
import { spawnSync } from "child_process";
|
|
41
|
+
import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
42
|
+
import { tmpdir } from "os";
|
|
43
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "path";
|
|
44
|
+
// ── Locating the binary ───────────────────────────────────────────
|
|
45
|
+
/** The executable name looked up on `PATH`. */
|
|
46
|
+
export const DOGWOOD_BINARY_NAME = "dogwood";
|
|
47
|
+
/** Environment override, read before `PATH` and after an explicit config. */
|
|
48
|
+
export const DOGWOOD_BINARY_ENV = "CHANT_DOGWOOD_BINARY";
|
|
49
|
+
let defaults = {};
|
|
50
|
+
/**
|
|
51
|
+
* Override binary resolution and the process runner.
|
|
52
|
+
*
|
|
53
|
+
* Two callers. Tests inject a runner so no gating check ever depends on a real
|
|
54
|
+
* binary — including on a developer's machine that happens to have one, which
|
|
55
|
+
* is why `binary: null` (force-absent) exists as well as `binary: "/path"`.
|
|
56
|
+
* The on-demand harness — the `forgejo-runtime-e2e` shape the epic names — is
|
|
57
|
+
* the other: it knows where it built the binary and says so directly rather
|
|
58
|
+
* than arranging `PATH`.
|
|
59
|
+
*/
|
|
60
|
+
export function configureDogwoodCli(options) {
|
|
61
|
+
defaults = { ...defaults, ...options };
|
|
62
|
+
}
|
|
63
|
+
/** Drop every override, including any cached resolution. Test-only. */
|
|
64
|
+
export function resetDogwoodCli() {
|
|
65
|
+
defaults = {};
|
|
66
|
+
cachedConfigBinary = undefined;
|
|
67
|
+
}
|
|
68
|
+
let cachedConfigBinary;
|
|
69
|
+
/**
|
|
70
|
+
* `cedar.dogwood.binary` out of a `chant.config.json`, walking up from `dir`.
|
|
71
|
+
*
|
|
72
|
+
* JSON only, and that is a real limitation rather than an oversight. A
|
|
73
|
+
* `PostSynthCheck.check()` is synchronous — see `@intentius/chant/lint/post-synth`
|
|
74
|
+
* — while `loadChantConfigUpward` is async and, under `chant build --sandbox`,
|
|
75
|
+
* evaluates `chant.config.ts` in a child process. Neither is available from
|
|
76
|
+
* inside a check. `chant.config.json` is data, not code (core says so in
|
|
77
|
+
* `loadChantConfig`'s own doc), so reading it here executes nothing. A project
|
|
78
|
+
* on `chant.config.ts` sets {@link DOGWOOD_BINARY_ENV} or calls
|
|
79
|
+
* {@link configureDogwoodCli} instead.
|
|
80
|
+
*/
|
|
81
|
+
function configuredBinary(startDir) {
|
|
82
|
+
if (cachedConfigBinary && cachedConfigBinary.dir === startDir)
|
|
83
|
+
return cachedConfigBinary.value;
|
|
84
|
+
let value;
|
|
85
|
+
let dir = startDir;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const path = join(dir, "chant.config.json");
|
|
88
|
+
if (existsSync(path)) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
91
|
+
const binary = readPath(parsed, ["cedar", "dogwood", "binary"]);
|
|
92
|
+
if (typeof binary === "string" && binary.length > 0) {
|
|
93
|
+
value = isAbsolute(binary) ? binary : resolve(dir, binary);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// An unreadable or malformed config is every other command's error to
|
|
99
|
+
// report first; a missing dogwood binary is not the place to raise it.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const parent = dirname(dir);
|
|
103
|
+
if (parent === dir)
|
|
104
|
+
break;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
cachedConfigBinary = { dir: startDir, value };
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
function readPath(value, path) {
|
|
111
|
+
let current = value;
|
|
112
|
+
for (const key of path) {
|
|
113
|
+
if (typeof current !== "object" || current === null)
|
|
114
|
+
return undefined;
|
|
115
|
+
current = current[key];
|
|
116
|
+
}
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
function executable(path) {
|
|
120
|
+
try {
|
|
121
|
+
accessSync(path, constants.X_OK);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** The first `dogwood` on `PATH`, by an access check rather than by running it. */
|
|
129
|
+
function onPath(name) {
|
|
130
|
+
for (const entry of (process.env.PATH ?? "").split(delimiter)) {
|
|
131
|
+
if (!entry)
|
|
132
|
+
continue;
|
|
133
|
+
const candidate = join(entry, name);
|
|
134
|
+
if (executable(candidate))
|
|
135
|
+
return candidate;
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Locate the `dogwood` binary, or `undefined` when there is none.
|
|
141
|
+
*
|
|
142
|
+
* Order: an explicit {@link configureDogwoodCli} override, `$CHANT_DOGWOOD_BINARY`,
|
|
143
|
+
* `cedar.dogwood.binary` from a `chant.config.json`, then `PATH`. A path from
|
|
144
|
+
* the environment or the config that is not executable is resolved past rather
|
|
145
|
+
* than returned to fail later; the advisory that follows names where chant
|
|
146
|
+
* looked. An explicit override is taken as given — its caller knows.
|
|
147
|
+
*/
|
|
148
|
+
export function findDogwoodBinary(cwd = process.cwd()) {
|
|
149
|
+
if (defaults.binary === null)
|
|
150
|
+
return undefined;
|
|
151
|
+
if (typeof defaults.binary === "string")
|
|
152
|
+
return { path: defaults.binary, source: "override" };
|
|
153
|
+
const fromEnv = process.env[DOGWOOD_BINARY_ENV];
|
|
154
|
+
if (fromEnv && executable(fromEnv))
|
|
155
|
+
return { path: fromEnv, source: "env" };
|
|
156
|
+
const fromConfig = configuredBinary(cwd);
|
|
157
|
+
if (fromConfig && executable(fromConfig))
|
|
158
|
+
return { path: fromConfig, source: "config" };
|
|
159
|
+
const found = onPath(DOGWOOD_BINARY_NAME);
|
|
160
|
+
return found ? { path: found, source: "path" } : undefined;
|
|
161
|
+
}
|
|
162
|
+
/** Where chant looked, for an advisory that has to be actionable. */
|
|
163
|
+
export const DOGWOOD_SEARCH_ORDER = `$${DOGWOOD_BINARY_ENV}, then cedar.dogwood.binary in chant.config.json, then \`${DOGWOOD_BINARY_NAME}\` on PATH`;
|
|
164
|
+
function isRecord(value) {
|
|
165
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Strip an internal `at line N column M` tail.
|
|
169
|
+
*
|
|
170
|
+
* Same reasoning as `wasm-helpers.ts`'s `call()`: a position that indexes a
|
|
171
|
+
* representation the reader never saw is worse than no position, because it
|
|
172
|
+
* looks like it points at their policy. The byte-offset labels are the
|
|
173
|
+
* positions this module does surface, and those genuinely index the `.dw`
|
|
174
|
+
* source chant wrote.
|
|
175
|
+
*/
|
|
176
|
+
function scrubPosition(message) {
|
|
177
|
+
return message.replace(/\s+at line \d+ column \d+\.?$/, "");
|
|
178
|
+
}
|
|
179
|
+
function parseDiagnostic(value) {
|
|
180
|
+
if (!isRecord(value) || typeof value.message !== "string")
|
|
181
|
+
return undefined;
|
|
182
|
+
const labels = [];
|
|
183
|
+
if (Array.isArray(value.labels)) {
|
|
184
|
+
for (const raw of value.labels) {
|
|
185
|
+
if (!isRecord(raw) || typeof raw.start !== "number" || typeof raw.len !== "number")
|
|
186
|
+
continue;
|
|
187
|
+
labels.push({
|
|
188
|
+
start: raw.start,
|
|
189
|
+
len: raw.len,
|
|
190
|
+
...(typeof raw.message === "string" ? { message: raw.message } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
severity: typeof value.severity === "string" ? value.severity : "error",
|
|
196
|
+
...(typeof value.code === "string" ? { code: value.code } : {}),
|
|
197
|
+
message: scrubPosition(value.message),
|
|
198
|
+
labels,
|
|
199
|
+
...(typeof value.help === "string" ? { help: value.help } : {}),
|
|
200
|
+
spanned: value.spanned === true || labels.length > 0,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function parseDiagnostics(value) {
|
|
204
|
+
if (!Array.isArray(value))
|
|
205
|
+
return [];
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const entry of value) {
|
|
208
|
+
const parsed = parseDiagnostic(entry);
|
|
209
|
+
if (parsed)
|
|
210
|
+
out.push(parsed);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* One diagnostic as a single line: code, message, help, then the byte ranges.
|
|
216
|
+
*
|
|
217
|
+
* Byte offsets rather than line/column because that is what upstream reports
|
|
218
|
+
* and converting them would mean re-deriving line breaks over a file this
|
|
219
|
+
* module does not hold — a wrong line number is worse than an honest offset.
|
|
220
|
+
*/
|
|
221
|
+
export function formatDogwoodDiagnostic(diagnostic) {
|
|
222
|
+
const parts = [];
|
|
223
|
+
if (diagnostic.code)
|
|
224
|
+
parts.push(`[${diagnostic.code}]`);
|
|
225
|
+
parts.push(diagnostic.message);
|
|
226
|
+
if (diagnostic.help)
|
|
227
|
+
parts.push(`(${diagnostic.help})`);
|
|
228
|
+
if (diagnostic.labels.length > 0) {
|
|
229
|
+
const spans = diagnostic.labels
|
|
230
|
+
.map((l) => {
|
|
231
|
+
const range = `bytes ${l.start}-${l.start + l.len}`;
|
|
232
|
+
return l.message ? `${range}: ${l.message}` : range;
|
|
233
|
+
})
|
|
234
|
+
.join("; ");
|
|
235
|
+
parts.push(`at ${spans}`);
|
|
236
|
+
}
|
|
237
|
+
return parts.join(" ");
|
|
238
|
+
}
|
|
239
|
+
function unusable(reason) {
|
|
240
|
+
return { kind: "unusable", reason };
|
|
241
|
+
}
|
|
242
|
+
/** A short, single-line tail of whatever the process said, for an unusable run. */
|
|
243
|
+
function streamNote(run) {
|
|
244
|
+
const text = (run.stderr || run.stdout).trim().split("\n").slice(0, 2).join(" ").trim();
|
|
245
|
+
const clipped = text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
|
246
|
+
return clipped ? `: ${clipped}` : "";
|
|
247
|
+
}
|
|
248
|
+
function fatalFrom(parsed) {
|
|
249
|
+
const error = parseDiagnostic(parsed);
|
|
250
|
+
if (!error)
|
|
251
|
+
return undefined;
|
|
252
|
+
return { kind: "fatal", error, related: parseDiagnostics(parsed.related) };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Read a `validate --format json` run.
|
|
256
|
+
*
|
|
257
|
+
* The JSON decides, not the exit code. A body carrying `passed` is the report
|
|
258
|
+
* shape; a body carrying `message` and no `passed` is the fatal `OpError`
|
|
259
|
+
* shape; anything else means the invocation itself was rejected — which is
|
|
260
|
+
* where clap's exit-2 usage error lands, with its complaint on stderr and
|
|
261
|
+
* nothing on stdout.
|
|
262
|
+
*/
|
|
263
|
+
export function parseValidateOutput(run) {
|
|
264
|
+
if (run.error)
|
|
265
|
+
return unusable(`the dogwood binary could not be run: ${run.error}`);
|
|
266
|
+
const parsed = readJson(run.stdout);
|
|
267
|
+
if (!parsed) {
|
|
268
|
+
return unusable(`\`dogwood validate --format json\` exited ${String(run.status)} without a JSON report on stdout, which is how an unknown flag or a rejected invocation surfaces${streamNote(run)}`);
|
|
269
|
+
}
|
|
270
|
+
if (typeof parsed.passed === "boolean") {
|
|
271
|
+
const errors = parseDiagnostics(parsed.errors);
|
|
272
|
+
const warnings = parseDiagnostics(parsed.warnings);
|
|
273
|
+
// The report wins over the exit code in both directions: a `passed: false`
|
|
274
|
+
// with a zero exit is still a rejection, and the reverse is still a pass.
|
|
275
|
+
if (parsed.passed && errors.length === 0)
|
|
276
|
+
return { kind: "passed", warnings };
|
|
277
|
+
return { kind: "rejected", errors, warnings };
|
|
278
|
+
}
|
|
279
|
+
const fatal = fatalFrom(parsed);
|
|
280
|
+
if (fatal)
|
|
281
|
+
return fatal;
|
|
282
|
+
return unusable(`\`dogwood validate --format json\` returned JSON in neither the report nor the error shape`);
|
|
283
|
+
}
|
|
284
|
+
/** Read a `lower --format json` run. Same two-shape discipline as validate. */
|
|
285
|
+
export function parseLowerOutput(run) {
|
|
286
|
+
if (run.error)
|
|
287
|
+
return unusable(`the dogwood binary could not be run: ${run.error}`);
|
|
288
|
+
const parsed = readJson(run.stdout);
|
|
289
|
+
if (!parsed) {
|
|
290
|
+
return unusable(`\`dogwood lower --format json\` exited ${String(run.status)} without JSON on stdout${streamNote(run)}`);
|
|
291
|
+
}
|
|
292
|
+
if (typeof parsed.cedar_policies === "string" && typeof parsed.cedar_schema === "string") {
|
|
293
|
+
return {
|
|
294
|
+
kind: "lowered",
|
|
295
|
+
value: {
|
|
296
|
+
cedarPolicies: parsed.cedar_policies,
|
|
297
|
+
cedarSchema: parsed.cedar_schema,
|
|
298
|
+
cedarSchemaJson: typeof parsed.cedar_schema_json === "string" ? parsed.cedar_schema_json : "",
|
|
299
|
+
selfContained: parsed.self_contained === true,
|
|
300
|
+
temporalFields: stringList(parsed.temporal_fields),
|
|
301
|
+
providerFields: stringList(parsed.provider_fields),
|
|
302
|
+
decisionKinds: stringList(parsed.decision_kinds),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const fatal = fatalFrom(parsed);
|
|
307
|
+
if (fatal)
|
|
308
|
+
return fatal;
|
|
309
|
+
return unusable("`dogwood lower --format json` returned JSON in neither the artifact nor the error shape");
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Read a `replay --format json` run.
|
|
313
|
+
*
|
|
314
|
+
* The one place the exit-code discipline matters most: **replay exits 0 even
|
|
315
|
+
* when every verdict is DENY.** A nonzero exit means the trace or the policy
|
|
316
|
+
* set failed to load, never that a policy denied — so a caller that read the
|
|
317
|
+
* exit code as a verdict would report a working deny-by-default policy set as
|
|
318
|
+
* a broken one. As everywhere else in this module, the JSON decides: a body
|
|
319
|
+
* carrying `verdicts` is the report, a body carrying `message` is the fatal
|
|
320
|
+
* `OpError` (a malformed trace line lands here), anything else is unusable.
|
|
321
|
+
*/
|
|
322
|
+
export function parseReplayOutput(run) {
|
|
323
|
+
if (run.error)
|
|
324
|
+
return unusable(`the dogwood binary could not be run: ${run.error}`);
|
|
325
|
+
const parsed = readJson(run.stdout);
|
|
326
|
+
if (!parsed) {
|
|
327
|
+
return unusable(`\`dogwood replay --format json\` exited ${String(run.status)} without JSON on stdout${streamNote(run)}`);
|
|
328
|
+
}
|
|
329
|
+
if (Array.isArray(parsed.verdicts)) {
|
|
330
|
+
return { kind: "replayed", verdicts: parseVerdicts(parsed.verdicts) };
|
|
331
|
+
}
|
|
332
|
+
const fatal = fatalFrom(parsed);
|
|
333
|
+
if (fatal)
|
|
334
|
+
return fatal;
|
|
335
|
+
return unusable("`dogwood replay --format json` returned JSON in neither the verdict nor the error shape");
|
|
336
|
+
}
|
|
337
|
+
function parseVerdicts(value) {
|
|
338
|
+
const out = [];
|
|
339
|
+
value.forEach((raw, position) => {
|
|
340
|
+
if (!isRecord(raw))
|
|
341
|
+
return;
|
|
342
|
+
// `allow`/`deny` lowercase, as upstream serializes the enum. Anything else
|
|
343
|
+
// is read as a deny rather than dropped: an unrecognised verdict is not a
|
|
344
|
+
// permission, and dropping the entry would shift every later index.
|
|
345
|
+
const verdict = raw.verdict === "allow" ? "allow" : "deny";
|
|
346
|
+
out.push({
|
|
347
|
+
index: typeof raw.index === "number" ? raw.index : position,
|
|
348
|
+
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : 0,
|
|
349
|
+
verdict,
|
|
350
|
+
determiningRules: Array.isArray(raw.determining_rules)
|
|
351
|
+
? raw.determining_rules.filter((r) => typeof r === "number")
|
|
352
|
+
: [],
|
|
353
|
+
errors: errorList(raw.errors),
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
/** Per-evaluation errors, tolerating both a string list and a diagnostic list. */
|
|
359
|
+
function errorList(value) {
|
|
360
|
+
if (!Array.isArray(value))
|
|
361
|
+
return [];
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const entry of value) {
|
|
364
|
+
if (typeof entry === "string")
|
|
365
|
+
out.push(entry);
|
|
366
|
+
else if (isRecord(entry) && typeof entry.message === "string")
|
|
367
|
+
out.push(scrubPosition(entry.message));
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function stringList(value) {
|
|
372
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
373
|
+
}
|
|
374
|
+
function readJson(text) {
|
|
375
|
+
const trimmed = text.trim();
|
|
376
|
+
if (!trimmed)
|
|
377
|
+
return undefined;
|
|
378
|
+
try {
|
|
379
|
+
const parsed = JSON.parse(trimmed);
|
|
380
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const defaultRunner = (binary, args) => {
|
|
387
|
+
const result = spawnSync(binary, args, {
|
|
388
|
+
encoding: "utf-8",
|
|
389
|
+
timeout: 60000,
|
|
390
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
391
|
+
});
|
|
392
|
+
return {
|
|
393
|
+
status: result.status,
|
|
394
|
+
stdout: result.stdout ?? "",
|
|
395
|
+
stderr: result.stderr ?? "",
|
|
396
|
+
...(result.error ? { error: result.error.message } : {}),
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* Materialize a bundle into a scratch directory and run one subcommand.
|
|
401
|
+
*
|
|
402
|
+
* Files rather than stdin because only the policy set can come from `-`; the
|
|
403
|
+
* schema flags all take paths. The directory is removed in a `finally`, so a
|
|
404
|
+
* throwing runner does not leave one behind.
|
|
405
|
+
*/
|
|
406
|
+
function runBundle(binary, subcommand, bundle, extras = []) {
|
|
407
|
+
const runner = defaults.runner ?? defaultRunner;
|
|
408
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-dogwood-"));
|
|
409
|
+
try {
|
|
410
|
+
const policies = join(dir, "policies.dw");
|
|
411
|
+
const policySchema = join(dir, "schema.cedarschema");
|
|
412
|
+
writeFileSync(policies, bundle.policies, "utf-8");
|
|
413
|
+
writeFileSync(policySchema, bundle.policySchema, "utf-8");
|
|
414
|
+
const args = [subcommand, policies, "--policy-schema", policySchema];
|
|
415
|
+
if (bundle.eventSchema !== undefined) {
|
|
416
|
+
const path = join(dir, "events.dwschema");
|
|
417
|
+
writeFileSync(path, bundle.eventSchema, "utf-8");
|
|
418
|
+
args.push("--event-schema", path);
|
|
419
|
+
}
|
|
420
|
+
if (bundle.macros !== undefined) {
|
|
421
|
+
const path = join(dir, "macros.dw");
|
|
422
|
+
writeFileSync(path, bundle.macros, "utf-8");
|
|
423
|
+
args.push("--macros", path);
|
|
424
|
+
}
|
|
425
|
+
if (bundle.providers !== undefined) {
|
|
426
|
+
const path = join(dir, "providers.json");
|
|
427
|
+
writeFileSync(path, bundle.providers, "utf-8");
|
|
428
|
+
args.push("--providers", path);
|
|
429
|
+
}
|
|
430
|
+
for (const extra of extras) {
|
|
431
|
+
const path = join(dir, extra.filename);
|
|
432
|
+
writeFileSync(path, extra.content, "utf-8");
|
|
433
|
+
args.push(extra.flag, path);
|
|
434
|
+
}
|
|
435
|
+
// No `--emit`: it is ignored under `--format json`, which returns all three
|
|
436
|
+
// lowered artifacts regardless. Passing it would imply a choice that the
|
|
437
|
+
// CLI does not honour.
|
|
438
|
+
args.push("--format", "json");
|
|
439
|
+
return runner(binary, args);
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
rmSync(dir, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Run `dogwood validate` over a bundle. Never throws. */
|
|
446
|
+
export function runDogwoodValidate(binary, bundle) {
|
|
447
|
+
try {
|
|
448
|
+
return parseValidateOutput(runBundle(binary, "validate", bundle));
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
return unusable(`the dogwood validate run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Run `dogwood lower` over a bundle. Never throws. */
|
|
455
|
+
export function runDogwoodLower(binary, bundle) {
|
|
456
|
+
try {
|
|
457
|
+
return parseLowerOutput(runBundle(binary, "lower", bundle));
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
return unusable(`the dogwood lower run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Run `dogwood replay` over a bundle and a trace. Never throws.
|
|
465
|
+
*
|
|
466
|
+
* The trace is materialized beside the bundle's own files and passed as
|
|
467
|
+
* `--trace`; the same scratch directory is removed in the same `finally`, so a
|
|
468
|
+
* trace carrying a session's request payloads does not outlive the run.
|
|
469
|
+
*/
|
|
470
|
+
export function runDogwoodReplay(binary, bundle, trace) {
|
|
471
|
+
try {
|
|
472
|
+
return parseReplayOutput(runBundle(binary, "replay", bundle, [{ flag: "--trace", filename: "trace.log", content: trace }]));
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
return unusable(`the dogwood replay run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed authoring for the `.dwschema` event-schema DSL.
|
|
3
|
+
*
|
|
4
|
+
* The `.dwschema`/`.cedarschema` question the epic flagged was a false alarm
|
|
5
|
+
* (#1657 §2): they are the two halves of one schema. The required half is the
|
|
6
|
+
* Cedar action schema (`--policy-schema`); this file is the optional half, the
|
|
7
|
+
* service side, which says what event *kinds* exist and what fields each one
|
|
8
|
+
* carries. Upstream's grammar is 136 lines of pest and is purely syntactic —
|
|
9
|
+
* it never consults an action schema, which is why every declaration is
|
|
10
|
+
* written against a symbolic action binder `<A>`:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* decision event <A>::request {
|
|
14
|
+
* ...inputs(A),
|
|
15
|
+
* pin callerPrincipal: principalType(A) = principal,
|
|
16
|
+
* callerResource: resourceType(A),
|
|
17
|
+
* requestId: String,
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* That symbolic binder is why DWDC010 can check an event *kind* against the
|
|
22
|
+
* emitted schema but not an action: the schema names no actions at all.
|
|
23
|
+
*
|
|
24
|
+
* ## The pin, and why opting out is explicit here
|
|
25
|
+
*
|
|
26
|
+
* `ServiceSchema::defaults()` — what runs when no `--event-schema` is passed —
|
|
27
|
+
* pins `callerPrincipal = principal` on every kind, so every temporal
|
|
28
|
+
* predicate is correlated to the current request's principal and events from
|
|
29
|
+
* other principals are invisible. Supplying *any* event schema opts out of the
|
|
30
|
+
* default wholesale, so a schema emitted without that pin silently widens
|
|
31
|
+
* every temporal predicate in the policy set to cross-principal.
|
|
32
|
+
*
|
|
33
|
+
* {@link defaultEventSchema} therefore carries the pin, and dropping it takes
|
|
34
|
+
* a named argument (`pinCallerPrincipal: false`) that also stamps a comment
|
|
35
|
+
* into the emitted file. DWDS010 reports the resulting schema either way, so
|
|
36
|
+
* the choice is visible in the build and not only in the source.
|
|
37
|
+
*/
|
|
38
|
+
import { renderWindow, window } from "./window.js";
|
|
39
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
40
|
+
const TYPE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
41
|
+
function assertIdent(value, what) {
|
|
42
|
+
if (!IDENT.test(value))
|
|
43
|
+
throw new Error(`dogwood: ${what} must be an identifier — got "${value}"`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
/** `principalType(A)` — the action's principal entity type. */
|
|
47
|
+
export function principalType() {
|
|
48
|
+
return { type: "selector", selector: "principalType" };
|
|
49
|
+
}
|
|
50
|
+
/** `resourceType(A)` — the action's resource entity type. */
|
|
51
|
+
export function resourceType() {
|
|
52
|
+
return { type: "selector", selector: "resourceType" };
|
|
53
|
+
}
|
|
54
|
+
/** `String`, `Long`, `MyApp::OAuthUser` — a concrete (possibly qualified) type. */
|
|
55
|
+
export function concrete(name) {
|
|
56
|
+
if (!TYPE_NAME.test(name))
|
|
57
|
+
throw new Error(`dogwood: a field type must be a Cedar type name — got "${name}"`);
|
|
58
|
+
return { type: "concrete", name };
|
|
59
|
+
}
|
|
60
|
+
/** `{ … }` — the named field becomes a group addressed as `name.member`. */
|
|
61
|
+
export function record(fields) {
|
|
62
|
+
return { type: "record", fields };
|
|
63
|
+
}
|
|
64
|
+
/** `= principal`, or `= principal.dept`. */
|
|
65
|
+
export function pinPrincipal(...attrs) {
|
|
66
|
+
for (const a of attrs)
|
|
67
|
+
assertIdent(a, "a pin attribute");
|
|
68
|
+
return { ref: "principal", attrs };
|
|
69
|
+
}
|
|
70
|
+
/** `= resource`, or `= resource.owner`. */
|
|
71
|
+
export function pinResource(...attrs) {
|
|
72
|
+
for (const a of attrs)
|
|
73
|
+
assertIdent(a, "a pin attribute");
|
|
74
|
+
return { ref: "resource", attrs };
|
|
75
|
+
}
|
|
76
|
+
/** `= context.input.user`. At least one path segment; `context` alone is not a pin. */
|
|
77
|
+
export function pinContext(path) {
|
|
78
|
+
const segments = path.split(".");
|
|
79
|
+
if (segments.length === 0 || segments[0] === "") {
|
|
80
|
+
throw new Error("dogwood: a context pin needs at least one path segment (context.<field>)");
|
|
81
|
+
}
|
|
82
|
+
for (const s of segments)
|
|
83
|
+
assertIdent(s, "a context pin segment");
|
|
84
|
+
return { ref: "context", path: segments };
|
|
85
|
+
}
|
|
86
|
+
/** `...inputs(A)`. */
|
|
87
|
+
export function spreadInputs() {
|
|
88
|
+
return { field: "spread", selector: "inputs" };
|
|
89
|
+
}
|
|
90
|
+
/** `...outputs(A)`. */
|
|
91
|
+
export function spreadOutputs() {
|
|
92
|
+
return { field: "spread", selector: "outputs" };
|
|
93
|
+
}
|
|
94
|
+
/** `name: <type>` — an injected field with an explicit type. */
|
|
95
|
+
export function field(name, type) {
|
|
96
|
+
return { field: "named", name: assertIdent(name, "a field name"), type };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `pin name: <type> = <target>` — a correlation against the decision request.
|
|
100
|
+
*
|
|
101
|
+
* Upstream requires the `pin` prefix and the `= …` clause together, and a
|
|
102
|
+
* pinned field must be a leaf (never a record type), so both are enforced here
|
|
103
|
+
* rather than deferred to the parser.
|
|
104
|
+
*/
|
|
105
|
+
export function pinnedField(name, type, target) {
|
|
106
|
+
if (type.type === "record") {
|
|
107
|
+
throw new Error(`dogwood: a pinned field must be a leaf, and "${name}" is a record type`);
|
|
108
|
+
}
|
|
109
|
+
return { field: "named", name: assertIdent(name, "a field name"), type, pin: target };
|
|
110
|
+
}
|
|
111
|
+
/** One `event` declaration. */
|
|
112
|
+
export function eventDeclaration(kind, fields, options = {}) {
|
|
113
|
+
return { kind: assertIdent(kind, "an event kind"), fields, decision: options.decision, comment: options.comment };
|
|
114
|
+
}
|
|
115
|
+
/** Assemble a schema, defaulting the action binder to `A`. */
|
|
116
|
+
export function eventSchema(events, options = {}) {
|
|
117
|
+
if (events.length === 0)
|
|
118
|
+
throw new Error("dogwood: an event schema declares at least one event kind");
|
|
119
|
+
const kinds = new Set();
|
|
120
|
+
for (const e of events) {
|
|
121
|
+
if (kinds.has(e.kind))
|
|
122
|
+
throw new Error(`dogwood: event kind "${e.kind}" is declared twice`);
|
|
123
|
+
kinds.add(e.kind);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
maxWindow: options.maxWindow === undefined ? undefined : window(options.maxWindow),
|
|
127
|
+
binder: assertIdent(options.binder ?? "A", "an action binder"),
|
|
128
|
+
events,
|
|
129
|
+
comment: options.comment,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The default event-schema shape: `request` (deciding), `response` and `error`,
|
|
134
|
+
* each pinned to the request's principal.
|
|
135
|
+
*
|
|
136
|
+
* This reproduces `configuration/event-schemas/pinned.dwschema`, which is what
|
|
137
|
+
* `ServiceSchema::defaults()` uses. Emitting it explicitly is the honest thing
|
|
138
|
+
* to do the moment a project supplies *any* event schema, because supplying
|
|
139
|
+
* one opts out of the built-in default entirely.
|
|
140
|
+
*
|
|
141
|
+
* @param options.pinCallerPrincipal Drop the pin to allow cross-principal
|
|
142
|
+
* correlation. A deliberate choice: it widens every temporal predicate in the
|
|
143
|
+
* policy set, so it stamps a comment into the emitted file and DWDS010 reports
|
|
144
|
+
* it in the build.
|
|
145
|
+
*/
|
|
146
|
+
export function defaultEventSchema(options = {}) {
|
|
147
|
+
const pinned = options.pinCallerPrincipal !== false;
|
|
148
|
+
const principalField = options.principalField ?? "callerPrincipal";
|
|
149
|
+
const common = () => [
|
|
150
|
+
pinned
|
|
151
|
+
? pinnedField(principalField, principalType(), pinPrincipal())
|
|
152
|
+
: field(principalField, principalType()),
|
|
153
|
+
field("callerResource", resourceType()),
|
|
154
|
+
field("requestId", concrete("String")),
|
|
155
|
+
field("sessionId", concrete("String")),
|
|
156
|
+
];
|
|
157
|
+
return eventSchema([
|
|
158
|
+
eventDeclaration("request", [spreadInputs(), ...common()], { decision: true }),
|
|
159
|
+
eventDeclaration("response", [spreadInputs(), spreadOutputs(), ...common()]),
|
|
160
|
+
eventDeclaration("error", [spreadInputs(), ...common()]),
|
|
161
|
+
], {
|
|
162
|
+
maxWindow: options.maxWindow,
|
|
163
|
+
binder: options.binder,
|
|
164
|
+
comment: pinned
|
|
165
|
+
? "The default event-schema shape: request/response/error, each correlated to\nthe deciding request's principal."
|
|
166
|
+
: `Cross-principal: ${principalField} is NOT pinned, so temporal predicates see\nevents from every principal, not only the deciding request's. This widens\nevery predicate in the policy set and is a deliberate departure from\nupstream's default (see ServiceSchema::defaults).`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// ── Rendering ─────────────────────────────────────────────────────
|
|
170
|
+
function renderPinTarget(target) {
|
|
171
|
+
return target.ref === "context" ? `context.${target.path.join(".")}` : [target.ref, ...target.attrs].join(".");
|
|
172
|
+
}
|
|
173
|
+
function renderFieldType(type, binder, indent) {
|
|
174
|
+
switch (type.type) {
|
|
175
|
+
case "selector":
|
|
176
|
+
return `${type.selector}(${binder})`;
|
|
177
|
+
case "concrete":
|
|
178
|
+
return type.name;
|
|
179
|
+
case "record":
|
|
180
|
+
return `{\n${renderFields(type.fields, binder, indent + " ")}\n${indent}}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function renderFields(fields, binder, indent) {
|
|
184
|
+
return fields
|
|
185
|
+
.map((f) => {
|
|
186
|
+
if (f.field === "spread")
|
|
187
|
+
return `${indent}...${f.selector}(${binder}),`;
|
|
188
|
+
const pin = f.pin ? ` = ${renderPinTarget(f.pin)}` : "";
|
|
189
|
+
return `${indent}${f.pin ? "pin " : ""}${f.name}: ${renderFieldType(f.type, binder, indent)}${pin},`;
|
|
190
|
+
})
|
|
191
|
+
.join("\n");
|
|
192
|
+
}
|
|
193
|
+
function renderComment(comment) {
|
|
194
|
+
return comment.split("\n").map((line) => `// ${line}`.trimEnd());
|
|
195
|
+
}
|
|
196
|
+
/** Render a schema to `.dwschema` text. */
|
|
197
|
+
export function renderEventSchema(schema) {
|
|
198
|
+
const lines = [];
|
|
199
|
+
if (schema.comment)
|
|
200
|
+
lines.push(...renderComment(schema.comment), "");
|
|
201
|
+
if (schema.maxWindow) {
|
|
202
|
+
lines.push(`max_window = ${renderWindow(schema.maxWindow)}`, "");
|
|
203
|
+
}
|
|
204
|
+
for (const [index, event] of schema.events.entries()) {
|
|
205
|
+
if (index > 0)
|
|
206
|
+
lines.push("");
|
|
207
|
+
if (event.comment)
|
|
208
|
+
lines.push(...renderComment(event.comment));
|
|
209
|
+
lines.push(`${event.decision ? "decision " : ""}event <${schema.binder}>::${event.kind} {`);
|
|
210
|
+
if (event.fields.length > 0)
|
|
211
|
+
lines.push(renderFields(event.fields, schema.binder, " "));
|
|
212
|
+
lines.push("}");
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n") + "\n";
|
|
215
|
+
}
|
|
216
|
+
/** Every event kind a schema declares, in declaration order. */
|
|
217
|
+
export function declaredEventKinds(schema) {
|
|
218
|
+
return schema.events.map((e) => e.kind);
|
|
219
|
+
}
|