@intentius/chant 0.52.2 → 0.53.1

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 (125) hide show
  1. package/dist/cdk/advise.d.ts +29 -0
  2. package/dist/cdk/advise.d.ts.map +1 -0
  3. package/dist/cdk/assembly.d.ts +38 -0
  4. package/dist/cdk/assembly.d.ts.map +1 -0
  5. package/dist/cdk/graph.d.ts +68 -0
  6. package/dist/cdk/graph.d.ts.map +1 -0
  7. package/dist/cdk/tier-map.d.ts +44 -0
  8. package/dist/cdk/tier-map.d.ts.map +1 -0
  9. package/dist/cdk/types.d.ts +114 -0
  10. package/dist/cdk/types.d.ts.map +1 -0
  11. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  12. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  13. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  14. package/dist/cli/commands/carve.d.ts +48 -6
  15. package/dist/cli/commands/carve.d.ts.map +1 -1
  16. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  17. package/dist/cli/handlers/carve.d.ts +5 -4
  18. package/dist/cli/handlers/carve.d.ts.map +1 -1
  19. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  20. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  21. package/dist/cli/handlers/op-progress.d.ts +37 -4
  22. package/dist/cli/handlers/op-progress.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +61 -1
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/run.d.ts.map +1 -1
  26. package/dist/cli/main.d.ts.map +1 -1
  27. package/dist/cli/registry.d.ts +9 -1
  28. package/dist/cli/registry.d.ts.map +1 -1
  29. package/dist/identity.d.ts +196 -0
  30. package/dist/identity.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/lexicon.d.ts +41 -0
  34. package/dist/lexicon.d.ts.map +1 -1
  35. package/dist/lifecycle/converge-ledger.d.ts +81 -1
  36. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  37. package/dist/lifecycle/gate-ledger.d.ts +43 -1
  38. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  39. package/dist/terraform/adopt-state.d.ts +17 -66
  40. package/dist/terraform/adopt-state.d.ts.map +1 -1
  41. package/dist/terraform/aws-resources.d.ts +1 -1
  42. package/dist/terraform/carve-provider.d.ts +142 -0
  43. package/dist/terraform/carve-provider.d.ts.map +1 -0
  44. package/dist/terraform/carve.d.ts +36 -3
  45. package/dist/terraform/carve.d.ts.map +1 -1
  46. package/dist/terraform/emit-source.d.ts +25 -0
  47. package/dist/terraform/emit-source.d.ts.map +1 -0
  48. package/dist/terraform/graduate.d.ts +11 -1
  49. package/dist/terraform/graduate.d.ts.map +1 -1
  50. package/dist/terraform/providers/aws.d.ts +19 -0
  51. package/dist/terraform/providers/aws.d.ts.map +1 -0
  52. package/dist/terraform/providers/gcp.d.ts +41 -0
  53. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  54. package/dist/terraform/providers/index.d.ts +15 -0
  55. package/dist/terraform/providers/index.d.ts.map +1 -0
  56. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  57. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  58. package/dist/terraform/score.d.ts +70 -4
  59. package/dist/terraform/score.d.ts.map +1 -1
  60. package/dist/terraform/tier-map.d.ts +30 -35
  61. package/dist/terraform/tier-map.d.ts.map +1 -1
  62. package/dist/terraform/types.d.ts +6 -0
  63. package/dist/terraform/types.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  66. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  67. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  68. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  69. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  70. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  71. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  72. package/src/cdk/advise.test.ts +208 -0
  73. package/src/cdk/advise.ts +44 -0
  74. package/src/cdk/assembly.ts +133 -0
  75. package/src/cdk/graph.test.ts +206 -0
  76. package/src/cdk/graph.ts +525 -0
  77. package/src/cdk/tier-map.ts +71 -0
  78. package/src/cdk/types.ts +115 -0
  79. package/src/cli/commands/carve-apply.ts +20 -4
  80. package/src/cli/commands/carve-bridge.ts +9 -5
  81. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  82. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  83. package/src/cli/commands/carve-emit.ts +28 -24
  84. package/src/cli/commands/carve.ts +139 -36
  85. package/src/cli/handlers/carve-emit.ts +8 -3
  86. package/src/cli/handlers/carve.ts +8 -7
  87. package/src/cli/handlers/lifecycle.test.ts +187 -1
  88. package/src/cli/handlers/lifecycle.ts +125 -1
  89. package/src/cli/handlers/op-progress.test.ts +67 -1
  90. package/src/cli/handlers/op-progress.ts +70 -14
  91. package/src/cli/handlers/operator.test.ts +302 -1
  92. package/src/cli/handlers/operator.ts +193 -5
  93. package/src/cli/handlers/run.ts +11 -13
  94. package/src/cli/main.test.ts +19 -0
  95. package/src/cli/main.ts +38 -10
  96. package/src/cli/registry.ts +9 -1
  97. package/src/identity.test.ts +199 -0
  98. package/src/identity.ts +346 -0
  99. package/src/index.ts +1 -0
  100. package/src/lexicon.ts +53 -0
  101. package/src/lifecycle/converge-ledger.test.ts +110 -0
  102. package/src/lifecycle/converge-ledger.ts +110 -2
  103. package/src/lifecycle/gate-ledger.test.ts +81 -1
  104. package/src/lifecycle/gate-ledger.ts +64 -1
  105. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  106. package/src/terraform/adopt-state.test.ts +131 -0
  107. package/src/terraform/adopt-state.ts +19 -167
  108. package/src/terraform/aws-resources.test.ts +38 -22
  109. package/src/terraform/aws-resources.ts +1 -1
  110. package/src/terraform/carve-provider.test.ts +155 -0
  111. package/src/terraform/carve-provider.ts +237 -0
  112. package/src/terraform/carve.test.ts +55 -1
  113. package/src/terraform/carve.ts +0 -0
  114. package/src/terraform/emit-source.ts +39 -0
  115. package/src/terraform/graduate.test.ts +37 -0
  116. package/src/terraform/graduate.ts +55 -7
  117. package/src/terraform/graph.ts +3 -3
  118. package/src/terraform/providers/aws.ts +169 -0
  119. package/src/terraform/providers/gcp.test.ts +228 -0
  120. package/src/terraform/providers/gcp.ts +329 -0
  121. package/src/terraform/providers/index.ts +21 -0
  122. package/src/terraform/providers/kubernetes.ts +224 -0
  123. package/src/terraform/score.ts +111 -25
  124. package/src/terraform/tier-map.ts +45 -107
  125. package/src/terraform/types.ts +6 -0
