@codapult/guard 0.7.0 → 0.8.1

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
@@ -57,43 +57,6 @@ The core is universal. The strongest first-class scenarios are Next.js SaaS and
57
57
  development, including server/client boundaries, routes, persistence, authentication, billing,
58
58
  background jobs, environment configuration, and AI integrations.
59
59
 
60
- ## Part of the Codapult ecosystem
61
-
62
- Guard is an independent open-source project from [Codapult](https://codapult.dev). It does not
63
- require Codapult and can be installed in any JavaScript or TypeScript repository.
64
-
65
- The relationship is complementary:
66
-
67
- | Project | Role |
68
- | --------------------- | ---------------------------------------------------------------------------------------- |
69
- | **`@codapult/guard`** | Universal architecture guardrails, project memory, contracts, MCP, and AI-agent context. |
70
- | **`@codapult/cli`** | Codapult SaaS project CLI that includes Guard through a thin adapter. |
71
- | **Codapult** | Full-source Next.js SaaS foundation with conventions Guard can discover and protect. |
72
-
73
- Use standalone Guard for any compatible project. Use the Codapult CLI when working on a Codapult
74
- SaaS project and you want project management, database, plugins, deployment, MCP, and Guard in one
75
- CLI.
76
-
77
- ```text
78
- Existing project
79
- │
80
- ▼
81
- deterministic discovery
82
- AST · files · imports · Git
83
- │
84
- ▼
85
- project architecture
86
- capabilities · graph · impact paths
87
- │
88
- ▼
89
- approved project policy
90
- rules · contracts · conventions · baseline
91
- │
92
- ▼
93
- change verification
94
- check · review packet · verify · CI
95
- ```
96
-
97
60
  ## Why Guard exists
98
61
 
99
62
  AI agents can produce syntactically valid code that still violates the architecture of a real
@@ -172,6 +135,23 @@ Policy paths are validated as project-relative paths. Guard rejects traversal, a
172
135
  symlinks escaping the project root; import-boundary checks include imports, re-exports, and literal
173
136
  dynamic imports.
174
137
 
138
+ ### Policy scope and state changes
139
+
140
+ Guard stores policy at the project level, while each policy item can target a narrower boundary:
141
+
142
+ | Policy item | Scope controls |
143
+ | ----------- | ---------------------------------------------------------------------- |
144
+ | Rule | `files`, import patterns, and rule kind |
145
+ | Contract | `scope`, `entrypoints`, import/package constraints, and required calls |
146
+ | Budget | `scope`, metric, limit, severity, and written reason |
147
+ | Baseline | Exact finding fingerprints already accepted as legacy debt |
148
+ | Waiver | One active finding fingerprint, with owner, reason, and expiry |
149
+
150
+ Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
151
+ change policy are explicit: `init`, `rules/contracts approve|reject`, `baseline accept|remove`, and
152
+ `waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
153
+ project's configured commands and records a local run manifest; neither changes approved policy.
154
+
175
155
  ## What Guard discovers
176
156
 
177
157
  The model is framework-aware without being framework-dependent:
@@ -207,7 +187,7 @@ init once → edit → check --changed → review → verify → commit / merge
207
187
  ```
208
188
 
209
189
  1. `init` builds project memory and establishes the initial baseline.
210
- 2. `check --changed` is the fast deterministic architecture gate.
190
+ 2. `check` scans the current project; `check --changed` is the fast change-scoped architecture gate.
211
191
  3. `review` prepares a bounded and redacted diff packet for an AI host; it does not call an LLM.
212
192
  4. `verify` runs Guard policy, configured project checks, adapters, runtime diagnostics, and
213
193
  contract validation.
@@ -238,6 +218,11 @@ Guard state is stored in `.codapult/guard/`:
238
218
  Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
239
219
  disposable according to the project’s policy, and never commit secrets.
240
220
 
221
+ Baseline fingerprints do not include line numbers, so formatting and line movement do not recreate
222
+ the same finding. They do include the rule and affected file/import identity, so a rename or module
223
+ move can produce a new fingerprint and should be reviewed explicitly before accepting it into the
224
+ baseline.
225
+
241
226
  ### Temporary waivers
242
227
 
243
228
  Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
@@ -315,9 +300,10 @@ Register that command in the MCP client from the project root. For example, Curs
315
300
  {
316
301
  "mcpServers": {
317
302
  "codapult-guard": {
303
+ "type": "stdio",
318
304
  "command": "pnpm",
319
305
  "args": ["exec", "codapult-guard", "mcp-server"],
320
- "cwd": "."
306
+ "cwd": "${workspaceFolder}"
321
307
  }
322
308
  }
323
309
  }
@@ -369,6 +355,37 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
369
355
  These variables provide declared provenance only; external branch protection or signed identity
370
356
  remains responsible for proving who approved the change.
371
357
 
358
+ ### Verifying against protected policy
359
+
360
+ In CI, verify pull requests against the policy from the protected base commit rather than the
361
+ copy carried by the branch:
362
+
363
+ ```bash
364
+ codapult-guard verify --policy-base "$BASE_SHA" --json
365
+ ```
366
+
367
+ Guard then evaluates the changed source with the base commit's rules, contracts, baseline, waivers,
368
+ and completion-gate configuration. The result reports `policy.source`, `policy.ref`,
369
+ `policy.revision`, `policy.fingerprint`, and whether the checked-out policy differs from that
370
+ base. A branch cannot relax its policy or disable the gate and use that relaxed copy for the same
371
+ verification run.
372
+
373
+ The supplied [`docs/guard-ci.yml`](docs/guard-ci.yml) fetches the full Git history and passes the
374
+ pull request base SHA automatically. Protect `.codapult/guard/` with CODEOWNERS or the equivalent
375
+ review control when policy changes require a separate approval. For example, a repository can add
376
+ `.codapult/guard/* @architecture-owners` to `CODEOWNERS`. Guard reports the policy change; the
377
+ hosting platform enforces reviewer authorization.
378
+
379
+ To make policy changes fail this invocation explicitly, add:
380
+
381
+ ```bash
382
+ codapult-guard verify --policy-base "$BASE_SHA" --fail-on-policy-change
383
+ ```
384
+
385
+ This is useful for a dedicated policy-review gate. It is intentionally separate from using the
386
+ base policy for source verification, so teams can choose how their protected review workflow
387
+ handles an approved policy update.
388
+
372
389
  See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
373
390
  adapter, approval-governance, and observability integrations.
374
391
 
@@ -394,16 +411,18 @@ real project shapes.
394
411
  | `init` | Create project memory and the initial baseline. |
395
412
  | `analyze` | Refresh facts without changing policy or baseline. |
396
413
  | `propose` | Generate evidence-based rule and contract proposals. |
397
- | `check --changed` | Enforce active policy on changed and untracked files. |
414
+ | `check [--changed]` | Enforce active policy; `--changed` limits findings to changed and untracked files. |
398
415
  | `audit` | Scan the complete current project, including baseline findings. |
399
416
  | `review` | Create a bounded semantic-review packet for an AI host. |
400
417
  | `verify` | Run the configured completion gate. |
418
+ | `governance` | Audit declared proposal/approval provenance and decision-to-run reachability. |
401
419
  | `doctor` | Diagnose invalid or missing Guard artifacts. |
402
420
  | `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
403
421
  | `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
404
422
  | `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
405
423
  | `rules` / `contracts` | Approve or reject proposed policy. |
406
424
  | `baseline` | Review or intentionally accept existing findings. |
425
+ | `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
407
426
 
408
427
  Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
409
428
  discovery cache; it does not change rules, contracts, baseline, or source files.
@@ -417,8 +436,13 @@ Run `pnpm exec codapult-guard <command> --help` for command-specific options.
417
436
 
418
437
  Every `verify` run also writes a local manifest under
419
438
  `.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.
439
+ policy gate that decided the result. It also records the effective policy revision and fingerprint;
440
+ when a base ref was supplied, it records the ref and whether the working-tree policy differed.
441
+ When an approved policy decision caused the current policy, the manifest also retains its decision
442
+ ID, making the relationship traversable from approval to verification. The `governance` command
443
+ audits this metadata and reports missing or overlapping declared actors. It contains no source code
444
+ or external telemetry. These manifests make a failed run explainable without turning Guard into a
445
+ production tracing system or an identity/signature ledger.
422
446
 
423
447
  ## Security and data handling
424
448
 
@@ -458,6 +482,12 @@ pnpm release:check
458
482
  - [Fixture matrix](docs/guard-fixtures.md)
459
483
  - [Release process](docs/releasing.md)
460
484
 
485
+ ## More from Codapult
486
+
487
+ Starting a SaaS from scratch? [Codapult](https://codapult.dev) is a production-ready Next.js SaaS
488
+ foundation built for AI-assisted development — with MCP context, built-in AI, modular
489
+ architecture, and the source code under your control.
490
+
461
491
  ## Project status
462
492
 
463
493
  `@codapult/guard` starts at `0.1.0` as a public alpha. The deterministic core is usable, tested,
@@ -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: approved.length - 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,35 @@ 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
+ export interface GuardWorkingTreePolicyState {
251
+ config: GuardConfig;
252
+ snapshot: GuardPolicySnapshot;
253
+ proposals?: GuardProposalFile | undefined;
254
+ }
255
+ /**
256
+ * Fingerprints the effective policy used by a verification run.
257
+ *
258
+ * The rules revision alone is not sufficient: baseline and waiver changes also
259
+ * affect the verdict. Keeping them in this fingerprint makes a run auditable
260
+ * without pretending that a numeric revision covers every policy artifact.
261
+ */
262
+ export declare function fingerprintGuardPolicy(guardConfig: GuardConfig, baseline?: Set<string>, waivers?: GuardWaiver[], agentConfig?: GuardAgentConfig): string;
263
+ export declare function createGuardPolicySnapshot(guardConfig: GuardConfig, baseline: Set<string>, waivers: GuardWaiver[], source?: GuardPolicySnapshot['source'], ref?: string, agentConfig?: GuardAgentConfig): GuardPolicySnapshot;
264
+ /**
265
+ * Loads policy artifacts from a trusted Git ref instead of the checked-out
266
+ * working tree. This is intended for CI verification of untrusted branches.
267
+ */
268
+ export declare function loadGuardPolicyAtRevision(root: string, revision: string): GuardPolicySnapshot | undefined;
236
269
  export declare function getGuardProposalFreshness(model: ProjectModel, proposals: GuardProposalFile | undefined): GuardProposalFreshness;
237
270
  export declare function loadGuardConfig(root: string): GuardConfig | undefined;
238
271
  export declare function loadGuardProposals(root: string): GuardProposalFile | undefined;
@@ -250,6 +283,7 @@ export interface GuardWaiverUpdateOptions {
250
283
  maxLifetimeDays?: number | undefined;
251
284
  }
252
285
  export declare function loadGuardWaivers(root: string): GuardWaiver[];
286
+ export declare function loadGuardWorkingTreePolicy(root: string): GuardWorkingTreePolicyState | undefined;
253
287
  export declare function writeGuardWaivers(root: string): void;
254
288
  export declare function updateGuardWaivers(root: string, options: GuardWaiverUpdateOptions): GuardWaiver[];
255
289
  export declare function updateBaseline(root: string, options?: GuardBaselineUpdateOptions): Set<string>;