@intentius/chant 0.52.1 → 0.53.0

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.
Files changed (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. package/src/yaml.ts +24 -3
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The identity contract (#1982) — which principal chant would act as in each
3
+ * substrate, reported before it acts.
4
+ *
5
+ * Every native observer already resolves this. `ObserverAdapter.bind()`
6
+ * (./observation.ts) reaches the provider on the applier's own transport, the
7
+ * k8s connector resolves a kubeconfig context and says which binding produced
8
+ * it, the aws read client resolves a region and an endpoint. All of it is
9
+ * discarded. The only way the answer has ever surfaced is as an `unobserved`
10
+ * entry with reason `no-credentials`, or — worse — as a successful read
11
+ * against the wrong account.
12
+ *
13
+ * A project spanning aws, k8s, gcp and github reaches four substrates with
14
+ * four credential sets, and nothing says which identity each one resolves to.
15
+ * `chant lifecycle whoami <env>` asks each configured lexicon that question
16
+ * and prints one row per lexicon.
17
+ *
18
+ * ## The tri-state
19
+ *
20
+ * The same discipline the observation contract draws between "absent" and
21
+ * "not observed" applies here, because the same mistake is available: an empty
22
+ * identity reads as "nobody", and "nobody" is not what "I could not find out"
23
+ * means.
24
+ *
25
+ * - **REPORTED** — the lexicon asked the substrate and it answered. A
26
+ * principal, a scope, and where the binding came from.
27
+ * - **UNRESOLVED** — the lexicon tried and could not, carrying a total
28
+ * {@link UnobservedReason}. `no-credentials` is "no identity is
29
+ * configured"; `no-binding` and `read-failed` are "could not determine".
30
+ * Those are different answers and the reason keeps them apart.
31
+ * - **NOT REPORTED** — the lexicon implements no {@link
32
+ * import("./lexicon").LexiconPlugin.describeIdentity}. It answers for
33
+ * nothing, which is honest; it is never rendered as an empty identity.
34
+ *
35
+ * ## Read-only, and never a gate
36
+ *
37
+ * `whoami` mutates nothing and blocks nothing. It exits 0 whatever the rows
38
+ * say unless the caller asks for a non-zero exit on an unresolved row with
39
+ * `--strict`. A lexicon's implementation is held to the same rule: a self-query
40
+ * that reaches the substrate must be one the substrate treats as a read of the
41
+ * caller's own identity.
42
+ *
43
+ * ## No credential ever reaches the output
44
+ *
45
+ * An identity is a principal and a scope — an account id, a project, an org, a
46
+ * cluster context, a service-account name. It is never a token, a key, a
47
+ * password or a certificate. The rule is on the lexicon: report the fact, not
48
+ * the value, and where a substrate's only identity signal IS a secret, say so
49
+ * without it.
50
+ *
51
+ * {@link redactCredentialMaterial} is the backstop core applies to every field
52
+ * of every row regardless, not a license to be careless. It is deliberately
53
+ * structural rather than heuristic — it redacts what the process actually
54
+ * holds in a credential-shaped environment variable, plus the three literal
55
+ * shapes (PEM block, JWT, `Bearer …`) that no principal string can be — so an
56
+ * account id, an ARN and a `system:serviceaccount:…` subject survive it intact.
57
+ */
58
+
59
+ import type { UnobservedReason } from "./observation";
60
+
61
+ /** The identity one lexicon resolved, and the scope it resolves to. */
62
+ export interface ResolvedIdentity {
63
+ /**
64
+ * The principal, as the substrate names it — an STS ARN, a
65
+ * `system:serviceaccount:<ns>:<name>` subject, a service-account email. Never
66
+ * a credential.
67
+ */
68
+ identity: string;
69
+ /**
70
+ * What that principal is scoped to here — an account id plus region, a
71
+ * cluster context, a project, an org. The half that answers "acting on
72
+ * WHAT", which is where a wrong-account read is actually visible.
73
+ */
74
+ scope: string;
75
+ /**
76
+ * Where the binding came from, in the project's own vocabulary:
77
+ * `k8s.profiles.prod.context`, `AWS_PROFILE`, `ADC`, `stacks[].region`. An
78
+ * identity with no provenance cannot be corrected by whoever reads it.
79
+ */
80
+ source: string;
81
+ /**
82
+ * The resolved address the self-query was issued against. Must be the address
83
+ * this lexicon's live read resolves for the same environment — a whoami that
84
+ * reports a binding the read does not use is worse than no whoami.
85
+ */
86
+ endpoint?: string;
87
+ }
88
+
89
+ /** Why one lexicon could not resolve an identity. Total, and reason-typed. */
90
+ export interface UnresolvedIdentity {
91
+ /**
92
+ * Total verdict, from the same set an unobservable entity uses.
93
+ * `no-credentials` — nothing is configured to act as. `no-binding` — the
94
+ * environment resolves to no concrete target. `read-failed` — the substrate
95
+ * was reached and the self-query errored.
96
+ */
97
+ reason: UnobservedReason;
98
+ /** Human-readable detail: the call that failed, the missing binding key. */
99
+ detail?: string;
100
+ }
101
+
102
+ /**
103
+ * What `describeIdentity()` may return: the resolved identity, or a typed
104
+ * refusal. Discriminated by the `unresolved` key, the same shape
105
+ * {@link import("./observation").EntityObservation} uses.
106
+ */
107
+ export type DescribeIdentityResult = ResolvedIdentity | { unresolved: UnresolvedIdentity };
108
+
109
+ /** True when a lexicon answered with a typed refusal rather than an identity. */
110
+ export function isUnresolvedIdentity(
111
+ value: DescribeIdentityResult,
112
+ ): value is { unresolved: UnresolvedIdentity } {
113
+ return typeof value === "object" && value !== null && "unresolved" in value;
114
+ }
115
+
116
+ /** The tri-state, as one row's verdict. */
117
+ export type IdentityStatus = "reported" | "unresolved" | "not-reported";
118
+
119
+ /** One lexicon's answer, normalized — the unit `whoami` renders and `--json` emits. */
120
+ export interface IdentityRow {
121
+ lexicon: string;
122
+ status: IdentityStatus;
123
+ /** REPORTED only. */
124
+ identity?: string;
125
+ /** REPORTED only. */
126
+ scope?: string;
127
+ /** REPORTED only. */
128
+ source?: string;
129
+ /** REPORTED only, and only when the lexicon named one. */
130
+ endpoint?: string;
131
+ /** UNRESOLVED only. */
132
+ reason?: UnobservedReason;
133
+ /** UNRESOLVED only, when the lexicon supplied one. */
134
+ detail?: string;
135
+ }
136
+
137
+ /** What core hands a lexicon's `describeIdentity`. */
138
+ export interface DescribeIdentityOptions {
139
+ environment: string;
140
+ /**
141
+ * The region the environment's stacks declare, when they declare exactly one
142
+ * (`stacks[].region`, #1261). Omitted when they declare none or disagree, in
143
+ * which case the lexicon resolves the region its own read path would.
144
+ */
145
+ region?: string;
146
+ /** Project root whose `chant.config.ts` carries the binding. Defaults to cwd. */
147
+ cwd?: string;
148
+ }
149
+
150
+ /** The subset of a plugin this module needs — narrower than importing the whole contract. */
151
+ export interface IdentityPlugin {
152
+ name: string;
153
+ describeIdentity?(options: DescribeIdentityOptions): Promise<DescribeIdentityResult>;
154
+ }
155
+
156
+ /** The marker a redacted field carries. Fixed text so a consumer can match it. */
157
+ export const REDACTED = "[redacted]";
158
+
159
+ /**
160
+ * Environment variables whose VALUE is credential material. Matched on the
161
+ * name, so a lexicon that echoes one of these into an identity string has the
162
+ * value removed before it is printed or serialized.
163
+ */
164
+ const CREDENTIAL_ENV_NAME =
165
+ /(SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|APIKEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH)/i;
166
+
167
+ /** Shortest env value worth redacting. Below this a "secret" is a false positive. */
168
+ const MIN_CREDENTIAL_LENGTH = 8;
169
+
170
+ /**
171
+ * Literal credential shapes. Each is something a principal string cannot be,
172
+ * so matching one is proof rather than a guess.
173
+ */
174
+ const CREDENTIAL_SHAPES: RegExp[] = [
175
+ // A PEM block of any key type.
176
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
177
+ // A JWT: three base64url segments, the first starting with the `{"` header.
178
+ /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g,
179
+ // An `Authorization`-style scheme plus its value.
180
+ /\b(?:Bearer|Basic)\s+[A-Za-z0-9\-._~+/]{16,}={0,2}/g,
181
+ ];
182
+
183
+ /**
184
+ * Strip credential material from one reported field.
185
+ *
186
+ * Two rules, both structural. Any value this process holds in a
187
+ * credential-named environment variable is replaced wherever it appears, which
188
+ * covers the realistic accident — a lexicon echoing `$GITHUB_TOKEN` or
189
+ * `$AWS_SECRET_ACCESS_KEY` into a `source`. And the three literal shapes above
190
+ * are replaced on sight.
191
+ *
192
+ * Nothing is entropy-scored, so an ARN, an account id, a service-account email
193
+ * and a cluster context name pass through unchanged — which matters, because a
194
+ * mangled principal is a wrong answer, not a safe one.
195
+ */
196
+ export function redactCredentialMaterial(
197
+ value: string,
198
+ env: Record<string, string | undefined> = process.env,
199
+ ): string {
200
+ let out = value;
201
+ for (const [name, secret] of Object.entries(env)) {
202
+ if (!secret || secret.length < MIN_CREDENTIAL_LENGTH) continue;
203
+ if (!CREDENTIAL_ENV_NAME.test(name)) continue;
204
+ if (!out.includes(secret)) continue;
205
+ out = out.split(secret).join(REDACTED);
206
+ }
207
+ for (const shape of CREDENTIAL_SHAPES) out = out.replace(shape, REDACTED);
208
+ return out;
209
+ }
210
+
211
+ /** Apply {@link redactCredentialMaterial} to every free-text field of a row. */
212
+ export function redactIdentityRow(
213
+ row: IdentityRow,
214
+ env: Record<string, string | undefined> = process.env,
215
+ ): IdentityRow {
216
+ const clean = (v: string | undefined): string | undefined =>
217
+ v === undefined ? undefined : redactCredentialMaterial(v, env);
218
+ return {
219
+ ...row,
220
+ ...(row.identity !== undefined ? { identity: clean(row.identity) } : {}),
221
+ ...(row.scope !== undefined ? { scope: clean(row.scope) } : {}),
222
+ ...(row.source !== undefined ? { source: clean(row.source) } : {}),
223
+ ...(row.endpoint !== undefined ? { endpoint: clean(row.endpoint) } : {}),
224
+ ...(row.detail !== undefined ? { detail: clean(row.detail) } : {}),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Normalize one lexicon's answer into a row.
230
+ *
231
+ * Every degradation lands somewhere honest. No method at all is NOT REPORTED.
232
+ * A throw is UNRESOLVED with `read-failed` — the lexicon reached for the
233
+ * substrate and something broke, which is a different claim from having no
234
+ * credentials. An answer that carries no identity string is UNRESOLVED too: a
235
+ * lexicon cannot report an empty principal, because an empty principal renders
236
+ * as "acting as nobody", and nobody is a claim.
237
+ */
238
+ export function identityRowFor(
239
+ lexicon: string,
240
+ result: DescribeIdentityResult | undefined,
241
+ env: Record<string, string | undefined> = process.env,
242
+ ): IdentityRow {
243
+ if (result === undefined) return { lexicon, status: "not-reported" };
244
+ if (isUnresolvedIdentity(result)) {
245
+ return redactIdentityRow(
246
+ {
247
+ lexicon,
248
+ status: "unresolved",
249
+ reason: result.unresolved.reason,
250
+ ...(result.unresolved.detail ? { detail: result.unresolved.detail } : {}),
251
+ },
252
+ env,
253
+ );
254
+ }
255
+ if (!result.identity || result.identity.trim() === "") {
256
+ return {
257
+ lexicon,
258
+ status: "unresolved",
259
+ reason: "read-failed",
260
+ detail: "the lexicon reported an identity with no principal in it",
261
+ };
262
+ }
263
+ return redactIdentityRow(
264
+ {
265
+ lexicon,
266
+ status: "reported",
267
+ identity: result.identity,
268
+ scope: result.scope,
269
+ source: result.source,
270
+ ...(result.endpoint ? { endpoint: result.endpoint } : {}),
271
+ },
272
+ env,
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Ask every plugin who it would act as. One call per lexicon, concurrently,
278
+ * in the order the plugins were configured so the report is stable.
279
+ *
280
+ * A plugin that throws never fails the run: the throw becomes that lexicon's
281
+ * UNRESOLVED row. Reporting is the whole point, and a report that aborts on
282
+ * the first broken substrate is exactly the report nobody can use.
283
+ */
284
+ export async function describeIdentities(
285
+ plugins: readonly IdentityPlugin[],
286
+ options: DescribeIdentityOptions,
287
+ env: Record<string, string | undefined> = process.env,
288
+ ): Promise<IdentityRow[]> {
289
+ return Promise.all(
290
+ plugins.map(async (plugin): Promise<IdentityRow> => {
291
+ if (!plugin.describeIdentity) return { lexicon: plugin.name, status: "not-reported" };
292
+ try {
293
+ return identityRowFor(plugin.name, await plugin.describeIdentity(options), env);
294
+ } catch (err) {
295
+ return identityRowFor(
296
+ plugin.name,
297
+ {
298
+ unresolved: {
299
+ reason: "read-failed",
300
+ detail: err instanceof Error ? err.message : String(err),
301
+ },
302
+ },
303
+ env,
304
+ );
305
+ }
306
+ }),
307
+ );
308
+ }
309
+
310
+ /**
311
+ * A row's verdict as one short cell, for the IDENTITY column. Deliberately
312
+ * without the `detail`, which is a sentence and belongs under the table rather
313
+ * than inside a column every other row has to be padded to.
314
+ */
315
+ export function identityStatusText(row: IdentityRow): string {
316
+ switch (row.status) {
317
+ case "reported":
318
+ return row.identity ?? "";
319
+ case "unresolved":
320
+ return `could not determine — ${unresolvedReasonText(row.reason)}`;
321
+ case "not-reported":
322
+ return "not reported — this lexicon does not answer for an identity";
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Why an identity did not resolve, in words. Deliberately distinct from
328
+ * `unobservedReasonText`: `no-credentials` here means "no identity is
329
+ * configured for this substrate", which is an answer, not a failure to look.
330
+ */
331
+ export function unresolvedReasonText(reason: UnobservedReason | undefined): string {
332
+ switch (reason) {
333
+ case "no-credentials":
334
+ return "no identity is configured for this substrate";
335
+ case "no-binding":
336
+ return "this environment resolves to no target";
337
+ case "read-failed":
338
+ return "the substrate was reached and the self-query failed";
339
+ case "unsupported-kind":
340
+ return "the substrate exposes no self-query";
341
+ case "filtered":
342
+ return "withheld";
343
+ default:
344
+ return "no reason given";
345
+ }
346
+ }
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export * from "./import/parser";
53
53
  export * from "./import/generator";
54
54
  export * from "./lexicon";
55
55
  export * from "./observation";
56
+ export * from "./identity";
56
57
  export * from "./apply";
57
58
  export * from "./deep-observation";
58
59
  export * from "./owner-chain";
package/src/lexicon.ts CHANGED
@@ -5,6 +5,7 @@ import type { RuleSpec } from "./lint/declarative";
5
5
  import type { PostSynthCheck } from "./lint/post-synth";
6
6
  import type { TemplateParser, TemplateIR } from "./import/parser";
7
7
  import type { TypeScriptGenerator } from "./import/generator";
8
+ import type { AgentConfigImporter } from "./agents/importer";
8
9
  import type { ArtifactIntegrity } from "./lexicon-integrity";
9
10
  import type { OkfFile } from "./okf";
10
11
  import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
@@ -17,6 +18,7 @@ import type { RuleMeta } from "./audit/catalog";
17
18
  import type { ReferenceCatalog } from "./graph-refs";
18
19
  import type { IREdge } from "./graph-ir";
19
20
  import type { DescribeResourcesResult, UnobservedReason } from "./observation";
21
+ import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
20
22
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
21
23
  import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
22
24
  import type { OwnerChainVerdict } from "./owner-chain";
@@ -45,6 +47,18 @@ export type {
45
47
  UnobservedReason,
46
48
  } from "./observation";
47
49
 
50
+ // The identity contract (#1982), re-exported from the same entry so a
51
+ // lexicon's `describeIdentity` types itself without a second import path.
52
+ // Runtime helpers live in `@intentius/chant/identity`.
53
+ export type {
54
+ DescribeIdentityOptions,
55
+ DescribeIdentityResult,
56
+ ResolvedIdentity,
57
+ UnresolvedIdentity,
58
+ IdentityRow,
59
+ IdentityStatus,
60
+ } from "./identity";
61
+
48
62
  // Disruption classification (#1665), re-exported from the same entry so a
49
63
  // lexicon's `classifyDisruption` types itself without a second import path.
50
64
  // Runtime helpers live in `@intentius/chant/lifecycle/disruption`.
@@ -714,6 +728,17 @@ export interface LexiconPlugin {
714
728
  /** Return a generator for converting IR to TypeScript */
715
729
  templateGenerator?(): TypeScriptGenerator;
716
730
 
731
+ /**
732
+ * Re-express local agent configuration (skills, MCP servers, instruction
733
+ * files) discovered by `chant audit --agents` as this lexicon's resources.
734
+ *
735
+ * Implement this when the lexicon has types that model an agent's workload —
736
+ * fountain's `Agent`/`Environment` are the motivating case. Core does the
737
+ * harness-neutral discovery; the mapping onto concrete resource types is the
738
+ * lexicon's call, for the same reason `templateParser` is.
739
+ */
740
+ agentConfigImporter?(): AgentConfigImporter;
741
+
717
742
  /** Return skills provided by this lexicon */
718
743
  skills?(): SkillDefinition[];
719
744
 
@@ -1071,6 +1096,46 @@ export interface LexiconPlugin {
1071
1096
  */
1072
1097
  describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
1073
1098
 
1099
+ /**
1100
+ * Report the principal chant would act as in this substrate, and the scope
1101
+ * that principal resolves to (#1982) — what `chant lifecycle whoami <env>`
1102
+ * prints, one row per lexicon, before anything acts.
1103
+ *
1104
+ * Every implementation of {@link describeResources} already resolves this
1105
+ * and throws it away: the bind reaches the provider on the applier's own
1106
+ * transport, the k8s connector resolves a context and knows which binding
1107
+ * produced it, the aws read client resolves a region and an endpoint. The
1108
+ * answer has only ever surfaced as a `no-credentials` hole or, when the
1109
+ * binding was wrong rather than missing, as a clean read of the wrong
1110
+ * account.
1111
+ *
1112
+ * Implement it with the substrate's own cheap self-query —
1113
+ * `sts:GetCallerIdentity`, `SelfSubjectReview`, the token introspection the
1114
+ * forge already exposes. Two rules bind the implementation:
1115
+ *
1116
+ * - **Read-only.** `whoami` is pre-flight and never a gate. The self-query
1117
+ * must be one the substrate treats as a read of the caller's own identity;
1118
+ * it creates nothing and changes nothing.
1119
+ * - **No credential in the answer.** An identity is a principal and a scope,
1120
+ * never a token, key or password. Where a substrate's only identity signal
1121
+ * IS a secret, report that fact in `source` without the value. Core
1122
+ * redacts credential material from every field as a backstop, which is not
1123
+ * a reason to put one there.
1124
+ *
1125
+ * `endpoint` must be the address this lexicon's live read resolves for the
1126
+ * same environment. A whoami that names a binding the read does not use is
1127
+ * worse than no whoami, so a test pins the two together.
1128
+ *
1129
+ * Returning `{ unresolved: { reason, detail } }` is a real answer and the
1130
+ * required one when the identity cannot be resolved — `no-credentials` for
1131
+ * "nothing is configured", `no-binding` for "this environment resolves to no
1132
+ * target", `read-failed` for a self-query that errored. A throw degrades to
1133
+ * `read-failed` for this lexicon alone and never fails the command. Omitting
1134
+ * the method entirely reports `not reported`, which is honest; it is never
1135
+ * rendered as an empty identity.
1136
+ */
1137
+ describeIdentity?(options: DescribeIdentityOptions): Promise<DescribeIdentityResult>;
1138
+
1074
1139
  /**
1075
1140
  * Enumerate the resources this lexicon would delete for one marker identity
1076
1141
  * (#1222). Opt-in, and read-only here: this method names the would-delete
@@ -0,0 +1,60 @@
1
+ # A small google-provider estate for the carve-out advisor (#2017), mixed the
2
+ # same way the AWS sample estate is: clean leaves, a hub with boundary work, a
3
+ # tier-3 map, and one resource with no native mapping at all.
4
+
5
+ # Clean leaf: a bucket nothing else reads.
6
+ resource "google_storage_bucket" "assets" {
7
+ name = "myapp-assets-prod"
8
+ location = "US"
9
+ }
10
+
11
+ # Clean leaf with one inbound edge: the subscription reads the topic.
12
+ resource "google_pubsub_topic" "events" {
13
+ name = "myapp-events"
14
+ }
15
+
16
+ resource "google_pubsub_subscription" "worker" {
17
+ name = "myapp-worker"
18
+ topic = google_pubsub_topic.events.name
19
+ }
20
+
21
+ # The hub: two subnets, a router and the cluster all reference the network, so
22
+ # carving it costs four data-source patches to the surviving Terraform.
23
+ resource "google_compute_network" "main" {
24
+ name = "myapp-vpc"
25
+ auto_create_subnetworks = false
26
+ }
27
+
28
+ resource "google_compute_subnetwork" "a" {
29
+ name = "myapp-subnet-a"
30
+ ip_cidr_range = "10.0.1.0/24"
31
+ network = google_compute_network.main.id
32
+ }
33
+
34
+ resource "google_compute_subnetwork" "b" {
35
+ name = "myapp-subnet-b"
36
+ ip_cidr_range = "10.0.2.0/24"
37
+ network = google_compute_network.main.id
38
+ }
39
+
40
+ resource "google_compute_router" "nat" {
41
+ name = "myapp-router"
42
+ network = google_compute_network.main.id
43
+ }
44
+
45
+ # Tier 3: Config Connector inlines node pools the cluster also declares apart.
46
+ resource "google_container_cluster" "primary" {
47
+ name = "myapp-gke"
48
+ network = google_compute_network.main.id
49
+ subnetwork = google_compute_subnetwork.a.id
50
+ }
51
+
52
+ # Identity lives in account_id here, not name.
53
+ resource "google_service_account" "runner" {
54
+ account_id = "myapp-runner"
55
+ }
56
+
57
+ # Leave in Terraform: no native mapping, scored 0.
58
+ resource "random_pet" "suffix" {
59
+ length = 2
60
+ }
@@ -159,3 +159,134 @@ describe("adoptFromState", () => {
159
159
  expect(adoptFromState({ type: "random_pet", name: "x", attributes: { id: "abc" } })).toBeNull();
160
160
  });
161
161
  });
162
+
163
+ /**
164
+ * `kubernetes_manifest` adoption (#999). The Terraform type names no kind: the
165
+ * target is whatever the manifest body says it is, so these assertions are
166
+ * about reading the body, not about a per-type property mapping.
167
+ */
168
+ describe("adoptFromState — kubernetes_manifest", () => {
169
+ const configMap: StateResource = {
170
+ type: "kubernetes_manifest",
171
+ name: "app_config",
172
+ attributes: {
173
+ manifest: {
174
+ apiVersion: "v1",
175
+ kind: "ConfigMap",
176
+ metadata: { name: "app-config", namespace: "web", labels: { "app.kubernetes.io/name": "web" } },
177
+ data: { LOG_LEVEL: "info" },
178
+ },
179
+ field_manager: null,
180
+ wait: null,
181
+ computed_fields: ["metadata.labels", "metadata.annotations"],
182
+ },
183
+ };
184
+
185
+ test("adopts the manifest body verbatim through the lexicon escape hatch", () => {
186
+ const out = adoptFromState(configMap)!;
187
+ expect(out.fileName).toBe("app_config.ts");
188
+ expect(out.mapped).toBe(true);
189
+ // The kind came out of the body, not out of the Terraform type.
190
+ expect(out.nativeType).toBe("v1 ConfigMap");
191
+
192
+ expect(out.content).toContain('import { k8sManifest } from "@intentius/chant-lexicon-k8s";');
193
+ expect(out.content).toContain("export const app_config = k8sManifest({");
194
+ expect(out.content).toContain('apiVersion: "v1"');
195
+ expect(out.content).toContain('kind: "ConfigMap"');
196
+ expect(out.content).toContain('namespace: "web"');
197
+ // A key that is not an identifier is quoted, so the emitted source parses.
198
+ expect(out.content).toContain('"app.kubernetes.io/name": "web"');
199
+ expect(out.content).toContain('LOG_LEVEL: "info"');
200
+ // Provider knobs are reported, never smuggled into the object.
201
+ expect(out.content).toContain("Provider-behaviour attributes not part of the object: computed_fields");
202
+ expect(out.content).not.toContain("computed_fields:");
203
+ expect(out.folded).toEqual([]);
204
+ });
205
+
206
+ test("a CRD body needs no per-type mapping — one rule covers every kind", () => {
207
+ const out = adoptFromState({
208
+ type: "kubernetes_manifest",
209
+ name: "cert",
210
+ attributes: {
211
+ manifest: {
212
+ apiVersion: "cert-manager.io/v1",
213
+ kind: "Certificate",
214
+ metadata: { name: "web-tls", namespace: "web" },
215
+ spec: {
216
+ secretName: "web-tls",
217
+ dnsNames: ["web.example.com"],
218
+ issuerRef: { name: "letsencrypt", kind: "ClusterIssuer" },
219
+ },
220
+ },
221
+ },
222
+ })!;
223
+ expect(out.nativeType).toBe("cert-manager.io/v1 Certificate");
224
+ expect(out.content).toContain('apiVersion: "cert-manager.io/v1"');
225
+ expect(out.content).toContain('kind: "Certificate"');
226
+ expect(out.content).toContain('secretName: "web-tls"');
227
+ expect(out.content).toContain('"web.example.com",');
228
+ });
229
+
230
+ test("falls back to the computed object, without the fields the API server owns", () => {
231
+ const out = adoptFromState({
232
+ type: "kubernetes_manifest",
233
+ name: "ns",
234
+ attributes: {
235
+ object: {
236
+ apiVersion: "v1",
237
+ kind: "Namespace",
238
+ metadata: {
239
+ name: "web",
240
+ uid: "8f1e",
241
+ resourceVersion: "4711",
242
+ creationTimestamp: "2026-01-01T00:00:00Z",
243
+ labels: {},
244
+ },
245
+ status: { phase: "Active" },
246
+ },
247
+ },
248
+ })!;
249
+ expect(out.content).toContain('name: "web"');
250
+ expect(out.content).not.toContain("resourceVersion");
251
+ expect(out.content).not.toContain("creationTimestamp");
252
+ expect(out.content).not.toContain("uid");
253
+ expect(out.content).not.toContain("status");
254
+ });
255
+
256
+ test("a deferred input is reported in the source, not substituted", () => {
257
+ // The survivor value sits inside the body, so the boundary report names the
258
+ // enclosing `manifest` attribute and state resolves it to an object —
259
+ // there is no scalar for a `params.<name>` reference to replace.
260
+ const params: DeferredParam[] = [
261
+ { name: "manifest", tfAttr: "manifest", survivor: "aws_eks_cluster.main", attrs: ["endpoint"] },
262
+ ];
263
+ const out = adoptFromState(configMap, params)!;
264
+ expect(out.parameterized).toEqual([]);
265
+ expect(out.content).toContain("Deferred deploy-time input: manifest read aws_eks_cluster.main.endpoint");
266
+ expect(out.content).toContain('declared as build param "manifest" in chant.config.ts');
267
+ expect(out.content).not.toContain("params.");
268
+ });
269
+
270
+ test("a name Terraform allows but TypeScript does not becomes an identifier", () => {
271
+ const out = adoptFromState({
272
+ type: "kubernetes_manifest",
273
+ name: "app-config",
274
+ attributes: { manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "app-config" } } },
275
+ })!;
276
+ expect(out.fileName).toBe("app_config.ts");
277
+ expect(out.content).toContain("export const app_config = k8sManifest({");
278
+ });
279
+
280
+ test("a body without apiVersion/kind is refused rather than emitted headless", () => {
281
+ expect(
282
+ adoptFromState({ type: "kubernetes_manifest", name: "x", attributes: { manifest: { metadata: { name: "x" } } } }),
283
+ ).toBeNull();
284
+ expect(adoptFromState({ type: "kubernetes_manifest", name: "x", attributes: {} })).toBeNull();
285
+ });
286
+
287
+ test("a typed kubernetes resource is ranked but not adoptable", () => {
288
+ expect(canAdoptFromState("kubernetes_manifest")).toBe(true);
289
+ expect(canAdoptFromState("kubernetes_config_map")).toBe(false);
290
+ expect(adoptFromState({ type: "kubernetes_config_map", name: "c", attributes: {} })).toBeNull();
291
+ });
292
+ });