@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.
- package/README.md +72 -42
- package/dist/adapters/agents/agent-integration.d.ts +3 -0
- package/dist/adapters/agents/agent-integration.js +29 -8
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +35 -0
- package/dist/cli/index.js +11 -3
- package/dist/core/audit/governance.d.ts +37 -0
- package/dist/core/audit/governance.js +166 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/guard.d.ts +34 -0
- package/dist/core/guard.js +176 -6
- package/dist/core/history/runs.d.ts +10 -1
- package/dist/core/history/runs.js +36 -3
- package/dist/core/policy/schemas.d.ts +8 -0
- package/dist/core/policy/schemas.js +41 -4
- package/dist/core/verification/verify.d.ts +6 -2
- package/dist/core/verification/verify.js +115 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/tools/guard.js +26 -1
- package/package.json +1 -1
package/dist/core/guard.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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:
|
|
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';
|