@codapult/guard 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -172,6 +172,23 @@ Policy paths are validated as project-relative paths. Guard rejects traversal, a
172
172
  symlinks escaping the project root; import-boundary checks include imports, re-exports, and literal
173
173
  dynamic imports.
174
174
 
175
+ ### Policy scope and state changes
176
+
177
+ Guard stores policy at the project level, while each policy item can target a narrower boundary:
178
+
179
+ | Policy item | Scope controls |
180
+ | ----------- | ---------------------------------------------------------------------- |
181
+ | Rule | `files`, import patterns, and rule kind |
182
+ | Contract | `scope`, `entrypoints`, import/package constraints, and required calls |
183
+ | Budget | `scope`, metric, limit, severity, and written reason |
184
+ | Baseline | Exact finding fingerprints already accepted as legacy debt |
185
+ | Waiver | One active finding fingerprint, with owner, reason, and expiry |
186
+
187
+ Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
188
+ change policy are explicit: `init`, `rules/contracts approve|reject`, `baseline accept|remove`, and
189
+ `waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
190
+ project's configured commands and records a local run manifest; neither changes approved policy.
191
+
175
192
  ## What Guard discovers
176
193
 
177
194
  The model is framework-aware without being framework-dependent:
@@ -207,7 +224,7 @@ init once → edit → check --changed → review → verify → commit / merge
207
224
  ```
208
225
 
209
226
  1. `init` builds project memory and establishes the initial baseline.
210
- 2. `check --changed` is the fast deterministic architecture gate.
227
+ 2. `check` scans the current project; `check --changed` is the fast change-scoped architecture gate.
211
228
  3. `review` prepares a bounded and redacted diff packet for an AI host; it does not call an LLM.
212
229
  4. `verify` runs Guard policy, configured project checks, adapters, runtime diagnostics, and
213
230
  contract validation.
@@ -238,6 +255,11 @@ Guard state is stored in `.codapult/guard/`:
238
255
  Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
239
256
  disposable according to the project’s policy, and never commit secrets.
240
257
 
258
+ Baseline fingerprints do not include line numbers, so formatting and line movement do not recreate
259
+ the same finding. They do include the rule and affected file/import identity, so a rename or module
260
+ move can produce a new fingerprint and should be reviewed explicitly before accepting it into the
261
+ baseline.
262
+
241
263
  ### Temporary waivers
242
264
 
243
265
  Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
@@ -315,9 +337,10 @@ Register that command in the MCP client from the project root. For example, Curs
315
337
  {
316
338
  "mcpServers": {
317
339
  "codapult-guard": {
340
+ "type": "stdio",
318
341
  "command": "pnpm",
319
342
  "args": ["exec", "codapult-guard", "mcp-server"],
320
- "cwd": "."
343
+ "cwd": "${workspaceFolder}"
321
344
  }
322
345
  }
323
346
  }
@@ -369,6 +392,37 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
369
392
  These variables provide declared provenance only; external branch protection or signed identity
370
393
  remains responsible for proving who approved the change.
371
394
 
395
+ ### Verifying against protected policy
396
+
397
+ In CI, verify pull requests against the policy from the protected base commit rather than the
398
+ copy carried by the branch:
399
+
400
+ ```bash
401
+ codapult-guard verify --policy-base "$BASE_SHA" --json
402
+ ```
403
+
404
+ Guard then evaluates the changed source with the base commit's rules, contracts, baseline, waivers,
405
+ and completion-gate configuration. The result reports `policy.source`, `policy.ref`,
406
+ `policy.revision`, `policy.fingerprint`, and whether the checked-out policy differs from that
407
+ base. A branch cannot relax its policy or disable the gate and use that relaxed copy for the same
408
+ verification run.
409
+
410
+ The supplied [`docs/guard-ci.yml`](docs/guard-ci.yml) fetches the full Git history and passes the
411
+ pull request base SHA automatically. Protect `.codapult/guard/` with CODEOWNERS or the equivalent
412
+ review control when policy changes require a separate approval. For example, a repository can add
413
+ `.codapult/guard/* @architecture-owners` to `CODEOWNERS`. Guard reports the policy change; the
414
+ hosting platform enforces reviewer authorization.
415
+
416
+ To make policy changes fail this invocation explicitly, add:
417
+
418
+ ```bash
419
+ codapult-guard verify --policy-base "$BASE_SHA" --fail-on-policy-change
420
+ ```
421
+
422
+ This is useful for a dedicated policy-review gate. It is intentionally separate from using the
423
+ base policy for source verification, so teams can choose how their protected review workflow
424
+ handles an approved policy update.
425
+
372
426
  See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
373
427
  adapter, approval-governance, and observability integrations.
374
428
 
@@ -394,16 +448,18 @@ real project shapes.
394
448
  | `init` | Create project memory and the initial baseline. |
395
449
  | `analyze` | Refresh facts without changing policy or baseline. |
396
450
  | `propose` | Generate evidence-based rule and contract proposals. |
397
- | `check --changed` | Enforce active policy on changed and untracked files. |
451
+ | `check [--changed]` | Enforce active policy; `--changed` limits findings to changed and untracked files. |
398
452
  | `audit` | Scan the complete current project, including baseline findings. |
399
453
  | `review` | Create a bounded semantic-review packet for an AI host. |
400
454
  | `verify` | Run the configured completion gate. |
455
+ | `governance` | Audit declared proposal/approval provenance and decision-to-run reachability. |
401
456
  | `doctor` | Diagnose invalid or missing Guard artifacts. |
402
457
  | `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
403
458
  | `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
404
459
  | `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
405
460
  | `rules` / `contracts` | Approve or reject proposed policy. |
406
461
  | `baseline` | Review or intentionally accept existing findings. |
462
+ | `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
407
463
 
408
464
  Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
409
465
  discovery cache; it does not change rules, contracts, baseline, or source files.
@@ -417,8 +473,13 @@ Run `pnpm exec codapult-guard <command> --help` for command-specific options.
417
473
 
418
474
  Every `verify` run also writes a local manifest under
419
475
  `.codapult/guard/history/runs/<run-id>.json`. It records stage durations, the outcome, and the
420
- policy gate that decided the result. It contains no source code or external telemetry. These
421
- manifests make a failed run explainable without turning Guard into a production tracing system.
476
+ policy gate that decided the result. It also records the effective policy revision and fingerprint;
477
+ when a base ref was supplied, it records the ref and whether the working-tree policy differed.
478
+ When an approved policy decision caused the current policy, the manifest also retains its decision
479
+ ID, making the relationship traversable from approval to verification. The `governance` command
480
+ audits this metadata and reports missing or overlapping declared actors. It contains no source code
481
+ or external telemetry. These manifests make a failed run explainable without turning Guard into a
482
+ production tracing system or an identity/signature ledger.
422
483
 
423
484
  ## Security and data handling
424
485
 
@@ -4,6 +4,9 @@ export interface GuardAgentInstallResult {
4
4
  path: string;
5
5
  action: 'created' | 'updated';
6
6
  }
7
+ export declare class GuardAgentInstallError extends Error {
8
+ constructor(message: string);
9
+ }
7
10
  export declare function installGuardAgentInstructions(root: string, target: GuardAgentTarget): GuardAgentInstallResult;
8
11
  export declare function installGuardAgentTargets(root: string, targetsToInstall: GuardAgentTarget[]): GuardAgentInstallResult[];
9
12
  export declare const guardAgentTargets: GuardAgentTarget[];
@@ -1,18 +1,24 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
+ export class GuardAgentInstallError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'GuardAgentInstallError';
7
+ }
8
+ }
3
9
  const START_MARKER = '<!-- codapult-guard:start -->';
