@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.
- package/README.md +105 -10
- 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 +13 -0
- package/dist/cli/commands/guard.js +163 -4
- package/dist/cli/index.js +37 -4
- package/dist/core/analysis/doctor.d.ts +1 -1
- package/dist/core/analysis/doctor.js +35 -2
- 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 +55 -2
- package/dist/core/guard.js +319 -8
- package/dist/core/history/runs.d.ts +10 -1
- package/dist/core/history/runs.js +18 -2
- package/dist/core/model/types.d.ts +22 -0
- package/dist/core/output/sarif.d.ts +7 -2
- package/dist/core/output/sarif.js +23 -14
- package/dist/core/policy/schemas.d.ts +59 -0
- package/dist/core/policy/schemas.js +37 -0
- package/dist/core/verification/verify.d.ts +6 -2
- package/dist/core/verification/verify.js +114 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/resources.js +18 -1
- package/dist/mcp/tools/guard.js +138 -2
- package/package.json +1 -1
package/dist/core/guard.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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
|
};
|