@codapult/guard 0.10.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 +144 -4
  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 +64 -1
  9. package/dist/core/history/runs.js +109 -0
  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 +601 -47
  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 +4 -2
@@ -1,13 +1,275 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { execFileSync } from 'node:child_process';
3
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
2
4
  import { createRequire } from 'node:module';
5
+ import { extname, relative, resolve, sep } from 'node:path';
3
6
  import {} from '../../adapters/command.js';
4
7
  import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
5
- import { loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
8
+ import { GUARD_DIR, GUARD_HISTORY_DIR, loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
6
9
  import { analyzeProjectImpact } from '../analysis/impact.js';
7
10
  import { discoverProject } from '../discovery/discovery.js';
8
11
  import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
12
+ import { auditGitHubActions } from './github-actions.js';
13
+ import { readGuardGitHubContext } from './github-context.js';
9
14
  const require = createRequire(import.meta.url);
10
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
+ }
11
273
  function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
12
274
  return {
13
275
  ...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
@@ -18,13 +280,61 @@ function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
18
280
  ...(decisionIds.length > 0 ? { decisionIds } : {}),
19
281
  };
20
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
+ }
21
304
  function receiptCheck(result) {
305
+ const command = sanitizeCommand(result.command);
306
+ const args = command.trim().split(/\s+/).filter(Boolean).slice(1);
22
307
  return {
23
308
  status: result.status,
24
309
  exitCode: result.exitCode,
25
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',
26
318
  };
27
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
+ }
28
338
  function resolveGitCommit(root, ref) {
29
339
  if (!ref)
30
340
  return undefined;
@@ -42,29 +352,88 @@ function resolveGitCommit(root, ref) {
42
352
  return undefined;
43
353
  }
44
354
  }
45
- function changedFilesSince(root, baseRef) {
46
- if (!baseRef)
47
- return undefined;
355
+ function hasWorkingTreeChanges(root) {
48
356
  try {
49
- return execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], {
357
+ return (execFileSync('git', ['status', '--porcelain', '-z'], {
50
358
  cwd: root,
51
359
  stdio: 'pipe',
52
360
  timeout: 5_000,
53
- })
54
- .toString()
55
- .split('\n')
56
- .map((file) => file.trim().replace(/\\/g, '/'))
57
- .filter(Boolean)
58
- .sort();
361
+ }).length > 0);
59
362
  }
60
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('..'))
61
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
+ }
62
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 };
63
431
  }
64
- function buildVerificationReceipt(root, policy, impact, checks, workspaceChecks, adapters, architecture, contractIssues, stages) {
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);
65
434
  const baseCommit = resolveGitCommit(root, policy.ref);
66
- const baseChangedFiles = changedFilesSince(root, policy.ref);
67
- const workingTreeFiles = impact.requestedFiles;
435
+ const baseChangedFiles = committedFilesSince(root, policy.ref);
436
+ const workingTreeFiles = workingTreeChangedFiles(root) ?? impact.requestedFiles;
68
437
  const receiptChecks = {};
69
438
  for (const [name, result] of Object.entries(checks)) {
70
439
  receiptChecks[name] = receiptCheck(result);
@@ -81,6 +450,10 @@ function buildVerificationReceipt(root, policy, impact, checks, workspaceChecks,
81
450
  version: 1,
82
451
  kind: 'verification-receipt',
83
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 } : {}),
84
457
  ...(policy.ref ? { baseRef: policy.ref } : {}),
85
458
  ...(baseCommit ? { baseCommit } : {}),
86
459
  ...(baseChangedFiles ? { baseChangedFiles } : {}),
@@ -89,12 +462,19 @@ function buildVerificationReceipt(root, policy, impact, checks, workspaceChecks,
89
462
  workingTreeDirty: workingTreeFiles.length > 0,
90
463
  },
91
464
  policy,
465
+ verificationScope: scope,
466
+ policySurface,
467
+ ...(ciIntegrity ? { ciIntegrity } : {}),
468
+ requiredStages,
469
+ incompleteStages,
470
+ effectiveInvocation,
92
471
  stages,
93
472
  checks: receiptChecks,