4
10
  const END_MARKER = '<!-- codapult-guard:end -->';
5
11
  const instruction = `${START_MARKER}
6
12
  ## Codapult Guard completion gate
7
13
 
8
- After completing a coding task, read the project Guard context, review the requirement and diff,
9
- then run Guard verification. Use the host's Guard MCP tools when available:
14
+ After completing a coding task, use the host's Guard MCP tools when available:
10
15
 
11
- 1. Call \`codapult_guard_context\`.
12
- 2. Call \`codapult_guard_review\` with the requirement and changed diff.
13
- 3. Call \`codapult_guard_verify\`.
14
- 4. If an error is reported, fix it and repeat steps 2–3 up to \`completionGate.maxIterations\`.
15
- 5. Report warnings and unresolved requirements; never hide them or activate proposals silently.
16
+ 1. Call \`codapult_guard_next_action\` and follow its bounded next step.
17
+ 2. Normally call \`codapult_guard_context\`.
18
+ 3. Call \`codapult_guard_review\` with the requirement and changed diff.
19
+ 4. Call \`codapult_guard_verify\`.
20
+ 5. If an error is reported, fix it and repeat steps 3–4 up to \`completionGate.maxIterations\`.
21
+ 6. Report warnings and unresolved requirements; never hide them or activate proposals silently.
16
22
 
17
23
  Guard does not edit source files or invoke an LLM. The host agent owns the repair loop. CI remains
18
24
  the independent final gate.
@@ -31,8 +37,23 @@ const targets = {
31
37
  function renderContent(target) {
32
38
  return `${targets[target].prefix ?? ''}${instruction}\n`;
33
39
  }
40
+ function assertNoSymlinkPath(root, relativePath) {
41
+ let current = resolve(root);
42
+ const rootStats = lstatSync(current, { throwIfNoEntry: false });
43
+ if (rootStats?.isSymbolicLink()) {
44
+ throw new GuardAgentInstallError(`Refusing to update ${relativePath}: project root is a symbolic link.`);
45
+ }
46
+ for (const segment of relativePath.split('/')) {
47
+ current = resolve(current, segment);
48
+ const stats = lstatSync(current, { throwIfNoEntry: false });
49
+ if (stats?.isSymbolicLink()) {
50
+ throw new GuardAgentInstallError(`Refusing to update ${relativePath}: ${current} is a symbolic link.`);
51
+ }
52
+ }
53
+ }
34
54
  export function installGuardAgentInstructions(root, target) {
35
55
  const relativePath = targets[target].path;
56
+ assertNoSymlinkPath(root, relativePath);
36
57
  const path = resolve(root, relativePath);
37
58
  const nextBlock = instruction;
38
59
  const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
@@ -17,6 +17,11 @@ interface GuardVerifyOptions extends GuardOutputOptions {
17
17
  tools?: GuardToolMode | undefined;
18
18
  strict?: boolean | undefined;
19
19
  projectChecks?: boolean | undefined;
20
+ policyBase?: string | undefined;
21
+ failOnPolicyChange?: boolean | undefined;
22
+ }
23
+ interface GuardGovernanceOptions extends GuardOutputOptions {
24
+ strict?: boolean | undefined;
20
25
  }
21
26
  interface GuardCheckOptions extends GuardOutputOptions {
22
27
  changed?: boolean | undefined;
@@ -49,6 +54,7 @@ export declare function guardDoctorCommand(options?: GuardOutputOptions & {
49
54
  }): void;
50
55
  export declare function guardHistoryCommand(): void;
51
56
  export declare function guardRunsCommand(options?: GuardOutputOptions): void;
57
+ export declare function guardGovernanceCommand(options?: GuardGovernanceOptions): void;
52
58
  export declare function guardImpactCommand(files: string[], options?: GuardOutputOptions): void;
53
59
  export declare function guardHistoryDiffCommand(from: string, to: string, options?: GuardOutputOptions): void;
54
60
  export declare function guardVerifyCommand(options?: GuardVerifyOptions): void;
@@ -11,6 +11,7 @@ import { dim, fail, heading, info, success, warn } from '../ui.js';
11
11
  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
+ import { auditGuardGovernance } from '../../core/audit/governance.js';
14
15
  function renderFindings(findings) {
15
16
  for (const finding of findings) {
16
17
  const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
@@ -221,6 +222,32 @@ export function guardRunsCommand(options = {}) {
221
222
  info(`${run.runId}: ${run.outcome}, gate=${run.gate}, ${run.durationMs} ms`);
222
223
  }
223
224
  }
225
+ export function guardGovernanceCommand(options = {}) {
226
+ const root = getRoot();
227
+ let result;
228
+ try {
229
+ result = auditGuardGovernance(root);
230
+ }
231
+ catch (error) {
232
+ fail(error instanceof Error ? error.message : String(error));
233
+ process.exitCode = 1;
234
+ return;
235
+ }
236
+ if (options.json) {
237
+ console.log(JSON.stringify(result, null, 2));
238
+ }
239
+ else {
240
+ heading('Codapult Guard Governance');
241
+ info(`Approvals: ${result.approvals.approved} approved, ${result.approvals.actors.length} declared actor(s), ${result.approvals.missingActors} missing actor(s).`);
242
+ info(`Declared actor/proposal-author overlap: ${result.approvals.actorAuthorOverlap.length}.`);
243
+ info(`Policy-linked approvals: ${result.reachability.approvedLinkedToRun}/${result.reachability.approvedWithPolicyFingerprint} linked to a retained verification run.`);
244
+ for (const warning of result.warnings)
245
+ warn(warning);
246
+ if (result.warnings.length === 0)
247
+ success('No governance completeness warnings.');
248
+ }
249
+ process.exitCode = options.strict && result.warnings.length > 0 ? 1 : 0;
250
+ }
224
251
  export function guardImpactCommand(files, options = {}) {
225
252
  const root = getRoot();
226
253
  if (files.length === 0) {
@@ -283,6 +310,8 @@ export function guardVerifyCommand(options = {}) {
283
310
  tools: options.tools,
284
311
  strict: options.strict,
285
312
  projectChecks: options.projectChecks,
313
+ policyBase: options.policyBase,
314
+ failOnPolicyChange: options.failOnPolicyChange,
286
315
  });
287
316
  if (options.json) {
288
317
  console.log(JSON.stringify(result, null, 2));
@@ -295,6 +324,12 @@ export function guardVerifyCommand(options = {}) {
295
324
  process.exitCode = 1;
296
325
  return;
297
326
  }
327
+ if (result.policy?.source === 'git-ref') {
328
+ dim(`policy: ${result.policy.ref ?? 'Git ref'} (${result.policy.fingerprint})`);
329
+ if (result.policyChanged) {
330
+ warn('The working-tree policy differs from the base policy; the base policy was used.');
331
+ }
332
+ }
298
333
  for (const [name, check] of Object.entries(result.checks)) {
299
334
  if (check.status === 'passed')
300
335
  success(`${name}: passed`);
package/dist/cli/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import pc from 'picocolors';
4
- import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, guardWaiverCommand, } from './commands/guard.js';
4
+ import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardGovernanceCommand, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, guardWaiverCommand, } from './commands/guard.js';
5
5
  import { config } from '../core/config.js';
6
6
  import { GuardBaselineReasonError, GuardConfigError, GuardStateBusyError, GuardStateStaleError, GuardWaiverError, } from '../core/guard.js';
7
+ import { GuardAgentInstallError } from '../adapters/agents/agent-integration.js';
7
8
  import { guardErrorPayload } from '../core/errors.js';
8
9
  const program = new Command()
9
10
  .name(config.commandName)
@@ -23,6 +24,7 @@ guard.command('install-agent [target]').option('--json').action(guardInstallAgen
23
24
  guard.command('doctor').option('--json').option('--fix-cache').action(guardDoctorCommand);
24
25
  guard.command('history').action(guardHistoryCommand);
25
26
  guard.command('runs').option('--json').action(guardRunsCommand);
27
+ guard.command('governance').option('--json').option('--strict').action(guardGovernanceCommand);
26
28
  guard.command('history-diff <from> <to>').option('--json').action(guardHistoryDiffCommand);
27
29
  guard.command('impact <files...>').option('--json').action(guardImpactCommand);
28
30
  guard
@@ -40,6 +42,8 @@ guard
40
42
  .option('--no-project-checks')
41
43
  .option('--requirement <file>')
42
44
  .option('--changed')
45
+ .option('--policy-base <ref>', 'read policy artifacts from this Git ref')
46
+ .option('--fail-on-policy-change', 'fail when the working-tree policy differs from the base ref')
43
47
  .option('--json')
44
48
  .action(guardVerifyCommand);
45
49
  guard
@@ -110,10 +114,12 @@ catch (error) {
110
114
  error instanceof GuardBaselineReasonError ||
111
115
  error instanceof GuardStateBusyError ||
112
116
  error instanceof GuardStateStaleError ||
113
- error instanceof GuardWaiverError) {
117
+ error instanceof GuardWaiverError ||
118
+ error instanceof GuardAgentInstallError) {
114
119
  const isBusy = error instanceof GuardStateBusyError;
115
120
  const isStale = error instanceof GuardStateStaleError;
116
121
  const isInvalidInput = error instanceof GuardBaselineReasonError;
122
+ const isInstallError = error instanceof GuardAgentInstallError;
117
123
  console.error(JSON.stringify(guardErrorPayload(isBusy
118
124
  ? 'GUARD_STATE_BUSY'
119
125
  : isStale
@@ -128,7 +134,9 @@ catch (error) {
128
134
  ? 'Re-read Guard state and retry the operation.'
129
135
  : isInvalidInput
130
136
  ? 'Provide a written reason for the baseline decision.'
131
- : 'Repair the invalid Guard artifact, then run codapult-guard doctor.',
137
+ : isInstallError
138
+ ? 'Remove the symbolic link or choose a regular project path, then retry.'
139
+ : 'Repair the invalid Guard artifact, then run codapult-guard doctor.',
132
140
  }), null, 2));
133
141
  process.exitCode = 1;
134
142
  }
@@ -0,0 +1,37 @@
1
+ export interface GuardGovernanceAudit {
2
+ version: 1;
3
+ generatedAt: string;
4
+ configured: boolean;
5
+ policy?: {
6
+ revision?: number | undefined;
7
+ fingerprint: string;
8
+ } | undefined;
9
+ proposals: {
10
+ currentId?: string | undefined;
11
+ currentAuthor?: string | undefined;
12
+ decisions: number;
13
+ authors: string[];
14
+ missingAuthors: number;
15
+ };
16
+ approvals: {
17
+ total: number;
18
+ approved: number;
19
+ rejected: number;
20
+ actors: string[];
21
+ missingActors: number;
22
+ actorAuthorOverlap: string[];
23
+ commitAuthors: string[];
24
+ actorCommitAuthorOverlap: string[];
25
+ missingCommits: number;
26
+ sources: Record<string, number>;
27
+ };
28
+ reachability: {
29
+ approvedWithPolicyFingerprint: number;
30
+ approvedLinkedToRun: number;
31
+ approvedWithoutRun: number;
32
+ runsWithPolicy: number;
33
+ runsWithoutPolicy: number;
34
+ };
35
+ warnings: string[];
36
+ }
37
+ export declare function auditGuardGovernance(root: string): GuardGovernanceAudit;
@@ -0,0 +1,166 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { createGuardPolicySnapshot, loadBaseline, loadGuardAgentConfig, loadGuardConfig, loadGuardProposals, loadGuardWaivers, withGuardStateLock, GUARD_DIR, } from '../guard.js';
5
+ import { GUARD_RUN_RETENTION, listGuardRuns } from '../history/runs.js';
6
+ function commitAuthor(root, commit, cache) {
7
+ if (!commit || !/^[0-9a-f]{7,64}$/i.test(commit))
8
+ return undefined;
9
+ if (cache.has(commit))
10
+ return cache.get(commit);
11
+ try {
12
+ const value = execFileSync('git', ['show', '-s', '--format=%ae', commit], {
13
+ cwd: root,
14
+ stdio: 'pipe',
15
+ timeout: 5_000,
16
+ maxBuffer: 64 * 1024,
17
+ })
18
+ .toString()
19
+ .trim();
20
+ const author = value || undefined;
21
+ cache.set(commit, author);
22
+ return author;
23
+ }
24
+ catch {
25
+ cache.set(commit, undefined);
26
+ return undefined;
27
+ }
28
+ }
29
+ function currentProposalAuthor(proposals, decision) {
30
+ if (decision.proposalAuthor)
31
+ return decision.proposalAuthor;
32
+ if (decision.proposalId && decision.proposalId === proposals?.proposalId) {
33
+ return proposals.generatedBy;
34
+ }
35
+ return undefined;
36
+ }
37
+ function auditGuardGovernanceUnlocked(root) {
38
+ const config = loadGuardConfig(root);
39
+ const proposals = loadGuardProposals(root);
40
+ const decisions = proposals?.decisions ?? [];
41
+ const approved = decisions.filter((decision) => decision.decision === 'approved');
42
+ const authors = [
43
+ ...new Set(decisions
44
+ .map((decision) => currentProposalAuthor(proposals, decision))
45
+ .filter((author) => Boolean(author))),
46
+ ].sort();
47
+ const actors = [
48
+ ...new Set(approved
49
+ .map((decision) => decision.actor?.trim())
50
+ .filter((actor) => Boolean(actor))),
51
+ ].sort();
52
+ const commitAuthorCache = new Map();
53
+ const enrichedApprovals = approved.map((decision) => ({
54
+ decision,
55
+ author: currentProposalAuthor(proposals, decision),
56
+ commitAuthor: commitAuthor(root, decision.commit, commitAuthorCache),
57
+ }));
58
+ const commitAuthors = [
59
+ ...new Set(enrichedApprovals
60
+ .map((item) => item.commitAuthor)
61
+ .filter((author) => Boolean(author))),
62
+ ].sort();
63
+ const actorAuthorOverlap = [
64
+ ...new Set(enrichedApprovals
65
+ .filter((item) => item.decision.actor && item.author && item.decision.actor === item.author)
66
+ .map((item) => item.decision.actor)
67
+ .filter((actor) => Boolean(actor))),
68
+ ].sort();
69
+ const actorCommitAuthorOverlap = [
70
+ ...new Set(enrichedApprovals
71
+ .filter((item) => item.decision.actor && item.commitAuthor === item.decision.actor)
72
+ .map((item) => item.decision.actor)
73
+ .filter((actor) => Boolean(actor))),
74
+ ].sort();
75
+ const runs = listGuardRuns(root, GUARD_RUN_RETENTION);
76
+ const runDecisionBindings = new Map();
77
+ for (const run of runs) {
78
+ const fingerprint = run.policy?.fingerprint;
79
+ if (!fingerprint)
80
+ continue;
81
+ for (const decisionId of run.policy?.decisionIds ?? []) {
82
+ const fingerprints = runDecisionBindings.get(decisionId) ?? new Set();
83
+ fingerprints.add(fingerprint);
84
+ runDecisionBindings.set(decisionId, fingerprints);
85
+ }
86
+ }
87
+ const approvedWithPolicyFingerprint = approved.filter((decision) => decision.policyFingerprint !== undefined).length;
88
+ const approvedLinkedToRun = approved.filter((decision) => decision.policyFingerprint !== undefined &&
89
+ runDecisionBindings
90
+ .get(decision.decisionId ??
91
+ `${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
92
+ ?.has(decision.policyFingerprint)).length;
93
+ const warnings = [];
94
+ if (approved.some((decision) => !decision.actor)) {
95
+ warnings.push('Some approved decisions do not declare an approval actor.');
96
+ }
97
+ if (approved.some((decision) => !currentProposalAuthor(proposals, decision))) {
98
+ warnings.push('Some approved decisions do not retain a proposal author.');
99
+ }
100
+ if (actorAuthorOverlap.length > 0) {
101
+ warnings.push('At least one declared approval actor also authored an approved proposal.');
102
+ }
103
+ if (approved.some((decision) => !decision.policyFingerprint)) {
104
+ warnings.push('Some approved decisions are not bound to a resulting policy fingerprint.');
105
+ }
106
+ if (approved.some((decision) => decision.policyFingerprint === undefined ||
107
+ !runDecisionBindings
108
+ .get(decision.decisionId ??
109
+ `${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
110
+ ?.has(decision.policyFingerprint))) {
111
+ warnings.push('Some approved decisions have no retained verification run linked by decision ID.');
112
+ }
113
+ return {
114
+ version: 1,
115
+ generatedAt: new Date().toISOString(),
116
+ configured: config !== undefined,
117
+ ...(config
118
+ ? {
119
+ policy: (() => {
120
+ const snapshot = createGuardPolicySnapshot(config, loadBaseline(root), loadGuardWaivers(root), 'working-tree', undefined, loadGuardAgentConfig(root));
121
+ return {
122
+ ...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
123
+ fingerprint: snapshot.fingerprint,
124
+ };
125
+ })(),
126
+ }
127
+ : {}),
128
+ proposals: {
129
+ ...(proposals?.proposalId ? { currentId: proposals.proposalId } : {}),
130
+ ...(proposals?.generatedBy ? { currentAuthor: proposals.generatedBy } : {}),
131
+ decisions: decisions.length,
132
+ authors,
133
+ missingAuthors: decisions.filter((decision) => !currentProposalAuthor(proposals, decision))
134
+ .length,
135
+ },
136
+ approvals: {
137
+ total: decisions.length,
138
+ approved: approved.length,
139
+ rejected: decisions.filter((decision) => decision.decision === 'rejected').length,
140
+ actors,
141
+ missingActors: approved.filter((decision) => !decision.actor).length,
142
+ actorAuthorOverlap,
143
+ commitAuthors,
144
+ actorCommitAuthorOverlap,
145
+ missingCommits: approved.filter((decision) => !decision.commit).length,
146
+ sources: decisions.reduce((counts, decision) => {
147
+ const source = decision.source ?? 'unknown';
148
+ counts[source] = (counts[source] ?? 0) + 1;
149
+ return counts;
150
+ }, {}),
151
+ },
152
+ reachability: {
153
+ approvedWithPolicyFingerprint,
154
+ approvedLinkedToRun,
155
+ approvedWithoutRun: approvedWithPolicyFingerprint - approvedLinkedToRun,
156
+ runsWithPolicy: runs.filter((run) => run.policy !== undefined).length,
157
+ runsWithoutPolicy: runs.filter((run) => run.policy === undefined).length,
158
+ },
159
+ warnings,
160
+ };
161
+ }
162
+ export function auditGuardGovernance(root) {
163
+ if (!existsSync(resolve(root, GUARD_DIR)))
164
+ return auditGuardGovernanceUnlocked(root);
165
+ return withGuardStateLock(root, () => auditGuardGovernanceUnlocked(root));
166
+ }
@@ -1,4 +1,4 @@
1
- export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | 'GUARD_INVALID_INPUT';
1
+ export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | 'GUARD_INVALID_INPUT';
2
2
  export interface GuardErrorPayload {
3
3
  status: 'error';
4
4
  outcome: 'not-configured' | 'error';
@@ -106,6 +106,7 @@ export interface GuardProposalFile {
106
106
  decisions?: GuardProposalDecision[] | undefined;
107
107
  }
108
108
  export interface GuardProposalDecision {
109
+ decisionId?: string | undefined;
109
110
  id: string;
110
111
  type: 'rule' | 'contract';
111
112
  decision: 'approved' | 'rejected';
@@ -114,8 +115,11 @@ export interface GuardProposalDecision {
114
115
  proposalFingerprint?: string | undefined;
115
116
  revision?: number | undefined;
116
117
  source?: 'cli' | 'mcp' | 'external' | undefined;
118
+ proposalAuthor?: string | undefined;
117
119
  actor?: string | undefined;
118
120
  commit?: string | undefined;
121
+ policyFingerprint?: string | undefined;
122
+ policyRevision?: number | undefined;
119
123
  }
120
124
  export type GuardProposalFreshness = 'current' | 'stale' | 'unknown';
121
125
  export interface GuardAgentConfig {
@@ -233,6 +237,30 @@ export declare function isGuardContractsFile(value: unknown): value is {
233
237
  export declare function isGuardProposalFile(value: unknown): value is GuardProposalFile;
234
238
  export declare function fingerprintProjectModel(model: ProjectModel): string;
235
239
  export declare function fingerprintGuardConfig(guardConfig: GuardConfig): string;
240
+ export interface GuardPolicySnapshot {
241
+ config: GuardConfig;
242
+ agentConfig: GuardAgentConfig;
243
+ baseline: Set<string>;
244
+ waivers: GuardWaiver[];
245
+ revision?: number | undefined;
246
+ fingerprint: string;
247
+ source: 'working-tree' | 'git-ref';
248
+ ref?: string | undefined;
249
+ }
250
+ /**
251
+ * Fingerprints the effective policy used by a verification run.
252
+ *
253
+ * The rules revision alone is not sufficient: baseline and waiver changes also
254
+ * affect the verdict. Keeping them in this fingerprint makes a run auditable
255
+ * without pretending that a numeric revision covers every policy artifact.
256
+ */
257
+ export declare function fingerprintGuardPolicy(guardConfig: GuardConfig, baseline?: Set<string>, waivers?: GuardWaiver[], agentConfig?: GuardAgentConfig): string;
258
+ export declare function createGuardPolicySnapshot(guardConfig: GuardConfig, baseline: Set<string>, waivers: GuardWaiver[], source?: GuardPolicySnapshot['source'], ref?: string, agentConfig?: GuardAgentConfig): GuardPolicySnapshot;
259
+ /**
260
+ * Loads policy artifacts from a trusted Git ref instead of the checked-out
261
+ * working tree. This is intended for CI verification of untrusted branches.
262
+ */
263
+ export declare function loadGuardPolicyAtRevision(root: string, revision: string): GuardPolicySnapshot | undefined;
236
264
  export declare function getGuardProposalFreshness(model: ProjectModel, proposals: GuardProposalFile | undefined): GuardProposalFreshness;
237
265
  export declare function loadGuardConfig(root: string): GuardConfig | undefined;
238
266
  export declare function loadGuardProposals(root: string): GuardProposalFile | undefined;
@@ -271,7 +271,9 @@ function atomicWriteFile(path, content) {
271
271
  }
272
272
  function writeGuardArtifactUnlocked(root, relativePath, value) {
273
273
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
274
- atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
274
+ const path = resolve(root, relativePath);
275
+ mkdirSync(dirname(path), { recursive: true });
276
+ atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`);
275
277
  }
276
278
  function writeGuardArtifact(root, relativePath, value) {
277
279
  withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
@@ -308,16 +310,136 @@ export function isGuardProposalFile(value) {
308
310
  export function fingerprintProjectModel(model) {
309
311
  return createHash('sha256').update(JSON.stringify(model)).digest('hex');
310
312
  }
313
+ function stableSerialize(value) {
314
+ if (Array.isArray(value)) {
315
+ return `[${value.map((item) => stableSerialize(item)).join(',')}]`;
316
+ }
317
+ if (value !== null && typeof value === 'object') {
318
+ const record = value;
319
+ return `{${Object.keys(record)
320
+ .sort()
321
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`)
322
+ .join(',')}}`;
323
+ }
324
+ return JSON.stringify(value) ?? 'null';
325
+ }
311
326
  export function fingerprintGuardConfig(guardConfig) {
312
327
  return createHash('sha256')
313
- .update(JSON.stringify({
314
- rules: guardConfig.rules,
315
- contracts: guardConfig.contracts ?? [],
316
- budgets: guardConfig.budgets ?? [],
328
+ .update(stableSerialize({
329
+ rules: [...guardConfig.rules].sort((left, right) => left.id.localeCompare(right.id)),
330
+ contracts: [...(guardConfig.contracts ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
331
+ budgets: [...(guardConfig.budgets ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
317
332
  approval: guardConfig.approval ?? defaultGuardConfig.approval,
318
333
  }))
319
334
  .digest('hex');
320
335
  }
336
+ /**
337
+ * Fingerprints the effective policy used by a verification run.
338
+ *
339
+ * The rules revision alone is not sufficient: baseline and waiver changes also
340
+ * affect the verdict. Keeping them in this fingerprint makes a run auditable
341
+ * without pretending that a numeric revision covers every policy artifact.
342
+ */
343
+ export function fingerprintGuardPolicy(guardConfig, baseline = new Set(), waivers = [], agentConfig = defaultGuardAgentConfig) {
344
+ return createHash('sha256')
345
+ .update(stableSerialize({
346
+ config: fingerprintGuardConfig(guardConfig),
347
+ agentConfig,
348
+ baseline: [...baseline].sort(),
349
+ waivers: [...waivers].sort((left, right) => left.id.localeCompare(right.id)),
350
+ }))
351
+ .digest('hex');
352
+ }
353
+ export function createGuardPolicySnapshot(guardConfig, baseline, waivers, source = 'working-tree', ref, agentConfig = defaultGuardAgentConfig) {
354
+ return {
355
+ config: guardConfig,
356
+ agentConfig,
357
+ baseline,
358
+ waivers,
359
+ ...(typeof guardConfig.revision === 'number' ? { revision: guardConfig.revision } : {}),
360
+ fingerprint: fingerprintGuardPolicy(guardConfig, baseline, waivers, agentConfig),
361
+ source,
362
+ ...(ref ? { ref } : {}),
363
+ };
364
+ }
365
+ function readGitFileAtRevision(root, revision, relativePath) {
366
+ try {
367
+ return execFileSync('git', ['show', `${revision}:${relativePath}`], {
368
+ cwd: root,
369
+ stdio: 'pipe',
370
+ maxBuffer: 4_000_000,
371
+ }).toString();
372
+ }
373
+ catch {
374
+ return undefined;
375
+ }
376
+ }
377
+ function parseGitJsonAtRevision(root, revision, relativePath) {
378
+ const text = readGitFileAtRevision(root, revision, relativePath);
379
+ if (text === undefined)
380
+ return undefined;
381
+ try {
382
+ return JSON.parse(text);
383
+ }
384
+ catch {
385
+ throw new GuardConfigError(`${relativePath} at ${revision}`);
386
+ }
387
+ }
388
+ /**
389
+ * Loads policy artifacts from a trusted Git ref instead of the checked-out
390
+ * working tree. This is intended for CI verification of untrusted branches.
391
+ */
392
+ export function loadGuardPolicyAtRevision(root, revision) {
393
+ if (!/^[\w./@-]+$/.test(revision) || revision.startsWith('-')) {
394
+ throw new GuardConfigError(`unsafe policy ref: ${revision}`);
395
+ }
396
+ const rulesValue = parseGitJsonAtRevision(root, revision, GUARD_RULES_FILE);
397
+ if (rulesValue === undefined || !isGuardConfig(rulesValue)) {
398
+ if (rulesValue === undefined)
399
+ return undefined;
400
+ throw new GuardConfigError(`${GUARD_RULES_FILE} at ${revision}`);
401
+ }
402
+ const contractsValue = parseGitJsonAtRevision(root, revision, GUARD_CONTRACTS_FILE);
403
+ if (contractsValue !== undefined && !isGuardContractsFile(contractsValue)) {
404
+ throw new GuardConfigError(`${GUARD_CONTRACTS_FILE} at ${revision}`);
405
+ }
406
+ const guardConfig = {
407
+ ...rulesValue,
408
+ contracts: contractsValue ? contractsValue.contracts : (rulesValue.contracts ?? []),
409
+ budgets: rulesValue.budgets ?? [],
410
+ approval: rulesValue.approval ?? defaultGuardConfig.approval,
411
+ };
412
+ const agentValue = parseGitJsonAtRevision(root, revision, GUARD_AGENT_FILE);
413
+ const agentConfig = agentValue === undefined
414
+ ? defaultGuardAgentConfig
415
+ : // eslint-disable-next-line @typescript-eslint/no-use-before-define
416
+ isGuardAgentConfig(agentValue)
417
+ ? agentValue
418
+ : (() => {
419
+ throw new GuardConfigError(`${GUARD_AGENT_FILE} at ${revision}`);
420
+ })();
421
+ const baselineValue = parseGitJsonAtRevision(root, revision, GUARD_BASELINE_FILE);
422
+ const baselineEntries = [];
423
+ if (baselineValue !== undefined) {
424
+ if (!Array.isArray(baselineValue)) {
425
+ throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
426
+ }
427
+ for (const item of baselineValue) {
428
+ if (typeof item !== 'string') {
429
+ throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
430
+ }
431
+ baselineEntries.push(item);
432
+ }
433
+ }
434
+ const waiversValue = parseGitJsonAtRevision(root, revision, GUARD_WAIVERS_FILE);
435
+ const parsedWaivers = guardWaiversFileSchema.safeParse(waiversValue ?? { version: 1, waivers: [], decisions: [] });
436
+ if (!parsedWaivers.success)
437
+ throw new GuardConfigError(`${GUARD_WAIVERS_FILE} at ${revision}`);
438
+ // The validator is declared with the waiver file model below the policy readers.
439
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
440
+ validateGuardWaiversFile(parsedWaivers.data);
441
+ return createGuardPolicySnapshot(guardConfig, new Set(baselineEntries), parsedWaivers.data.waivers, 'git-ref', revision, agentConfig);
442
+ }
321
443
  export function getGuardProposalFreshness(model, proposals) {
322
444
  if (!proposals?.projectFingerprint)
323
445
  return 'unknown';
@@ -607,15 +729,24 @@ function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
607
729
  return undefined;
608
730
  }
609
731
  })();
732
+ const currentRevision = readGuardConfigFilesUnlocked(root)?.revision ?? 0;
733
+ const policyFingerprint = options.resultingPolicy
734
+ ? fingerprintGuardPolicy(options.resultingPolicy, loadBaseline(root), readGuardWaiversUnlocked(root).waivers, loadGuardAgentConfig(root))
735
+ : undefined;
610
736
  const nextDecisions = decisions.map((decision) => ({
611
737
  ...decision,
738
+ decisionId: randomUUID(),
612
739
  decidedAt,
613
740
  source: options.source,
614
741
  ...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
615
742
  ...(commit ? { commit } : {}),
743
+ ...(proposals.generatedBy ? { proposalAuthor: proposals.generatedBy } : {}),
616
744
  ...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
617
745
  ...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
618
746
  ...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
747
+ ...(decision.decision === 'approved' && policyFingerprint
748
+ ? { policyFingerprint, policyRevision: currentRevision + 1 }
749
+ : {}),
619
750
  }));
620
751
  return {
621
752
  ...proposals,
@@ -635,7 +766,10 @@ export function recordGuardProposalDecision(root, decisions, options = { source:
635
766
  }
636
767
  export function applyGuardProposalDecision(root, guardConfig, decisions, options) {
637
768
  withGuardStateLock(root, () => {
638
- const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
769
+ const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, {
770
+ ...options,
771
+ resultingPolicy: guardConfig,
772
+ });
639
773
  writeGuardArtifactUnlocked(root, GUARD_POLICY_TRANSACTION_FILE, {
640
774
  version: 1,
641
775
  createdAt: new Date().toISOString(),
@@ -1107,6 +1241,23 @@ export function validateGuardBudgets(root, budgets = []) {
1107
1241
  }
1108
1242
  export function validateGuardPolicy(root, guardConfig) {
1109
1243
  const issues = [];
1244
+ const validateUniqueIds = (items, kind) => {
1245
+ const seen = new Set();
1246
+ for (const item of items) {
1247
+ if (seen.has(item.id)) {
1248
+ issues.push({
1249
+ contractId: `${kind}:${item.id}`,
1250
+ field: 'definition',
1251
+ value: item.id,
1252
+ message: `${kind} IDs must be unique: ${item.id}`,
1253
+ });
1254
+ }
1255
+ seen.add(item.id);
1256
+ }
1257
+ };
1258
+ validateUniqueIds(guardConfig.rules, 'rule');
1259
+ validateUniqueIds(guardConfig.contracts ?? [], 'contract');
1260
+ validateUniqueIds(guardConfig.budgets ?? [], 'budget');
1110
1261
  for (const rule of guardConfig.rules) {
1111
1262
  for (const file of rule.files ?? []) {
1112
1263
  if (!isProjectPath(root, file)) {
@@ -5,6 +5,14 @@ export interface GuardRunStage {
5
5
  status: 'ok' | 'fail' | 'skipped';
6
6
  detail?: string | undefined;
7
7
  }
8
+ export interface GuardRunPolicy {
9
+ revision?: number | undefined;
10
+ fingerprint: string;
11
+ source: 'working-tree' | 'git-ref';
12
+ ref?: string | undefined;
13
+ changedFromBase?: boolean | undefined;
14
+ decisionIds?: string[] | undefined;
15
+ }
8
16
  export interface GuardRunManifest {
9
17
  version: 1;
10
18
  runId: string;
@@ -16,6 +24,7 @@ export interface GuardRunManifest {
16
24
  gate: string;
17
25
  stages: Record<string, GuardRunStage>;
18
26
  commit?: string | undefined;
27
+ policy?: GuardRunPolicy | undefined;
19
28
  }
20
29
  export interface GuardRunContext {
21
30
  runId: string;
@@ -40,4 +49,4 @@ export declare function writeGuardRun(root: string, manifest: GuardRunManifest):
40
49
  /** Reads locally persisted run manifests without reaching a remote service. */
41
50
  export declare function listGuardRuns(root: string, limit?: number): GuardRunManifest[];
42
51
  export declare function summarizeGuardRuns(root: string, limit?: number): GuardRunSummary;
43
- export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string): GuardRunManifest;
52
+ export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string, policy?: GuardRunPolicy): GuardRunManifest;
@@ -33,7 +33,22 @@ function isGuardRunManifest(value) {
33
33
  manifest.outcome === 'not-configured') &&
34
34
  typeof manifest.gate === 'string' &&
35
35
  manifest.stages !== null &&
36
- typeof manifest.stages === 'object');
36
+ typeof manifest.stages === 'object' &&
37
+ (manifest.policy === undefined ||
38
+ (manifest.policy !== null &&
39
+ typeof manifest.policy === 'object' &&
40
+ typeof manifest.policy.fingerprint === 'string' &&
41
+ manifest.policy.fingerprint.length > 0 &&
42
+ (manifest.policy.source === 'working-tree' || manifest.policy.source === 'git-ref') &&
43
+ (manifest.policy.revision === undefined ||
44
+ (Number.isInteger(manifest.policy.revision) && manifest.policy.revision >= 0)) &&
45
+ (manifest.policy.ref === undefined || typeof manifest.policy.ref === 'string') &&
46
+ (manifest.policy.changedFromBase === undefined ||
47
+ typeof manifest.policy.changedFromBase === 'boolean') &&
48
+ (manifest.policy.decisionIds === undefined ||
49
+ (Array.isArray(manifest.policy.decisionIds) &&
50
+ new Set(manifest.policy.decisionIds).size === manifest.policy.decisionIds.length &&
51
+ manifest.policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0))))));
37
52
  }
38
53
  export function startGuardRun() {
39
54
  return {
@@ -122,7 +137,7 @@ export function summarizeGuardRuns(root, limit = 100) {
122
137
  ...(total > 0 ? { last: runs[0] } : {}),
123
138
  };
124
139
  }
125
- export function finishGuardRun(root, context, outcome, gate, command = 'verify') {
140
+ export function finishGuardRun(root, context, outcome, gate, command = 'verify', policy) {
126
141
  let commit;
127
142
  try {
128
143
  commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
@@ -143,6 +158,7 @@ export function finishGuardRun(root, context, outcome, gate, command = 'verify')
143
158
  gate,
144
159
  stages: context.stages,
145
160
  ...(commit ? { commit } : {}),
161
+ ...(policy ? { policy } : {}),
146
162
  };
147
163
  try {
148
164
  writeGuardRun(root, manifest);
@@ -266,6 +266,7 @@ export declare const guardContractsFileSchema: z.ZodObject<{
266
266
  }, z.core.$strip>>;
267
267
  }, z.core.$strip>;
268
268
  export declare const guardProposalDecisionSchema: z.ZodObject<{
269
+ decisionId: z.ZodOptional<z.ZodString>;
269
270
  id: z.ZodString;
270
271
  type: z.ZodEnum<{
271
272
  contract: "contract";
@@ -284,8 +285,11 @@ export declare const guardProposalDecisionSchema: z.ZodObject<{
284
285
  external: "external";
285
286
  mcp: "mcp";
286
287
  }>>;
288
+ proposalAuthor: z.ZodOptional<z.ZodString>;
287
289
  actor: z.ZodOptional<z.ZodString>;
288
290
  commit: z.ZodOptional<z.ZodString>;
291
+ policyFingerprint: z.ZodOptional<z.ZodString>;
292
+ policyRevision: z.ZodOptional<z.ZodNumber>;
289
293
  }, z.core.$strip>;
290
294
  export declare const guardProposalSchema: z.ZodObject<{
291
295
  version: z.ZodLiteral<1>;
@@ -357,6 +361,7 @@ export declare const guardProposalSchema: z.ZodObject<{
357
361
  }, z.core.$strip>>;
358
362
  questions: z.ZodArray<z.ZodString>;
359
363
  decisions: z.ZodOptional<z.ZodArray<z.ZodObject<{
364
+ decisionId: z.ZodOptional<z.ZodString>;
360
365
  id: z.ZodString;
361
366
  type: z.ZodEnum<{
362
367
  contract: "contract";
@@ -375,8 +380,11 @@ export declare const guardProposalSchema: z.ZodObject<{
375
380
  external: "external";
376
381
  mcp: "mcp";
377
382
  }>>;
383
+ proposalAuthor: z.ZodOptional<z.ZodString>;
378
384
  actor: z.ZodOptional<z.ZodString>;
379
385
  commit: z.ZodOptional<z.ZodString>;
386
+ policyFingerprint: z.ZodOptional<z.ZodString>;
387
+ policyRevision: z.ZodOptional<z.ZodNumber>;
380
388
  }, z.core.$strip>>>;
381
389
  }, z.core.$strip>;
382
390
  export declare const guardAgentConfigSchema: z.ZodObject<{
@@ -95,6 +95,7 @@ export const guardContractsFileSchema = z.object({
95
95
  contracts: z.array(guardContractSchema),
96
96
  });
97
97
  export const guardProposalDecisionSchema = z.object({
98
+ decisionId: z.string().trim().min(1).optional(),
98
99
  id: z.string().trim().min(1),
99
100
  type: z.enum(['rule', 'contract']),
100
101
  decision: z.enum(['approved', 'rejected']),
@@ -103,8 +104,11 @@ export const guardProposalDecisionSchema = z.object({
103
104
  proposalFingerprint: z.string().optional(),
104
105
  revision: z.number().optional(),
105
106
  source: z.enum(['cli', 'mcp', 'external']).optional(),
107
+ proposalAuthor: z.string().trim().min(1).optional(),
106
108
  actor: z.string().optional(),
107
109
  commit: z.string().optional(),
110
+ policyFingerprint: z.string().trim().min(1).optional(),
111
+ policyRevision: z.number().int().nonnegative().optional(),
108
112
  });
109
113
  export const guardProposalSchema = z.object({
110
114
  version: z.literal(1),
@@ -3,7 +3,7 @@ import type { ProjectCheck } from '../model/types.js';
3
3
  import { type GuardAdapter, type ProjectCheckResults, type ProjectRuntimeDiagnostics } from '../../adapters/project-checks.js';
4
4
  import { type GuardToolMode, type GuardContractIssue, type GuardReport } from '../guard.js';
5
5
  import { type GuardImpactAnalysis } from '../analysis/impact.js';
6
- import { type GuardRunManifest } from '../history/runs.js';
6
+ import { type GuardRunManifest, type GuardRunPolicy } from '../history/runs.js';
7
7
  export type GuardVerificationCheck = ProjectCheck;
8
8
  export interface GuardVerificationOptions {
9
9
  checks?: GuardVerificationCheck[] | undefined;
@@ -13,11 +13,13 @@ export interface GuardVerificationOptions {
13
13
  tools?: GuardToolMode | undefined;
14
14
  strict?: boolean | undefined;
15
15
  projectChecks?: boolean | undefined;
16
+ policyBase?: string | undefined;
17
+ failOnPolicyChange?: boolean | undefined;
16
18
  }
17
19
  export interface GuardVerificationResult {
18
20
  status: 'ok' | 'fail' | 'not-configured';
19
21
  outcome: 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
20
- errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | undefined;
22
+ errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | undefined;
21
23
  recoverable?: boolean | undefined;
22
24
  tools?: GuardToolMode | undefined;
23
25
  checks: ProjectCheckResults;
@@ -34,5 +36,7 @@ export interface GuardVerificationResult {
34
36
  message: string;
35
37
  };
36
38
  run: GuardRunManifest;
39
+ policy?: GuardRunPolicy | undefined;
40
+ policyChanged?: boolean | undefined;
37
41
  }
38
42
  export declare function runGuardVerification(root: string, options?: GuardVerificationOptions): GuardVerificationResult;
@@ -1,9 +1,19 @@
1
1
  import {} from '../../adapters/command.js';
2
2
  import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
3
- import { loadBaseline, loadGuardAgentConfig, loadGuardConfig, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
3
+ import { loadBaseline, loadGuardPolicyAtRevision, createGuardPolicySnapshot, loadGuardWaivers, loadGuardAgentConfig, loadGuardConfig, loadGuardProposals, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
4
4
  import { analyzeProjectImpact } from '../analysis/impact.js';
5
5
  import { discoverProject } from '../discovery/discovery.js';
6
6
  import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
7
+ function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
8
+ return {
9
+ ...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
10
+ fingerprint: snapshot.fingerprint,
11
+ source: snapshot.source,
12
+ ...(base ? { ref: base } : {}),
13
+ ...(base ? { changedFromBase } : {}),
14
+ ...(decisionIds.length > 0 ? { decisionIds } : {}),
15
+ };
16
+ }
7
17
  export function runGuardVerification(root, options = {}) {
8
18
  const runContext = startGuardRun();
9
19
  const requirement = {
@@ -16,6 +26,23 @@ export function runGuardVerification(root, options = {}) {
16
26
  const runtimeStartedAt = Date.now();
17
27
  const runtime = inspectProjectRuntime(root);
18
28
  recordGuardRunStage(runContext, 'runtime', 'ok', runtimeStartedAt);
29
+ if (options.failOnPolicyChange && !options.policyBase) {
30
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
31
+ return {
32
+ status: 'fail',
33
+ outcome: 'fail',
34
+ errorCode: 'GUARD_POLICY_BASE_INVALID',
35
+ recoverable: true,
36
+ checks: {},
37
+ workspaceChecks: {},
38
+ adapters: {},
39
+ runtime,
40
+ configError: '`failOnPolicyChange` requires `policyBase`.',
41
+ contractIssues: [],
42
+ requirement,
43
+ run,
44
+ };
45
+ }
19
46
  let config;
20
47
  try {
21
48
  config = loadGuardConfig(root);
@@ -53,7 +80,82 @@ export function runGuardVerification(root, options = {}) {
53
80
  run,
54
81
  };
55
82
  }
56
- const agentConfig = loadGuardAgentConfig(root);
83
+ let policySnapshot;
84
+ let workingTreeSnapshot;
85
+ let policyChanged;
86
+ let policyDecisionIds = [];
87
+ try {
88
+ workingTreeSnapshot = createGuardPolicySnapshot(config, loadBaseline(root), loadGuardWaivers(root), 'working-tree', undefined, loadGuardAgentConfig(root));
89
+ const baseSnapshot = options.policyBase
90
+ ? loadGuardPolicyAtRevision(root, options.policyBase)
91
+ : undefined;
92
+ if (options.policyBase && !baseSnapshot) {
93
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
94
+ return {
95
+ status: 'fail',
96
+ outcome: 'fail',
97
+ errorCode: 'GUARD_POLICY_BASE_INVALID',
98
+ recoverable: true,
99
+ checks: {},
100
+ workspaceChecks: {},
101
+ adapters: {},
102
+ runtime,
103
+ configError: `Guard policy was not found at Git ref: ${options.policyBase}`,
104
+ contractIssues: [],
105
+ requirement,
106
+ run,
107
+ };
108
+ }
109
+ policySnapshot = baseSnapshot ?? workingTreeSnapshot;
110
+ policyChanged =
111
+ baseSnapshot !== undefined && baseSnapshot.fingerprint !== workingTreeSnapshot.fingerprint;
112
+ if (policySnapshot.source === 'working-tree') {
113
+ policyDecisionIds = (loadGuardProposals(root)?.decisions ?? [])
114
+ .filter((decision) => decision.policyFingerprint === policySnapshot.fingerprint)
115
+ .map((decision) => decision.decisionId ??
116
+ `${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
117
+ .sort();
118
+ }
119
+ if (options.failOnPolicyChange && policyChanged) {
120
+ const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
121
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-change', 'verify', policy);
122
+ return {
123
+ status: 'fail',
124
+ outcome: 'fail',
125
+ errorCode: 'GUARD_POLICY_CHANGED',
126
+ recoverable: true,
127
+ checks: {},
128
+ workspaceChecks: {},
129
+ adapters: {},
130
+ runtime,
131
+ configError: 'Guard policy changed relative to the supplied Git base ref.',
132
+ contractIssues: [],
133
+ requirement,
134
+ run,
135
+ policy,
136
+ policyChanged,
137
+ };
138
+ }
139
+ }
140
+ catch (error) {
141
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
142
+ return {
143
+ status: 'fail',
144
+ outcome: 'fail',
145
+ errorCode: 'GUARD_POLICY_BASE_INVALID',
146
+ recoverable: true,
147
+ checks: {},
148
+ workspaceChecks: {},
149
+ adapters: {},
150
+ runtime,
151
+ configError: error instanceof Error ? error.message : String(error),
152
+ contractIssues: [],
153
+ requirement,
154
+ run,
155
+ };
156
+ }
157
+ const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
158
+ const agentConfig = policySnapshot.agentConfig;
57
159
  const projectChecks = options.projectChecks ?? agentConfig.completionGate.projectChecks;
58
160
  const checks = projectChecks ? (options.checks ?? agentConfig.completionGate.checks) : [];
59
161
  const checksStartedAt = Date.now();
@@ -63,7 +165,7 @@ export function runGuardVerification(root, options = {}) {
63
165
  const workspaceModel = discoverProject(root);
64
166
  const impact = analyzeProjectImpact(workspaceModel, options.changedOnly === false
65
167
  ? workspaceModel.modules.map((module) => module.path)
66
- : workspaceModel.git.changedFiles, config.contracts ?? []);
168
+ : workspaceModel.git.changedFiles, policySnapshot.config.contracts ?? []);
67
169
  const workspaceChecks = runWorkspaceProjectChecks(root, workspaceModel.project.workspacePackages ?? [], checks, {
68
170
  timeout: options.timeout,
69
171
  rootResults: results,
@@ -80,12 +182,13 @@ export function runGuardVerification(root, options = {}) {
80
182
  });
81
183
  recordGuardRunStage(runContext, 'adapters', toolMode === 'off' ? 'skipped' : 'ok', adaptersStartedAt);
82
184
  const architectureStartedAt = Date.now();
83
- const architecture = scanGuard(root, config, {
185
+ const architecture = scanGuard(root, policySnapshot.config, {
84
186
  changedOnly: options.changedOnly,
85
- baseline: loadBaseline(root),
187
+ baseline: policySnapshot.baseline,
86
188
  includeArchitectureInsights: true,
189
+ waivers: policySnapshot.waivers,
87
190
  });
88
- const contractIssues = validateGuardPolicy(root, config);
191
+ const contractIssues = validateGuardPolicy(root, policySnapshot.config);
89
192
  recordGuardRunStage(runContext, 'architecture-policy', 'ok', architectureStartedAt);
90
193
  const commandFailed = Object.values(results).some((result) => result.status === 'failed');
91
194
  const workspaceCommandFailed = Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'failed'));
@@ -133,7 +236,7 @@ export function runGuardVerification(root, options = {}) {
133
236
  : !runtime.compatible && projectChecks
134
237
  ? 'runtime'
135
238
  : 'none';
136
- const run = finishGuardRun(root, runContext, outcome, gate);
239
+ const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy);
137
240
  return {
138
241
  status: failed ? 'fail' : 'ok',
139
242
  outcome,
@@ -147,5 +250,7 @@ export function runGuardVerification(root, options = {}) {
147
250
  contractIssues,
148
251
  requirement,
149
252
  run,
253
+ policy,
254
+ ...(policy.changedFromBase !== undefined ? { policyChanged: policy.changedFromBase } : {}),
150
255
  };
151
256
  }
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ export * from './core/output/sarif.js';
11
11
  export * from './core/analysis/doctor.js';
12
12
  export * from './core/analysis/packs.js';
13
13
  export * from './core/analysis/impact.js';
14
+ export * from './core/audit/governance.js';
14
15
  export * from './adapters/project-checks.js';
package/dist/index.js CHANGED
@@ -10,4 +10,5 @@ export * from './core/output/sarif.js';
10
10
  export * from './core/analysis/doctor.js';
11
11
  export * from './core/analysis/packs.js';
12
12
  export * from './core/analysis/impact.js';
13
+ export * from './core/audit/governance.js';
13
14
  export * from './adapters/project-checks.js';
@@ -4,6 +4,7 @@ import { guardErrorPayload } from '../../core/errors.js';
4
4
  import { analyzeProjectImpact } from '../../core/analysis/impact.js';
5
5
  import { z } from 'zod';
6
6
  import { runGuardVerification } from '../../core/verification/verify.js';
7
+ import { auditGuardGovernance } from '../../core/audit/governance.js';
7
8
  const rootSchema = z.string().trim().min(1).optional().describe('Project path or workspace root.');
8
9
  function getGuardRoot(root) {
9
10
  return root ? findGuardRoot(root) : findGuardRoot();
@@ -450,9 +451,19 @@ export function registerGuardTools(server) {
450
451
  .boolean()
451
452
  .optional()
452
453
  .describe('Override completionGate.projectChecks for this invocation.'),
454
+ policy_base: z
455
+ .string()
456
+ .trim()
457
+ .min(1)
458
+ .optional()
459
+ .describe('Git ref whose Guard policy should be used for this verification.'),
460
+ fail_on_policy_change: z
461
+ .boolean()
462
+ .optional()
463
+ .describe('Fail when the checked-out policy differs from policy_base.'),
453
464
  iteration: z.number().int().positive().default(1),
454
465
  },
455
- }, ({ root: requestedRoot, checks, changed_only, requirement, tools, strict, project_checks, iteration, }) => {
466
+ }, ({ root: requestedRoot, checks, changed_only, requirement, tools, strict, project_checks, policy_base, fail_on_policy_change, iteration, }) => {
456
467
  const root = getGuardRoot(requestedRoot);
457
468
  const agent = loadAgentSafely(root);
458
469
  if ('error' in agent)
@@ -464,6 +475,8 @@ export function registerGuardTools(server) {
464
475
  tools,
465
476
  strict,
466
477
  projectChecks: project_checks,
478
+ policyBase: policy_base,
479
+ failOnPolicyChange: fail_on_policy_change,
467
480
  });
468
481
  const completionGate = agent.config.completionGate;
469
482
  return jsonToolResult({
@@ -475,6 +488,18 @@ export function registerGuardTools(server) {
475
488
  },
476
489
  }, result.status === 'fail');
477
490
  });
491
+ server.registerTool('codapult_guard_governance', {
492
+ title: 'Guard Governance Audit',
493
+ description: 'Audit declared proposal and approval provenance, policy bindings, and retained verification reachability. This reports metadata; it does not prove identity or signatures.',
494
+ inputSchema: { root: rootSchema },
495
+ }, ({ root: requestedRoot }) => {
496
+ try {
497
+ return jsonToolResult(auditGuardGovernance(getGuardRoot(requestedRoot)));
498
+ }
499
+ catch (error) {
500
+ return jsonToolResult({ status: 'fail', message: error instanceof Error ? error.message : String(error) }, true);
501
+ }
502
+ });
478
503
  server.registerTool('codapult_guard_audit', {
479
504
  title: 'Guard Audit',
480
505
  description: 'Run a full Guard audit, ignoring baseline suppression and validating contracts.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codapult/guard",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.codapult/guard",
5
5
  "description": "Local-first architecture guardrails for JavaScript and TypeScript projects, with first-class support for Next.js SaaS and AI-assisted development",
6
6
  "license": "MIT",