@apexops/capabilities 0.10.0-next.4 → 0.11.0-next.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 (93) hide show
  1. package/README.md +26 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/archetype-source.d.ts +49 -0
  4. package/dist/archetype-source.d.ts.map +1 -0
  5. package/dist/archetype-source.js +421 -0
  6. package/dist/archetype-source.js.map +1 -0
  7. package/dist/bound-envelope-transport.js +1 -1
  8. package/dist/bound-envelope-transport.js.map +1 -1
  9. package/dist/executable-resolution.d.ts +21 -0
  10. package/dist/executable-resolution.d.ts.map +1 -0
  11. package/dist/executable-resolution.js +87 -0
  12. package/dist/executable-resolution.js.map +1 -0
  13. package/dist/governance-baseline.d.ts +87 -0
  14. package/dist/governance-baseline.d.ts.map +1 -0
  15. package/dist/governance-baseline.js +639 -0
  16. package/dist/governance-baseline.js.map +1 -0
  17. package/dist/governance-setup.d.ts +13 -0
  18. package/dist/governance-setup.d.ts.map +1 -0
  19. package/dist/governance-setup.js +231 -0
  20. package/dist/governance-setup.js.map +1 -0
  21. package/dist/iac-generation.d.ts.map +1 -1
  22. package/dist/iac-generation.js +56 -20
  23. package/dist/iac-generation.js.map +1 -1
  24. package/dist/iac-normalizers.d.ts.map +1 -1
  25. package/dist/iac-normalizers.js +19 -1
  26. package/dist/iac-normalizers.js.map +1 -1
  27. package/dist/iac.d.ts +30 -2
  28. package/dist/iac.d.ts.map +1 -1
  29. package/dist/iac.js.map +1 -1
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +6 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/native-iac-providers.d.ts +20 -4
  35. package/dist/native-iac-providers.d.ts.map +1 -1
  36. package/dist/native-iac-providers.js +591 -27
  37. package/dist/native-iac-providers.js.map +1 -1
  38. package/dist/pack-manager.d.ts.map +1 -1
  39. package/dist/pack-manager.js +15 -14
  40. package/dist/pack-manager.js.map +1 -1
  41. package/dist/policy-validation.d.ts +68 -0
  42. package/dist/policy-validation.d.ts.map +1 -0
  43. package/dist/policy-validation.js +653 -0
  44. package/dist/policy-validation.js.map +1 -0
  45. package/dist/process-runner.d.ts.map +1 -1
  46. package/dist/process-runner.js +10 -1
  47. package/dist/process-runner.js.map +1 -1
  48. package/dist/rename-retry.d.ts +12 -0
  49. package/dist/rename-retry.d.ts.map +1 -0
  50. package/dist/rename-retry.js +27 -0
  51. package/dist/rename-retry.js.map +1 -0
  52. package/dist/repository-publish.d.ts +17 -0
  53. package/dist/repository-publish.d.ts.map +1 -0
  54. package/dist/repository-publish.js +124 -0
  55. package/dist/repository-publish.js.map +1 -0
  56. package/dist/terraform-provider-schema.d.ts.map +1 -1
  57. package/dist/terraform-provider-schema.js +6 -2
  58. package/dist/terraform-provider-schema.js.map +1 -1
  59. package/dist/test/archetype-source.test.d.ts +2 -0
  60. package/dist/test/archetype-source.test.d.ts.map +1 -0
  61. package/dist/test/archetype-source.test.js +258 -0
  62. package/dist/test/archetype-source.test.js.map +1 -0
  63. package/dist/test/executable-resolution.test.d.ts +2 -0
  64. package/dist/test/executable-resolution.test.d.ts.map +1 -0
  65. package/dist/test/executable-resolution.test.js +87 -0
  66. package/dist/test/executable-resolution.test.js.map +1 -0
  67. package/dist/test/governance-baseline.test.d.ts +2 -0
  68. package/dist/test/governance-baseline.test.d.ts.map +1 -0
  69. package/dist/test/governance-baseline.test.js +841 -0
  70. package/dist/test/governance-baseline.test.js.map +1 -0
  71. package/dist/test/governance-setup.test.d.ts +2 -0
  72. package/dist/test/governance-setup.test.d.ts.map +1 -0
  73. package/dist/test/governance-setup.test.js +160 -0
  74. package/dist/test/governance-setup.test.js.map +1 -0
  75. package/dist/test/iac-generation.test.js +203 -14
  76. package/dist/test/iac-generation.test.js.map +1 -1
  77. package/dist/test/native-iac-providers.test.js +1482 -5
  78. package/dist/test/native-iac-providers.test.js.map +1 -1
  79. package/dist/test/policy-validation.test.d.ts +2 -0
  80. package/dist/test/policy-validation.test.d.ts.map +1 -0
  81. package/dist/test/policy-validation.test.js +1061 -0
  82. package/dist/test/policy-validation.test.js.map +1 -0
  83. package/dist/test/rename-retry.test.d.ts +2 -0
  84. package/dist/test/rename-retry.test.d.ts.map +1 -0
  85. package/dist/test/rename-retry.test.js +25 -0
  86. package/dist/test/rename-retry.test.js.map +1 -0
  87. package/dist/test/repository-publish.test.d.ts +2 -0
  88. package/dist/test/repository-publish.test.d.ts.map +1 -0
  89. package/dist/test/repository-publish.test.js +123 -0
  90. package/dist/test/repository-publish.test.js.map +1 -0
  91. package/dist/test/terraform-provider-schema.test.js +12 -6
  92. package/dist/test/terraform-provider-schema.test.js.map +1 -1
  93. package/package.json +3 -3