94
473
  findings: {
95
474
  total: architecture.findings.length,
96
475
  errors: architecture.findings.filter((finding) => finding.severity === 'error').length,
97
- warnings: architecture.findings.filter((finding) => finding.severity === 'warning').length,
476
+ warnings: architecture.findings.filter((finding) => finding.severity === 'warning').length +
477
+ (policySurface.changes ?? []).filter((change) => change.severity === 'warning').length,
98
478
  waived: architecture.waived,
99
479
  waiverWarnings: architecture.waiverWarnings.length,
100
480
  expiredWaivers: architecture.expiredWaivers.length,
@@ -106,6 +486,48 @@ function buildVerificationReceipt(root, policy, impact, checks, workspaceChecks,
106
486
  tool: { name: '@codapult/guard', version: packageJson.version },
107
487
  };
108
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
+ }
109
531
  export function runGuardVerification(root, options = {}) {
110
532
  const runContext = startGuardRun();
111
533
  const requirement = {
@@ -118,7 +540,7 @@ export function runGuardVerification(root, options = {}) {
118
540
  const runtimeStartedAt = Date.now();
119
541
  const runtime = inspectProjectRuntime(root);
120
542
  recordGuardRunStage(runContext, 'runtime', 'ok', runtimeStartedAt);
121
- if (options.failOnPolicyChange && !options.policyBase) {
543
+ if ((options.failOnPolicyChange || options.failOnProtectedChanges) && !options.policyBase) {
122
544
  const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
123
545
  return {
124
546
  status: 'fail',
@@ -129,7 +551,7 @@ export function runGuardVerification(root, options = {}) {
129
551
  workspaceChecks: {},
130
552
  adapters: {},
131
553
  runtime,
132
- configError: '`failOnPolicyChange` requires `policyBase`.',
554
+ configError: 'Strict policy checks require `policyBase`.',
133
555
  contractIssues: [],
134
556
  requirement,
135
557
  run,
@@ -176,6 +598,9 @@ export function runGuardVerification(root, options = {}) {
176
598
  let policySnapshot;
177
599
  let workingTreeSnapshot;
178
600
  let policyChanged;
601
+ let policySurface;
602
+ let ciIntegrity;
603
+ let uncheckedProtectedPaths;
179
604
  let policyDecisionIds = [];
180
605
  try {
181
606
  workingTreeSnapshot = workingTreeState.snapshot;
@@ -202,6 +627,25 @@ export function runGuardVerification(root, options = {}) {
202
627
  policySnapshot = baseSnapshot ?? workingTreeSnapshot;
203
628
  policyChanged =
204
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);
205
649
  if (policySnapshot.source === 'working-tree') {
206
650
  policyDecisionIds = (workingTreeState.proposals?.decisions ?? [])
207
651
  .filter((decision) => decision.policyFingerprint === policySnapshot.fingerprint)
@@ -211,7 +655,7 @@ export function runGuardVerification(root, options = {}) {
211
655
  }
212
656
  if (options.failOnPolicyChange && policyChanged) {
213
657
  const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
214
- 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]))));
215
659
  return {
216
660
  status: 'fail',
217
661
  outcome: 'fail',
@@ -227,6 +671,40 @@ export function runGuardVerification(root, options = {}) {
227
671
  run,
228
672
  policy,
229
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 } : {}),
230
708
  };
231
709
  }
232
710
  }
@@ -251,11 +729,13 @@ export function runGuardVerification(root, options = {}) {
251
729
  const agentConfig = policySnapshot.agentConfig;
252
730
  const projectChecks = options.projectChecks ?? agentConfig.completionGate.projectChecks;
253
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);
254
736
  const checksStartedAt = Date.now();
255
737
  const results = runProjectChecks(root, checks, { timeout: options.timeout });
256
738
  recordGuardRunStage(runContext, 'project-checks', projectChecks ? 'ok' : 'skipped', checksStartedAt);
257
- const discoveryStartedAt = Date.now();
258
- const workspaceModel = discoverProject(root);
259
739
  const impact = analyzeProjectImpact(workspaceModel, options.changedOnly === false
260
740
  ? workspaceModel.modules.map((module) => module.path)
261
741
  : workspaceModel.git.changedFiles, policySnapshot.config.contracts ?? []);
@@ -283,6 +763,19 @@ export function runGuardVerification(root, options = {}) {
283
763
  });
284
764
  const contractIssues = validateGuardPolicy(root, policySnapshot.config);
285
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.');
286
779
  const commandFailed = Object.values(results).some((result) => result.status === 'failed');
287
780
  const workspaceCommandFailed = Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'failed'));
