@codapult/guard 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -271,7 +271,9 @@ function atomicWriteFile(path, content) {
271
271
  }
272
272
  function writeGuardArtifactUnlocked(root, relativePath, value) {
273
273
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
274
- atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
274
+ const path = resolve(root, relativePath);
275
+ mkdirSync(dirname(path), { recursive: true });
276
+ atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`);
275
277
  }
276
278
  function writeGuardArtifact(root, relativePath, value) {
277
279
  withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
@@ -308,16 +310,136 @@ export function isGuardProposalFile(value) {
308
310
  export function fingerprintProjectModel(model) {
309
311
  return createHash('sha256').update(JSON.stringify(model)).digest('hex');
310
312
  }
313
+ function stableSerialize(value) {
314
+ if (Array.isArray(value)) {
315
+ return `[${value.map((item) => stableSerialize(item)).join(',')}]`;
316
+ }
317
+ if (value !== null && typeof value === 'object') {
318
+ const record = value;
319
+ return `{${Object.keys(record)
320
+ .sort()
321
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`)
322
+ .join(',')}}`;
323
+ }
324
+ return JSON.stringify(value) ?? 'null';
325
+ }
311
326
  export function fingerprintGuardConfig(guardConfig) {
312
327
  return createHash('sha256')
313
- .update(JSON.stringify({
314
- rules: guardConfig.rules,
315
- contracts: guardConfig.contracts ?? [],
316
- budgets: guardConfig.budgets ?? [],
328
+ .update(stableSerialize({
329
+ rules: [...guardConfig.rules].sort((left, right) => left.id.localeCompare(right.id)),
330
+ contracts: [...(guardConfig.contracts ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
331
+ budgets: [...(guardConfig.budgets ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
317
332
  approval: guardConfig.approval ?? defaultGuardConfig.approval,
318
333
  }))
319
334
  .digest('hex');
320
335
  }
336
+ /**
337
+ * Fingerprints the effective policy used by a verification run.
338
+ *
339
+ * The rules revision alone is not sufficient: baseline and waiver changes also
340
+ * affect the verdict. Keeping them in this fingerprint makes a run auditable
341
+ * without pretending that a numeric revision covers every policy artifact.
342
+ */
343
+ export function fingerprintGuardPolicy(guardConfig, baseline = new Set(), waivers = [], agentConfig = defaultGuardAgentConfig) {
344
+ return createHash('sha256')
345
+ .update(stableSerialize({
346
+ config: fingerprintGuardConfig(guardConfig),
347
+ agentConfig,
348
+ baseline: [...baseline].sort(),
349
+ waivers: [...waivers].sort((left, right) => left.id.localeCompare(right.id)),
350
+ }))
351
+ .digest('hex');
352
+ }
353
+ export function createGuardPolicySnapshot(guardConfig, baseline, waivers, source = 'working-tree', ref, agentConfig = defaultGuardAgentConfig) {
354
+ return {
355
+ config: guardConfig,
356
+ agentConfig,
357
+ baseline,
358
+ waivers,
359
+ ...(typeof guardConfig.revision === 'number' ? { revision: guardConfig.revision } : {}),
360
+ fingerprint: fingerprintGuardPolicy(guardConfig, baseline, waivers, agentConfig),
361
+ source,
362
+ ...(ref ? { ref } : {}),
363
+ };
364
+ }
365
+ function readGitFileAtRevision(root, revision, relativePath) {
366
+ try {
367
+ return execFileSync('git', ['show', `${revision}:${relativePath}`], {
368
+ cwd: root,
369
+ stdio: 'pipe',
370
+ maxBuffer: 4_000_000,
371
+ }).toString();
372
+ }
373
+ catch {
374
+ return undefined;
375
+ }
376
+ }
377
+ function parseGitJsonAtRevision(root, revision, relativePath) {
378
+ const text = readGitFileAtRevision(root, revision, relativePath);
379
+ if (text === undefined)
380
+ return undefined;
381
+ try {
382
+ return JSON.parse(text);
383
+ }
384
+ catch {
385
+ throw new GuardConfigError(`${relativePath} at ${revision}`);
386
+ }
387
+ }
388
+ /**
389
+ * Loads policy artifacts from a trusted Git ref instead of the checked-out
390
+ * working tree. This is intended for CI verification of untrusted branches.
391
+ */
392
+ export function loadGuardPolicyAtRevision(root, revision) {
393
+ if (!/^[\w./@-]+$/.test(revision) || revision.startsWith('-')) {
394
+ throw new GuardConfigError(`unsafe policy ref: ${revision}`);
395
+ }
396
+ const rulesValue = parseGitJsonAtRevision(root, revision, GUARD_RULES_FILE);
397
+ if (rulesValue === undefined || !isGuardConfig(rulesValue)) {
398
+ if (rulesValue === undefined)
399
+ return undefined;
400
+ throw new GuardConfigError(`${GUARD_RULES_FILE} at ${revision}`);
401
+ }
402
+ const contractsValue = parseGitJsonAtRevision(root, revision, GUARD_CONTRACTS_FILE);
403
+ if (contractsValue !== undefined && !isGuardContractsFile(contractsValue)) {
404
+ throw new GuardConfigError(`${GUARD_CONTRACTS_FILE} at ${revision}`);
405
+ }
406
+ const guardConfig = {
407
+ ...rulesValue,
408
+ contracts: contractsValue ? contractsValue.contracts : (rulesValue.contracts ?? []),
409
+ budgets: rulesValue.budgets ?? [],
410
+ approval: rulesValue.approval ?? defaultGuardConfig.approval,
411
+ };
412
+ const agentValue = parseGitJsonAtRevision(root, revision, GUARD_AGENT_FILE);
413
+ const agentConfig = agentValue === undefined
414
+ ? defaultGuardAgentConfig
415
+ : // eslint-disable-next-line @typescript-eslint/no-use-before-define
416
+ isGuardAgentConfig(agentValue)
417
+ ? agentValue
418
+ : (() => {
419
+ throw new GuardConfigError(`${GUARD_AGENT_FILE} at ${revision}`);
420
+ })();
421
+ const baselineValue = parseGitJsonAtRevision(root, revision, GUARD_BASELINE_FILE);
422
+ const baselineEntries = [];
423
+ if (baselineValue !== undefined) {
424
+ if (!Array.isArray(baselineValue)) {
425
+ throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
426
+ }
427
+ for (const item of baselineValue) {
428
+ if (typeof item !== 'string') {
429
+ throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
430
+ }
431
+ baselineEntries.push(item);
432
+ }
433
+ }
434
+ const waiversValue = parseGitJsonAtRevision(root, revision, GUARD_WAIVERS_FILE);
435
+ const parsedWaivers = guardWaiversFileSchema.safeParse(waiversValue ?? { version: 1, waivers: [], decisions: [] });
436
+ if (!parsedWaivers.success)
437
+ throw new GuardConfigError(`${GUARD_WAIVERS_FILE} at ${revision}`);
438
+ // The validator is declared with the waiver file model below the policy readers.
439
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
440
+ validateGuardWaiversFile(parsedWaivers.data);
441
+ return createGuardPolicySnapshot(guardConfig, new Set(baselineEntries), parsedWaivers.data.waivers, 'git-ref', revision, agentConfig);
442
+ }
321
443
  export function getGuardProposalFreshness(model, proposals) {
322
444
  if (!proposals?.projectFingerprint)
323
445
  return 'unknown';
@@ -475,6 +597,25 @@ export function loadGuardWaivers(root) {
475
597
  return readGuardWaiversUnlocked(root).waivers;
476
598
  return withGuardStateLock(root, () => readGuardWaiversUnlocked(root).waivers);
477
599
  }
600
+ function readGuardWorkingTreePolicyUnlocked(root) {
601
+ recoverGuardPolicyTransaction(root);
602
+ const guardConfig = readGuardConfigFilesUnlocked(root);
603
+ if (!guardConfig)
604
+ return undefined;
605
+ const baseline = loadBaseline(root);
606
+ const waivers = readGuardWaiversUnlocked(root).waivers;
607
+ const agentConfig = loadGuardAgentConfig(root);
608
+ return {
609
+ config: guardConfig,
610
+ snapshot: createGuardPolicySnapshot(guardConfig, baseline, waivers, 'working-tree', undefined, agentConfig),
611
+ proposals: loadGuardProposalsUnlocked(root),
612
+ };
613
+ }
614
+ export function loadGuardWorkingTreePolicy(root) {
615
+ if (!existsSync(resolve(root, GUARD_DIR)))
616
+ return readGuardWorkingTreePolicyUnlocked(root);
617
+ return withGuardStateLock(root, () => readGuardWorkingTreePolicyUnlocked(root));
618
+ }
478
619
  export function writeGuardWaivers(root) {
479
620
  withGuardStateLock(root, () => {
480
621
  const file = { version: 1, waivers: [], decisions: [] };
@@ -607,15 +748,24 @@ function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
607
748
  return undefined;
608
749
  }
609
750
  })();
751
+ const currentRevision = readGuardConfigFilesUnlocked(root)?.revision ?? 0;
752
+ const policyFingerprint = options.resultingPolicy
753
+ ? fingerprintGuardPolicy(options.resultingPolicy, loadBaseline(root), readGuardWaiversUnlocked(root).waivers, loadGuardAgentConfig(root))
754
+ : undefined;
610
755
  const nextDecisions = decisions.map((decision) => ({
611
756
  ...decision,
757
+ decisionId: randomUUID(),
612
758
  decidedAt,
613
759
  source: options.source,
614
760
  ...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
615
761
  ...(commit ? { commit } : {}),
762
+ ...(proposals.generatedBy ? { proposalAuthor: proposals.generatedBy } : {}),
616
763
  ...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
617
764
  ...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
618
765
  ...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
766
+ ...(decision.decision === 'approved' && policyFingerprint
767
+ ? { policyFingerprint, policyRevision: currentRevision + 1 }
768
+ : {}),
619
769
  }));
620
770
  return {
621
771
  ...proposals,
@@ -635,7 +785,10 @@ export function recordGuardProposalDecision(root, decisions, options = { source:
635
785
  }
636
786
  export function applyGuardProposalDecision(root, guardConfig, decisions, options) {
637
787
  withGuardStateLock(root, () => {
638
- const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
788
+ const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, {
789
+ ...options,
790
+ resultingPolicy: guardConfig,
791
+ });
639
792
  writeGuardArtifactUnlocked(root, GUARD_POLICY_TRANSACTION_FILE, {
640
793
  version: 1,
641
794
  createdAt: new Date().toISOString(),
@@ -1107,6 +1260,23 @@ export function validateGuardBudgets(root, budgets = []) {
1107
1260
  }
1108
1261
  export function validateGuardPolicy(root, guardConfig) {
1109
1262
  const issues = [];
1263
+ const validateUniqueIds = (items, kind) => {
1264
+ const seen = new Set();
1265
+ for (const item of items) {
1266
+ if (seen.has(item.id)) {
1267
+ issues.push({
1268
+ contractId: `${kind}:${item.id}`,
1269
+ field: 'definition',
1270
+ value: item.id,
1271
+ message: `${kind} IDs must be unique: ${item.id}`,
1272
+ });
1273
+ }
1274
+ seen.add(item.id);
1275
+ }
1276
+ };
1277
+ validateUniqueIds(guardConfig.rules, 'rule');
1278
+ validateUniqueIds(guardConfig.contracts ?? [], 'contract');
1279
+ validateUniqueIds(guardConfig.budgets ?? [], 'budget');
1110
1280
  for (const rule of guardConfig.rules) {
1111
1281
  for (const file of rule.files ?? []) {
1112
1282
  if (!isProjectPath(root, file)) {
@@ -5,6 +5,14 @@ export interface GuardRunStage {
5
5
  status: 'ok' | 'fail' | 'skipped';
6
6
  detail?: string | undefined;
7
7
  }
8
+ export interface GuardRunPolicy {
9
+ revision?: number | undefined;
10
+ fingerprint: string;
11
+ source: 'working-tree' | 'git-ref';
12
+ ref?: string | undefined;
13
+ changedFromBase?: boolean | undefined;
14
+ decisionIds?: string[] | undefined;
15
+ }
8
16
  export interface GuardRunManifest {
9
17
  version: 1;
10
18
  runId: string;
@@ -16,6 +24,7 @@ export interface GuardRunManifest {
16
24
  gate: string;
17
25
  stages: Record<string, GuardRunStage>;
18
26
  commit?: string | undefined;
27
+ policy?: GuardRunPolicy | undefined;
19
28
  }
20
29
  export interface GuardRunContext {
21
30
  runId: string;
@@ -40,4 +49,4 @@ export declare function writeGuardRun(root: string, manifest: GuardRunManifest):
40
49
  /** Reads locally persisted run manifests without reaching a remote service. */
41
50
  export declare function listGuardRuns(root: string, limit?: number): GuardRunManifest[];
42
51
  export declare function summarizeGuardRuns(root: string, limit?: number): GuardRunSummary;
43
- export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string): GuardRunManifest;
52
+ export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string, policy?: GuardRunPolicy): GuardRunManifest;
@@ -15,6 +15,22 @@ function isGuardRunManifest(value) {
15
15
  if (value === null || typeof value !== 'object')
16
16
  return false;
17
17
  const manifest = value;
18
+ const stages = manifest.stages;
19
+ const validStages = stages !== null &&
20
+ typeof stages === 'object' &&
21
+ !Array.isArray(stages) &&
22
+ Object.values(stages).every((stage) => {
23
+ if (stage === null || typeof stage !== 'object')
24
+ return false;
25
+ const candidate = stage;
26
+ return (typeof candidate.durationMs === 'number' &&
27
+ Number.isFinite(candidate.durationMs) &&
28
+ candidate.durationMs >= 0 &&
29
+ (candidate.status === 'ok' ||
30
+ candidate.status === 'fail' ||
31
+ candidate.status === 'skipped') &&
32
+ (candidate.detail === undefined || typeof candidate.detail === 'string'));
33
+ });
18
34
  return (manifest.version === 1 &&
19
35
  typeof manifest.runId === 'string' &&
20
36
  /^guard-[A-Za-z0-9-]+$/.test(manifest.runId) &&
@@ -23,6 +39,7 @@ function isGuardRunManifest(value) {
23
39
  Number.isFinite(Date.parse(manifest.startedAt)) &&
24
40
  typeof manifest.completedAt === 'string' &&
25
41
  Number.isFinite(Date.parse(manifest.completedAt)) &&
42
+ Date.parse(manifest.completedAt) >= Date.parse(manifest.startedAt) &&
26
43
  typeof manifest.durationMs === 'number' &&
27
44
  Number.isFinite(manifest.durationMs) &&
28
45
  manifest.durationMs >= 0 &&
@@ -32,8 +49,23 @@ function isGuardRunManifest(value) {
32
49
  manifest.outcome === 'needs-review' ||
33
50
  manifest.outcome === 'not-configured') &&
34
51
  typeof manifest.gate === 'string' &&
35
- manifest.stages !== null &&
36
- typeof manifest.stages === 'object');
52
+ validStages &&
53
+ (manifest.policy === undefined ||
54
+ (manifest.policy !== null &&
55
+ typeof manifest.policy === 'object' &&
56
+ typeof manifest.policy.fingerprint === 'string' &&
57
+ manifest.policy.fingerprint.length > 0 &&
58
+ (manifest.policy.source === 'working-tree' || manifest.policy.source === 'git-ref') &&
59
+ (manifest.policy.revision === undefined ||
60
+ (Number.isInteger(manifest.policy.revision) && manifest.policy.revision >= 0)) &&
61
+ (manifest.policy.ref === undefined || typeof manifest.policy.ref === 'string') &&
62
+ (manifest.policy.changedFromBase === undefined ||
63
+ typeof manifest.policy.changedFromBase === 'boolean') &&
64
+ (manifest.policy.decisionIds === undefined ||
65
+ (manifest.policy.source === 'working-tree' &&
66
+ Array.isArray(manifest.policy.decisionIds) &&
67
+ new Set(manifest.policy.decisionIds).size === manifest.policy.decisionIds.length &&
68
+ manifest.policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0))))));
37
69
  }
38
70
  export function startGuardRun() {
39
71
  return {
@@ -122,7 +154,7 @@ export function summarizeGuardRuns(root, limit = 100) {
122
154
  ...(total > 0 ? { last: runs[0] } : {}),
123
155
  };
124
156
  }
125
- export function finishGuardRun(root, context, outcome, gate, command = 'verify') {
157
+ export function finishGuardRun(root, context, outcome, gate, command = 'verify', policy) {
126
158
  let commit;
127
159
  try {
128
160
  commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
@@ -143,6 +175,7 @@ export function finishGuardRun(root, context, outcome, gate, command = 'verify')
143
175
  gate,
144
176
  stages: context.stages,
145
177
  ...(commit ? { commit } : {}),
178
+ ...(policy ? { policy } : {}),
146
179
  };
147
180
  try {
148
181
  writeGuardRun(root, manifest);
@@ -266,6 +266,7 @@ export declare const guardContractsFileSchema: z.ZodObject<{
266
266
  }, z.core.$strip>>;
267
267
  }, z.core.$strip>;
268
268
  export declare const guardProposalDecisionSchema: z.ZodObject<{
269
+ decisionId: z.ZodOptional<z.ZodString>;
269
270
  id: z.ZodString;
270
271
  type: z.ZodEnum<{
271
272
  contract: "contract";
@@ -284,8 +285,11 @@ export declare const guardProposalDecisionSchema: z.ZodObject<{
284
285
  external: "external";
285
286
  mcp: "mcp";
286
287
  }>>;
288
+ proposalAuthor: z.ZodOptional<z.ZodString>;
287
289
  actor: z.ZodOptional<z.ZodString>;
288
290
  commit: z.ZodOptional<z.ZodString>;
291
+ policyFingerprint: z.ZodOptional<z.ZodString>;
292
+ policyRevision: z.ZodOptional<z.ZodNumber>;
289
293
  }, z.core.$strip>;
290
294
  export declare const guardProposalSchema: z.ZodObject<{
291
295
  version: z.ZodLiteral<1>;
@@ -357,6 +361,7 @@ export declare const guardProposalSchema: z.ZodObject<{
357
361
  }, z.core.$strip>>;
358
362
  questions: z.ZodArray<z.ZodString>;
359
363
  decisions: z.ZodOptional<z.ZodArray<z.ZodObject<{
364
+ decisionId: z.ZodOptional<z.ZodString>;
360
365
  id: z.ZodString;
361
366
  type: z.ZodEnum<{
362
367
  contract: "contract";
@@ -375,8 +380,11 @@ export declare const guardProposalSchema: z.ZodObject<{
375
380
  external: "external";
376
381
  mcp: "mcp";
377
382
  }>>;
383
+ proposalAuthor: z.ZodOptional<z.ZodString>;
378
384
  actor: z.ZodOptional<z.ZodString>;
379
385
  commit: z.ZodOptional<z.ZodString>;
386
+ policyFingerprint: z.ZodOptional<z.ZodString>;
387
+ policyRevision: z.ZodOptional<z.ZodNumber>;
380
388
  }, z.core.$strip>>>;
381
389
  }, z.core.$strip>;
382
390
  export declare const guardAgentConfigSchema: z.ZodObject<{
@@ -94,7 +94,9 @@ export const guardContractsFileSchema = z.object({
94
94
  version: z.literal(1),
95
95
  contracts: z.array(guardContractSchema),
96
96
  });
97
- export const guardProposalDecisionSchema = z.object({
97
+ export const guardProposalDecisionSchema = z
98
+ .object({
99
+ decisionId: z.string().trim().min(1).optional(),
98
100
  id: z.string().trim().min(1),
99
101
  type: z.enum(['rule', 'contract']),
100
102
  decision: z.enum(['approved', 'rejected']),
@@ -103,10 +105,30 @@ export const guardProposalDecisionSchema = z.object({
103
105
  proposalFingerprint: z.string().optional(),
104
106
  revision: z.number().optional(),
105
107
  source: z.enum(['cli', 'mcp', 'external']).optional(),
106
- actor: z.string().optional(),
107
- commit: z.string().optional(),
108
+ proposalAuthor: z.string().trim().min(1).optional(),
109
+ actor: z.string().trim().min(1).optional(),
110
+ commit: z.string().trim().min(1).optional(),
111
+ policyFingerprint: z.string().trim().min(1).optional(),
112
+ policyRevision: z.number().int().nonnegative().optional(),
113
+ })
114
+ .superRefine((decision, context) => {
115
+ if (decision.policyFingerprint !== undefined && decision.decision !== 'approved') {
116
+ context.addIssue({
117
+ code: 'custom',
118
+ message: 'policyFingerprint is only valid for approved decisions.',
119
+ path: ['policyFingerprint'],
120
+ });
121
+ }
122
+ if (decision.policyRevision !== undefined && decision.policyFingerprint === undefined) {
123
+ context.addIssue({
124
+ code: 'custom',
125
+ message: 'policyRevision requires policyFingerprint.',
126
+ path: ['policyRevision'],
127
+ });
128
+ }
108
129
  });
109
- export const guardProposalSchema = z.object({
130
+ export const guardProposalSchema = z
131
+ .object({
110
132
  version: z.literal(1),
111
133
  generatedAt: z.string(),
112
134
  generatedBy: z.string().trim().min(1).optional(),
@@ -118,6 +140,21 @@ export const guardProposalSchema = z.object({
118
140
  contracts: z.array(guardContractSchema),
119
141
  questions: z.array(z.string()),
120
142
  decisions: z.array(guardProposalDecisionSchema).optional(),
143
+ })
144
+ .superRefine((proposal, context) => {
145
+ const decisionIds = new Set();
146
+ for (const [index, decision] of (proposal.decisions ?? []).entries()) {
147
+ if (decision.decisionId === undefined)
148
+ continue;
149
+ if (decisionIds.has(decision.decisionId)) {
150
+ context.addIssue({
151
+ code: 'custom',
152
+ message: 'decisionId values must be unique.',
153
+ path: ['decisions', index, 'decisionId'],
154
+ });
155
+ }
156
+ decisionIds.add(decision.decisionId);
157
+ }
121
158
  });
122
159
  export const guardAgentConfigSchema = z.object({
123
160
  version: z.literal(1),
@@ -3,7 +3,7 @@ import type { ProjectCheck } from '../model/types.js';
3
3
  import { type GuardAdapter, type ProjectCheckResults, type ProjectRuntimeDiagnostics } from '../../adapters/project-checks.js';
4
4
  import { type GuardToolMode, type GuardContractIssue, type GuardReport } from '../guard.js';
5
5
  import { type GuardImpactAnalysis } from '../analysis/impact.js';
6
- import { type GuardRunManifest } from '../history/runs.js';
6
+ import { type GuardRunManifest, type GuardRunPolicy } from '../history/runs.js';
7
7
  export type GuardVerificationCheck = ProjectCheck;
8
8
  export interface GuardVerificationOptions {
9
9
  checks?: GuardVerificationCheck[] | undefined;
@@ -13,11 +13,13 @@ export interface GuardVerificationOptions {
13
13
  tools?: GuardToolMode | undefined;
14
14
  strict?: boolean | undefined;
15
15
  projectChecks?: boolean | undefined;
16
+ policyBase?: string | undefined;
17
+ failOnPolicyChange?: boolean | undefined;
16
18
  }
17
19
  export interface GuardVerificationResult {
18
20
  status: 'ok' | 'fail' | 'not-configured';
19
21
  outcome: 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
20
- errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | undefined;
22
+ errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | undefined;
21
23
  recoverable?: boolean | undefined;
22
24
  tools?: GuardToolMode | undefined;
23
25
  checks: ProjectCheckResults;
@@ -34,5 +36,7 @@ export interface GuardVerificationResult {
34
36
  message: string;
35
37
  };
36
38
  run: GuardRunManifest;
39
+ policy?: GuardRunPolicy | undefined;
40
+ policyChanged?: boolean | undefined;
37
41
  }
38
42
  export declare function runGuardVerification(root: string, options?: GuardVerificationOptions): GuardVerificationResult;
@@ -1,9 +1,19 @@
1
1
  import {} from '../../adapters/command.js';
2
2
  import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
3
- import { loadBaseline, loadGuardAgentConfig, loadGuardConfig, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
3
+ import { loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
4
4
  import { analyzeProjectImpact } from '../analysis/impact.js';
5
5
  import { discoverProject } from '../discovery/discovery.js';
6
6
  import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
7
+ function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
8
+ return {
9
+ ...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
10
+ fingerprint: snapshot.fingerprint,
11
+ source: snapshot.source,
12
+ ...(base ? { ref: base } : {}),
13
+ ...(base ? { changedFromBase } : {}),
14
+ ...(decisionIds.length > 0 ? { decisionIds } : {}),
15
+ };
16
+ }
7
17
  export function runGuardVerification(root, options = {}) {
8
18
  const runContext = startGuardRun();
9
19
  const requirement = {
@@ -16,9 +26,26 @@ export function runGuardVerification(root, options = {}) {
16
26
  const runtimeStartedAt = Date.now();
17
27
  const runtime = inspectProjectRuntime(root);
18
28
  recordGuardRunStage(runContext, 'runtime', 'ok', runtimeStartedAt);
19
- let config;
29
+ if (options.failOnPolicyChange && !options.policyBase) {
30
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
31
+ return {
32
+ status: 'fail',
33
+ outcome: 'fail',
34
+ errorCode: 'GUARD_POLICY_BASE_INVALID',
35
+ recoverable: true,
36
+ checks: {},
37
+ workspaceChecks: {},
38
+ adapters: {},
39
+ runtime,
40
+ configError: '`failOnPolicyChange` requires `policyBase`.',
41
+ contractIssues: [],
42
+ requirement,
43
+ run,
44
+ };
45
+ }
46
+ let workingTreeState;
20
47
  try {
21
- config = loadGuardConfig(root);
48
+ workingTreeState = loadGuardWorkingTreePolicy(root);
22
49
  }
23
50
  catch (error) {
24
51
  const run = finishGuardRun(root, runContext, 'fail', 'configuration');
@@ -37,7 +64,7 @@ export function runGuardVerification(root, options = {}) {
37
64
  run,
38
65
  };
39
66
  }
40
- if (!config) {
67
+ if (!workingTreeState) {
41
68
  const run = finishGuardRun(root, runContext, 'not-configured', 'configuration');
42
69
  return {
43
70
  status: 'not-configured',
@@ -53,7 +80,82 @@ export function runGuardVerification(root, options = {}) {
53
80
  run,
54
81
  };
55
82
  }
56
- const agentConfig = loadGuardAgentConfig(root);
83
+ let policySnapshot;
84
+ let workingTreeSnapshot;
85
+ let policyChanged;
86
+ let policyDecisionIds = [];
87
+ try {
88
+ workingTreeSnapshot = workingTreeState.snapshot;
89
+ const baseSnapshot = options.policyBase
90
+ ? loadGuardPolicyAtRevision(root, options.policyBase)
91
+ : undefined;
92
+ if (options.policyBase && !baseSnapshot) {
93
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
94
+ return {
95
+ status: 'fail',
96
+ outcome: 'fail',
97
+ errorCode: 'GUARD_POLICY_BASE_INVALID',
98
+ recoverable: true,
99
+ checks: {},
100
+ workspaceChecks: {},
101
+ adapters: {},
102
+ runtime,
103
+ configError: `Guard policy was not found at Git ref: ${options.policyBase}`,
104
+ contractIssues: [],
105
+ requirement,
106
+ run,
107
+ };
108
+ }
109
+ policySnapshot = baseSnapshot ?? workingTreeSnapshot;
110
+ policyChanged =
111
+ baseSnapshot !== undefined && baseSnapshot.fingerprint !== workingTreeSnapshot.fingerprint;
112
+ if (policySnapshot.source === 'working-tree') {
113
+ policyDecisionIds = (workingTreeState.proposals?.decisions ?? [])
114
+ .filter((decision) => decision.policyFingerprint === policySnapshot.fingerprint)
115
+ .map((decision) => decision.decisionId ??
116
+ `${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
117
+ .sort();
118
+ }
119
+ if (options.failOnPolicyChange && policyChanged) {
120
+ const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
121
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-change', 'verify', policy);
122
+ return {
123
+ status: 'fail',
124
+ outcome: 'fail',
125
+ errorCode: 'GUARD_POLICY_CHANGED',
126
+ recoverable: true,
127
+ checks: {},
128
+ workspaceChecks: {},
129
+ adapters: {},
130
+ runtime,
131
+ configError: 'Guard policy changed relative to the supplied Git base ref.',
132
+ contractIssues: [],
133
+ requirement,
134
+ run,
135
+ policy,
136
+ policyChanged,
137
+ };
138
+ }
139
+ }
140
+ catch (error) {
141
+ const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
142
+ return {
143
+ status: 'fail',
144
+ outcome: 'fail',
145
+ errorCode: 'GUARD_POLICY_BASE_INVALID',
146
+ recoverable: true,
147
+ checks: {},
148
+ workspaceChecks: {},
149
+ adapters: {},
150
+ runtime,
151
+ configError: error instanceof Error ? error.message : String(error),
152
+ contractIssues: [],
153
+ requirement,
154
+ run,
155
+ };
156
+ }
157
+ const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
158
+ const agentConfig = policySnapshot.agentConfig;
57
159
  const projectChecks = options.projectChecks ?? agentConfig.completionGate.projectChecks;
58
160
  const checks = projectChecks ? (options.checks ?? agentConfig.completionGate.checks) : [];
59
161
  const checksStartedAt = Date.now();
@@ -63,7 +165,7 @@ export function runGuardVerification(root, options = {}) {
63
165
  const workspaceModel = discoverProject(root);
64
166
  const impact = analyzeProjectImpact(workspaceModel, options.changedOnly === false
65
167
  ? workspaceModel.modules.map((module) => module.path)
66
- : workspaceModel.git.changedFiles, config.contracts ?? []);
168
+ : workspaceModel.git.changedFiles, policySnapshot.config.contracts ?? []);
67
169
  const workspaceChecks = runWorkspaceProjectChecks(root, workspaceModel.project.workspacePackages ?? [], checks, {
68
170
  timeout: options.timeout,
69
171
  rootResults: results,
@@ -80,12 +182,13 @@ export function runGuardVerification(root, options = {}) {
80
182
  });
81
183
  recordGuardRunStage(runContext, 'adapters', toolMode === 'off' ? 'skipped' : 'ok', adaptersStartedAt);
82
184
  const architectureStartedAt = Date.now();
83
- const architecture = scanGuard(root, config, {
185
+ const architecture = scanGuard(root, policySnapshot.config, {
84
186
  changedOnly: options.changedOnly,
85
- baseline: loadBaseline(root),
187
+ baseline: policySnapshot.baseline,
86
188
  includeArchitectureInsights: true,
189
+ waivers: policySnapshot.waivers,
87
190
  });
88
- const contractIssues = validateGuardPolicy(root, config);
191
+ const contractIssues = validateGuardPolicy(root, policySnapshot.config);
89
192
  recordGuardRunStage(runContext, 'architecture-policy', 'ok', architectureStartedAt);
90
193
  const commandFailed = Object.values(results).some((result) => result.status === 'failed');
91
194
  const workspaceCommandFailed = Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'failed'));
@@ -133,7 +236,7 @@ export function runGuardVerification(root, options = {}) {
133
236
  : !runtime.compatible && projectChecks
134
237
  ? 'runtime'
135
238
  : 'none';
136
- const run = finishGuardRun(root, runContext, outcome, gate);
239
+ const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy);
137
240
  return {
138
241
  status: failed ? 'fail' : 'ok',
139
242
  outcome,
@@ -147,5 +250,7 @@ export function runGuardVerification(root, options = {}) {
147
250
  contractIssues,
148
251
  requirement,
149
252
  run,
253
+ policy,
254
+ ...(policy.changedFromBase !== undefined ? { policyChanged: policy.changedFromBase } : {}),
150
255
  };
151
256
  }
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ export * from './core/output/sarif.js';
11
11
  export * from './core/analysis/doctor.js';
12
12
  export * from './core/analysis/packs.js';
13
13
  export * from './core/analysis/impact.js';
14
+ export * from './core/audit/governance.js';
14
15
  export * from './adapters/project-checks.js';
package/dist/index.js CHANGED
@@ -10,4 +10,5 @@ export * from './core/output/sarif.js';
10
10
  export * from './core/analysis/doctor.js';
11
11
  export * from './core/analysis/packs.js';
12
12
  export * from './core/analysis/impact.js';
13
+ export * from './core/audit/governance.js';
13
14
  export * from './adapters/project-checks.js';