@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,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed construction of dogwood replay traces (#1661, epic #1646).
|
|
3
|
+
*
|
|
4
|
+
* A trace is one event per line, and the line grammar recorded by the #1657
|
|
5
|
+
* verification (§6, read from `dogwood-language/src/interpreter/log_parse.rs`
|
|
6
|
+
* at the pinned SHA) is:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* @<timestamp> [scope(principal: <uid>, resource: <uid>)]
|
|
10
|
+
* [entities(<uid>: { <attrs> } [in [<uid>, …]], …)]
|
|
11
|
+
* [request_context(<group>: { … }, …)]
|
|
12
|
+
* <Ns>::Action::"<Name>"::<kind>(<field>: <value>, …)
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The three envelopes are optional and must appear in that order; the trailing
|
|
16
|
+
* group is the logged record. There is no comment syntax — a `//` is an
|
|
17
|
+
* ordinary part of a value, so URLs survive — blank lines are skipped, and a
|
|
18
|
+
* leading BOM is stripped.
|
|
19
|
+
*
|
|
20
|
+
* Two things in that format silently weaken a replay rather than failing it,
|
|
21
|
+
* and both are what this module exists to make hard:
|
|
22
|
+
*
|
|
23
|
+
* 1. **`request_context(…)` and the logged record are different bags.** The
|
|
24
|
+
* Cedar request is built from the first; temporal predicates match against
|
|
25
|
+
* the second. A field needed by both and supplied to only one weakens
|
|
26
|
+
* either the Cedar check or the temporal check, and the replay still exits
|
|
27
|
+
* 0 with a verdict that looks authoritative. So {@link traceEvent} writes a
|
|
28
|
+
* `context` group into *both* bags by default, and dropping one is an
|
|
29
|
+
* explicit per-event `bags` opt-out that has to be typed out.
|
|
30
|
+
* 2. **Actions must be fully qualified.** `Drupe::Action::"Transfer"`, never
|
|
31
|
+
* `Transfer` — a short name leaves the temporal predicate unmatched while
|
|
32
|
+
* Cedar still authorizes. {@link traceEvent} rejects anything that is not
|
|
33
|
+
* `Ns::…::"Name"` at construction.
|
|
34
|
+
*
|
|
35
|
+
* {@link auditTrace} is the same two checks applied to a trace this module did
|
|
36
|
+
* not build — the AgentCore session history #1661 leaves to the aws lexicon,
|
|
37
|
+
* or a `.log` recorded by hand. A trace is a trace, wherever it came from.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { escapeCedarString } from "../policy-text";
|
|
41
|
+
|
|
42
|
+
// ── Names ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** `Drupe::Action::"Read"`, `Drupe::OAuthUser::"alice"` — a qualified uid. */
|
|
45
|
+
const QUALIFIED_UID = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)+::"[^"]*"$/;
|
|
46
|
+
/** A bare field, group or event-kind name. */
|
|
47
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
|
+
|
|
49
|
+
function assertUid(value: string, what: string): string {
|
|
50
|
+
if (!QUALIFIED_UID.test(value)) {
|
|
51
|
+
throw new Error(`dogwood: ${what} must be fully qualified, like Ns::Type::"id" — got "${value}"`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertIdent(value: string, what: string): string {
|
|
57
|
+
if (!IDENT.test(value)) throw new Error(`dogwood: ${what} must be an identifier — got "${value}"`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Values ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** `Ns::Type::"id"` in value position. */
|
|
64
|
+
export interface TraceEntityRef {
|
|
65
|
+
readonly traceValue: "entity";
|
|
66
|
+
readonly uid: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `1.50` — Cedar's decimal surface form, which a JS number cannot carry. */
|
|
70
|
+
export interface TraceDecimal {
|
|
71
|
+
readonly traceValue: "decimal";
|
|
72
|
+
readonly text: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Surface text passed through untouched — the escape hatch, used sparingly. */
|
|
76
|
+
export interface TraceRaw {
|
|
77
|
+
readonly traceValue: "raw";
|
|
78
|
+
readonly text: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A tagged value: one this module renders specially rather than by JS type. */
|
|
82
|
+
export type TraceTagged = TraceEntityRef | TraceDecimal | TraceRaw;
|
|
83
|
+
|
|
84
|
+
/** Anything that can sit in a trace field, in Cedar surface forms. */
|
|
85
|
+
export type TraceValue =
|
|
86
|
+
| string
|
|
87
|
+
| number
|
|
88
|
+
| boolean
|
|
89
|
+
| TraceTagged
|
|
90
|
+
| readonly TraceValue[]
|
|
91
|
+
| { readonly [key: string]: TraceValue };
|
|
92
|
+
|
|
93
|
+
/** A named group of fields — what one `request_context` envelope entry holds. */
|
|
94
|
+
export type TraceFields = { readonly [key: string]: TraceValue };
|
|
95
|
+
|
|
96
|
+
/** `Ns::Type::"id"`. Validated at construction, so a short name cannot slip through. */
|
|
97
|
+
export function entityRef(uid: string): TraceEntityRef {
|
|
98
|
+
return { traceValue: "entity", uid: assertUid(uid, "an entity reference") };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A Cedar decimal, written as it should appear (`"1.50"`). */
|
|
102
|
+
export function decimalValue(text: string): TraceDecimal {
|
|
103
|
+
if (!/^-?\d+\.\d+$/.test(text)) {
|
|
104
|
+
throw new Error(`dogwood: a trace decimal looks like "1.50" — got "${text}"`);
|
|
105
|
+
}
|
|
106
|
+
return { traceValue: "decimal", text };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Surface text, rendered verbatim. For values this module has no shape for. */
|
|
110
|
+
export function rawValue(text: string): TraceRaw {
|
|
111
|
+
return { traceValue: "raw", text };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isTagged(value: TraceValue): value is TraceTagged {
|
|
115
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "traceValue" in value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Render one value in the Cedar surface form the trace parser reads. */
|
|
119
|
+
export function renderTraceValue(value: TraceValue): string {
|
|
120
|
+
if (typeof value === "string") return `"${escapeCedarString(value)}"`;
|
|
121
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
122
|
+
if (typeof value === "number") {
|
|
123
|
+
if (!Number.isInteger(value)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`dogwood: ${String(value)} is not an integer — a trace decimal must be written with decimalValue("1.50") so its scale survives`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return String(value);
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(value)) return `[${value.map(renderTraceValue).join(", ")}]`;
|
|
131
|
+
if (isTagged(value)) {
|
|
132
|
+
if (value.traceValue === "entity") return value.uid;
|
|
133
|
+
if (value.traceValue === "decimal") return value.text;
|
|
134
|
+
return value.text;
|
|
135
|
+
}
|
|
136
|
+
return renderTraceFields(value as TraceFields);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `{ a: 1, b: "x" }` — a record body, braces included. */
|
|
140
|
+
export function renderTraceFields(fields: TraceFields): string {
|
|
141
|
+
const parts = Object.entries(fields).map(
|
|
142
|
+
([key, value]) => `${assertIdent(key, "a trace field name")}: ${renderTraceValue(value)}`,
|
|
143
|
+
);
|
|
144
|
+
return `{ ${parts.join(", ")} }`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Envelopes ─────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** The `scope(principal: …, resource: …)` envelope. Both are entity uids. */
|
|
150
|
+
export interface TraceScope {
|
|
151
|
+
readonly principal: string;
|
|
152
|
+
readonly resource: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One `entities(…)` entry: a uid, its attributes, and its parents. */
|
|
156
|
+
export interface TraceEntityDecl {
|
|
157
|
+
readonly uid: string;
|
|
158
|
+
readonly attrs?: TraceFields;
|
|
159
|
+
/** `in [<uid>, …]` — the entity's parents in the hierarchy. */
|
|
160
|
+
readonly parents?: readonly string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `Ns::Type::"id": { … } in [ … ]`. */
|
|
164
|
+
export function traceEntity(uid: string, attrs?: TraceFields, parents?: readonly string[]): TraceEntityDecl {
|
|
165
|
+
return {
|
|
166
|
+
uid: assertUid(uid, "an entity declaration"),
|
|
167
|
+
...(attrs ? { attrs } : {}),
|
|
168
|
+
...(parents ? { parents: parents.map((p) => assertUid(p, "an entity parent")) } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Events ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One trace line, fully resolved: every envelope decided, both bags settled.
|
|
176
|
+
*
|
|
177
|
+
* Built by {@link traceEvent} rather than by hand — the constructor is where
|
|
178
|
+
* the both-bags default and the qualified-action check live. The type stays
|
|
179
|
+
* public because a trace fetched from somewhere else (an AgentCore session
|
|
180
|
+
* history, a recorded `.log`) is normalized into it and then handed to
|
|
181
|
+
* {@link auditTrace}.
|
|
182
|
+
*/
|
|
183
|
+
export interface TraceEvent {
|
|
184
|
+
/** The `i64` after `@`. */
|
|
185
|
+
readonly timestamp: number;
|
|
186
|
+
/** Fully qualified: `Ns::Action::"Name"`. */
|
|
187
|
+
readonly action: string;
|
|
188
|
+
/** `request`, `response`, `error` conventionally — author-defined in truth. */
|
|
189
|
+
readonly kind: string;
|
|
190
|
+
/** The trailing logged record — what temporal predicates match against. */
|
|
191
|
+
readonly record: TraceFields;
|
|
192
|
+
/** The `request_context(…)` envelope — what the Cedar request is built from. */
|
|
193
|
+
readonly requestContext?: TraceFields;
|
|
194
|
+
readonly scope?: TraceScope;
|
|
195
|
+
readonly entities?: readonly TraceEntityDecl[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Which bags a `context` group lands in.
|
|
200
|
+
*
|
|
201
|
+
* `both` is the default and the only one that keeps a replay honest. The other
|
|
202
|
+
* two exist so a test can *demonstrate* the weakening rather than only
|
|
203
|
+
* describe it, and so a trace that genuinely has no Cedar-side counterpart for
|
|
204
|
+
* a field can say so out loud instead of by omission.
|
|
205
|
+
*/
|
|
206
|
+
export type TraceBags = "both" | "record-only" | "context-only";
|
|
207
|
+
|
|
208
|
+
/** What {@link traceEvent} takes. */
|
|
209
|
+
export interface TraceEventInput {
|
|
210
|
+
readonly timestamp: number;
|
|
211
|
+
/** Fully qualified: `Ns::Action::"Name"`. A short name throws. */
|
|
212
|
+
readonly action: string;
|
|
213
|
+
/** Default `request` — the only kind the default event schema decides on. */
|
|
214
|
+
readonly kind?: string;
|
|
215
|
+
/**
|
|
216
|
+
* Named field groups (`input`, `output`, …). Each lands in **both** the
|
|
217
|
+
* `request_context` envelope and the logged record unless {@link bags} says
|
|
218
|
+
* otherwise.
|
|
219
|
+
*/
|
|
220
|
+
readonly context?: { readonly [group: string]: TraceFields };
|
|
221
|
+
/**
|
|
222
|
+
* Fields that belong to the logged record only — `callerPrincipal`,
|
|
223
|
+
* `callerResource`, `requestId`, `sessionId` and the rest of the event
|
|
224
|
+
* schema's own injections. These are never part of the Cedar request.
|
|
225
|
+
*/
|
|
226
|
+
readonly record?: TraceFields;
|
|
227
|
+
readonly scope?: TraceScope;
|
|
228
|
+
readonly entities?: readonly TraceEntityDecl[];
|
|
229
|
+
/** Explicit opt-out of both-bag population. See {@link TraceBags}. */
|
|
230
|
+
readonly bags?: TraceBags;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Build one trace event with both bags populated.
|
|
235
|
+
*
|
|
236
|
+
* Every `context` group is written to the `request_context` envelope *and*
|
|
237
|
+
* merged into the logged record, because that is the only combination in which
|
|
238
|
+
* the Cedar check and the temporal check both see the field. Dropping one is a
|
|
239
|
+
* typed decision (`bags`), not an omission.
|
|
240
|
+
*/
|
|
241
|
+
export function traceEvent(input: TraceEventInput): TraceEvent {
|
|
242
|
+
const action = assertUid(input.action, "a trace action");
|
|
243
|
+
const kind = assertIdent(input.kind ?? "request", "an event kind");
|
|
244
|
+
const bags = input.bags ?? "both";
|
|
245
|
+
|
|
246
|
+
const groups = input.context ?? {};
|
|
247
|
+
for (const group of Object.keys(groups)) assertIdent(group, "a request-context group");
|
|
248
|
+
|
|
249
|
+
const record: Record<string, TraceValue> = {};
|
|
250
|
+
if (bags !== "context-only") Object.assign(record, groups);
|
|
251
|
+
Object.assign(record, input.record ?? {});
|
|
252
|
+
|
|
253
|
+
const context: Record<string, TraceValue> = {};
|
|
254
|
+
if (bags !== "record-only") Object.assign(context, groups);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
timestamp: input.timestamp,
|
|
258
|
+
action,
|
|
259
|
+
kind,
|
|
260
|
+
record,
|
|
261
|
+
...(Object.keys(context).length > 0 ? { requestContext: context } : {}),
|
|
262
|
+
...(input.scope
|
|
263
|
+
? {
|
|
264
|
+
scope: {
|
|
265
|
+
principal: assertUid(input.scope.principal, "a scope principal"),
|
|
266
|
+
resource: assertUid(input.scope.resource, "a scope resource"),
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
: {}),
|
|
270
|
+
...(input.entities && input.entities.length > 0 ? { entities: input.entities } : {}),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Rendering ─────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
function renderScope(scope: TraceScope): string {
|
|
277
|
+
return `scope(principal: ${scope.principal}, resource: ${scope.resource})`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderEntities(entities: readonly TraceEntityDecl[]): string {
|
|
281
|
+
const parts = entities.map((e) => {
|
|
282
|
+
const attrs = renderTraceFields(e.attrs ?? {});
|
|
283
|
+
const parents = e.parents && e.parents.length > 0 ? ` in [${e.parents.join(", ")}]` : "";
|
|
284
|
+
return `${e.uid}: ${attrs}${parents}`;
|
|
285
|
+
});
|
|
286
|
+
return `entities(${parts.join(", ")})`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderGroups(fields: TraceFields): string {
|
|
290
|
+
const parts = Object.entries(fields).map(
|
|
291
|
+
([group, value]) => `${assertIdent(group, "a request-context group")}: ${renderTraceValue(value)}`,
|
|
292
|
+
);
|
|
293
|
+
return `request_context(${parts.join(", ")})`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** One event as the single line the trace parser reads. Never ends in a newline. */
|
|
297
|
+
export function renderTraceLine(event: TraceEvent): string {
|
|
298
|
+
if (!Number.isInteger(event.timestamp)) {
|
|
299
|
+
throw new Error(`dogwood: a trace timestamp is an i64 — got ${String(event.timestamp)}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const parts = [`@${event.timestamp}`];
|
|
303
|
+
if (event.scope) parts.push(renderScope(event.scope));
|
|
304
|
+
if (event.entities && event.entities.length > 0) parts.push(renderEntities(event.entities));
|
|
305
|
+
if (event.requestContext && Object.keys(event.requestContext).length > 0) {
|
|
306
|
+
parts.push(renderGroups(event.requestContext));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const record = Object.entries(event.record).map(
|
|
310
|
+
([key, value]) => `${assertIdent(key, "a trace field name")}: ${renderTraceValue(value)}`,
|
|
311
|
+
);
|
|
312
|
+
parts.push(`${event.action}::${event.kind}(${record.join(", ")})`);
|
|
313
|
+
|
|
314
|
+
return parts.join(" ");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A whole trace, one event per line, newline-terminated. */
|
|
318
|
+
export function renderTrace(events: readonly TraceEvent[]): string {
|
|
319
|
+
return events.map(renderTraceLine).join("\n") + "\n";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Auditing a trace this module did not build ────────────────────
|
|
323
|
+
|
|
324
|
+
/** What {@link auditTrace} can find. */
|
|
325
|
+
export type TraceIssueKind =
|
|
326
|
+
| "single-bag"
|
|
327
|
+
| "no-request-context"
|
|
328
|
+
| "empty-record"
|
|
329
|
+
| "out-of-order";
|
|
330
|
+
|
|
331
|
+
/** One finding against a trace, indexed by its position in the event list. */
|
|
332
|
+
export interface TraceIssue {
|
|
333
|
+
readonly kind: TraceIssueKind;
|
|
334
|
+
/** 0-based position in the event list — a trace has no line numbers of its own. */
|
|
335
|
+
readonly index: number;
|
|
336
|
+
readonly timestamp: number;
|
|
337
|
+
readonly message: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Options for {@link auditTrace} and {@link traceFixture}. */
|
|
341
|
+
export interface TraceAuditOptions {
|
|
342
|
+
/**
|
|
343
|
+
* Event kinds that produce a decision, and so build a Cedar request.
|
|
344
|
+
*
|
|
345
|
+
* Default `["request"]`, which is the convention the default event schema
|
|
346
|
+
* follows — the truth is whichever kinds the project's `.dwschema` marks
|
|
347
|
+
* `decision`, and that file is not visible from here. A history-only event
|
|
348
|
+
* never becomes a Cedar request, so an absent `request_context` on one is
|
|
349
|
+
* not a weakening and is not reported.
|
|
350
|
+
*/
|
|
351
|
+
readonly decisionKinds?: readonly string[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The both-bags and ordering checks, applied to any trace.
|
|
356
|
+
*
|
|
357
|
+
* Every finding here is something that makes a replay *weaker* rather than
|
|
358
|
+
* something that makes it fail — which is exactly the class a green replay run
|
|
359
|
+
* hides. A trace built by {@link traceEvent} produces none of them unless a
|
|
360
|
+
* `bags` opt-out was taken deliberately.
|
|
361
|
+
*/
|
|
362
|
+
export function auditTrace(events: readonly TraceEvent[], options: TraceAuditOptions = {}): TraceIssue[] {
|
|
363
|
+
const issues: TraceIssue[] = [];
|
|
364
|
+
const decisionKinds = new Set(options.decisionKinds ?? ["request"]);
|
|
365
|
+
let previous: number | undefined;
|
|
366
|
+
|
|
367
|
+
events.forEach((event, index) => {
|
|
368
|
+
const at = { index, timestamp: event.timestamp };
|
|
369
|
+
const context = event.requestContext ?? {};
|
|
370
|
+
const contextKeys = Object.keys(context);
|
|
371
|
+
const decides = decisionKinds.has(event.kind);
|
|
372
|
+
|
|
373
|
+
if (decides && contextKeys.length === 0) {
|
|
374
|
+
issues.push({
|
|
375
|
+
...at,
|
|
376
|
+
kind: "no-request-context",
|
|
377
|
+
message: `${event.action}::${event.kind} has no request_context envelope, so the Cedar request it is evaluated against carries no context — every context.* test in a policy silently misses`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const group of contextKeys) {
|
|
382
|
+
if (!(group in event.record)) {
|
|
383
|
+
issues.push({
|
|
384
|
+
...at,
|
|
385
|
+
kind: "single-bag",
|
|
386
|
+
message: `${event.action}::${event.kind} puts "${group}" in request_context but not in the logged record, so temporal predicates over ${group}.* silently miss`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const group of Object.keys(event.record)) {
|
|
392
|
+
// Only groups (records) are candidates for the Cedar side; the event
|
|
393
|
+
// schema's own scalar injections (callerPrincipal, requestId, …) belong
|
|
394
|
+
// to the logged record alone and are not a single-bag finding.
|
|
395
|
+
const value = event.record[group];
|
|
396
|
+
const isGroup = typeof value === "object" && value !== null && !Array.isArray(value) && !isTagged(value);
|
|
397
|
+
if (decides && isGroup && contextKeys.length > 0 && !(group in context)) {
|
|
398
|
+
issues.push({
|
|
399
|
+
...at,
|
|
400
|
+
kind: "single-bag",
|
|
401
|
+
message: `${event.action}::${event.kind} puts "${group}" in the logged record but not in request_context, so context.${group}.* is absent from the Cedar request`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (Object.keys(event.record).length === 0) {
|
|
407
|
+
issues.push({
|
|
408
|
+
...at,
|
|
409
|
+
kind: "empty-record",
|
|
410
|
+
message: `${event.action}::${event.kind} logs no fields at all, so no temporal predicate can match it`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (previous !== undefined && event.timestamp < previous) {
|
|
415
|
+
issues.push({
|
|
416
|
+
...at,
|
|
417
|
+
kind: "out-of-order",
|
|
418
|
+
message: `timestamp ${event.timestamp} follows ${previous} — the interpreter accumulates history in file order, so an out-of-order line changes what a window sees`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
previous = event.timestamp;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return issues;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Fixtures ──────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/** Options for {@link traceFixture}. */
|
|
430
|
+
export interface TraceFixtureOptions extends TraceAuditOptions {
|
|
431
|
+
/**
|
|
432
|
+
* Issue kinds to tolerate. Empty by default: a fixture that weakens its own
|
|
433
|
+
* replay throws at build time rather than producing a green run that proves
|
|
434
|
+
* nothing.
|
|
435
|
+
*/
|
|
436
|
+
readonly allow?: readonly TraceIssueKind[];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A built trace: the events, the text, and whatever the audit tolerated. */
|
|
440
|
+
export interface TraceFixture {
|
|
441
|
+
readonly events: readonly TraceEvent[];
|
|
442
|
+
readonly text: string;
|
|
443
|
+
readonly issues: readonly TraceIssue[];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Assemble events into trace text, refusing to produce a weakened one.
|
|
448
|
+
*
|
|
449
|
+
* The inversion of the trap: the default is a trace that populates both bags,
|
|
450
|
+
* and every way of getting a weaker one — a `bags` opt-out on an event, a
|
|
451
|
+
* hand-normalized event with an empty record — has to be named in `allow`
|
|
452
|
+
* before this will hand it back.
|
|
453
|
+
*/
|
|
454
|
+
export function traceFixture(
|
|
455
|
+
events: readonly TraceEvent[],
|
|
456
|
+
options: TraceFixtureOptions = {},
|
|
457
|
+
): TraceFixture {
|
|
458
|
+
const allow = new Set(options.allow ?? []);
|
|
459
|
+
const issues = auditTrace(events, options);
|
|
460
|
+
const blocking = issues.filter((i) => !allow.has(i.kind));
|
|
461
|
+
|
|
462
|
+
if (blocking.length > 0) {
|
|
463
|
+
const detail = blocking.map((i) => ` [${i.kind}] @${i.timestamp}: ${i.message}`).join("\n");
|
|
464
|
+
throw new Error(
|
|
465
|
+
`dogwood: this trace would weaken its own replay rather than fail it:\n${detail}\n` +
|
|
466
|
+
`Fix the events, or pass { allow: [${[...new Set(blocking.map((i) => `"${i.kind}"`))].join(", ")}] } to say the weakening is the point.`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { events, text: renderTrace(events), issues };
|
|
471
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,41 @@ export type { CedarGenerateOptions, CedarGenerateResult } from "./import/generat
|
|
|
54
54
|
export { CedarTemplateParser, CedarTemplateGenerator } from "./import/adapter";
|
|
55
55
|
export { detectTemplate } from "./detect";
|
|
56
56
|
|
|
57
|
+
// AgentCore embedding (#1660) — the typed statement an
|
|
58
|
+
// `AWS::BedrockAgentCore::Policy` carries on either arm of its Definition
|
|
59
|
+
// union, and the EnforcementMode dial that stages the rollout. Same
|
|
60
|
+
// no-dependency rule as the AVP seam above. See src/agentcore/embed.ts.
|
|
61
|
+
export {
|
|
62
|
+
agentCoreStatement,
|
|
63
|
+
agentCorePolicyDefinition,
|
|
64
|
+
agentCorePolicyResource,
|
|
65
|
+
agentCorePolicySet,
|
|
66
|
+
agentCoreStagedPolicy,
|
|
67
|
+
agentCorePolicyName,
|
|
68
|
+
AGENTCORE_NAME_PATTERN,
|
|
69
|
+
AGENTCORE_STATEMENT_MIN,
|
|
70
|
+
AGENTCORE_STATEMENT_MAX,
|
|
71
|
+
} from "./agentcore/embed";
|
|
72
|
+
export type {
|
|
73
|
+
AgentCoreCedarDefinition,
|
|
74
|
+
AgentCoreEmbedOptions,
|
|
75
|
+
AgentCoreLanguageDefinition,
|
|
76
|
+
AgentCorePolicyDefinition,
|
|
77
|
+
AgentCorePolicyResource,
|
|
78
|
+
AgentCorePolicySource,
|
|
79
|
+
AgentCoreStagedPolicy,
|
|
80
|
+
} from "./agentcore/embed";
|
|
81
|
+
export {
|
|
82
|
+
AGENTCORE_ENFORCEMENT,
|
|
83
|
+
describeStage,
|
|
84
|
+
enforcementMode,
|
|
85
|
+
enforcementStage,
|
|
86
|
+
isEnforcing,
|
|
87
|
+
} from "./agentcore/enforcement";
|
|
88
|
+
export type { AgentCoreEnforcementMode, AgentCoreStage } from "./agentcore/enforcement";
|
|
89
|
+
export { embeddedAgentCoreDefinitions, embeddedAgentCorePolicyStatements } from "./agentcore/scan";
|
|
90
|
+
export type { EmbeddedAgentCoreDefinition, EmbeddedAgentCoreStatement } from "./agentcore/scan";
|
|
91
|
+
|
|
57
92
|
// The dogwood temporal dialect (#1658) — typed temporal builders, the `.dw`
|
|
58
93
|
// serializer leg, `.dwschema` event schemas. Pre-release; see ./dogwood.
|
|
59
94
|
//
|
|
@@ -78,6 +113,23 @@ export {
|
|
|
78
113
|
export type { EventSchemaProps, MacroLibraryProps, TemporalPolicyProps } from "./dogwood/policy";
|
|
79
114
|
export { DOGWOOD_UPSTREAM } from "./dogwood/upstream";
|
|
80
115
|
|
|
116
|
+
// The replay Op composite and its typed step builders (#1661). Flat, like
|
|
117
|
+
// fly's `flyDeploy`: an Op factory is what a project's `ops/*.op.ts` names,
|
|
118
|
+
// and it carries no dependency on the temporal lexicon — see
|
|
119
|
+
// ./dogwood/replay-op.ts for why the composite ships from cedar.
|
|
120
|
+
export {
|
|
121
|
+
DEFAULT_REPLAY_REPORT_PATH,
|
|
122
|
+
PolicyReplayOp,
|
|
123
|
+
dogwoodReplayReportStep,
|
|
124
|
+
dogwoodReplayStep,
|
|
125
|
+
} from "./dogwood/replay-op";
|
|
126
|
+
export type {
|
|
127
|
+
DogwoodReplayReportStepOpts,
|
|
128
|
+
DogwoodReplayStepOpts,
|
|
129
|
+
PolicyReplayOpConfig,
|
|
130
|
+
PolicyReplayOpResources,
|
|
131
|
+
} from "./dogwood/replay-op";
|
|
132
|
+
|
|
81
133
|
// Lint rules
|
|
82
134
|
export { rules as cedarLintRules } from "./lint/rules";
|
|
83
135
|
|
|
@@ -159,6 +159,14 @@ export const cedarAuditCatalog: Record<string, RuleMeta> = {
|
|
|
159
159
|
"Give the operator a `within <n><s|m|h|d>` window — all three past-only operators require one. The typed builders take it as an argument.",
|
|
160
160
|
{ category: "correctness" },
|
|
161
161
|
),
|
|
162
|
+
DWDC013: auditRule(
|
|
163
|
+
"DWDC013",
|
|
164
|
+
"report-only",
|
|
165
|
+
"guidance",
|
|
166
|
+
"AgentCore policy embeds temporal text with no event schema emitted",
|
|
167
|
+
"Declare a TemporalEventSchema so the .dwschema ships with the policy, or record where the service schema is registered — AWS::BedrockAgentCore::Policy has no property to carry it, and until the engine has it every temporal predicate in the statement matches nothing.",
|
|
168
|
+
{ category: "correctness" },
|
|
169
|
+
),
|
|
162
170
|
DWDE010: auditRule(
|
|
163
171
|
"DWDE010",
|
|
164
172
|
"merge-worthy",
|
|
@@ -13,6 +13,7 @@ import { postSynthChecks } from "./index";
|
|
|
13
13
|
import { dwdc010 } from "./dwdc010";
|
|
14
14
|
import { dwdc011 } from "./dwdc011";
|
|
15
15
|
import { dwdc012 } from "./dwdc012";
|
|
16
|
+
import { dwdc013 } from "./dwdc013";
|
|
16
17
|
import { dwds010 } from "./dwds010";
|
|
17
18
|
import { cedarAuditCatalog } from "../audit-catalog";
|
|
18
19
|
import { renderEventSchema, defaultEventSchema } from "../../dogwood/event-schema";
|
|
@@ -207,12 +208,129 @@ describe("DWDS010 — an unpinned schema widens every predicate", () => {
|
|
|
207
208
|
});
|
|
208
209
|
});
|
|
209
210
|
|
|
211
|
+
// ── DWDC013 ────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A build with two lexicons in it: the cedar one, and whatever emitted the
|
|
215
|
+
* CloudFormation the AgentCore policy landed in. The check reads the emitted
|
|
216
|
+
* JSON rather than an aws entity class, which is what lets the cedar lexicon
|
|
217
|
+
* have an opinion about an embedding without depending on the aws lexicon.
|
|
218
|
+
*/
|
|
219
|
+
function ctxOfLexicons(byLexicon: Record<string, Record<string, string>>): PostSynthContext {
|
|
220
|
+
const outputs = new Map<string, string | SerializerResult>();
|
|
221
|
+
for (const [lexicon, files] of Object.entries(byLexicon)) {
|
|
222
|
+
outputs.set(lexicon, { primary: "", files } satisfies SerializerResult);
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
outputs,
|
|
226
|
+
entities: new Map(),
|
|
227
|
+
buildResult: { outputs, entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const TEMPORAL_STATEMENT =
|
|
232
|
+
'@id("write-needs-approval")\npermit (\n principal,\n action == Ns::Action::"Write",\n resource\n)\nwhen temporal {\n formerly within 1h Ns::Action::"Approve"::response{}\n}\n;';
|
|
233
|
+
|
|
234
|
+
const PLAIN_STATEMENT = '@id("deny")\nforbid (\n principal,\n action,\n resource\n)\nunless { context.ok == true };';
|
|
235
|
+
|
|
236
|
+
function template(definition: Record<string, unknown>, logicalId = "GatewayPolicy"): string {
|
|
237
|
+
return JSON.stringify({
|
|
238
|
+
Resources: {
|
|
239
|
+
[logicalId]: {
|
|
240
|
+
Type: "AWS::BedrockAgentCore::Policy",
|
|
241
|
+
Properties: {
|
|
242
|
+
PolicyEngineId: "GatewayEngine-abcdefghij",
|
|
243
|
+
Name: logicalId,
|
|
244
|
+
Definition: definition,
|
|
245
|
+
EnforcementMode: "LOG_ONLY",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
describe("DWDC013 — an embedded temporal statement needs its event schema", () => {
|
|
253
|
+
test("temporal text with no emitted schema is a warning naming the resource", () => {
|
|
254
|
+
const ctx = ctxOfLexicons({
|
|
255
|
+
cedar: { "policies.dw": policy('formerly within 1h Ns::Action::"Approve"::response{}') },
|
|
256
|
+
aws: { "template.json": template({ Policy: { Statement: TEMPORAL_STATEMENT } }) },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const [finding, ...rest] = dwdc013.check(ctx);
|
|
260
|
+
expect(rest).toEqual([]);
|
|
261
|
+
expect(finding.severity).toBe("warning");
|
|
262
|
+
expect(finding.entity).toBe("GatewayPolicy");
|
|
263
|
+
expect(finding.lexicon).toBe("aws");
|
|
264
|
+
expect(finding.message).toContain("Definition.Policy.Statement");
|
|
265
|
+
expect(finding.message).toContain("no .dwschema");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("an emitted event schema settles it", () => {
|
|
269
|
+
const ctx = ctxOfLexicons({
|
|
270
|
+
cedar: { "events.dwschema": PINNED },
|
|
271
|
+
aws: { "template.json": template({ Policy: { Statement: TEMPORAL_STATEMENT } }) },
|
|
272
|
+
});
|
|
273
|
+
expect(dwdc013.check(ctx)).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("plain Cedar in the language-agnostic arm is not this check's business", () => {
|
|
277
|
+
const ctx = ctxOfLexicons({
|
|
278
|
+
aws: { "template.json": template({ Policy: { Statement: PLAIN_STATEMENT } }) },
|
|
279
|
+
});
|
|
280
|
+
expect(dwdc013.check(ctx)).toEqual([]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("the Cedar arm is left alone — a temporal statement there is a different bug", () => {
|
|
284
|
+
const ctx = ctxOfLexicons({
|
|
285
|
+
aws: { "template.json": template({ Cedar: { Statement: TEMPORAL_STATEMENT } }) },
|
|
286
|
+
});
|
|
287
|
+
expect(dwdc013.check(ctx)).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("every embedded policy is reported, not only the first", () => {
|
|
291
|
+
const both = JSON.stringify({
|
|
292
|
+
Resources: {
|
|
293
|
+
Approval: {
|
|
294
|
+
Type: "AWS::BedrockAgentCore::Policy",
|
|
295
|
+
Properties: { Definition: { Policy: { Statement: TEMPORAL_STATEMENT } } },
|
|
296
|
+
},
|
|
297
|
+
Budget: {
|
|
298
|
+
Type: "AWS::BedrockAgentCore::Policy",
|
|
299
|
+
Properties: { Definition: { Policy: { Statement: TEMPORAL_STATEMENT } } },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const findings = dwdc013.check(ctxOfLexicons({ aws: { "template.json": both } }));
|
|
304
|
+
expect(findings.map((f) => f.entity).sort()).toEqual(["Approval", "Budget"]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a build with no embedding at all is silent", () => {
|
|
308
|
+
const ctx = ctxOfLexicons({
|
|
309
|
+
cedar: { "policies.dw": policy('formerly within 1h Ns::Action::"Approve"::response{}') },
|
|
310
|
+
});
|
|
311
|
+
expect(dwdc013.check(ctx)).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("non-JSON output is skipped rather than reported", () => {
|
|
315
|
+
const ctx = ctxOfLexicons({ aws: { "template.yaml": "Resources:\n Gateway:\n Type: Whatever\n" } });
|
|
316
|
+
expect(dwdc013.check(ctx)).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
210
320
|
// ── Registration ───────────────────────────────────────────────────
|
|
211
321
|
|
|
212
322
|
describe("registration", () => {
|
|
213
323
|
test("every DWD check is auto-discovered into the committed barrel", () => {
|
|
214
324
|
const ids = postSynthChecks.map((c) => c.id).filter((id) => id.startsWith("DWD"));
|
|
215
|
-
expect(ids.sort()).toEqual([
|
|
325
|
+
expect(ids.sort()).toEqual([
|
|
326
|
+
"DWDC010",
|
|
327
|
+
"DWDC011",
|
|
328
|
+
"DWDC012",
|
|
329
|
+
"DWDC013",
|
|
330
|
+
"DWDE010",
|
|
331
|
+
"DWDE011",
|
|
332
|
+
"DWDS010",
|
|
333
|
+
]);
|
|
216
334
|
});
|
|
217
335
|
|
|
218
336
|
test("every DWD check has a catalog entry, so `chant audit` can title it", () => {
|