@intentius/chant 0.46.0 → 0.49.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 (183) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/lifecycle.d.ts +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  14. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  15. package/dist/cli/plugins.d.ts +1 -1
  16. package/dist/cli/plugins.d.ts.map +1 -1
  17. package/dist/cli/reporters/stylish.d.ts +15 -1
  18. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  19. package/dist/components/auto-release.d.ts +4 -0
  20. package/dist/components/auto-release.d.ts.map +1 -1
  21. package/dist/components/starter-plugin.d.ts +2 -0
  22. package/dist/components/starter-plugin.d.ts.map +1 -1
  23. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  24. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  25. package/dist/components/verbs/index.d.ts +8 -0
  26. package/dist/components/verbs/index.d.ts.map +1 -1
  27. package/dist/components/verbs/r2-sync.d.ts +76 -0
  28. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  29. package/dist/components/verbs/wrangler.d.ts +108 -0
  30. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  31. package/dist/config.d.ts +26 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/deep-observation.d.ts +14 -0
  34. package/dist/deep-observation.d.ts.map +1 -1
  35. package/dist/effect-receipt.d.ts +177 -0
  36. package/dist/effect-receipt.d.ts.map +1 -0
  37. package/dist/fold/subset.d.ts +15 -2
  38. package/dist/fold/subset.d.ts.map +1 -1
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/lexicon.d.ts +44 -3
  42. package/dist/lexicon.d.ts.map +1 -1
  43. package/dist/lifecycle/change-set.d.ts +33 -5
  44. package/dist/lifecycle/change-set.d.ts.map +1 -1
  45. package/dist/lifecycle/index.d.ts +2 -0
  46. package/dist/lifecycle/index.d.ts.map +1 -1
  47. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  48. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  49. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  50. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  51. package/dist/lifecycle/release-ledger.d.ts +20 -0
  52. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  53. package/dist/lifecycle/teardown.d.ts +6 -4
  54. package/dist/lifecycle/teardown.d.ts.map +1 -1
  55. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  56. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  57. package/dist/lint/knowledge-checks.d.ts +48 -0
  58. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  59. package/dist/lint/output-checks.d.ts +5 -0
  60. package/dist/lint/output-checks.d.ts.map +1 -0
  61. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  62. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  63. package/dist/lint/post-synth.d.ts +12 -0
  64. package/dist/lint/post-synth.d.ts.map +1 -1
  65. package/dist/lint/receipt-checks.d.ts +9 -0
  66. package/dist/lint/receipt-checks.d.ts.map +1 -0
  67. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  68. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  69. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  70. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  71. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  72. package/dist/lint/rules/index.d.ts +3 -1
  73. package/dist/lint/rules/index.d.ts.map +1 -1
  74. package/dist/okf-read.d.ts +78 -0
  75. package/dist/okf-read.d.ts.map +1 -0
  76. package/dist/op/builders.d.ts +98 -1
  77. package/dist/op/builders.d.ts.map +1 -1
  78. package/dist/op/index.d.ts +4 -2
  79. package/dist/op/index.d.ts.map +1 -1
  80. package/dist/op/local-executor.d.ts +2 -1
  81. package/dist/op/local-executor.d.ts.map +1 -1
  82. package/dist/op/receipt-store.d.ts +138 -0
  83. package/dist/op/receipt-store.d.ts.map +1 -0
  84. package/dist/op/types.d.ts +31 -1
  85. package/dist/op/types.d.ts.map +1 -1
  86. package/dist/secret-materialization.d.ts +138 -0
  87. package/dist/secret-materialization.d.ts.map +1 -0
  88. package/dist/secret-provenance.d.ts +218 -0
  89. package/dist/secret-provenance.d.ts.map +1 -0
  90. package/dist/serializer.d.ts +11 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/yaml.d.ts.map +1 -1
  93. package/package.json +4 -1
  94. package/src/audit/core.test.ts +57 -0
  95. package/src/audit/core.ts +0 -0
  96. package/src/audit/detect-bundle.test.ts +1 -1
  97. package/src/audit/discover.test.ts +24 -0
  98. package/src/audit/discover.ts +11 -2
  99. package/src/audit/rules-doc.ts +11 -1
  100. package/src/build.test.ts +41 -0
  101. package/src/build.ts +34 -6
  102. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  103. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  104. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  105. package/src/cli/commands/audit.test.ts +53 -0
  106. package/src/cli/commands/audit.ts +1 -1
  107. package/src/cli/commands/build.test.ts +80 -0
  108. package/src/cli/commands/build.ts +106 -8
  109. package/src/cli/commands/lint.ts +15 -3
  110. package/src/cli/handlers/explain.test.ts +70 -1
  111. package/src/cli/handlers/graph.ts +2 -2
  112. package/src/cli/handlers/lifecycle.test.ts +115 -1
  113. package/src/cli/handlers/lifecycle.ts +84 -11
  114. package/src/cli/mcp/resource-handlers.ts +38 -1
  115. package/src/cli/mcp/server.test.ts +58 -1
  116. package/src/cli/mcp/tools/explain.ts +51 -2
  117. package/src/cli/plugins.ts +4 -2
  118. package/src/cli/reporters/stylish.test.ts +154 -0
  119. package/src/cli/reporters/stylish.ts +154 -33
  120. package/src/components/auto-release.ts +6 -0
  121. package/src/components/registry.test.ts +7 -2
  122. package/src/components/starter-plugin.ts +17 -0
  123. package/src/components/verbs/ensure-secret.test.ts +130 -0
  124. package/src/components/verbs/ensure-secret.ts +79 -0
  125. package/src/components/verbs/index.ts +8 -0
  126. package/src/components/verbs/r2-sync.test.ts +107 -0
  127. package/src/components/verbs/r2-sync.ts +124 -0
  128. package/src/components/verbs/wrangler.test.ts +170 -0
  129. package/src/components/verbs/wrangler.ts +241 -0
  130. package/src/config.test.ts +15 -0
  131. package/src/config.ts +30 -0
  132. package/src/deep-observation.test.ts +19 -0
  133. package/src/deep-observation.ts +17 -0
  134. package/src/effect-receipt-exclusion.test.ts +190 -0
  135. package/src/effect-receipt.test.ts +419 -0
  136. package/src/effect-receipt.ts +412 -0
  137. package/src/fold/subset.test.ts +26 -0
  138. package/src/fold/subset.ts +45 -19
  139. package/src/index.ts +4 -0
  140. package/src/lexicon.ts +48 -3
  141. package/src/lifecycle/change-set.ts +46 -7
  142. package/src/lifecycle/index.ts +2 -0
  143. package/src/lifecycle/observation-baseline.test.ts +46 -0
  144. package/src/lifecycle/observation-baseline.ts +33 -1
  145. package/src/lifecycle/receipt-plan.test.ts +250 -0
  146. package/src/lifecycle/receipt-plan.ts +249 -0
  147. package/src/lifecycle/release-ledger.ts +20 -0
  148. package/src/lifecycle/teardown.test.ts +31 -0
  149. package/src/lifecycle/teardown.ts +6 -4
  150. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  151. package/src/lifecycle/unobserved-gate.ts +102 -0
  152. package/src/lint/knowledge-checks.test.ts +80 -0
  153. package/src/lint/knowledge-checks.ts +74 -0
  154. package/src/lint/output-checks.test.ts +85 -0
  155. package/src/lint/output-checks.ts +99 -0
  156. package/src/lint/pipeline-change-gate.test.ts +144 -0
  157. package/src/lint/pipeline-change-gate.ts +153 -0
  158. package/src/lint/post-synth.ts +15 -0
  159. package/src/lint/receipt-checks.test.ts +101 -0
  160. package/src/lint/receipt-checks.ts +93 -0
  161. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  162. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  163. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  164. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  165. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  166. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  167. package/src/lint/rules/index.ts +7 -1
  168. package/src/okf-read.test.ts +149 -0
  169. package/src/okf-read.ts +197 -0
  170. package/src/op/builders.ts +139 -1
  171. package/src/op/effect-step.test.ts +311 -0
  172. package/src/op/index.ts +10 -3
  173. package/src/op/local-executor.ts +172 -25
  174. package/src/op/op.test.ts +25 -2
  175. package/src/op/receipt-store.ts +211 -0
  176. package/src/op/types.ts +33 -1
  177. package/src/secret-materialization.test.ts +199 -0
  178. package/src/secret-materialization.ts +235 -0
  179. package/src/secret-provenance.test.ts +388 -0
  180. package/src/secret-provenance.ts +475 -0
  181. package/src/serializer.ts +12 -0
  182. package/src/yaml.test.ts +88 -0
  183. package/src/yaml.ts +76 -6
