@codapult/guard 0.9.0 → 0.11.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 (40) hide show
  1. package/README.md +154 -10
  2. package/dist/adapters/agents/agent-integration.js +26 -3
  3. package/dist/cli/commands/guard.d.ts +2 -0
  4. package/dist/cli/commands/guard.js +77 -11
  5. package/dist/cli/index.js +2 -0
  6. package/dist/core/guard.d.ts +28 -1
  7. package/dist/core/guard.js +37 -0
  8. package/dist/core/history/runs.d.ts +107 -2
  9. package/dist/core/history/runs.js +244 -18
  10. package/dist/core/output/attestation.d.ts +17 -0
  11. package/dist/core/output/attestation.js +22 -0
  12. package/dist/core/policy/schemas.d.ts +47 -0
  13. package/dist/core/policy/schemas.js +67 -0
  14. package/dist/core/verification/github-actions.d.ts +25 -0
  15. package/dist/core/verification/github-actions.js +397 -0
  16. package/dist/core/verification/github-context.d.ts +8 -0
  17. package/dist/core/verification/github-context.js +41 -0
  18. package/dist/core/verification/verify.d.ts +23 -4
  19. package/dist/core/verification/verify.js +701 -40
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/mcp/prompts.js +7 -4
  23. package/dist/mcp/tools/guard.js +9 -3
  24. package/docs/extensions.md +79 -0
  25. package/docs/guard-agent-integration.md +51 -0
  26. package/docs/guard-ci.yml +202 -0
  27. package/docs/guard-fixtures.md +127 -0
  28. package/docs/guard-trusted-pr-attestation.yml +213 -0
  29. package/docs/guard.md +1125 -0
  30. package/docs/integrations/README.md +38 -0
  31. package/docs/integrations/claude-code.md +92 -0
  32. package/docs/integrations/codex.md +58 -0
  33. package/docs/integrations/cursor.md +116 -0
  34. package/docs/integrations/gemini-cli.md +47 -0
  35. package/docs/integrations/generic.md +41 -0
  36. package/docs/integrations/github-copilot.md +72 -0
  37. package/docs/policy-mutations.md +75 -0
  38. package/docs/releasing.md +58 -0
  39. package/docs/verify-guard-attestation.mjs +166 -0
  40. package/package.json +11 -9
@@ -1,9 +1,275 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { extname, relative, resolve, sep } from 'node:path';
1
6
  import {} from '../../adapters/command.js';
2
7
  import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
