@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,475 @@
1
+ /**
2
+ * Secret provenance vocabulary (chant #1828, epic #1365).
3
+ *
4
+ * A secret's PROVENANCE is where its value comes from — never what the value
5
+ * is. chant's constitutional line on secrets: no code path may hold, log,
6
+ * hash, or compare a secret value. This module makes that structural: the
7
+ * declaration types have no field that could carry material, the factory
8
+ * rejects material-shaped fields at both the type level (`never` fields) and
9
+ * at runtime (own-property check, naming only the offending KEY), and
10
+ * "mismatch" anywhere downstream means presence, declared key-set, and
11
+ * metadata — never a value or a value-derived hash (#1365 decision 6).
12
+ *
13
+ * The closed kind set:
14
+ *
15
+ * - `referenced` — the value exists out of band (a human or an external
16
+ * process put it where consumers read it). chant records only that the
17
+ * estate depends on it, which is what lets the consumed-but-unproduced
18
+ * check (#1382) ship as an error instead of a guess.
19
+ * - `from-provider` — a declared provider binding materializes it (the
20
+ * operator/CRD seam; `K8s::Infisical::InfisicalSecret` from #1321 is the
21
+ * exemplar). The declaration POINTS at that binding, it does not re-model
22
+ * it.
23
+ * - `generated-once` — minted on first materialization, then never
24
+ * regenerated (present means done). The declaration carries contract flags
25
+ * only — e.g. the declared key-set — never mint parameters holding
26
+ * material. `generated-once` secrets never enter the prunable set (#1365
27
+ * decision 5).
28
+ * - `committed-encrypted` — sops-style ciphertext committed in the repo,
29
+ * decrypted by the delivery system (Flux) straight into the target. The
30
+ * declaration records a repo-relative PATH to the ciphertext, never the
31
+ * bytes: the factory is pure and touches no filesystem, so it still folds
32
+ * under `--sandbox`. The bytes are read at `buildRoots()` — the one
33
+ * sanctioned impure seam — and emitted as a sidecar file, never as a
34
+ * document in the primary output. See
35
+ * `docs/design/committed-encrypted-sops-provenance.md`.
36
+ *
37
+ * Declarations are serializer-neutral: discovery collects them like any
38
+ * entity (they are Declarables), but `partitionByLexicon` (../build.ts)
39
+ * excludes them from every serializer partition, so no lexicon ever emits
40
+ * them. They are data that lint rules and lexicons READ (via
41
+ * {@link collectSecretDeclarations}), not output.
42
+ */
43
+
44
+ import { DECLARABLE_MARKER, type Declarable } from "./declarable";
45
+
46
+ /** The closed union of secret origins. */
47
+ export type SecretProvenance =
48
+ | "referenced"
49
+ | "from-provider"
50
+ | "generated-once"
51
+ | "committed-encrypted";
52
+
53
+ /** Every provenance kind, for exhaustiveness checks. */
54
+ export const SECRET_PROVENANCE_KINDS: readonly SecretProvenance[] = [
55
+ "referenced",
56
+ "from-provider",
57
+ "generated-once",
58
+ "committed-encrypted",
59
+ ];
60
+
61
+ /**
62
+ * Encryption tools a {@link CommittedEncryptedSecretDeclaration} understands.
63
+ * A closed union with one member, not a free string: a second tool later is a
64
+ * deliberate widening with a detection rule attached, not an unvalidated
65
+ * string that silently means nothing.
66
+ */
67
+ export type SecretEncryption = "sops";
68
+
69
+ /** Every encryption tool, for exhaustiveness checks. */
70
+ export const SECRET_ENCRYPTION_TOOLS: readonly SecretEncryption[] = ["sops"];
71
+
72
+ /**
73
+ * File extensions a committed ciphertext path may carry (v1).
74
+ *
75
+ * The CLI writer round-trips every additional file through `JSON.parse` and
76
+ * re-emits it key-sorted when the parse succeeds
77
+ * (`./cli/commands/build.ts`), which would silently rewrite a `.sops.json`
78
+ * file and break the byte-for-byte guarantee. v1 refuses anything but YAML;
79
+ * the writer additionally skips the round trip for these files, so byte
80
+ * identity is structural rather than a happy accident of JSON.parse failing.
81
+ */
82
+ const CIPHERTEXT_FILE_EXTENSIONS = [".yaml", ".yml"] as const;
83
+
84
+ /**
85
+ * Patterns that mean a PRIVATE key was pasted where a public recipient
86
+ * belongs. Matched against `recipients` entries; the thrown message names the
87
+ * field and the index, never the value.
88
+ */
89
+ const PRIVATE_KEY_MARKERS = [/AGE-SECRET-KEY-/i, /-----BEGIN [A-Z ]*PRIVATE KEY-----/];
90
+
91
+ /** Marker symbol identifying a secret provenance declaration. `Symbol.for` so
92
+ * it survives the entity-wire codec (../discovery/entity-wire-codec.ts). */
93
+ export const SECRET_DECLARATION_MARKER = Symbol.for("chant.secret-declaration");
94
+
95
+ /** The `entityType` every secret declaration carries. */
96
+ export const SECRET_DECLARATION_ENTITY_TYPE = "Chant::SecretProvenance";
97
+
98
+ /**
99
+ * Points a `from-provider` declaration at the provider binding that
100
+ * materializes the secret — the CRD seam, not a re-model of it. The binding
101
+ * itself is an ordinary declarable in its own lexicon (e.g. an
102
+ * `InfisicalSecret` instance, #1321); this ref names it so lint can resolve
103
+ * the pair without core depending on any lexicon.
104
+ */
105
+ export interface SecretProviderRef {
106
+ /** Entity name (export name) of the provider binding declarable in this project. */
107
+ readonly binding: string;
108
+ /**
109
+ * Expected `entityType` of the binding (e.g.
110
+ * `"K8s::Infisical::InfisicalSecret"`). Optional; when present, lint can
111
+ * verify the named binding is actually the kind of seam the declaration
112
+ * claims.
113
+ */
114
+ readonly entityType?: string;
115
+ }
116
+
117
+ /**
118
+ * Fields that would carry secret material, forbidden by construction. Typed
119
+ * as `never` on every factory input so they fail to compile even when the
120
+ * input object was widened or spread (plain excess-property checks only catch
121
+ * object literals). The runtime check in {@link declareSecret} is the same
122
+ * list, for untyped callers.
123
+ */
124
+ interface NoSecretMaterial {
125
+ readonly value?: never;
126
+ readonly data?: never;
127
+ readonly stringData?: never;
128
+ readonly material?: never;
129
+ readonly plaintext?: never;
130
+ readonly ciphertext?: never;
131
+ }
132
+
133
+ const FORBIDDEN_MATERIAL_FIELDS = [
134
+ "value",
135
+ "data",
136
+ "stringData",
137
+ "material",
138
+ "plaintext",
139
+ "ciphertext",
140
+ ] as const;
141
+
142
+ /** Fields shared by every secret declaration. */
143
+ interface SecretDeclarationBase extends Declarable {
144
+ readonly [SECRET_DECLARATION_MARKER]: true;
145
+ readonly lexicon: "chant";
146
+ readonly entityType: typeof SECRET_DECLARATION_ENTITY_TYPE;
147
+ /** The secret's name as consumers know it (e.g. the k8s Secret name). */
148
+ readonly name: string;
149
+ /** Which of the closed kind set this declaration is. */
150
+ readonly provenance: SecretProvenance;
151
+ }
152
+
153
+ /** A secret whose value exists out of band. */
154
+ export interface ReferencedSecretDeclaration extends SecretDeclarationBase {
155
+ readonly provenance: "referenced";
156
+ /** Where consumers find it — free-form (a namespace, a vault path, a doc link). */
157
+ readonly scope?: string;
158
+ }
159
+
160
+ /** A secret a declared provider binding materializes. */
161
+ export interface FromProviderSecretDeclaration extends SecretDeclarationBase {
162
+ readonly provenance: "from-provider";
163
+ readonly provider: SecretProviderRef;
164
+ }
165
+
166
+ /** A secret minted on first materialization and never regenerated. */
167
+ export interface GeneratedOnceSecretDeclaration extends SecretDeclarationBase {
168
+ readonly provenance: "generated-once";
169
+ /**
170
+ * The declared key-set of the materialized secret. This is the contract a
171
+ * materializer (#1830) checks for mismatch — presence and keys, never
172
+ * values.
173
+ */
174
+ readonly keys?: readonly string[];
175
+ }
176
+
177
+ /**
178
+ * A secret whose ciphertext is committed to the repo and decrypted by the
179
+ * delivery system on the way into the target.
180
+ *
181
+ * The declaration is a POINTER: `file` is a repo-relative path, and nothing
182
+ * here carries bytes. That is what keeps the factory pure — the ciphertext is
183
+ * read at `buildRoots()` and emitted as a sidecar, never inlined into the
184
+ * primary output an applier reads.
185
+ */
186
+ export interface CommittedEncryptedSecretDeclaration extends SecretDeclarationBase {
187
+ readonly provenance: "committed-encrypted";
188
+ /** Repo-relative path to the committed ciphertext file. */
189
+ readonly file: string;
190
+ /** Encryption tool. Defaults to `"sops"` when omitted. */
191
+ readonly encryption: SecretEncryption;
192
+ /**
193
+ * Public recipient identifiers — age recipients or PGP fingerprints. Public
194
+ * by definition, which is the opposite of material; a private key here is
195
+ * refused by the factory.
196
+ */
197
+ readonly recipients?: readonly string[];
198
+ /**
199
+ * The declared key-set of the decrypted Secret — the same contract meaning
200
+ * as {@link GeneratedOnceSecretDeclaration.keys}: presence and key names,
201
+ * never values. SOPS leaves key NAMES cleartext, so this is checkable
202
+ * against the file itself.
203
+ */
204
+ readonly keys?: readonly string[];
205
+ }
206
+
207
+ /** A secret provenance declaration — the discriminant is `provenance`. */
208
+ export type SecretDeclaration =
209
+ | ReferencedSecretDeclaration
210
+ | FromProviderSecretDeclaration
211
+ | GeneratedOnceSecretDeclaration
212
+ | CommittedEncryptedSecretDeclaration;
213
+
214
+ /** Narrow a declaration to the committed-encrypted kind. */
215
+ export function isCommittedEncryptedSecret(
216
+ decl: SecretDeclaration,
217
+ ): decl is CommittedEncryptedSecretDeclaration {
218
+ return decl.provenance === "committed-encrypted";
219
+ }
220
+
221
+ /** Factory input for a `referenced` secret. */
222
+ export interface ReferencedSecretInput extends NoSecretMaterial {
223
+ readonly name: string;
224
+ readonly provenance: "referenced";
225
+ readonly scope?: string;
226
+ }
227
+
228
+ /** Factory input for a `from-provider` secret. */
229
+ export interface FromProviderSecretInput extends NoSecretMaterial {
230
+ readonly name: string;
231
+ readonly provenance: "from-provider";
232
+ readonly provider: SecretProviderRef;
233
+ }
234
+
235
+ /** Factory input for a `generated-once` secret. */
236
+ export interface GeneratedOnceSecretInput extends NoSecretMaterial {
237
+ readonly name: string;
238
+ readonly provenance: "generated-once";
239
+ readonly keys?: readonly string[];
240
+ }
241
+
242
+ /** Factory input for a `committed-encrypted` secret. */
243
+ export interface CommittedEncryptedSecretInput extends NoSecretMaterial {
244
+ readonly name: string;
245
+ readonly provenance: "committed-encrypted";
246
+ /**
247
+ * Repo-relative path to the committed ciphertext file — `file`, never
248
+ * `ciphertext`: the declaration points at bytes, it does not carry them,
249
+ * and `ciphertext` is a forbidden material field.
250
+ */
251
+ readonly file: string;
252
+ /** Encryption tool. Closed union; `"sops"` is its only member today. */
253
+ readonly encryption?: SecretEncryption;
254
+ /** Public recipient identifiers — age recipients or PGP fingerprints. */
255
+ readonly recipients?: readonly string[];
256
+ /** The declared key-set of the decrypted Secret. Names only, never values. */
257
+ readonly keys?: readonly string[];
258
+ }
259
+
260
+ export type SecretDeclarationInput =
261
+ | ReferencedSecretInput
262
+ | FromProviderSecretInput
263
+ | GeneratedOnceSecretInput
264
+ | CommittedEncryptedSecretInput;
265
+
266
+ /**
267
+ * Declare a secret's provenance. The returned object is a locked Declarable:
268
+ * discovery collects it like any entity, `chant list` shows it, lint and
269
+ * lexicons read it — and no serializer ever emits it.
270
+ *
271
+ * Only the fields the kind defines are copied onto the declaration; anything
272
+ * else on the input — in particular anything that could carry material — is
273
+ * either a compile error ({@link NoSecretMaterial}) or a thrown error here.
274
+ * The thrown message names the offending KEY only, never its value.
275
+ */
276
+ export function declareSecret(input: ReferencedSecretInput): ReferencedSecretDeclaration;
277
+ export function declareSecret(input: FromProviderSecretInput): FromProviderSecretDeclaration;
278
+ export function declareSecret(input: GeneratedOnceSecretInput): GeneratedOnceSecretDeclaration;
279
+ export function declareSecret(
280
+ input: CommittedEncryptedSecretInput,
281
+ ): CommittedEncryptedSecretDeclaration;
282
+ export function declareSecret(input: SecretDeclarationInput): SecretDeclaration {
283
+ if (typeof input.name !== "string" || input.name.length === 0) {
284
+ throw new Error("declareSecret: `name` must be a non-empty string");
285
+ }
286
+ if (!SECRET_PROVENANCE_KINDS.includes(input.provenance)) {
287
+ throw new Error(
288
+ `declareSecret("${input.name}"): unknown provenance "${String(input.provenance)}" — ` +
289
+ `expected one of ${SECRET_PROVENANCE_KINDS.join(", ")}`,
290
+ );
291
+ }
292
+ for (const field of FORBIDDEN_MATERIAL_FIELDS) {
293
+ if (Object.prototype.hasOwnProperty.call(input, field)) {
294
+ // Name the key only. Never read, log, or echo the value.
295
+ throw new Error(
296
+ `declareSecret("${input.name}"): field "${field}" is not allowed — ` +
297
+ `a secret declaration records provenance, never material`,
298
+ );
299
+ }
300
+ }
301
+
302
+ const base = {
303
+ [DECLARABLE_MARKER]: true,
304
+ [SECRET_DECLARATION_MARKER]: true,
305
+ lexicon: "chant",
306
+ entityType: SECRET_DECLARATION_ENTITY_TYPE,
307
+ name: input.name,
308
+ } as const;
309
+
310
+ // Copy known fields per kind explicitly — never spread `input`, so a field
311
+ // that slipped past the type system still cannot land on the declaration.
312
+ // The top-level object stays extensible (discovery stamps logical-name and
313
+ // provenance symbols onto entities); the declared fields themselves are
314
+ // defined non-writable below, and nested structures are frozen.
315
+ switch (input.provenance) {
316
+ case "referenced": {
317
+ const decl: ReferencedSecretDeclaration = {
318
+ ...base,
319
+ provenance: "referenced",
320
+ ...(input.scope !== undefined ? { scope: input.scope } : {}),
321
+ };
322
+ return lockDeclaredFields(decl);
323
+ }
324
+ case "from-provider": {
325
+ if (typeof input.provider?.binding !== "string" || input.provider.binding.length === 0) {
326
+ throw new Error(
327
+ `declareSecret("${input.name}"): from-provider requires \`provider.binding\` ` +
328
+ `naming the provider binding entity`,
329
+ );
330
+ }
331
+ const decl: FromProviderSecretDeclaration = {
332
+ ...base,
333
+ provenance: "from-provider",
334
+ provider: Object.freeze({
335
+ binding: input.provider.binding,
336
+ ...(input.provider.entityType !== undefined ? { entityType: input.provider.entityType } : {}),
337
+ }),
338
+ };
339
+ return lockDeclaredFields(decl);
340
+ }
341
+ case "generated-once": {
342
+ const decl: GeneratedOnceSecretDeclaration = {
343
+ ...base,
344
+ provenance: "generated-once",
345
+ ...(input.keys !== undefined ? { keys: Object.freeze([...input.keys]) } : {}),
346
+ };
347
+ return lockDeclaredFields(decl);
348
+ }
349
+ case "committed-encrypted": {
350
+ const decl: CommittedEncryptedSecretDeclaration = {
351
+ ...base,
352
+ provenance: "committed-encrypted",
353
+ file: validateCiphertextPath(input.name, input.file),
354
+ encryption: validateEncryption(input.name, input.encryption),
355
+ ...(input.recipients !== undefined
356
+ ? { recipients: Object.freeze(validateRecipients(input.name, input.recipients)) }
357
+ : {}),
358
+ ...(input.keys !== undefined ? { keys: Object.freeze([...input.keys]) } : {}),
359
+ };
360
+ return lockDeclaredFields(decl);
361
+ }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Validate the repo-relative ciphertext path. Pure and offline — the factory
367
+ * never stats the file, because discovery folds project source statically
368
+ * under `--sandbox` and a factory that touched the filesystem would either
369
+ * break folding or reintroduce the trust boundary that suite defends. The
370
+ * file's existence and shape are checked at `buildRoots()` instead.
371
+ */
372
+ function validateCiphertextPath(name: string, file: unknown): string {
373
+ if (typeof file !== "string" || file.length === 0) {
374
+ throw new Error(
375
+ `declareSecret("${name}"): committed-encrypted requires \`file\`, a non-empty ` +
376
+ `repo-relative path to the committed ciphertext`,
377
+ );
378
+ }
379
+ if (file.startsWith("/") || /^[A-Za-z]:[\\/]/.test(file)) {
380
+ throw new Error(
381
+ `declareSecret("${name}"): \`file\` must be repo-relative, not absolute — got "${file}"`,
382
+ );
383
+ }
384
+ const segments = file.split(/[\\/]/);
385
+ if (segments.includes("..")) {
386
+ throw new Error(
387
+ `declareSecret("${name}"): \`file\` must not escape the project with a ".." segment — ` +
388
+ `got "${file}"`,
389
+ );
390
+ }
391
+ const dot = file.lastIndexOf(".");
392
+ const extension = dot === -1 ? "" : file.slice(dot).toLowerCase();
393
+ if (!(CIPHERTEXT_FILE_EXTENSIONS as readonly string[]).includes(extension)) {
394
+ throw new Error(
395
+ `declareSecret("${name}"): \`file\` must be a YAML file ` +
396
+ `(${CIPHERTEXT_FILE_EXTENSIONS.join(", ")}) — got "${extension || "no extension"}". ` +
397
+ `Other formats are not emitted byte-for-byte yet.`,
398
+ );
399
+ }
400
+ return file;
401
+ }
402
+
403
+ /** Validate the encryption tool, defaulting to `"sops"`. */
404
+ function validateEncryption(name: string, encryption: unknown): SecretEncryption {
405
+ if (encryption === undefined) return "sops";
406
+ if (!(SECRET_ENCRYPTION_TOOLS as readonly unknown[]).includes(encryption)) {
407
+ throw new Error(
408
+ `declareSecret("${name}"): unknown encryption "${String(encryption)}" — ` +
409
+ `expected one of ${SECRET_ENCRYPTION_TOOLS.join(", ")}`,
410
+ );
411
+ }
412
+ return encryption as SecretEncryption;
413
+ }
414
+
415
+ /**
416
+ * Copy `recipients`, refusing a private key pasted where a public recipient
417
+ * belongs. The message names the field and the index — never the value, the
418
+ * same discipline the forbidden-material check above uses.
419
+ */
420
+ function validateRecipients(name: string, recipients: readonly string[]): string[] {
421
+ const copied = [...recipients];
422
+ copied.forEach((recipient, index) => {
423
+ if (typeof recipient !== "string" || recipient.length === 0) {
424
+ throw new Error(
425
+ `declareSecret("${name}"): \`recipients[${index}]\` must be a non-empty string`,
426
+ );
427
+ }
428
+ if (PRIVATE_KEY_MARKERS.some((pattern) => pattern.test(recipient))) {
429
+ throw new Error(
430
+ `declareSecret("${name}"): \`recipients[${index}]\` looks like a PRIVATE key — ` +
431
+ `recipients are public identifiers (age recipients, PGP fingerprints). ` +
432
+ `Rotate it: it has been in a source file.`,
433
+ );
434
+ }
435
+ });
436
+ return copied;
437
+ }
438
+
439
+ /**
440
+ * Make every declared (string-keyed) field non-writable and non-configurable
441
+ * without sealing the object — discovery still stamps its own symbol-keyed
442
+ * metadata (logical name, build provenance) onto entities, which a frozen
443
+ * object would reject.
444
+ */
445
+ function lockDeclaredFields<T extends object>(decl: T): T {
446
+ for (const key of Object.keys(decl)) {
447
+ Object.defineProperty(decl, key, { writable: false, configurable: false });
448
+ }
449
+ return decl;
450
+ }
451
+
452
+ /** Type guard for a secret provenance declaration. */
453
+ export function isSecretDeclaration(value: unknown): value is SecretDeclaration {
454
+ return (
455
+ typeof value === "object" &&
456
+ value !== null &&
457
+ SECRET_DECLARATION_MARKER in value &&
458
+ (value as Record<symbol, unknown>)[SECRET_DECLARATION_MARKER] === true
459
+ );
460
+ }
461
+
462
+ /**
463
+ * Extract the secret declarations from a discovered entity map — the read
464
+ * surface for lint rules (#1382) and lexicon materializers (#1830). Keyed by
465
+ * entity name (export name), the same key `DiscoveryResult.entities` uses.
466
+ */
467
+ export function collectSecretDeclarations(
468
+ entities: ReadonlyMap<string, Declarable>,
469
+ ): Map<string, SecretDeclaration> {
470
+ const out = new Map<string, SecretDeclaration>();
471
+ for (const [name, entity] of entities) {
472
+ if (isSecretDeclaration(entity)) out.set(name, entity);
473
+ }
474
+ return out;
475
+ }
package/src/serializer.ts CHANGED
@@ -20,6 +20,18 @@ export interface SerializeContext {
20
20
  * ad-hoc builds (e.g. context tools) that pass no config.
21
21
  */
22
22
  config?: Record<string, unknown>;
23
+
24
+ /**
25
+ * Effect receipts (#1832, epic #1703) declared in this lexicon's partition.
26
+ * Withheld from the `entities` map by the build: receipts are observe-only
27
+ * to the generic apply path, and the serialized output is what feeds
28
+ * appliers, so a receipt must never enter the apply-bound document. A
29
+ * serializer that wants to render receipts for visibility (#1835) reads
30
+ * them here and puts them anywhere BUT the section an applier writes from;
31
+ * a serializer that ignores this field emits nothing for them, which is the
32
+ * safe default.
33
+ */
34
+ receipts?: ReadonlyMap<string, Declarable>;
23
35
  }
24
36
 
25
37
  /**
package/src/yaml.test.ts CHANGED
@@ -226,6 +226,94 @@ describe("parseScalar", () => {
226
226
  expect(parseScalar("hello")).toBe("hello");
227
227
  });
228
228
  });
229
+ // Quoted scalars (#1860) — stripping the delimiters is only half the job; the
230
+ // escapes inside them have to be decoded too. A double-quoted `\n` that stayed
231
+ // two literal characters is what sent a seven-line setup script to a shell as
232
+ // one line.
233
+ describe("parseScalar quoted scalars (#1860)", () => {
234
+ test("double-quoted escapes decode", () => {
235
+ expect(parseScalar(String.raw`"a\nb"`)).toBe("a\nb");
236
+ expect(parseScalar(String.raw`"a\tb"`)).toBe("a\tb");
237
+ expect(parseScalar(String.raw`"say \"hi\""`)).toBe('say "hi"');
238
+ expect(parseScalar(String.raw`"back\\slash"`)).toBe("back\\slash");
239
+ expect(parseScalar(String.raw`"a\/b"`)).toBe("a/b");
240
+ });
241
+
242
+ test("hex, unicode and 32-bit escapes", () => {
243
+ expect(parseScalar(String.raw`"\x41"`)).toBe("A");
244
+ expect(parseScalar(String.raw`"A"`)).toBe("A");
245
+ expect(parseScalar(String.raw`"\U0001F600"`)).toBe("\u{1F600}");
246
+ });
247
+
248
+ test("an escaped line break folds away with its indentation", () => {
249
+ expect(parseScalar('"a\\\n b"')).toBe("ab");
250
+ });
251
+
252
+ test("single-quoted '' is one quote", () => {
253
+ expect(parseScalar("'it''s'")).toBe("it's");
254
+ expect(parseScalar("'''quoted'''")).toBe("'quoted'");
255
+ });
256
+
257
+ test("a lone quote character is not a quoted scalar", () => {
258
+ expect(parseScalar('"')).toBe('"');
259
+ expect(parseScalar("'")).toBe("'");
260
+ });
261
+
262
+ test("a body with a bare quote keeps the old strip-only behaviour", () => {
263
+ expect(parseScalar('"a" + "b"')).toBe('a" + "b');
264
+ });
265
+
266
+ test("an unknown or truncated escape keeps the old strip-only behaviour", () => {
267
+ expect(parseScalar(String.raw`"a\qb"`)).toBe(String.raw`a\qb`);
268
+ expect(parseScalar(String.raw`"\x4"`)).toBe(String.raw`\x4`);
269
+ });
270
+ });
271
+
272
+ // Round-trip parity for the scalars `emitYAML` quotes: what it writes,
273
+ // `parseYAML` must read back unchanged. The single-quote half of #1860 was
274
+ // invisible until this existed. Multiline strings are excluded on purpose —
275
+ // the emitter sends those through a block scalar, which #910 already covers
276
+ // and which does not preserve trailing newlines.
277
+ describe("emitYAML/parseYAML round trip (#1860)", () => {
278
+ const values = [
279
+ "'quoted'",
280
+ "#hash's",
281
+ "it's a test",
282
+ "plain",
283
+ "yes",
284
+ "a: b",
285
+ "$VAR",
286
+ "",
287
+ ];
288
+
289
+ for (const v of values) {
290
+ test(`round-trips ${JSON.stringify(v)}`, () => {
291
+ expect(parseYAML(`k: ${emitYAML(v, 0)}\n`).k).toBe(v);
292
+ });
293
+ }
294
+ });
295
+
296
+ // The fountain lexicon emits multiline strings as `JSON.stringify(s)` — a
297
+ // double-quoted scalar, not a block scalar — so this is the shape that
298
+ // actually reached the applier (#1860).
299
+ describe("double-quoted multiline manifest values (#1860)", () => {
300
+ test("a joined shell script keeps its newlines", () => {
301
+ const script = [
302
+ "set -e",
303
+ "sudo service postgresql start || true",
304
+ `sudo -u postgres psql -tc "ALTER USER postgres PASSWORD 'postgres'" || true`,
305
+ "cd /workspace/fountain",
306
+ ].join("\n");
307
+
308
+ const yaml = `spec:\n setup_script: ${JSON.stringify(script)}\n`;
309
+ const parsed = parseYAML(yaml) as { spec: { setup_script: string } };
310
+
311
+ expect(parsed.spec.setup_script).toBe(script);
312
+ expect(parsed.spec.setup_script.split("\n")).toHaveLength(4);
313
+ expect(parsed.spec.setup_script).not.toContain(String.raw`\n`);
314
+ });
315
+ });
316
+
229
317
 
230
318
  // Block scalars (#910) — round-trip parity with js-yaml for literal/folded/chomping,
231
319
  // including block scalars nested inside array items (the case that mis-parsed).
package/src/yaml.ts CHANGED
@@ -559,15 +559,85 @@ export function parseScalar(value: string): unknown {
559
559
  if (value === "" || value === "~" || value === "null") return null;
560
560
  if (value === "true" || value === "yes") return true;
561
561
  if (value === "false" || value === "no") return false;
562
- // Strip quotes
563
- if (
564
- (value.startsWith("'") && value.endsWith("'")) ||
565
- (value.startsWith('"') && value.endsWith('"'))
566
- ) {
567
- return value.slice(1, -1);
562
+ // Quoted scalars carry escapes, and stripping the quotes is only half the
563
+ // job: a double-quoted `\n` is a newline, a single-quoted `''` is one
564
+ // quote. Dropping that step leaves the escape sequence in the value as
565
+ // literal characters, which is how a multiline `setup_script` reached a
566
+ // shell as one line of `set -e\nsudo ...` (#1860).
567
+ if (value.length >= 2) {
568
+ if (value.startsWith('"') && value.endsWith('"')) {
569
+ const unescaped = unescapeDoubleQuoted(value.slice(1, -1));
570
+ // `null` means the body held a bare `"`, so this was never a single
571
+ // well-formed scalar (`"a" + "b"`). Keep the old behaviour there.
572
+ return unescaped ?? value.slice(1, -1);
573
+ }
574
+ if (value.startsWith("'") && value.endsWith("'")) {
575
+ return value.slice(1, -1).replace(/''/g, "'");
576
+ }
568
577
  }
569
578
  // Number
570
579
  const num = Number(value);
571
580
  if (!isNaN(num) && value !== "") return num;
572
581
  return value;
573
582
  }
583
+
584
+ /** YAML double-quoted escapes, per the spec's escape table. */
585
+ const DQ_ESCAPES: Record<string, string> = {
586
+ "0": "\0",
587
+ a: "\x07",
588
+ b: "\b",
589
+ t: "\t",
590
+ "\t": "\t",
591
+ n: "\n",
592
+ v: "\v",
593
+ f: "\f",
594
+ r: "\r",
595
+ e: "\x1b",
596
+ " ": " ",
597
+ '"': '"',
598
+ "/": "/",
599
+ "\\": "\\",
600
+ N: "\x85",
601
+ _: "\xa0",
602
+ L: "\u2028",
603
+ P: "\u2029",
604
+ };
605
+
606
+ /**
607
+ * Decode the body of a double-quoted YAML scalar.
608
+ *
609
+ * Returns `null` when the body holds an unescaped `"`, which means the caller
610
+ * was not looking at one quoted scalar after all.
611
+ */
612
+ function unescapeDoubleQuoted(body: string): string | null {
613
+ let out = "";
614
+ for (let i = 0; i < body.length; i++) {
615
+ const ch = body[i];
616
+ if (ch === '"') return null;
617
+ if (ch !== "\\") {
618
+ out += ch;
619
+ continue;
620
+ }
621
+ const esc = body[++i];
622
+ if (esc === undefined) return null;
623
+ // \xXX, \uXXXX, \UXXXXXXXX
624
+ const width = esc === "x" ? 2 : esc === "u" ? 4 : esc === "U" ? 8 : 0;
625
+ if (width > 0) {
626
+ const hex = body.slice(i + 1, i + 1 + width);
627
+ if (hex.length < width || !/^[0-9a-fA-F]+$/.test(hex)) return null;
628
+ out += String.fromCodePoint(parseInt(hex, 16));
629
+ i += width;
630
+ continue;
631
+ }
632
+ // An escaped line break is a line continuation: it and the following
633
+ // indentation fold away to nothing.
634
+ if (esc === "\n") {
635
+ while (body[i + 1] === " " || body[i + 1] === "\t") i++;
636
+ continue;
637
+ }
638
+ const mapped = DQ_ESCAPES[esc];
639
+ if (mapped === undefined) return null;
640
+ out += mapped;
641
+ }
642
+ return out;
643
+ }