package/src/cli/main.ts CHANGED
@@ -23,14 +23,14 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
23
23
  import { runCarveEmit } from "./handlers/carve-emit";
24
24
  import { runCarveBridge } from "./handlers/carve-bridge";
25
25
  import { runCarveApply } from "./handlers/carve-apply";
26
- import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } from "./handlers/lifecycle";
26
+ import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
27
27
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
28
28
  import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
29
29
  import { runGraph } from "./handlers/graph";
30
30
  import { runExplain } from "./handlers/explain";
31
31
  import { runSearch } from "./handlers/search";
32
32
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
33
- import { runOperator, runOperatorStatus, runApprove } from "./handlers/operator";
33
+ import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
34
34
  import { runEmulator } from "./handlers/emulator";
35
35
  import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
36
36
  import type { LexiconPlugin } from "../lexicon";
@@ -388,6 +388,16 @@ export function parseArgs(args: string[]): ParsedArgs {
388
388
  result.once = true;
389
389
  } else if (arg === "--note") {
390
390
  result.note = args[++i];
391
+ } else if (arg === "--url") {
392
+ result.url = args[++i];
393
+ } else if (arg === "--op") {
394
+ result.op = args[++i];
395
+ } else if (arg === "--since") {
396
+ result.since = args[++i];
397
+ } else if (arg === "--limit") {
398
+ // Parsed here, validated by the handler — `parseArgs` reports shape, not
399
+ // policy, the same split every other value flag here uses.
400
+ result.limit = Number(args[++i]);
391
401
  } else if (arg.startsWith("--")) {
392
402
  // chant #1127 — every recognized flag is matched above; anything left
393
403
  // starting with `--` is unrecognized, whether it arrived bare
@@ -461,10 +471,12 @@ Commands:
461
471
  --all-projects)
462
472
  migrate <file> Translate a workflow between lexicons
463
473
  (default: --from github --to gitlab)
464
- carve advise Read-only Terraform peelability advisor: rank which
465
- --from <tf-dir> resources are cheap to carve into native chant
474
+ carve advise Read-only peelability advisor: rank which resources
475
+ --from <dir> are cheap to carve into native chant
466
476
  (--json, --report <path>). Emits nothing, changes nothing.
467
- Needs @cdktf/hcl2json (npm install -D @cdktf/hcl2json).
477
+ --from a Terraform dir needs @cdktf/hcl2json
478
+ (npm install -D @cdktf/hcl2json); --from a CDK cloud
479
+ assembly (cdk.out) needs nothing and ranks constructs.
468
480
  carve emit Adopt a selected TF resource into chant source + report