3
- import { loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
8
+ import { GUARD_DIR, GUARD_HISTORY_DIR, loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
4
9
  import { analyzeProjectImpact } from '../analysis/impact.js';
5
10
  import { discoverProject } from '../discovery/discovery.js';
6
11
  import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
12
+ import { auditGitHubActions } from './github-actions.js';
13
+ import { readGuardGitHubContext } from './github-context.js';
14
+ const require = createRequire(import.meta.url);
15
+ const packageJson = require('../../../package.json');
16
+ const DEFAULT_PROTECTED_PATHS = [
17
+ '.codapult/guard/**',
18
+ '.github/workflows/**',
19
+ 'CODEOWNERS',
20
+ '.github/CODEOWNERS',
21
+ 'package.json',
22
+ '**/package.json',
23
+ 'pnpm-lock.yaml',
24
+ '**/pnpm-lock.yaml',
25
+ 'yarn.lock',
26
+ '**/yarn.lock',
27
+ 'package-lock.json',
28
+ '**/package-lock.json',
29
+ 'npm-shrinkwrap.json',
30
+ '**/npm-shrinkwrap.json',
31
+ 'bun.lock',
32
+ '**/bun.lock',
33
+ 'bun.lockb',
34
+ '**/bun.lockb',
35
+ 'pnpm-workspace.yaml',
36
+ '**/pnpm-workspace.yaml',
37
+ '.npmrc',
38
+ '**/.npmrc',
39
+ '.pnpmfile.cjs',
40
+ '**/.pnpmfile.cjs',
41
+ 'pnpmfile.cjs',
42
+ '**/pnpmfile.cjs',
43
+ '.yarnrc',
44
+ '**/.yarnrc',
45
+ '.yarnrc.yml',
46
+ '**/.yarnrc.yml',
47
+ '.pnp.cjs',
48
+ '**/.pnp.cjs',
49
+ '.pnp.loader.mjs',
50
+ '**/.pnp.loader.mjs',
51
+ '.gitignore',
52
+ '.ignore',
53
+ '.dockerignore',
54
+ '.npmignore',
55
+ '.prettierignore',
56
+ '.eslintignore',
57
+ 'eslint.config.*',
58
+ '.eslintrc*',
59
+ 'biome.json',
60
+ 'biome.jsonc',
61
+ '.biomeignore',
62
+ 'prettier.config.*',
63
+ '.prettierrc*',
64
+ 'tsconfig.json',
65
+ 'vitest.config.*',
66
+ 'jest.config.*',
67
+ 'playwright.config.*',
68
+ 'next.config.*',
69
+ 'babel.config.*',
70
+ '.babelrc*',
71
+ 'webpack.config.*',
72
+ 'stylelint.config.*',
73
+ '.stylelintrc*',
74
+ 'nyc.config.*',
75
+ 'tsconfig*.json',
76
+ ];
77
+ function matchesPathPattern(path, pattern) {
78
+ let expression = '^';
79
+ for (let index = 0; index < pattern.length; index += 1) {
80
+ const character = pattern[index];
81
+ if (character === '*' && pattern[index + 1] === '*') {
82
+ if (pattern[index + 2] === '/') {
83
+ expression += '(?:.*/)?';
84
+ index += 2;
85
+ }
86
+ else {
87
+ expression += '.*';
88
+ index += 1;
89
+ }
90
+ }
91
+ else if (character === '*')
92
+ expression += '[^/]*';
93
+ else if (character === '?')
94
+ expression += '[^/]';
95
+ else
96
+ expression += '.+^${}()|[]\\'.includes(character) ? `\\${character}` : character;
97
+ }
98
+ return new RegExp(`${expression}$`).test(path);
99
+ }
100
+ const SOURCE_EXTENSIONS = new Set([
101
+ '.js',
102
+ '.jsx',
103
+ '.ts',
104
+ '.tsx',
105
+ '.mts',
106
+ '.cts',
107
+ '.mjs',
108
+ '.cjs',
109
+ '.html',
110
+ '.md',
111
+ '.mdx',
112
+ '.py',
113
+ '.pyi',
114
+ '.sh',
115
+ '.vue',
116
+ '.svelte',
117
+ ]);
118
+ const SUPPRESSION_COMMENT = /(?:\/\/|\/\*|\*|<!--|#)\s*(eslint-disable|@ts-ignore|@ts-nocheck|@ts-expect-error|biome-ignore|prettier-ignore|tslint:disable|stylelint-disable|standard:disable|svelte-ignore|noqa|nosec|type:\s*ignore|pragma:\s*no\s+cover|istanbul\s+ignore|c8\s+ignore)\b/i;
119
+ function addedSuppressionMarkers(root, baseRef) {
120
+ if (!baseRef)
121
+ return undefined;
122
+ try {
123
+ const diffs = [
124
+ execFileSync('git', ['diff', '--no-ext-diff', '--unified=0', `${baseRef}...HEAD`], {
125
+ cwd: root,
126
+ stdio: 'pipe',
127
+ timeout: 5_000,
128
+ maxBuffer: 8_000_000,
129
+ }).toString(),
130
+ execFileSync('git', ['diff', '--no-ext-diff', '--unified=0', 'HEAD'], {
131
+ cwd: root,
132
+ stdio: 'pipe',
133
+ timeout: 5_000,
134
+ maxBuffer: 8_000_000,
135
+ }).toString(),
136
+ ];
137
+ const found = new Set();
138
+ for (const diff of diffs) {
139
+ let file = '';
140
+ for (const line of diff.split('\n')) {
141
+ if (line.startsWith('+++ "'))
142
+ return undefined;
143
+ if (line.startsWith('+++ b/'))
144
+ file = line.slice(6);
145
+ else if (line.startsWith('+') && !line.startsWith('+++')) {
146
+ const extension = extname(file).toLowerCase();
147
+ const match = SUPPRESSION_COMMENT.exec(line);
148
+ if (file && SOURCE_EXTENSIONS.has(extension) && match?.[1]) {
149
+ found.add(`${file}:${match[1].toLowerCase()}`);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
155
+ cwd: root,
156
+ stdio: 'pipe',
157
+ timeout: 5_000,
158
+ maxBuffer: 8_000_000,
159
+ })
160
+ .toString()
161
+ .split('\0')
162
+ .filter(Boolean);
163
+ const realRoot = realpathSync(root);
164
+ for (const file of untracked) {
165
+ if (!SOURCE_EXTENSIONS.has(extname(file).toLowerCase()))
166
+ continue;
167
+ const absolutePath = resolve(root, file);
168
+ const realFilePath = realpathSync(absolutePath);
169
+ const fileRelative = relative(realRoot, realFilePath);
170
+ if (fileRelative === '..' || fileRelative.startsWith(`..${sep}`))
171
+ return undefined;
172
+ const stats = lstatSync(absolutePath);
173
+ if (stats.isSymbolicLink() || !stats.isFile() || stats.size > 2_000_000)
174
+ return undefined;
175
+ const source = readFileSync(absolutePath, 'utf8');
176
+ if (source.includes('\0'))
177
+ continue;
178
+ for (const line of source.split('\n')) {
179
+ const match = SUPPRESSION_COMMENT.exec(line);
180
+ if (match?.[1])
181
+ found.add(`${file}:${match[1].toLowerCase()}`);
182
+ }
183
+ }
184
+ return [...found].sort();
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ }
190
+ function isGeneratedGuardArtifact(path) {
191
+ const normalized = path.replaceAll('\\', '/');
192
+ return (normalized === `${GUARD_DIR}/cache.json` ||
193
+ normalized === GUARD_HISTORY_DIR ||
194
+ normalized.startsWith(`${GUARD_HISTORY_DIR}/`));
195
+ }
196
+ function gitPaths(root, commands) {
197
+ try {
198
+ const pathLists = commands.map((args) => execFileSync('git', args, {
199
+ cwd: root,
200
+ stdio: 'pipe',
201
+ timeout: 5_000,
202
+ }).toString());
203
+ return [...new Set(pathLists.flatMap((paths) => paths.split('\0')))]
204
+ .map((file) => file.replace(/\\/g, '/'))
205
+ .filter((file) => Boolean(file) && !isGeneratedGuardArtifact(file))
206
+ .sort();
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ function committedFilesSince(root, baseRef) {
213
+ if (!baseRef)
214
+ return undefined;
215
+ return gitPaths(root, [['diff', '--name-only', '-z', `${baseRef}...HEAD`]]);
216
+ }
217
+ function workingTreeChangedFiles(root) {
218
+ return gitPaths(root, [
219
+ ['diff', '--name-only', '-z', 'HEAD'],
220
+ ['ls-files', '--others', '--exclude-standard', '-z'],
221
+ ]);
222
+ }
223
+ function changedFilesSince(root, baseRef) {
224
+ if (!baseRef)
225
+ return undefined;
226
+ const committed = committedFilesSince(root, baseRef);
227
+ const workingTree = workingTreeChangedFiles(root);
228
+ if (!committed || !workingTree)
229
+ return undefined;
230
+ return [...new Set([...committed, ...workingTree])].sort();
231
+ }
232
+ function getPolicySurface(root, baseRef, configPaths = [], policySurfaceConfig) {
233
+ const baseChangedPaths = changedFilesSince(root, baseRef);
234
+ const changedPaths = baseChangedPaths ?? [];
235
+ const suppressionMarkers = addedSuppressionMarkers(root, baseRef);
236
+ const defaultEntries = DEFAULT_PROTECTED_PATHS.map((pattern) => ({
237
+ pattern,
238
+ kind: pattern.startsWith('.github/workflows/')
239
+ ? 'ci-workflow'
240
+ : pattern.startsWith('.codapult/guard/')
241
+ ? 'guard-policy'
242
+ : pattern.toLowerCase().includes('codeowners')
243
+ ? 'ownership'
244
+ : ['package.json', 'lock.yaml', 'lock.json', '.npmrc'].some((part) => pattern.includes(part))
245
+ ? 'dependency-input'
246
+ : pattern.includes('ignore')
247
+ ? 'ignore-config'
248
+ : 'tool-config',
249
+ severity: 'error',
250
+ }));
251
+ const entries = [
252
+ ...defaultEntries,
253
+ ...configPaths.map((pattern) => ({ pattern, kind: 'custom', severity: 'error' })),
254
+ ...(policySurfaceConfig?.paths ?? []).map((entry) => ({
255
+ ...entry,
256
+ severity: entry.severity ?? 'error',
257
+ })),
258
+ ];
259
+ const protectedPaths = [...new Set(entries.map((entry) => entry.pattern))].sort();
260
+ const changes = changedPaths.flatMap((path) => entries
261
+ .filter((entry) => matchesPathPattern(path, entry.pattern))
262
+ .map((entry) => ({ path, ...entry })));
263
+ const matched = [...new Set(changes.map((change) => change.path))].sort();
264
+ return {
265
+ protectedPaths,
266
+ changedPaths: matched,
267
+ changes,
268
+ suppressionMarkers: suppressionMarkers ?? [],
269
+ workflowChanged: matched.some((path) => path.startsWith('.github/workflows/')),
270
+ complete: baseChangedPaths !== undefined && suppressionMarkers !== undefined,
271
+ };
272
+ }
7
273
  function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
8
274
  return {
9
275
  ...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
@@ -14,6 +280,254 @@ function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
14
280
  ...(decisionIds.length > 0 ? { decisionIds } : {}),
15
281
  };
16
282
  }
283
+ function stripControlCharacters(value) {
284
+ let output = '';
285
+ for (const character of value) {
286
+ const code = character.charCodeAt(0);
287
+ output += code <= 0x1f || code === 0x7f ? ' ' : character;
288
+ }
289
+ return output;
290
+ }
291
+ function sanitizeCommand(command) {
292
+ return stripControlCharacters(command.slice(0, 2_000))
293
+ .replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]')
294
+ .replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]')
295
+ .replace(new RegExp('((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\\s*[:=]\\s*)[^\\s,}]+', 'gi'), '$1[REDACTED]')
296
+ .replace(/(--(?:token|secret|password|api-key|authorization)(?:=|\s+))[^\s]+/gi, '$1[REDACTED]');
297
+ }
298
+ function sanitizeProjectChecks(results) {
299
+ return Object.fromEntries(Object.entries(results).map(([name, result]) => [
300
+ name,
301
+ { ...result, command: sanitizeCommand(result.command) },
302
+ ]));
303
+ }
304
+ function receiptCheck(result) {
305
+ const command = sanitizeCommand(result.command);
306
+ const args = command.trim().split(/\s+/).filter(Boolean).slice(1);
307
+ return {
308
+ status: result.status,
309
+ exitCode: result.exitCode,
310
+ durationMs: result.durationMs,
311
+ command,
312
+ args,
313
+ execution: result.status === 'not-configured'
314
+ ? 'not-configured'
315
+ : result.stderr.includes('Unsafe or empty project command rejected.')
316
+ ? 'rejected'
317
+ : 'attempted',
318
+ };
319
+ }
320
+ function effectiveVerifierInvocation(options) {
321
+ const args = ['--tools=' + options.tools];
322
+ if (!options.projectChecks)
323
+ args.push('--no-project-checks');
324
+ if (options.checks.length > 0)
325
+ args.push(`--checks=${options.checks.join(',')}`);
326
+ if (options.strict)
327
+ args.push('--strict');
328
+ if (options.changedOnly)
329
+ args.push('--changed');
330
+ if (options.policyBase)
331
+ args.push(`--policy-base=${options.policyBase}`);
332
+ if (options.failOnPolicyChange)
333
+ args.push('--fail-on-policy-change');
334
+ if (options.failOnProtectedChanges)
335
+ args.push('--fail-on-protected-changes');
336
+ return { command: 'codapult-guard verify', args };
337
+ }
338
+ function resolveGitCommit(root, ref) {
339
+ if (!ref)
340
+ return undefined;
341
+ try {
342
+ const commit = execFileSync('git', ['rev-parse', `${ref}^{commit}`], {
343
+ cwd: root,
344
+ stdio: 'pipe',
345
+ timeout: 5_000,
346
+ })
347
+ .toString()
348
+ .trim();
349
+ return /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
350
+ }
351
+ catch {
352
+ return undefined;
353
+ }
354
+ }
355
+ function hasWorkingTreeChanges(root) {
356
+ try {
357
+ return (execFileSync('git', ['status', '--porcelain', '-z'], {
358
+ cwd: root,
359
+ stdio: 'pipe',
360
+ timeout: 5_000,
361
+ }).length > 0);
362
+ }
363
+ catch {
364
+ return true;
365
+ }
366
+ }
367
+ function sha256(bytes) {
368
+ return createHash('sha256').update(bytes).digest('hex');
369
+ }
370
+ function baseFileDigest(root, revisions, path) {
371
+ if (!path || path.startsWith('/') || path.split('/').includes('..'))
372
+ return undefined;
373
+ for (const revision of revisions) {
374
+ try {
375
+ const content = execFileSync('git', ['show', `${revision}:${path}`], {
376
+ cwd: root,
377
+ stdio: 'pipe',
378
+ timeout: 5_000,
379
+ maxBuffer: 32_000_000,
380
+ });
381
+ return sha256(content);
382
+ }
383
+ catch {
384
+ // A deleted working-tree file may exist at HEAD even when absent from the base ref.
385
+ }
386
+ }
387
+ return undefined;
388
+ }
389
+ function verificationScope(root, inventory, changedPaths, policy) {
390
+ const revisions = [resolveGitCommit(root, 'HEAD'), resolveGitCommit(root, policy.ref)].filter((revision) => revision !== undefined);
391
+ const paths = [
392
+ ...new Set([...inventory.map((file) => file.path), ...changedPaths]
393
+ .map((path) => path.replaceAll('\\', '/'))
394
+ .filter((path) => !isGeneratedGuardArtifact(path))),
395
+ ].sort();
396
+ let complete = true;
397
+ const files = paths.map((path) => {
398
+ try {
399
+ if (!path ||
400
+ path.startsWith('/') ||
401
+ /^[a-z]:\//i.test(path) ||
402
+ path.split('/').includes('..')) {
403
+ complete = false;
404
+ return { path, status: 'unavailable' };
405
+ }
406
+ const fullPath = resolve(root, path);
407
+ const stats = lstatSync(fullPath);
408
+ if (!stats.isFile() || stats.isSymbolicLink()) {
409
+ complete = false;
410
+ return { path, status: 'unavailable' };
411
+ }
412
+ return { path, status: 'present', sha256: sha256(readFileSync(fullPath)) };
413
+ }
414
+ catch {
415
+ const digest = baseFileDigest(root, revisions, path);
416
+ if (digest)
417
+ return { path, status: 'deleted', sha256: digest };
418
+ complete = false;
419
+ return { path, status: 'unavailable' };
420
+ }
421
+ });
422
+ const fingerprint = createHash('sha256')
423
+ .update(JSON.stringify({
424
+ algorithm: 'sha256',
425
+ policyFingerprint: policy.fingerprint,
426
+ tool: { name: '@codapult/guard', version: packageJson.version },
427
+ files,
428
+ }))
429
+ .digest('hex');
430
+ return { algorithm: 'sha256', fingerprint, complete, stable: true, files };
431
+ }
432
+ function buildVerificationReceipt(root, policy, impact, checks, workspaceChecks, adapters, architecture, contractIssues, stages, policySurface, ciIntegrity, scope, requiredStages, incompleteStages, effectiveInvocation) {
433
+ const githubContext = readGuardGitHubContext(process.env.GITHUB_EVENT_NAME, process.env.GITHUB_EVENT_PATH, resolveGitCommit(root, 'HEAD'), process.env.GITHUB_SHA);
434
+ const baseCommit = resolveGitCommit(root, policy.ref);
435
+ const baseChangedFiles = committedFilesSince(root, policy.ref);
436
+ const workingTreeFiles = workingTreeChangedFiles(root) ?? impact.requestedFiles;
437
+ const receiptChecks = {};
438
+ for (const [name, result] of Object.entries(checks)) {
439
+ receiptChecks[name] = receiptCheck(result);
440
+ }
441
+ for (const [workspacePackage, packageChecks] of Object.entries(workspaceChecks)) {
442
+ for (const [name, result] of Object.entries(packageChecks)) {
443
+ receiptChecks[`${workspacePackage}:${name}`] = receiptCheck(result);
444
+ }
445
+ }
446
+ for (const [name, result] of Object.entries(adapters)) {
447
+ receiptChecks[`adapter:${name}`] = receiptCheck(result);
448
+ }
449
+ return {
450
+ version: 1,
451
+ kind: 'verification-receipt',
452
+ subject: {
453
+ ...(githubContext?.event ? { githubEvent: githubContext.event } : {}),
454
+ ...(githubContext?.headSha ? { headSha: githubContext.headSha } : {}),
455
+ ...(githubContext?.baseSha ? { baseSha: githubContext.baseSha } : {}),
456
+ ...(githubContext?.mergeSha ? { mergeSha: githubContext.mergeSha } : {}),
457
+ ...(policy.ref ? { baseRef: policy.ref } : {}),
458
+ ...(baseCommit ? { baseCommit } : {}),
459
+ ...(baseChangedFiles ? { baseChangedFiles } : {}),
460
+ ...(workingTreeFiles.length > 0 ? { workingTreeFiles } : {}),
461
+ changedFiles: [...new Set([...(baseChangedFiles ?? []), ...workingTreeFiles])].sort(),
462
+ workingTreeDirty: workingTreeFiles.length > 0,
463
+ },
464
+ policy,
465
+ verificationScope: scope,
466
+ policySurface,
467
+ ...(ciIntegrity ? { ciIntegrity } : {}),
468
+ requiredStages,
469
+ incompleteStages,
470
+ effectiveInvocation,
471
+ stages,
472
+ checks: receiptChecks,
473
+ findings: {
474
+ total: architecture.findings.length,
475
+ errors: architecture.findings.filter((finding) => finding.severity === 'error').length,
476
+ warnings: architecture.findings.filter((finding) => finding.severity === 'warning').length +
477
+ (policySurface.changes ?? []).filter((change) => change.severity === 'warning').length,
478
+ waived: architecture.waived,
479
+ waiverWarnings: architecture.waiverWarnings.length,
480
+ expiredWaivers: architecture.expiredWaivers.length,
481
+ fingerprints: architecture.findings.map((finding) => finding.fingerprint).sort(),
482
+ ruleIds: [...new Set(architecture.findings.map((finding) => finding.ruleId))].sort(),
483
+ },
484
+ contractIssues: contractIssues.map((issue) => issue.contractId),
485
+ decisionIds: policy.decisionIds ?? [],
486
+ tool: { name: '@codapult/guard', version: packageJson.version },
487
+ };
488
+ }
489
+ function buildPolicyGateReceipt(root, policy, policySurface, ciIntegrity, stages) {
490
+ const githubContext = readGuardGitHubContext(process.env.GITHUB_EVENT_NAME, process.env.GITHUB_EVENT_PATH, resolveGitCommit(root, 'HEAD'), process.env.GITHUB_SHA);
491
+ const baseChangedFiles = committedFilesSince(root, policy.ref);
492
+ const workingTreeFiles = workingTreeChangedFiles(root) ?? [];
493
+ const baseCommit = resolveGitCommit(root, policy.ref);
494
+ return {
495
+ version: 1,
496
+ kind: 'verification-receipt',
497
+ subject: {
498
+ ...(githubContext?.event ? { githubEvent: githubContext.event } : {}),
499
+ ...(githubContext?.headSha ? { headSha: githubContext.headSha } : {}),
500
+ ...(githubContext?.baseSha ? { baseSha: githubContext.baseSha } : {}),
501
+ ...(githubContext?.mergeSha ? { mergeSha: githubContext.mergeSha } : {}),
502
+ ...(policy.ref ? { baseRef: policy.ref } : {}),
503
+ ...(baseCommit ? { baseCommit } : {}),
504
+ ...(baseChangedFiles ? { baseChangedFiles } : {}),
505
+ ...(workingTreeFiles.length > 0 ? { workingTreeFiles } : {}),
506
+ changedFiles: [...new Set([...(baseChangedFiles ?? []), ...workingTreeFiles])].sort(),
507
+ workingTreeDirty: hasWorkingTreeChanges(root),
508
+ },
509
+ policy,
510
+ policySurface,
511
+ ...(ciIntegrity ? { ciIntegrity } : {}),
512
+ requiredStages: [],
513
+ incompleteStages: [],
514
+ stages,
515
+ checks: {},
516
+ findings: {
517
+ total: 0,
518
+ errors: 0,
519
+ warnings: 0,
520
+ waived: 0,
521
+ waiverWarnings: 0,
522
+ expiredWaivers: 0,
523
+ fingerprints: [],
524
+ ruleIds: [],
525
+ },
526
+ contractIssues: [],
527
+ decisionIds: policy.decisionIds ?? [],
528
+ tool: { name: '@codapult/guard', version: packageJson.version },
529
+ };
530
+ }
17
531
  export function runGuardVerification(root, options = {}) {
18
532
  const runContext = startGuardRun();
19
533
  const requirement = {
@@ -26,7 +540,7 @@ export function runGuardVerification(root, options = {}) {
26
540
  const runtimeStartedAt = Date.now();
27
541
  const runtime = inspectProjectRuntime(root);
28
542
  recordGuardRunStage(runContext, 'runtime', 'ok', runtimeStartedAt);
29
- if (options.failOnPolicyChange && !options.policyBase) {
543
+ if ((options.failOnPolicyChange || options.failOnProtectedChanges) && !options.policyBase) {
30
544
  const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
31
545
  return {
32
546
  status: 'fail',
@@ -37,7 +551,7 @@ export function runGuardVerification(root, options = {}) {
37
551
  workspaceChecks: {},
38
552
  adapters: {},
39
553
  runtime,
40
- configError: '`failOnPolicyChange` requires `policyBase`.',
554
+ configError: 'Strict policy checks require `policyBase`.',
41
555
  contractIssues: [],
42
556
  requirement,
43
557
  run,
@@ -65,11 +579,12 @@ export function runGuardVerification(root, options = {}) {
65
579
  };
66
580
  }
67
581
  if (!workingTreeState) {
68
- const run = finishGuardRun(root, runContext, 'not-configured', 'configuration');
582
+ const policyEnforcementFailed = options.failOnPolicyChange === true;
583
+ const run = finishGuardRun(root, runContext, policyEnforcementFailed ? 'fail' : 'not-configured', policyEnforcementFailed ? 'policy-change' : 'configuration');
69
584
  return {
70
- status: 'not-configured',
71
- outcome: 'not-configured',
72
- errorCode: 'GUARD_NOT_CONFIGURED',
585
+ status: policyEnforcementFailed ? 'fail' : 'not-configured',
586
+ outcome: policyEnforcementFailed ? 'fail' : 'not-configured',
587
+ errorCode: policyEnforcementFailed ? 'GUARD_POLICY_CHANGED' : 'GUARD_NOT_CONFIGURED',
73
588
  recoverable: true,
74
589
  checks: {},
75
590
  workspaceChecks: {},
@@ -83,6 +598,9 @@ export function runGuardVerification(root, options = {}) {
83
598
  let policySnapshot;
84
599
  let workingTreeSnapshot;
85
600
  let policyChanged;
601
+ let policySurface;
602
+ let ciIntegrity;
603
+ let uncheckedProtectedPaths;
86
604
  let policyDecisionIds = [];
87
605
  try {
88
606
  workingTreeSnapshot = workingTreeState.snapshot;
@@ -109,6 +627,25 @@ export function runGuardVerification(root, options = {}) {
109
627
  policySnapshot = baseSnapshot ?? workingTreeSnapshot;
110
628
  policyChanged =
111
629
  baseSnapshot !== undefined && baseSnapshot.fingerprint !== workingTreeSnapshot.fingerprint;
630
+ const policySurfaceStartedAt = Date.now();
631
+ policySurface = getPolicySurface(root, options.policyBase, baseSnapshot?.config.protectedPaths ?? [], baseSnapshot?.config.policySurface);
632
+ recordGuardRunStage(runContext, 'policy-surface', policySurface.complete ? 'ok' : 'skipped', policySurfaceStartedAt, policySurface.complete ? undefined : 'A policy base was not available for comparison.');
633
+ const ciAuditStartedAt = Date.now();
634
+ ciIntegrity = auditGitHubActions(root, policySnapshot.config.githubActions, options.policyBase);
635
+ const contractedWorkflowPaths = new Set(policySnapshot.config.githubActions?.workflows.map((workflow) => workflow.path) ?? []);
636
+ uncheckedProtectedPaths = [
637
+ ...new Set(policySurface.changes
638
+ .filter((change) => change.severity === 'error')
639
+ .map((change) => change.path)
640
+ .filter((path) => !(path.startsWith('.github/workflows/') && contractedWorkflowPaths.has(path)))),
641
+ ];
642
+ recordGuardRunStage(runContext, 'ci-integrity', ciIntegrity === undefined
643
+ ? 'skipped'
644
+ : ciIntegrity.status === 'ok'
645
+ ? 'ok'
646
+ : ciIntegrity.status === 'policy-weakened'
647
+ ? 'fail'
648
+ : 'skipped', ciAuditStartedAt, ciIntegrity === undefined ? 'No GitHub Actions integrity contract is configured.' : undefined);
112
649
  if (policySnapshot.source === 'working-tree') {
113
650
  policyDecisionIds = (workingTreeState.proposals?.decisions ?? [])
114
651
  .filter((decision) => decision.policyFingerprint === policySnapshot.fingerprint)
@@ -118,7 +655,7 @@ export function runGuardVerification(root, options = {}) {
118
655
  }
119
656
  if (options.failOnPolicyChange && policyChanged) {
120
657
  const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
121
- const run = finishGuardRun(root, runContext, 'fail', 'policy-change', 'verify', policy);
658
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-change', 'verify', policy, buildPolicyGateReceipt(root, policy, policySurface, ciIntegrity, Object.fromEntries(Object.entries(runContext.stages).map(([name, stage]) => [name, stage.status]))));
122
659
  return {
123
660
  status: 'fail',
124
661
  outcome: 'fail',
@@ -134,6 +671,40 @@ export function runGuardVerification(root, options = {}) {
134
671
  run,
135
672
  policy,
136
673
  policyChanged,
674
+ policySurface,
675
+ ...(ciIntegrity ? { ciIntegrity } : {}),
676
+ };
677
+ }
678
+ if (options.failOnProtectedChanges &&
679
+ (!policySurface.complete ||
680
+ uncheckedProtectedPaths.length > 0 ||
681
+ policySurface.suppressionMarkers.length > 0)) {
682
+ const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
683
+ const run = finishGuardRun(root, runContext, 'fail', 'protected-path-change', 'verify', policy, buildPolicyGateReceipt(root, policy, policySurface, ciIntegrity, Object.fromEntries(Object.entries(runContext.stages).map(([name, stage]) => [name, stage.status]))));
684
+ return {
685
+ status: 'fail',
686
+ outcome: 'fail',
687
+ errorCode: policySurface.complete
688
+ ? 'GUARD_PROTECTED_PATH_CHANGED'
689
+ : 'GUARD_POLICY_SURFACE_INCOMPLETE',
690
+ recoverable: true,
691
+ checks: {},
692
+ workspaceChecks: {},
693
+ adapters: {},
694
+ runtime,
695
+ configError: policySurface.complete
696
+ ? `Verification-sensitive paths or inline suppression markers changed: ${[
697
+ ...uncheckedProtectedPaths,
698
+ ...policySurface.suppressionMarkers,
699
+ ].join(', ')}`
700
+ : 'Could not inspect the complete policy surface against the base ref; strict verification failed closed.',
701
+ contractIssues: [],
702
+ requirement,
703
+ run,
704
+ policy,
705
+ policyChanged,
706
+ policySurface,
707
+ ...(ciIntegrity ? { ciIntegrity } : {}),
137
708
  };
138
709
  }
139
710
  }
@@ -158,11 +729,13 @@ export function runGuardVerification(root, options = {}) {
158
729
  const agentConfig = policySnapshot.agentConfig;
159
730
  const projectChecks = options.projectChecks ?? agentConfig.completionGate.projectChecks;
160
731
  const checks = projectChecks ? (options.checks ?? agentConfig.completionGate.checks) : [];
732
+ const discoveryStartedAt = Date.now();
733
+ const workspaceModel = discoverProject(root);
734
+ const scopeChangedPaths = changedFilesSince(root, policy.ref) ?? workspaceModel.git.changedFiles;
735
+ const initialScope = verificationScope(root, workspaceModel.files, scopeChangedPaths, policy);
161
736
  const checksStartedAt = Date.now();
162
737
  const results = runProjectChecks(root, checks, { timeout: options.timeout });
163
738
  recordGuardRunStage(runContext, 'project-checks', projectChecks ? 'ok' : 'skipped', checksStartedAt);
164
- const discoveryStartedAt = Date.now();
165
- const workspaceModel = discoverProject(root);
166
739
  const impact = analyzeProjectImpact(workspaceModel, options.changedOnly === false
167
740
  ? workspaceModel.modules.map((module) => module.path)
168
741
  : workspaceModel.git.changedFiles, policySnapshot.config.contracts ?? []);
@@ -190,60 +763,144 @@ export function runGuardVerification(root, options = {}) {
190
763
  });
191
764
  const contractIssues = validateGuardPolicy(root, policySnapshot.config);
192
765
  recordGuardRunStage(runContext, 'architecture-policy', 'ok', architectureStartedAt);
766
+ const finalWorkspaceModel = discoverProject(root);
767
+ const finalScope = verificationScope(root, finalWorkspaceModel.files, changedFilesSince(root, policy.ref) ?? finalWorkspaceModel.git.changedFiles, policy);
768
+ const scopeStable = initialScope.complete &&
769
+ finalScope.complete &&
770
+ initialScope.fingerprint === finalScope.fingerprint;
771
+ const scope = {
772
+ ...initialScope,
773
+ complete: initialScope.complete && finalScope.complete,
774
+ stable: scopeStable,
775
+ };
776
+ recordGuardRunStage(runContext, 'verification-scope', scope.complete && scope.stable ? 'ok' : 'skipped', discoveryStartedAt, scope.complete && scope.stable
777
+ ? undefined
778
+ : 'The complete verification input scope could not be hashed consistently for the entire run.');
193
779
  const commandFailed = Object.values(results).some((result) => result.status === 'failed');
194
780
  const workspaceCommandFailed = Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'failed'));
195
781
  const adapterFailed = Object.values(adapters).some((result) => result.status === 'failed');
196
- const missingRequiredTools = options.strict &&
197
- [...Object.values(results), ...Object.values(adapters)].some((result) => result.status === 'not-configured');
782
+ const missingProjectChecks = projectChecks &&
783
+ (checks.length === 0 ||
784
+ Object.values(results).some((result) => result.status === 'not-configured') ||
785
+ Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'not-configured')));
786
+ const missingAdapterTools = options.strict && Object.values(adapters).some((result) => result.status === 'not-configured');
787
+ const runtimeFailed = projectChecks && !runtime.compatible;
198
788
  const architectureFailed = architecture.findings.some((finding) => finding.severity === 'error');
789
+ const expiredWaivers = architecture.expiredWaivers.length > 0;
199
790
  const architectureWarnings = architecture.findings.some((finding) => finding.severity === 'warning') ||
200
791
  (architecture.waiverWarnings ?? []).length > 0;
201
- const failed = (projectChecks && !runtime.compatible) ||
792
+ const policySurfaceWarnings = policySurface.changes.some((change) => change.severity === 'warning');
793
+ const otherFailures = runtimeFailed ||
202
794
  commandFailed ||
203
795
  workspaceCommandFailed ||
204
796
  adapterFailed ||
205
797
  architectureFailed ||
798
+ expiredWaivers ||
206
799
  contractIssues.length > 0 ||
207
- missingRequiredTools;
208
- const projectChecksFailed = commandFailed || workspaceCommandFailed || (projectChecks && !runtime.compatible);
209
- const adaptersFailed = adapterFailed || missingRequiredTools;
800
+ missingProjectChecks ||
801
+ missingAdapterTools;
802
+ const ciWeakened = ciIntegrity?.status === 'policy-weakened';
803
+ const ciIncomplete = ciIntegrity?.status === 'incomplete';
804
+ const failed = otherFailures || ciWeakened;
805
+ const projectChecksFailed = commandFailed || workspaceCommandFailed || missingProjectChecks || runtimeFailed;
806
+ const adaptersFailed = adapterFailed || missingAdapterTools;
210
807
  runContext.stages['project-checks'] = {
211
808
  ...runContext.stages['project-checks'],
212
809
  status: projectChecks ? (projectChecksFailed ? 'fail' : 'ok') : 'skipped',
213
810
  };
214
811
  runContext.stages.adapters = {
215
812
  ...runContext.stages.adapters,
216
- status: toolMode === 'off' ? 'skipped' : adaptersFailed ? 'fail' : 'ok',
813
+ status: toolMode === 'off' ||
814
+ !Object.values(adapters).some((result) => result.status !== 'not-configured')
815
+ ? 'skipped'
816
+ : adaptersFailed
817
+ ? 'fail'
818
+ : 'ok',
819
+ };
820
+ runContext.stages.runtime = {
821
+ ...runContext.stages.runtime,
822
+ status: projectChecks ? (runtimeFailed ? 'fail' : 'ok') : 'skipped',
217
823
  };
218
824
  runContext.stages['architecture-policy'] = {
219
825
  ...runContext.stages['architecture-policy'],
220
- status: architectureFailed || contractIssues.length > 0 ? 'fail' : 'ok',
826
+ status: architectureFailed || expiredWaivers || contractIssues.length > 0 ? 'fail' : 'ok',
221
827
  };
222
- const outcome = classifyGuardOutcome({
223
- errors: failed ? 1 : 0,
224
- warnings: architectureWarnings ? 1 : 0,
225
- });
226
- const gate = architectureFailed
227
- ? 'architecture'
228
- : contractIssues.length > 0
229
- ? 'contracts'
230
- : commandFailed || workspaceCommandFailed
231
- ? 'project-checks'
232
- : adapterFailed
233
- ? 'adapters'
234
- : missingRequiredTools
235
- ? 'required-tools'
236
- : !runtime.compatible && projectChecks
237
- ? 'runtime'
238
- : 'none';
239
- const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy);
828
+ const requiredStages = agentConfig.completionGate.requiredStages ?? ['architecture-policy'];
829
+ const effectiveRequiredStages = [
830
+ ...new Set([
831
+ ...requiredStages,
832
+ ...(policySnapshot.source === 'git-ref' ? ['policy-surface'] : []),
833
+ ...(ciIntegrity !== undefined ? ['ci-integrity'] : []),
834
+ 'verification-scope',
835
+ ]),
836
+ ];
837
+ const effectiveIncompleteStages = effectiveRequiredStages.filter((stage) => runContext.stages[stage].status !== 'ok');
838
+ const incomplete = ciIncomplete || effectiveIncompleteStages.length > 0;
839
+ const outcome = ciWeakened && !otherFailures
840
+ ? 'policy-weakened'
841
+ : classifyGuardOutcome({
842
+ errors: failed ? 1 : 0,
843
+ warnings: architectureWarnings || policySurfaceWarnings ? 1 : 0,
844
+ incomplete: !failed && incomplete,
845
+ });
846
+ const gate = ciWeakened
847
+ ? 'ci-integrity'
848
+ : architectureFailed || expiredWaivers
849
+ ? 'architecture'
850
+ : contractIssues.length > 0
851
+ ? 'contracts'
852
+ : commandFailed || workspaceCommandFailed
853
+ ? 'project-checks'
854
+ : adapterFailed
855
+ ? 'adapters'
856
+ : missingProjectChecks
857
+ ? 'project-checks'
858
+ : missingAdapterTools
859
+ ? 'required-tools'
860
+ : runtimeFailed
861
+ ? 'runtime'
862
+ : incomplete
863
+ ? 'required-stages'
864
+ : 'none';
865
+ const receipt = buildVerificationReceipt(root, policy, impact, results, workspaceChecks, adapters, architecture, contractIssues, Object.fromEntries(Object.entries(runContext.stages).map(([name, stage]) => [name, stage.status])), policySurface, ciIntegrity, scope, effectiveRequiredStages, effectiveIncompleteStages, effectiveVerifierInvocation({
866
+ checks,
867
+ tools: toolMode,
868
+ projectChecks,
869
+ strict: options.strict,
870
+ changedOnly: options.changedOnly,
871
+ policyBase: options.policyBase,
872
+ failOnPolicyChange: options.failOnPolicyChange,
873
+ failOnProtectedChanges: options.failOnProtectedChanges,
874
+ }));
875
+ const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy, receipt);
240
876
  return {
241
- status: failed ? 'fail' : 'ok',
877
+ status: outcome === 'policy-weakened'
878
+ ? 'policy-weakened'
879
+ : failed
880
+ ? 'fail'
881
+ : incomplete
882
+ ? 'incomplete'
883
+ : 'ok',
884
+ ...(ciWeakened || ciIncomplete || incomplete
885
+ ? {
886
+ errorCode: ciWeakened
887
+ ? 'GUARD_CI_POLICY_WEAKENED'
888
+ : ciIncomplete
889
+ ? 'GUARD_CI_POLICY_INCOMPLETE'
890
+ : 'GUARD_REQUIRED_STAGE_INCOMPLETE',
891
+ }
892
+ : {}),
242
893
  outcome,
243
894
  tools: toolMode,
244
- checks: results,
245
- workspaceChecks,
246
- adapters,
895
+ checks: sanitizeProjectChecks(results),
896
+ workspaceChecks: Object.fromEntries(Object.entries(workspaceChecks).map(([name, packageChecks]) => [
897
+ name,
898
+ sanitizeProjectChecks(packageChecks),
899
+ ])),
900
+ adapters: Object.fromEntries(Object.entries(adapters).map(([name, result]) => [
901
+ name,
902
+ { ...result, command: sanitizeCommand(result.command) },
903
+ ])),
247
904
  runtime,
248
905
  architecture,
249
906
  impact,
@@ -252,5 +909,9 @@ export function runGuardVerification(root, options = {}) {
252
909
  run,
253
910
  policy,
254
911
  ...(policy.changedFromBase !== undefined ? { policyChanged: policy.changedFromBase } : {}),
912
+ policySurface,
913
+ ...(ciIntegrity ? { ciIntegrity } : {}),
914
+ requiredStages: effectiveRequiredStages,
915
+ incompleteStages: effectiveIncompleteStages,
255
916
  };
256
917
  }