@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.
- package/dist/agentcore/trace-fetch.d.ts +332 -0
- package/dist/agentcore/trace-fetch.d.ts.map +1 -0
- package/dist/agentcore/trace-render.d.ts +256 -0
- package/dist/agentcore/trace-render.d.ts.map +1 -0
- package/dist/api/read-client.d.ts +16 -0
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/identity-observe.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/agentcore/trace-fetch.test.ts +588 -0
- package/src/agentcore/trace-fetch.ts +754 -0
- package/src/agentcore/trace-render.test.ts +366 -0
- package/src/agentcore/trace-render.ts +589 -0
- package/src/api/read-client.ts +7 -1
- package/src/identity-observe.test.ts +44 -2
- package/src/identity-observe.ts +25 -2
- package/src/op/activities/index.ts +15 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an AgentCore session history as dogwood replay-trace text (#1685).
|
|
3
|
+
*
|
|
4
|
+
* This module is pure: normalized events in, text out, no transport and no
|
|
5
|
+
* filesystem. It exists separately from `./trace-fetch.ts` for two reasons.
|
|
6
|
+
* The Bedrock AgentCore observability surface is in preview and moving, so the
|
|
7
|
+
* thing most likely to be rewritten is the fetch, and the grammar it renders
|
|
8
|
+
* into is the thing least likely to move. And the line grammar has two traps in
|
|
9
|
+
* it that are worth testing byte-for-byte without a mock HTTP stack in the way.
|
|
10
|
+
*
|
|
11
|
+
* ## The grammar
|
|
12
|
+
*
|
|
13
|
+
* From the #1657 verification (§6, read from
|
|
14
|
+
* `dogwood-language/src/interpreter/log_parse.rs` at the pinned SHA
|
|
15
|
+
* `5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c`), one event per line:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* @<timestamp> [scope(principal: <uid>, resource: <uid>)]
|
|
19
|
+
* [entities(<uid>: { <attrs> } [in [<uid>, …]], …)]
|
|
20
|
+
* [request_context(<group>: { … }, …)]
|
|
21
|
+
* <Ns>::Action::"<Name>"::<kind>(<field>: <value>, …)
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* The three envelopes are optional and must appear in that order; the trailing
|
|
25
|
+
* group is the logged record. Blank lines are skipped and there is no comment
|
|
26
|
+
* syntax, so a `//` inside a value survives.
|
|
27
|
+
*
|
|
28
|
+
* ## The two traps
|
|
29
|
+
*
|
|
30
|
+
* 1. **`request_context(…)` and the logged record are different bags.** The
|
|
31
|
+
* Cedar request is built from the first, temporal predicates match against
|
|
32
|
+
* the second, and a field supplied to only one weakens either the Cedar
|
|
33
|
+
* check or the temporal check while the replay still exits 0 with a verdict
|
|
34
|
+
* that looks authoritative. So every payload group an event carries is
|
|
35
|
+
* written to *both*, always — there is no per-event opt-out here, because an
|
|
36
|
+
* AgentCore history has no way to express "this field is deliberately
|
|
37
|
+
* Cedar-invisible". A decision-kind event with no payload at all is a
|
|
38
|
+
* refusal, not a shrug: see {@link renderAgentCoreTrace}.
|
|
39
|
+
* 2. **Actions must be fully qualified.** `AgentCore::Action::"Transfer"`,
|
|
40
|
+
* never `Transfer` — a short name leaves the temporal predicate unmatched
|
|
41
|
+
* while Cedar still authorizes. A bare tool name from the history is
|
|
42
|
+
* qualified here; an already-qualified one is validated and passed through.
|
|
43
|
+
*
|
|
44
|
+
* ## Decoupling
|
|
45
|
+
*
|
|
46
|
+
* Nothing here imports the cedar lexicon, and the cedar lexicon imports nothing
|
|
47
|
+
* from here. The contract between them is the text. The value and event types
|
|
48
|
+
* below are deliberately shaped so an object built here is *structurally*
|
|
49
|
+
* assignable to the cedar side's `TraceValue`/`TraceEvent` for anyone who wants
|
|
50
|
+
* that, but neither package depends on the other to get it, and the rendering
|
|
51
|
+
* is implemented twice on purpose. Where a choice was free — the space inside
|
|
52
|
+
* `{ … }`, the `, ` between fields, the order of the record's own injections —
|
|
53
|
+
* it matches `lexicons/cedar/src/dogwood/trace.ts` byte for byte, so a trace
|
|
54
|
+
* fetched from AWS and a trace built by hand look the same to a reader and to
|
|
55
|
+
* the parser.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/* ── Values ───────────────────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
/** `Ns::Type::"id"` in value position. */
|
|
61
|
+
export interface AgentCoreEntityRef {
|
|
62
|
+
readonly traceValue: "entity";
|
|
63
|
+
readonly uid: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `1.50` — Cedar's decimal surface form, which a JS number cannot carry. */
|
|
67
|
+
export interface AgentCoreDecimal {
|
|
68
|
+
readonly traceValue: "decimal";
|
|
69
|
+
readonly text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Surface text passed through untouched — the escape hatch, used sparingly. */
|
|
73
|
+
export interface AgentCoreRaw {
|
|
74
|
+
readonly traceValue: "raw";
|
|
75
|
+
readonly text: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A tagged value: one rendered specially rather than by JS type. */
|
|
79
|
+
export type AgentCoreTagged = AgentCoreEntityRef | AgentCoreDecimal | AgentCoreRaw;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which objects are *really* tagged values.
|
|
83
|
+
*
|
|
84
|
+
* Identity, not shape. A tagged value renders as surface text — an `entity`
|
|
85
|
+
* becomes a bare uid, a `raw` becomes its `text` verbatim — so if membership
|
|
86
|
+
* were decided by a `traceValue` key, any agent that wrote
|
|
87
|
+
* `{"traceValue": "raw", "text": "…"}` into a payload could inject arbitrary
|
|
88
|
+
* unescaped text into both bags of the trace: a forged `callerPrincipal`, an
|
|
89
|
+
* unbalanced paren, a whole extra field. The payloads this module reads are
|
|
90
|
+
* written by the agent under observation, which is the last party that should
|
|
91
|
+
* get to decide what its own trace says. So only the three constructors below
|
|
92
|
+
* confer the tag, and {@link renderValue} refuses a record that merely looks
|
|
93
|
+
* tagged rather than rendering it either way.
|
|
94
|
+
*/
|
|
95
|
+
const TAGGED = new WeakSet<object>();
|
|
96
|
+
|
|
97
|
+
function tag<T extends AgentCoreTagged>(value: T): T {
|
|
98
|
+
TAGGED.add(value);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Anything that can sit in a trace field, in Cedar surface forms. */
|
|
103
|
+
export type AgentCoreTraceValue =
|
|
104
|
+
| string
|
|
105
|
+
| number
|
|
106
|
+
| boolean
|
|
107
|
+
| AgentCoreTagged
|
|
108
|
+
| readonly AgentCoreTraceValue[]
|
|
109
|
+
| { readonly [key: string]: AgentCoreTraceValue };
|
|
110
|
+
|
|
111
|
+
/** A named group of fields — what one `request_context` envelope entry holds. */
|
|
112
|
+
export type AgentCoreFields = { readonly [key: string]: AgentCoreTraceValue };
|
|
113
|
+
|
|
114
|
+
/** `Ns::Type::"id"`, `Ns::Action::"Name"` — a fully qualified uid. */
|
|
115
|
+
const QUALIFIED_UID = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)+::"[^"]*"$/;
|
|
116
|
+
/** A bare field, group, namespace or event-kind name. */
|
|
117
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A history that cannot become an honest trace.
|
|
121
|
+
*
|
|
122
|
+
* Thrown rather than worked around. Every case this covers is one where the
|
|
123
|
+
* alternative is a trace that replays green and proves less than it looks like
|
|
124
|
+
* it proves, which is the failure mode the whole module is arranged against.
|
|
125
|
+
*/
|
|
126
|
+
export class AgentCoreTraceError extends Error {
|
|
127
|
+
constructor(
|
|
128
|
+
message: string,
|
|
129
|
+
/** 0-based position in the event list, when one event is to blame. */
|
|
130
|
+
readonly index?: number,
|
|
131
|
+
) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "AgentCoreTraceError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertUid(value: string, what: string, index?: number): string {
|
|
138
|
+
if (!QUALIFIED_UID.test(value)) {
|
|
139
|
+
throw new AgentCoreTraceError(
|
|
140
|
+
`agentcore trace: ${what} must be fully qualified, like Ns::Type::"id" — got "${value}"`,
|
|
141
|
+
index,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertIdent(value: string, what: string, index?: number): string {
|
|
148
|
+
if (!IDENT.test(value)) {
|
|
149
|
+
throw new AgentCoreTraceError(`agentcore trace: ${what} must be an identifier — got "${value}"`, index);
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `Ns::Type::"id"`. Validated at construction, so a short name cannot slip through. */
|
|
155
|
+
export function agentCoreEntityRef(uid: string): AgentCoreEntityRef {
|
|
156
|
+
return tag({ traceValue: "entity", uid: assertUid(uid, "an entity reference") });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A Cedar decimal, written as it should appear (`"1.50"`).
|
|
161
|
+
*
|
|
162
|
+
* `context` names the field it came from when there is one, so a payload that
|
|
163
|
+
* cannot be represented says *where* rather than only *what*.
|
|
164
|
+
*/
|
|
165
|
+
export function agentCoreDecimal(text: string, context?: string): AgentCoreDecimal {
|
|
166
|
+
if (!/^-?\d+\.\d+$/.test(text)) {
|
|
167
|
+
const where = context ? ` at ${context}` : "";
|
|
168
|
+
throw new AgentCoreTraceError(`agentcore trace: a decimal looks like "1.50" — got "${text}"${where}`);
|
|
169
|
+
}
|
|
170
|
+
return tag({ traceValue: "decimal", text });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Surface text, rendered verbatim. For values this module has no shape for. */
|
|
174
|
+
export function agentCoreRaw(text: string): AgentCoreRaw {
|
|
175
|
+
return tag({ traceValue: "raw", text });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isTagged(value: AgentCoreTraceValue): value is AgentCoreTagged {
|
|
179
|
+
return typeof value === "object" && value !== null && TAGGED.has(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The five escapes a Cedar string literal needs. Mirrors the cedar lexicon's. */
|
|
183
|
+
function escapeString(value: string): string {
|
|
184
|
+
return value
|
|
185
|
+
.replace(/\\/g, "\\\\")
|
|
186
|
+
.replace(/"/g, '\\"')
|
|
187
|
+
.replace(/\n/g, "\\n")
|
|
188
|
+
.replace(/\r/g, "\\r")
|
|
189
|
+
.replace(/\t/g, "\\t");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Render one value in the Cedar surface form the trace parser reads. */
|
|
193
|
+
export function renderValue(value: AgentCoreTraceValue): string {
|
|
194
|
+
if (typeof value === "string") return `"${escapeString(value)}"`;
|
|
195
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
196
|
+
if (typeof value === "number") {
|
|
197
|
+
if (!Number.isInteger(value)) {
|
|
198
|
+
throw new AgentCoreTraceError(
|
|
199
|
+
`agentcore trace: ${String(value)} is not an integer — a decimal must be written with agentCoreDecimal("1.50") so its scale survives`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
// Beyond 2^53 a JS number has already lost the digits an i64 would keep,
|
|
203
|
+
// and `String(1e21)` is `"1e+21"`, which is not a Cedar integer literal at
|
|
204
|
+
// all. Either way the trace would carry a number that is not the number the
|
|
205
|
+
// agent saw.
|
|
206
|
+
if (!Number.isSafeInteger(value)) {
|
|
207
|
+
throw new AgentCoreTraceError(
|
|
208
|
+
`agentcore trace: ${String(value)} is outside the range a JS number represents exactly, so the trace would carry a different number than the history did — carry it as a string`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return String(value);
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(value)) return `[${value.map(renderValue).join(", ")}]`;
|
|
214
|
+
if (isTagged(value)) return value.traceValue === "entity" ? value.uid : value.text;
|
|
215
|
+
if ("traceValue" in value) {
|
|
216
|
+
// See TAGGED. Rendering it as a record would be a lie about what the agent
|
|
217
|
+
// wrote; rendering it as a tagged value would let the agent write its own
|
|
218
|
+
// trace. Neither is on offer.
|
|
219
|
+
throw new AgentCoreTraceError(
|
|
220
|
+
`agentcore trace: a payload carries a "traceValue" field, which is the marker this module uses for entity refs, decimals and raw surface text. ` +
|
|
221
|
+
"Rendering it either way would let the observed agent decide what its own trace says, so it is refused — rename the field at the source, " +
|
|
222
|
+
"or build the value with agentCoreEntityRef/agentCoreDecimal/agentCoreRaw.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return renderFields(value as AgentCoreFields);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `{ a: 1, b: "x" }` — a record body, braces included. */
|
|
229
|
+
export function renderFields(fields: AgentCoreFields): string {
|
|
230
|
+
const parts = Object.entries(fields).map(
|
|
231
|
+
([key, value]) => `${assertIdent(key, "a trace field name")}: ${renderValue(value)}`,
|
|
232
|
+
);
|
|
233
|
+
return `{ ${parts.join(", ")} }`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ── Normalized events ────────────────────────────────────────────────────── */
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One decision-relevant thing that happened in an AgentCore session, in the
|
|
240
|
+
* shape the fetch normalizes to and the renderer reads.
|
|
241
|
+
*
|
|
242
|
+
* This is deliberately *not* the dogwood event shape. It is the semantic shape
|
|
243
|
+
* of an agent session — who acted, on what, with which payload — so that a
|
|
244
|
+
* change to the observability surface under preview churn is a change to the
|
|
245
|
+
* mapping in `./trace-fetch.ts` and not to the grammar below.
|
|
246
|
+
*/
|
|
247
|
+
export interface AgentCoreSessionEvent {
|
|
248
|
+
/** When it happened, epoch **milliseconds**. Converted to the trace's i64 here. */
|
|
249
|
+
readonly timeMs: number;
|
|
250
|
+
/** The session it belongs to. Lands in the logged record as `sessionId`. */
|
|
251
|
+
readonly sessionId: string;
|
|
252
|
+
/** Unique within the session. Lands in the logged record as `requestId`. */
|
|
253
|
+
readonly eventId: string;
|
|
254
|
+
/**
|
|
255
|
+
* The dogwood event kind. `request` / `response` / `error` conventionally;
|
|
256
|
+
* the truth is whichever kinds the project's `.dwschema` marks `decision`,
|
|
257
|
+
* and that file is not visible from here.
|
|
258
|
+
*/
|
|
259
|
+
readonly kind: string;
|
|
260
|
+
/**
|
|
261
|
+
* The tool or operation. A bare name (`"Transfer"`) is qualified with the
|
|
262
|
+
* namespace; an already-qualified `Ns::Action::"Name"` is validated and used
|
|
263
|
+
* as-is. Never rendered short.
|
|
264
|
+
*/
|
|
265
|
+
readonly action: string;
|
|
266
|
+
/** Who acted — an actor id, or a fully qualified uid to override the type. */
|
|
267
|
+
readonly actor: string;
|
|
268
|
+
/** What was acted on — a runtime/gateway id, or a fully qualified uid. */
|
|
269
|
+
readonly target: string;
|
|
270
|
+
/** The call's input payload. Lands in both bags as the `input` group. */
|
|
271
|
+
readonly input?: AgentCoreFields;
|
|
272
|
+
/** The call's result. Lands in both bags as the `output` group. */
|
|
273
|
+
readonly output?: AgentCoreFields;
|
|
274
|
+
/** A failure's detail. Lands in both bags as the `error` group. */
|
|
275
|
+
readonly error?: AgentCoreFields;
|
|
276
|
+
/**
|
|
277
|
+
* Anything the source carried that has no field of its own. Lands in both
|
|
278
|
+
* bags as the `attributes` group.
|
|
279
|
+
*/
|
|
280
|
+
readonly attributes?: AgentCoreFields;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** How `@<i64>` is derived from `timeMs`. */
|
|
284
|
+
export type AgentCoreTimeOrigin =
|
|
285
|
+
/** Epoch seconds. Absolute, comparable across sessions, large numbers. */
|
|
286
|
+
| "epoch-seconds"
|
|
287
|
+
/** Seconds since the earliest event, so the first line reads `@0`. */
|
|
288
|
+
| "relative-seconds";
|
|
289
|
+
|
|
290
|
+
/** What {@link renderAgentCoreTrace} takes. */
|
|
291
|
+
export interface AgentCoreTraceOptions {
|
|
292
|
+
/** Cedar namespace for actions and entity types. Default `"AgentCore"`. */
|
|
293
|
+
readonly namespace?: string;
|
|
294
|
+
/** Entity type for `actor`. Default `"Actor"`. */
|
|
295
|
+
readonly principalType?: string;
|
|
296
|
+
/** Entity type for `target`. Default `"Runtime"`. */
|
|
297
|
+
readonly resourceType?: string;
|
|
298
|
+
/** Default `"epoch-seconds"`. */
|
|
299
|
+
readonly origin?: AgentCoreTimeOrigin;
|
|
300
|
+
/**
|
|
301
|
+
* Kinds that produce a decision, and so build a Cedar request. Default
|
|
302
|
+
* `["request"]`, the convention the default event schema follows. A
|
|
303
|
+
* history-only event never becomes a Cedar request, so an absent payload on
|
|
304
|
+
* one is not a weakening.
|
|
305
|
+
*/
|
|
306
|
+
readonly decisionKinds?: readonly string[];
|
|
307
|
+
/** Weakenings to tolerate. Empty by default — see {@link renderAgentCoreTrace}. */
|
|
308
|
+
readonly allow?: readonly AgentCoreTraceIssueKind[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** What {@link auditAgentCoreEvents} can find. */
|
|
312
|
+
export type AgentCoreTraceIssueKind =
|
|
313
|
+
/** A decision-kind event with no payload: the Cedar request carries no context. */
|
|
314
|
+
| "no-request-context"
|
|
315
|
+
/** Two events in one session sharing an id: `requestId` stops identifying anything. */
|
|
316
|
+
| "duplicate-event-id";
|
|
317
|
+
|
|
318
|
+
/** One finding against a normalized history. */
|
|
319
|
+
export interface AgentCoreTraceIssue {
|
|
320
|
+
readonly kind: AgentCoreTraceIssueKind;
|
|
321
|
+
/** 0-based position in the *sorted* event list. */
|
|
322
|
+
readonly index: number;
|
|
323
|
+
readonly timeMs: number;
|
|
324
|
+
readonly message: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const DEFAULTS = {
|
|
328
|
+
namespace: "AgentCore",
|
|
329
|
+
principalType: "Actor",
|
|
330
|
+
resourceType: "Runtime",
|
|
331
|
+
origin: "epoch-seconds" as AgentCoreTimeOrigin,
|
|
332
|
+
decisionKinds: ["request"] as readonly string[],
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/** A payload group name → the field on the normalized event. Order is the render order. */
|
|
336
|
+
const GROUPS = ["input", "output", "error", "attributes"] as const;
|
|
337
|
+
|
|
338
|
+
function payloadGroups(event: AgentCoreSessionEvent): Record<string, AgentCoreFields> {
|
|
339
|
+
const out: Record<string, AgentCoreFields> = {};
|
|
340
|
+
for (const group of GROUPS) {
|
|
341
|
+
const fields = event[group];
|
|
342
|
+
if (fields && Object.keys(fields).length > 0) out[group] = fields;
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `Ns::Action::"Name"` from a bare tool name, or a qualified action validated.
|
|
349
|
+
*
|
|
350
|
+
* The fully-qualified requirement is the second §6 trap: a short name leaves
|
|
351
|
+
* every temporal predicate unmatched while Cedar still authorizes, so the
|
|
352
|
+
* replay is green and blind.
|
|
353
|
+
*/
|
|
354
|
+
export function qualifyAction(action: string, namespace: string, index?: number): string {
|
|
355
|
+
if (action.includes("::")) return assertUid(action, "a trace action", index);
|
|
356
|
+
if (action.length === 0 || action.includes('"')) {
|
|
357
|
+
throw new AgentCoreTraceError(
|
|
358
|
+
`agentcore trace: a bare action name cannot be empty or contain a quote — got "${action}"`,
|
|
359
|
+
index,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return `${assertIdent(namespace, "a namespace", index)}::Action::"${action}"`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** `Ns::Type::"id"` from a bare id, or a qualified uid validated. */
|
|
366
|
+
export function qualifyUid(id: string, namespace: string, type: string, what: string, index?: number): string {
|
|
367
|
+
if (id.includes("::")) return assertUid(id, what, index);
|
|
368
|
+
if (id.length === 0 || id.includes('"')) {
|
|
369
|
+
throw new AgentCoreTraceError(
|
|
370
|
+
`agentcore trace: ${what} cannot be empty or contain a quote — got "${id}"`,
|
|
371
|
+
index,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return `${assertIdent(namespace, "a namespace", index)}::${assertIdent(type, "an entity type", index)}::"${id}"`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Order the history the way the interpreter reads it.
|
|
379
|
+
*
|
|
380
|
+
* dogwood accumulates history in *file* order, so an out-of-order line changes
|
|
381
|
+
* what a temporal window sees. A fetch that pages backwards (CloudWatch Logs
|
|
382
|
+
* hands out the newest first) would otherwise produce a trace whose windows are
|
|
383
|
+
* quietly wrong, so ordering is settled here rather than being a caller's
|
|
384
|
+
* problem. Ties keep their original relative order.
|
|
385
|
+
*/
|
|
386
|
+
function sortByTime(events: readonly AgentCoreSessionEvent[]): AgentCoreSessionEvent[] {
|
|
387
|
+
return events.map((event, index) => ({ event, index }))
|
|
388
|
+
.sort((a, b) => a.event.timeMs - b.event.timeMs || a.index - b.index)
|
|
389
|
+
.map(({ event }) => event);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function assertWellFormed(event: AgentCoreSessionEvent, index: number): void {
|
|
393
|
+
if (typeof event.timeMs !== "number" || !Number.isFinite(event.timeMs)) {
|
|
394
|
+
throw new AgentCoreTraceError(
|
|
395
|
+
`agentcore trace: event ${index} has no usable timestamp (${String(event.timeMs)}) — a trace timepoint is an i64 and there is no honest default for a missing one`,
|
|
396
|
+
index,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
for (const [field, value] of [
|
|
400
|
+
["sessionId", event.sessionId],
|
|
401
|
+
["eventId", event.eventId],
|
|
402
|
+
["actor", event.actor],
|
|
403
|
+
["target", event.target],
|
|
404
|
+
["action", event.action],
|
|
405
|
+
["kind", event.kind],
|
|
406
|
+
] as const) {
|
|
407
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
408
|
+
throw new AgentCoreTraceError(
|
|
409
|
+
`agentcore trace: event ${index} has no ${field} — the history is malformed, and a trace with a guessed ${field} replays green while proving nothing`,
|
|
410
|
+
index,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The weakening checks, applied to a normalized history.
|
|
418
|
+
*
|
|
419
|
+
* Everything here is something that makes a replay *weaker* rather than
|
|
420
|
+
* something that makes it fail, which is exactly the class a green replay run
|
|
421
|
+
* hides. Structural malformation throws instead: see {@link AgentCoreTraceError}.
|
|
422
|
+
*/
|
|
423
|
+
export function auditAgentCoreEvents(
|
|
424
|
+
events: readonly AgentCoreSessionEvent[],
|
|
425
|
+
options: AgentCoreTraceOptions = {},
|
|
426
|
+
): AgentCoreTraceIssue[] {
|
|
427
|
+
const decisionKinds = new Set(options.decisionKinds ?? DEFAULTS.decisionKinds);
|
|
428
|
+
const issues: AgentCoreTraceIssue[] = [];
|
|
429
|
+
const seen = new Set<string>();
|
|
430
|
+
|
|
431
|
+
sortByTime(events).forEach((event, index) => {
|
|
432
|
+
assertWellFormed(event, index);
|
|
433
|
+
const at = { index, timeMs: event.timeMs };
|
|
434
|
+
|
|
435
|
+
if (decisionKinds.has(event.kind) && Object.keys(payloadGroups(event)).length === 0) {
|
|
436
|
+
issues.push({
|
|
437
|
+
...at,
|
|
438
|
+
kind: "no-request-context",
|
|
439
|
+
message: `${event.action}::${event.kind} carries no input, output, error or attributes, so its request_context envelope would be empty and every context.* test in a policy silently misses`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const key = `${event.sessionId}${event.eventId}`;
|
|
444
|
+
if (seen.has(key)) {
|
|
445
|
+
issues.push({
|
|
446
|
+
...at,
|
|
447
|
+
kind: "duplicate-event-id",
|
|
448
|
+
message: `session ${event.sessionId} reports eventId "${event.eventId}" twice, so requestId no longer identifies a decision point and an expectation written against one addresses both`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
seen.add(key);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return issues;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ── Rendering ────────────────────────────────────────────────────────────── */
|
|
458
|
+
|
|
459
|
+
/** One line's worth: the envelopes and the logged record, fully resolved. */
|
|
460
|
+
export interface AgentCoreTraceLine {
|
|
461
|
+
readonly timestamp: number;
|
|
462
|
+
readonly action: string;
|
|
463
|
+
readonly kind: string;
|
|
464
|
+
readonly scope: { readonly principal: string; readonly resource: string };
|
|
465
|
+
readonly requestContext: AgentCoreFields;
|
|
466
|
+
readonly record: AgentCoreFields;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Resolve one normalized event into the envelopes and the logged record.
|
|
471
|
+
*
|
|
472
|
+
* Both bags get every payload group; the record additionally gets the event
|
|
473
|
+
* schema's own injections (`callerPrincipal`, `callerResource`, `sessionId`,
|
|
474
|
+
* `requestId`), which are never part of the Cedar request. Field order matches
|
|
475
|
+
* the §6 example line: groups first, then the injections.
|
|
476
|
+
*/
|
|
477
|
+
export function toTraceLine(
|
|
478
|
+
event: AgentCoreSessionEvent,
|
|
479
|
+
timestamp: number,
|
|
480
|
+
options: AgentCoreTraceOptions = {},
|
|
481
|
+
index?: number,
|
|
482
|
+
): AgentCoreTraceLine {
|
|
483
|
+
const namespace = options.namespace ?? DEFAULTS.namespace;
|
|
484
|
+
const principal = qualifyUid(
|
|
485
|
+
event.actor,
|
|
486
|
+
namespace,
|
|
487
|
+
options.principalType ?? DEFAULTS.principalType,
|
|
488
|
+
"an actor",
|
|
489
|
+
index,
|
|
490
|
+
);
|
|
491
|
+
const resource = qualifyUid(
|
|
492
|
+
event.target,
|
|
493
|
+
namespace,
|
|
494
|
+
options.resourceType ?? DEFAULTS.resourceType,
|
|
495
|
+
"a target",
|
|
496
|
+
index,
|
|
497
|
+
);
|
|
498
|
+
const groups = payloadGroups(event);
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
timestamp,
|
|
502
|
+
action: qualifyAction(event.action, namespace, index),
|
|
503
|
+
kind: assertIdent(event.kind, "an event kind", index),
|
|
504
|
+
scope: { principal, resource },
|
|
505
|
+
requestContext: groups,
|
|
506
|
+
record: {
|
|
507
|
+
...groups,
|
|
508
|
+
callerPrincipal: agentCoreEntityRef(principal),
|
|
509
|
+
callerResource: agentCoreEntityRef(resource),
|
|
510
|
+
sessionId: event.sessionId,
|
|
511
|
+
requestId: event.eventId,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** One line as the trace parser reads it. Never ends in a newline. */
|
|
517
|
+
export function renderTraceLine(line: AgentCoreTraceLine): string {
|
|
518
|
+
if (!Number.isSafeInteger(line.timestamp)) {
|
|
519
|
+
throw new AgentCoreTraceError(`agentcore trace: a timepoint is an i64 — got ${String(line.timestamp)}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const parts = [`@${line.timestamp}`];
|
|
523
|
+
parts.push(`scope(principal: ${line.scope.principal}, resource: ${line.scope.resource})`);
|
|
524
|
+
|
|
525
|
+
const context = Object.entries(line.requestContext).map(
|
|
526
|
+
([group, value]) => `${assertIdent(group, "a request-context group")}: ${renderValue(value)}`,
|
|
527
|
+
);
|
|
528
|
+
if (context.length > 0) parts.push(`request_context(${context.join(", ")})`);
|
|
529
|
+
|
|
530
|
+
const record = Object.entries(line.record).map(
|
|
531
|
+
([key, value]) => `${assertIdent(key, "a trace field name")}: ${renderValue(value)}`,
|
|
532
|
+
);
|
|
533
|
+
parts.push(`${line.action}::${line.kind}(${record.join(", ")})`);
|
|
534
|
+
|
|
535
|
+
return parts.join(" ");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A rendered trace, and what the audit tolerated on the way. */
|
|
539
|
+
export interface AgentCoreTrace {
|
|
540
|
+
/** The text, one event per line, newline-terminated. */
|
|
541
|
+
readonly text: string;
|
|
542
|
+
/** The resolved lines, in render order. */
|
|
543
|
+
readonly lines: readonly AgentCoreTraceLine[];
|
|
544
|
+
/** Findings the caller allowed. Empty unless `allow` named something. */
|
|
545
|
+
readonly issues: readonly AgentCoreTraceIssue[];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Render a normalized AgentCore history as dogwood trace text.
|
|
550
|
+
*
|
|
551
|
+
* The inversion of the §6 traps: every payload group lands in both bags, every
|
|
552
|
+
* action comes out fully qualified, the history is ordered the way the
|
|
553
|
+
* interpreter reads it, and a history that would produce a weakened trace
|
|
554
|
+
* throws instead. Naming a kind in `allow` is how a caller says the weakening
|
|
555
|
+
* is the point — the alternative was a trace that replays green and proves
|
|
556
|
+
* less than it looks like it proves.
|
|
557
|
+
*/
|
|
558
|
+
export function renderAgentCoreTrace(
|
|
559
|
+
events: readonly AgentCoreSessionEvent[],
|
|
560
|
+
options: AgentCoreTraceOptions = {},
|
|
561
|
+
): AgentCoreTrace {
|
|
562
|
+
const sorted = sortByTime(events);
|
|
563
|
+
// The audit runs `assertWellFormed` over the same sorted list, so a
|
|
564
|
+
// structurally broken history throws here before anything is rendered.
|
|
565
|
+
const issues = auditAgentCoreEvents(sorted, options);
|
|
566
|
+
const allow = new Set(options.allow ?? []);
|
|
567
|
+
const blocking = issues.filter((issue) => !allow.has(issue.kind));
|
|
568
|
+
if (blocking.length > 0) {
|
|
569
|
+
const detail = blocking.map((i) => ` [${i.kind}] event ${i.index}: ${i.message}`).join("\n");
|
|
570
|
+
const kinds = [...new Set(blocking.map((i) => `"${i.kind}"`))].join(", ");
|
|
571
|
+
throw new AgentCoreTraceError(
|
|
572
|
+
`agentcore trace: this history would weaken its own replay rather than fail it:\n${detail}\n` +
|
|
573
|
+
`Fix the history, or pass { allow: [${kinds}] } to say the weakening is the point.`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const origin = options.origin ?? DEFAULTS.origin;
|
|
578
|
+
const base = origin === "relative-seconds" && sorted.length > 0 ? (sorted[0]?.timeMs ?? 0) : 0;
|
|
579
|
+
const lines = sorted.map((event, index) =>
|
|
580
|
+
toTraceLine(event, Math.floor((event.timeMs - base) / 1000), options, index),
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// An empty history renders as empty text, not as a bare newline: dogwood
|
|
584
|
+
// skips blank lines, so the two replay identically, and "" is the honest
|
|
585
|
+
// answer to "what did this session do". Whether an empty trace is worth
|
|
586
|
+
// replaying is the caller's call — see `trace-fetch.ts`'s `requireEvents`.
|
|
587
|
+
const text = lines.length > 0 ? lines.map(renderTraceLine).join("\n") + "\n" : "";
|
|
588
|
+
return { text, lines, issues };
|
|
589
|
+
}
|
package/src/api/read-client.ts
CHANGED
|
@@ -135,8 +135,14 @@ function regionScope(
|
|
|
135
135
|
* string has a slot for one; it borrows the same `us-east-1` default that
|
|
136
136
|
* {@link serviceUrl} already used to build the host, so the signature agrees
|
|
137
137
|
* with the endpoint it is sent to.
|
|
138
|
+
*
|
|
139
|
+
* Exported because this decision — sign, or carry the scope and no signature —
|
|
140
|
+
* belongs to the lexicon's read transport rather than to any one API on it.
|
|
141
|
+
* `agentcore/trace-fetch.ts` reads `bedrock-agentcore` through the same seam,
|
|
142
|
+
* and a second copy of this would be a second place for the emulator carve-out
|
|
143
|
+
* to drift.
|
|
138
144
|
*/
|
|
139
|
-
function requestHeaders(
|
|
145
|
+
export function requestHeaders(
|
|
140
146
|
service: string,
|
|
141
147
|
url: string,
|
|
142
148
|
body: string,
|
|
@@ -74,9 +74,51 @@ describe("observeByIdentity (#1647)", () => {
|
|
|
74
74
|
expect(queried.assets).toBeDefined();
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test("an emulator without Cloud Control keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
|
|
77
|
+
test("an emulator without Cloud Control at all keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
|
|
78
78
|
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
79
|
-
http: async () => ccError("UnsupportedOperation", "
|
|
79
|
+
http: async () => ccError("UnsupportedOperation", "not supported"),
|
|
80
|
+
});
|
|
81
|
+
expect(resources.assets).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Floci serves ListResources but answers UnsupportedOperation for
|
|
85
|
+
// GetResource (read-client's own note) — and the emulator is where the carve
|
|
86
|
+
// walkthrough films the observe beat. Verified against Floci 1.5.34 on the
|
|
87
|
+
// behold demo: the list leg is what turns the miss into a read.
|
|
88
|
+
test("GetResource-unsupported falls back to ListResources and matches the identifier (Floci)", async () => {
|
|
89
|
+
const targets: string[] = [];
|
|
90
|
+
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
91
|
+
http: async (_url, init) => {
|
|
92
|
+
const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
|
|
93
|
+
targets.push(target);
|
|
94
|
+
if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "Operation GetResource is not supported.");
|
|
95
|
+
return {
|
|
96
|
+
status: 200,
|
|
97
|
+
text: JSON.stringify({
|
|
98
|
+
ResourceDescriptions: [
|
|
99
|
+
{ Identifier: "some-other-bucket", Properties: JSON.stringify({ BucketName: "some-other-bucket" }) },
|
|
100
|
+
{ Identifier: "acme-platform-assets-prod", Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }) },
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
expect(targets.some((t) => t.endsWith("ListResources"))).toBe(true);
|
|
107
|
+
expect(resources.assets).toMatchObject({
|
|
108
|
+
type: "AWS::S3::Bucket",
|
|
109
|
+
physicalId: "acme-platform-assets-prod",
|
|
110
|
+
status: "EXTERNAL",
|
|
111
|
+
ownership: "foreign",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the list leg missing the identifier keeps the absent verdict", async () => {
|
|
116
|
+
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
117
|
+
http: async (_url, init) => {
|
|
118
|
+
const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
|
|
119
|
+
if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "not supported");
|
|
120
|
+
return { status: 200, text: JSON.stringify({ ResourceDescriptions: [] }) };
|
|
121
|
+
},
|
|
80
122
|
});
|
|
81
123
|
expect(resources.assets).toBeUndefined();
|
|
82
124
|
});
|