469
481
  --from <tf-dir> its boundary. --state <tfstate> adopts offline
470
482
  --select <addr> (recommended for TF-managed resources); --env
@@ -522,11 +534,21 @@ Ops:
522
534
  ConvergeOp, read from the chant/lifecycle orphan
523
535
  branch alone — no daemon needs to be running
524
536
  (--env <env>, --json)
537
+ operator log Converge tick history and the gate resolutions
538
+ against it, merged into one timestamp-ordered
539
+ timeline, from the same orphan branch (--env <env>,
540
+ --op <name>, --since <iso>, --limit <n>, --json).
541
+ --json also carries the count of ledger lines that
542
+ were unreadable, so a short timeline is never
543
+ silently short
525
544
  approve <op> <gate> Record a gate's out-of-band resolution fact
526
- (--actor <name>, --note <text>) — the durable
527
- counterpart to a converge tick's gate-as-fact
528
- outcome; see the pending-gates list in operator
529
- status. Does not itself unblock the gated op's local
545
+ (--actor <name>, --note <text>, --url <url>) — the
546
+ durable counterpart to a converge tick's
547
+ gate-as-fact outcome; see the pending-gates list in
548
+ operator status. --url is the PR/MR the resolution
549
+ happened at, recorded typed rather than as free text,
550
+ and defaults to the PR/MR of the surrounding CI job.
551
+ Does not itself unblock the gated op's local
530
552
  dispatch (re-run --temporal, or merge its PR)
531
553
 
