@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.
@@ -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
  }
@@ -0,0 +1,37 @@
1
+ export interface GuardGovernanceAudit {
2
+ version: 1;
3
+ generatedAt: string;
4
+ configured: boolean;
5
+ policy?: {
6
+ revision?: number | undefined;
7
+ fingerprint: string;
8
+ } | undefined;
9
+ proposals: {
10
+ currentId?: string | undefined;
11
+ currentAuthor?: string | undefined;
12
+ decisions: number;
13
+ authors: string[];
14
+ missingAuthors: number;
15
+ };
16
+ approvals: {
17
+ total: number;
18
+ approved: number;
19
+ rejected: number;
20
+ actors: string[];
21
+ missingActors: number;
22
+ actorAuthorOverlap: string[];
23
+ commitAuthors: string[];
24
+ actorCommitAuthorOverlap: string[];
25
+ missingCommits: number;
26
+ sources: Record<string, number>;
27
+ };
28
+ reachability: {
29
+ approvedWithPolicyFingerprint: number;
30
+ approvedLinkedToRun: number;
31
+ approvedWithoutRun: number;
32
+ runsWithPolicy: number;
33
+ runsWithoutPolicy: number;
34
+ };
35
+ warnings: string[];
36
+ }
37
+ export declare function auditGuardGovernance(root: string): GuardGovernanceAudit;
@@ -0,0 +1,166 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { createGuardPolicySnapshot, loadBaseline, loadGuardAgentConfig, loadGuardConfig, loadGuardProposals, loadGuardWaivers, withGuardStateLock, GUARD_DIR, } from '../guard.js';
5
+ import { GUARD_RUN_RETENTION, listGuardRuns } from '../history/runs.js';
6
+ function commitAuthor(root, commit, cache) {
7
+ if (!commit || !/^[0-9a-f]{7,64}$/i.test(commit))
8
+ return undefined;
9
+ if (cache.has(commit))
10
+ return cache.get(commit);
11
+ try {
12
+ const value = execFileSync('git', ['show', '-s', '--format=%ae', commit], {
13
+ cwd: root,
14
+ stdio: 'pipe',
15
+ timeout: 5_000,
16
+ maxBuffer: 64 * 1024,
17
+ })
18
+ .toString()
19
+ .trim();
20
+ const author = value || undefined;
21
+ cache.set(commit, author);
22
+ return author;
23
+ }
24
+ catch {
25
+ cache.set(commit, undefined);
26
+ return undefined;
27
+ }
28
+ }
29
+ function currentProposalAuthor(proposals, decision) {
30
+ if (decision.proposalAuthor)
31
+ return decision.proposalAuthor;
32
+ if (decision.proposalId && decision.proposalId === proposals?.proposalId) {
33
+ return proposals.generatedBy;
34
+ }
35
+ return undefined;
36
+ }
37
+ function auditGuardGovernanceUnlocked(root) {
38
+ const config = loadGuardConfig(root);
39
+ const proposals = loadGuardProposals(root);
40
+ const decisions = proposals?.decisions ?? [];
41
+ const approved = decisions.filter((decision) => decision.decision === 'approved');
42
+ const authors = [
43
+ ...new Set(decisions
44
+ .map((decision) => currentProposalAuthor(proposals, decision))
45
+ .filter((author) => Boolean(author))),
46
+ ].sort();
47
+ const actors = [
48
+ ...new Set(approved
49
+ .map((decision) => decision.actor?.trim())
50
+ .filter((actor) => Boolean(actor))),
51
+ ].sort();
52
+ const commitAuthorCache = new Map();
53
+ const enrichedApprovals = approved.map((decision) => ({
54
+ decision,
55
+ author: currentProposalAuthor(proposals, decision),
56
+ commitAuthor: commitAuthor(root, decision.commit, commitAuthorCache),
57
+ }));
58
+ const commitAuthors = [
59
+ ...new Set(enrichedApprovals
60
+ .map((item) => item.commitAuthor)
61
+ .filter((author) => Boolean(author))),
62
+ ].sort();
63
+ const actorAuthorOverlap = [
64
+ ...new Set(enrichedApprovals
65
+ .filter((item) => item.decision.actor && item.author && item.decision.actor === item.author)
66
+ .map((item) => item.decision.actor)
67
+ .filter((actor) => Boolean(actor))),
68
+ ].sort();
69
+ const actorCommitAuthorOverlap = [
70
+ ...new Set(enrichedApprovals
71
+ .filter((item) => item.decision.actor && item.commitAuthor === item.decision.actor)
72
+ .map((item) => item.decision.actor)
73
+ .filter((actor) => Boolean(actor))),
74
+ ].sort();
75
+ const runs = listGuardRuns(root, GUARD_RUN_RETENTION);
76
+ const runDecisionBindings = new Map();
77
+ for (const run of runs) {
78
+ const fingerprint = run.policy?.fingerprint;
79
+ if (!fingerprint)
80
+ continue;
81
+ for (const decisionId of run.policy?.decisionIds ?? []) {
82
+ const fingerprints = runDecisionBindings.get(decisionId) ?? new Set();
83
+ fingerprints.add(fingerprint);
84
+ runDecisionBindings.set(decisionId, fingerprints);
85
+ }
86
+ }
87
+ const approvedWithPolicyFingerprint = approved.filter((decision) => decision.policyFingerprint !== undefined).length;
88
+ const approvedLinkedToRun = approved.filter((decision) => decision.policyFingerprint !== undefined &&
89
+ runDecisionBindings
90
+ .get(decision.decisionId ??
91
+ `${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
92
+ ?.has(decision.policyFingerprint)).length;
93
+ const warnings = [];
94
+ if (approved.some((decision) => !decision.actor)) {
95
+ warnings.push('Some approved decisions do not declare an approval actor.');
96
+ }
97
+ if (approved.some((decision) => !currentProposalAuthor(proposals, decision))) {
98
+ warnings.push('Some approved decisions do not retain a proposal author.');
99
+ }
100
+ if (actorAuthorOverlap.length > 0) {
101
+ warnings.push('At least one declared approval actor also authored an approved proposal.');
102
+ }
103
+ if (approved.some((decision) => !decision.policyFingerprint)) {
104
+ warnings.push('Some approved decisions are not bound to a resulting policy fingerprint.');
105
+ }
106
+ if (approved.some((decision) => decision.policyFingerprint === undefined ||
107
+ !runDecisionBindings
108
+ .get(decision.decisionId ??
109
+ `${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
110
+ ?.has(decision.policyFingerprint))) {
111
+ warnings.push('Some approved decisions have no retained verification run linked by decision ID.');
112
+ }
113
+ return {
114
+ version: 1,
115
+ generatedAt: new Date().toISOString(),
116
+ configured: config !== undefined,
117
+ ...(config
118
+ ? {
119
+ policy: (() => {
120
+ const snapshot = createGuardPolicySnapshot(config, loadBaseline(root), loadGuardWaivers(root), 'working-tree', undefined, loadGuardAgentConfig(root));
121
+ return {
122
+ ...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
123
+ fingerprint: snapshot.fingerprint,
124
+ };
125
+ })(),
126
+ }
127
+ : {}),
128
+ proposals: {
129
+ ...(proposals?.proposalId ? { currentId: proposals.proposalId } : {}),
130
+ ...(proposals?.generatedBy ? { currentAuthor: proposals.generatedBy } : {}),
131
+ decisions: decisions.length,
132
+ authors,
133
+ missingAuthors: decisions.filter((decision) => !currentProposalAuthor(proposals, decision))
134
+ .length,
135
+ },
136
+ approvals: {
137
+ total: decisions.length,
138
+ approved: approved.length,
139
+ rejected: decisions.filter((decision) => decision.decision === 'rejected').length,
140
+ actors,
141
+ missingActors: approved.filter((decision) => !decision.actor).length,
142
+ actorAuthorOverlap,
143
+ commitAuthors,
144
+ actorCommitAuthorOverlap,
145
+ missingCommits: approved.filter((decision) => !decision.commit).length,
146
+ sources: decisions.reduce((counts, decision) => {
147
+ const source = decision.source ?? 'unknown';
148
+ counts[source] = (counts[source] ?? 0) + 1;
149
+ return counts;
150
+ }, {}),
151
+ },
152
+ reachability: {
153
+ approvedWithPolicyFingerprint,
154
+ approvedLinkedToRun,
155
+ approvedWithoutRun: approvedWithPolicyFingerprint - approvedLinkedToRun,
156
+ runsWithPolicy: runs.filter((run) => run.policy !== undefined).length,
157
+ runsWithoutPolicy: runs.filter((run) => run.policy === undefined).length,
158
+ },
159
+ warnings,
160
+ };
161
+ }
162
+ export function auditGuardGovernance(root) {
163
+ if (!existsSync(resolve(root, GUARD_DIR)))
164
+ return auditGuardGovernanceUnlocked(root);
165
+ return withGuardStateLock(root, () => auditGuardGovernanceUnlocked(root));
166
+ }
@@ -1,4 +1,4 @@
1
- export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | 'GUARD_INVALID_INPUT';
1
+ export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | 'GUARD_INVALID_INPUT';
2
2
  export interface GuardErrorPayload {
3
3
  status: 'error';
4
4
  outcome: 'not-configured' | 'error';
@@ -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;
@@ -105,6 +106,7 @@ export interface GuardProposalFile {
105
106
  decisions?: GuardProposalDecision[] | undefined;
106
107
  }
107
108
  export interface GuardProposalDecision {
109
+ decisionId?: string | undefined;
108
110
  id: string;
109
111
  type: 'rule' | 'contract';
110
112
  decision: 'approved' | 'rejected';
@@ -113,8 +115,11 @@ export interface GuardProposalDecision {
113
115
  proposalFingerprint?: string | undefined;
114
116
  revision?: number | undefined;
115
117
  source?: 'cli' | 'mcp' | 'external' | undefined;
118
+ proposalAuthor?: string | undefined;
116
119
  actor?: string | undefined;
117
120
  commit?: string | undefined;
121
+ policyFingerprint?: string | undefined;
122
+ policyRevision?: number | undefined;
118
123
  }
119
124
  export type GuardProposalFreshness = 'current' | 'stale' | 'unknown';
120
125
  export interface GuardAgentConfig {
@@ -157,12 +162,17 @@ export interface GuardReport {
157
162
  findings: GuardFinding[];
158
163
  suppressed: number;
159
164
  scannedFiles: number;
165
+ waived: number;
166
+ waiverWarnings: GuardWaiver[];
167
+ expiredWaivers: GuardWaiver[];
160
168
  }
161
169
  interface ScanGuardOptions {
162
170
  changedOnly?: boolean | undefined;
163
171
  changedFiles?: Set<string> | undefined;
164
172
  baseline?: Set<string> | undefined;
165
173
  includeArchitectureInsights?: boolean | undefined;
174
+ waivers?: GuardWaiver[] | undefined;
175
+ now?: string | undefined;
166
176
  }
167
177
  export interface GuardReviewPacket {
168
178
  version: 1;
@@ -178,6 +188,9 @@ export interface GuardReviewPacket {
178
188
  contracts: GuardContract[];
179
189
  requirement?: string | undefined;
180
190
  deterministicFindings: GuardFinding[];
191
+ waived: number;
192
+ waiverWarnings: GuardWaiver[];
193
+ expiredWaivers: GuardWaiver[];
181
194
  impact: GuardImpactAnalysis;
182
195
  reviewInstructions: string[];
183
196
  }
@@ -185,6 +198,7 @@ export declare const GUARD_DIR: string;
185
198
  export declare const GUARD_RULES_FILE: string;
186
199
  export declare const GUARD_BASELINE_FILE: string;
187
200
  export declare const GUARD_BASELINE_META_FILE: string;
201
+ export declare const GUARD_WAIVERS_FILE: string;
188
202
  export declare const GUARD_PROJECT_FILE: string;
189
203
  export declare const GUARD_HISTORY_DIR: string;
190
204
  export declare const GUARD_ARCHITECTURE_FILE: string;
@@ -212,6 +226,9 @@ export declare class GuardStateStaleError extends Error {
212
226
  export declare class GuardBaselineReasonError extends Error {
213
227
  constructor();
214
228
  }
229
+ export declare class GuardWaiverError extends Error {
230
+ constructor(message: string);
231
+ }
215
232
  export declare function withGuardStateLock<T>(root: string, callback: () => T, options?: GuardStateLockOptions): T;
216
233
  export declare function isGuardContractsFile(value: unknown): value is {
217
234
  version: 1;
@@ -220,6 +237,30 @@ export declare function isGuardContractsFile(value: unknown): value is {
220
237
  export declare function isGuardProposalFile(value: unknown): value is GuardProposalFile;
221
238
  export declare function fingerprintProjectModel(model: ProjectModel): string;
222
239
  export declare function fingerprintGuardConfig(guardConfig: GuardConfig): string;
240
+ export interface GuardPolicySnapshot {
241
+ config: GuardConfig;
242
+ agentConfig: GuardAgentConfig;
243
+ baseline: Set<string>;
244
+ waivers: GuardWaiver[];
245
+ revision?: number | undefined;
246
+ fingerprint: string;
247
+ source: 'working-tree' | 'git-ref';
248
+ ref?: string | undefined;
249
+ }
250
+ /**
251
+ * Fingerprints the effective policy used by a verification run.
252
+ *
253
+ * The rules revision alone is not sufficient: baseline and waiver changes also
254
+ * affect the verdict. Keeping them in this fingerprint makes a run auditable
255
+ * without pretending that a numeric revision covers every policy artifact.
256
+ */
257
+ export declare function fingerprintGuardPolicy(guardConfig: GuardConfig, baseline?: Set<string>, waivers?: GuardWaiver[], agentConfig?: GuardAgentConfig): string;
258
+ export declare function createGuardPolicySnapshot(guardConfig: GuardConfig, baseline: Set<string>, waivers: GuardWaiver[], source?: GuardPolicySnapshot['source'], ref?: string, agentConfig?: GuardAgentConfig): GuardPolicySnapshot;
259
+ /**
260
+ * Loads policy artifacts from a trusted Git ref instead of the checked-out
261
+ * working tree. This is intended for CI verification of untrusted branches.
262
+ */
263
+ export declare function loadGuardPolicyAtRevision(root: string, revision: string): GuardPolicySnapshot | undefined;
223
264
  export declare function getGuardProposalFreshness(model: ProjectModel, proposals: GuardProposalFile | undefined): GuardProposalFreshness;
224
265
  export declare function loadGuardConfig(root: string): GuardConfig | undefined;
225
266
  export declare function loadGuardProposals(root: string): GuardProposalFile | undefined;
@@ -227,6 +268,18 @@ export declare function isGuardAgentConfig(value: unknown): value is GuardAgentC
227
268
  export declare function loadGuardAgentConfig(root: string): GuardAgentConfig;
228
269
  export declare function writeGuardAgentConfig(root: string, agentConfig?: GuardAgentConfig): void;
229
270
  export declare function loadBaseline(root: string): Set<string>;
271
+ export interface GuardWaiverUpdateOptions {
272
+ action: 'add' | 'renew' | 'remove';
273
+ waiver?: GuardWaiver | undefined;
274
+ waiverId: string;
275
+ reason: string;
276
+ expiresAt?: string | undefined;
277
+ actor?: string | undefined;
278
+ maxLifetimeDays?: number | undefined;
279
+ }
280
+ export declare function loadGuardWaivers(root: string): GuardWaiver[];
281
+ export declare function writeGuardWaivers(root: string): void;
282
+ export declare function updateGuardWaivers(root: string, options: GuardWaiverUpdateOptions): GuardWaiver[];
230
283
  export declare function updateBaseline(root: string, options?: GuardBaselineUpdateOptions): Set<string>;
231
284
  export declare function writeGuardConfig(root: string, guardConfig: GuardConfig, options?: GuardConfigWriteOptions): void;
232
285
  export declare function writeGuardProposals(root: string, proposals: GuardProposalFile): void;