@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
package/README.md CHANGED
@@ -6,14 +6,18 @@
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
  [![Node.js >=20.19](https://img.shields.io/badge/node-%3E%3D20.19-339933.svg?logo=node.js&logoColor=white)](package.json)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-3178C6.svg?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
9
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-Listed-4B6A88?style=flat-square)](https://registry.modelcontextprotocol.io/?q=io.github.codapult%2Fguard)
9
10
 
10
11
  ## Architecture guardrails for AI coding agents
11
12
 
12
- `@codapult/guard` is a local-first, model-agnostic architecture guard for JavaScript and
13
- TypeScript projects.
13
+ `@codapult/guard` is local-first, model-agnostic **architecture guardrail software for AI coding
14
+ agents**. Use it to detect architectural drift, enforce approved software architecture rules, and
15
+ check code changes in JavaScript and TypeScript projects.
14
16
 
15
- It learns what already exists, records the project’s architectural memory, and can protect future
16
- changes from introducing regressions covered by the project’s approved policy.
17
+ Guard learns how your codebase is structured, records project architecture as reviewable memory, and
18
+ checks future changes against the rules your team approves. It connects AI coding agents to those
19
+ rules and returns deterministic evidence when a change introduces a covered architecture
20
+ regression. Run the same project-specific checks locally and in CI.
17
21
 
18
22
  ### See the value in one change
19
23
 
@@ -385,6 +389,131 @@ review control when policy changes require a separate approval. For example, a r
385
389
  `.codapult/guard/* @architecture-owners` to `CODEOWNERS`. Guard reports the policy change; the
386
390
  hosting platform enforces reviewer authorization.
387
391
 
392
+ Guard also reports changes to verification-sensitive files using built-in paths for CI workflows,
393
+ package scripts and lockfiles, and common formatter, linter, typecheck, and test configurations.
394
+ Projects can extend this list in the base revision's `.codapult/guard/rules.json`:
395
+
396
+ ```json
397
+ {
398
+ "version": 1,
399
+ "rules": [],
400
+ "protectedPaths": [".husky/**", "config/security-checks/**"]
401
+ }
402
+ ```
403
+
404
+ For classified entries and warning-only paths, use the explicit `policySurface.paths` form in that
405
+ same base policy:
406
+
407
+ ```json
408
+ {
409
+ "version": 1,
410
+ "rules": [],
411
+ "policySurface": {
412
+ "paths": [
413
+ { "pattern": "config/security-checks/**", "kind": "custom", "severity": "error" },
414
+ { "pattern": "**/.custom-ignore", "kind": "ignore-config", "severity": "warning" }
415
+ ]
416
+ }
417
+ }
418
+ ```
419
+
420
+ Supported kinds are `ci-workflow`, `guard-policy`, `ownership`, `dependency-input`, `tool-config`, `ignore-config`,
421
+ `suppression-source`, and `custom`. `error` entries fail when `--fail-on-protected-changes` is
422
+ enabled; `warning` entries are reported without blocking. Built-in verification-sensitive paths
423
+ remain blocking. Both forms are read from the base policy during PR verification, so the PR cannot
424
+ remove its own path coverage.
425
+
426
+ In pull request CI, add `--fail-on-protected-changes` alongside `--policy-base`. This fails the
427
+ Guard invocation when a listed path changes without a matching CI integrity contract, or when a
428
+ contracted workflow fails its checks. It emits all matched paths in JSON output. Protect
429
+ the Guard workflow itself and require its status check in repository settings: a workflow edited
430
+ or removed by a PR cannot independently enforce its own integrity. Guard reports workflow edits,
431
+ but GitHub branch protection is what makes the check mandatory. The sample workflow rejects PR
432
+ changes to package manifests, common npm/pnpm/Yarn/Bun lockfiles, package-manager config, and
433
+ workspace install inputs before
434
+ selecting pnpm or installing dependencies. Review those changes in a separate PR. This preflight is
435
+ defense in depth: repository rules must still protect the workflow and require the Guard check.
436
+
437
+ Guard reports added `eslint-disable`, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, and
438
+ `biome-ignore`, and `prettier-ignore` markers in the PR diff. It reports the file and marker
439
+ without interpreting the tool-specific reason or whether the suppression is justified. Other
440
+ tools can be covered by protecting their configuration/source paths and running them in a trusted
441
+ CI workflow. The matched paths, markers, and scan completeness are recorded in the verification
442
+ result and receipt.
443
+
444
+ ### Required verification stages
445
+
446
+ `agent.json` declares which stages must complete for verification to pass:
447
+
448
+ ```json
449
+ {
450
+ "version": 1,
451
+ "tools": "auto",
452
+ "tooling": {},
453
+ "completionGate": {
454
+ "enabled": true,
455
+ "maxIterations": 3,
456
+ "projectChecks": true,
457
+ "checks": ["lint", "typecheck", "test", "build"],
458
+ "requiredStages": ["architecture-policy", "project-checks", "runtime"]
459
+ }
460
+ }
461
+ ```
462
+
463
+ Supported stages are `runtime`, `project-checks`, `adapters`, `architecture-policy`,
464
+ `policy-surface`, `ci-integrity`, and `verification-scope`. The compatibility default requires `architecture-policy`;
465
+ projects opt into additional requirements. If a required stage is skipped or cannot be confirmed,
466
+ the result is `incomplete`, the CLI exits nonzero, and the receipt names the incomplete stages.
467
+ When base policy is used, `policy-surface` is required automatically. A configured CI integrity
468
+ contract makes `ci-integrity` required automatically. `verification-scope` is always required:
469
+ Guard hashes its discovered project inputs before checks and confirms that the scope stayed stable
470
+ through the run.
471
+
472
+ ### GitHub Actions integrity contract
473
+
474
+ Declare the exact workflow and required job in the base revision's `rules.json`:
475
+
476
+ ```json
477
+ {
478
+ "version": 1,
479
+ "rules": [],
480
+ "githubActions": {
481
+ "workflows": [
482
+ {
483
+ "path": ".github/workflows/guard.yml",
484
+ "job": "guard",
485
+ "requiredRunIncludes": [
486
+ "pnpm exec codapult-guard verify",
487
+ "--policy-base",
488
+ "--fail-on-policy-change",
489
+ "--fail-on-protected-changes"
490
+ ],
491
+ "maxPermissions": { "contents": "read", "security-events": "write" }
492
+ }
493
+ ]
494
+ }
495
+ }
496
+ ```
497
+
498
+ Guard parses the declared workflow and checks its pull request trigger, configured filters, required
499
+ job, required command fragments in the Guard invocation, conditional execution, `continue-on-error`,
500
+ declared job dependencies, explicit permission ceilings, and full commit SHA pins for external
501
+ actions. Local actions cannot meet immutable SHA pinning; if a workflow uses one, set
502
+ `requireFullShaActions` to `false` and list its source under `protectedPaths` in the base policy.
503
+ Guard compares each action ref with the contracted workflow at the base revision; additions,
504
+ removals, and SHA changes are reported as `policy-weakened` for separate review even when the new
505
+ ref is itself SHA-pinned. Missing protections are reported as `policy-weakened`; malformed or
506
+ unreadable workflow data is `incomplete`. The result and receipt include base/current workflow
507
+ digests, action refs and their changes, and issues. Guard does not
508
+ evaluate arbitrary GitHub expressions or prove that repository settings require the check; branch
509
+ protection or rulesets must still require the expected check source.
510
+ The audit rejects `pull_request_target` on a contracted verification workflow because Guard cannot
511
+ prove that it never executes untrusted PR content with that event's elevated context.
512
+
513
+ The receipt records the effective Guard invocation and each project check's command and arguments.
514
+ Credential-like values are redacted from command evidence; treat receipts as review artifacts, not
515
+ as a substitute for trusted CI logs.
516
+
388
517
  To make policy changes fail this invocation explicitly, add:
389
518
 
390
519
  ```bash
@@ -415,6 +544,13 @@ The package repository separately runs its own unit, typecheck, build, and packa
415
544
  [`.github/workflows/ci.yml`](.github/workflows/ci.yml), while the fixture workflow tests Guard on
416
545
  real project shapes.
417
546
 
547
+ To add a separately signed Guard receipt, copy
548
+ [`docs/guard-trusted-pr-attestation.yml`](docs/guard-trusted-pr-attestation.yml) to
549
+ `.github/workflows/guard-trusted-pr-attestation.yml` and follow the
550
+ [trusted-attestation setup guide](docs/guard.md#install-the-trusted-github-actions-workflow).
551
+ This optional workflow re-runs Guard; it does not replace the required `Guard` check or attest
552
+ project test/build results.
553
+
418
554
  ## CLI surface
419
555
 
420
556
  | Command | Purpose |
@@ -455,6 +591,20 @@ audits this metadata and reports missing or overlapping declared actors. It cont
455
591
  or external telemetry. These manifests make a failed run explainable without turning Guard into a
456
592
  production tracing system or an identity/signature ledger.
457
593
 
594
+ The manifest also contains a `verification-receipt` object. It is a compact, machine-readable
595
+ record of the commit, policy base and fingerprint, decision IDs, check statuses, finding counts,
596
+ contract issues, waiver state, and Guard version. Its `verificationScope` contains SHA-256 hashes
597
+ and paths for files discovered by Guard plus changed and deleted paths; the fingerprint binds that
598
+ inventory to the effective policy and Guard version. The `verification-scope` stage is incomplete
599
+ if Guard cannot hash the full scope or if the inputs changed during the run. The receipt excludes
600
+ source contents and command output. It describes what Guard decided; it is not a cryptographic
601
+ signature or proof of CI identity. When a policy base is supplied, it separates the base diff from
602
+ dirty working-tree files so local verification cannot silently present an incomplete change set.
603
+ For portable exchange, `verify --attestation-out <file>` exports a passing receipt as an unsigned
604
+ in-toto Statement. The format is independent of the CI platform and signing mechanism; a trusted CI
605
+ signer can sign it using the mechanism supported by that platform. The attestation guide includes a
606
+ GitHub Actions example using Sigstore. See the [attestation guide](docs/guard.md#exporting-and-signing-a-verification-attestation).
607
+
458
608
  ## Security and data handling
459
609
 
460
610
  - Deterministic discovery and checks run locally.
@@ -499,12 +649,6 @@ Starting a SaaS from scratch? [Codapult](https://codapult.dev) is a production-r
499
649
  foundation built for AI-assisted development — with MCP context, built-in AI, modular
500
650
  architecture, and the source code under your control.
501
651
 
502
- ## Project status
503
-
504
- `@codapult/guard` starts at `0.1.0` as a public alpha. The deterministic core is usable, tested,
505
- and intended for real projects, while policy schema and integration surfaces may still evolve
506
- before `1.0.0`.
507
-
508
652
  ## License
509
653
 
510
654
  MIT. See [LICENSE](LICENSE).
@@ -1,4 +1,5 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { dirname, resolve } from 'node:path';
3
4
  export class GuardAgentInstallError extends Error {
4
5
  constructor(message) {
@@ -8,6 +9,21 @@ export class GuardAgentInstallError extends Error {
8
9
  }
9
10
  const START_MARKER = '<!-- codapult-guard:start -->';
10
11
  const END_MARKER = '<!-- codapult-guard:end -->';
12
+ function writeInstructionFile(path, content) {
13
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
14
+ try {
15
+ writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
16
+ renameSync(temporaryPath, path);
17
+ }
18
+ finally {
19
+ try {
20
+ unlinkSync(temporaryPath);
21
+ }
22
+ catch {
23
+ // The temporary file was renamed or could not be created.
24
+ }
25
+ }
26
+ }
11
27
  const instruction = `${START_MARKER}
12
28
  ## Codapult Guard completion gate
13
29
 
@@ -56,7 +72,14 @@ export function installGuardAgentInstructions(root, target) {
56
72
  assertNoSymlinkPath(root, relativePath);
57
73
  const path = resolve(root, relativePath);
58
74
  const nextBlock = instruction;
59
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
75
+ let existing = '';
76
+ try {
77
+ existing = readFileSync(path, 'utf8');
78
+ }
79
+ catch (error) {
80
+ if (error.code !== 'ENOENT')
81
+ throw error;
82
+ }
60
83
  const start = existing.indexOf(START_MARKER);
61
84
  const end = existing.indexOf(END_MARKER);
62
85
  const content = start >= 0 && end >= start
@@ -65,7 +88,7 @@ export function installGuardAgentInstructions(root, target) {
65
88
  ? `${existing.trimEnd()}\n\n${renderContent(target)}`
66
89
  : renderContent(target);
67
90
  mkdirSync(dirname(path), { recursive: true });
68
- writeFileSync(path, content, 'utf8');
91
+ writeInstructionFile(path, content);
69
92
  return { target, path: relativePath, action: existing ? 'updated' : 'created' };
70
93
  }
71
94
  export function installGuardAgentTargets(root, targetsToInstall) {
@@ -19,6 +19,8 @@ interface GuardVerifyOptions extends GuardOutputOptions {
19
19
  projectChecks?: boolean | undefined;
20
20
  policyBase?: string | undefined;
21
21
  failOnPolicyChange?: boolean | undefined;
22
+ failOnProtectedChanges?: boolean | undefined;
23
+ attestationOut?: string | undefined;
22
24
  }
23
25
  interface GuardGovernanceOptions extends GuardOutputOptions {
24
26
  strict?: boolean | undefined;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { relative, resolve } from 'node:path';
4
4
  import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, GuardWaiverError, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, loadGuardWaivers, updateBaseline, updateGuardWaivers, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, getGuardPolicyMutationPolicy, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
5
5
  import { guardErrorPayload } from '../../core/errors.js';
@@ -12,6 +12,15 @@ import { guardFindingsToSarif } from '../../core/output/sarif.js';
12
12
  import { analyzeProjectImpact } from '../../core/analysis/impact.js';
13
13
  import { listGuardRuns, summarizeGuardRuns } from '../../core/history/runs.js';
14
14
  import { auditGuardGovernance } from '../../core/audit/governance.js';
15
+ import { guardReceiptToInToto } from '../../core/output/attestation.js';
16
+ function terminalSafe(value) {
17
+ let output = '';
18
+ for (const character of value) {
19
+ const code = character.charCodeAt(0);
20
+ output += code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : character;
21
+ }
22
+ return output;
23
+ }
15
24
  function renderFindings(findings) {
16
25
  for (const finding of findings) {
17
26
  const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
@@ -49,7 +58,7 @@ function loadConfigSafely(root, options = {}) {
49
58
  if (options.json || options.machine)
50
59
  console.log(JSON.stringify(payload, null, 2));
51
60
  else
52
- fail(`${payload.message} Run \`codapult-guard doctor\` for details.`);
61
+ fail(`${terminalSafe(payload.message)} Run \`codapult-guard doctor\` for details.`);
53
62
  process.exitCode = 1;
54
63
  return { invalid: true };
55
64
  }
@@ -217,7 +226,7 @@ export function guardRunsCommand(options = {}) {
217
226
  return;
218
227
  }
219
228
  heading('Codapult Guard Runs');
220
- info(`${result.summary.total} run(s); ${result.summary.failed} failed, ${result.summary.warnings} warning, average ${result.summary.averageDurationMs} ms`);
229
+ info(`${result.summary.total} run(s); ${result.summary.failed} failed, ${result.summary.incomplete} incomplete, ${result.summary.policyWeakened} policy-weakened, ${result.summary.warnings} warning, average ${result.summary.averageDurationMs} ms`);
221
230
  for (const run of result.runs) {
222
231
  info(`${run.runId}: ${run.outcome}, gate=${run.gate}, ${run.durationMs} ms`);
223
232
  }
@@ -312,10 +321,26 @@ export function guardVerifyCommand(options = {}) {
312
321
  projectChecks: options.projectChecks,
313
322
  policyBase: options.policyBase,
314
323
  failOnPolicyChange: options.failOnPolicyChange,
324
+ failOnProtectedChanges: options.failOnProtectedChanges,
315
325
  });
326
+ let attestationFailed = false;
327
+ if (options.attestationOut) {
328
+ try {
329
+ if (!result.run.receipt)
330
+ throw new Error('Guard did not produce a verification receipt.');
331
+ const statement = guardReceiptToInToto(result.run.receipt);
332
+ writeFileSync(resolve(options.attestationOut), `${JSON.stringify(statement, null, 2)}\n`);
333
+ if (!options.json)
334
+ info(`in-toto verification statement written to ${options.attestationOut}`);
335
+ }
336
+ catch (error) {
337
+ attestationFailed = true;
338
+ fail(error instanceof Error ? error.message : 'Could not write the in-toto statement.');
339
+ }
340
+ }
316
341
  if (options.json) {
317
342
  console.log(JSON.stringify(result, null, 2));
318
- process.exitCode = result.status === 'ok' ? 0 : 1;
343
+ process.exitCode = result.status === 'ok' && !attestationFailed ? 0 : 1;
319
344
  return;
320
345
  }
321
346
  heading('Codapult Guard Verify');
@@ -330,21 +355,62 @@ export function guardVerifyCommand(options = {}) {
330
355
  warn('The working-tree policy differs from the base policy; the base policy was used.');
331
356
  }
332
357
  }
358
+ if (result.policySurface) {
359
+ dim(`protected paths changed: ${result.policySurface.changedPaths.length}`);
360
+ for (const path of result.policySurface.changedPaths)
361
+ warn(`verification-sensitive: ${terminalSafe(path)}`);
362
+ for (const change of result.policySurface.changes) {
363
+ dim(`policy surface: ${change.kind}/${change.severity} ${terminalSafe(change.path)} (${terminalSafe(change.pattern)})`);
364
+ }
365
+ for (const marker of result.policySurface.suppressionMarkers)
366
+ warn(`inline suppression added: ${terminalSafe(marker)}`);
367
+ if (result.policySurface.workflowChanged)
368
+ warn('Workflow changes require host-enforced required checks and protected review.');
369
+ }
370
+ if (result.ciIntegrity) {
371
+ if (result.ciIntegrity.status === 'ok')
372
+ success('CI integrity: passed');
373
+ else if (result.ciIntegrity.status === 'policy-weakened') {
374
+ fail('CI integrity: policy weakened');
375
+ }
376
+ else {
377
+ warn('CI integrity: incomplete');
378
+ }
379
+ for (const workflow of result.ciIntegrity.workflows) {
380
+ for (const change of workflow.actionChanges ?? []) {
381
+ warn(`[${terminalSafe(workflow.path)}] action refs changed for ${terminalSafe(change.name)}: ${terminalSafe(change.base.join(', ') || '(none)')} → ${terminalSafe(change.current.join(', ') || '(none)')}`);
382
+ }
383
+ for (const issue of workflow.issues)
384
+ fail(`[${terminalSafe(workflow.path)}] ${terminalSafe(issue.message)}`);
385
+ }
386
+ }
387
+ const receipt = result.run.receipt;
388
+ if (receipt?.effectiveInvocation) {
389
+ dim(`effective invocation: ${terminalSafe(receipt.effectiveInvocation.command)} ${terminalSafe(receipt.effectiveInvocation.args.join(' '))}`);
390
+ }
391
+ if (result.incompleteStages && result.incompleteStages.length > 0) {
392
+ warn(`Required stages incomplete: ${result.incompleteStages.join(', ')}`);
393
+ }
333
394
  for (const [name, check] of Object.entries(result.checks)) {
334
395
  if (check.status === 'passed')
335
- success(`${name}: passed`);
396
+ success(`${terminalSafe(name)}: passed`);
336
397
  else if (check.status === 'not-configured')
337
- dim(`${name}: not configured`);
398
+ dim(`${terminalSafe(name)}: not configured`);
338
399
  else
339
- fail(`${name}: failed (exit ${check.exitCode})`);
400
+ fail(`${terminalSafe(name)}: failed (exit ${check.exitCode})`);
340
401
  }
341
402
  for (const [name, check] of Object.entries(result.adapters ?? {})) {
342
403
  if (check.status === 'passed')
343
- success(`${name}: passed`);
404
+ success(`${terminalSafe(name)}: passed`);
344
405
  else if (check.status === 'not-configured')
345
- dim(`${name}: not configured`);
406
+ dim(`${terminalSafe(name)}: not configured`);
346
407
  else
347
- fail(`${name}: failed (exit ${check.exitCode})`);
408
+ fail(`${terminalSafe(name)}: failed (exit ${check.exitCode})`);
409
+ }
410
+ for (const [name, evidence] of Object.entries(receipt?.checks ?? {})) {
411
+ if (!evidence.command)
412
+ continue;
413
+ dim(`${terminalSafe(name)} command (${evidence.execution ?? 'unknown'}): ${terminalSafe(evidence.command)}${(evidence.args?.length ?? 0) > 0 ? ` ${terminalSafe(evidence.args?.join(' ') ?? '')}` : ''}`);
348
414
  }
349
415
  const findings = result.architecture?.findings ?? [];
350
416
  if (findings.length === 0)
@@ -358,7 +424,7 @@ export function guardVerifyCommand(options = {}) {
358
424
  if (result.contractIssues.length > 0)
359
425
  fail(`contracts: ${result.contractIssues.length} invalid`);
360
426
  dim(`requirements: ${result.requirement.message}`);
361
- process.exitCode = result.status === 'ok' ? 0 : 1;
427
+ process.exitCode = result.status === 'ok' && !attestationFailed ? 0 : 1;
362
428
  }
363
429
  export function guardCheckCommand(options = {}) {
364
430
  const root = getRoot();
package/dist/cli/index.js CHANGED
@@ -44,6 +44,8 @@ guard
44
44
  .option('--changed')
45
45
  .option('--policy-base <ref>', 'read policy artifacts from this Git ref')
46
46
  .option('--fail-on-policy-change', 'fail when the working-tree policy differs from the base ref')
47
+ .option('--fail-on-protected-changes', 'fail when protected verification paths change relative to base')
48
+ .option('--attestation-out <file>', 'write a passing verification as an in-toto statement')
47
49
  .option('--json')
48
50
  .action(guardVerifyCommand);
49
51
  guard
@@ -3,12 +3,13 @@ import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findG
3
3
  import { type GuardImpactAnalysis } from './analysis/impact.js';
4
4
  import type { GuardAdapterName, GuardApprovalMode, GuardContractKind, GuardBudgetMetric, GuardRuleKind, GuardRuleStatus, GuardSeverity, GuardToolMode, GuardWaiver, GuardWaiverPolicy } from './model/types.js';
5
5
  export type { GuardAdapterName, GuardApprovalMode, GuardContractKind, GuardBudgetMetric, GuardRuleKind, GuardRuleStatus, GuardSeverity, GuardToolMode, GuardWaiver, GuardWaiverPolicy, GuardWaiverDecision, } from './model/types.js';
6
- export type GuardOutcomeStatus = 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
6
+ export type GuardOutcomeStatus = 'pass' | 'fail' | 'incomplete' | 'policy-weakened' | 'warning' | 'needs-review' | 'not-configured';
7
7
  export interface GuardOutcomeInput {
8
8
  configured?: boolean | undefined;
9
9
  errors?: number | undefined;
10
10
  warnings?: number | undefined;
11
11
  needsReview?: boolean | undefined;
12
+ incomplete?: boolean | undefined;
12
13
  }
13
14
  interface GuardStateLockOptions {
14
15
  waitMs?: number | undefined;
@@ -86,6 +87,30 @@ export interface GuardConfig {
86
87
  budgets?: GuardBudget[] | undefined;
87
88
  approval?: GuardApprovalPolicy | undefined;
88
89
  waiverPolicy?: GuardWaiverPolicy | undefined;
90
+ protectedPaths?: string[] | undefined;
91
+ policySurface?: GuardPolicySurfacePolicy | undefined;
92
+ githubActions?: GuardGitHubActionsPolicy | undefined;
93
+ }
94
+ export interface GuardPolicySurfacePolicy {
95
+ paths: {
96
+ pattern: string;
97
+ kind: 'ci-workflow' | 'guard-policy' | 'ownership' | 'dependency-input' | 'tool-config' | 'ignore-config' | 'suppression-source' | 'custom';
98
+ severity: 'warning' | 'error';
99
+ }[];
100
+ }
101
+ export interface GuardGitHubActionsPolicy {
102
+ workflows: {
103
+ path: string;
104
+ job: string;
105
+ requiredRunIncludes: string[];
106
+ requiredNeeds?: string[] | undefined;
107
+ requirePullRequest?: boolean | undefined;
108
+ requireUnfilteredPullRequest?: boolean | undefined;
109
+ requireUnconditionalJob?: boolean | undefined;
110
+ requireFullShaActions?: boolean | undefined;
111
+ disallowContinueOnError?: boolean | undefined;
112
+ maxPermissions?: Record<string, 'none' | 'read' | 'write'> | undefined;
113
+ }[];
89
114
  }
90
115
  export declare function getGuardPolicyMutationPolicy(guardConfig: GuardConfig | undefined): GuardPolicyMutationPolicy;
91
116
  export interface GuardApprovalPolicy {
@@ -155,8 +180,10 @@ export interface GuardAgentConfig {
155
180
  maxIterations: number;
156
181
  projectChecks: boolean;
157
182
  checks: ('lint' | 'typecheck' | 'test' | 'build')[];
183
+ requiredStages?: GuardRequiredStage[] | undefined;
158
184
  };
159
185
  }
186
+ export type GuardRequiredStage = 'runtime' | 'project-checks' | 'adapters' | 'architecture-policy' | 'policy-surface' | 'ci-integrity' | 'verification-scope';
160
187
  export interface GuardFinding {
161
188
  ruleId: string;
162
189
  severity: GuardSeverity;
@@ -17,6 +17,8 @@ export function classifyGuardOutcome(input) {
17
17
  return 'fail';
18
18
  if (input.needsReview)
19
19
  return 'needs-review';
20
+ if (input.incomplete)
21
+ return 'incomplete';
20
22
  return (input.warnings ?? 0) > 0 ? 'warning' : 'pass';
21
23
  }
22
24
  export function getGuardPolicyMutationPolicy(guardConfig) {
@@ -129,6 +131,7 @@ export const defaultGuardAgentConfig = {
129
131
  maxIterations: 3,
130
132
  projectChecks: true,
131
133
  checks: ['lint', 'typecheck', 'test', 'build'],
134
+ requiredStages: ['architecture-policy'],
132
135
  },
133
136
  };
134
137
  export class GuardAlreadyInitializedError extends Error {
@@ -428,6 +431,40 @@ export function fingerprintGuardConfig(guardConfig) {
428
431
  contracts: [...(guardConfig.contracts ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
429
432
  budgets: [...(guardConfig.budgets ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
430
433
  approval: guardConfig.approval ?? defaultGuardConfig.approval,
434
+ ...(guardConfig.protectedPaths !== undefined && guardConfig.protectedPaths.length > 0
435
+ ? { protectedPaths: [...guardConfig.protectedPaths].sort() }
436
+ : {}),
437
+ ...(guardConfig.policySurface
438
+ ? {
439
+ policySurface: {
440
+ paths: [...guardConfig.policySurface.paths]
441
+ .map((entry) => ({ ...entry, severity: entry.severity ?? 'error' }))
442
+ .sort((left, right) => `${left.pattern}:${left.kind}:${left.severity}`.localeCompare(`${right.pattern}:${right.kind}:${right.severity}`)),
443
+ },
444
+ }
445
+ : {}),
446
+ ...(guardConfig.githubActions
447
+ ? {
448
+ githubActions: {
449
+ workflows: guardConfig.githubActions.workflows
450
+ .map((workflow) => ({
451
+ ...workflow,
452
+ requiredNeeds: workflow.requiredNeeds
453
+ ? [...workflow.requiredNeeds].sort()
454
+ : undefined,
455
+ requirePullRequest: workflow.requirePullRequest ?? true,
456
+ requireUnfilteredPullRequest: workflow.requireUnfilteredPullRequest ?? true,
457
+ requireUnconditionalJob: workflow.requireUnconditionalJob ?? true,
458
+ requireFullShaActions: workflow.requireFullShaActions ?? true,
459
+ disallowContinueOnError: workflow.disallowContinueOnError ?? true,
460
+ maxPermissions: workflow.maxPermissions
461
+ ? Object.fromEntries(Object.entries(workflow.maxPermissions).sort(([left], [right]) => left.localeCompare(right)))
462
+ : undefined,
463
+ }))
464
+ .sort((left, right) => `${left.path}:${left.job}`.localeCompare(`${right.path}:${right.job}`)),
465
+ },
466
+ }
467
+ : {}),
431
468
  }))
432
469
  .digest('hex');
433
470
  }