@codapult/guard 0.6.1 → 0.7.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
@@ -231,12 +231,46 @@ Guard state is stored in `.codapult/guard/`:
231
231
  ├── proposals.json evidence and approval history
232
232
  ├── baseline.json accepted pre-existing findings
233
233
  ├── agent.json AI-host completion-gate configuration
234
+ ├── waivers.json time-bounded exceptions to active findings
234
235
  └── history/ project snapshots and local verification run manifests
235
236
  ```
236
237
 
237
238
  Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
238
239
  disposable according to the project’s policy, and never commit secrets.
239
240
 
241
+ ### Temporary waivers
242
+
243
+ Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
244
+ temporarily suppresses one active finding and must have an owner, reason, and expiry date.
245
+
246
+ ```bash
247
+ codapult-guard waiver add <fingerprint> \
248
+ --owner platform-team \
249
+ --reason "Migration in progress" \
250
+ --expires 2026-12-28
251
+ codapult-guard waiver list
252
+ codapult-guard waiver renew <id> --expires 2027-01-31 --reason "Migration continues"
253
+ codapult-guard waiver remove <id> --reason "Migration completed"
254
+ ```
255
+
256
+ Guard warns when a waiver is within the configured warning window (14 days by default). Once it
257
+ expires, the original finding is active again. Protected policy can forbid waiver changes through
258
+ MCP so an agent can propose a waiver but cannot approve it itself. Waivers are intentionally
259
+ fingerprint-specific; changing the violating code invalidates the waiver.
260
+
261
+ Teams can optionally cap each new waiver or renewal with `waiverPolicy.maxDays` in `rules.json`:
262
+
263
+ ```json
264
+ "waiverPolicy": { "warningDays": 14, "maxDays": 90 }
265
+ ```
266
+
267
+ The cap applies to the requested grant from the current operation; it does not silently delete or
268
+ rewrite existing waivers. Boundary findings with severity `error` fail `check`; warning findings
269
+ and waiver-expiry reminders remain advisory.
270
+
271
+ Run `codapult-guard doctor` to detect an explicitly downgraded client or package boundary. Doctor
272
+ reports it as a warning and leaves the project policy unchanged.
273
+
240
274
  ### Optional scoped budgets
241
275
 
242
276
  Budgets are policy, not a universal style rule. Add them only for a named risk boundary such as a
@@ -310,11 +344,11 @@ For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, s
310
344
 
311
345
  ### MCP Registry
312
346
 
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.
347
+ Guard is published in the official [MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.codapult%2Fguard)
348
+ under the server name `io.github.codapult/guard`. The Registry is the discovery layer for the
349
+ published `@codapult/guard` npm package and its stdio MCP server; it does not replace npm
350
+ installation or the host-specific configuration above. For deterministic setup, install the npm
351
+ package directly and use the MCP configuration shown below.
318
352
 
319
353
  For workflows where the authoring agent must not approve its own policy proposals, set Guard to
320
354
  protected mode in `rules.json`:
@@ -26,6 +26,12 @@ interface GuardBaselineOptions extends GuardOutputOptions {
26
26
  all?: boolean | undefined;
27
27
  reason?: string | undefined;
28
28
  }
29
+ export interface GuardWaiverOptions extends GuardOutputOptions {
30
+ owner?: string | undefined;
31
+ reason?: string | undefined;
32
+ expires?: string | undefined;
33
+ issue?: string | undefined;
34
+ }
29
35
  interface GuardApprovalOptions {
30
36
  all?: boolean | undefined;
31
37
  }
@@ -49,6 +55,7 @@ export declare function guardVerifyCommand(options?: GuardVerifyOptions): void;
49
55
  export declare function guardCheckCommand(options?: GuardCheckOptions): void;
50
56
  export declare function guardAuditCommand(options?: GuardOutputOptions): void;
51
57
  export declare function guardBaselineCommand(action: 'list' | 'accept' | 'remove', ids?: string, options?: GuardBaselineOptions): void;
58
+ export declare function guardWaiverCommand(action: 'list' | 'add' | 'remove' | 'renew', value?: string, options?: GuardWaiverOptions): void;
52
59
  export declare function guardRulesApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
53
60
  export declare function guardPolicyExplainCommand(id: string, options?: GuardOutputOptions): void;
54
61
  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';
@@ -19,6 +20,16 @@ function renderFindings(findings) {
19
20
  dim(` resolved: ${finding.resolvedPath}`);
20
21
  }
21
22
  }
23
+ function renderWaiverStatus(report) {
24
+ for (const waiver of report.waiverWarnings ?? []) {
25
+ warn(`Waiver ${waiver.id} expires soon: ${waiver.expiresAt} (${waiver.owner})`);
26
+ dim(` ${waiver.fingerprint}: ${waiver.reason}`);
27
+ }
28
+ for (const waiver of report.expiredWaivers ?? []) {
29
+ warn(`Waiver ${waiver.id} expired: ${waiver.expiresAt} (${waiver.owner})`);
30
+ dim(` The original finding is active again: ${waiver.fingerprint}`);
31
+ }
32
+ }
22
33
  function getRoot() {
23
34
  return findGuardRoot();
24
35
  }
@@ -174,6 +185,8 @@ export function guardDoctorCommand(options = {}) {
174
185
  success(`${item.path}: ok`);
175
186
  else if (item.status === 'missing')
176
187
  warn(`${item.path}: missing`);
188
+ else if (item.status === 'warning')
189
+ warn(`${item.path}: ${item.message}`);
177
190
  else
178
191
  fail(`${item.path}: invalid`);
179
192
  }
@@ -303,6 +316,8 @@ export function guardVerifyCommand(options = {}) {
303
316
  success('architecture: no new regressions');
304
317
  else
305
318
  renderFindings(findings);
319
+ if (result.architecture)
320
+ renderWaiverStatus(result.architecture);
306
321
  for (const issue of result.contractIssues)
307
322
  warn(`[${issue.contractId}] ${issue.message}`);
308
323
  if (result.contractIssues.length > 0)
@@ -340,7 +355,8 @@ export function guardCheckCommand(options = {}) {
340
355
  });
341
356
  const contractIssues = validateGuardPolicy(root, config);
342
357
  const errors = report.findings.filter((finding) => finding.severity === 'error').length;
343
- const warnings = report.findings.filter((finding) => finding.severity === 'warning').length;
358
+ const warnings = report.findings.filter((finding) => finding.severity === 'warning').length +
359
+ (report.waiverWarnings ?? []).length;
344
360
  const contractFindings = contractIssues.map((issue) => ({
345
361
  ruleId: `contract:${issue.contractId}`,
346
362
  severity: 'error',
@@ -351,7 +367,19 @@ export function guardCheckCommand(options = {}) {
351
367
  fingerprint: `contract-invalid|${issue.contractId}|${issue.field}|${issue.value}`,
352
368
  }));
353
369
  if (options.sarif) {
354
- console.log(JSON.stringify(guardFindingsToSarif([...report.findings, ...contractFindings]), null, 2));
370
+ const waiverNotices = [
371
+ ...(report.waiverWarnings ?? []).map((waiver) => ({
372
+ ruleId: 'guard-waiver-expiring',
373
+ level: 'warning',
374
+ message: `Waiver ${waiver.id} for ${waiver.ruleId} expires at ${waiver.expiresAt}. Owner: ${waiver.owner}.`,
375
+ })),
376
+ ...(report.expiredWaivers ?? []).map((waiver) => ({
377
+ ruleId: 'guard-waiver-expired',
378
+ level: 'warning',
379
+ message: `Waiver ${waiver.id} for ${waiver.ruleId} expired at ${waiver.expiresAt}; the finding is active again.`,
380
+ })),
381
+ ];
382
+ console.log(JSON.stringify(guardFindingsToSarif([...report.findings, ...contractFindings], waiverNotices), null, 2));
355
383
  process.exitCode = errors > 0 || contractIssues.length > 0 ? 1 : 0;
356
384
  return;
357
385
  }
@@ -370,6 +398,7 @@ export function guardCheckCommand(options = {}) {
370
398
  heading(`Codapult Guard${options.changed ? ' (changed files)' : ''}`);
371
399
  dim(`Scanned ${report.scannedFiles} source file(s); suppressed ${report.suppressed} baseline finding(s).`);
372
400
  renderFindings(report.findings);
401
+ renderWaiverStatus(report);
373
402
  for (const issue of contractIssues)
374
403
  warn(`[${issue.contractId}] ${issue.message}`);
375
404
  if (errors > 0 || contractIssues.length > 0)
@@ -406,7 +435,8 @@ export function guardAuditCommand(options = {}) {
406
435
  const report = scanGuard(root, config, { includeArchitectureInsights: true });
407
436
  const contractIssues = validateGuardPolicy(root, config);
408
437
  const errors = report.findings.filter((finding) => finding.severity === 'error').length;
409
- const warnings = report.findings.filter((finding) => finding.severity === 'warning').length;
438
+ const warnings = report.findings.filter((finding) => finding.severity === 'warning').length +
439
+ (report.waiverWarnings ?? []).length;
410
440
  if (options.json) {
411
441
  console.log(JSON.stringify({
412
442
  status: errors > 0 || contractIssues.length > 0 ? 'fail' : warnings > 0 ? 'warning' : 'ok',
@@ -425,6 +455,7 @@ export function guardAuditCommand(options = {}) {
425
455
  heading('Codapult Guard Audit');
426
456
  dim(`Scanned ${report.scannedFiles} source file(s); baseline is ignored for this full audit.`);
427
457
  renderFindings(report.findings);
458
+ renderWaiverStatus(report);
428
459
  for (const issue of contractIssues)
429
460
  warn(`[${issue.contractId}] ${issue.message}`);
430
461
  if (errors > 0)
@@ -480,6 +511,99 @@ export function guardBaselineCommand(action, ids, options = {}) {
480
511
  success(`${action === 'accept' ? 'Accepted' : 'Removed'} ${selected.length} baseline fingerprint(s).`);
481
512
  process.exitCode = 0;
482
513
  }
514
+ export function guardWaiverCommand(action, value, options = {}) {
515
+ const root = getRoot();
516
+ const waivers = loadGuardWaivers(root);
517
+ const reason = options.reason?.trim();
518
+ if (action === 'list') {
519
+ const result = { count: waivers.length, waivers };
520
+ if (options.json)
521
+ console.log(JSON.stringify(result, null, 2));
522
+ else {
523
+ heading('Codapult Guard Waivers');
524
+ for (const waiver of waivers) {
525
+ const status = Date.parse(waiver.expiresAt) <= Date.now() ? 'expired' : 'active';
526
+ info(`${waiver.id} [${status}] ${waiver.owner} → ${waiver.expiresAt}`);
527
+ dim(` ${waiver.fingerprint}: ${waiver.reason}`);
528
+ }
529
+ }
530
+ process.exitCode = 0;
531
+ return;
532
+ }
533
+ if (!reason)
534
+ throw new GuardWaiverError('A reason is required for waiver changes.');
535
+ const config = loadGuardConfig(root);
536
+ if (!config)
537
+ throw new GuardWaiverError('Guard is not initialized.');
538
+ if (config.approval?.mode === 'protected' &&
539
+ config.approval.requireDistinctActor &&
540
+ !process.env.GUARD_APPROVER?.trim()) {
541
+ throw new GuardWaiverError('Protected Guard policy requires GUARD_APPROVER for waiver changes.');
542
+ }
543
+ if (action === 'add') {
544
+ const owner = options.owner?.trim();
545
+ if (!value || !owner || !options.expires) {
546
+ throw new GuardWaiverError('Add requires <fingerprint>, --owner, and --expires.');
547
+ }
548
+ const expires = new Date(options.expires);
549
+ if (!Number.isFinite(expires.getTime()))
550
+ throw new GuardWaiverError('Invalid waiver expiry date.');
551
+ const expiresAt = expires.toISOString();
552
+ const finding = scanGuard(root, config, {
553
+ waivers: [],
554
+ includeArchitectureInsights: true,
555
+ }).findings.find((candidate) => candidate.fingerprint === value);
556
+ if (!finding)
557
+ throw new GuardWaiverError(`Finding fingerprint not found: ${value}`);
558
+ const waiver = {
559
+ id: `waiver-${randomUUID()}`,
560
+ fingerprint: value,
561
+ ruleId: finding.ruleId,
562
+ owner,
563
+ reason,
564
+ createdAt: new Date().toISOString(),
565
+ expiresAt,
566
+ ...(options.issue ? { issue: options.issue } : {}),
567
+ ...(process.env.GUARD_APPROVER?.trim()
568
+ ? { approvedBy: process.env.GUARD_APPROVER.trim() }
569
+ : {}),
570
+ };
571
+ updateGuardWaivers(root, {
572
+ action,
573
+ waiverId: waiver.id,
574
+ waiver,
575
+ reason,
576
+ actor: process.env.GUARD_APPROVER,
577
+ maxLifetimeDays: config.waiverPolicy?.maxDays,
578
+ });
579
+ if (options.json)
580
+ console.log(JSON.stringify({ action, waiver }, null, 2));
581
+ else
582
+ success(`Added waiver ${waiver.id}; expires ${waiver.expiresAt}.`);
583
+ }
584
+ else {
585
+ if (!value)
586
+ throw new GuardWaiverError(`Waiver id is required to ${action}.`);
587
+ const expires = action === 'renew' ? new Date(options.expires ?? '') : undefined;
588
+ if (expires && !Number.isFinite(expires.getTime())) {
589
+ throw new GuardWaiverError('Invalid waiver expiry date.');
590
+ }
591
+ const expiresAt = expires?.toISOString();
592
+ updateGuardWaivers(root, {
593
+ action,
594
+ waiverId: value,
595
+ ...(expiresAt ? { expiresAt } : {}),
596
+ reason,
597
+ actor: process.env.GUARD_APPROVER,
598
+ maxLifetimeDays: config.waiverPolicy?.maxDays,
599
+ });
600
+ if (options.json)
601
+ console.log(JSON.stringify({ action, waiverId: value, expiresAt }, null, 2));
602
+ else
603
+ success(`${action === 'renew' ? 'Renewed' : 'Removed'} waiver ${value}.`);
604
+ }
605
+ process.exitCode = 0;
606
+ }
483
607
  export function guardRulesApproveCommand(ids, options = {}) {
484
608
  const root = getRoot();
485
609
  const loaded = loadConfigSafely(root);
package/dist/cli/index.js CHANGED
@@ -1,9 +1,9 @@
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, 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
7
  import { guardErrorPayload } from '../core/errors.js';
8
8
  const program = new Command()
9
9
  .name(config.commandName)
@@ -72,6 +72,30 @@ baseline
72
72
  .option('--reason <text>')
73
73
  .option('--json')
74
74
  .action((ids, options) => guardBaselineCommand('remove', ids, options));
75
+ const waivers = guard.command('waiver');
76
+ waivers
77
+ .command('list')
78
+ .option('--json')
79
+ .action((options) => guardWaiverCommand('list', undefined, options));
80
+ waivers
81
+ .command('add <fingerprint>')
82
+ .requiredOption('--owner <owner>')
83
+ .requiredOption('--reason <text>')
84
+ .requiredOption('--expires <date>')
85
+ .option('--issue <url>')
86
+ .option('--json')
87
+ .action((fingerprint, options) => guardWaiverCommand('add', fingerprint, options));
88
+ waivers
89
+ .command('remove <id>')
90
+ .requiredOption('--reason <text>')
91
+ .option('--json')
92
+ .action((id, options) => guardWaiverCommand('remove', id, options));
93
+ waivers
94
+ .command('renew <id>')
95
+ .requiredOption('--expires <date>')
96
+ .requiredOption('--reason <text>')
97
+ .option('--json')
98
+ .action((id, options) => guardWaiverCommand('renew', id, options));
75
99
  program
76
100
  .command('mcp-server')
77
101
  .description('start the Guard MCP server over stdio')
@@ -85,7 +109,8 @@ catch (error) {
85
109
  if (error instanceof GuardConfigError ||
86
110
  error instanceof GuardBaselineReasonError ||
87
111
  error instanceof GuardStateBusyError ||
88
- error instanceof GuardStateStaleError) {
112
+ error instanceof GuardStateStaleError ||
113
+ error instanceof GuardWaiverError) {
89
114
  const isBusy = error instanceof GuardStateBusyError;
90
115
  const isStale = error instanceof GuardStateStaleError;
91
116
  const isInvalidInput = error instanceof GuardBaselineReasonError;
@@ -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 {
@@ -11,6 +11,33 @@ const requiredArtifacts = [
11
11
  GUARD_CONTRACTS_FILE,
12
12
  GUARD_PROPOSALS_FILE,
13
13
  ];
14
+ function downgradedBoundaryItems(root) {
15
+ let guardConfig;
16
+ try {
17
+ guardConfig = loadGuardConfig(root);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ if (!guardConfig)
23
+ return [];
24
+ const items = guardConfig.rules
25
+ .filter((rule) => rule.kind === 'client-forbidden-import' && rule.severity === 'warning')
26
+ .map((rule) => ({
27
+ path: GUARD_RULES_FILE,
28
+ status: 'warning',
29
+ message: `Boundary rule '${rule.id}' is warning-only and will not block changes.`,
30
+ }));
31
+ items.push(...(guardConfig.contracts ?? [])
32
+ .filter((contract) => (contract.kind === 'import-boundary' || contract.kind === 'package-boundary') &&
33
+ contract.severity === 'warning')
34
+ .map((contract) => ({
35
+ path: GUARD_CONTRACTS_FILE,
36
+ status: 'warning',
37
+ message: `Boundary contract '${contract.id}' is warning-only and will not block changes.`,
38
+ })));
39
+ return items;
40
+ }
14
41
  function parseJson(root, path) {
15
42
  try {
16
43
  return JSON.parse(readFileSync(resolve(root, path), 'utf8'));
@@ -64,6 +91,7 @@ export function diagnoseGuard(root) {
64
91
  }
65
92
  return { path, status: 'ok', message: 'Artifact is present and readable.' };
66
93
  });
94
+ items.push(...downgradedBoundaryItems(root));
67
95
  if (initialized && existsSync(resolve(root, GUARD_BASELINE_META_FILE))) {
68
96
  const metadata = parseJson(root, GUARD_BASELINE_META_FILE);
69
97
  items.push({
@@ -79,10 +107,11 @@ export function diagnoseGuard(root) {
79
107
  }
80
108
  const missing = items.filter((item) => item.status === 'missing');
81
109
  const invalid = items.filter((item) => item.status === 'invalid');
110
+ const warnings = items.filter((item) => item.status === 'warning');
82
111
  return {
83
112
  status: invalid.length > 0 || (initialized && missing.length > 0)
84
113
  ? 'fail'
85
- : missing.length > 0
114
+ : missing.length > 0 || warnings.length > 0
86
115
  ? 'warning'
87
116
  : 'ok',
88
117
  initialized,
@@ -93,6 +122,10 @@ export function diagnoseGuard(root) {
93
122
  }
94
123
  : missing.length > 0
95
124
  ? { recommendation: 'Run `codapult-guard analyze` or reinitialize Guard intentionally.' }
96
- : {}),
125
+ : warnings.length > 0
126
+ ? {
127
+ recommendation: 'Review downgraded boundary policy before relying on Guard as a hard gate.',
128
+ }
129
+ : {}),
97
130
  };
98
131
  }
@@ -1,8 +1,8 @@
1
1
  import { type ProjectModel } from './discovery/discovery.js';
2
2
  import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot } from './discovery/discovery.js';
3
3
  import { type GuardImpactAnalysis } from './analysis/impact.js';
4
- import type { GuardAdapterName, GuardApprovalMode, GuardContractKind, GuardBudgetMetric, GuardRuleKind, GuardRuleStatus, GuardSeverity, GuardToolMode } from './model/types.js';
5
- export type { GuardAdapterName, GuardApprovalMode, GuardContractKind, GuardBudgetMetric, GuardRuleKind, GuardRuleStatus, GuardSeverity, GuardToolMode, } from './model/types.js';
4
+ import type { GuardAdapterName, GuardApprovalMode, GuardContractKind, GuardBudgetMetric, GuardRuleKind, GuardRuleStatus, GuardSeverity, GuardToolMode, GuardWaiver, GuardWaiverPolicy } from './model/types.js';
5
+ export type { GuardAdapterName, GuardApprovalMode, GuardContractKind, GuardBudgetMetric, GuardRuleKind, GuardRuleStatus, GuardSeverity, GuardToolMode, GuardWaiver, GuardWaiverPolicy, GuardWaiverDecision, } from './model/types.js';
6
6
  export type GuardOutcomeStatus = 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
7
7
  export interface GuardOutcomeInput {
8
8
  configured?: boolean | undefined;
@@ -85,6 +85,7 @@ export interface GuardConfig {
85
85
  contracts?: GuardContract[] | undefined;
86
86
  budgets?: GuardBudget[] | undefined;
87
87
  approval?: GuardApprovalPolicy | undefined;
88
+ waiverPolicy?: GuardWaiverPolicy | undefined;
88
89
  }
89
90
  export interface GuardApprovalPolicy {
90
91
  mode: GuardApprovalMode;
@@ -157,12 +158,17 @@ export interface GuardReport {
157
158
  findings: GuardFinding[];
158
159
  suppressed: number;
159
160
  scannedFiles: number;
161
+ waived: number;
162
+ waiverWarnings: GuardWaiver[];
163
+ expiredWaivers: GuardWaiver[];
160
164
  }
161
165
  interface ScanGuardOptions {
162
166
  changedOnly?: boolean | undefined;
163
167
  changedFiles?: Set<string> | undefined;
164
168
  baseline?: Set<string> | undefined;
165
169
  includeArchitectureInsights?: boolean | undefined;
170
+ waivers?: GuardWaiver[] | undefined;
171
+ now?: string | undefined;
166
172
  }
167
173
  export interface GuardReviewPacket {
168
174
  version: 1;
@@ -178,6 +184,9 @@ export interface GuardReviewPacket {
178
184
  contracts: GuardContract[];
179
185
  requirement?: string | undefined;
180
186
  deterministicFindings: GuardFinding[];
187
+ waived: number;
188
+ waiverWarnings: GuardWaiver[];
189
+ expiredWaivers: GuardWaiver[];
181
190
  impact: GuardImpactAnalysis;
182
191
  reviewInstructions: string[];
183
192
  }
@@ -185,6 +194,7 @@ export declare const GUARD_DIR: string;
185
194
  export declare const GUARD_RULES_FILE: string;
186
195
  export declare const GUARD_BASELINE_FILE: string;
187
196
  export declare const GUARD_BASELINE_META_FILE: string;
197
+ export declare const GUARD_WAIVERS_FILE: string;
188
198
  export declare const GUARD_PROJECT_FILE: string;
189
199
  export declare const GUARD_HISTORY_DIR: string;
190
200
  export declare const GUARD_ARCHITECTURE_FILE: string;
@@ -212,6 +222,9 @@ export declare class GuardStateStaleError extends Error {
212
222
  export declare class GuardBaselineReasonError extends Error {
213
223
  constructor();
214
224
  }
225
+ export declare class GuardWaiverError extends Error {
226
+ constructor(message: string);
227
+ }
215
228
  export declare function withGuardStateLock<T>(root: string, callback: () => T, options?: GuardStateLockOptions): T;
216
229
  export declare function isGuardContractsFile(value: unknown): value is {
217
230
  version: 1;
@@ -227,6 +240,18 @@ export declare function isGuardAgentConfig(value: unknown): value is GuardAgentC
227
240
  export declare function loadGuardAgentConfig(root: string): GuardAgentConfig;
228
241
  export declare function writeGuardAgentConfig(root: string, agentConfig?: GuardAgentConfig): void;
229
242
  export declare function loadBaseline(root: string): Set<string>;
243
+ export interface GuardWaiverUpdateOptions {
244
+ action: 'add' | 'renew' | 'remove';
245
+ waiver?: GuardWaiver | undefined;
246
+ waiverId: string;
247
+ reason: string;
248
+ expiresAt?: string | undefined;
249
+ actor?: string | undefined;
250
+ maxLifetimeDays?: number | undefined;
251
+ }
252
+ export declare function loadGuardWaivers(root: string): GuardWaiver[];
253
+ export declare function writeGuardWaivers(root: string): void;
254
+ export declare function updateGuardWaivers(root: string, options: GuardWaiverUpdateOptions): GuardWaiver[];
230
255
  export declare function updateBaseline(root: string, options?: GuardBaselineUpdateOptions): Set<string>;
231
256
  export declare function writeGuardConfig(root: string, guardConfig: GuardConfig, options?: GuardConfigWriteOptions): void;
232
257
  export declare function writeGuardProposals(root: string, proposals: GuardProposalFile): void;
@@ -9,7 +9,7 @@ import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findG
9
9
  import { config } from './config.js';
10
10
  import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
11
11
  import { analyzeProjectImpact } from './analysis/impact.js';
12
- import { guardAgentConfigSchema, guardConfigSchema, guardContractsFileSchema, guardProposalSchema, } from './policy/schemas.js';
12
+ import { guardAgentConfigSchema, guardConfigSchema, guardContractsFileSchema, guardProposalSchema, guardWaiversFileSchema, } from './policy/schemas.js';
13
13
  export function classifyGuardOutcome(input) {
14
14
  if (input.configured === false)
15
15
  return 'not-configured';
@@ -23,6 +23,7 @@ export const GUARD_DIR = `.${config.appName}/guard`;
23
23
  export const GUARD_RULES_FILE = `${GUARD_DIR}/rules.json`;
24
24
  export const GUARD_BASELINE_FILE = `${GUARD_DIR}/baseline.json`;
25
25
  export const GUARD_BASELINE_META_FILE = `${GUARD_DIR}/baseline-meta.json`;
26
+ export const GUARD_WAIVERS_FILE = `${GUARD_DIR}/waivers.json`;
26
27
  export const GUARD_PROJECT_FILE = `${GUARD_DIR}/project.json`;
27
28
  export const GUARD_HISTORY_DIR = `${GUARD_DIR}/history`;
28
29
  export const GUARD_ARCHITECTURE_FILE = `${GUARD_DIR}/architecture.json`;
@@ -44,6 +45,7 @@ export const defaultGuardConfig = {
44
45
  allowMcpApproval: true,
45
46
  requireDistinctActor: false,
46
47
  },
48
+ waiverPolicy: { warningDays: 14 },
47
49
  };
48
50
  export const defaultGuardAgentConfig = {
49
51
  version: 1,
@@ -86,6 +88,12 @@ export class GuardBaselineReasonError extends Error {
86
88
  this.name = 'GuardBaselineReasonError';
87
89
  }
88
90
  }
91
+ export class GuardWaiverError extends Error {
92
+ constructor(message) {
93
+ super(message);
94
+ this.name = 'GuardWaiverError';
95
+ }
96
+ }
89
97
  const activeStateLocks = new Map();
90
98
  function processIsAlive(pid) {
91
99
  try {
@@ -429,6 +437,119 @@ export function loadBaseline(root) {
429
437
  throw new GuardConfigError(GUARD_BASELINE_FILE);
430
438
  return new Set(value.filter((item) => typeof item === 'string'));
431
439
  }
440
+ function validateGuardWaiversFile(value) {
441
+ const ids = new Set();
442
+ const fingerprints = new Set();
443
+ for (const waiver of value.waivers) {
444
+ if (ids.has(waiver.id))
445
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
446
+ if (fingerprints.has(waiver.fingerprint))
447
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
448
+ if (Date.parse(waiver.expiresAt) <= Date.parse(waiver.createdAt)) {
449
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
450
+ }
451
+ ids.add(waiver.id);
452
+ fingerprints.add(waiver.fingerprint);
453
+ }
454
+ }
455
+ function assertWaiverLifetime(expiresAt, maxLifetimeDays) {
456
+ if (maxLifetimeDays === undefined)
457
+ return;
458
+ const lifetime = Date.parse(expiresAt) - Date.now();
459
+ if (lifetime > maxLifetimeDays * 24 * 60 * 60 * 1000) {
460
+ throw new GuardWaiverError(`A waiver may not exceed the configured maximum lifetime of ${maxLifetimeDays} day(s).`);
461
+ }
462
+ }
463
+ function readGuardWaiversUnlocked(root) {
464
+ const path = resolve(root, GUARD_WAIVERS_FILE);
465
+ if (!existsSync(path))
466
+ return { version: 1, waivers: [], decisions: [] };
467
+ const parsed = guardWaiversFileSchema.safeParse(readJson(path));
468
+ if (!parsed.success)
469
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
470
+ validateGuardWaiversFile(parsed.data);
471
+ return parsed.data;
472
+ }
473
+ export function loadGuardWaivers(root) {
474
+ if (!existsSync(resolve(root, GUARD_DIR)))
475
+ return readGuardWaiversUnlocked(root).waivers;
476
+ return withGuardStateLock(root, () => readGuardWaiversUnlocked(root).waivers);
477
+ }
478
+ export function writeGuardWaivers(root) {
479
+ withGuardStateLock(root, () => {
480
+ const file = { version: 1, waivers: [], decisions: [] };
481
+ atomicWriteFile(resolve(root, GUARD_WAIVERS_FILE), `${JSON.stringify(file, null, 2)}\n`);
482
+ });
483
+ }
484
+ export function updateGuardWaivers(root, options) {
485
+ if (!options.reason.trim())
486
+ throw new GuardWaiverError('A reason is required for waiver changes.');
487
+ return withGuardStateLock(root, () => {
488
+ const current = readGuardWaiversUnlocked(root);
489
+ const index = current.waivers.findIndex((waiver) => waiver.id === options.waiverId);
490
+ if (options.action === 'add') {
491
+ if (!options.waiver)
492
+ throw new GuardWaiverError('A waiver is required when adding one.');
493
+ if (index >= 0 ||
494
+ current.waivers.some((waiver) => waiver.fingerprint === options.waiver?.fingerprint)) {
495
+ throw new GuardWaiverError('A waiver already exists for this id or fingerprint.');
496
+ }
497
+ if (Date.parse(options.waiver.expiresAt) <= Date.now()) {
498
+ throw new GuardWaiverError('A waiver must expire in the future.');
499
+ }
500
+ if (Date.parse(options.waiver.expiresAt) <= Date.parse(options.waiver.createdAt)) {
501
+ throw new GuardWaiverError('A waiver must expire after it is created.');
502
+ }
503
+ assertWaiverLifetime(options.waiver.expiresAt, options.maxLifetimeDays);
504
+ const candidate = guardWaiversFileSchema.safeParse({
505
+ version: 1,
506
+ waivers: [...current.waivers, options.waiver],
507
+ decisions: current.decisions,
508
+ });
509
+ if (!candidate.success)
510
+ throw new GuardWaiverError('The waiver data is invalid.');
511
+ try {
512
+ validateGuardWaiversFile(candidate.data);
513
+ }
514
+ catch {
515
+ throw new GuardWaiverError('The waiver data is invalid.');
516
+ }
517
+ current.waivers.push(options.waiver);
518
+ }
519
+ else {
520
+ if (index < 0)
521
+ throw new GuardWaiverError(`Waiver not found: ${options.waiverId}`);
522
+ if (options.action === 'remove')
523
+ current.waivers.splice(index, 1);
524
+ else {
525
+ if (!options.expiresAt || Date.parse(options.expiresAt) <= Date.now()) {
526
+ throw new GuardWaiverError('A renewed waiver must expire in the future.');
527
+ }
528
+ assertWaiverLifetime(options.expiresAt, options.maxLifetimeDays);
529
+ current.waivers[index] = { ...current.waivers[index], expiresAt: options.expiresAt };
530
+ }
531
+ }
532
+ const decision = {
533
+ at: new Date().toISOString(),
534
+ action: options.action,
535
+ waiverId: options.waiverId,
536
+ reason: options.reason.trim(),
537
+ ...(options.actor?.trim() ? { actor: options.actor.trim() } : {}),
538
+ };
539
+ current.decisions = [...current.decisions, decision].slice(-500);
540
+ const validated = guardWaiversFileSchema.safeParse(current);
541
+ if (!validated.success)
542
+ throw new GuardWaiverError('The waiver data is invalid.');
543
+ try {
544
+ validateGuardWaiversFile(validated.data);
545
+ }
546
+ catch {
547
+ throw new GuardWaiverError('The waiver data is invalid.');
548
+ }
549
+ atomicWriteFile(resolve(root, GUARD_WAIVERS_FILE), `${JSON.stringify(current, null, 2)}\n`);
550
+ return current.waivers;
551
+ });
552
+ }
432
553
  export function updateBaseline(root, options = {}) {
433
554
  if ((options.add?.length ?? 0) > 0 || (options.remove?.length ?? 0) > 0) {
434
555
  if (!options.reason?.trim())
@@ -1538,12 +1659,45 @@ export function scanGuard(root, guardConfig, options = {}) {
1538
1659
  ...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
1539
1660
  ];
1540
1661
  const baseline = options.baseline ?? new Set();
1541
- const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
1662
+ const waivers = options.waivers ?? loadGuardWaivers(root);
1663
+ if (options.waivers) {
1664
+ const parsedWaivers = guardWaiversFileSchema.safeParse({
1665
+ version: 1,
1666
+ waivers,
1667
+ decisions: [],
1668
+ });
1669
+ if (!parsedWaivers.success)
1670
+ throw new GuardWaiverError('The waiver data is invalid.');
1671
+ try {
1672
+ validateGuardWaiversFile(parsedWaivers.data);
1673
+ }
1674
+ catch {
1675
+ throw new GuardWaiverError('The waiver data is invalid.');
1676
+ }
1677
+ }
1678
+ const nowValue = options.now ?? new Date().toISOString();
1679
+ const now = Date.parse(nowValue);
1680
+ if (!Number.isFinite(now))
1681
+ throw new GuardWaiverError(`Invalid Guard time: ${nowValue}`);
1682
+ const activeWaivers = new Map(waivers
1683
+ .filter((waiver) => Date.parse(waiver.expiresAt) > now)
1684
+ .map((waiver) => [waiver.fingerprint, waiver]));
1685
+ const warningDays = guardConfig.waiverPolicy?.warningDays ?? 14;
1686
+ const warningLimit = now + warningDays * 24 * 60 * 60 * 1000;
1687
+ const currentFingerprints = new Set(allFindings.map((finding) => finding.fingerprint));
1688
+ const relevantWaivers = waivers.filter((waiver) => currentFingerprints.has(waiver.fingerprint));
1689
+ const waiverWarnings = relevantWaivers.filter((waiver) => Date.parse(waiver.expiresAt) > now && Date.parse(waiver.expiresAt) <= warningLimit);
1690
+ const expiredWaivers = relevantWaivers.filter((waiver) => Date.parse(waiver.expiresAt) <= now);
1691
+ const baselineFindings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
1692
+ const findings = baselineFindings.filter((finding) => !activeWaivers.has(finding.fingerprint));
1542
1693
  return {
1543
1694
  configured: true,
1544
1695
  findings,
1545
1696
  suppressed: allFindings.length - findings.length,
1546
1697
  scannedFiles: files.length,
1698
+ waived: baselineFindings.length - findings.length,
1699
+ waiverWarnings,
1700
+ expiredWaivers,
1547
1701
  };
1548
1702
  }
1549
1703
  export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(), maxDiffChars = 120_000, changedOnly = true, requirement, base) {
@@ -1570,6 +1724,9 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
1570
1724
  contracts: guardConfig.contracts ?? [],
1571
1725
  ...(requirement ? { requirement } : {}),
1572
1726
  deterministicFindings: report.findings,
1727
+ waived: report.waived,
1728
+ waiverWarnings: report.waiverWarnings,
1729
+ expiredWaivers: report.expiredWaivers,
1573
1730
  impact,
1574
1731
  reviewInstructions: [
1575
1732
  ...(requirement
@@ -1595,6 +1752,9 @@ export function initializeGuard(root, options = {}) {
1595
1752
  const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
1596
1753
  ensureGeneratedStateIgnored(root, projectModel);
1597
1754
  writeGuardConfig(root, guardConfig);
1755
+ if (options.force || !existsSync(resolve(root, GUARD_WAIVERS_FILE))) {
1756
+ writeGuardWaivers(root);
1757
+ }
1598
1758
  if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
1599
1759
  writeGuardAgentConfig(root);
1600
1760
  }
@@ -5,6 +5,28 @@ export type GuardRuleStatus = 'active' | 'proposed';
5
5
  export type GuardContractKind = 'guidance' | 'import-boundary' | 'required-call' | 'package-boundary';
6
6
  export type GuardBudgetMetric = 'lines' | 'bytes' | 'imports';
7
7
  export type GuardToolMode = 'auto' | 'on' | 'off';
8
+ export interface GuardWaiver {
9
+ id: string;
10
+ fingerprint: string;
11
+ ruleId: string;
12
+ owner: string;
13
+ reason: string;
14
+ createdAt: string;
15
+ expiresAt: string;
16
+ issue?: string | undefined;
17
+ approvedBy?: string | undefined;
18
+ }
19
+ export interface GuardWaiverPolicy {
20
+ warningDays: number;
21
+ maxDays?: number | undefined;
22
+ }
23
+ export interface GuardWaiverDecision {
24
+ at: string;
25
+ action: 'add' | 'renew' | 'remove';
26
+ waiverId: string;
27
+ reason: string;
28
+ actor?: string | undefined;
29
+ }
8
30
  export type GuardApprovalMode = 'local' | 'protected';
9
31
  export type GuardAdapterName = 'dependency-graph' | 'security' | 'dependency-hygiene' | 'sast' | 'secret-scanning' | 'dependency-audit';
10
32
  export type ProjectCheck = 'lint' | 'typecheck' | 'test' | 'build';
@@ -1,11 +1,16 @@
1
1
  import type { GuardFinding } from '../guard.js';
2
+ export interface GuardSarifNotice {
3
+ ruleId: string;
4
+ level: 'error' | 'warning' | 'note';
5
+ message: string;
6
+ }
2
7
  interface SarifResult {
3
8
  ruleId: string;
4
9
  level: 'error' | 'warning' | 'note';
5
10
  message: {
6
11
  text: string;
7
12
  };
8
- locations: [
13
+ locations?: [
9
14
  {
10
15
  physicalLocation: {
11
16
  artifactLocation: {
@@ -31,5 +36,5 @@ export interface SarifLog {
31
36
  results: SarifResult[];
32
37
  }];
33
38
  }
34
- export declare function guardFindingsToSarif(findings: GuardFinding[]): SarifLog;
39
+ export declare function guardFindingsToSarif(findings: GuardFinding[], notices?: GuardSarifNotice[]): SarifLog;
35
40
  export {};
@@ -1,4 +1,25 @@
1
- export function guardFindingsToSarif(findings) {
1
+ export function guardFindingsToSarif(findings, notices = []) {
2
+ const findingResults = findings.map((finding) => ({
3
+ ruleId: finding.ruleId,
4
+ level: finding.severity === 'info' ? 'note' : finding.severity,
5
+ message: { text: finding.message },
6
+ locations: [
7
+ {
8
+ physicalLocation: {
9
+ artifactLocation: { uri: finding.file },
10
+ region: { startLine: finding.line },
11
+ },
12
+ },
13
+ ],
14
+ }));
15
+ const results = [
16
+ ...findingResults,
17
+ ...notices.map((notice) => ({
18
+ ruleId: notice.ruleId,
19
+ level: notice.level,
20
+ message: { text: notice.message },
21
+ })),
22
+ ];
2
23
  return {
3
24
  version: '2.1.0',
4
25
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
@@ -10,19 +31,7 @@ export function guardFindingsToSarif(findings) {
10
31
  informationUri: 'https://codapult.dev/docs/developer-tools/guard',
11
32
  },
12
33
  },
13
- results: findings.map((finding) => ({
14
- ruleId: finding.ruleId,
15
- level: finding.severity === 'info' ? 'note' : finding.severity,
16
- message: { text: finding.message },
17
- locations: [
18
- {
19
- physicalLocation: {
20
- artifactLocation: { uri: finding.file },
21
- region: { startLine: finding.line },
22
- },
23
- },
24
- ],
25
- })),
34
+ results,
26
35
  },
27
36
  ],
28
37
  };
@@ -1,4 +1,51 @@
1
1
  import { z } from 'zod';
2
+ export declare const guardWaiverSchema: z.ZodObject<{
3
+ id: z.ZodString;
4
+ fingerprint: z.ZodString;
5
+ ruleId: z.ZodString;
6
+ owner: z.ZodString;
7
+ reason: z.ZodString;
8
+ createdAt: z.ZodISODateTime;
9
+ expiresAt: z.ZodISODateTime;
10
+ issue: z.ZodOptional<z.ZodURL>;
11
+ approvedBy: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$strip>;
13
+ export declare const guardWaiverDecisionSchema: z.ZodObject<{
14
+ at: z.ZodISODateTime;
15
+ action: z.ZodEnum<{
16
+ add: "add";
17
+ remove: "remove";
18
+ renew: "renew";
19
+ }>;
20
+ waiverId: z.ZodString;
21
+ reason: z.ZodString;
22
+ actor: z.ZodOptional<z.ZodString>;
23
+ }, z.core.$strip>;
24
+ export declare const guardWaiversFileSchema: z.ZodObject<{
25
+ version: z.ZodLiteral<1>;
26
+ waivers: z.ZodArray<z.ZodObject<{
27
+ id: z.ZodString;
28
+ fingerprint: z.ZodString;
29
+ ruleId: z.ZodString;
30
+ owner: z.ZodString;
31
+ reason: z.ZodString;
32
+ createdAt: z.ZodISODateTime;
33
+ expiresAt: z.ZodISODateTime;
34
+ issue: z.ZodOptional<z.ZodURL>;
35
+ approvedBy: z.ZodOptional<z.ZodString>;
36
+ }, z.core.$strip>>;
37
+ decisions: z.ZodArray<z.ZodObject<{
38
+ at: z.ZodISODateTime;
39
+ action: z.ZodEnum<{
40
+ add: "add";
41
+ remove: "remove";
42
+ renew: "renew";
43
+ }>;
44
+ waiverId: z.ZodString;
45
+ reason: z.ZodString;
46
+ actor: z.ZodOptional<z.ZodString>;
47
+ }, z.core.$strip>>;
48
+ }, z.core.$strip>;
2
49
  export declare const guardBudgetSchema: z.ZodObject<{
3
50
  id: z.ZodString;
4
51
  description: z.ZodString;
@@ -175,6 +222,10 @@ export declare const guardConfigSchema: z.ZodObject<{
175
222
  allowMcpApproval: z.ZodBoolean;
176
223
  requireDistinctActor: z.ZodBoolean;
177
224
  }, z.core.$strip>>;
225
+ waiverPolicy: z.ZodOptional<z.ZodObject<{
226
+ warningDays: z.ZodNumber;
227
+ maxDays: z.ZodOptional<z.ZodNumber>;
228
+ }, z.core.$strip>>;
178
229
  }, z.core.$strip>;
179
230
  export declare const guardContractsFileSchema: z.ZodObject<{
180
231
  version: z.ZodLiteral<1>;
@@ -1,6 +1,29 @@
1
1
  import { z } from 'zod';
2
2
  const severity = z.enum(['error', 'warning', 'info']);
3
3
  const status = z.enum(['active', 'proposed']);
4
+ export const guardWaiverSchema = z.object({
5
+ id: z.string().trim().min(1),
6
+ fingerprint: z.string().trim().min(1),
7
+ ruleId: z.string().trim().min(1),
8
+ owner: z.string().trim().min(1),
9
+ reason: z.string().trim().min(1),
10
+ createdAt: z.iso.datetime({ offset: true }),
11
+ expiresAt: z.iso.datetime({ offset: true }),
12
+ issue: z.url().optional(),
13
+ approvedBy: z.string().trim().min(1).optional(),
14
+ });
15
+ export const guardWaiverDecisionSchema = z.object({
16
+ at: z.iso.datetime({ offset: true }),
17
+ action: z.enum(['add', 'renew', 'remove']),
18
+ waiverId: z.string().trim().min(1),
19
+ reason: z.string().trim().min(1),
20
+ actor: z.string().trim().min(1).optional(),
21
+ });
22
+ export const guardWaiversFileSchema = z.object({
23
+ version: z.literal(1),
24
+ waivers: z.array(guardWaiverSchema),
25
+ decisions: z.array(guardWaiverDecisionSchema),
26
+ });
4
27
  export const guardBudgetSchema = z.object({
5
28
  id: z.string().trim().min(1),
6
29
  description: z.string().trim().min(1),
@@ -56,6 +79,16 @@ export const guardConfigSchema = z.object({
56
79
  requireDistinctActor: z.boolean(),
57
80
  })
58
81
  .optional(),
82
+ waiverPolicy: z
83
+ .object({
84
+ warningDays: z.number().int().min(0).max(3650),
85
+ maxDays: z.number().int().positive().max(3650).optional(),
86
+ })
87
+ .refine((policy) => policy.maxDays === undefined || policy.maxDays >= policy.warningDays, {
88
+ message: 'waiverPolicy.maxDays must be greater than or equal to warningDays',
89
+ path: ['maxDays'],
90
+ })
91
+ .optional(),
59
92
  });
60
93
  export const guardContractsFileSchema = z.object({
61
94
  version: z.literal(1),
@@ -93,7 +93,8 @@ export function runGuardVerification(root, options = {}) {
93
93
  const missingRequiredTools = options.strict &&
94
94
  [...Object.values(results), ...Object.values(adapters)].some((result) => result.status === 'not-configured');
95
95
  const architectureFailed = architecture.findings.some((finding) => finding.severity === 'error');
96
- const architectureWarnings = architecture.findings.some((finding) => finding.severity === 'warning');
96
+ const architectureWarnings = architecture.findings.some((finding) => finding.severity === 'warning') ||
97
+ (architecture.waiverWarnings ?? []).length > 0;
97
98
  const failed = (projectChecks && !runtime.compatible) ||
98
99
  commandFailed ||
99
100
  workspaceCommandFailed ||
@@ -1,4 +1,4 @@
1
- import { buildArchitectureMemory, buildConventionsMemory, discoverProject, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadGuardAgentConfig, loadGuardArtifact, loadGuardConfig, loadGuardProposals, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
1
+ import { buildArchitectureMemory, buildConventionsMemory, discoverProject, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadGuardAgentConfig, loadGuardArtifact, loadGuardConfig, loadGuardProposals, loadGuardWaivers, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
2
2
  import { guardErrorPayload } from '../core/errors.js';
3
3
  import { DiscoveryStaleError } from '../core/discovery/discovery.js';
4
4
  import { listGuardRuns, summarizeGuardRuns } from '../core/history/runs.js';
@@ -97,6 +97,23 @@ export function registerGuardResources(server) {
97
97
  return resourceError('codapult://guard/architecture', error);
98
98
  }
99
99
  });
100
+ server.registerResource('codapult_guard_waivers', 'codapult://guard/waivers', {
101
+ title: 'Guard Waivers',
102
+ description: 'Active and expired time-bounded exceptions to Guard findings.',
103
+ mimeType: 'application/json',
104
+ }, () => {
105
+ const root = findGuardRoot();
106
+ try {
107
+ return jsonResource('codapult://guard/waivers', {
108
+ version: 1,
109
+ root,
110
+ waivers: loadGuardWaivers(root),
111
+ });
112
+ }
113
+ catch (error) {
114
+ return resourceError('codapult://guard/waivers', error);
115
+ }
116
+ });
100
117
  server.registerResource('codapult_guard_proposals', 'codapult://guard/proposals', {
101
118
  title: 'Architecture Guard Proposals',
102
119
  description: 'Evidence-backed rules and contracts awaiting review.',
@@ -1,4 +1,5 @@
1
- import { buildArchitectureMemory, buildConventionsMemory, buildGeneratedGuardConfig, buildGuardProposals, buildGuardReviewPacket, discoverProject, discoverProjectWithMetrics, fingerprintProjectModel, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadBaseline, loadGuardArtifact, loadGuardConfig, loadGuardAgentConfig, loadGuardProposals, getPendingGuardProposals, getGuardProposalFreshness, loadProjectModel, writeGuardProposals, initializeGuard, GuardAlreadyInitializedError, GuardStateBusyError, GuardStateStaleError, applyGuardProposalDecision, validateGuardProposalApproval, writeProjectState, scanGuard, validateGuardPolicy, classifyGuardOutcome, } from '../../core/guard.js';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { buildArchitectureMemory, buildConventionsMemory, buildGeneratedGuardConfig, buildGuardProposals, buildGuardReviewPacket, discoverProject, discoverProjectWithMetrics, fingerprintProjectModel, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadBaseline, loadGuardArtifact, loadGuardConfig, loadGuardAgentConfig, loadGuardProposals, loadGuardWaivers, getPendingGuardProposals, getGuardProposalFreshness, loadProjectModel, writeGuardProposals, initializeGuard, GuardAlreadyInitializedError, GuardStateBusyError, GuardStateStaleError, applyGuardProposalDecision, validateGuardProposalApproval, writeProjectState, scanGuard, updateGuardWaivers, validateGuardPolicy, classifyGuardOutcome, } from '../../core/guard.js';
2
3
  import { guardErrorPayload } from '../../core/errors.js';
3
4
  import { analyzeProjectImpact } from '../../core/analysis/impact.js';
4
5
  import { z } from 'zod';
@@ -13,6 +14,12 @@ function jsonToolResult(value, isError = false) {
13
14
  ...(isError ? { isError: true } : {}),
14
15
  };
15
16
  }
17
+ function waiverToolError(error) {
18
+ return jsonToolResult({
19
+ status: 'invalid',
20
+ message: error instanceof Error ? error.message : 'Waiver update failed.',
21
+ }, true);
22
+ }
16
23
  function notConfiguredToolResult() {
17
24
  return jsonToolResult({
18
25
  status: 'error',
@@ -563,6 +570,110 @@ export function registerGuardTools(server) {
563
570
  ...report,
564
571
  }, errors > 0 || contractIssues.length > 0);
565
572
  });
573
+ server.registerTool('codapult_guard_waivers', {
574
+ title: 'Guard Waivers',
575
+ description: 'List and manage time-bounded, project-approved exceptions to active Guard findings.',
576
+ inputSchema: {
577
+ root: rootSchema,
578
+ action: z.enum(['list', 'add', 'remove', 'renew']).default('list'),
579
+ fingerprint: z.string().trim().min(1).optional(),
580
+ id: z.string().trim().min(1).optional(),
581
+ owner: z.string().trim().min(1).optional(),
582
+ reason: z.string().trim().min(1).max(2000).optional(),
583
+ expires_at: z.iso.datetime({ offset: true }).optional(),
584
+ issue: z.url().optional(),
585
+ confirm: z.boolean().default(false),
586
+ },
587
+ }, ({ root: requestedRoot, action, fingerprint, id, owner, reason, expires_at, issue, confirm, }) => {
588
+ const root = getGuardRoot(requestedRoot);
589
+ let waivers;
590
+ try {
591
+ waivers = loadGuardWaivers(root);
592
+ }
593
+ catch (error) {
594
+ return waiverToolError(error);
595
+ }
596
+ if (action === 'list')
597
+ return jsonToolResult({ status: 'ok', waivers });
598
+ const loaded = loadConfigSafely(root);
599
+ if (loaded.error)
600
+ return loaded.error;
601
+ if (loaded.config?.approval?.mode === 'protected' &&
602
+ !loaded.config.approval.allowMcpApproval) {
603
+ return jsonToolResult({
604
+ status: 'approval-required',
605
+ message: 'Protected Guard policy requires waiver approval outside the MCP agent surface.',
606
+ action,
607
+ }, true);
608
+ }
609
+ if (loaded.config?.approval?.mode === 'protected' &&
610
+ loaded.config.approval.requireDistinctActor &&
611
+ !process.env.GUARD_APPROVER?.trim()) {
612
+ return jsonToolResult({
613
+ status: 'approval-required',
614
+ message: 'Protected Guard policy requires GUARD_APPROVER for waiver changes.',
615
+ action,
616
+ }, true);
617
+ }
618
+ if (!confirm)
619
+ return jsonToolResult({ status: 'needs-confirmation', action });
620
+ if (!reason)
621
+ return jsonToolResult({ status: 'invalid', message: 'reason is required.' }, true);
622
+ if (action === 'add') {
623
+ if (!fingerprint || !owner || !expires_at) {
624
+ return jsonToolResult({ status: 'invalid', message: 'fingerprint, owner, and expires_at are required.' }, true);
625
+ }
626
+ const finding = scanGuard(root, loaded.config ?? { version: 1, rules: [] }, {
627
+ waivers: [],
628
+ includeArchitectureInsights: true,
629
+ }).findings.find((candidate) => candidate.fingerprint === fingerprint);
630
+ if (!finding)
631
+ return jsonToolResult({ status: 'not-found', fingerprint }, true);
632
+ const waiver = {
633
+ id: `waiver-${randomUUID()}`,
634
+ fingerprint,
635
+ ruleId: finding.ruleId,
636
+ owner,
637
+ reason,
638
+ createdAt: new Date().toISOString(),
639
+ expiresAt: expires_at,
640
+ ...(issue ? { issue } : {}),
641
+ ...(process.env.GUARD_APPROVER?.trim()
642
+ ? { approvedBy: process.env.GUARD_APPROVER.trim() }
643
+ : {}),
644
+ };
645
+ try {
646
+ updateGuardWaivers(root, {
647
+ action,
648
+ waiverId: waiver.id,
649
+ waiver,
650
+ reason,
651
+ actor: process.env.GUARD_APPROVER,
652
+ maxLifetimeDays: loaded.config?.waiverPolicy?.maxDays,
653
+ });
654
+ }
655
+ catch (error) {
656
+ return waiverToolError(error);
657
+ }
658
+ return jsonToolResult({ status: 'ok', action, waiver });
659
+ }
660
+ if (!id)
661
+ return jsonToolResult({ status: 'invalid', message: 'id is required.' }, true);
662
+ try {
663
+ updateGuardWaivers(root, {
664
+ action,
665
+ waiverId: id,
666
+ ...(expires_at ? { expiresAt: expires_at } : {}),
667
+ reason,
668
+ actor: process.env.GUARD_APPROVER,
669
+ maxLifetimeDays: loaded.config?.waiverPolicy?.maxDays,
670
+ });
671
+ }
672
+ catch (error) {
673
+ return waiverToolError(error);
674
+ }
675
+ return jsonToolResult({ status: 'ok', action, id });
676
+ });
566
677
  server.registerTool('codapult_guard_impact', {
567
678
  title: 'Guard Impact Analysis',
568
679
  description: 'Explain the project impact of one or more changed files using the discovered dependency graph, routes, capabilities, and contracts.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codapult/guard",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",
@@ -57,6 +57,7 @@
57
57
  "release:check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build && npm pack --dry-run && pnpm mcp:validate",
58
58
  "mcp:validate": "node scripts/mcp-registry.mjs validate",
59
59
  "mcp:sync": "node scripts/mcp-registry.mjs sync",
60
+ "mcp:wait": "node scripts/mcp-registry.mjs wait",
60
61
  "test": "vitest run",
61
62
  "test:coverage": "vitest run --coverage",
62
63
  "test:guard:coverage": "vitest run src/core src/adapters/project-checks.test.ts --coverage --coverage.include='src/core/**/*.ts' --coverage.include='src/adapters/project-checks.ts'",