@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
@@ -5,6 +5,9 @@ import { loadChantConfig } from "../../config";
5
5
  import { resolve } from "path";
6
6
  import { auditCommand, printAuditResult, type AuditFormat, type AuditTier, type AuditFailOn } from "../commands/audit";
7
7
  import type { ReportTheme } from "../../audit/report-html";
8
+ import { AGENT_RUNTIMES, AGENT_SCOPES, type AgentRuntime, type AgentScope } from "../../agents/types";
9
+ import { registeredProjectRoots } from "../../agents/discover";
10
+ import { homedir } from "os";
8
11
  import type { ResourceSelector } from "../../lexicon";
9
12
  import { formatError, formatSuccess, formatWarning, formatBold } from "../format";
10
13
  import type { CommandContext } from "../registry";
@@ -23,6 +26,44 @@ const AUDIT_FORMATS: AuditFormat[] = ["stylish", "json", "sarif", "markdown", "h
23
26
  const AUDIT_TIERS: AuditTier[] = ["merge-worthy", "all"];
24
27
  const AUDIT_FAIL_ON: AuditFailOn[] = ["merge-worthy", "warning", "none"];
25
28
 
29
+ /**
30
+ * Parse `--scope system,user,project`. Returns `undefined` (meaning "all") when
31
+ * the flag is absent, or an Error the caller reports — an unrecognized scope is
32
+ * rejected rather than ignored, since silently scanning more than the user
33
+ * asked for is exactly the surprise this command exists to prevent.
34
+ */
35
+ function parseScopes(raw: string | undefined): readonly AgentScope[] | undefined | Error {
36
+ if (!raw) return undefined;
37
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
38
+ const bad = parts.filter((p) => !AGENT_SCOPES.includes(p as AgentScope));
39
+ if (bad.length > 0) return new Error(`Invalid --scope: ${bad.join(", ")}. Expected one or more of ${AGENT_SCOPES.join(", ")}.`);
40
+ return parts.length > 0 ? (parts as AgentScope[]) : undefined;
41
+ }
42
+
43
+ /** Parse `--runtime claude,codex,...`. Same contract as {@link parseScopes}. */
44
+ function parseRuntimes(raw: string | undefined): readonly AgentRuntime[] | undefined | Error {
45
+ if (!raw) return undefined;
46
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
47
+ const bad = parts.filter((p) => !AGENT_RUNTIMES.includes(p as AgentRuntime));
48
+ if (bad.length > 0) return new Error(`Invalid --runtime: ${bad.join(", ")}. Expected one or more of ${AGENT_RUNTIMES.join(", ")}.`);
49
+ return parts.length > 0 ? (parts as AgentRuntime[]) : undefined;
50
+ }
51
+
52
+ /**
53
+ * Project roots for an `--agents` run.
54
+ *
55
+ * Default: the path argument alone. With `--all-projects`, every project root
56
+ * the harness has registered, *unioned* with the path argument rather than
57
+ * replacing it — a flag that silently discarded an argument the user typed
58
+ * would be the wrong kind of surprise, and a cwd that isn't registered yet is
59
+ * still a project the user is plainly interested in.
60
+ */
61
+ function resolveProjectRoots(args: { path: string; allProjects?: boolean }): string[] {
62
+ const named = resolve(args.path);
63
+ if (!args.allProjects) return [named];
64
+ return [...new Set([...registeredProjectRoots(homedir()), named])].sort();
65
+ }
66
+
26
67
  export async function runAudit(ctx: CommandContext): Promise<number> {
27
68
  const { args } = ctx;
28
69
 
@@ -42,6 +83,37 @@ export async function runAudit(ctx: CommandContext): Promise<number> {
42
83
  return 1;
43
84
  }
44
85
 
86
+ // `--agents` switches the subject from a repo to this machine's agent
87
+ // configuration. Same formats, tiers, and fail-on policy — a different thing
88
+ // being audited, not a different report.
89
+ if (args.agents) {
90
+ const scopes = parseScopes(args.scope);
91
+ if (scopes instanceof Error) {
92
+ console.error(formatError({ message: scopes.message }));
93
+ return 1;
94
+ }
95
+ const runtimes = parseRuntimes(args.runtime);
96
+ if (runtimes instanceof Error) {
97
+ console.error(formatError({ message: runtimes.message }));
98
+ return 1;
99
+ }
100
+ const { auditAgentsCommand } = await import("../commands/audit-agents");
101
+ const result = auditAgentsCommand({
102
+ format,
103
+ tier,
104
+ failOn,
105
+ scopes,
106
+ runtimes,
107
+ projectRoots: resolveProjectRoots(args),
108
+ output: args.output,
109
+ toolVersion: CHANT_VERSION,
110
+ });
111
+ if (result.error) console.error(formatError({ message: result.error }));
112
+ else if (result.wroteTo) console.log(formatSuccess(`Report written to ${result.wroteTo}`));
113
+ else console.log(result.output);
114
+ return result.exitCode;
115
+ }
116
+
45
117
  // HTML report customization: --template <file> (full override) + --theme <file> (JSON knobs).
46
118
  let template: string | undefined;
47
119
  let theme: ReportTheme | undefined;
@@ -171,6 +243,45 @@ export async function runDescribe(ctx: CommandContext): Promise<number> {
171
243
  export async function runImport(ctx: CommandContext): Promise<number> {
172
244
  const { args } = ctx;
173
245
 
246
+ // `--agents` (#chant audit --agents' other half): the source is this
247
+ // machine's agent configuration rather than a template file, so there is
248
+ // nothing to detect — the flag names the subject, like `--kustomize` below
249
+ // names the lexicon.
250
+ if (args.agents) {
251
+ const scopes = parseScopes(args.scope);
252
+ if (scopes instanceof Error) {
253
+ console.error(formatError({ message: scopes.message }));
254
+ return 1;
255
+ }
256
+ const runtimes = parseRuntimes(args.runtime);
257
+ if (runtimes instanceof Error) {
258
+ console.error(formatError({ message: runtimes.message }));
259
+ return 1;
260
+ }
261
+ const { importAgentsCommand } = await import("../commands/import-agents");
262
+ const result = await importAgentsCommand({
263
+ lexicon: args.lexicon,
264
+ output: args.output,
265
+ force: args.force,
266
+ scopes,
267
+ runtimes,
268
+ projectRoots: resolveProjectRoots(args),
269
+ });
270
+ for (const warning of result.warnings) console.warn(formatWarning({ message: warning }));
271
+ if (!result.success) {
272
+ console.error(formatError({ message: result.error ?? "Import failed." }));
273
+ return 1;
274
+ }
275
+ console.log(
276
+ formatSuccess(
277
+ `Re-expressed ${result.summary.mapped} resource${result.summary.mapped === 1 ? "" : "s"} ` +
278
+ `from ${result.summary.discovered} agent config${result.summary.discovered === 1 ? "" : "s"}:`,
279
+ ),
280
+ );
281
+ for (const file of result.generatedFiles) console.log(` ${file}`);
282
+ return 0;
283
+ }
284
+
174
285
  // `--kustomize <dir>` (#1548): render the overlay and import the output
175
286
  // through the k8s template parser — the flag NAMES the lexicon, so no JSON
176
287
  // detection. `kustomize build`, falling back to kubectl's vendored
package/src/cli/main.ts CHANGED
@@ -23,7 +23,7 @@ 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";
@@ -48,6 +48,8 @@ import type { LexiconPlugin } from "../lexicon";
48
48
  */
49
49
  const BOOLEAN_FLAGS = new Set([
50
50
  "--help",
51
+ "--agents",
52
+ "--all-projects",
51
53
  "--force",
52
54
  "--fix",
53
55
  "--watch",
@@ -254,6 +256,14 @@ export function parseArgs(args: string[]): ParsedArgs {
254
256
  result.env = args[++i];
255
257
  } else if (arg === "--tier") {
256
258
  result.tier = args[++i];
259
+ } else if (arg === "--agents") {
260
+ result.agents = true;
261
+ } else if (arg === "--scope") {
262
+ result.scope = args[++i];
263
+ } else if (arg === "--all-projects") {
264
+ result.allProjects = true;
265
+ } else if (arg === "--runtime") {
266
+ result.runtime = args[++i];
257
267
  } else if (arg === "--fail-on") {
258
268
  result.failOn = args[++i];
259
269
  } else if (arg === "--theme") {
@@ -438,16 +448,25 @@ Commands:
438
448
  bundle tree, otherwise JSON path→content on stdout)
439
449
  vendor Pull pinned, checksummed patterns into your repo
440
450
  import Import external template into TypeScript
451
+ (--agents re-expresses this machine's agent config as
452
+ chant code instead of reading a template file)
441
453
  audit [path|url] Audit a repo's CI YAML for security issues
442
454
  (--format stylish|json|sarif|markdown|html, -o <file>,
443
455
  --tier merge-worthy|all, --fail-on merge-worthy|warning|none,
444
456
  --template <file> / --theme <file> for the html report)
457
+ --agents audits this machine's agent configuration —
458
+ instruction files, MCP servers, skills, plugins,
459
+ permissions — instead of a repository
460
+ (--scope system,user,project, --runtime claude,codex,...,
461
+ --all-projects)
445
462
  migrate <file> Translate a workflow between lexicons
446
463
  (default: --from github --to gitlab)
447
- carve advise Read-only Terraform peelability advisor: rank which
448
- --from <tf-dir> resources are cheap to carve into native chant
464
+ carve advise Read-only peelability advisor: rank which resources
465
+ --from <dir> are cheap to carve into native chant
449
466
  (--json, --report <path>). Emits nothing, changes nothing.
450
- Needs @cdktf/hcl2json (npm install -D @cdktf/hcl2json).
467
+ --from a Terraform dir needs @cdktf/hcl2json
468
+ (npm install -D @cdktf/hcl2json); --from a CDK cloud
469
+ assembly (cdk.out) needs nothing and ranks constructs.
451
470
  carve emit Adopt a selected TF resource into chant source + report
452
471
  --from <tf-dir> its boundary. --state <tfstate> adopts offline
453
472
  --select <addr> (recommended for TF-managed resources); --env
@@ -539,6 +558,9 @@ Lifecycle (alias: lc):
539
558
  lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
540
559
  lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
541
560
  --json: emit the ChangeSet as JSON
561
+ lifecycle whoami <env> Who chant would act as in each configured lexicon,
562
+ and what that principal is scoped to — read-only,
563
+ before anything acts (--json, --strict)
542
564
  lifecycle teardown <env> Plan what deleting the environment would remove —
543
565
  marker-scoped (this project's stack + env); --yes
544
566
  executes the plan (production-like names also need
@@ -629,7 +651,8 @@ Options:
629
651
  --from <name> Source lexicon for migrate (default: github)
630
652
  --to <name> Target lexicon for migrate (default: gitlab)
631
653
  --emit <fmt> Migration output format: yaml (default) or ts
632
- --strict Escalate needs-review/validation to errors (migrate)
654
+ --strict Escalate needs-review/validation to errors (migrate);
655
+ exit nonzero on an unresolved identity (lifecycle whoami)
633
656
  --validate Run external validator (glci/glab) after migrate
634
657
  --use-composites Rewrite to composite calls when patterns match (migrate)
635
658
  --components Target discovered Component declarations instead of
@@ -865,6 +888,7 @@ const registry: CommandDef[] = [
865
888
  { name: "lifecycle rollback", handler: runLifecycleRollback },
866
889
  { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
867
890
  { name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
891
+ { name: "lifecycle whoami", requiresPlugins: true, handler: runLifecycleWhoami },
868
892
  { name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
869
893
  { name: "lifecycle log", handler: runLifecycleLog },
870
894
 
@@ -158,6 +158,21 @@ export interface ParsedArgs {
158
158
  includeDependents?: boolean;
159
159
  /** `chant audit --tier merge-worthy|all` */
160
160
  tier?: string;
161
+ /**
162
+ * `chant audit --agents` / `chant import --agents` — switch the subject from
163
+ * a repo's pipeline YAML to the agent configuration installed on this
164
+ * machine (skills, MCP servers, instruction files, permissions).
165
+ */
166
+ agents?: boolean;
167
+ /** `--agents --scope system,user,project` — which scopes to scan (default all). */
168
+ scope?: string;
169
+ /** `--agents --runtime claude,codex,...` — which agent harnesses to scan (default all). */
170
+ runtime?: string;
171
+ /**
172
+ * `--agents --all-projects` — scan every project root the harness has
173
+ * registered in `~/.claude.json`, not just the path argument.
174
+ */
175
+ allProjects?: boolean;
161
176
  /** `chant audit --fail-on merge-worthy|warning|none` */
162
177
  failOn?: string;
163
178
  /** `chant audit --theme <file>` — JSON theme knobs for the HTML report */
@@ -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
+ });