@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,327 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-cedar-dogwood
|
|
3
|
+
description: Author dogwood temporal policies with the cedar lexicon's typed builders — the parser primitives, the macro caveat, the validation split, AgentCore embedding, and replay
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Temporal Policies with the Dogwood Dialect
|
|
8
|
+
|
|
9
|
+
## Read this part first
|
|
10
|
+
|
|
11
|
+
Dogwood is pre-release, and the framing matters more than usual because it
|
|
12
|
+
changes what you should write.
|
|
13
|
+
|
|
14
|
+
Upstream (`dogwood-policy/dogwood`, Apache-2.0) says on its own README, in
|
|
15
|
+
bold, that it is **not intended for production use**. It is a reference
|
|
16
|
+
interpreter with enumerated gaps: no event timestamp validation, no event
|
|
17
|
+
authentication, no trace durability, unsandboxed Rhai in providers, no audit
|
|
18
|
+
logging, no multi-tenancy isolation, and an `http_get` provider with no SSRF
|
|
19
|
+
protection.
|
|
20
|
+
|
|
21
|
+
It also has no versioning story at all. Zero tags, zero releases, no changelog.
|
|
22
|
+
The crate reports `1.0.0` because it is published to an Amazon-internal
|
|
23
|
+
registry, and every future sync will also report `1.0.0`. The repository is a
|
|
24
|
+
read-only mirror: content arrives as squashed "Sync from internal source"
|
|
25
|
+
commits from a publish bot, against a repository nobody outside Amazon can see,
|
|
26
|
+
at a cadence of roughly one sync every three days over its public life.
|
|
27
|
+
|
|
28
|
+
chant pins a git SHA plus the blob hashes of seven files in
|
|
29
|
+
`src/dogwood/upstream.ts`. Do not tell a user this surface is stable. Do tell
|
|
30
|
+
them what is pinned and what a sync can move.
|
|
31
|
+
|
|
32
|
+
## The one distinction to get right
|
|
33
|
+
|
|
34
|
+
**`formerly`, `previous`, `since`, `exists`, `tp()`, `count for … where` and
|
|
35
|
+
`sum … for … where` are the parser primitives.** They are the entire temporal
|
|
36
|
+
keyword set in upstream's grammar, and they are what the typed builders target.
|
|
37
|
+
|
|
38
|
+
**`count_within`, `sum_within`, `count_distinct_within` and `bind` are macros**
|
|
39
|
+
in a swappable default library (`configuration/default_macros.dw`). A caller
|
|
40
|
+
who passes `--macros` replaces that library wholesale, so a policy built on
|
|
41
|
+
them is built on an assumption about the far end.
|
|
42
|
+
|
|
43
|
+
**`once` does not ship at all.** It appears in upstream's examples as an
|
|
44
|
+
ordinary user-defined macro and is in no library. (The grammar rule behind
|
|
45
|
+
`formerly` is internally named `once_op`, which is where the confusion comes
|
|
46
|
+
from.) If a user asks for `once`, define it as a macro or use `formerly`
|
|
47
|
+
directly — do not emit a bare `once(…)` call and hope.
|
|
48
|
+
|
|
49
|
+
So: the four aggregates are expressible as **calls**
|
|
50
|
+
(`dogwood.countWithin(…)`), never as operators. A missing macro at the far end
|
|
51
|
+
is a comprehensible error. A builder that emits a name the callee's library
|
|
52
|
+
does not define is a mystery.
|
|
53
|
+
|
|
54
|
+
The way to stop assuming is to ship the definitions:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { TemporalMacroLibrary, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
58
|
+
|
|
59
|
+
// Upstream's four definitions, verbatim, into the project's own macros.dw.
|
|
60
|
+
export const macros = new TemporalMacroLibrary({ macros: dogwood.defaultMacroLibrary() });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
With `inline: true` they go at the top of `policies.dw` instead, and a policy
|
|
64
|
+
set's own `def` shadows a same-named library macro — the strongest form,
|
|
65
|
+
because the definitions travel with the policies.
|
|
66
|
+
|
|
67
|
+
## Authoring
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { TemporalPolicy, TemporalEventSchema, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
71
|
+
|
|
72
|
+
export const events = new TemporalEventSchema({ schema: dogwood.defaultEventSchema() });
|
|
73
|
+
|
|
74
|
+
export const readAfterLogin = new TemporalPolicy({
|
|
75
|
+
annotations: { id: "read_after_login" },
|
|
76
|
+
action: { eq: 'Drupe::Action::"Read"' },
|
|
77
|
+
whenTemporal: [
|
|
78
|
+
dogwood.formerly(
|
|
79
|
+
"1h",
|
|
80
|
+
dogwood.predicate('Drupe::Action::"Login"', "response", {
|
|
81
|
+
"input.user": dogwood.ctx("input.user"),
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Emits:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
@id("read_after_login")
|
|
92
|
+
permit (
|
|
93
|
+
principal,
|
|
94
|
+
action == Drupe::Action::"Read",
|
|
95
|
+
resource
|
|
96
|
+
)
|
|
97
|
+
when temporal {
|
|
98
|
+
formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Clause forms
|
|
103
|
+
|
|
104
|
+
| Prop | Emits |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `when` / `unless` | `when { … }` — Cedar expression strings, same as `Cedar::Policy` |
|
|
107
|
+
| `whenGuardrails` / `unlessGuardrails` | `when guardrails { … }` — a Cedar expression with a tag upstream discards when lowering |
|
|
108
|
+
| `whenTemporal` / `unlessTemporal` | `when temporal { … }` — the temporal sub-language |
|
|
109
|
+
|
|
110
|
+
### Windows
|
|
111
|
+
|
|
112
|
+
`formerly`, `previous` and `since` all take a mandatory window: an integer and
|
|
113
|
+
one of `s`, `m`, `h`, `d`. Nothing else parses. The builders take it as an
|
|
114
|
+
argument, so a windowless operator is unrepresentable — do not reach for
|
|
115
|
+
`dogwood.raw()` to work around a window you do not want, because there is no
|
|
116
|
+
such form.
|
|
117
|
+
|
|
118
|
+
Every operator is past-only. There is no future operator and no way to write
|
|
119
|
+
one. A user asking for "deny if X happens later" needs a different design.
|
|
120
|
+
|
|
121
|
+
### Terms
|
|
122
|
+
|
|
123
|
+
Numbers and booleans lift. A bare string does not, on purpose: `"alice"` is a
|
|
124
|
+
Cedar string literal and `alice` is a binder reference, and guessing is how a
|
|
125
|
+
policy silently stops matching. Say which:
|
|
126
|
+
|
|
127
|
+
| Builder | Renders |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `dogwood.str("alice")` | `"alice"` |
|
|
130
|
+
| `dogwood.varRef("a")` | `a` |
|
|
131
|
+
| `dogwood.ctx("input.user")` | `context.input.user` |
|
|
132
|
+
| `dogwood.scopeRef("principal", "dept")` | `principal.dept` |
|
|
133
|
+
| `dogwood.entityUid('Drupe::OAuthUser::"alice"')` | `Drupe::OAuthUser::"alice"` |
|
|
134
|
+
|
|
135
|
+
### An aggregate, from the primitive
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
export const rateLimited = new TemporalPolicy({
|
|
139
|
+
annotations: { id: "rate_limited" },
|
|
140
|
+
action: { eq: 'Drupe::Action::"Transfer"' },
|
|
141
|
+
whenTemporal: [
|
|
142
|
+
dogwood.compare(
|
|
143
|
+
dogwood.count(
|
|
144
|
+
[dogwood.typedBinder("t", "Timepoint")],
|
|
145
|
+
dogwood.formerly(
|
|
146
|
+
"15m",
|
|
147
|
+
dogwood.and(dogwood.predicate('Drupe::Action::"Transfer"', "request"), dogwood.tp("t")),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
"<",
|
|
151
|
+
5,
|
|
152
|
+
),
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
That is what `count_within` expands to. Writing it directly costs four lines
|
|
158
|
+
and depends on nothing at the far end.
|
|
159
|
+
|
|
160
|
+
## The event schema, and the pin
|
|
161
|
+
|
|
162
|
+
`.dwschema` is the optional service half of the schema; the required half is
|
|
163
|
+
the Cedar `.cedarschema` the rest of the lexicon already generates from. They
|
|
164
|
+
are two halves of one schema, not competing formats.
|
|
165
|
+
|
|
166
|
+
The default event schema pins `callerPrincipal = principal` on every kind, so
|
|
167
|
+
every temporal predicate is correlated to the deciding request's principal and
|
|
168
|
+
other principals' events are invisible.
|
|
169
|
+
|
|
170
|
+
**Supplying any event schema opts out of upstream's default wholesale.** A
|
|
171
|
+
schema emitted without a pin does not merely fail to add a correlation — it
|
|
172
|
+
removes one the author probably assumed, and every predicate in the set starts
|
|
173
|
+
matching across principals. `dogwood.defaultEventSchema()` carries the pin;
|
|
174
|
+
dropping it is a named argument that stamps a comment into the emitted file:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
dogwood.defaultEventSchema({ pinCallerPrincipal: false });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
DWDS010 warns about an unpinned emitted schema. That is report-only — chant
|
|
181
|
+
does not know which the author wanted, only that the choice should be visible
|
|
182
|
+
in a diff.
|
|
183
|
+
|
|
184
|
+
`max_window` caps look-back for the whole set; absent, it is 24h — the same 24h
|
|
185
|
+
that applies when no schema is supplied at all.
|
|
186
|
+
|
|
187
|
+
## Deploying to AgentCore
|
|
188
|
+
|
|
189
|
+
`AWS::BedrockAgentCore::Policy` is the vehicle. Its `Definition` is a two-arm
|
|
190
|
+
`oneOf` — `Cedar.Statement` for plain Cedar, `Policy.Statement` for anything
|
|
191
|
+
else — and a `.dw` policy travels in the second arm.
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import { agentCoreStagedPolicy } from "@intentius/chant-lexicon-cedar";
|
|
195
|
+
|
|
196
|
+
new BedrockAgentCorePolicy({
|
|
197
|
+
PolicyEngineId: engine.ref(),
|
|
198
|
+
...agentCoreStagedPolicy("writeNeedsApproval", writeNeedsApproval, "log-only"),
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`agentCorePolicyDefinition(name, policy)` picks the arm from the policy itself.
|
|
203
|
+
Templates are refused — AgentCore's union has no template-linked arm, so a
|
|
204
|
+
statement carrying `?principal` or `?resource` throws at authoring time rather
|
|
205
|
+
than failing at deploy.
|
|
206
|
+
|
|
207
|
+
`EnforcementMode` stages the rollout: `"log-only"` is evaluated on every request
|
|
208
|
+
with its decision observed rather than returned, `"enforce"` binds. Start a
|
|
209
|
+
temporal policy log-only — whether it fires depends on traffic nobody has
|
|
210
|
+
replayed. Promotion is a one-token diff.
|
|
211
|
+
|
|
212
|
+
The resource carries a statement and nothing else, so the event schema is
|
|
213
|
+
registered with the engine separately; DWDC013 warns when temporal text is
|
|
214
|
+
embedded and no `.dwschema` was emitted beside it.
|
|
215
|
+
|
|
216
|
+
## The validation split
|
|
217
|
+
|
|
218
|
+
Explain this split when a user asks why something did or did not fail.
|
|
219
|
+
|
|
220
|
+
Always, gating, no binary anywhere:
|
|
221
|
+
|
|
222
|
+
| Check | Catches |
|
|
223
|
+
|---|---|
|
|
224
|
+
| DWDC010 | A temporal predicate naming an event kind the emitted `.dwschema` never declares |
|
|
225
|
+
| DWDC011 | A window past `max_window` (or upstream's 24h default) — macro-call intervals count |
|
|
226
|
+
| DWDC012 | A `formerly`/`previous`/`since` with no `within` window |
|
|
227
|
+
| DWDC013 | An AgentCore-embedded temporal statement with no `.dwschema` emitted beside it (warning) |
|
|
228
|
+
| DWDS010 | An emitted event schema that pins nothing (warning) |
|
|
229
|
+
|
|
230
|
+
Only when the `dogwood` binary is present:
|
|
231
|
+
|
|
232
|
+
| Check | Catches |
|
|
233
|
+
|---|---|
|
|
234
|
+
| DWDE010 | Everything `dogwood validate` catches — macro expansion, the temporal type check, the Cedar body against the action schema |
|
|
235
|
+
| DWDE011 | The `dogwood lower` output failing Cedar's own validator via `@cedar-policy/cedar-wasm` |
|
|
236
|
+
|
|
237
|
+
Binary resolution order: an explicit `configureDogwoodCli({ binary })`, then
|
|
238
|
+
`$CHANT_DOGWOOD_BINARY`, then `cedar.dogwood.binary` in a `chant.config.json`,
|
|
239
|
+
then `dogwood` on `PATH`. There is no published build — it is `cargo build
|
|
240
|
+
--release` from the pinned revision.
|
|
241
|
+
|
|
242
|
+
When it is absent, DWDE010 emits exactly one `info` finding naming the binary,
|
|
243
|
+
where chant looked, and the issue. It never silently passes: a check that
|
|
244
|
+
quietly succeeds when it could not run is claiming a guarantee it did not make.
|
|
245
|
+
|
|
246
|
+
Nothing in gating CI runs the binary, by design.
|
|
247
|
+
|
|
248
|
+
### Do not read exit codes
|
|
249
|
+
|
|
250
|
+
If you write anything that shells to the CLI: exit 2 means both "policy set
|
|
251
|
+
rejected" and clap's usage error for an unknown flag, and upstream's own guide
|
|
252
|
+
claims 1 for the latter. The JSON on stdout decides, in both directions. There
|
|
253
|
+
are two JSON shapes — a validate report (`passed`, `errors[]`, `warnings[]`)
|
|
254
|
+
and a bare fatal error object with the same diagnostic fields flattened plus
|
|
255
|
+
`related[]`. A run that produced neither is "could not be used", never "your
|
|
256
|
+
policy is bad".
|
|
257
|
+
|
|
258
|
+
## Replay
|
|
259
|
+
|
|
260
|
+
A temporal policy is not decidable from its source — whether `formerly within
|
|
261
|
+
1h …` fires depends on history nobody has replayed — so the check is a replay
|
|
262
|
+
against recorded traffic. That ships as `PolicyReplayOp`:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import { PolicyReplayOp } from "@intentius/chant-lexicon-cedar";
|
|
266
|
+
|
|
267
|
+
export const { op } = PolicyReplayOp({
|
|
268
|
+
name: "policy-replay",
|
|
269
|
+
policiesPath: "dist/policies.dw",
|
|
270
|
+
policySchemaPath: "schema.cedarschema",
|
|
271
|
+
eventSchemaPath: "dist/events.dwschema",
|
|
272
|
+
tracePath: "trace/read-after-login.log",
|
|
273
|
+
expect: [
|
|
274
|
+
{ timestamp: 10, verdict: "allow", determiningRules: [0] },
|
|
275
|
+
{ timestamp: 7200, verdict: "deny", note: "the login is two hours stale" },
|
|
276
|
+
],
|
|
277
|
+
onFinding: "report",
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Three phases — Artifacts (`chantBuild`, skippable with `buildScript: false`),
|
|
282
|
+
Replay (`dogwoodReplay`, writes `dist/dogwood-replay.json`), Report
|
|
283
|
+
(`dogwoodReplayReport`, acts on `report | issue | pull-request`).
|
|
284
|
+
`failOnDivergence` defaults to false: an observe-dial Op reports. The composite
|
|
285
|
+
ships from cedar and carries no dependency on the temporal lexicon; a scheduled
|
|
286
|
+
form is a project-side `TemporalSchedule` pairing.
|
|
287
|
+
|
|
288
|
+
Build traces with `dogwood.traceEvent()` rather than by hand. Two traps it
|
|
289
|
+
exists to close, and both are worth naming whenever a user assembles a fixture:
|
|
290
|
+
|
|
291
|
+
1. **Both bags.** A trace line carries a `request_context(...)` envelope, which
|
|
292
|
+
the Cedar request is built from, and a trailing logged record, which temporal
|
|
293
|
+
predicates match against. A field both halves need must be in both.
|
|
294
|
+
`traceEvent()` writes a `context` group into both by default; getting the
|
|
295
|
+
weaker trace takes an explicit `bags: "record-only"` / `"context-only"`.
|
|
296
|
+
Populate one bag only and nothing errors — the other check silently weakens.
|
|
297
|
+
2. **Fully-qualified action names.** `Drupe::Action::"Transfer"`, never
|
|
298
|
+
`Transfer`. A bare name still authorizes through Cedar while every temporal
|
|
299
|
+
predicate quietly fails to match. `traceEvent()` rejects one at construction.
|
|
300
|
+
|
|
301
|
+
`dogwood.auditTrace(events)` applies the same checks to a trace chant did not
|
|
302
|
+
build (`single-bag`, `no-request-context`, `empty-record`, `out-of-order`), and
|
|
303
|
+
`dogwood.traceFixture(events)` throws rather than handing back a weakened one.
|
|
304
|
+
|
|
305
|
+
Write expectations against `timestamp`, not `index`: `index` is the position in
|
|
306
|
+
the decision stream, and a history-only event (any kind the schema does not
|
|
307
|
+
mark `decision`) contributes history and no verdict, so the two do not line up.
|
|
308
|
+
`determiningRules` catches a decision that is right for the wrong reason.
|
|
309
|
+
|
|
310
|
+
Replay exits 0 even when every verdict is DENY, so the activity reads the JSON
|
|
311
|
+
rather than the exit status — and a run that could not happen throws instead of
|
|
312
|
+
reporting zero divergences.
|
|
313
|
+
|
|
314
|
+
## What chant does not do
|
|
315
|
+
|
|
316
|
+
- **No lowering.** `dogwood lower` owns that; a reimplementation would drift on
|
|
317
|
+
the next sync. Where the lowered form is wanted, chant shells to the binary.
|
|
318
|
+
- **No request-time evaluation.** The policy engine in front of the traffic
|
|
319
|
+
decides; chant has no seat there. The offline half is `PolicyReplayOp`, which
|
|
320
|
+
replays recorded history through upstream's interpreter.
|
|
321
|
+
- **No gate.** Dogwood is a target, the same as Cedar. Organizational policy in
|
|
322
|
+
chant is TypeScript post-synth checks.
|
|
323
|
+
|
|
324
|
+
## Reference
|
|
325
|
+
|
|
326
|
+
Full pages: `/chant/lexicons/cedar/dogwood/`, and from there temporal policies,
|
|
327
|
+
event schemas, validation and replay.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-cedar",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.12",
|
|
4
4
|
"description": "Cedar lexicon for chant — typed authoring for Cedar authorization policies",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
"types": "./dist/lint/post-synth/index.d.ts",
|
|
46
46
|
"default": "./src/lint/post-synth/index.ts"
|
|
47
47
|
},
|
|
48
|
+
"./op/activities": {
|
|
49
|
+
"development": "./src/op/activities/index.ts",
|
|
50
|
+
"types": "./dist/op/activities/index.d.ts",
|
|
51
|
+
"default": "./src/op/activities/index.ts"
|
|
52
|
+
},
|
|
48
53
|
"./manifest": "./dist/manifest.json",
|
|
49
54
|
"./meta": "./dist/meta.json",
|
|
50
55
|
"./types": "./dist/types/index.d.ts"
|
|
@@ -69,7 +74,7 @@
|
|
|
69
74
|
},
|
|
70
75
|
"peerDependencies": {
|
|
71
76
|
"zod": "^4.3.6",
|
|
72
|
-
"@intentius/chant": "^0.44.
|
|
77
|
+
"@intentius/chant": "^0.44.12",
|
|
73
78
|
"typescript": "^5.9.3"
|
|
74
79
|
}
|
|
75
80
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { checkParsePolicySet } from "@cedar-policy/cedar-wasm/nodejs";
|
|
3
|
+
import { build } from "@intentius/chant/build";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
AGENTCORE_STATEMENT_MAX,
|
|
8
|
+
agentCorePolicyDefinition,
|
|
9
|
+
agentCorePolicyName,
|
|
10
|
+
agentCorePolicyResource,
|
|
11
|
+
agentCorePolicySet,
|
|
12
|
+
agentCoreStagedPolicy,
|
|
13
|
+
agentCoreStatement,
|
|
14
|
+
} from "./embed";
|
|
15
|
+
import { cedarSerializer, type CedarPolicyProps } from "../serializer";
|
|
16
|
+
import { DOGWOOD_POLICY_FILENAME, TemporalPolicy, type TemporalPolicyProps } from "../dogwood/policy";
|
|
17
|
+
import { Document, Policy } from "../generated/index";
|
|
18
|
+
import { ctx, formerly, predicate } from "../dogwood/temporal";
|
|
19
|
+
|
|
20
|
+
const exampleDir = join(dirname(fileURLToPath(import.meta.url)), "../../examples/agentcore-policy/src");
|
|
21
|
+
|
|
22
|
+
const denyWrite: CedarPolicyProps = {
|
|
23
|
+
effect: "forbid",
|
|
24
|
+
principal: { is: "App::ServiceAccount" },
|
|
25
|
+
action: { eq: 'App::Action::"write"' },
|
|
26
|
+
resource: { is: "App::Document" },
|
|
27
|
+
unless: ["context.authenticated == true"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const needsApproval: TemporalPolicyProps = {
|
|
31
|
+
effect: "permit",
|
|
32
|
+
principal: { is: "App::ServiceAccount" },
|
|
33
|
+
action: { eq: 'App::Action::"write"' },
|
|
34
|
+
resource: { is: "App::Document" },
|
|
35
|
+
whenTemporal: [
|
|
36
|
+
formerly("1h", predicate('App::Action::"approve"', "response", { "input.document": ctx("input.document") })),
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function buildExample() {
|
|
41
|
+
const result = await build(exampleDir, [cedarSerializer]);
|
|
42
|
+
expect(result.errors).toEqual([]);
|
|
43
|
+
const output = result.outputs.get("cedar");
|
|
44
|
+
if (typeof output === "string" || output === undefined) throw new Error("expected a multi-file result");
|
|
45
|
+
return { result, cedarText: output.primary, dwText: output.files?.[DOGWOOD_POLICY_FILENAME] ?? "" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── The statement ─────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("the AgentCore statement", () => {
|
|
51
|
+
it("is Cedar the real parser accepts, for the plain-Cedar arm", () => {
|
|
52
|
+
const statement = agentCoreStatement("denyWrite", denyWrite);
|
|
53
|
+
expect(checkParsePolicySet({ staticPolicies: statement }).type).toBe("success");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("carries the @id the live policy is matched back on", () => {
|
|
57
|
+
expect(agentCoreStatement("denyWrite", denyWrite)).toContain('@id("deny-write")');
|
|
58
|
+
expect(agentCoreStatement("denyWrite", denyWrite, { policyId: "override" })).toContain('@id("override")');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders a temporal policy as .dw text", () => {
|
|
62
|
+
const statement = agentCoreStatement("needsApproval", needsApproval);
|
|
63
|
+
expect(statement).toContain("when temporal {");
|
|
64
|
+
expect(statement).toContain('formerly within 1h App::Action::"approve"::response{');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is byte-identical to what the serializer writes to .cedar", async () => {
|
|
68
|
+
const { result, cedarText } = await buildExample();
|
|
69
|
+
const set = agentCorePolicySet(result.entities);
|
|
70
|
+
|
|
71
|
+
const cedarArm = Object.values(set).filter((d) => d.Cedar !== undefined);
|
|
72
|
+
expect(cedarArm.length).toBeGreaterThan(0);
|
|
73
|
+
for (const definition of cedarArm) {
|
|
74
|
+
expect(cedarText).toContain(definition.Cedar!.Statement);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("is byte-identical to what the serializer writes to policies.dw", async () => {
|
|
79
|
+
const { result, dwText } = await buildExample();
|
|
80
|
+
const set = agentCorePolicySet(result.entities);
|
|
81
|
+
|
|
82
|
+
const policyArm = Object.values(set).filter((d) => d.Policy !== undefined);
|
|
83
|
+
expect(policyArm.length).toBe(2);
|
|
84
|
+
for (const definition of policyArm) {
|
|
85
|
+
expect(dwText).toContain(definition.Policy!.Statement);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("refuses a template — AgentCore statements are static", () => {
|
|
90
|
+
expect(() =>
|
|
91
|
+
agentCoreStatement("ownerOnly", {
|
|
92
|
+
effect: "permit",
|
|
93
|
+
principal: { eq: "?principal" },
|
|
94
|
+
action: { eq: 'App::Action::"read"' },
|
|
95
|
+
resource: { is: "App::Document" },
|
|
96
|
+
}),
|
|
97
|
+
).toThrow(/carries a Cedar template slot/);
|
|
98
|
+
|
|
99
|
+
expect(() =>
|
|
100
|
+
agentCoreStatement("resourceSlot", {
|
|
101
|
+
effect: "permit",
|
|
102
|
+
principal: { is: "App::User" },
|
|
103
|
+
action: { eq: 'App::Action::"read"' },
|
|
104
|
+
resource: { eq: "?resource" },
|
|
105
|
+
}),
|
|
106
|
+
).toThrow(/no template-linked arm/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("names the declaration when there is nothing to embed", () => {
|
|
110
|
+
expect(() => agentCoreStatement("empty", new Map())).toThrow(/rendered no policy text/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("refuses a statement past AgentCore's own length cap", () => {
|
|
114
|
+
const long = { ...denyWrite, unless: ["context.authenticated == true".padEnd(AGENTCORE_STATEMENT_MAX, " ")] };
|
|
115
|
+
expect(() => agentCoreStatement("tooLong", long)).toThrow(/maximum is 10000/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── The Definition union ──────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe("the AgentCore Definition union", () => {
|
|
122
|
+
it("puts plain Cedar in the Cedar arm and nothing else", () => {
|
|
123
|
+
const definition = agentCorePolicyDefinition("denyWrite", denyWrite);
|
|
124
|
+
expect(Object.keys(definition)).toEqual(["Cedar"]);
|
|
125
|
+
expect(typeof definition.Cedar!.Statement).toBe("string");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("puts temporal text in the language-agnostic Policy arm and nothing else", () => {
|
|
129
|
+
const definition = agentCorePolicyDefinition("needsApproval", needsApproval);
|
|
130
|
+
expect(Object.keys(definition)).toEqual(["Policy"]);
|
|
131
|
+
expect(definition.Policy!.Statement).toContain("when temporal {");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reads the entity type off a declared TemporalPolicy", () => {
|
|
135
|
+
const definition = agentCorePolicyDefinition("needsApproval", new TemporalPolicy(needsApproval));
|
|
136
|
+
expect(Object.keys(definition)).toEqual(["Policy"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reads the entity type off a declared Cedar Policy", () => {
|
|
140
|
+
// Built against the generated class rather than spread from `denyWrite`:
|
|
141
|
+
// `PolicyProps` narrows every scope to the schema's own entity and action
|
|
142
|
+
// names, which `CedarPolicyProps` leaves as strings.
|
|
143
|
+
const declared = new Policy({
|
|
144
|
+
effect: "forbid",
|
|
145
|
+
principal: { is: "App::ServiceAccount" },
|
|
146
|
+
action: { eq: 'App::Action::"write"' },
|
|
147
|
+
resource: { is: "App::Document" },
|
|
148
|
+
unless: ["context.authenticated == true"],
|
|
149
|
+
});
|
|
150
|
+
const definition = agentCorePolicyDefinition("denyWrite", declared);
|
|
151
|
+
expect(Object.keys(definition)).toEqual(["Cedar"]);
|
|
152
|
+
expect(definition.Cedar!.Statement).toBe(agentCoreStatement("denyWrite", denyWrite));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("refuses a declared entity that is not a policy at all", () => {
|
|
156
|
+
const document = new Document({ owner: 'App::User::"alice"' } as never);
|
|
157
|
+
expect(() => agentCorePolicyDefinition("doc", document)).toThrow(/which is not a policy/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("lets plain Cedar be forced into the Policy arm, because dogwood embeds Cedar", () => {
|
|
161
|
+
const definition = agentCorePolicyDefinition("denyWrite", denyWrite, { language: "dogwood" });
|
|
162
|
+
expect(Object.keys(definition)).toEqual(["Policy"]);
|
|
163
|
+
expect(definition.Policy!.Statement).toContain("forbid");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("refuses to force temporal text into the Cedar arm", () => {
|
|
167
|
+
expect(() => agentCorePolicyDefinition("needsApproval", needsApproval, { language: "cedar" })).toThrow(
|
|
168
|
+
/has no `when temporal` rule/,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("takes a whole policy set and lands it in the Policy arm when any policy is temporal", async () => {
|
|
173
|
+
const { result, cedarText, dwText } = await buildExample();
|
|
174
|
+
const definition = agentCorePolicyDefinition("gateway", result.entities);
|
|
175
|
+
|
|
176
|
+
expect(Object.keys(definition)).toEqual(["Policy"]);
|
|
177
|
+
// Cedar first, then temporal, both verbatim from the emitted artifacts.
|
|
178
|
+
expect(definition.Policy!.Statement).toContain(cedarText.trimEnd());
|
|
179
|
+
for (const line of ['@id("write-needs-approval")', '@id("session-spend-budget")']) {
|
|
180
|
+
expect(definition.Policy!.Statement).toContain(line);
|
|
181
|
+
expect(dwText).toContain(line);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── The resource props ────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe("the resource form", () => {
|
|
189
|
+
it("fills every required prop of the generated class", () => {
|
|
190
|
+
const resource = agentCorePolicyResource("denyWrite", denyWrite, "GatewayEngine-abcdefghij");
|
|
191
|
+
expect(resource.PolicyEngineId).toBe("GatewayEngine-abcdefghij");
|
|
192
|
+
expect(resource.Name).toBe("denyWrite");
|
|
193
|
+
expect(resource.Definition.Cedar!.Statement).toContain("forbid");
|
|
194
|
+
expect(resource.EnforcementMode).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("carries the stage and the description when asked", () => {
|
|
198
|
+
const resource = agentCorePolicyResource("denyWrite", denyWrite, "engine", {
|
|
199
|
+
stage: "log-only",
|
|
200
|
+
description: "watch it first",
|
|
201
|
+
});
|
|
202
|
+
expect(resource.EnforcementMode).toBe("LOG_ONLY");
|
|
203
|
+
expect(resource.Description).toBe("watch it first");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("checks Name against AgentCore's create-only pattern instead of coercing it", () => {
|
|
207
|
+
expect(agentCorePolicyName("writeNeedsApproval")).toBe("writeNeedsApproval");
|
|
208
|
+
expect(() => agentCorePolicyName("write-needs-approval")).toThrow(/not a legal/);
|
|
209
|
+
expect(() => agentCorePolicyName("9lives")).toThrow(/not a legal/);
|
|
210
|
+
expect(() => agentCorePolicyName("a".repeat(49))).toThrow(/maximum is 48/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── Staging ───────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe("the staged form", () => {
|
|
217
|
+
it("is the three props the generated class needs beside PolicyEngineId", () => {
|
|
218
|
+
const staged = agentCoreStagedPolicy("needsApproval", needsApproval, "log-only");
|
|
219
|
+
expect(staged.Name).toBe("needsApproval");
|
|
220
|
+
expect(staged.EnforcementMode).toBe("LOG_ONLY");
|
|
221
|
+
expect(staged.Definition.Policy!.Statement).toContain("when temporal {");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("promotes on one token", () => {
|
|
225
|
+
const observed = agentCoreStagedPolicy("needsApproval", needsApproval, "log-only");
|
|
226
|
+
const enforced = agentCoreStagedPolicy("needsApproval", needsApproval, "enforce");
|
|
227
|
+
expect(enforced.EnforcementMode).toBe("ACTIVE");
|
|
228
|
+
expect(enforced.Definition).toEqual(observed.Definition);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── The whole-set form ────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe("the whole-set form", () => {
|
|
235
|
+
it("keys one definition per policy by chant entity name", async () => {
|
|
236
|
+
const { result } = await buildExample();
|
|
237
|
+
const set = agentCorePolicySet(result.entities);
|
|
238
|
+
expect(Object.keys(set).sort()).toEqual([
|
|
239
|
+
"denyUnauthenticatedWrite",
|
|
240
|
+
"sessionSpendBudget",
|
|
241
|
+
"writeNeedsApproval",
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps each policy on its own arm, so EnforcementMode stays per policy", async () => {
|
|
246
|
+
const { result } = await buildExample();
|
|
247
|
+
const set = agentCorePolicySet(result.entities);
|
|
248
|
+
expect(Object.keys(set.denyUnauthenticatedWrite)).toEqual(["Cedar"]);
|
|
249
|
+
expect(Object.keys(set.writeNeedsApproval)).toEqual(["Policy"]);
|
|
250
|
+
expect(checkParsePolicySet({ staticPolicies: set.denyUnauthenticatedWrite.Cedar!.Statement }).type).toBe(
|
|
251
|
+
"success",
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|