@codapult/guard 0.6.2 → 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.
@@ -231,12 +248,51 @@ Guard state is stored in `.codapult/guard/`:
231
248
  ├── proposals.json evidence and approval history
232
249
  ├── baseline.json accepted pre-existing findings
233
250
  ├── agent.json AI-host completion-gate configuration
251
+ ├── waivers.json time-bounded exceptions to active findings
234
252
  └── history/ project snapshots and local verification run manifests
235
253
  ```
236
254
 
237
255
  Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
238
256
  disposable according to the project’s policy, and never commit secrets.
239
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
+
263
+ ### Temporary waivers
264
+
265
+ Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
266
+ temporarily suppresses one active finding and must have an owner, reason, and expiry date.
267
+
268
+ ```bash
269
+ codapult-guard waiver add <fingerprint> \
270
+ --owner platform-team \
271
+ --reason "Migration in progress" \
272
+ --expires 2026-12-28
273
+ codapult-guard waiver list
274
+ codapult-guard waiver renew <id> --expires 2027-01-31 --reason "Migration continues"
275
+ codapult-guard waiver remove <id> --reason "Migration completed"
276
+ ```
277
+
278
+ Guard warns when a waiver is within the configured warning window (14 days by default). Once it
279
+ expires, the original finding is active again. Protected policy can forbid waiver changes through
280
+ MCP so an agent can propose a waiver but cannot approve it itself. Waivers are intentionally
281
+ fingerprint-specific; changing the violating code invalidates the waiver.
282
+
283
+ Teams can optionally cap each new waiver or renewal with `waiverPolicy.maxDays` in `rules.json`:
284
+
285
+ ```json
286
+ "waiverPolicy": { "warningDays": 14, "maxDays": 90 }
287
+ ```
288
+
289
+ The cap applies to the requested grant from the current operation; it does not silently delete or
290
+ rewrite existing waivers. Boundary findings with severity `error` fail `check`; warning findings
291
+ and waiver-expiry reminders remain advisory.
292
+
293
+ Run `codapult-guard doctor` to detect an explicitly downgraded client or package boundary. Doctor
294
+ reports it as a warning and leaves the project policy unchanged.
295
+
240
296
  ### Optional scoped budgets
241
297
 
242
298
  Budgets are policy, not a universal style rule. Add them only for a named risk boundary such as a
@@ -281,9 +337,10 @@ Register that command in the MCP client from the project root. For example, Curs
281
337
  {
282
338
  "mcpServers": {
283
339
  "codapult-guard": {
340
+ "type": "stdio",
284
341
  "command": "pnpm",
285
342
  "args": ["exec", "codapult-guard", "mcp-server"],
286
- "cwd": "."
343
+ "cwd": "${workspaceFolder}"
287
344
  }
288
345
  }
289
346
  }
@@ -310,11 +367,11 @@ For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, s
310
367
 
311
368
  ### MCP Registry
312
369
 
313
- Guard is also prepared for discovery through the official [MCP Registry](https://registry.modelcontextprotocol.io/).
314
- The Registry entry points to the published `@codapult/guard` npm package and its stdio MCP server;
315
- it does not replace npm installation or the host-specific configuration above. Registry metadata is
316
- validated during CI and published after npm in the release workflow. The Registry is currently in
317
- preview, so the canonical installation path remains npm.
370
+ Guard is published in the official [MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.codapult%2Fguard)
371
+ under the server name `io.github.codapult/guard`. The Registry is the discovery layer for the
372
+ published `@codapult/guard` npm package and its stdio MCP server; it does not replace npm
373
+ installation or the host-specific configuration above. For deterministic setup, install the npm
374
+ package directly and use the MCP configuration shown below.
318
375
 
319
376
  For workflows where the authoring agent must not approve its own policy proposals, set Guard to
320
377
  protected mode in `rules.json`:
@@ -335,6 +392,37 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
335
392
  These variables provide declared provenance only; external branch protection or signed identity
336
393
  remains responsible for proving who approved the change.
337
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
+
338
426
  See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
339
427
  adapter, approval-governance, and observability integrations.
340
428
 
@@ -360,16 +448,18 @@ real project shapes.
360
448
  | `init` | Create project memory and the initial baseline. |
361
449
  | `analyze` | Refresh facts without changing policy or baseline. |
362
450
  | `propose` | Generate evidence-based rule and contract proposals. |
363
- | `check --changed` | Enforce active policy on changed and untracked files. |
451
+ | `check [--changed]` | Enforce active policy; `--changed` limits findings to changed and untracked files. |
364
452
  | `audit` | Scan the complete current project, including baseline findings. |
365
453
  | `review` | Create a bounded semantic-review packet for an AI host. |
366
454
  | `verify` | Run the configured completion gate. |
455
+ | `governance` | Audit declared proposal/approval provenance and decision-to-run reachability. |
367
456
  | `doctor` | Diagnose invalid or missing Guard artifacts. |
368
457
  | `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