@@ -1,11 +1,27 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, rm } from "node:fs/promises";
3
- import { isAbsolute, resolve } from "node:path";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, mkdtemp, open, opendir, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
6
+ import { Value } from "@sinclair/typebox/value";
7
+ import { hasValidPolicyValidation, NATIVE_VALIDATION_COMMANDS, calculateNativeValidationCommandHash, calculateNativeValidationReceiptHash, calculatePolicyValidationDigest, hasActionablePolicyMappings, hasValidNativeValidationReceipt, PolicyPropertyMapV1Schema, LogicalResourceManifestV1Schema, IacBindingV1Schema, } from "@apexops/contracts";
4
8
  import { BicepCommandAdapter, TerraformCommandAdapter, } from "./command-plans.js";
5
9
  import { authorizeDeploymentPreview, IacProviderError, sha256, } from "./iac.js";
6
10
  import { normalizeAzureStackResources, normalizeAzureWhatIf, normalizeTerraformPlan, parseJsonProcessOutput, selectAzureDeploymentStack, } from "./iac-normalizers.js";
7
11
  import { secretFreeProperties } from "./secret-redaction.js";
8
12
  import { LocalEncryptedPlanTransport } from "./local-plan-transport.js";
13
+ import { validatePolicyProperties, validateStorageSecurityBindings, validateBicepResourceParity, validateBicepStorageDiagnostics, validateBicepStorageBaseline, } from "./policy-validation.js";
14
+ export function nativePolicyValidationBinding(receipt) {
15
+ return {
16
+ track: receipt.track,
17
+ sourceHash: receipt.sourceHash,
18
+ policyMapHash: receipt.policyMapHash,
19
+ policyMapContentHash: receipt.policyMapContentHash,
20
+ logicalResourceManifestHash: receipt.logicalResourceManifestHash,
21
+ inputHash: receipt.inputHash,
22
+ receiptHash: receipt.receiptHash,
23
+ };
24
+ }
9
25
  function terraformStateIdentity(value) {
10
26
  if (value === null || typeof value !== "object" || Array.isArray(value))
11
27
  return {};
@@ -20,6 +36,120 @@ function terraformStateIdentity(value) {
20
36
  function terraformStateIdentityOutput(stdout) {
21
37
  return stdout.trim().length === 0 ? {} : terraformStateIdentity(parseJsonProcessOutput("terraform-state", stdout));
22
38
  }
39
+ const maxGeneratedSourceBytes = 16 * 1024 * 1024;
40
+ function pathEscapes(parent, child) {
41
+ const relation = relative(parent, child);
42
+ return relation === "" || relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation);
43
+ }
44
+ function sourceBindingError() {
45
+ return new IacProviderError("PREVIEW_HASH_MISMATCH", "Native preview source does not match the accepted generated tree");
46
+ }
47
+ async function readGeneratedSource(source, accepted, outputs = new Set()) {
48
+ try {
49
+ const rootPath = resolve(source.rootPath);
50
+ if (!isAbsolute(source.rootPath) || !/^[a-f0-9]{64}$/.test(source.treeHash))
51
+ throw sourceBindingError();
52
+ const rootStat = await lstat(rootPath);
53
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
54
+ throw sourceBindingError();
55
+ }
56
+ const rootRealPath = await realpath(rootPath);
57
+ let bytes = 0;
58
+ let entries = 0;
59
+ const files = [];
60
+ const fileHashes = new Map();
61
+ const fileContents = new Map();
62
+ const visit = async (directory, depth) => {
63
+ if (depth > 64)
64
+ throw sourceBindingError();
65
+ const handle = await opendir(directory);
66
+ for await (const entry of handle) {
67
+ if (++entries > 4096)
68
+ throw sourceBindingError();
69
+ const filePath = resolve(directory, entry.name);
70
+ if (pathEscapes(rootPath, filePath) || pathEscapes(rootRealPath, await realpath(filePath))) {
71
+ throw sourceBindingError();
72
+ }
73
+ const metadata = await lstat(filePath);
74
+ if (metadata.isSymbolicLink() || (metadata.isFile() && metadata.nlink !== 1))
75
+ throw sourceBindingError();
76
+ const path = relative(rootPath, filePath).split(sep).join("/");
77
+ const isAccepted = accepted?.paths.has(path) === true;
78
+ if (accepted !== undefined && !isAccepted && outputs.has(filePath)) {
79
+ if (path === ".terraform" ? metadata.isDirectory() : metadata.isFile())
80
+ continue;
81
+ throw sourceBindingError();
82
+ }
83
+ if (metadata.isDirectory()) {
84
+ await visit(filePath, depth + 1);
85
+ continue;
86
+ }
87
+ if (!metadata.isFile() || metadata.size > maxGeneratedSourceBytes - bytes)
88
+ throw sourceBindingError();
89
+ const input = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
90
+ try {
91
+ const opened = await input.stat();
92
+ if (!opened.isFile() || opened.nlink !== 1)
93
+ throw sourceBindingError();
94
+ const chunks = [];
95
+ for (;;) {
96
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxGeneratedSourceBytes - bytes + 1));
97
+ const { bytesRead } = await input.read(chunk);
98
+ if (bytesRead === 0)
99
+ break;
100
+ bytes += bytesRead;
101
+ if (bytes > maxGeneratedSourceBytes)
102
+ throw sourceBindingError();
103
+ chunks.push(chunk.subarray(0, bytesRead));
104
+ }
105
+ const buffer = Buffer.concat(chunks);
106
+ const content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
107
+ files.push({ path, content });
108
+ fileHashes.set(path, createHash("sha256").update(buffer).digest("hex"));
109
+ fileContents.set(path, buffer);
110
+ }
111
+ finally {
112
+ await input.close();
113
+ }
114
+ }
115
+ };
116
+ await visit(rootPath, 0);
117
+ files.sort((left, right) => left.path.localeCompare(right.path));
118
+ if (sha256(files) !== source.treeHash)
119
+ throw sourceBindingError();
120
+ return {
121
+ rootPath,
122
+ treeHash: source.treeHash,
123
+ paths: new Set(files.map(({ path }) => path)),
124
+ fileHashes,
125
+ fileContents,
126
+ };
127
+ }
128
+ catch {
129
+ throw sourceBindingError();
130
+ }
131
+ }
132
+ export async function assertGeneratedSourceUnchanged(source) {
133
+ await readGeneratedSource(source);
134
+ }
135
+ async function bindGeneratedSource(request, cwd, templateFile, parametersFile, accepted, outputs) {
136
+ if (request.generatedSource === undefined)
137
+ return undefined;
138
+ const source = await readGeneratedSource({ ...request.generatedSource }, accepted, outputs);
139
+ if (resolve(cwd ?? process.cwd()) !== source.rootPath)
140
+ throw sourceBindingError();
141
+ if (templateFile !== undefined && resolve(source.rootPath, templateFile) !== resolve(source.rootPath, "main.bicep")) {
142
+ throw sourceBindingError();
143
+ }
144
+ for (const file of [templateFile, parametersFile]) {
145
+ if (file === undefined)
146
+ continue;
147
+ const path = relative(source.rootPath, resolve(source.rootPath, file)).split(sep).join("/");
148
+ if (!source.paths.has(path))
149
+ throw sourceBindingError();
150
+ }
151
+ return source;
152
+ }
23
153
  class NativeProviderBase {
24
154
  runner;
25
155
  now;
@@ -29,6 +159,7 @@ class NativeProviderBase {
29
159
  #maxOutputBytes;
30
160
  #operations = new Map();
31
161
  #executionEvidence = new Map();
162
+ #policyValidations = new Map();
32
163
  bindingStore;
33
164
  constructor(options) {
34
165
  this.runner = options.runner;
@@ -49,6 +180,263 @@ class NativeProviderBase {
49
180
  });
50
181
  return result.stdout;
51
182
  }
183
+ async validateBoundSource(request, track, cwd, templateFile) {
184
+ if (request.generatedSource === undefined || cwd() === undefined)
185
+ throw sourceBindingError();
186
+ const generatedSource = { ...request.generatedSource };
187
+ const commands = NATIVE_VALIDATION_COMMANDS[track];
188
+ let receipt;
189
+ let policyInput;
190
+ let storageBindings;
191
+ let parityManifest;
192
+ let parityBinding;
193
+ let diagnosticsTargets;
194
+ try {
195
+ if (track === "bicep" && request.resourceParityBinding !== undefined) {
196
+ calculatePolicyValidationDigest(request.resourceParityBinding);
197
+ parityBinding = structuredClone(request.resourceParityBinding);
198
+ if (!Value.Check(IacBindingV1Schema, parityBinding) ||
199
+ request.resourceParityManifest === undefined ||
200
+ parityBinding.track !== track ||
201
+ parityBinding.projectId !== request.projectId ||
202
+ parityBinding.runId !== request.runId ||
203
+ parityBinding.intentHash !== request.inputHash)
204
+ throw new Error();
205
+ }
206
+ if (track === "bicep" && request.storageDiagnosticsTargets !== undefined) {
207
+ calculatePolicyValidationDigest(request.storageDiagnosticsTargets);
208
+ diagnosticsTargets = structuredClone(request.storageDiagnosticsTargets);
209
+ if (Object.keys(diagnosticsTargets).length > 1000)
210
+ throw new Error();
211
+ }
212
+ if (track === "bicep" && request.resourceParityManifest !== undefined) {
213
+ calculatePolicyValidationDigest(request.resourceParityManifest);
214
+ parityManifest = structuredClone(request.resourceParityManifest);
215
+ if (!Value.Check(LogicalResourceManifestV1Schema, parityManifest) ||
216
+ parityManifest.track !== track ||
217
+ parityManifest.projectId !== request.projectId ||
218
+ parityManifest.runId !== request.runId ||
219
+ parityManifest.resources.length > 1000)
220
+ throw new Error();
221
+ }
222
+ if (track === "bicep" && request.storageSecurityBindings !== undefined) {
223
+ calculatePolicyValidationDigest(request.storageSecurityBindings);
224
+ storageBindings = structuredClone(request.storageSecurityBindings);
225
+ if (Object.keys(storageBindings).length > 1000)
226
+ throw new Error();
227
+ }
228
+ if (request.policyValidation !== undefined) {
229
+ calculatePolicyValidationDigest(request.policyValidation);
230
+ policyInput = structuredClone(request.policyValidation);
231
+ if (!Value.Check(PolicyPropertyMapV1Schema, policyInput.policyMap) ||
232
+ calculatePolicyValidationDigest(policyInput.policyMap) !== request.policyHash ||
233
+ policyInput.policyMap.projectId !== request.projectId ||
234
+ policyInput.policyMap.runId !== request.runId)
235
+ throw new Error();
236
+ }
237
+ const body = {
238
+ schemaVersion: "1.0.0",
239
+ projectId: request.projectId,
240
+ runId: request.runId,
241
+ track,
242
+ sourceHash: request.sourceHash,
243
+ treeHash: generatedSource.treeHash,
244
+ policyHash: request.policyHash,
245
+ inputHash: request.inputHash,
246
+ ...(policyInput !== undefined && !hasActionablePolicyMappings(policyInput.policyMap)
247
+ ? {
248
+ policyApplicability: {
249
+ status: "no-actionable-mappings",
250
+ policyMapContentHash: calculatePolicyValidationDigest(policyInput.policyMap),
251
+ },
252
+ }
253
+ : {}),
254
+ outcome: "pass",
255
+ commands: commands.map((command) => ({
256
+ validatorId: command.validatorId,
257
+ commandHash: calculateNativeValidationCommandHash(command),
258
+ exitCode: 0,
259
+ signal: null,
260
+ timedOut: false,
261
+ outputTruncated: false,
262
+ })),
263
+ };
264
+ receipt = { ...body, receiptHash: calculateNativeValidationReceiptHash(body) };
265
+ if (!hasValidNativeValidationReceipt(receipt, body))
266
+ throw new Error();
267
+ }
268
+ catch {
269
+ throw new IacProviderError("NATIVE_VALIDATION_INPUT_INVALID", "Native validation inputs are invalid");
270
+ }
271
+ const source = await bindGeneratedSource({ generatedSource }, cwd(), templateFile);
272
+ if (source === undefined)
273
+ throw sourceBindingError();
274
+ if (track === "terraform") {
275
+ const cache = resolve(source.rootPath, ".terraform");
276
+ const existing = await lstat(cache).catch((error) => {
277
+ if (error.code === "ENOENT")
278
+ return undefined;
279
+ throw sourceBindingError();
280
+ });
281
+ if (existing !== undefined)
282
+ throw sourceBindingError();
283
+ }
284
+ let scratchRoot;
285
+ try {
286
+ try {
287
+ scratchRoot = await mkdtemp(resolve(await realpath(tmpdir()), "apex-native-validation-"));
288
+ for (const path of source.paths) {
289
+ const destination = resolve(scratchRoot, path);
290
+ if (!destination.startsWith(`${scratchRoot}${sep}`))
291
+ throw sourceBindingError();
292
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
293
+ await writeFile(destination, source.fileContents.get(path), { flag: "wx", mode: 0o600 });
294
+ }
295
+ }
296
+ catch {
297
+ throw sourceBindingError();
298
+ }
299
+ const scratch = await readGeneratedSource({ rootPath: scratchRoot, treeHash: source.treeHash });
300
+ const outputs = new Set();
301
+ if (track === "terraform") {
302
+ outputs.add(resolve(scratchRoot, ".terraform"));
303
+ outputs.add(resolve(scratchRoot, ".terraform.lock.hcl"));
304
+ }
305
+ const verify = async () => {
306
+ if (cwd() === undefined)
307
+ throw sourceBindingError();
308
+ await bindGeneratedSource({ generatedSource }, cwd(), templateFile);
309
+ if (track === "terraform") {
310
+ for (const path of [".terraform", ".terraform.lock.hcl"]) {
311
+ if (source.paths.has(path))
312
+ continue;
313
+ const metadata = await lstat(resolve(source.rootPath, path)).catch((error) => {
314
+ if (error.code === "ENOENT")
315
+ return undefined;
316
+ throw sourceBindingError();
317
+ });
318
+ if (metadata !== undefined)
319
+ throw sourceBindingError();
320
+ }
321
+ }
322
+ await readGeneratedSource(scratch, scratch, outputs);
323
+ };
324
+ let compiledTemplate;
325
+ for (const command of commands) {
326
+ await verify();
327
+ try {
328
+ const result = await this.runner.run({
329
+ executable: command.executable,
330
+ args: [...command.args],
331
+ cwd: scratchRoot,
332
+ timeoutMs: this.#timeoutMs,
333
+ maxOutputBytes: this.#maxOutputBytes,
334
+ });
335
+ if (result.exitCode !== 0 ||
336
+ result.signal !== null ||
337
+ result.timedOut !== false ||
338
+ result.outputTruncated !== false)
339
+ throw new Error();
340
+ if (command.validatorId === "bicep:build")
341
+ compiledTemplate = result.stdout;
342
+ }
343
+ catch {
344
+ throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native validation command failed");
345
+ }
346
+ finally {
347
+ await verify();
348
+ }
349
+ }
350
+ if (parityManifest !== undefined) {
351
+ receipt.resourceParity = validateBicepResourceParity({
352
+ sourceHash: receipt.sourceHash,
353
+ manifest: parityManifest,
354
+ ...(parityBinding === undefined ? {} : { binding: parityBinding }),
355
+ json: compiledTemplate ?? "",
356
+ });
357
+ const { receiptHash: previousHash, ...updated } = receipt;
358
+ if (!previousHash)
359
+ throw sourceBindingError();
360
+ receipt.receiptHash = calculateNativeValidationReceiptHash(updated);
361
+ }
362
+ if (parityManifest !== undefined && parityBinding !== undefined) {
363
+ receipt.securityBaseline = validateBicepStorageBaseline({
364
+ sourceHash: receipt.sourceHash,
365
+ manifest: parityManifest,
366
+ binding: parityBinding,
367
+ json: compiledTemplate ?? "",
368
+ });
369
+ const { receiptHash: previousHash, ...updated } = receipt;
370
+ if (!previousHash)
371
+ throw sourceBindingError();
372
+ receipt.receiptHash = calculateNativeValidationReceiptHash(updated);
373
+ }
374
+ if (diagnosticsTargets !== undefined) {
375
+ receipt.storageDiagnostics = Object.fromEntries(Object.entries(diagnosticsTargets).map(([logicalId, target]) => [
376
+ logicalId,
377
+ validateBicepStorageDiagnostics({
378
+ ...target,
379
+ sourceHash: receipt.sourceHash,
380
+ json: compiledTemplate ?? "",
381
+ }),
382
+ ]));
383
+ const { receiptHash: previousHash, ...updated } = receipt;
384
+ if (!previousHash)
385
+ throw sourceBindingError();
386
+ receipt.receiptHash = calculateNativeValidationReceiptHash(updated);
387
+ }
388
+ if (track === "bicep" && policyInput !== undefined && hasActionablePolicyMappings(policyInput.policyMap)) {
389
+ let policyValidation;
390
+ try {
391
+ policyValidation = validatePolicyProperties({
392
+ ...policyInput,
393
+ track,
394
+ sourceHash: receipt.sourceHash,
395
+ policyMapHash: receipt.policyHash,
396
+ json: compiledTemplate ?? "",
397
+ });
398
+ if (policyValidation.outcome !== "pass")
399
+ throw new Error();
400
+ }
401
+ catch {
402
+ throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native policy property validation failed");
403
+ }
404
+ finally {
405
+ await verify();
406
+ }
407
+ receipt.policyValidation = policyValidation;
408
+ const { receiptHash, ...updated } = receipt;
409
+ if (!receiptHash)
410
+ throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native validation receipt is missing");
411
+ receipt = { ...updated, receiptHash: calculateNativeValidationReceiptHash(updated) };
412
+ }
413
+ if (storageBindings !== undefined && Object.keys(storageBindings).length > 0) {
414
+ const observations = validateStorageSecurityBindings({
415
+ track,
416
+ bindings: storageBindings,
417
+ sourceHash: receipt.sourceHash,
418
+ json: compiledTemplate ?? "",
419
+ });
420
+ const storageSecurity = Object.fromEntries(Object.entries(observations).map(([logicalId, observation]) => {
421
+ return [logicalId, { ...observation, results: [...observation.results] }];
422
+ }));
423
+ await verify();
424
+ const { receiptHash, ...body } = receipt;
425
+ if (!receiptHash)
426
+ throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native validation receipt is missing");
427
+ const updated = { ...body, storageSecurity };
428
+ receipt = { ...updated, receiptHash: calculateNativeValidationReceiptHash(updated) };
429
+ }
430
+ return receipt;
431
+ }
432
+ finally {
433
+ if (scratchRoot !== undefined) {
434
+ await rm(scratchRoot, { recursive: true, force: true }).catch(() => {
435
+ throw sourceBindingError();
436
+ });
437
+ }
438
+ }
439
+ }
52
440
  async authorize(track, operation, preview, approval, suppliedAuthority, latestPreviewHash) {
53
441
  const authority = await this.currentAuthority();
54
442
  if (authority.head !== suppliedAuthority.head ||
@@ -100,6 +488,58 @@ class NativeProviderBase {
100
488
  executionEvidence(operationId) {
101
489
  return this.#executionEvidence.get(operationId);
102
490
  }
491
+ policyValidation(previewHash) {
492
+ const receipt = this.#policyValidations.get(previewHash);
493
+ return receipt === undefined ? undefined : structuredClone(receipt);
494
+ }
495
+ evaluatePolicy(track, request, json, blockers) {
496
+ const input = request.policyValidation;
497
+ if (input === undefined || !hasActionablePolicyMappings(input.policyMap))
498
+ return {};
499
+ const receipt = validatePolicyProperties({
500
+ ...input,
501
+ track,
502
+ sourceHash: request.iacHash,
503
+ policyMapHash: request.policyHash,
504
+ json,
505
+ });
506
+ if (receipt.projectId !== request.projectId || receipt.runId !== request.runId) {
507
+ blockers.push("Policy validation project/run does not match the preview");
508
+ }
509
+ for (const result of receipt.results) {
510
+ if (result.outcome !== "pass") {
511
+ blockers.push(`Policy validation mapping ${result.mappingIndex}: ${result.outcome} (${result.reason})`);
512
+ }
513
+ }
514
+ return {
515
+ policyValidation: receipt,
516
+ policyValidationBinding: nativePolicyValidationBinding(receipt),
517
+ };
518
+ }
519
+ rememberPolicy(preview, binding) {
520
+ if (binding.policyValidation !== undefined) {
521
+ this.#policyValidations.set(preview.previewHash, structuredClone(binding.policyValidation));
522
+ }
523
+ }
524
+ verifyPolicy(preview, binding) {
525
+ const receipt = binding.policyValidation;
526
+ const expected = binding.policyValidationBinding;
527
+ if (receipt === undefined && expected === undefined)
528
+ return;
529
+ if (receipt === undefined ||
530
+ expected === undefined ||
531
+ !hasValidPolicyValidation(receipt, expected) ||
532
+ receipt.receiptHash !== expected.receiptHash ||
533
+ receipt.track !== preview.track ||
534
+ receipt.sourceHash !== preview.iacHash ||
535
+ receipt.policyMapHash !== preview.policyHash ||
536
+ receipt.projectId !== preview.projectId ||
537
+ receipt.runId !== preview.runId ||
538
+ preview.operation !== "apply" ||
539
+ receipt.outcome !== "pass") {
540
+ throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Policy validation receipt does not match the preview");
541
+ }
542
+ }
103
543
  previewKey(request, operation) {
104
544
  return `${request.projectId}\u0000${request.runId}\u0000${operation}`;
105
545
  }
@@ -152,10 +592,26 @@ export class NativeBicepProvider extends NativeProviderBase {
152
592
  }
153
593
  }
154
594
  async validate() {
155
- await this.run(this.#commands.validate(this.#target.templateFile, this.#target.cwd));
595
+ await this.#compiledTemplate();
156
596
  return [];
157
597
  }
598
+ async validateSource(request) {
599
+ return await this.validateBoundSource(request, this.track, () => this.#target.cwd, this.#target.templateFile);
600
+ }
158
601
  async previewApply(request) {
602
+ if (request.policyValidation !== undefined) {
603
+ try {
604
+ calculatePolicyValidationDigest(request.policyValidation);
605
+ request = { ...request, policyValidation: structuredClone(request.policyValidation) };
606
+ }
607
+ catch {
608
+ throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Bicep policy validation inputs are invalid");
609
+ }
610
+ }
611
+ const source = await bindGeneratedSource(request, this.#target.cwd, this.#target.templateFile, this.#target.parametersFile);
612
+ const policyInputs = request.policyValidation?.policyMap.mappings.length
613
+ ? await this.#inputHashes(source)
614
+ : undefined;
159
615
  const [stdout, stackResources] = await Promise.all([
160
616
  this.run(this.#commands.preview(this.#target)),
161
617
  this.#currentStackResources(),
@@ -166,14 +622,26 @@ export class NativeBicepProvider extends NativeProviderBase {
166
622
  if (unrepresented.length > 0) {
167
623
  normalized.blockers.push(`Group what-if cannot prove action-on-unmanage behavior for current stack resources: ${unrepresented.map(({ resourceId }) => resourceId).join(", ")}`);
168
624
  }
169
- return await this.#bind(request, "apply", normalized, stackResources);
625
+ let policy = {};
626
+ if (policyInputs !== undefined) {
627
+ let compiled = "";
628
+ try {
629
+ compiled = await this.#compiledTemplate();
630
+ }
631
+ catch {
632
+ normalized.blockers.push("Policy validation compiler output is unavailable");
633
+ }
634
+ policy = this.evaluatePolicy(this.track, request, compiled, normalized.blockers);
635
+ }
636
+ return await this.#bind(request, "apply", normalized, stackResources, policy, policyInputs, source);
170
637
  }
171
638
  async previewDestroy(request) {
639
+ const source = await bindGeneratedSource(request, this.#target.cwd, this.#target.templateFile, this.#target.parametersFile);
172
640
  const resources = await this.#currentStackResources();
173
641
  return await this.#bind(request, "destroy", {
174
642
  changes: resources.map((resource) => ({ resourceId: resource.resourceId, action: "delete", material: true })),
175
643
  blockers: [],
176
- }, resources);
644
+ }, resources, {}, undefined, source);
177
645
  }
178
646
  async apply(preview, approval, authority) {
179
647
  return await this.#execute("apply", preview, approval, authority);
@@ -192,21 +660,16 @@ export class NativeBicepProvider extends NativeProviderBase {
192
660
  resources: [...resources],
193
661
  };
194
662
  }
195
- async #bind(request, operation, normalized, stackResources) {
196
- const templateHash = await this.#rawFileHash(this.#target.templateFile);
197
- const parametersHash = this.#target.parametersFile === undefined
198
- ? createHash("sha256").update(Buffer.alloc(0)).digest("hex")
199
- : await this.#rawFileHash(this.#target.parametersFile);
663
+ async #bind(request, operation, normalized, stackResources, policy = {}, policyInputs, source) {
664
+ if (source !== undefined)
665
+ await readGeneratedSource(source, source);
666
+ const { templateHash, parametersHash } = await this.#inputHashes(source);
667
+ if (policyInputs !== undefined &&
668
+ (policyInputs.templateHash !== templateHash || policyInputs.parametersHash !== parametersHash)) {
669
+ normalized.blockers.push("Bicep inputs changed during policy validation preview");
670
+ }
200
671
  const stackStateHash = sha256(stackResources);
201
- const providerBindingHash = sha256({
202
- resourceGroup: this.#target.resourceGroup,
203
- stackName: this.#target.stackName,
204
- actionOnUnmanage: this.#target.actionOnUnmanage,
205
- denySettingsMode: this.#target.denySettingsMode,
206
- templateHash,
207
- parametersHash,
208
- stackStateHash,
209
- });
672
+ const providerBindingHash = this.#providerBindingHash(templateHash, parametersHash, stackStateHash, policy);
210
673
  const preview = this.createPreview(this.track, operation, request, normalized, {
211
674
  artifactHash: providerBindingHash,
212
675
  });
@@ -217,11 +680,49 @@ export class NativeBicepProvider extends NativeProviderBase {
217
680
  templateHash,
218
681
  parametersHash,
219
682
  stackStateHash,
683
+ ...policy,
220
684
  };
221
685
  this.#bindings.set(this.previewKey(request, operation), binding);
222
686
  await this.bindingStore?.save(preview.previewHash, binding);
687
+ this.rememberPolicy(preview, binding);
223
688
  return preview;
224
689
  }
690
+ #providerBindingHash(templateHash, parametersHash, stackStateHash, policy) {
691
+ return sha256({
692
+ resourceGroup: this.#target.resourceGroup,
693
+ stackName: this.#target.stackName,
694
+ actionOnUnmanage: this.#target.actionOnUnmanage,
695
+ denySettingsMode: this.#target.denySettingsMode,
696
+ templateHash,
697
+ parametersHash,
698
+ stackStateHash,
699
+ ...(policy.policyValidationBinding === undefined ? {} : { policyValidation: policy.policyValidationBinding }),
700
+ });
701
+ }
702
+ async #inputHashes(source) {
703
+ const fileHash = async (file) => {
704
+ if (source === undefined)
705
+ return await this.#rawFileHash(file);
706
+ const path = relative(source.rootPath, resolve(source.rootPath, file)).split(sep).join("/");
707
+ const hash = source.fileHashes.get(path);
708
+ if (hash === undefined)
709
+ throw sourceBindingError();
710
+ return hash;
711
+ };
712
+ return {
713
+ templateHash: await fileHash(this.#target.templateFile),
714
+ parametersHash: this.#target.parametersFile === undefined
715
+ ? createHash("sha256").update(Buffer.alloc(0)).digest("hex")
716
+ : await fileHash(this.#target.parametersFile),
717
+ };
718
+ }
719
+ async #compiledTemplate() {
720
+ return await this.run({
721
+ executable: "bicep",
722
+ args: ["build", this.#target.templateFile, "--stdout"],
723
+ ...(this.#target.cwd === undefined ? {} : { cwd: this.#target.cwd }),
724
+ });
725
+ }
225
726
  async #rawFileHash(path) {
226
727
  const filePath = isAbsolute(path) ? path : resolve(this.#target.cwd ?? process.cwd(), path);
227
728
  return createHash("sha256")
@@ -247,17 +748,23 @@ export class NativeBicepProvider extends NativeProviderBase {
247
748
  if (binding === undefined || binding.preview.previewHash !== preview.previewHash) {
248
749
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "No exact Bicep stack binding is available for this preview");
249
750
  }
250
- const templateHash = await this.#rawFileHash(this.#target.templateFile);
251
- const parametersHash = this.#target.parametersFile === undefined
252
- ? createHash("sha256").update(Buffer.alloc(0)).digest("hex")
253
- : await this.#rawFileHash(this.#target.parametersFile);
751
+ this.verifyPolicy(preview, binding);
752
+ if (binding.policyValidationBinding !== undefined &&
753
+ createHash("sha256")
754
+ .update(await this.#compiledTemplate())
755
+ .digest("hex") !== binding.policyValidationBinding.inputHash) {
756
+ throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Bicep compiler output changed after policy validation");
757
+ }
758
+ const { templateHash, parametersHash } = await this.#inputHashes();
254
759
  const stackStateHash = sha256(await this.#currentStackResources());
255
760
  if (templateHash !== binding.templateHash ||
256
761
  parametersHash !== binding.parametersHash ||
257
762
  stackStateHash !== binding.stackStateHash ||
258
- preview.artifactHash !== binding.providerBindingHash) {
763
+ preview.artifactHash !== binding.providerBindingHash ||
764
+ preview.artifactHash !== this.#providerBindingHash(templateHash, parametersHash, stackStateHash, binding)) {
259
765
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Bicep stack inputs, configuration, or managed state changed after preview");
260
766
  }
767
+ this.rememberPolicy(preview, binding);
261
768
  const stdout = await this.run(operation === "apply" ? this.#commands.stackApply(this.#target) : this.#commands.stackDestroy(this.#target));
262
769
  let providerOperationId;
263
770
  if (stdout.trim().length > 0) {
@@ -289,6 +796,9 @@ export class NativeTerraformProvider extends NativeProviderBase {
289
796
  }
290
797
  return [];
291
798
  }
799
+ async validateSource(request) {
800
+ return await this.validateBoundSource(request, this.track, () => this.#target.cwd);
801
+ }
292
802
  async previewApply(request) {
293
803
  return await this.#preview(request, "apply");
294
804
  }
@@ -352,8 +862,39 @@ export class NativeTerraformProvider extends NativeProviderBase {
352
862
  if (this.#keyProvider === undefined || this.#artifactStore === undefined || this.bindingStore === undefined) {
353
863
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform local/reference plan transport requires injected key, artifact, and binding stores");
354
864
  }
355
- const planPath = this.#target.planPath(request, operation);
865
+ if (operation === "apply" && request.policyValidation !== undefined) {
866
+ try {
867
+ calculatePolicyValidationDigest(request.policyValidation);
868
+ request = { ...request, policyValidation: structuredClone(request.policyValidation) };
869
+ }
870
+ catch {
871
+ throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform policy validation inputs are invalid");
872
+ }
873
+ }
874
+ const source = await bindGeneratedSource(request, this.#target.cwd);
875
+ const requestedPlanPath = this.#target.planPath(request, operation);
876
+ const planPath = source === undefined ? requestedPlanPath : resolve(source.rootPath, requestedPlanPath);
877
+ const outputs = new Set();
878
+ if (source !== undefined) {
879
+ const relativePlanPath = relative(source.rootPath, planPath).split(sep).join("/");
880
+ if (source.paths.has(relativePlanPath) ||
881
+ planPath === source.rootPath ||
882
+ [...source.paths].some((path) => path.startsWith(`${relativePlanPath}/`)) ||
883
+ /\.(?:tf|tfvars)(?:\.json)?$/i.test(planPath) ||
884
+ relativePlanPath === ".terraform.lock.hcl" ||
885
+ relativePlanPath === ".terraform" ||
886
+ relativePlanPath.startsWith(".terraform/") ||
887
+ [...source.paths].some((path) => path.startsWith(".terraform/"))) {
888
+ throw sourceBindingError();
889
+ }
890
+ outputs.add(planPath);
891
+ outputs.add(resolve(source.rootPath, ".terraform"));
892
+ outputs.add(resolve(source.rootPath, ".terraform.lock.hcl"));
893
+ }
356
894
  await this.run(this.#commands.init(this.#target.cwd, true));
895
+ const policyExpected = operation === "apply" && Boolean(request.policyValidation?.policyMap.mappings.length);
896
+ const initialConfigHash = policyExpected ? await this.#target.configHash() : undefined;
897
+ const initialLockfileHash = this.#target.lockfileHash;
357
898
  const stateIdentity = terraformStateIdentityOutput(await this.run(this.#commands.statePull(this.#target.cwd)));
358
899
  await this.run(this.#commands.preview(this.#target.cwd, planPath, operation === "destroy"));
359
900
  try {
@@ -361,6 +902,13 @@ export class NativeTerraformProvider extends NativeProviderBase {
361
902
  const planDigest = createHash("sha256").update(planBytes).digest("hex");
362
903
  const showOutput = await this.run(this.#commands.showJson(this.#target.cwd, planPath));
363
904
  const normalized = normalizeTerraformPlan(parseJsonProcessOutput("terraform-plan", showOutput));
905
+ const policy = policyExpected ? this.evaluatePolicy(this.track, request, showOutput, normalized.blockers) : {};
906
+ if (policyExpected &&
907
+ createHash("sha256")
908
+ .update(await readFile(planPath))
909
+ .digest("hex") !== planDigest) {
910
+ normalized.blockers.push("Terraform saved plan changed during policy validation preview");
911
+ }
364
912
  const authority = await this.currentAuthority();
365
913
  if (authority.ownerEpoch !== request.ownerEpoch ||
366
914
  authority.head !== request.commit ||
@@ -373,14 +921,22 @@ export class NativeTerraformProvider extends NativeProviderBase {
373
921
  ttlMs: request.ttlMs,
374
922
  });
375
923
  const artifactRef = `${request.projectId}/${request.runId}/${operation}/${planDigest}.tfplan.enc`;
376
- await this.#artifactStore.put(artifactRef, encrypted);
377
924
  const configHash = await this.#target.configHash();
925
+ if (source !== undefined) {
926
+ if (resolve(this.#target.cwd) !== source.rootPath)
927
+ throw sourceBindingError();
928
+ await readGeneratedSource(source, source, outputs);
929
+ }
930
+ if (policyExpected && (configHash !== initialConfigHash || this.#target.lockfileHash !== initialLockfileHash)) {
931
+ normalized.blockers.push("Terraform configuration changed during policy validation preview");
932
+ }
378
933
  const artifactHash = sha256({
379
934
  planDigest,
380
935
  configHash,
381
936
  lockfileHash: this.#target.lockfileHash,
382
937
  recipient: executionRecipient,
383
938
  artifactRef,
939
+ ...(policy.policyValidationBinding === undefined ? {} : { policyValidation: policy.policyValidationBinding }),
384
940
  });
385
941
  const preview = this.createPreview(this.track, operation, request, normalized, {
386
942
  artifactHash,
@@ -415,9 +971,11 @@ export class NativeTerraformProvider extends NativeProviderBase {
415
971
  createdAt: createdAt.toISOString(),
416
972
  expiresAt: preview.expiresAt,
417
973
  };
418
- const binding = { kind: "terraform", preview, attestation };
974
+ const binding = { kind: "terraform", preview, attestation, ...policy };
975
+ await this.#artifactStore.put(artifactRef, encrypted);
419
976
  this.#bindings.set(this.previewKey(request, operation), binding);
420
977
  await this.bindingStore.save(preview.previewHash, binding);
978
+ this.rememberPolicy(preview, binding);
421
979
  return preview;
422
980
  }
423
981
  finally {
@@ -435,12 +993,16 @@ export class NativeTerraformProvider extends NativeProviderBase {
435
993
  if (binding === undefined) {
436
994
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "No exact saved plan is bound to this preview");
437
995
  }
996
+ this.verifyPolicy(preview, binding);
438
997
  if (binding.attestation.recipient !== authority.recipientIdentity) {
439
998
  throw new IacProviderError("APPROVAL_RECIPIENT_MISMATCH", "Saved plan recipient is not the current authority");
440
999
  }
441
1000
  if (binding.attestation.configHash !== (await this.#target.configHash()) ||
442
1001
  binding.attestation.lockfileHash !== this.#target.lockfileHash ||
443
1002
  binding.attestation.inputHash !== preview.inputHash ||
1003
+ binding.attestation.iacHash !== preview.iacHash ||
1004
+ binding.attestation.policyHash !== preview.policyHash ||
1005
+ binding.attestation.previewHash !== preview.previewHash ||
444
1006
  binding.attestation.stateLineage !== preview.stateLineage ||
445
1007
  binding.attestation.stateSerial !== preview.stateSerial) {
446
1008
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Saved plan attestation does not match the preview");
@@ -456,10 +1018,12 @@ export class NativeTerraformProvider extends NativeProviderBase {
456
1018
  lockfileHash: binding.attestation.lockfileHash,
457
1019
  recipient: binding.attestation.recipient,
458
1020
  artifactRef: binding.attestation.artifactRef,
1021
+ ...(binding.policyValidationBinding === undefined ? {} : { policyValidation: binding.policyValidationBinding }),
459
1022
  });
460
1023
  if (preview.artifactHash !== providerArtifactHash) {
461
1024
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform provider binding hash does not match the preview");
462
1025
  }
1026
+ this.rememberPolicy(preview, binding);
463
1027
  if (this.#keyProvider === undefined || this.#artifactStore === undefined) {
464
1028
  throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform plan decryption dependencies are unavailable");
465
1029
  }