532
554
  graph Show Op dependency graph (--stacks for cross-stack order,
@@ -556,6 +578,9 @@ Lifecycle (alias: lc):
556
578
  lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
557
579
  lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
558
580
  --json: emit the ChangeSet as JSON
581
+ lifecycle whoami <env> Who chant would act as in each configured lexicon,
582
+ and what that principal is scoped to — read-only,
583
+ before anything acts (--json, --strict)
559
584
  lifecycle teardown <env> Plan what deleting the environment would remove —
560
585
  marker-scoped (this project's stack + env); --yes
561
586
  executes the plan (production-like names also need
@@ -646,7 +671,8 @@ Options:
646
671
  --from <name> Source lexicon for migrate (default: github)
647
672
  --to <name> Target lexicon for migrate (default: gitlab)
648
673
  --emit <fmt> Migration output format: yaml (default) or ts
649
- --strict Escalate needs-review/validation to errors (migrate)
674
+ --strict Escalate needs-review/validation to errors (migrate);
675
+ exit nonzero on an unresolved identity (lifecycle whoami)
650
676
  --validate Run external validator (glci/glab) after migrate
651
677
  --use-composites Rewrite to composite calls when patterns match (migrate)
652
678
  --components Target discovered Component declarations instead of
@@ -869,6 +895,7 @@ const registry: CommandDef[] = [
869
895
  { name: "run", handler: runOp },
870
896
 
871
897
  { name: "operator status", handler: runOperatorStatus },
898
+ { name: "operator log", handler: runOperatorLog },
872
899
  { name: "operator", handler: runOperator },
873
900
  { name: "approve", handler: runApprove },
874
901
 
@@ -882,6 +909,7 @@ const registry: CommandDef[] = [
882
909
  { name: "lifecycle rollback", handler: runLifecycleRollback },
883
910
  { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
884
911
  { name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
912
+ { name: "lifecycle whoami", requiresPlugins: true, handler: runLifecycleWhoami },
885
913
  { name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
886
914
  { name: "lifecycle log", handler: runLifecycleLog },
887
915
 
@@ -285,8 +285,16 @@ export interface ParsedArgs {
285
285
  leaseTtl?: string;
286
286
  /** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
287
287
  once?: boolean;
288
- /** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text context recorded on the gate-resolution fact (e.g. a PR URL). */
288
+ /** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
289
289
  note?: string;
290
+ /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
291
+ op?: string;
292
+ /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
293
+ since?: string;
294
+ /** `chant operator log --limit <n>` (#2029) — keep only the newest n entries (still printed oldest-first). */
295
+ limit?: number;
296
+ /** `chant approve <op> <gate> --url <url>` (#2028) — the address this resolution happened at (the PR/MR that carried the change), recorded typed on the gate-resolution fact so a reader is not sniffing `--note` for something link-shaped. Defaults to the PR/MR the surrounding CI job is for, when there is one. Must be an absolute http/https URL. */
297
+ url?: string;
290
298
  }
291
299
 
292
300
  /**
@@ -0,0 +1,199 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ describeIdentities,
4
+ identityRowFor,
5
+ identityStatusText,
6
+ isUnresolvedIdentity,
7
+ redactCredentialMaterial,
8
+ redactIdentityRow,
9
+ REDACTED,
10
+ type IdentityPlugin,
11
+ } from "./identity";
12
+
13
+ describe("the identity tri-state (#1982)", () => {
14
+ test("a lexicon with no describeIdentity is not-reported, never an empty identity", async () => {
15
+ const rows = await describeIdentities([{ name: "github" }], { environment: "prod" }, {});
16
+ expect(rows).toEqual([{ lexicon: "github", status: "not-reported" }]);
17
+ expect(rows[0].identity).toBeUndefined();
18
+ expect(identityStatusText(rows[0])).toContain("not reported");
19
+ });
20
+
21
+ test("a resolved identity carries principal, scope, source and endpoint", async () => {
22
+ const plugin: IdentityPlugin = {
23
+ name: "aws",
24
+ describeIdentity: async () => ({
25
+ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
26
+ scope: "491500000000 us-east-1",
27
+ source: "env AWS_ACCESS_KEY_ID",
28
+ endpoint: "https://sts.us-east-1.amazonaws.com/",
29
+ }),
30
+ };
31
+ const [row] = await describeIdentities([plugin], { environment: "prod" }, {});
32
+ expect(row).toEqual({
33
+ lexicon: "aws",
34
+ status: "reported",
35
+ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
36
+ scope: "491500000000 us-east-1",
37
+ source: "env AWS_ACCESS_KEY_ID",
38
+ endpoint: "https://sts.us-east-1.amazonaws.com/",
39
+ });
40
+ });
41
+
42
+ test("no credentials and could-not-determine are distinguishable answers", async () => {
43
+ const rows = await describeIdentities(
44
+ [
45
+ { name: "aws", describeIdentity: async () => ({ unresolved: { reason: "no-credentials" } }) },
46
+ {
47
+ name: "k8s",
48
+ describeIdentity: async () => ({ unresolved: { reason: "read-failed", detail: "connection refused" } }),
49
+ },
50
+ ],
51
+ { environment: "prod" },
52
+ {},
53
+ );
54
+ expect(rows[0]).toEqual({ lexicon: "aws", status: "unresolved", reason: "no-credentials" });
55
+ expect(identityStatusText(rows[0])).toContain("no identity is configured");
56
+ expect(rows[1]).toEqual({
57
+ lexicon: "k8s",
58
+ status: "unresolved",
59
+ reason: "read-failed",
60
+ detail: "connection refused",
61
+ });
62
+ expect(identityStatusText(rows[1])).toContain("self-query failed");
63
+ // The point of the distinction: "nothing is configured" and "I could not
64
+ // find out" must never render as the same row.
65
+ expect(identityStatusText(rows[0])).not.toBe(identityStatusText(rows[1]));
66
+ });
67
+
68
+ test("an empty principal is refused rather than reported as an identity", () => {
69
+ const row = identityRowFor("gcp", { identity: " ", scope: "acme-prod", source: "ADC" }, {});
70
+ expect(row.status).toBe("unresolved");
71
+ expect(row.reason).toBe("read-failed");
72
+ expect(row.identity).toBeUndefined();
73
+ });
74
+
75
+ test("a lexicon that throws degrades to read-failed and never fails the run", async () => {
76
+ const rows = await describeIdentities(
77
+ [
78
+ {
79
+ name: "azure",
80
+ describeIdentity: async () => {
81
+ throw new Error("az CLI is not installed");
82
+ },
83
+ },
84
+ { name: "aws", describeIdentity: async () => ({ identity: "arn:x", scope: "s", source: "env" }) },
85
+ ],
86
+ { environment: "prod" },
87
+ {},
88
+ );
89
+ expect(rows[0]).toEqual({
90
+ lexicon: "azure",
91
+ status: "unresolved",
92
+ reason: "read-failed",
93
+ detail: "az CLI is not installed",
94
+ });
95
+ expect(rows[1].status).toBe("reported");
96
+ });
97
+
98
+ test("rows come back in the order the lexicons were configured", async () => {
99
+ const names = ["k8s", "aws", "gcp", "github"];
100
+ const rows = await describeIdentities(
101
+ names.map((name) => ({ name })),
102
+ { environment: "prod" },
103
+ {},
104
+ );
105
+ expect(rows.map((r) => r.lexicon)).toEqual(names);
106
+ });
107
+
108
+ test("the environment, region and cwd reach the lexicon unchanged", async () => {
109
+ const seen: unknown[] = [];
110
+ await describeIdentities(
111
+ [
112
+ {
113
+ name: "aws",
114
+ describeIdentity: async (options) => {
115
+ seen.push(options);
116
+ return { identity: "arn:x", scope: "s", source: "env" };
117
+ },
118
+ },
119
+ ],
120
+ { environment: "prod", region: "eu-west-1", cwd: "/repo" },
121
+ {},
122
+ );
123
+ expect(seen).toEqual([{ environment: "prod", region: "eu-west-1", cwd: "/repo" }]);
124
+ });
125
+
126
+ test("isUnresolvedIdentity discriminates the two return shapes", () => {
127
+ expect(isUnresolvedIdentity({ unresolved: { reason: "no-binding" } })).toBe(true);
128
+ expect(isUnresolvedIdentity({ identity: "a", scope: "b", source: "c" })).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("no credential reaches the output (#1982)", () => {
133
+ test("a value held in a credential-named env var is redacted wherever it appears", () => {
134
+ const env = { GITHUB_TOKEN: "ghp_averyrealtokenvalue", HOME: "/Users/x" };
135
+ expect(redactCredentialMaterial("authorized by ghp_averyrealtokenvalue", env)).toBe(
136
+ `authorized by ${REDACTED}`,
137
+ );
138
+ });
139
+
140
+ test("every free-text field of a row is scrubbed, not just the identity", () => {
141
+ const env = { AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMIsecretKEY" };
142
+ const row = redactIdentityRow(
143
+ {
144
+ lexicon: "aws",
145
+ status: "reported",
146
+ identity: "wJalrXUtnFEMIsecretKEY",
147
+ scope: "wJalrXUtnFEMIsecretKEY",
148
+ source: "signed with wJalrXUtnFEMIsecretKEY",
149
+ endpoint: "https://sts.amazonaws.com/?k=wJalrXUtnFEMIsecretKEY",
150
+ detail: "wJalrXUtnFEMIsecretKEY",
151
+ },
152
+ env,
153
+ );
154
+ for (const field of [row.identity, row.scope, row.source, row.endpoint, row.detail]) {
155
+ expect(field).not.toContain("wJalrXUtnFEMIsecretKEY");
156
+ expect(field).toContain(REDACTED);
157
+ }
158
+ });
159
+
160
+ test("a short env value is not treated as a credential", () => {
161
+ // Redacting "dev" out of every identity would be worse than the leak.
162
+ expect(redactCredentialMaterial("cluster dev-a", { AUTH_TOKEN: "dev" })).toBe("cluster dev-a");
163
+ });
164
+
165
+ test("literal credential shapes are redacted with no env var involved", () => {
166
+ const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dBjftJeZ4CVPmB92K27u";
167
+ expect(redactCredentialMaterial(jwt, {})).toBe(REDACTED);
168
+ expect(redactCredentialMaterial("Bearer abcdefghijklmnopqrstuvwxyz012345", {})).toBe(REDACTED);
169
+ expect(
170
+ redactCredentialMaterial("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", {}),
171
+ ).toBe(REDACTED);
172
+ });
173
+
174
+ test("real principals survive redaction unchanged", () => {
175
+ // A mangled principal is a wrong answer, not a safe one. Nothing here is
176
+ // entropy-scored, so these pass through whole.
177
+ const env = { AWS_SESSION_TOKEN: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN" };
178
+ for (const principal of [
179
+ "arn:aws:sts::491500000000:assumed-role/deploy/ci",
180
+ "system:serviceaccount:chant:deployer",
181
+ "deploy@acme.iam.gserviceaccount.com",
182
+ "https://prod-eks-a.eu-west-1.eks.amazonaws.com",
183
+ "491500000000 us-east-1",
184
+ ]) {
185
+ expect(redactCredentialMaterial(principal, env)).toBe(principal);
186
+ }
187
+ });
188
+
189
+ test("an identity built out of the session token is redacted, not printed", () => {
190
+ const env = { AWS_SESSION_TOKEN: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN" };
191
+ const row = identityRowFor(
192
+ "aws",
193
+ { identity: "FwoGZXIvYXdzEBYaDLONGSESSIONTOKEN", scope: "us-east-1", source: "env" },
194
+ env,
195
+ );
196
+ expect(row.identity).toBe(REDACTED);
197
+ expect(row.identity).not.toContain("FwoGZXIvYXdz");
198
+ });
199
+ });
@@ -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
+ }