369
458
  | `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
370
459
  | `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
371
460
  | `rules` / `contracts` | Approve or reject proposed policy. |
372
461
  | `baseline` | Review or intentionally accept existing findings. |
462
+ | `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
373
463
 
374
464
  Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
375
465
  discovery cache; it does not change rules, contracts, baseline, or source files.
@@ -383,8 +473,13 @@ Run `pnpm exec codapult-guard <command> --help` for command-specific options.
383
473
 
384
474
  Every `verify` run also writes a local manifest under
385
475
  `.codapult/guard/history/runs/<run-id>.json`. It records stage durations, the outcome, and the
386
- policy gate that decided the result. It contains no source code or external telemetry. These
387
- 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.
388
483
 
389
484
  ## Security and data handling
390
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;
@@ -26,6 +31,12 @@ interface GuardBaselineOptions extends GuardOutputOptions {
26
31
  all?: boolean | undefined;
27
32
  reason?: string | undefined;
28
33
  }
34
+ export interface GuardWaiverOptions extends GuardOutputOptions {
35
+ owner?: string | undefined;
36
+ reason?: string | undefined;
37
+ expires?: string | undefined;
38
+ issue?: string | undefined;
39
+ }
29
40
  interface GuardApprovalOptions {
30
41
  all?: boolean | undefined;
31
42
  }
@@ -43,12 +54,14 @@ export declare function guardDoctorCommand(options?: GuardOutputOptions & {
43
54
  }): void;
44
55
  export declare function guardHistoryCommand(): void;
45
56
  export declare function guardRunsCommand(options?: GuardOutputOptions): void;
57
+ export declare function guardGovernanceCommand(options?: GuardGovernanceOptions): void;
46
58
  export declare function guardImpactCommand(files: string[], options?: GuardOutputOptions): void;
47
59
  export declare function guardHistoryDiffCommand(from: string, to: string, options?: GuardOutputOptions): void;
48
60
  export declare function guardVerifyCommand(options?: GuardVerifyOptions): void;
49
61
  export declare function guardCheckCommand(options?: GuardCheckOptions): void;
50
62
  export declare function guardAuditCommand(options?: GuardOutputOptions): void;
51
63
  export declare function guardBaselineCommand(action: 'list' | 'accept' | 'remove', ids?: string, options?: GuardBaselineOptions): void;
64
+ export declare function guardWaiverCommand(action: 'list' | 'add' | 'remove' | 'renew', value?: string, options?: GuardWaiverOptions): void;
52
65
  export declare function guardRulesApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
53
66
  export declare function guardPolicyExplainCommand(id: string, options?: GuardOutputOptions): void;
54
67
  export declare function guardContractsApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
@@ -1,6 +1,7 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { relative, resolve } from 'node:path';
3
- import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, updateBaseline, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
4
+ import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, GuardWaiverError, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, loadGuardWaivers, updateBaseline, updateGuardWaivers, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
4
5
  import { guardErrorPayload } from '../../core/errors.js';
5
6
  import { runGuardVerification, } from '../../core/verification/verify.js';
6
7
  import { diagnoseGuard } from '../../core/analysis/doctor.js';
@@ -10,6 +11,7 @@ import { dim, fail, heading, info, success, warn } from '../ui.js';
10
11
  import { guardFindingsToSarif } from '../../core/output/sarif.js';
11
12
  import { analyzeProjectImpact } from '../../core/analysis/impact.js';
12
13
  import { listGuardRuns, summarizeGuardRuns } from '../../core/history/runs.js';
14
+ import { auditGuardGovernance } from '../../core/audit/governance.js';
13
15
  function renderFindings(findings) {
14
16
  for (const finding of findings) {
15
17
  const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
@@ -19,6 +21,16 @@ function renderFindings(findings) {
19
21
  dim(` resolved: ${finding.resolvedPath}`);
20
22
  }
21
23
  }
24
+ function renderWaiverStatus(report) {
25
+ for (const waiver of report.waiverWarnings ?? []) {
26
+ warn(`Waiver ${waiver.id} expires soon: ${waiver.expiresAt} (${waiver.owner})`);
27
+ dim(` ${waiver.fingerprint}: ${waiver.reason}`);
28
+ }
29
+ for (const waiver of report.expiredWaivers ?? []) {
30
+ warn(`Waiver ${waiver.id} expired: ${waiver.expiresAt} (${waiver.owner})`);
31
+ dim(` The original finding is active again: ${waiver.fingerprint}`);
32
+ }
33
+ }
22
34
  function getRoot() {
23
35
  return findGuardRoot();
24
36
  }
@@ -174,6 +186,8 @@ export function guardDoctorCommand(options = {}) {
174
186
  success(`${item.path}: ok`);
175
187
  else if (item.status === 'missing')
176
188
  warn(`${item.path}: missing`);
189
+ else if (item.status === 'warning')
190
+ warn(`${item.path}: ${item.message}`);
177
191
  else
178
192
  fail(`${item.path}: invalid`);
179
193
  }
@@ -208,6 +222,32 @@ export function guardRunsCommand(options = {}) {
208
222
  info(`${run.runId}: ${run.outcome}, gate=${run.gate}, ${run.durationMs} ms`);
209
223
  }
210
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
+ }
211
251
  export function guardImpactCommand(files, options = {}) {
212
252
  const root = getRoot();
213
253
  if (files.length === 0) {
@@ -270,6 +310,8 @@ export function guardVerifyCommand(options = {}) {
270
310
  tools: options.tools,
271
311
  strict: options.strict,
272
312
  projectChecks: options.projectChecks,
313
+ policyBase: options.policyBase,
314
+ failOnPolicyChange: options.failOnPolicyChange,
273
315
  });
274
316
  if (options.json) {
275
317
  console.log(JSON.stringify(result, null, 2));
@@ -282,6 +324,12 @@ export function guardVerifyCommand(options = {}) {
282
324
  process.exitCode = 1;
283
325
  return;
284
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
+ }
285
333
  for (const [name, check] of Object.entries(result.checks)) {
286
334
  if (check.status === 'passed')
287
335
  success(`${name}: passed`);
@@ -303,6 +351,8 @@ export function guardVerifyCommand(options = {}) {
303
351
  success('architecture: no new regressions');
304
352
  else
305
353
  renderFindings(findings);
354
+ if (result.architecture)
355
+ renderWaiverStatus(result.architecture);
306
356
  for (const issue of result.contractIssues)
307
357
  warn(`[${issue.contractId}] ${issue.message}`);
308
358
  if (result.contractIssues.length > 0)
@@ -340,7 +390,8 @@ export function guardCheckCommand(options = {}) {
340
390
  });
341
391
  const contractIssues = validateGuardPolicy(root, config);
342
392
  const errors = report.findings.filter((finding) => finding.severity === 'error').length;
343
- const warnings = report.findings.filter((finding) => finding.severity === 'warning').length;
393
+ const warnings = report.findings.filter((finding) => finding.severity === 'warning').length +
394
+ (report.waiverWarnings ?? []).length;
344
395
  const contractFindings = contractIssues.map((issue) => ({
345
396
  ruleId: `contract:${issue.contractId}`,
346
397
  severity: 'error',
@@ -351,7 +402,19 @@ export function guardCheckCommand(options = {}) {
351
402
  fingerprint: `contract-invalid|${issue.contractId}|${issue.field}|${issue.value}`,
352
403
  }));
353
404
  if (options.sarif) {
354
- console.log(JSON.stringify(guardFindingsToSarif([...report.findings, ...contractFindings]), null, 2));
405
+ const waiverNotices = [
406
+ ...(report.waiverWarnings ?? []).map((waiver) => ({
407
+ ruleId: 'guard-waiver-expiring',
408
+ level: 'warning',
409
+ message: `Waiver ${waiver.id} for ${waiver.ruleId} expires at ${waiver.expiresAt}. Owner: ${waiver.owner}.`,
410
+ })),
411
+ ...(report.expiredWaivers ?? []).map((waiver) => ({
412
+ ruleId: 'guard-waiver-expired',
413
+ level: 'warning',
414
+ message: `Waiver ${waiver.id} for ${waiver.ruleId} expired at ${waiver.expiresAt}; the finding is active again.`,
415
+ })),
416
+ ];
417
+ console.log(JSON.stringify(guardFindingsToSarif([...report.findings, ...contractFindings], waiverNotices), null, 2));
355
418
  process.exitCode = errors > 0 || contractIssues.length > 0 ? 1 : 0;
356
419
  return;
357
420
  }
@@ -370,6 +433,7 @@ export function guardCheckCommand(options = {}) {
370
433
  heading(`Codapult Guard${options.changed ? ' (changed files)' : ''}`);
371
434
  dim(`Scanned ${report.scannedFiles} source file(s); suppressed ${report.suppressed} baseline finding(s).`);
372
435
  renderFindings(report.findings);
436
+ renderWaiverStatus(report);
373
437
  for (const issue of contractIssues)
374
438
  warn(`[${issue.contractId}] ${issue.message}`);
375
439
  if (errors > 0 || contractIssues.length > 0)
@@ -406,7 +470,8 @@ export function guardAuditCommand(options = {}) {
406
470
  const report = scanGuard(root, config, { includeArchitectureInsights: true });
407
471
  const contractIssues = validateGuardPolicy(root, config);
408
472
  const errors = report.findings.filter((finding) => finding.severity === 'error').length;
409
- const warnings = report.findings.filter((finding) => finding.severity === 'warning').length;
473
+ const warnings = report.findings.filter((finding) => finding.severity === 'warning').length +
474
+ (report.waiverWarnings ?? []).length;
410
475
  if (options.json) {
411
476
  console.log(JSON.stringify({
412
477
  status: errors > 0 || contractIssues.length > 0 ? 'fail' : warnings > 0 ? 'warning' : 'ok',
@@ -425,6 +490,7 @@ export function guardAuditCommand(options = {}) {
425
490
  heading('Codapult Guard Audit');
426
491
  dim(`Scanned ${report.scannedFiles} source file(s); baseline is ignored for this full audit.`);
427
492
  renderFindings(report.findings);
493
+ renderWaiverStatus(report);
428
494
  for (const issue of contractIssues)
429
495
  warn(`[${issue.contractId}] ${issue.message}`);
430
496
  if (errors > 0)
@@ -480,6 +546,99 @@ export function guardBaselineCommand(action, ids, options = {}) {
480
546
  success(`${action === 'accept' ? 'Accepted' : 'Removed'} ${selected.length} baseline fingerprint(s).`);
481
547
  process.exitCode = 0;
482
548
  }
549
+ export function guardWaiverCommand(action, value, options = {}) {
550
+ const root = getRoot();
551
+ const waivers = loadGuardWaivers(root);
552
+ const reason = options.reason?.trim();
553
+ if (action === 'list') {
554
+ const result = { count: waivers.length, waivers };
555
+ if (options.json)
556
+ console.log(JSON.stringify(result, null, 2));
557
+ else {
558
+ heading('Codapult Guard Waivers');
559
+ for (const waiver of waivers) {
560
+ const status = Date.parse(waiver.expiresAt) <= Date.now() ? 'expired' : 'active';
561
+ info(`${waiver.id} [${status}] ${waiver.owner} → ${waiver.expiresAt}`);
562
+ dim(` ${waiver.fingerprint}: ${waiver.reason}`);
563
+ }
564
+ }
565
+ process.exitCode = 0;
566
+ return;
567
+ }
568
+ if (!reason)
569
+ throw new GuardWaiverError('A reason is required for waiver changes.');
570
+ const config = loadGuardConfig(root);
571
+ if (!config)
572
+ throw new GuardWaiverError('Guard is not initialized.');
573
+ if (config.approval?.mode === 'protected' &&
574
+ config.approval.requireDistinctActor &&
575
+ !process.env.GUARD_APPROVER?.trim()) {
576
+ throw new GuardWaiverError('Protected Guard policy requires GUARD_APPROVER for waiver changes.');
577
+ }
578
+ if (action === 'add') {
579
+ const owner = options.owner?.trim();
580
+ if (!value || !owner || !options.expires) {
581
+ throw new GuardWaiverError('Add requires <fingerprint>, --owner, and --expires.');
582
+ }
583
+ const expires = new Date(options.expires);
584
+ if (!Number.isFinite(expires.getTime()))
585
+ throw new GuardWaiverError('Invalid waiver expiry date.');
586
+ const expiresAt = expires.toISOString();
587
+ const finding = scanGuard(root, config, {
588
+ waivers: [],
589
+ includeArchitectureInsights: true,
590
+ }).findings.find((candidate) => candidate.fingerprint === value);
591
+ if (!finding)
592
+ throw new GuardWaiverError(`Finding fingerprint not found: ${value}`);
593
+ const waiver = {
594
+ id: `waiver-${randomUUID()}`,
595
+ fingerprint: value,
596
+ ruleId: finding.ruleId,
597
+ owner,
598
+ reason,
599
+ createdAt: new Date().toISOString(),
600
+ expiresAt,
601
+ ...(options.issue ? { issue: options.issue } : {}),
602
+ ...(process.env.GUARD_APPROVER?.trim()
603
+ ? { approvedBy: process.env.GUARD_APPROVER.trim() }
604
+ : {}),
605
+ };
606
+ updateGuardWaivers(root, {
607
+ action,
608
+ waiverId: waiver.id,
609
+ waiver,
610
+ reason,
611
+ actor: process.env.GUARD_APPROVER,
612
+ maxLifetimeDays: config.waiverPolicy?.maxDays,
613
+ });
614
+ if (options.json)
615
+ console.log(JSON.stringify({ action, waiver }, null, 2));
616
+ else
617
+ success(`Added waiver ${waiver.id}; expires ${waiver.expiresAt}.`);
618
+ }
619
+ else {
620
+ if (!value)
621
+ throw new GuardWaiverError(`Waiver id is required to ${action}.`);
622
+ const expires = action === 'renew' ? new Date(options.expires ?? '') : undefined;
623
+ if (expires && !Number.isFinite(expires.getTime())) {
624
+ throw new GuardWaiverError('Invalid waiver expiry date.');
625
+ }
626
+ const expiresAt = expires?.toISOString();
627
+ updateGuardWaivers(root, {
628
+ action,
629
+ waiverId: value,
630
+ ...(expiresAt ? { expiresAt } : {}),
631
+ reason,
632
+ actor: process.env.GUARD_APPROVER,
633
+ maxLifetimeDays: config.waiverPolicy?.maxDays,
634
+ });
635
+ if (options.json)
636
+ console.log(JSON.stringify({ action, waiverId: value, expiresAt }, null, 2));
637
+ else
638
+ success(`${action === 'renew' ? 'Renewed' : 'Removed'} waiver ${value}.`);
639
+ }
640
+ process.exitCode = 0;
641
+ }
483
642
  export function guardRulesApproveCommand(ids, options = {}) {
484
643
  const root = getRoot();
485
644
  const loaded = loadConfigSafely(root);
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, } 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
- import { GuardBaselineReasonError, GuardConfigError, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
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
@@ -72,6 +76,30 @@ baseline
72
76
  .option('--reason <text>')
73
77
  .option('--json')
74
78
  .action((ids, options) => guardBaselineCommand('remove', ids, options));
79
+ const waivers = guard.command('waiver');
80
+ waivers
81
+ .command('list')
82
+ .option('--json')
83
+ .action((options) => guardWaiverCommand('list', undefined, options));
84
+ waivers
85
+ .command('add <fingerprint>')
86
+ .requiredOption('--owner <owner>')
87
+ .requiredOption('--reason <text>')
88
+ .requiredOption('--expires <date>')
89
+ .option('--issue <url>')
90
+ .option('--json')
91
+ .action((fingerprint, options) => guardWaiverCommand('add', fingerprint, options));
92
+ waivers
93
+ .command('remove <id>')
94
+ .requiredOption('--reason <text>')
95
+ .option('--json')
96
+ .action((id, options) => guardWaiverCommand('remove', id, options));
97
+ waivers
98
+ .command('renew <id>')
99
+ .requiredOption('--expires <date>')
100
+ .requiredOption('--reason <text>')
101
+ .option('--json')
102
+ .action((id, options) => guardWaiverCommand('renew', id, options));
75
103
  program
76
104
  .command('mcp-server')
77
105
  .description('start the Guard MCP server over stdio')
@@ -85,10 +113,13 @@ catch (error) {
85
113
  if (error instanceof GuardConfigError ||
86
114
  error instanceof GuardBaselineReasonError ||
87
115
  error instanceof GuardStateBusyError ||
88
- error instanceof GuardStateStaleError) {
116
+ error instanceof GuardStateStaleError ||
117
+ error instanceof GuardWaiverError ||
118
+ error instanceof GuardAgentInstallError) {
89
119
  const isBusy = error instanceof GuardStateBusyError;
90
120
  const isStale = error instanceof GuardStateStaleError;
91
121
  const isInvalidInput = error instanceof GuardBaselineReasonError;
122
+ const isInstallError = error instanceof GuardAgentInstallError;
92
123
  console.error(JSON.stringify(guardErrorPayload(isBusy
93
124
  ? 'GUARD_STATE_BUSY'
94
125
  : isStale
@@ -103,7 +134,9 @@ catch (error) {
103
134
  ? 'Re-read Guard state and retry the operation.'
104
135
  : isInvalidInput
105
136
  ? 'Provide a written reason for the baseline decision.'
106
- : '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.',
107
140
  }), null, 2));
108
141
  process.exitCode = 1;
109
142
  }
@@ -1,6 +1,6 @@
1
1
  export interface GuardDoctorItem {
2
2
  path: string;
3
- status: 'ok' | 'missing' | 'invalid';
3
+ status: 'ok' | 'missing' | 'invalid' | 'warning';
4
4
  message: string;
5
5
  }
6
6
  export interface GuardDoctorReport {