@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.
- package/dist/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +52 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -63
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +38 -26
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +24 -2
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +55 -28
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +8 -1
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +29 -5
- package/src/cli/registry.ts +15 -0
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +65 -0
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +22 -167
- package/src/terraform/aws-resources.test.ts +55 -16
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +55 -90
- package/src/terraform/types.ts +6 -0
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
package/src/identity.ts
ADDED
|
@@ -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
|
+
});
|