@@ -0,0 +1,93 @@
1
+ import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
2
+ import { isEffectReceipt } from "../effect-receipt";
3
+ import { isSensitiveKey } from "../deep-observation";
4
+ import { isResourceDeclarable } from "../declarable";
5
+
6
+ /**
7
+ * COR023: Receipt Materializes Into a Plain Store (#1833, epic #1703)
8
+ *
9
+ * A receipt's materialization target must be a plain store — never a
10
+ * SecureString, a Secret, or any secret-capable kind. A receipt value is a
11
+ * witness (an existence marker or a `sha256:` digest,
12
+ * ../effect-receipt.ts), not a secret: parking it in a secret-capable store
13
+ * invites masking, rotation, and access-control semantics that defeat the
14
+ * observe-and-compare loop the receipt exists for, and quietly teaches
15
+ * readers that the value is sensitive when it must never be.
16
+ *
17
+ * This is the honest core half of the guard. Core recognizes receipts
18
+ * lexicon-independently through the marker (#1831) but cannot know what any
19
+ * lexicon's kinds mean, so it checks the two signals it does have, both
20
+ * reusing the secret-kind knowledge core already carries
21
+ * (SENSITIVE_KEY_PATTERNS, ../deep-observation.ts):
22
+ *
23
+ * - the materialized `entityType` names a secret-capable kind
24
+ * (`K8s::Core::Secret`, `AWS::SecretsManager::Secret`, ...);
25
+ * - the declared props select a secret-capable variant of an otherwise
26
+ * plain kind (a `Type`/`Kind` prop whose value matches secret/secure —
27
+ * SSM's `Type: "SecureString"` is the canonical case).
28
+ *
29
+ * The concrete per-kind enforcement is each materialization row's job — the
30
+ * aws row (#1835) pins SSM to plain `String` at the source.
31
+ */
32
+
33
+ /** Prop names that select a store variant. */
34
+ const VARIANT_PROP = /^(type|kind)$/i;
35
+ /** Variant values that make the store secret-capable. */
36
+ const SECRET_VARIANT = /secret|secure/i;
37
+
38
+ export const RECEIPT_PLAIN_STORE_CHECK_ID = "COR023";
39
+
40
+ const receiptPlainStoreCheck: PostSynthCheck = {
41
+ id: RECEIPT_PLAIN_STORE_CHECK_ID,
42
+ description:
43
+ "An effect receipt's materialization target must be a plain store — never a secret-capable kind or variant",
44
+ check(ctx) {
45
+ const diagnostics: PostSynthDiagnostic[] = [];
46
+ for (const [name, entity] of ctx.entities) {
47
+ if (!isEffectReceipt(entity)) continue;
48
+
49
+ if (isSensitiveKey(entity.entityType)) {
50
+ diagnostics.push({
51
+ checkId: RECEIPT_PLAIN_STORE_CHECK_ID,
52
+ severity: "error",
53
+ entity: name,
54
+ lexicon: entity.lexicon,
55
+ message:
56
+ `Effect receipt "${name}" materializes into secret-capable kind "${entity.entityType}" — ` +
57
+ `a receipt's target must be a plain store. The receipt value is a witness ` +
58
+ `(an existence marker or a sha256 digest), not a secret; a secret-capable store adds ` +
59
+ `masking and rotation semantics that defeat the receipt's observe-and-compare loop.`,
60
+ });
61
+ continue;
62
+ }
63
+
64
+ if (isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null) {
65
+ for (const [key, value] of Object.entries(entity.props as Record<string, unknown>)) {
66
+ if (VARIANT_PROP.test(key) && typeof value === "string" && SECRET_VARIANT.test(value)) {
67
+ diagnostics.push({
68
+ checkId: RECEIPT_PLAIN_STORE_CHECK_ID,
69
+ severity: "error",
70
+ entity: name,
71
+ lexicon: entity.lexicon,
72
+ message:
73
+ `Effect receipt "${name}" (${entity.entityType}) declares ${key}: "${value}" — ` +
74
+ `a secret-capable store variant. A receipt's target must be a plain store; ` +
75
+ `use the plain variant (e.g. SSM Type: "String") so the witness value stays ` +
76
+ `readable to the observe-and-compare loop.`,
77
+ });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return diagnostics;
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Core's own post-synth checks over effect receipts, run by `chant build`
88
+ * over the full build result (plugin checks are lexicon-scoped; receipts are
89
+ * recognized by marker, so this set is deliberately not).
90
+ */
91
+ export function coreReceiptChecks(): PostSynthCheck[] {
92
+ return [receiptPlainStoreCheck];
93
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import { cor022ReceiptLeafRule, collectReceiptVariables } from "./cor022-receipt-leaf";
4
+ import type { LintContext } from "../rule";
5
+
6
+ function createContext(code: string, filePath = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath, lexicon: undefined };
9
+ }
10
+
11
+ describe("COR022: effect receipt is a leaf (#1833)", () => {
12
+ test("rule metadata", () => {
13
+ expect(cor022ReceiptLeafRule.id).toBe("COR022");
14
+ expect(cor022ReceiptLeafRule.severity).toBe("error");
15
+ expect(cor022ReceiptLeafRule.category).toBe("correctness");
16
+ });
17
+
18
+ test("flags a property access on a receipt inside another declarable", () => {
19
+ const ctx = createContext(`
20
+ export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
21
+ export const app = new Service({ marker: seeded.effect });
22
+ `);
23
+ const diags = cor022ReceiptLeafRule.check(ctx);
24
+ expect(diags).toHaveLength(1);
25
+ expect(diags[0].ruleId).toBe("COR022");
26
+ expect(diags[0].severity).toBe("error");
27
+ expect(diags[0].message).toContain('"seeded" is an effect receipt');
28
+ expect(diags[0].message).toContain('"effect"');
29
+ expect(diags[0].message).toContain("sole writer");
30
+ });
31
+
32
+ test("const indirection fires: const r = EffectReceipt(...); other.prop = r.something", () => {
33
+ const ctx = createContext(`
34
+ const r = EffectReceipt("migrated", { effect: "schema-migrate", flavor: "hash" });
35
+ export const other = new Store({});
36
+ other.prop = r.something;
37
+ `);
38
+ const diags = cor022ReceiptLeafRule.check(ctx);
39
+ expect(diags).toHaveLength(1);
40
+ expect(diags[0].message).toContain('"r" is an effect receipt');
41
+ expect(diags[0].message).toContain('"something"');
42
+ });
43
+
44
+ test("an alias of a receipt is still the receipt", () => {
45
+ const ctx = createContext(`
46
+ const r = EffectReceipt("migrated", { effect: "schema-migrate", flavor: "existence" });
47
+ const alias = r;
48
+ export const svc = new Service({ tag: alias.name });
49
+ `);
50
+ const diags = cor022ReceiptLeafRule.check(ctx);
51
+ expect(diags).toHaveLength(1);
52
+ expect(diags[0].message).toContain('"alias"');
53
+ });
54
+
55
+ test("element access fires like property access", () => {
56
+ const ctx = createContext(`
57
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
58
+ export const svc = new Service({ tag: r["effect"] });
59
+ `);
60
+ const diags = cor022ReceiptLeafRule.check(ctx);
61
+ expect(diags).toHaveLength(1);
62
+ expect(diags[0].message).toContain('"effect"');
63
+ });
64
+
65
+ test("namespaced factory call is recognized", () => {
66
+ const ctx = createContext(`
67
+ const r = chant.EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
68
+ export const svc = new Service({ tag: r.effect });
69
+ `);
70
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(1);
71
+ });
72
+
73
+ test("passes: a receipt that nothing dereferences", () => {
74
+ const ctx = createContext(`
75
+ import { EffectReceipt } from "@intentius/chant";
76
+ export const seeded = EffectReceipt("seeded", {
77
+ effect: "db-seed",
78
+ flavor: "hash",
79
+ inputs: { schema: "v3" },
80
+ });
81
+ export const app = new Service({ image: "app:1" });
82
+ `);
83
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(0);
84
+ });
85
+
86
+ test("passes: referencing the receipt value itself (no attribute access)", () => {
87
+ const ctx = createContext(`
88
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
89
+ registerReceipt(r);
90
+ `);
91
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(0);
92
+ });
93
+
94
+ test("passes: property access on a non-receipt declarable", () => {
95
+ const ctx = createContext(`
96
+ export const db = new Database({ name: "main" });
97
+ export const app = new Service({ endpoint: db.endpoint });
98
+ `);
99
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(0);
100
+ });
101
+
102
+ test("collectReceiptVariables resolves aliases declared before the receipt", () => {
103
+ const sf = ts.createSourceFile(
104
+ "t.ts",
105
+ `
106
+ const early = late;
107
+ const late = EffectReceipt("r", { effect: "e", flavor: "existence" });
108
+ `,
109
+ ts.ScriptTarget.Latest,
110
+ true,
111
+ );
112
+ const vars = collectReceiptVariables(sf);
113
+ expect(vars.has("late")).toBe(true);
114
+ expect(vars.has("early")).toBe(true);
115
+ });
116
+ });
@@ -0,0 +1,130 @@
1
+ import * as ts from "typescript";
2
+ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
3
+
4
+ /**
5
+ * COR022: Effect Receipt Is a Leaf (#1833, epic #1703)
6
+ *
7
+ * Nothing may reference an effect receipt's attributes. A receipt is the
8
+ * declared witness that an out-of-band effect ran — the `effect()` step
9
+ * (#1834) is its sole writer, on success, last (../../effect-receipt.ts).
10
+ * Any resource that derives a property from a receipt couples itself to a
11
+ * value only the effect controls: the coupling is invisible at synthesis,
12
+ * and the receipt's late write (or its absence after a crash) would ripple
13
+ * into resources that were supposed to be independent of whether the effect
14
+ * has fired yet.
15
+ *
16
+ * The rule walks the file's reference graph the way COR011 does: it collects
17
+ * every variable initialized from an `EffectReceipt(...)` call (aliases
18
+ * included — `const alias = receipt` is still the receipt), then flags every
19
+ * property or element access rooted at one of them. Const indirection fires:
20
+ *
21
+ * const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
22
+ * other.prop = r.something; // COR022
23
+ *
24
+ * Referencing the receipt VALUE (passing `r` itself around) is not flagged —
25
+ * the leaf constraint is about attributes, and lexicon materialization rows
26
+ * legitimately take the whole declaration.
27
+ *
28
+ * Core recognizes the factory call by name; a lexicon-materialized receipt
29
+ * row (#1835) is recognized at build time by its marker instead — this rule
30
+ * is the source-level half.
31
+ */
32
+
33
+ /** Simple callee name of a call: `EffectReceipt(...)` or `chant.EffectReceipt(...)`. */
34
+ function calleeName(expr: ts.CallExpression): string | undefined {
35
+ if (ts.isIdentifier(expr.expression)) return expr.expression.text;
36
+ if (ts.isPropertyAccessExpression(expr.expression)) return expr.expression.name.text;
37
+ return undefined;
38
+ }
39
+
40
+ /** A variable declaration's `EffectReceipt(...)` initializer, if that is what it is. */
41
+ export function receiptFactoryCall(decl: ts.VariableDeclaration): ts.CallExpression | undefined {
42
+ if (!decl.initializer || !ts.isCallExpression(decl.initializer)) return undefined;
43
+ return calleeName(decl.initializer) === "EffectReceipt" ? decl.initializer : undefined;
44
+ }
45
+
46
+ /**
47
+ * Every variable name bound to an effect receipt in this file: direct
48
+ * `EffectReceipt(...)` initializers plus identifier aliases, resolved to a
49
+ * fixpoint so declaration order does not matter. Scope-naive by design, the
50
+ * same trade COR011 makes — a chant source file is flat declarations.
51
+ */
52
+ export function collectReceiptVariables(sourceFile: ts.SourceFile): Set<string> {
53
+ const receipts = new Set<string>();
54
+ const aliases: Array<[string, string]> = [];
55
+
56
+ function visit(node: ts.Node): void {
57
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
58
+ if (receiptFactoryCall(node)) {
59
+ receipts.add(node.name.text);
60
+ } else if (ts.isIdentifier(node.initializer)) {
61
+ aliases.push([node.name.text, node.initializer.text]);
62
+ }
63
+ }
64
+ ts.forEachChild(node, visit);
65
+ }
66
+ visit(sourceFile);
67
+
68
+ let grew = true;
69
+ while (grew) {
70
+ grew = false;
71
+ for (const [name, source] of aliases) {
72
+ if (receipts.has(source) && !receipts.has(name)) {
73
+ receipts.add(name);
74
+ grew = true;
75
+ }
76
+ }
77
+ }
78
+ return receipts;
79
+ }
80
+
81
+ /** The accessed attribute's name, for the message: `r.something` → "something". */
82
+ function accessedAttribute(node: ts.PropertyAccessExpression | ts.ElementAccessExpression): string {
83
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
84
+ const arg = node.argumentExpression;
85
+ return ts.isStringLiteralLike(arg) ? arg.text : "<computed>";
86
+ }
87
+
88
+ export const cor022ReceiptLeafRule: LintRule = {
89
+ id: "COR022",
90
+ severity: "error",
91
+ category: "correctness",
92
+ description:
93
+ "An effect receipt is a leaf — nothing may reference its attributes; the effect() step is the receipt's sole writer",
94
+ check(context: LintContext): LintDiagnostic[] {
95
+ const receipts = collectReceiptVariables(context.sourceFile);
96
+ if (receipts.size === 0) return [];
97
+
98
+ const diagnostics: LintDiagnostic[] = [];
99
+
100
+ function visit(node: ts.Node): void {
101
+ if (
102
+ (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
103
+ ts.isIdentifier(node.expression) &&
104
+ receipts.has(node.expression.text)
105
+ ) {
106
+ const receipt = node.expression.text;
107
+ const attribute = accessedAttribute(node);
108
+ const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
109
+ node.getStart(context.sourceFile),
110
+ );
111
+ diagnostics.push({
112
+ file: context.filePath,
113
+ line: line + 1,
114
+ column: character + 1,
115
+ ruleId: "COR022",
116
+ severity: "error",
117
+ message:
118
+ `"${receipt}" is an effect receipt — a receipt is a leaf, and nothing may reference its ` +
119
+ `attributes ("${attribute}" here). The effect() step is the receipt's sole writer, so a ` +
120
+ `property derived from it couples this resource to a value only the effect controls. ` +
121
+ `Reference the effect's inputs (or the resources they come from) directly instead.`,
122
+ });
123
+ }
124
+ ts.forEachChild(node, visit);
125
+ }
126
+ visit(context.sourceFile);
127
+
128
+ return diagnostics;
129
+ },
130
+ };
@@ -0,0 +1,121 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import { cor024ReceiptSecretPointerRule } from "./cor024-receipt-secret-pointer";
4
+ import type { LintContext } from "../rule";
5
+
6
+ function createContext(code: string, filePath = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath, lexicon: undefined };
9
+ }
10
+
11
+ describe("COR024: receipt inputs reference secrets by pointer (#1833)", () => {
12
+ test("rule metadata", () => {
13
+ expect(cor024ReceiptSecretPointerRule.id).toBe("COR024");
14
+ expect(cor024ReceiptSecretPointerRule.severity).toBe("error");
15
+ expect(cor024ReceiptSecretPointerRule.category).toBe("correctness");
16
+ });
17
+
18
+ test("flags a receipt input reading a Secret entity's data attribute", () => {
19
+ const ctx = createContext(`
20
+ export const dbSecret = new SecretManagerSecret({ name: "db-password" });
21
+ export const seeded = EffectReceipt("seeded", {
22
+ effect: "db-seed",
23
+ flavor: "hash",
24
+ inputs: { password: dbSecret.data.password },
25
+ });
26
+ `);
27
+ const diags = cor024ReceiptSecretPointerRule.check(ctx);
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].ruleId).toBe("COR024");
30
+ expect(diags[0].severity).toBe("error");
31
+ expect(diags[0].message).toContain("dbSecret.data.password");
32
+ expect(diags[0].message).toContain("name+version pointer");
33
+ expect(diags[0].message).toContain("secretVersion");
34
+ });
35
+
36
+ test("flags a .value read on a Secret entity", () => {
37
+ const ctx = createContext(`
38
+ const apiToken = new ExternalSecret({ name: "api-token" });
39
+ export const bootstrapped = EffectReceipt("bootstrapped", {
40
+ effect: "bootstrap",
41
+ flavor: "hash",
42
+ inputs: { token: apiToken.value },
43
+ });
44
+ `);
45
+ const diags = cor024ReceiptSecretPointerRule.check(ctx);
46
+ expect(diags).toHaveLength(1);
47
+ expect(diags[0].message).toContain("apiToken.value");
48
+ });
49
+
50
+ test("const indirection fires: material extracted into a variable first", () => {
51
+ const ctx = createContext(`
52
+ const dbSecret = new SecretManagerSecret({ name: "db-password" });
53
+ const material = dbSecret.data;
54
+ export const seeded = EffectReceipt("seeded", {
55
+ effect: "db-seed",
56
+ flavor: "hash",
57
+ inputs: { password: material.password },
58
+ });
59
+ `);
60
+ const diags = cor024ReceiptSecretPointerRule.check(ctx);
61
+ expect(diags).toHaveLength(1);
62
+ expect(diags[0].message).toContain("material");
63
+ });
64
+
65
+ test("bare material variable as an input fires", () => {
66
+ const ctx = createContext(`
67
+ const dbSecret = new SecretManagerSecret({ name: "db-password" });
68
+ const material = dbSecret.stringData;
69
+ export const seeded = EffectReceipt("seeded", {
70
+ effect: "db-seed",
71
+ flavor: "hash",
72
+ inputs: { blob: material },
73
+ });
74
+ `);
75
+ expect(cor024ReceiptSecretPointerRule.check(ctx)).toHaveLength(1);
76
+ });
77
+
78
+ test("passes: the name+version pointer form", () => {
79
+ const ctx = createContext(`
80
+ export const dbSecret = new SecretManagerSecret({ name: "db-password" });
81
+ export const seeded = EffectReceipt("seeded", {
82
+ effect: "db-seed",
83
+ flavor: "hash",
84
+ inputs: { secretName: dbSecret.name, secretVersion: 3 },
85
+ });
86
+ `);
87
+ expect(cor024ReceiptSecretPointerRule.check(ctx)).toHaveLength(0);
88
+ });
89
+
90
+ test("passes: non-secret entity attributes and static inputs", () => {
91
+ const ctx = createContext(`
92
+ export const db = new Database({ name: "main" });
93
+ export const seeded = EffectReceipt("seeded", {
94
+ effect: "db-seed",
95
+ flavor: "hash",
96
+ inputs: { endpoint: db.endpoint, schema: "v3", value: db.port },
97
+ });
98
+ `);
99
+ expect(cor024ReceiptSecretPointerRule.check(ctx)).toHaveLength(0);
100
+ });
101
+
102
+ test("passes: secret material read OUTSIDE a receipt's inputs is not this rule's concern", () => {
103
+ const ctx = createContext(`
104
+ const dbSecret = new SecretManagerSecret({ name: "db-password" });
105
+ export const app = new Service({ env: { DB_PASSWORD: dbSecret.data.password } });
106
+ `);
107
+ expect(cor024ReceiptSecretPointerRule.check(ctx)).toHaveLength(0);
108
+ });
109
+
110
+ test("declareSecret provenance declarations are secret-rooted", () => {
111
+ const ctx = createContext(`
112
+ const dbSecret = declareSecret("db-password", { provenance: "referenced" });
113
+ export const seeded = EffectReceipt("seeded", {
114
+ effect: "db-seed",
115
+ flavor: "hash",
116
+ inputs: { v: dbSecret.value },
117
+ });
118
+ `);
119
+ expect(cor024ReceiptSecretPointerRule.check(ctx)).toHaveLength(1);
120
+ });
121
+ });
@@ -0,0 +1,218 @@
1
+ import * as ts from "typescript";
2
+ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
3
+ import { receiptFactoryCall } from "./cor022-receipt-leaf";
4
+
5
+ /**
6
+ * COR024: Receipt Inputs Reference Secrets by Pointer (#1833, epic #1703;
7
+ * composes with the secret-provenance work, #1365/#1828)
8
+ *
9
+ * A receipt's `inputs` feed its expectation hash (../../effect-receipt.ts) —
10
+ * whatever lands there is canonicalized, digested, and compared against a
11
+ * stored value for the rest of the receipt's life. chant's constitutional
12
+ * line on secrets is that no code path may hold, log, hash, or compare a
13
+ * secret value (../../secret-provenance.ts), so a receipt input that reads a
14
+ * Secret-kind entity's material (`.data`, `.value`, `.stringData`, ...)
15
+ * violates it twice over: the material enters the hash, and the serialized
16
+ * placeholder ties the receipt to the secret's value rather than its
17
+ * identity.
18
+ *
19
+ * The pointer form is fine — and is the point: reference the secret by name
20
+ * and version (`{ secretName: dbSecret.name, secretVersion: 3 }`), so a
21
+ * rotation is an explicit version bump that re-proposes the effect, and the
22
+ * hash only ever sees the pointer.
23
+ *
24
+ * Recognition is the same file-local reference walk COR022 uses (const
25
+ * indirection included): a variable is Secret-kind when its initializer is a
26
+ * constructor or factory whose name says so (`new SecretManagerSecret(...)`,
27
+ * `new ExternalSecret(...)`, `declareSecret(...)`); an input fires when a
28
+ * property chain rooted at one of those crosses a material field.
29
+ */
30
+
31
+ /** Fields that carry secret material. Mirrors secret-provenance.ts's
32
+ * FORBIDDEN_MATERIAL_FIELDS, plus the provider-attribute spellings
33
+ * (`secretString`/`secretValue`) a materialized secret exposes. */
34
+ const MATERIAL_FIELDS = new Set([
35
+ "value",
36
+ "data",
37
+ "stringdata",
38
+ "material",
39
+ "plaintext",
40
+ "ciphertext",
41
+ "secretstring",
42
+ "secretvalue",
43
+ ]);
44
+
45
+ function isMaterialField(name: string): boolean {
46
+ return MATERIAL_FIELDS.has(name.toLowerCase());
47
+ }
48
+
49
+ /** Simple name of a `new X(...)` / `X(...)` / `ns.X(...)` initializer. */
50
+ function initializerName(init: ts.Expression): string | undefined {
51
+ if (!ts.isNewExpression(init) && !ts.isCallExpression(init)) return undefined;
52
+ const callee = init.expression;
53
+ if (ts.isIdentifier(callee)) return callee.text;
54
+ if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
55
+ return undefined;
56
+ }
57
+
58
+ /** Does this constructor/factory name declare a Secret-kind entity? */
59
+ function isSecretKindName(name: string): boolean {
60
+ return /secret/i.test(name) || name === "declareSecret";
61
+ }
62
+
63
+ /** One access chain: `sec.data.password` → root "sec", segments ["data","password"]. */
64
+ function decomposeChain(
65
+ node: ts.Expression,
66
+ ): { root: string; segments: string[] } | undefined {
67
+ const segments: string[] = [];
68
+ let current: ts.Expression = node;
69
+ while (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
70
+ if (ts.isPropertyAccessExpression(current)) {
71
+ segments.unshift(current.name.text);
72
+ } else {
73
+ const arg = current.argumentExpression;
74
+ segments.unshift(ts.isStringLiteralLike(arg) ? arg.text : "<computed>");
75
+ }
76
+ current = current.expression;
77
+ }
78
+ if (!ts.isIdentifier(current)) return undefined;
79
+ return { root: current.text, segments };
80
+ }
81
+
82
+ interface SecretVars {
83
+ /** Variables holding a Secret-kind entity (or an alias of one). */
84
+ secretRooted: Set<string>;
85
+ /** Variables that already crossed a material field (`const m = sec.data`). */
86
+ material: Set<string>;
87
+ }
88
+
89
+ /** File-local Secret-kind variables, aliases and material extractions, to a fixpoint. */
90
+ function collectSecretVariables(sourceFile: ts.SourceFile): SecretVars {
91
+ const secretRooted = new Set<string>();
92
+ const material = new Set<string>();
93
+ const derived: Array<{ name: string; root: string; crossesMaterial: boolean; isBareAlias: boolean }> = [];
94
+
95
+ function visit(node: ts.Node): void {
96
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
97
+ const init = node.initializer;
98
+ const ctorName = initializerName(init);
99
+ if (ctorName !== undefined && isSecretKindName(ctorName)) {
100
+ secretRooted.add(node.name.text);
101
+ } else if (ts.isIdentifier(init)) {
102
+ derived.push({ name: node.name.text, root: init.text, crossesMaterial: false, isBareAlias: true });
103
+ } else if (ts.isPropertyAccessExpression(init) || ts.isElementAccessExpression(init)) {
104
+ const chain = decomposeChain(init);
105
+ if (chain) {
106
+ derived.push({
107
+ name: node.name.text,
108
+ root: chain.root,
109
+ crossesMaterial: chain.segments.some(isMaterialField),
110
+ isBareAlias: false,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ ts.forEachChild(node, visit);
116
+ }
117
+ visit(sourceFile);
118
+
119
+ let grew = true;
120
+ while (grew) {
121
+ grew = false;
122
+ for (const d of derived) {
123
+ const rootIsSecret = secretRooted.has(d.root);
124
+ const rootIsMaterial = material.has(d.root);
125
+ if (!rootIsSecret && !rootIsMaterial) continue;
126
+ const target = rootIsMaterial || d.crossesMaterial ? material : secretRooted;
127
+ if (d.isBareAlias && !d.crossesMaterial && rootIsSecret) {
128
+ if (!secretRooted.has(d.name)) {
129
+ secretRooted.add(d.name);
130
+ grew = true;
131
+ }
132
+ } else if (!target.has(d.name)) {
133
+ target.add(d.name);
134
+ grew = true;
135
+ }
136
+ }
137
+ }
138
+ return { secretRooted, material };
139
+ }
140
+
141
+ export const cor024ReceiptSecretPointerRule: LintRule = {
142
+ id: "COR024",
143
+ severity: "error",
144
+ category: "correctness",
145
+ description:
146
+ "A receipt's inputs may reference a secret only by name+version pointer, never by value — secret material must not enter the expectation hash",
147
+ check(context: LintContext): LintDiagnostic[] {
148
+ const diagnostics: LintDiagnostic[] = [];
149
+ const secrets = collectSecretVariables(context.sourceFile);
150
+ if (secrets.secretRooted.size === 0 && secrets.material.size === 0) return diagnostics;
151
+
152
+ const report = (node: ts.Node, rendered: string, root: string): void => {
153
+ const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
154
+ node.getStart(context.sourceFile),
155
+ );
156
+ diagnostics.push({
157
+ file: context.filePath,
158
+ line: line + 1,
159
+ column: character + 1,
160
+ ruleId: "COR024",
161
+ severity: "error",
162
+ message:
163
+ `Receipt input reads secret material (${rendered}) from "${root}" — a receipt's inputs may ` +
164
+ `reference a secret only by name+version pointer, never by value: the inputs are hashed into ` +
165
+ `the receipt's expectation, and no chant code path may hash or compare secret material. ` +
166
+ `Use the pointer form instead, e.g. { secretName: ${root}.name, secretVersion: 3 } — ` +
167
+ `a rotation then re-proposes the effect through an explicit version bump.`,
168
+ });
169
+ };
170
+
171
+ const checkInputs = (inputs: ts.Expression): void => {
172
+ const visit = (node: ts.Node): void => {
173
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
174
+ const chain = decomposeChain(node);
175
+ if (chain) {
176
+ const rootMaterial = secrets.material.has(chain.root);
177
+ const crossesMaterial = chain.segments.some(isMaterialField);
178
+ if (rootMaterial || (secrets.secretRooted.has(chain.root) && crossesMaterial)) {
179
+ report(node, `\`${chain.root}.${chain.segments.join(".")}\``, chain.root);
180
+ return; // one diagnostic per chain, not one per link
181
+ }
182
+ }
183
+ } else if (ts.isIdentifier(node) && secrets.material.has(node.text)) {
184
+ const parent = node.parent;
185
+ const isChainBase =
186
+ (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
187
+ parent.expression === node;
188
+ const isKey = ts.isPropertyAssignment(parent) && parent.name === node;
189
+ if (!isChainBase && !isKey) report(node, `\`${node.text}\``, node.text);
190
+ }
191
+ ts.forEachChild(node, visit);
192
+ };
193
+ visit(inputs);
194
+ };
195
+
196
+ const walk = (node: ts.Node): void => {
197
+ if (ts.isVariableDeclaration(node)) {
198
+ const call = receiptFactoryCall(node);
199
+ const options = call?.arguments[1];
200
+ if (options && ts.isObjectLiteralExpression(options)) {
201
+ for (const prop of options.properties) {
202
+ if (
203
+ ts.isPropertyAssignment(prop) &&
204
+ (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
205
+ prop.name.text === "inputs"
206
+ ) {
207
+ checkInputs(prop.initializer);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ ts.forEachChild(node, walk);
213
+ };
214
+ walk(context.sourceFile);
215
+
216
+ return diagnostics;
217
+ },
218
+ };