@intentius/chant-lexicon-aws 0.44.10 → 0.44.13

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.
@@ -0,0 +1,366 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ agentCoreDecimal,
4
+ agentCoreEntityRef,
5
+ agentCoreRaw,
6
+ auditAgentCoreEvents,
7
+ AgentCoreTraceError,
8
+ qualifyAction,
9
+ qualifyUid,
10
+ renderAgentCoreTrace,
11
+ renderFields,
12
+ renderValue,
13
+ toTraceLine,
14
+ renderTraceLine,
15
+ type AgentCoreSessionEvent,
16
+ } from "./trace-render";
17
+
18
+ /**
19
+ * The golden line every assertion below is measured against is the real one
20
+ * quoted in the #1657 verification §6, reproduced here so a drift in the
21
+ * grammar shows up as a diff against a line that was read off dogwood's own
22
+ * parser rather than against this module's own opinion:
23
+ *
24
+ * ```
25
+ * @0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Login"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
26
+ * ```
27
+ */
28
+
29
+ function event(over: Partial<AgentCoreSessionEvent> = {}): AgentCoreSessionEvent {
30
+ return {
31
+ timeMs: 0,
32
+ sessionId: "s-1",
33
+ eventId: "u1",
34
+ kind: "request",
35
+ action: "Login",
36
+ actor: "alice",
37
+ target: "gw1",
38
+ input: { user: "alice" },
39
+ ...over,
40
+ };
41
+ }
42
+
43
+ describe("value rendering — Cedar surface forms (§6)", () => {
44
+ test("strings are quoted and escaped", () => {
45
+ expect(renderValue("alice")).toBe('"alice"');
46
+ expect(renderValue('say "hi"')).toBe('"say \\"hi\\""');
47
+ expect(renderValue("a\\b")).toBe('"a\\\\b"');
48
+ expect(renderValue("line\nbreak\ttab\rcr")).toBe('"line\\nbreak\\ttab\\rcr"');
49
+ });
50
+
51
+ test("a URL survives, because a trace has no comment syntax", () => {
52
+ expect(renderValue("https://example.com/a//b")).toBe('"https://example.com/a//b"');
53
+ });
54
+
55
+ test("booleans and integers render bare", () => {
56
+ expect(renderValue(true)).toBe("true");
57
+ expect(renderValue(false)).toBe("false");
58
+ expect(renderValue(42)).toBe("42");
59
+ expect(renderValue(-7)).toBe("-7");
60
+ });
61
+
62
+ test("a non-integer number throws rather than losing its scale", () => {
63
+ expect(() => renderValue(1.5)).toThrow(/agentCoreDecimal/);
64
+ });
65
+
66
+ test("decimals keep the scale a JS number cannot carry", () => {
67
+ expect(renderValue(agentCoreDecimal("1.50"))).toBe("1.50");
68
+ expect(() => agentCoreDecimal("1.5x")).toThrow(AgentCoreTraceError);
69
+ expect(() => agentCoreDecimal("2")).toThrow(/looks like "1.50"/);
70
+ });
71
+
72
+ test("entity refs render as the bare uid, and must be qualified", () => {
73
+ expect(renderValue(agentCoreEntityRef('Drupe::OAuthUser::"alice"'))).toBe('Drupe::OAuthUser::"alice"');
74
+ expect(() => agentCoreEntityRef("alice")).toThrow(/fully qualified/);
75
+ });
76
+
77
+ test("raw passes through untouched", () => {
78
+ expect(renderValue(agentCoreRaw("ip(\"10.0.0.1\")"))).toBe('ip("10.0.0.1")');
79
+ });
80
+
81
+ test("arrays and nested records", () => {
82
+ expect(renderValue([1, "a", true])).toBe('[1, "a", true]');
83
+ expect(renderFields({ a: 1, b: { c: "x" } })).toBe('{ a: 1, b: { c: "x" } }');
84
+ });
85
+
86
+ test("a field name that is not an identifier throws", () => {
87
+ expect(() => renderFields({ "not-an-ident": 1 })).toThrow(/must be an identifier/);
88
+ });
89
+
90
+ test("an integer past 2^53 throws rather than rendering a different number", () => {
91
+ // String(1e21) is "1e+21", which is not a Cedar integer literal at all.
92
+ expect(() => renderValue(1e21)).toThrow(/outside the range a JS number represents exactly/);
93
+ expect(() => renderValue(Number.MAX_SAFE_INTEGER + 2)).toThrow(AgentCoreTraceError);
94
+ expect(renderValue(Number.MAX_SAFE_INTEGER)).toBe("9007199254740991");
95
+ });
96
+ });
97
+
98
+ describe("a payload cannot write its own trace", () => {
99
+ /**
100
+ * The observed agent authors the payloads this module renders. If the tagged
101
+ * values were recognised by shape, an agent could emit surface text straight
102
+ * into both bags — a forged principal, an unbalanced paren, an extra field.
103
+ */
104
+ test("a record that merely looks like a raw value is refused, not rendered", () => {
105
+ const forged = { traceValue: "raw", text: 'INJECTED), callerPrincipal: Ns::Admin::"root"' } as const;
106
+ expect(() => renderValue(forged)).toThrow(/would let the observed agent decide what its own trace says/);
107
+ expect(() => renderFields({ meta: forged })).toThrow(AgentCoreTraceError);
108
+ });
109
+
110
+ test("a look-alike entity ref is refused too, so a principal cannot be forged", () => {
111
+ expect(() => renderValue({ traceValue: "entity", uid: 'Ns::Admin::"root"' })).toThrow(
112
+ /a payload carries a "traceValue" field/,
113
+ );
114
+ });
115
+
116
+ test("the real constructors still render, because the tag is identity and not shape", () => {
117
+ expect(renderValue(agentCoreRaw("ip(\"10.0.0.1\")"))).toBe('ip("10.0.0.1")');
118
+ expect(renderValue(agentCoreEntityRef('Ns::Admin::"root"'))).toBe('Ns::Admin::"root"');
119
+ expect(renderValue(agentCoreDecimal("1.50"))).toBe("1.50");
120
+ });
121
+
122
+ test("a structurally identical copy of a real tagged value loses the tag", () => {
123
+ expect(() => renderValue({ ...agentCoreEntityRef('Ns::Admin::"root"') })).toThrow(AgentCoreTraceError);
124
+ });
125
+ });
126
+
127
+ describe("qualification — the second §6 trap", () => {
128
+ test("a bare action gets the namespace", () => {
129
+ expect(qualifyAction("Transfer", "Drupe")).toBe('Drupe::Action::"Transfer"');
130
+ });
131
+
132
+ test("an already-qualified action is validated and passed through", () => {
133
+ expect(qualifyAction('Drupe::Action::"Transfer"', "AgentCore")).toBe('Drupe::Action::"Transfer"');
134
+ expect(() => qualifyAction("Drupe::Action::Transfer", "AgentCore")).toThrow(/fully qualified/);
135
+ });
136
+
137
+ test("a bare name that could not be quoted safely throws", () => {
138
+ expect(() => qualifyAction('Trans"fer', "Drupe")).toThrow(/cannot be empty or contain a quote/);
139
+ expect(() => qualifyAction("", "Drupe")).toThrow(AgentCoreTraceError);
140
+ });
141
+
142
+ test("uids qualify the same way", () => {
143
+ expect(qualifyUid("alice", "Drupe", "OAuthUser", "an actor")).toBe('Drupe::OAuthUser::"alice"');
144
+ expect(qualifyUid('Other::Type::"x"', "Drupe", "OAuthUser", "an actor")).toBe('Other::Type::"x"');
145
+ });
146
+ });
147
+
148
+ describe("renderAgentCoreTrace — byte-level golden lines", () => {
149
+ test("one event reproduces the §6 example line exactly", () => {
150
+ const { text } = renderAgentCoreTrace(
151
+ [event({ sessionId: "sess-1" })],
152
+ { namespace: "Drupe", principalType: "OAuthUser", resourceType: "Gateway" },
153
+ );
154
+
155
+ expect(text).toBe(
156
+ '@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") ' +
157
+ 'request_context(input: { user: "alice" }) ' +
158
+ 'Drupe::Action::"Login"::request(input: { user: "alice" }, ' +
159
+ 'callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", ' +
160
+ 'sessionId: "sess-1", requestId: "u1")\n',
161
+ );
162
+ });
163
+
164
+ test("every payload group lands in BOTH bags — the first §6 trap", () => {
165
+ const [line] = renderAgentCoreTrace(
166
+ [
167
+ event({
168
+ kind: "response",
169
+ action: "Transfer",
170
+ input: { amount: 10 },
171
+ output: { result: "ok" },
172
+ attributes: { toolName: "transfer" },
173
+ }),
174
+ ],
175
+ { decisionKinds: ["response"] },
176
+ ).lines;
177
+
178
+ expect(Object.keys(line!.requestContext)).toEqual(["input", "output", "attributes"]);
179
+ // The record carries the same groups, then the event schema's own
180
+ // injections, which are never part of the Cedar request.
181
+ expect(Object.keys(line!.record)).toEqual([
182
+ "input",
183
+ "output",
184
+ "attributes",
185
+ "callerPrincipal",
186
+ "callerResource",
187
+ "sessionId",
188
+ "requestId",
189
+ ]);
190
+ for (const group of ["input", "output", "attributes"]) {
191
+ expect(line!.record[group]).toEqual(line!.requestContext[group]);
192
+ }
193
+ });
194
+
195
+ test("an error group renders into both bags too", () => {
196
+ const { text } = renderAgentCoreTrace(
197
+ [event({ kind: "error", input: undefined, error: { code: "AccessDenied" } })],
198
+ { namespace: "Drupe", principalType: "OAuthUser", resourceType: "Gateway", decisionKinds: ["error"] },
199
+ );
200
+ expect(text).toBe(
201
+ '@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") ' +
202
+ 'request_context(error: { code: "AccessDenied" }) ' +
203
+ 'Drupe::Action::"Login"::error(error: { code: "AccessDenied" }, ' +
204
+ 'callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", ' +
205
+ 'sessionId: "s-1", requestId: "u1")\n',
206
+ );
207
+ });
208
+
209
+ test("the default namespace and entity types", () => {
210
+ const { text } = renderAgentCoreTrace([event({ input: { q: 1 } })]);
211
+ expect(text).toBe(
212
+ '@0 scope(principal: AgentCore::Actor::"alice", resource: AgentCore::Runtime::"gw1") ' +
213
+ "request_context(input: { q: 1 }) " +
214
+ 'AgentCore::Action::"Login"::request(input: { q: 1 }, ' +
215
+ 'callerPrincipal: AgentCore::Actor::"alice", callerResource: AgentCore::Runtime::"gw1", ' +
216
+ 'sessionId: "s-1", requestId: "u1")\n',
217
+ );
218
+ });
219
+
220
+ test("epoch-seconds is the default origin; relative-seconds starts the trace at @0", () => {
221
+ const events = [
222
+ event({ eventId: "u1", timeMs: 1_700_000_000_000 }),
223
+ event({ eventId: "u2", timeMs: 1_700_000_010_500 }),
224
+ ];
225
+
226
+ const epoch = renderAgentCoreTrace(events).lines.map((l) => l.timestamp);
227
+ expect(epoch).toEqual([1_700_000_000, 1_700_000_010]);
228
+
229
+ const relative = renderAgentCoreTrace(events, { origin: "relative-seconds" }).lines.map((l) => l.timestamp);
230
+ expect(relative).toEqual([0, 10]);
231
+ });
232
+
233
+ test("the history is ordered the way the interpreter reads it, newest-last", () => {
234
+ // CloudWatch Logs hands out the newest first; replayed in that order the
235
+ // temporal windows would see the future before the past.
236
+ const { text } = renderAgentCoreTrace(
237
+ [
238
+ event({ eventId: "u3", timeMs: 7_200_000 }),
239
+ event({ eventId: "u1", timeMs: 0 }),
240
+ event({ eventId: "u2", timeMs: 10_000 }),
241
+ ],
242
+ { origin: "relative-seconds" },
243
+ );
244
+ const stamps = text.trimEnd().split("\n").map((l) => l.split(" ")[0]);
245
+ expect(stamps).toEqual(["@0", "@10", "@7200"]);
246
+ });
247
+
248
+ test("a tie keeps the order the source reported", () => {
249
+ const { lines } = renderAgentCoreTrace([
250
+ event({ eventId: "second", timeMs: 5_000 }),
251
+ event({ eventId: "first", timeMs: 5_000 }),
252
+ ]);
253
+ expect(lines.map((l) => l.record.requestId)).toEqual(["second", "first"]);
254
+ });
255
+
256
+ test("a multi-line trace is newline-terminated with no trailing blank", () => {
257
+ const { text } = renderAgentCoreTrace(
258
+ [event({ eventId: "u1", timeMs: 0 }), event({ eventId: "u2", timeMs: 1_000 })],
259
+ { origin: "relative-seconds" },
260
+ );
261
+ expect(text.endsWith(")\n")).toBe(true);
262
+ expect(text.split("\n")).toHaveLength(3);
263
+ expect(text.split("\n")[2]).toBe("");
264
+ });
265
+
266
+ test("an empty history renders as empty text, not a bare newline", () => {
267
+ expect(renderAgentCoreTrace([]).text).toBe("");
268
+ });
269
+ });
270
+
271
+ describe("a malformed history fails loudly rather than weakening the trace", () => {
272
+ test("a decision-kind event with no payload is refused", () => {
273
+ expect(() => renderAgentCoreTrace([event({ input: undefined })])).toThrow(AgentCoreTraceError);
274
+ expect(() => renderAgentCoreTrace([event({ input: undefined })])).toThrow(
275
+ /request_context envelope would be empty/,
276
+ );
277
+ });
278
+
279
+ test("…and the refusal names the opt-out rather than leaving the caller stuck", () => {
280
+ expect(() => renderAgentCoreTrace([event({ input: undefined })])).toThrow(
281
+ /allow: \["no-request-context"\]/,
282
+ );
283
+ const { text, issues } = renderAgentCoreTrace([event({ input: undefined })], {
284
+ allow: ["no-request-context"],
285
+ });
286
+ expect(issues.map((i) => i.kind)).toEqual(["no-request-context"]);
287
+ expect(text).toContain('Login"::request(callerPrincipal:');
288
+ });
289
+
290
+ test("a history-only kind with no payload is not a weakening", () => {
291
+ const { text } = renderAgentCoreTrace([event({ kind: "response", input: undefined })]);
292
+ expect(text).toContain('::Action::"Login"::response(');
293
+ });
294
+
295
+ test("an empty payload group counts as no payload", () => {
296
+ expect(() => renderAgentCoreTrace([event({ input: {} })])).toThrow(/request_context envelope would be empty/);
297
+ });
298
+
299
+ test("a repeated eventId within a session is refused", () => {
300
+ const events = [event({ eventId: "u1", timeMs: 0 }), event({ eventId: "u1", timeMs: 1_000 })];
301
+ expect(() => renderAgentCoreTrace(events)).toThrow(/reports eventId "u1" twice/);
302
+ });
303
+
304
+ test("the same eventId in two different sessions is fine", () => {
305
+ const events = [
306
+ event({ sessionId: "s-1", eventId: "u1" }),
307
+ event({ sessionId: "s-2", eventId: "u1", timeMs: 1_000 }),
308
+ ];
309
+ expect(renderAgentCoreTrace(events).lines).toHaveLength(2);
310
+ });
311
+
312
+ test.each([
313
+ ["timeMs", { timeMs: Number.NaN }, /no usable timestamp/],
314
+ ["timeMs", { timeMs: undefined as unknown as number }, /no usable timestamp/],
315
+ ["sessionId", { sessionId: "" }, /has no sessionId/],
316
+ ["eventId", { eventId: "" }, /has no eventId/],
317
+ ["actor", { actor: "" }, /has no actor/],
318
+ ["target", { target: "" }, /has no target/],
319
+ ["action", { action: "" }, /has no action/],
320
+ ["kind", { kind: "" }, /has no kind/],
321
+ ])("a history missing %s throws instead of guessing", (_field, over, message) => {
322
+ expect(() => renderAgentCoreTrace([event(over as Partial<AgentCoreSessionEvent>)])).toThrow(message);
323
+ });
324
+
325
+ test("a kind that is not an identifier throws", () => {
326
+ expect(() => renderAgentCoreTrace([event({ kind: "tool-call" })])).toThrow(/must be an identifier/);
327
+ });
328
+
329
+ test("the error names the event's position in the sorted history", () => {
330
+ try {
331
+ renderAgentCoreTrace([event({ timeMs: 10_000 }), event({ eventId: "u2", timeMs: 0, actor: "" })]);
332
+ expect.unreachable("expected a throw");
333
+ } catch (error) {
334
+ expect(error).toBeInstanceOf(AgentCoreTraceError);
335
+ expect((error as AgentCoreTraceError).index).toBe(0);
336
+ }
337
+ });
338
+ });
339
+
340
+ describe("auditAgentCoreEvents / toTraceLine / renderTraceLine", () => {
341
+ test("the audit reports without rendering", () => {
342
+ const issues = auditAgentCoreEvents([event({ input: undefined })]);
343
+ expect(issues).toHaveLength(1);
344
+ expect(issues[0]).toMatchObject({ kind: "no-request-context", index: 0, timeMs: 0 });
345
+ });
346
+
347
+ test("a clean history audits empty", () => {
348
+ expect(auditAgentCoreEvents([event()])).toEqual([]);
349
+ });
350
+
351
+ test("toTraceLine and renderTraceLine compose to the same line", () => {
352
+ const line = toTraceLine(event(), 0, { namespace: "Drupe", principalType: "OAuthUser", resourceType: "Gateway" });
353
+ expect(renderTraceLine(line) + "\n").toBe(
354
+ renderAgentCoreTrace([event()], {
355
+ namespace: "Drupe",
356
+ principalType: "OAuthUser",
357
+ resourceType: "Gateway",
358
+ }).text,
359
+ );
360
+ });
361
+
362
+ test("a non-integer timepoint throws", () => {
363
+ const line = toTraceLine(event(), 1.5);
364
+ expect(() => renderTraceLine(line)).toThrow(/a timepoint is an i64/);
365
+ });
366
+ });