288
781
  const adapterFailed = Object.values(adapters).some((result) => result.status === 'failed');
@@ -296,7 +789,8 @@ export function runGuardVerification(root, options = {}) {
296
789
  const expiredWaivers = architecture.expiredWaivers.length > 0;
297
790
  const architectureWarnings = architecture.findings.some((finding) => finding.severity === 'warning') ||
298
791
  (architecture.waiverWarnings ?? []).length > 0;
299
- const failed = runtimeFailed ||
792
+ const policySurfaceWarnings = policySurface.changes.some((change) => change.severity === 'warning');
793
+ const otherFailures = runtimeFailed ||
300
794
  commandFailed ||
301
795
  workspaceCommandFailed ||
302
796
  adapterFailed ||
@@ -305,6 +799,9 @@ export function runGuardVerification(root, options = {}) {
305
799
  contractIssues.length > 0 ||
306
800
  missingProjectChecks ||
307
801
  missingAdapterTools;
802
+ const ciWeakened = ciIntegrity?.status === 'policy-weakened';
803
+ const ciIncomplete = ciIntegrity?.status === 'incomplete';
804
+ const failed = otherFailures || ciWeakened;
308
805
  const projectChecksFailed = commandFailed || workspaceCommandFailed || missingProjectChecks || runtimeFailed;
309
806
  const adaptersFailed = adapterFailed || missingAdapterTools;
310
807
  runContext.stages['project-checks'] = {
@@ -313,44 +810,97 @@ export function runGuardVerification(root, options = {}) {
313
810
  };
314
811
  runContext.stages.adapters = {
315
812
  ...runContext.stages.adapters,
316
- 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',
317
819
  };
318
820
  runContext.stages.runtime = {
319
821
  ...runContext.stages.runtime,
320
- status: runtimeFailed ? 'fail' : 'ok',
822
+ status: projectChecks ? (runtimeFailed ? 'fail' : 'ok') : 'skipped',
321
823
  };
322
824
  runContext.stages['architecture-policy'] = {
323
825
  ...runContext.stages['architecture-policy'],
324
826
  status: architectureFailed || expiredWaivers || contractIssues.length > 0 ? 'fail' : 'ok',
325
827
  };
326
- const outcome = classifyGuardOutcome({
327
- errors: failed ? 1 : 0,
328
- warnings: architectureWarnings ? 1 : 0,
329
- });
330
- const gate = architectureFailed || expiredWaivers
331
- ? 'architecture'
332
- : contractIssues.length > 0
333
- ? 'contracts'
334
- : commandFailed || workspaceCommandFailed
335
- ? 'project-checks'
336
- : adapterFailed
337
- ? 'adapters'
338
- : missingProjectChecks
339
- ? 'project-checks'
340
- : missingAdapterTools
341
- ? 'required-tools'
342
- : runtimeFailed
343
- ? 'runtime'
344
- : 'none';
345
- const receipt = buildVerificationReceipt(root, policy, impact, results, workspaceChecks, adapters, architecture, contractIssues, Object.fromEntries(Object.entries(runContext.stages).map(([name, stage]) => [name, stage.status])));
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
+ }));
346
875
  const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy, receipt);
347
876
  return {
348
- 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
+ : {}),
349
893
  outcome,
350
894
  tools: toolMode,
351
- checks: results,
352
- workspaceChecks,
353
- 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
+ ])),
354
904
  runtime,
355
905
  architecture,
356
906
  impact,
@@ -359,5 +909,9 @@ export function runGuardVerification(root, options = {}) {
359
909
  run,
360
910
  policy,
361
911
  ...(policy.changedFromBase !== undefined ? { policyChanged: policy.changedFromBase } : {}),
912
+ policySurface,
913
+ ...(ciIntegrity ? { ciIntegrity } : {}),
914
+ requiredStages: effectiveRequiredStages,
915
+ incompleteStages: effectiveIncompleteStages,
362
916
  };
363
917
  }