@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.
@@ -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 {
@@ -263,7 +271,9 @@ function atomicWriteFile(path, content) {
263
271
  }
264
272
  function writeGuardArtifactUnlocked(root, relativePath, value) {
265
273
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
266
- atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
274
+ const path = resolve(root, relativePath);
275
+ mkdirSync(dirname(path), { recursive: true });
276
+ atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`);
267
277
  }
268
278
  function writeGuardArtifact(root, relativePath, value) {
269
279
  withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
@@ -300,16 +310,136 @@ export function isGuardProposalFile(value) {
300
310
  export function fingerprintProjectModel(model) {
301
311
  return createHash('sha256').update(JSON.stringify(model)).digest('hex');
302
312
  }
313
+ function stableSerialize(value) {
314
+ if (Array.isArray(value)) {
315
+ return `[${value.map((item) => stableSerialize(item)).join(',')}]`;
316
+ }
317
+ if (value !== null && typeof value === 'object') {
318
+ const record = value;
319
+ return `{${Object.keys(record)
320
+ .sort()
321
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`)
322
+ .join(',')}}`;
323
+ }
324
+ return JSON.stringify(value) ?? 'null';
325
+ }
303
326
  export function fingerprintGuardConfig(guardConfig) {
304
327
  return createHash('sha256')
305
- .update(JSON.stringify({
306
- rules: guardConfig.rules,
307
- contracts: guardConfig.contracts ?? [],
308
- budgets: guardConfig.budgets ?? [],
328
+ .update(stableSerialize({
329
+ rules: [...guardConfig.rules].sort((left, right) => left.id.localeCompare(right.id)),
330
+ contracts: [...(guardConfig.contracts ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
331
+ budgets: [...(guardConfig.budgets ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
309
332
  approval: guardConfig.approval ?? defaultGuardConfig.approval,
310
333
  }))
311
334
  .digest('hex');
312
335
  }
336
+ /**
337
+ * Fingerprints the effective policy used by a verification run.
338
+ *
339
+ * The rules revision alone is not sufficient: baseline and waiver changes also
340
+ * affect the verdict. Keeping them in this fingerprint makes a run auditable
341
+ * without pretending that a numeric revision covers every policy artifact.
342
+ */
343
+ export function fingerprintGuardPolicy(guardConfig, baseline = new Set(), waivers = [], agentConfig = defaultGuardAgentConfig) {
344
+ return createHash('sha256')
345
+ .update(stableSerialize({
346
+ config: fingerprintGuardConfig(guardConfig),
347
+ agentConfig,
348
+ baseline: [...baseline].sort(),
349
+ waivers: [...waivers].sort((left, right) => left.id.localeCompare(right.id)),
350
+ }))
351
+ .digest('hex');
352
+ }
353
+ export function createGuardPolicySnapshot(guardConfig, baseline, waivers, source = 'working-tree', ref, agentConfig = defaultGuardAgentConfig) {
354
+ return {
355
+ config: guardConfig,
356
+ agentConfig,
357
+ baseline,
358
+ waivers,
359
+ ...(typeof guardConfig.revision === 'number' ? { revision: guardConfig.revision } : {}),
360
+ fingerprint: fingerprintGuardPolicy(guardConfig, baseline, waivers, agentConfig),
361
+ source,
362
+ ...(ref ? { ref } : {}),
363
+ };
364
+ }
365
+ function readGitFileAtRevision(root, revision, relativePath) {
366
+ try {
367
+ return execFileSync('git', ['show', `${revision}:${relativePath}`], {
368
+ cwd: root,
369
+ stdio: 'pipe',
370
+ maxBuffer: 4_000_000,
371
+ }).toString();
372
+ }
373
+ catch {
374
+ return undefined;
375
+ }
376
+ }
377
+ function parseGitJsonAtRevision(root, revision, relativePath) {
378
+ const text = readGitFileAtRevision(root, revision, relativePath);
379
+ if (text === undefined)
380
+ return undefined;
381
+ try {
382
+ return JSON.parse(text);
383
+ }
384
+ catch {
385
+ throw new GuardConfigError(`${relativePath} at ${revision}`);
386
+ }
387
+ }
388
+ /**
389
+ * Loads policy artifacts from a trusted Git ref instead of the checked-out
390
+ * working tree. This is intended for CI verification of untrusted branches.
391
+ */
392
+ export function loadGuardPolicyAtRevision(root, revision) {
393
+ if (!/^[\w./@-]+$/.test(revision) || revision.startsWith('-')) {
394
+ throw new GuardConfigError(`unsafe policy ref: ${revision}`);
395
+ }
396
+ const rulesValue = parseGitJsonAtRevision(root, revision, GUARD_RULES_FILE);
397
+ if (rulesValue === undefined || !isGuardConfig(rulesValue)) {
398
+ if (rulesValue === undefined)
399
+ return undefined;
400
+ throw new GuardConfigError(`${GUARD_RULES_FILE} at ${revision}`);
401
+ }
402
+ const contractsValue = parseGitJsonAtRevision(root, revision, GUARD_CONTRACTS_FILE);
403
+ if (contractsValue !== undefined && !isGuardContractsFile(contractsValue)) {
404
+ throw new GuardConfigError(`${GUARD_CONTRACTS_FILE} at ${revision}`);
405
+ }
406
+ const guardConfig = {
407
+ ...rulesValue,
408
+ contracts: contractsValue ? contractsValue.contracts : (rulesValue.contracts ?? []),
409
+ budgets: rulesValue.budgets ?? [],
410
+ approval: rulesValue.approval ?? defaultGuardConfig.approval,
411
+ };
412
+ const agentValue = parseGitJsonAtRevision(root, revision, GUARD_AGENT_FILE);
413
+ const agentConfig = agentValue === undefined
414
+ ? defaultGuardAgentConfig
415
+ : // eslint-disable-next-line @typescript-eslint/no-use-before-define
416
+ isGuardAgentConfig(agentValue)
417
+ ? agentValue
418
+ : (() => {
419
+ throw new GuardConfigError(`${GUARD_AGENT_FILE} at ${revision}`);
420
+ })();
421
+ const baselineValue = parseGitJsonAtRevision(root, revision, GUARD_BASELINE_FILE);
422
+ const baselineEntries = [];
423
+ if (baselineValue !== undefined) {
424
+ if (!Array.isArray(baselineValue)) {
425
+ throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
426
+ }
427
+ for (const item of baselineValue) {
428
+ if (typeof item !== 'string') {
429
+ throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
430
+ }
431
+ baselineEntries.push(item);
432
+ }
433
+ }
434
+ const waiversValue = parseGitJsonAtRevision(root, revision, GUARD_WAIVERS_FILE);
435
+ const parsedWaivers = guardWaiversFileSchema.safeParse(waiversValue ?? { version: 1, waivers: [], decisions: [] });
436
+ if (!parsedWaivers.success)
437
+ throw new GuardConfigError(`${GUARD_WAIVERS_FILE} at ${revision}`);
438
+ // The validator is declared with the waiver file model below the policy readers.
439
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
440
+ validateGuardWaiversFile(parsedWaivers.data);
441
+ return createGuardPolicySnapshot(guardConfig, new Set(baselineEntries), parsedWaivers.data.waivers, 'git-ref', revision, agentConfig);
442
+ }
313
443
  export function getGuardProposalFreshness(model, proposals) {
314
444
  if (!proposals?.projectFingerprint)
315
445
  return 'unknown';
@@ -429,6 +559,119 @@ export function loadBaseline(root) {
429
559
  throw new GuardConfigError(GUARD_BASELINE_FILE);
430
560
  return new Set(value.filter((item) => typeof item === 'string'));
431
561
  }
562
+ function validateGuardWaiversFile(value) {
563
+ const ids = new Set();
564
+ const fingerprints = new Set();
565
+ for (const waiver of value.waivers) {
566
+ if (ids.has(waiver.id))
567
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
568
+ if (fingerprints.has(waiver.fingerprint))
569
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
570
+ if (Date.parse(waiver.expiresAt) <= Date.parse(waiver.createdAt)) {
571
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
572
+ }
573
+ ids.add(waiver.id);
574
+ fingerprints.add(waiver.fingerprint);
575
+ }
576
+ }
577
+ function assertWaiverLifetime(expiresAt, maxLifetimeDays) {
578
+ if (maxLifetimeDays === undefined)
579
+ return;
580
+ const lifetime = Date.parse(expiresAt) - Date.now();
581
+ if (lifetime > maxLifetimeDays * 24 * 60 * 60 * 1000) {
582
+ throw new GuardWaiverError(`A waiver may not exceed the configured maximum lifetime of ${maxLifetimeDays} day(s).`);
583
+ }
584
+ }
585
+ function readGuardWaiversUnlocked(root) {
586
+ const path = resolve(root, GUARD_WAIVERS_FILE);
587
+ if (!existsSync(path))
588
+ return { version: 1, waivers: [], decisions: [] };
589
+ const parsed = guardWaiversFileSchema.safeParse(readJson(path));
590
+ if (!parsed.success)
591
+ throw new GuardConfigError(GUARD_WAIVERS_FILE);
592
+ validateGuardWaiversFile(parsed.data);
593
+ return parsed.data;
594
+ }
595
+ export function loadGuardWaivers(root) {
596
+ if (!existsSync(resolve(root, GUARD_DIR)))
597
+ return readGuardWaiversUnlocked(root).waivers;
598
+ return withGuardStateLock(root, () => readGuardWaiversUnlocked(root).waivers);
599
+ }
600
+ export function writeGuardWaivers(root) {
601
+ withGuardStateLock(root, () => {
602
+ const file = { version: 1, waivers: [], decisions: [] };
603
+ atomicWriteFile(resolve(root, GUARD_WAIVERS_FILE), `${JSON.stringify(file, null, 2)}\n`);
604
+ });
605
+ }
606
+ export function updateGuardWaivers(root, options) {
607
+ if (!options.reason.trim())
608
+ throw new GuardWaiverError('A reason is required for waiver changes.');
609
+ return withGuardStateLock(root, () => {
610
+ const current = readGuardWaiversUnlocked(root);
611
+ const index = current.waivers.findIndex((waiver) => waiver.id === options.waiverId);
612
+ if (options.action === 'add') {
613
+ if (!options.waiver)
614
+ throw new GuardWaiverError('A waiver is required when adding one.');
615
+ if (index >= 0 ||
616
+ current.waivers.some((waiver) => waiver.fingerprint === options.waiver?.fingerprint)) {
617
+ throw new GuardWaiverError('A waiver already exists for this id or fingerprint.');
618
+ }
619
+ if (Date.parse(options.waiver.expiresAt) <= Date.now()) {
620
+ throw new GuardWaiverError('A waiver must expire in the future.');
621
+ }
622
+ if (Date.parse(options.waiver.expiresAt) <= Date.parse(options.waiver.createdAt)) {
623
+ throw new GuardWaiverError('A waiver must expire after it is created.');
624
+ }
625
+ assertWaiverLifetime(options.waiver.expiresAt, options.maxLifetimeDays);
626
+ const candidate = guardWaiversFileSchema.safeParse({
627
+ version: 1,
628
+ waivers: [...current.waivers, options.waiver],
629
+ decisions: current.decisions,
630
+ });
631
+ if (!candidate.success)
632
+ throw new GuardWaiverError('The waiver data is invalid.');
633
+ try {
634
+ validateGuardWaiversFile(candidate.data);
635
+ }
636
+ catch {
637
+ throw new GuardWaiverError('The waiver data is invalid.');
638
+ }
639
+ current.waivers.push(options.waiver);
640
+ }
641
+ else {
642
+ if (index < 0)
643
+ throw new GuardWaiverError(`Waiver not found: ${options.waiverId}`);
644
+ if (options.action === 'remove')
645
+ current.waivers.splice(index, 1);
646
+ else {
647
+ if (!options.expiresAt || Date.parse(options.expiresAt) <= Date.now()) {
648
+ throw new GuardWaiverError('A renewed waiver must expire in the future.');
649
+ }
650
+ assertWaiverLifetime(options.expiresAt, options.maxLifetimeDays);
651
+ current.waivers[index] = { ...current.waivers[index], expiresAt: options.expiresAt };
652
+ }
653
+ }
654
+ const decision = {
655
+ at: new Date().toISOString(),
656
+ action: options.action,
657
+ waiverId: options.waiverId,
658
+ reason: options.reason.trim(),
659
+ ...(options.actor?.trim() ? { actor: options.actor.trim() } : {}),
660
+ };
661
+ current.decisions = [...current.decisions, decision].slice(-500);
662
+ const validated = guardWaiversFileSchema.safeParse(current);
663
+ if (!validated.success)
664
+ throw new GuardWaiverError('The waiver data is invalid.');
665
+ try {
666
+ validateGuardWaiversFile(validated.data);
667
+ }
668
+ catch {
669
+ throw new GuardWaiverError('The waiver data is invalid.');
670
+ }
671
+ atomicWriteFile(resolve(root, GUARD_WAIVERS_FILE), `${JSON.stringify(current, null, 2)}\n`);
672
+ return current.waivers;
673
+ });
674
+ }
432
675
  export function updateBaseline(root, options = {}) {
433
676
  if ((options.add?.length ?? 0) > 0 || (options.remove?.length ?? 0) > 0) {
434
677
  if (!options.reason?.trim())
@@ -486,15 +729,24 @@ function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
486
729
  return undefined;
487
730
  }
488
731
  })();
732
+ const currentRevision = readGuardConfigFilesUnlocked(root)?.revision ?? 0;
733
+ const policyFingerprint = options.resultingPolicy
734
+ ? fingerprintGuardPolicy(options.resultingPolicy, loadBaseline(root), readGuardWaiversUnlocked(root).waivers, loadGuardAgentConfig(root))
735
+ : undefined;
489
736
  const nextDecisions = decisions.map((decision) => ({
490
737
  ...decision,
738
+ decisionId: randomUUID(),
491
739
  decidedAt,
492
740
  source: options.source,
493
741
  ...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
494
742
  ...(commit ? { commit } : {}),
743
+ ...(proposals.generatedBy ? { proposalAuthor: proposals.generatedBy } : {}),
495
744
  ...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
496
745
  ...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
497
746
  ...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
747
+ ...(decision.decision === 'approved' && policyFingerprint
748
+ ? { policyFingerprint, policyRevision: currentRevision + 1 }
749
+ : {}),
498
750
  }));
499
751
  return {
500
752
  ...proposals,
@@ -514,7 +766,10 @@ export function recordGuardProposalDecision(root, decisions, options = { source:
514
766
  }
515
767
  export function applyGuardProposalDecision(root, guardConfig, decisions, options) {
516
768
  withGuardStateLock(root, () => {
517
- const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
769
+ const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, {
770
+ ...options,
771
+ resultingPolicy: guardConfig,
772
+ });
518
773
  writeGuardArtifactUnlocked(root, GUARD_POLICY_TRANSACTION_FILE, {
519
774
  version: 1,
520
775
  createdAt: new Date().toISOString(),
@@ -986,6 +1241,23 @@ export function validateGuardBudgets(root, budgets = []) {
986
1241
  }
987
1242
  export function validateGuardPolicy(root, guardConfig) {
988
1243
  const issues = [];
1244
+ const validateUniqueIds = (items, kind) => {
1245
+ const seen = new Set();
1246
+ for (const item of items) {
1247
+ if (seen.has(item.id)) {
1248
+ issues.push({
1249
+ contractId: `${kind}:${item.id}`,
1250
+ field: 'definition',
1251
+ value: item.id,
1252
+ message: `${kind} IDs must be unique: ${item.id}`,
1253
+ });
1254
+ }
1255
+ seen.add(item.id);
1256
+ }
1257
+ };
1258
+ validateUniqueIds(guardConfig.rules, 'rule');
1259
+ validateUniqueIds(guardConfig.contracts ?? [], 'contract');
1260
+ validateUniqueIds(guardConfig.budgets ?? [], 'budget');
989
1261
  for (const rule of guardConfig.rules) {
990
1262
  for (const file of rule.files ?? []) {
991
1263
  if (!isProjectPath(root, file)) {
@@ -1538,12 +1810,45 @@ export function scanGuard(root, guardConfig, options = {}) {
1538
1810
  ...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
1539
1811
  ];
1540
1812
  const baseline = options.baseline ?? new Set();
1541
- const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
1813
+ const waivers = options.waivers ?? loadGuardWaivers(root);
1814
+ if (options.waivers) {
1815
+ const parsedWaivers = guardWaiversFileSchema.safeParse({
1816
+ version: 1,
1817
+ waivers,
1818
+ decisions: [],
1819
+ });
1820
+ if (!parsedWaivers.success)
1821
+ throw new GuardWaiverError('The waiver data is invalid.');
1822
+ try {
1823
+ validateGuardWaiversFile(parsedWaivers.data);
1824
+ }
1825
+ catch {
1826
+ throw new GuardWaiverError('The waiver data is invalid.');
1827
+ }
1828
+ }
1829
+ const nowValue = options.now ?? new Date().toISOString();
1830
+ const now = Date.parse(nowValue);
1831
+ if (!Number.isFinite(now))
1832
+ throw new GuardWaiverError(`Invalid Guard time: ${nowValue}`);
1833
+ const activeWaivers = new Map(waivers
1834
+ .filter((waiver) => Date.parse(waiver.expiresAt) > now)
1835
+ .map((waiver) => [waiver.fingerprint, waiver]));
1836
+ const warningDays = guardConfig.waiverPolicy?.warningDays ?? 14;
1837
+ const warningLimit = now + warningDays * 24 * 60 * 60 * 1000;
1838
+ const currentFingerprints = new Set(allFindings.map((finding) => finding.fingerprint));
1839
+ const relevantWaivers = waivers.filter((waiver) => currentFingerprints.has(waiver.fingerprint));
1840
+ const waiverWarnings = relevantWaivers.filter((waiver) => Date.parse(waiver.expiresAt) > now && Date.parse(waiver.expiresAt) <= warningLimit);
1841
+ const expiredWaivers = relevantWaivers.filter((waiver) => Date.parse(waiver.expiresAt) <= now);
1842
+ const baselineFindings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
1843
+ const findings = baselineFindings.filter((finding) => !activeWaivers.has(finding.fingerprint));
1542
1844
  return {
1543
1845
  configured: true,
1544
1846
  findings,
1545
1847
  suppressed: allFindings.length - findings.length,
1546
1848
  scannedFiles: files.length,
1849
+ waived: baselineFindings.length - findings.length,
1850
+ waiverWarnings,
1851
+ expiredWaivers,
1547
1852
  };
1548
1853
  }
1549
1854
  export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(), maxDiffChars = 120_000, changedOnly = true, requirement, base) {
@@ -1570,6 +1875,9 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
1570
1875
  contracts: guardConfig.contracts ?? [],
1571
1876
  ...(requirement ? { requirement } : {}),
1572
1877
  deterministicFindings: report.findings,
1878
+ waived: report.waived,
1879
+ waiverWarnings: report.waiverWarnings,
1880
+ expiredWaivers: report.expiredWaivers,
1573
1881
  impact,
1574
1882
  reviewInstructions: [
1575
1883
  ...(requirement
@@ -1595,6 +1903,9 @@ export function initializeGuard(root, options = {}) {
1595
1903
  const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
1596
1904
  ensureGeneratedStateIgnored(root, projectModel);
1597
1905
  writeGuardConfig(root, guardConfig);
1906
+ if (options.force || !existsSync(resolve(root, GUARD_WAIVERS_FILE))) {
1907
+ writeGuardWaivers(root);
1908
+ }
1598
1909
  if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
1599
1910
  writeGuardAgentConfig(root);
1600
1911
  }
@@ -5,6 +5,14 @@ export interface GuardRunStage {
5
5
  status: 'ok' | 'fail' | 'skipped';
6
6
  detail?: string | undefined;
7
7
  }
8
+ export interface GuardRunPolicy {
9
+ revision?: number | undefined;
10
+ fingerprint: string;
11
+ source: 'working-tree' | 'git-ref';
12
+ ref?: string | undefined;
13
+ changedFromBase?: boolean | undefined;
14
+ decisionIds?: string[] | undefined;
15
+ }
8
16
  export interface GuardRunManifest {
9
17
  version: 1;
10
18
  runId: string;
@@ -16,6 +24,7 @@ export interface GuardRunManifest {
16
24
  gate: string;
17
25
  stages: Record<string, GuardRunStage>;
18
26
  commit?: string | undefined;
27
+ policy?: GuardRunPolicy | undefined;
19
28
  }
20
29
  export interface GuardRunContext {
21
30
  runId: string;
@@ -40,4 +49,4 @@ export declare function writeGuardRun(root: string, manifest: GuardRunManifest):
40
49
  /** Reads locally persisted run manifests without reaching a remote service. */
41
50
  export declare function listGuardRuns(root: string, limit?: number): GuardRunManifest[];
42
51
  export declare function summarizeGuardRuns(root: string, limit?: number): GuardRunSummary;
43
- export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string): GuardRunManifest;
52
+ export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string, policy?: GuardRunPolicy): GuardRunManifest;
@@ -33,7 +33,22 @@ function isGuardRunManifest(value) {
33
33
  manifest.outcome === 'not-configured') &&
34
34
  typeof manifest.gate === 'string' &&
35
35
  manifest.stages !== null &&
36
- typeof manifest.stages === 'object');
36
+ typeof manifest.stages === 'object' &&
37
+ (manifest.policy === undefined ||
38
+ (manifest.policy !== null &&
39
+ typeof manifest.policy === 'object' &&
40
+ typeof manifest.policy.fingerprint === 'string' &&
41
+ manifest.policy.fingerprint.length > 0 &&
42
+ (manifest.policy.source === 'working-tree' || manifest.policy.source === 'git-ref') &&
43
+ (manifest.policy.revision === undefined ||
44
+ (Number.isInteger(manifest.policy.revision) && manifest.policy.revision >= 0)) &&
45
+ (manifest.policy.ref === undefined || typeof manifest.policy.ref === 'string') &&
46
+ (manifest.policy.changedFromBase === undefined ||
47
+ typeof manifest.policy.changedFromBase === 'boolean') &&
48
+ (manifest.policy.decisionIds === undefined ||
49
+ (Array.isArray(manifest.policy.decisionIds) &&
50
+ new Set(manifest.policy.decisionIds).size === manifest.policy.decisionIds.length &&
51
+ manifest.policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0))))));
37
52
  }
38
53
  export function startGuardRun() {
39
54
  return {
@@ -122,7 +137,7 @@ export function summarizeGuardRuns(root, limit = 100) {
122
137
  ...(total > 0 ? { last: runs[0] } : {}),
123
138
  };
124
139
  }
125
- export function finishGuardRun(root, context, outcome, gate, command = 'verify') {
140
+ export function finishGuardRun(root, context, outcome, gate, command = 'verify', policy) {
126
141
  let commit;
127
142
  try {
128
143
  commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
@@ -143,6 +158,7 @@ export function finishGuardRun(root, context, outcome, gate, command = 'verify')
143
158
  gate,
144
159
  stages: context.stages,
145
160
  ...(commit ? { commit } : {}),
161
+ ...(policy ? { policy } : {}),
146
162
  };
147
163
  try {
148
164
  writeGuardRun(root, manifest);
@@ -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
  };