@codapult/guard 0.2.0 → 0.3.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 +18 -0
- package/dist/adapters/command.js +23 -2
- package/dist/cli/commands/guard.d.ts +7 -1
- package/dist/cli/commands/guard.js +125 -15
- package/dist/cli/index.js +27 -4
- package/dist/core/analysis/doctor.js +19 -5
- package/dist/core/discovery/discovery.d.ts +16 -6
- package/dist/core/discovery/discovery.js +150 -17
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/guard.d.ts +18 -5
- package/dist/core/guard.js +226 -57
- package/dist/core/policy/schemas.d.ts +12 -0
- package/dist/core/policy/schemas.js +21 -17
- package/dist/core/verification/verify.d.ts +5 -0
- package/dist/core/verification/verify.js +26 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/tools/guard.js +165 -36
- package/package.json +6 -1
package/dist/core/guard.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { dirname, extname, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
6
|
-
import {
|
|
6
|
+
import { buildModuleTargetGraph } from './discovery/discovery.js';
|
|
7
|
+
import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
|
|
7
8
|
import { config } from './config.js';
|
|
8
9
|
import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
|
|
9
10
|
import { analyzeProjectImpact } from './analysis/impact.js';
|
|
@@ -50,6 +51,18 @@ export class GuardAlreadyInitializedError extends Error {
|
|
|
50
51
|
this.name = 'GuardAlreadyInitializedError';
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
export class GuardConfigError extends Error {
|
|
55
|
+
constructor(path) {
|
|
56
|
+
super(`Guard configuration is invalid or unsupported: ${path}`);
|
|
57
|
+
this.name = 'GuardConfigError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class GuardStateBusyError extends Error {
|
|
61
|
+
constructor(path) {
|
|
62
|
+
super(`Guard state is being updated by another process: ${path}`);
|
|
63
|
+
this.name = 'GuardStateBusyError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
53
66
|
function isGuardConfig(value) {
|
|
54
67
|
return guardConfigSchema.safeParse(value).success;
|
|
55
68
|
}
|
|
@@ -62,9 +75,37 @@ function readJson(filePath) {
|
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
function atomicWriteFile(path, content) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
const lockPath = `${path}.lock`;
|
|
79
|
+
let lockAcquired = false;
|
|
80
|
+
try {
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
83
|
+
lockAcquired = true;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === 'EEXIST') {
|
|
87
|
+
try {
|
|
88
|
+
if (Date.now() - statSync(lockPath).mtimeMs > 60_000) {
|
|
89
|
+
unlinkSync(lockPath);
|
|
90
|
+
writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
91
|
+
lockAcquired = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Another writer may have replaced or removed the lock.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!lockAcquired)
|
|
99
|
+
throw new GuardStateBusyError(path);
|
|
100
|
+
}
|
|
101
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
102
|
+
writeFileSync(temporaryPath, content, 'utf8');
|
|
103
|
+
renameSync(temporaryPath, path);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (lockAcquired)
|
|
107
|
+
unlinkSync(lockPath);
|
|
108
|
+
}
|
|
68
109
|
}
|
|
69
110
|
function writeGuardArtifact(root, relativePath, value) {
|
|
70
111
|
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
@@ -108,14 +149,17 @@ export function getGuardProposalFreshness(model, proposals) {
|
|
|
108
149
|
return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
|
|
109
150
|
}
|
|
110
151
|
export function loadGuardConfig(root) {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
152
|
+
const rulesPath = resolve(root, GUARD_RULES_FILE);
|
|
153
|
+
if (!existsSync(rulesPath))
|
|
113
154
|
return undefined;
|
|
155
|
+
const value = readJson(rulesPath);
|
|
156
|
+
if (!isGuardConfig(value))
|
|
157
|
+
throw new GuardConfigError(GUARD_RULES_FILE);
|
|
114
158
|
const contractsPath = resolve(root, GUARD_CONTRACTS_FILE);
|
|
115
159
|
if (existsSync(contractsPath)) {
|
|
116
160
|
const contractFile = readJson(contractsPath);
|
|
117
161
|
if (!isGuardContractsFile(contractFile))
|
|
118
|
-
|
|
162
|
+
throw new GuardConfigError(GUARD_CONTRACTS_FILE);
|
|
119
163
|
return { ...value, contracts: contractFile.contracts };
|
|
120
164
|
}
|
|
121
165
|
return {
|
|
@@ -137,9 +181,6 @@ export function loadGuardAgentConfig(root) {
|
|
|
137
181
|
export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
|
|
138
182
|
writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
|
|
139
183
|
}
|
|
140
|
-
export function loadGuardArtifact(root, relativePath) {
|
|
141
|
-
return readJson(resolve(root, relativePath));
|
|
142
|
-
}
|
|
143
184
|
export function loadBaseline(root) {
|
|
144
185
|
const value = readJson(resolve(root, GUARD_BASELINE_FILE));
|
|
145
186
|
if (!Array.isArray(value))
|
|
@@ -202,6 +243,31 @@ export function recordGuardProposalDecision(root, decisions) {
|
|
|
202
243
|
decisions: [...(proposals.decisions ?? []), ...nextDecisions],
|
|
203
244
|
});
|
|
204
245
|
}
|
|
246
|
+
export function getPendingGuardProposals(proposals) {
|
|
247
|
+
if (!proposals)
|
|
248
|
+
return [];
|
|
249
|
+
const decisions = new Map();
|
|
250
|
+
for (const decision of proposals.decisions ?? []) {
|
|
251
|
+
const current = decisions.get(`${decision.type}:${decision.id}`);
|
|
252
|
+
if (!current || decision.decidedAt >= current.decidedAt) {
|
|
253
|
+
decisions.set(`${decision.type}:${decision.id}`, decision);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const matchesCurrentProposal = (decision) => decision !== undefined &&
|
|
257
|
+
(proposals.proposalId === undefined || decision.proposalId === proposals.proposalId) &&
|
|
258
|
+
(proposals.contentFingerprint === undefined ||
|
|
259
|
+
decision.proposalFingerprint === proposals.contentFingerprint) &&
|
|
260
|
+
(proposals.revision === undefined || decision.revision === proposals.revision);
|
|
261
|
+
return [
|
|
262
|
+
...proposals.rules
|
|
263
|
+
.filter((rule) => rule.status === 'proposed' && !matchesCurrentProposal(decisions.get(`rule:${rule.id}`)))
|
|
264
|
+
.map((rule) => ({ id: rule.id, type: 'rule' })),
|
|
265
|
+
...proposals.contracts
|
|
266
|
+
.filter((contract) => contract.status === 'proposed' &&
|
|
267
|
+
!matchesCurrentProposal(decisions.get(`contract:${contract.id}`)))
|
|
268
|
+
.map((contract) => ({ id: contract.id, type: 'contract' })),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
205
271
|
export function buildGuardProposals(model, guardConfig) {
|
|
206
272
|
const capabilities = Object.keys(model.capabilities);
|
|
207
273
|
const questions = [];
|
|
@@ -347,14 +413,19 @@ export function buildConventionsMemory(model) {
|
|
|
347
413
|
}
|
|
348
414
|
export function buildGeneratedGuardConfig(model) {
|
|
349
415
|
const isPersistenceImport = (importPath) => /(?:^|\/)(?:database|db)(?:\/|$)/i.test(importPath) ||
|
|
350
|
-
/^(?:@prisma
|
|
416
|
+
/^(?:@prisma\/client|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
|
|
417
|
+
const moduleReferences = (module) => [
|
|
418
|
+
...module.imports,
|
|
419
|
+
...module.exports,
|
|
420
|
+
...module.dynamicImports,
|
|
421
|
+
];
|
|
351
422
|
const persistenceImports = [
|
|
352
|
-
...new Set(model.modules.flatMap((module) => module
|
|
423
|
+
...new Set(model.modules.flatMap((module) => moduleReferences(module)).filter(isPersistenceImport)),
|
|
353
424
|
].sort();
|
|
354
425
|
const clientPersistenceFiles = model.insights.boundaries
|
|
355
426
|
.find((boundary) => boundary.kind === 'client')
|
|
356
427
|
?.files.filter((file) => model.modules.some((module) => module.path === file &&
|
|
357
|
-
module.
|
|
428
|
+
moduleReferences(module).some((importPath) => persistenceImports.includes(importPath)))) ?? [];
|
|
358
429
|
const proposedRules = clientPersistenceFiles.length > 0
|
|
359
430
|
? [
|
|
360
431
|
{
|
|
@@ -424,6 +495,32 @@ export function writeProjectSnapshot(root, model) {
|
|
|
424
495
|
atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
|
|
425
496
|
return revision;
|
|
426
497
|
}
|
|
498
|
+
function isProjectPath(root, value) {
|
|
499
|
+
const normalized = value.replace(/\\/g, '/');
|
|
500
|
+
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
const projectRoot = `${realpathSync(root)}${sep}`;
|
|
505
|
+
const targetPath = resolve(root, normalized);
|
|
506
|
+
let target;
|
|
507
|
+
try {
|
|
508
|
+
target = realpathSync(targetPath);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
target = realpathSync(dirname(targetPath));
|
|
512
|
+
}
|
|
513
|
+
return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
export function loadGuardArtifact(root, relativePath) {
|
|
520
|
+
if (!isProjectPath(root, relativePath))
|
|
521
|
+
return undefined;
|
|
522
|
+
return readJson(resolve(root, relativePath));
|
|
523
|
+
}
|
|
427
524
|
export function validateGuardContracts(root, contracts = []) {
|
|
428
525
|
const issues = [];
|
|
429
526
|
for (const contract of contracts) {
|
|
@@ -445,8 +542,18 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
445
542
|
message: 'Required-call contracts need at least one mustCall pattern.',
|
|
446
543
|
});
|
|
447
544
|
}
|
|
545
|
+
if (contract.kind === 'package-boundary' &&
|
|
546
|
+
((contract.fromPackages?.length ?? 0) === 0 ||
|
|
547
|
+
(contract.mustNotImportPackages?.length ?? 0) === 0)) {
|
|
548
|
+
issues.push({
|
|
549
|
+
contractId: contract.id,
|
|
550
|
+
field: 'definition',
|
|
551
|
+
value: contract.kind,
|
|
552
|
+
message: 'Package-boundary contracts need fromPackages and mustNotImportPackages patterns.',
|
|
553
|
+
});
|
|
554
|
+
}
|
|
448
555
|
for (const scope of contract.scope ?? []) {
|
|
449
|
-
if (!existsSync(resolve(root, scope))) {
|
|
556
|
+
if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
|
|
450
557
|
issues.push({
|
|
451
558
|
contractId: contract.id,
|
|
452
559
|
field: 'scope',
|
|
@@ -456,7 +563,7 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
456
563
|
}
|
|
457
564
|
}
|
|
458
565
|
for (const entrypoint of contract.entrypoints ?? []) {
|
|
459
|
-
if (!existsSync(resolve(root, entrypoint))) {
|
|
566
|
+
if (!isProjectPath(root, entrypoint) || !existsSync(resolve(root, entrypoint))) {
|
|
460
567
|
issues.push({
|
|
461
568
|
contractId: contract.id,
|
|
462
569
|
field: 'scope',
|
|
@@ -466,7 +573,7 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
466
573
|
}
|
|
467
574
|
}
|
|
468
575
|
for (const excluded of contract.exclude ?? []) {
|
|
469
|
-
if (!existsSync(resolve(root, excluded))) {
|
|
576
|
+
if (!isProjectPath(root, excluded) || !existsSync(resolve(root, excluded))) {
|
|
470
577
|
issues.push({
|
|
471
578
|
contractId: contract.id,
|
|
472
579
|
field: 'scope',
|
|
@@ -476,7 +583,7 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
476
583
|
}
|
|
477
584
|
}
|
|
478
585
|
for (const reference of contract.references ?? []) {
|
|
479
|
-
if (!existsSync(resolve(root, reference))) {
|
|
586
|
+
if (!isProjectPath(root, reference) || !existsSync(resolve(root, reference))) {
|
|
480
587
|
issues.push({
|
|
481
588
|
contractId: contract.id,
|
|
482
589
|
field: 'reference',
|
|
@@ -541,6 +648,32 @@ function importLine(sourceFile, importPath) {
|
|
|
541
648
|
});
|
|
542
649
|
return dynamicImport?.getStartLineNumber() ?? 1;
|
|
543
650
|
}
|
|
651
|
+
function moduleImportReferences(module) {
|
|
652
|
+
const sources = [...new Set([...module.imports, ...module.exports, ...module.dynamicImports])];
|
|
653
|
+
return sources.map((source) => ({ source, resolved: module.resolvedImportMap?.[source] }));
|
|
654
|
+
}
|
|
655
|
+
function packageSelectorMatches(value, selector) {
|
|
656
|
+
if (!value)
|
|
657
|
+
return false;
|
|
658
|
+
return selector.endsWith('*') ? value.startsWith(selector.slice(0, -1)) : value === selector;
|
|
659
|
+
}
|
|
660
|
+
function packageForFile(model, file) {
|
|
661
|
+
const packageInfo = model.project.workspacePackages
|
|
662
|
+
.filter((workspace) => workspace.path === '.' ||
|
|
663
|
+
file === `${workspace.path}/package.json` ||
|
|
664
|
+
file.startsWith(`${workspace.path}/`))
|
|
665
|
+
.sort((left, right) => right.path.length - left.path.length)[0] ?? null;
|
|
666
|
+
return packageInfo === null ? undefined : (packageInfo.name ?? packageInfo.path);
|
|
667
|
+
}
|
|
668
|
+
function packageForImport(model, reference) {
|
|
669
|
+
const resolvedPackage = reference.resolved
|
|
670
|
+
? packageForFile(model, reference.resolved)
|
|
671
|
+
: undefined;
|
|
672
|
+
if (resolvedPackage)
|
|
673
|
+
return resolvedPackage;
|
|
674
|
+
return model.project.workspacePackages.find((workspace) => workspace.name === reference.source ||
|
|
675
|
+
(workspace.name !== undefined && reference.source.startsWith(`${workspace.name}/`)))?.name;
|
|
676
|
+
}
|
|
544
677
|
function scanFile(root, file, rules, module, astProject) {
|
|
545
678
|
if (!module)
|
|
546
679
|
return [];
|
|
@@ -555,26 +688,25 @@ function scanFile(root, file, rules, module, astProject) {
|
|
|
555
688
|
}
|
|
556
689
|
}
|
|
557
690
|
const findings = [];
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
];
|
|
561
|
-
for (const importPath of importPaths) {
|
|
562
|
-
const line = sourceFile ? importLine(sourceFile, importPath) : 1;
|
|
691
|
+
for (const reference of moduleImportReferences(module)) {
|
|
692
|
+
const line = sourceFile ? importLine(sourceFile, reference.source) : 1;
|
|
563
693
|
for (const rule of rules) {
|
|
564
694
|
if (rule.status === 'proposed' || !ruleAppliesToFile(rule, file))
|
|
565
695
|
continue;
|
|
566
696
|
if (rule.kind === 'client-forbidden-import' && !isClient)
|
|
567
697
|
continue;
|
|
568
|
-
if (!rule.patterns.some((pattern) => patternMatches(
|
|
698
|
+
if (!rule.patterns.some((pattern) => patternMatches(reference.source, pattern) ||
|
|
699
|
+
(reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))
|
|
569
700
|
continue;
|
|
570
701
|
findings.push({
|
|
571
702
|
ruleId: rule.id,
|
|
572
703
|
severity: rule.severity,
|
|
573
704
|
file,
|
|
574
705
|
line,
|
|
575
|
-
importPath,
|
|
706
|
+
importPath: reference.source,
|
|
707
|
+
...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
|
|
576
708
|
message: rule.description,
|
|
577
|
-
fingerprint: `${rule.id}|${file}|${
|
|
709
|
+
fingerprint: `${rule.id}|${file}|${reference.source}|${reference.resolved ?? ''}`,
|
|
578
710
|
});
|
|
579
711
|
}
|
|
580
712
|
}
|
|
@@ -589,22 +721,25 @@ function scanContracts(root, contracts, changed) {
|
|
|
589
721
|
const severity = contract.severity ?? 'error';
|
|
590
722
|
for (const module of model.modules.filter((item) => (!changed || changed.has(item.path)) && contractAppliesToFile(contract, item.path))) {
|
|
591
723
|
if (contract.kind === 'import-boundary') {
|
|
592
|
-
for (const
|
|
593
|
-
if (contract.mustNotImport?.some((pattern) => patternMatches(
|
|
724
|
+
for (const reference of moduleImportReferences(module)) {
|
|
725
|
+
if (contract.mustNotImport?.some((pattern) => patternMatches(reference.source, pattern) ||
|
|
726
|
+
(reference.resolved !== undefined && patternMatches(reference.resolved, pattern)))) {
|
|
594
727
|
findings.push({
|
|
595
728
|
ruleId: `contract:${contract.id}`,
|
|
596
729
|
severity,
|
|
597
730
|
file: module.path,
|
|
598
731
|
line: 1,
|
|
599
|
-
importPath,
|
|
732
|
+
importPath: reference.source,
|
|
733
|
+
...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
|
|
600
734
|
message: contract.statement,
|
|
601
|
-
fingerprint: `contract-import|${contract.id}|${module.path}|${
|
|
735
|
+
fingerprint: `contract-import|${contract.id}|${module.path}|${reference.source}|${reference.resolved ?? ''}`,
|
|
602
736
|
});
|
|
603
737
|
}
|
|
604
738
|
}
|
|
605
739
|
if (contract.mustImport !== undefined &&
|
|
606
740
|
contract.mustImport.length > 0 &&
|
|
607
|
-
!module.
|
|
741
|
+
!moduleImportReferences(module).some((reference) => contract.mustImport?.some((pattern) => patternMatches(reference.source, pattern) ||
|
|
742
|
+
(reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))) {
|
|
608
743
|
findings.push({
|
|
609
744
|
ruleId: `contract:${contract.id}`,
|
|
610
745
|
severity,
|
|
@@ -630,6 +765,28 @@ function scanContracts(root, contracts, changed) {
|
|
|
630
765
|
fingerprint: `contract-required-call|${contract.id}|${module.path}`,
|
|
631
766
|
});
|
|
632
767
|
}
|
|
768
|
+
if (contract.kind === 'package-boundary') {
|
|
769
|
+
const sourcePackage = packageForFile(model, module.path);
|
|
770
|
+
if (!contract.fromPackages?.some((selector) => packageSelectorMatches(sourcePackage, selector))) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
for (const reference of moduleImportReferences(module)) {
|
|
774
|
+
const targetPackage = packageForImport(model, reference);
|
|
775
|
+
if (!contract.mustNotImportPackages?.some((selector) => packageSelectorMatches(targetPackage, selector))) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
findings.push({
|
|
779
|
+
ruleId: `contract:${contract.id}`,
|
|
780
|
+
severity,
|
|
781
|
+
file: module.path,
|
|
782
|
+
line: 1,
|
|
783
|
+
importPath: reference.source,
|
|
784
|
+
...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
|
|
785
|
+
message: contract.statement,
|
|
786
|
+
fingerprint: `contract-package|${contract.id}|${module.path}|${reference.source}|${targetPackage ?? ''}`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
633
790
|
}
|
|
634
791
|
}
|
|
635
792
|
return findings;
|
|
@@ -657,6 +814,30 @@ function changedFiles(root) {
|
|
|
657
814
|
return undefined;
|
|
658
815
|
}
|
|
659
816
|
}
|
|
817
|
+
function changedImpactScope(model, changed) {
|
|
818
|
+
if (!changed)
|
|
819
|
+
return undefined;
|
|
820
|
+
const graph = buildModuleTargetGraph(model.modules);
|
|
821
|
+
const reverse = new Map();
|
|
822
|
+
for (const [from, targets] of graph) {
|
|
823
|
+
for (const target of targets)
|
|
824
|
+
reverse.set(target, [...(reverse.get(target) ?? []), from]);
|
|
825
|
+
}
|
|
826
|
+
const scope = new Set(changed);
|
|
827
|
+
const queue = [...changed];
|
|
828
|
+
while (queue.length > 0) {
|
|
829
|
+
const current = queue.shift();
|
|
830
|
+
if (!current)
|
|
831
|
+
continue;
|
|
832
|
+
for (const dependent of reverse.get(current) ?? []) {
|
|
833
|
+
if (!scope.has(dependent)) {
|
|
834
|
+
scope.add(dependent);
|
|
835
|
+
queue.push(dependent);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return scope;
|
|
840
|
+
}
|
|
660
841
|
function architectureInsightFindings(root, changed) {
|
|
661
842
|
const model = discoverProject(root);
|
|
662
843
|
const findings = model.insights.cycles
|
|
@@ -677,7 +858,9 @@ function architectureInsightFindings(root, changed) {
|
|
|
677
858
|
for (const module of model.modules) {
|
|
678
859
|
if (!clientFiles.has(module.path))
|
|
679
860
|
continue;
|
|
680
|
-
for (const importPath of
|
|
861
|
+
for (const importPath of [
|
|
862
|
+
...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
|
|
863
|
+
].filter((value) => serverOnlyImports.test(value))) {
|
|
681
864
|
if (changed && !changed.has(module.path))
|
|
682
865
|
continue;
|
|
683
866
|
findings.push({
|
|
@@ -716,25 +899,7 @@ function isSafeReviewFile(file) {
|
|
|
716
899
|
/\.(?:pem|key|p12|pfx)$/i.test(file));
|
|
717
900
|
}
|
|
718
901
|
function isSafeReviewPath(root, file) {
|
|
719
|
-
|
|
720
|
-
if (normalizedFile.startsWith('/') || normalizedFile.split('/').includes('..'))
|
|
721
|
-
return false;
|
|
722
|
-
try {
|
|
723
|
-
const projectRoot = `${realpathSync(root)}${sep}`;
|
|
724
|
-
const targetPath = resolve(root, normalizedFile);
|
|
725
|
-
let target;
|
|
726
|
-
try {
|
|
727
|
-
target = realpathSync(targetPath);
|
|
728
|
-
}
|
|
729
|
-
catch {
|
|
730
|
-
// Deleted files have no realpath. Validate their nearest existing parent instead.
|
|
731
|
-
target = realpathSync(dirname(targetPath));
|
|
732
|
-
}
|
|
733
|
-
return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
return false;
|
|
737
|
-
}
|
|
902
|
+
return isProjectPath(root, file);
|
|
738
903
|
}
|
|
739
904
|
function parseGitChanges(root, args) {
|
|
740
905
|
try {
|
|
@@ -780,6 +945,9 @@ export function redactSensitiveText(value) {
|
|
|
780
945
|
};
|
|
781
946
|
replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]');
|
|
782
947
|
replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]');
|
|
948
|
+
replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED JWT]');
|
|
949
|
+
replace(/\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, '[REDACTED URL CREDENTIALS]@');
|
|
950
|
+
replace(/\b(?:AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})\b/g, '[REDACTED TOKEN]');
|
|
783
951
|
replace(/((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\s*[:=]\s*["']?)[^\s"'`,}]+/gi, '$1[REDACTED]');
|
|
784
952
|
return { value: redactedValue, redacted, redactionCount };
|
|
785
953
|
}
|
|
@@ -898,9 +1066,10 @@ function reviewDiff(root, maxChars, base) {
|
|
|
898
1066
|
}
|
|
899
1067
|
}
|
|
900
1068
|
export function scanGuard(root, guardConfig, options = {}) {
|
|
901
|
-
const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
|
|
902
|
-
const files = listSourceFiles(root).filter((file) => !changed || changed.has(file));
|
|
903
1069
|
const model = discoverProject(root);
|
|
1070
|
+
const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
|
|
1071
|
+
const scope = changedImpactScope(model, changed);
|
|
1072
|
+
const files = listSourceFiles(root).filter((file) => !scope || scope.has(file));
|
|
904
1073
|
const modules = new Map(model.modules.map((module) => [module.path, module]));
|
|
905
1074
|
const astProject = (() => {
|
|
906
1075
|
try {
|
|
@@ -912,8 +1081,8 @@ export function scanGuard(root, guardConfig, options = {}) {
|
|
|
912
1081
|
})();
|
|
913
1082
|
const allFindings = [
|
|
914
1083
|
...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
|
|
915
|
-
...scanContracts(root, guardConfig.contracts ?? [],
|
|
916
|
-
...(options.includeArchitectureInsights ? architectureInsightFindings(root,
|
|
1084
|
+
...scanContracts(root, guardConfig.contracts ?? [], scope),
|
|
1085
|
+
...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
|
|
917
1086
|
];
|
|
918
1087
|
const baseline = options.baseline ?? new Set();
|
|
919
1088
|
const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
|
|
@@ -983,4 +1152,4 @@ export function initializeGuard(root, options = {}) {
|
|
|
983
1152
|
writeBaseline(root, initialReport.findings, projectModel);
|
|
984
1153
|
return { config: guardConfig, report: { ...initialReport, findings: [] } };
|
|
985
1154
|
}
|
|
986
|
-
export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
|
|
1155
|
+
export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };
|
|
@@ -30,6 +30,7 @@ export declare const guardContractSchema: z.ZodObject<{
|
|
|
30
30
|
kind: z.ZodOptional<z.ZodEnum<{
|
|
31
31
|
guidance: "guidance";
|
|
32
32
|
"import-boundary": "import-boundary";
|
|
33
|
+
"package-boundary": "package-boundary";
|
|
33
34
|
"required-call": "required-call";
|
|
34
35
|
}>>;
|
|
35
36
|
severity: z.ZodOptional<z.ZodEnum<{
|
|
@@ -45,6 +46,8 @@ export declare const guardContractSchema: z.ZodObject<{
|
|
|
45
46
|
mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
46
47
|
mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
47
48
|
mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
49
|
+
fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
50
|
+
mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
48
51
|
status: z.ZodOptional<z.ZodEnum<{
|
|
49
52
|
active: "active";
|
|
50
53
|
proposed: "proposed";
|
|
@@ -89,6 +92,7 @@ export declare const guardConfigSchema: z.ZodObject<{
|
|
|
89
92
|
kind: z.ZodOptional<z.ZodEnum<{
|
|
90
93
|
guidance: "guidance";
|
|
91
94
|
"import-boundary": "import-boundary";
|
|
95
|
+
"package-boundary": "package-boundary";
|
|
92
96
|
"required-call": "required-call";
|
|
93
97
|
}>>;
|
|
94
98
|
severity: z.ZodOptional<z.ZodEnum<{
|
|
@@ -104,6 +108,8 @@ export declare const guardConfigSchema: z.ZodObject<{
|
|
|
104
108
|
mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
105
109
|
mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
106
110
|
mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
111
|
+
fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
112
|
+
mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
107
113
|
status: z.ZodOptional<z.ZodEnum<{
|
|
108
114
|
active: "active";
|
|
109
115
|
proposed: "proposed";
|
|
@@ -124,6 +130,7 @@ export declare const guardContractsFileSchema: z.ZodObject<{
|
|
|
124
130
|
kind: z.ZodOptional<z.ZodEnum<{
|
|
125
131
|
guidance: "guidance";
|
|
126
132
|
"import-boundary": "import-boundary";
|
|
133
|
+
"package-boundary": "package-boundary";
|
|
127
134
|
"required-call": "required-call";
|
|
128
135
|
}>>;
|
|
129
136
|
severity: z.ZodOptional<z.ZodEnum<{
|
|
@@ -139,6 +146,8 @@ export declare const guardContractsFileSchema: z.ZodObject<{
|
|
|
139
146
|
mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
140
147
|
mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
141
148
|
mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
149
|
+
fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
150
|
+
mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
142
151
|
status: z.ZodOptional<z.ZodEnum<{
|
|
143
152
|
active: "active";
|
|
144
153
|
proposed: "proposed";
|
|
@@ -204,6 +213,7 @@ export declare const guardProposalSchema: z.ZodObject<{
|
|
|
204
213
|
kind: z.ZodOptional<z.ZodEnum<{
|
|
205
214
|
guidance: "guidance";
|
|
206
215
|
"import-boundary": "import-boundary";
|
|
216
|
+
"package-boundary": "package-boundary";
|
|
207
217
|
"required-call": "required-call";
|
|
208
218
|
}>>;
|
|
209
219
|
severity: z.ZodOptional<z.ZodEnum<{
|
|
@@ -219,6 +229,8 @@ export declare const guardProposalSchema: z.ZodObject<{
|
|
|
219
229
|
mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
220
230
|
mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
221
231
|
mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
232
|
+
fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
233
|
+
mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
222
234
|
status: z.ZodOptional<z.ZodEnum<{
|
|
223
235
|
active: "active";
|
|
224
236
|
proposed: "proposed";
|
|
@@ -2,29 +2,31 @@ import { z } from 'zod';
|
|
|
2
2
|
const severity = z.enum(['error', 'warning', 'info']);
|
|
3
3
|
const status = z.enum(['active', 'proposed']);
|
|
4
4
|
export const guardRuleSchema = z.object({
|
|
5
|
-
id: z.string(),
|
|
6
|
-
description: z.string(),
|
|
5
|
+
id: z.string().trim().min(1),
|
|
6
|
+
description: z.string().trim().min(1),
|
|
7
7
|
severity,
|
|
8
8
|
kind: z.enum(['forbidden-import', 'client-forbidden-import']),
|
|
9
|
-
patterns: z.array(z.string()),
|
|
10
|
-
files: z.array(z.string()).optional(),
|
|
9
|
+
patterns: z.array(z.string().trim().min(1)).min(1),
|
|
10
|
+
files: z.array(z.string().trim().min(1)).optional(),
|
|
11
11
|
status: status.optional(),
|
|
12
12
|
confidence: z.enum(['high', 'medium', 'low']).optional(),
|
|
13
13
|
evidence: z.array(z.string()).optional(),
|
|
14
14
|
});
|
|
15
15
|
export const guardContractSchema = z.object({
|
|
16
|
-
id: z.string(),
|
|
17
|
-
statement: z.string(),
|
|
18
|
-
kind: z.enum(['guidance', 'import-boundary', 'required-call']).optional(),
|
|
16
|
+
id: z.string().trim().min(1),
|
|
17
|
+
statement: z.string().trim().min(1),
|
|
18
|
+
kind: z.enum(['guidance', 'import-boundary', 'required-call', 'package-boundary']).optional(),
|
|
19
19
|
severity: severity.optional(),
|
|
20
|
-
scope: z.array(z.string()).optional(),
|
|
21
|
-
entrypoints: z.array(z.string()).optional(),
|
|
22
|
-
exclude: z.array(z.string()).optional(),
|
|
23
|
-
guidance: z.array(z.string()).optional(),
|
|
24
|
-
references: z.array(z.string()).optional(),
|
|
25
|
-
mustImport: z.array(z.string()).optional(),
|
|
26
|
-
mustNotImport: z.array(z.string()).optional(),
|
|
27
|
-
mustCall: z.array(z.string()).optional(),
|
|
20
|
+
scope: z.array(z.string().trim().min(1)).optional(),
|
|
21
|
+
entrypoints: z.array(z.string().trim().min(1)).optional(),
|
|
22
|
+
exclude: z.array(z.string().trim().min(1)).optional(),
|
|
23
|
+
guidance: z.array(z.string().trim().min(1)).optional(),
|
|
24
|
+
references: z.array(z.string().trim().min(1)).optional(),
|
|
25
|
+
mustImport: z.array(z.string().trim().min(1)).optional(),
|
|
26
|
+
mustNotImport: z.array(z.string().trim().min(1)).optional(),
|
|
27
|
+
mustCall: z.array(z.string().trim().min(1)).optional(),
|
|
28
|
+
fromPackages: z.array(z.string().trim().min(1)).optional(),
|
|
29
|
+
mustNotImportPackages: z.array(z.string().trim().min(1)).optional(),
|
|
28
30
|
status: status.optional(),
|
|
29
31
|
confidence: z.enum(['high', 'medium', 'low']).optional(),
|
|
30
32
|
evidence: z.array(z.string()).optional(),
|
|
@@ -39,7 +41,7 @@ export const guardContractsFileSchema = z.object({
|
|
|
39
41
|
contracts: z.array(guardContractSchema),
|
|
40
42
|
});
|
|
41
43
|
export const guardProposalDecisionSchema = z.object({
|
|
42
|
-
id: z.string(),
|
|
44
|
+
id: z.string().trim().min(1),
|
|
43
45
|
type: z.enum(['rule', 'contract']),
|
|
44
46
|
decision: z.enum(['approved', 'rejected']),
|
|
45
47
|
decidedAt: z.string(),
|
|
@@ -62,7 +64,9 @@ export const guardProposalSchema = z.object({
|
|
|
62
64
|
export const guardAgentConfigSchema = z.object({
|
|
63
65
|
version: z.literal(1),
|
|
64
66
|
tools: z.enum(['auto', 'on', 'off']),
|
|
65
|
-
tooling: z
|
|
67
|
+
tooling: z
|
|
68
|
+
.record(z.string().trim().min(1), z.object({ script: z.string().trim().min(1), enabled: z.boolean() }))
|
|
69
|
+
.default({}),
|
|
66
70
|
completionGate: z.object({
|
|
67
71
|
enabled: z.boolean(),
|
|
68
72
|
maxIterations: z.number().int().min(1).max(10),
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { type CommandResult } from '../../adapters/command.js';
|
|
2
2
|
import { type GuardAdapter, type ProjectCheck, type ProjectCheckResults, type ProjectRuntimeDiagnostics } from '../../adapters/project-checks.js';
|
|
3
3
|
import { type GuardToolMode, type GuardContractIssue, type GuardReport } from '../guard.js';
|
|
4
|
+
import { type GuardImpactAnalysis } from '../analysis/impact.js';
|
|
4
5
|
export type GuardVerificationCheck = ProjectCheck;
|
|
5
6
|
export interface GuardVerificationResult {
|
|
6
7
|
status: 'ok' | 'fail' | 'not-configured';
|
|
7
8
|
outcome: 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
|
|
9
|
+
errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID';
|
|
10
|
+
recoverable?: boolean;
|
|
8
11
|
tools?: GuardToolMode;
|
|
9
12
|
checks: ProjectCheckResults;
|
|
10
13
|
workspaceChecks: Record<string, ProjectCheckResults>;
|
|
11
14
|
adapters: Partial<Record<GuardAdapter, CommandResult>>;
|
|
12
15
|
runtime: ProjectRuntimeDiagnostics;
|
|
16
|
+
configError?: string;
|
|
13
17
|
architecture?: GuardReport;
|
|
18
|
+
impact?: GuardImpactAnalysis;
|
|
14
19
|
contractIssues: GuardContractIssue[];
|
|
15
20
|
requirement: {
|
|
16
21
|
status: 'delegated-to-review';
|