@codapult/guard 0.2.0 → 0.4.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 +73 -1
- package/dist/adapters/command.d.ts +10 -8
- package/dist/adapters/command.js +23 -2
- package/dist/adapters/project-checks.d.ts +23 -20
- package/dist/cli/commands/guard.d.ts +53 -54
- package/dist/cli/commands/guard.js +155 -32
- package/dist/cli/index.js +41 -5
- package/dist/core/analysis/doctor.d.ts +1 -1
- package/dist/core/analysis/doctor.js +19 -6
- package/dist/core/discovery/discovery.d.ts +27 -13
- package/dist/core/discovery/discovery.js +182 -29
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/guard.d.ts +134 -69
- package/dist/core/guard.js +767 -145
- package/dist/core/history/runs.d.ts +28 -0
- package/dist/core/history/runs.js +59 -0
- package/dist/core/model/types.d.ts +10 -0
- package/dist/core/model/types.js +1 -0
- package/dist/core/policy/schemas.d.ts +81 -0
- package/dist/core/policy/schemas.js +46 -17
- package/dist/core/verification/verify.d.ts +21 -12
- package/dist/core/verification/verify.js +81 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp/resources.js +64 -29
- package/dist/mcp/tools/guard.js +300 -52
- package/package.json +12 -7
package/dist/core/guard.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
5
|
import { dirname, extname, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
6
|
-
import {
|
|
7
|
+
import { buildModuleTargetGraph } from './discovery/discovery.js';
|
|
8
|
+
import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
|
|
7
9
|
import { config } from './config.js';
|
|
8
10
|
import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
|
|
9
11
|
import { analyzeProjectImpact } from './analysis/impact.js';
|
|
@@ -28,10 +30,20 @@ export const GUARD_CONVENTIONS_FILE = `${GUARD_DIR}/conventions.json`;
|
|
|
28
30
|
export const GUARD_AGENT_FILE = `${GUARD_DIR}/agent.json`;
|
|
29
31
|
export const GUARD_CONTRACTS_FILE = `${GUARD_DIR}/contracts.json`;
|
|
30
32
|
export const GUARD_PROPOSALS_FILE = `${GUARD_DIR}/proposals.json`;
|
|
33
|
+
export const GUARD_STATE_DIR = `${GUARD_DIR}/state`;
|
|
34
|
+
export const GUARD_STATE_GENERATIONS_DIR = `${GUARD_STATE_DIR}/generations`;
|
|
35
|
+
export const GUARD_STATE_CURRENT_FILE = `${GUARD_STATE_DIR}/current.json`;
|
|
36
|
+
const GUARD_POLICY_TRANSACTION_FILE = `${GUARD_STATE_DIR}/policy-transaction.json`;
|
|
37
|
+
const GUARD_STATE_LOCK_FILE = `${GUARD_DIR}/.state.lock`;
|
|
31
38
|
export const defaultGuardConfig = {
|
|
32
39
|
version: 1,
|
|
33
40
|
rules: [],
|
|
34
41
|
contracts: [],
|
|
42
|
+
approval: {
|
|
43
|
+
mode: 'local',
|
|
44
|
+
allowMcpApproval: true,
|
|
45
|
+
requireDistinctActor: false,
|
|
46
|
+
},
|
|
35
47
|
};
|
|
36
48
|
export const defaultGuardAgentConfig = {
|
|
37
49
|
version: 1,
|
|
@@ -50,6 +62,154 @@ export class GuardAlreadyInitializedError extends Error {
|
|
|
50
62
|
this.name = 'GuardAlreadyInitializedError';
|
|
51
63
|
}
|
|
52
64
|
}
|
|
65
|
+
export class GuardConfigError extends Error {
|
|
66
|
+
constructor(path) {
|
|
67
|
+
super(`Guard configuration is invalid or unsupported: ${path}`);
|
|
68
|
+
this.name = 'GuardConfigError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class GuardStateBusyError extends Error {
|
|
72
|
+
constructor(path) {
|
|
73
|
+
super(`Guard state is being updated by another process: ${path}`);
|
|
74
|
+
this.name = 'GuardStateBusyError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export class GuardStateStaleError extends Error {
|
|
78
|
+
constructor() {
|
|
79
|
+
super('Guard state changed while this operation was running.');
|
|
80
|
+
this.name = 'GuardStateStaleError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class GuardBaselineReasonError extends Error {
|
|
84
|
+
constructor() {
|
|
85
|
+
super('A reason is required when changing the Guard baseline.');
|
|
86
|
+
this.name = 'GuardBaselineReasonError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const activeStateLocks = new Map();
|
|
90
|
+
function processIsAlive(pid) {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(pid, 0);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return error.code === 'EPERM';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function sleepSync(milliseconds) {
|
|
100
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
101
|
+
}
|
|
102
|
+
function removeLockFile(path) {
|
|
103
|
+
try {
|
|
104
|
+
unlinkSync(path);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Lock cleanup is best effort. A later invocation can recover an orphaned lock by PID.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function readStateLock(path) {
|
|
111
|
+
try {
|
|
112
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
113
|
+
const hasValidDate = (candidate) => typeof candidate === 'string' && Number.isFinite(Date.parse(candidate));
|
|
114
|
+
return Number.isInteger(value.pid) &&
|
|
115
|
+
value.pid > 0 &&
|
|
116
|
+
typeof value.hostname === 'string' &&
|
|
117
|
+
value.hostname.length > 0 &&
|
|
118
|
+
typeof value.command === 'string' &&
|
|
119
|
+
typeof value.token === 'string' &&
|
|
120
|
+
value.token.length > 0 &&
|
|
121
|
+
hasValidDate(value.createdAt) &&
|
|
122
|
+
hasValidDate(value.expiresAt)
|
|
123
|
+
? value
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function getActiveStateRoot(path) {
|
|
131
|
+
const normalizedPath = resolve(path);
|
|
132
|
+
return [...activeStateLocks.keys()]
|
|
133
|
+
.filter((root) => normalizedPath === root || normalizedPath.startsWith(`${root}${sep}`))
|
|
134
|
+
.sort((left, right) => right.length - left.length)[0];
|
|
135
|
+
}
|
|
136
|
+
function safeGeneration(value) {
|
|
137
|
+
return typeof value === 'string' && /^[A-Za-z0-9._-]+$/.test(value);
|
|
138
|
+
}
|
|
139
|
+
export function withGuardStateLock(root, callback, options = {}) {
|
|
140
|
+
const normalizedRoot = resolve(root);
|
|
141
|
+
const activeDepth = activeStateLocks.get(normalizedRoot);
|
|
142
|
+
if (activeDepth !== undefined) {
|
|
143
|
+
activeStateLocks.set(normalizedRoot, activeDepth + 1);
|
|
144
|
+
try {
|
|
145
|
+
return callback();
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
activeStateLocks.set(normalizedRoot, activeDepth);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const lockPath = resolve(normalizedRoot, GUARD_STATE_LOCK_FILE);
|
|
152
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
153
|
+
const startedAt = Date.now();
|
|
154
|
+
const waitMs = options.noWait ? 0 : (options.waitMs ?? 30_000);
|
|
155
|
+
let lock;
|
|
156
|
+
let delay = 25;
|
|
157
|
+
while (!lock) {
|
|
158
|
+
const now = new Date();
|
|
159
|
+
const candidate = {
|
|
160
|
+
pid: process.pid,
|
|
161
|
+
hostname: hostname(),
|
|
162
|
+
command: process.argv.join(' '),
|
|
163
|
+
token: randomUUID(),
|
|
164
|
+
createdAt: now.toISOString(),
|
|
165
|
+
expiresAt: new Date(now.getTime() + 300_000).toISOString(),
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(lockPath, `${JSON.stringify(candidate)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
169
|
+
lock = candidate;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code !== 'EEXIST')
|
|
174
|
+
throw error;
|
|
175
|
+
const owner = readStateLock(lockPath);
|
|
176
|
+
const ownerDead = owner?.hostname === hostname() && !processIsAlive(owner.pid);
|
|
177
|
+
let malformedStale = false;
|
|
178
|
+
if (owner === undefined) {
|
|
179
|
+
try {
|
|
180
|
+
malformedStale = Date.now() - statSync(lockPath).mtimeMs > 60_000;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// The lock disappeared between the failed create and stat.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (ownerDead || malformedStale) {
|
|
187
|
+
try {
|
|
188
|
+
unlinkSync(lockPath);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// The owner or another waiter changed the lock; retry normally.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (Date.now() - startedAt >= waitMs)
|
|
196
|
+
throw new GuardStateBusyError(lockPath);
|
|
197
|
+
sleepSync(Math.min(delay, Math.max(1, waitMs - (Date.now() - startedAt))));
|
|
198
|
+
delay = Math.min(delay * 2, 500);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
activeStateLocks.set(normalizedRoot, 1);
|
|
202
|
+
try {
|
|
203
|
+
return callback();
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
activeStateLocks.delete(normalizedRoot);
|
|
207
|
+
const current = readStateLock(lockPath);
|
|
208
|
+
if (current?.token === lock.token) {
|
|
209
|
+
removeLockFile(lockPath);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
53
213
|
function isGuardConfig(value) {
|
|
54
214
|
return guardConfigSchema.safeParse(value).success;
|
|
55
215
|
}
|
|
@@ -62,14 +222,52 @@ function readJson(filePath) {
|
|
|
62
222
|
}
|
|
63
223
|
}
|
|
64
224
|
function atomicWriteFile(path, content) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
225
|
+
if (getActiveStateRoot(path) !== undefined) {
|
|
226
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
227
|
+
writeFileSync(temporaryPath, content, 'utf8');
|
|
228
|
+
renameSync(temporaryPath, path);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const lockPath = `${path}.lock`;
|
|
232
|
+
let lockAcquired = false;
|
|
233
|
+
try {
|
|
234
|
+
try {
|
|
235
|
+
writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
236
|
+
lockAcquired = true;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error.code === 'EEXIST') {
|
|
240
|
+
try {
|
|
241
|
+
if (Date.now() - statSync(lockPath).mtimeMs > 60_000) {
|
|
242
|
+
unlinkSync(lockPath);
|
|
243
|
+
writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
244
|
+
lockAcquired = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Another writer may have replaced or removed the lock.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!lockAcquired)
|
|
252
|
+
throw new GuardStateBusyError(path);
|
|
253
|
+
}
|
|
254
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
255
|
+
writeFileSync(temporaryPath, content, 'utf8');
|
|
256
|
+
renameSync(temporaryPath, path);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
if (lockAcquired) {
|
|
260
|
+
removeLockFile(lockPath);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
68
263
|
}
|
|
69
|
-
function
|
|
264
|
+
function writeGuardArtifactUnlocked(root, relativePath, value) {
|
|
70
265
|
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
71
266
|
atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
|
|
72
267
|
}
|
|
268
|
+
function writeGuardArtifact(root, relativePath, value) {
|
|
269
|
+
withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
|
|
270
|
+
}
|
|
73
271
|
function hasProjectTool(model, name) {
|
|
74
272
|
return (Object.keys({ ...model.project.dependencies, ...model.project.devDependencies }).some((dependency) => dependency === name || dependency.endsWith(`/${name}`)) || Object.values(model.project.scripts).some((script) => script.includes(name)));
|
|
75
273
|
}
|
|
@@ -102,106 +300,270 @@ export function isGuardProposalFile(value) {
|
|
|
102
300
|
export function fingerprintProjectModel(model) {
|
|
103
301
|
return createHash('sha256').update(JSON.stringify(model)).digest('hex');
|
|
104
302
|
}
|
|
303
|
+
export function fingerprintGuardConfig(guardConfig) {
|
|
304
|
+
return createHash('sha256')
|
|
305
|
+
.update(JSON.stringify({
|
|
306
|
+
rules: guardConfig.rules,
|
|
307
|
+
contracts: guardConfig.contracts ?? [],
|
|
308
|
+
budgets: guardConfig.budgets ?? [],
|
|
309
|
+
approval: guardConfig.approval ?? defaultGuardConfig.approval,
|
|
310
|
+
}))
|
|
311
|
+
.digest('hex');
|
|
312
|
+
}
|
|
105
313
|
export function getGuardProposalFreshness(model, proposals) {
|
|
106
314
|
if (!proposals?.projectFingerprint)
|
|
107
315
|
return 'unknown';
|
|
108
316
|
return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
|
|
109
317
|
}
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
318
|
+
function readGuardConfigFilesUnlocked(root) {
|
|
319
|
+
const rulesPath = resolve(root, GUARD_RULES_FILE);
|
|
320
|
+
if (!existsSync(rulesPath))
|
|
113
321
|
return undefined;
|
|
322
|
+
const value = readJson(rulesPath);
|
|
323
|
+
if (!isGuardConfig(value))
|
|
324
|
+
throw new GuardConfigError(GUARD_RULES_FILE);
|
|
114
325
|
const contractsPath = resolve(root, GUARD_CONTRACTS_FILE);
|
|
115
326
|
if (existsSync(contractsPath)) {
|
|
116
327
|
const contractFile = readJson(contractsPath);
|
|
117
328
|
if (!isGuardContractsFile(contractFile))
|
|
118
|
-
|
|
119
|
-
return {
|
|
329
|
+
throw new GuardConfigError(GUARD_CONTRACTS_FILE);
|
|
330
|
+
return {
|
|
331
|
+
...value,
|
|
332
|
+
contracts: contractFile.contracts,
|
|
333
|
+
budgets: value.budgets ?? [],
|
|
334
|
+
approval: value.approval ?? defaultGuardConfig.approval,
|
|
335
|
+
};
|
|
120
336
|
}
|
|
121
337
|
return {
|
|
122
338
|
...value,
|
|
123
339
|
contracts: value.contracts ?? [],
|
|
340
|
+
budgets: value.budgets ?? [],
|
|
341
|
+
approval: value.approval ?? defaultGuardConfig.approval,
|
|
124
342
|
};
|
|
125
343
|
}
|
|
344
|
+
function isGuardPolicyTransaction(value) {
|
|
345
|
+
if (value === null || typeof value !== 'object')
|
|
346
|
+
return false;
|
|
347
|
+
const transaction = value;
|
|
348
|
+
return (transaction.version === 1 &&
|
|
349
|
+
typeof transaction.createdAt === 'string' &&
|
|
350
|
+
isGuardConfig(transaction.config) &&
|
|
351
|
+
(transaction.proposals === undefined || isGuardProposalFile(transaction.proposals)));
|
|
352
|
+
}
|
|
353
|
+
function writeGuardConfigUnlocked(root, guardConfig, options = {}) {
|
|
354
|
+
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
355
|
+
const current = readGuardConfigFilesUnlocked(root);
|
|
356
|
+
const currentRevision = current?.revision ?? 0;
|
|
357
|
+
const expectedRevision = options.expectedRevision ?? guardConfig.revision;
|
|
358
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
|
|
359
|
+
throw new GuardStateStaleError();
|
|
360
|
+
}
|
|
361
|
+
const nextRevision = currentRevision + 1;
|
|
362
|
+
const contentFingerprint = fingerprintGuardConfig(guardConfig);
|
|
363
|
+
const { contracts = [], ...rulesConfig } = guardConfig;
|
|
364
|
+
atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify({ ...rulesConfig, revision: nextRevision, contentFingerprint }, null, 2)}\n`);
|
|
365
|
+
atomicWriteFile(resolve(root, GUARD_CONTRACTS_FILE), `${JSON.stringify({ version: 1, contracts }, null, 2)}\n`);
|
|
366
|
+
}
|
|
367
|
+
function recoverGuardPolicyTransaction(root) {
|
|
368
|
+
const path = resolve(root, GUARD_POLICY_TRANSACTION_FILE);
|
|
369
|
+
if (!existsSync(path))
|
|
370
|
+
return;
|
|
371
|
+
const value = readJson(path);
|
|
372
|
+
if (!isGuardPolicyTransaction(value)) {
|
|
373
|
+
throw new GuardConfigError(GUARD_POLICY_TRANSACTION_FILE);
|
|
374
|
+
}
|
|
375
|
+
writeGuardConfigUnlocked(root, value.config);
|
|
376
|
+
if (value.proposals) {
|
|
377
|
+
writeGuardArtifactUnlocked(root, GUARD_PROPOSALS_FILE, value.proposals);
|
|
378
|
+
}
|
|
379
|
+
unlinkSync(path);
|
|
380
|
+
}
|
|
381
|
+
function loadGuardConfigUnlocked(root) {
|
|
382
|
+
recoverGuardPolicyTransaction(root);
|
|
383
|
+
return readGuardConfigFilesUnlocked(root);
|
|
384
|
+
}
|
|
385
|
+
export function loadGuardConfig(root) {
|
|
386
|
+
if (!existsSync(resolve(root, GUARD_DIR)))
|
|
387
|
+
return loadGuardConfigUnlocked(root);
|
|
388
|
+
return withGuardStateLock(root, () => loadGuardConfigUnlocked(root));
|
|
389
|
+
}
|
|
390
|
+
function loadGuardProposalsUnlocked(root) {
|
|
391
|
+
const path = resolve(root, GUARD_PROPOSALS_FILE);
|
|
392
|
+
if (!existsSync(path))
|
|
393
|
+
return undefined;
|
|
394
|
+
const value = readJson(path);
|
|
395
|
+
if (value === undefined || !isGuardProposalFile(value)) {
|
|
396
|
+
throw new GuardConfigError(GUARD_PROPOSALS_FILE);
|
|
397
|
+
}
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
126
400
|
export function loadGuardProposals(root) {
|
|
127
|
-
|
|
128
|
-
|
|
401
|
+
if (!existsSync(resolve(root, GUARD_DIR)))
|
|
402
|
+
return loadGuardProposalsUnlocked(root);
|
|
403
|
+
return withGuardStateLock(root, () => {
|
|
404
|
+
recoverGuardPolicyTransaction(root);
|
|
405
|
+
return loadGuardProposalsUnlocked(root);
|
|
406
|
+
});
|
|
129
407
|
}
|
|
130
408
|
export function isGuardAgentConfig(value) {
|
|
131
409
|
return guardAgentConfigSchema.safeParse(value).success;
|
|
132
410
|
}
|
|
133
411
|
export function loadGuardAgentConfig(root) {
|
|
134
|
-
const
|
|
135
|
-
|
|
412
|
+
const path = resolve(root, GUARD_AGENT_FILE);
|
|
413
|
+
if (!existsSync(path))
|
|
414
|
+
return defaultGuardAgentConfig;
|
|
415
|
+
const value = readJson(path);
|
|
416
|
+
if (!isGuardAgentConfig(value))
|
|
417
|
+
throw new GuardConfigError(GUARD_AGENT_FILE);
|
|
418
|
+
return value;
|
|
136
419
|
}
|
|
137
420
|
export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
|
|
138
421
|
writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
|
|
139
422
|
}
|
|
140
|
-
export function loadGuardArtifact(root, relativePath) {
|
|
141
|
-
return readJson(resolve(root, relativePath));
|
|
142
|
-
}
|
|
143
423
|
export function loadBaseline(root) {
|
|
144
|
-
const
|
|
145
|
-
if (!
|
|
424
|
+
const path = resolve(root, GUARD_BASELINE_FILE);
|
|
425
|
+
if (!existsSync(path))
|
|
146
426
|
return new Set();
|
|
427
|
+
const value = readJson(path);
|
|
428
|
+
if (!Array.isArray(value))
|
|
429
|
+
throw new GuardConfigError(GUARD_BASELINE_FILE);
|
|
147
430
|
return new Set(value.filter((item) => typeof item === 'string'));
|
|
148
431
|
}
|
|
149
432
|
export function updateBaseline(root, options = {}) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
433
|
+
if ((options.add?.length ?? 0) > 0 || (options.remove?.length ?? 0) > 0) {
|
|
434
|
+
if (!options.reason?.trim())
|
|
435
|
+
throw new GuardBaselineReasonError();
|
|
436
|
+
}
|
|
437
|
+
return withGuardStateLock(root, () => {
|
|
438
|
+
const next = loadBaseline(root);
|
|
439
|
+
for (const fingerprint of options.add ?? [])
|
|
440
|
+
next.add(fingerprint);
|
|
441
|
+
for (const fingerprint of options.remove ?? [])
|
|
442
|
+
next.delete(fingerprint);
|
|
443
|
+
const fingerprints = [...next].sort();
|
|
444
|
+
const metadataPath = resolve(root, GUARD_BASELINE_META_FILE);
|
|
445
|
+
const previous = readJson(metadataPath);
|
|
446
|
+
const history = previous !== null &&
|
|
447
|
+
typeof previous === 'object' &&
|
|
448
|
+
Array.isArray(previous.decisions)
|
|
449
|
+
? previous.decisions
|
|
450
|
+
: [];
|
|
451
|
+
const decision = {
|
|
452
|
+
at: new Date().toISOString(),
|
|
453
|
+
action: (options.add?.length ?? 0) > 0 ? 'accept' : 'remove',
|
|
454
|
+
fingerprints: [...(options.add ?? []), ...(options.remove ?? [])],
|
|
455
|
+
...(options.reason?.trim() ? { reason: options.reason.trim() } : {}),
|
|
456
|
+
};
|
|
457
|
+
atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
|
|
458
|
+
atomicWriteFile(metadataPath, `${JSON.stringify({
|
|
459
|
+
...(previous !== null && typeof previous === 'object' ? previous : {}),
|
|
460
|
+
version: 1,
|
|
461
|
+
generatedAt: new Date().toISOString(),
|
|
462
|
+
findings: fingerprints.length,
|
|
463
|
+
decisions: [...history, decision],
|
|
464
|
+
}, null, 2)}\n`);
|
|
465
|
+
return next;
|
|
466
|
+
});
|
|
178
467
|
}
|
|
179
|
-
export function writeGuardConfig(root, guardConfig) {
|
|
180
|
-
|
|
181
|
-
const { contracts = [], ...rulesConfig } = guardConfig;
|
|
182
|
-
atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify(rulesConfig, null, 2)}\n`);
|
|
183
|
-
atomicWriteFile(resolve(root, GUARD_CONTRACTS_FILE), `${JSON.stringify({ version: 1, contracts }, null, 2)}\n`);
|
|
468
|
+
export function writeGuardConfig(root, guardConfig, options = {}) {
|
|
469
|
+
withGuardStateLock(root, () => writeGuardConfigUnlocked(root, guardConfig, options), options);
|
|
184
470
|
}
|
|
185
471
|
export function writeGuardProposals(root, proposals) {
|
|
186
472
|
writeGuardArtifact(root, GUARD_PROPOSALS_FILE, proposals);
|
|
187
473
|
}
|
|
188
|
-
|
|
189
|
-
const proposals =
|
|
474
|
+
function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
|
|
475
|
+
const proposals = loadGuardProposalsUnlocked(root);
|
|
190
476
|
if (!proposals || decisions.length === 0)
|
|
191
477
|
return;
|
|
192
478
|
const decidedAt = new Date().toISOString();
|
|
479
|
+
const commit = (() => {
|
|
480
|
+
try {
|
|
481
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
|
482
|
+
.toString()
|
|
483
|
+
.trim();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
})();
|
|
193
489
|
const nextDecisions = decisions.map((decision) => ({
|
|
194
490
|
...decision,
|
|
195
491
|
decidedAt,
|
|
492
|
+
source: options.source,
|
|
493
|
+
...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
|
|
494
|
+
...(commit ? { commit } : {}),
|
|
196
495
|
...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
|
|
197
496
|
...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
|
|
198
497
|
...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
|
|
199
498
|
}));
|
|
200
|
-
|
|
499
|
+
return {
|
|
201
500
|
...proposals,
|
|
202
501
|
decisions: [...(proposals.decisions ?? []), ...nextDecisions],
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function appendGuardProposalDecisionsUnlocked(root, decisions, options) {
|
|
505
|
+
const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
|
|
506
|
+
if (!proposals)
|
|
507
|
+
return;
|
|
508
|
+
writeGuardArtifactUnlocked(root, GUARD_PROPOSALS_FILE, proposals);
|
|
509
|
+
}
|
|
510
|
+
export function recordGuardProposalDecision(root, decisions, options = { source: 'external' }) {
|
|
511
|
+
withGuardStateLock(root, () => {
|
|
512
|
+
appendGuardProposalDecisionsUnlocked(root, decisions, options);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
export function applyGuardProposalDecision(root, guardConfig, decisions, options) {
|
|
516
|
+
withGuardStateLock(root, () => {
|
|
517
|
+
const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
|
|
518
|
+
writeGuardArtifactUnlocked(root, GUARD_POLICY_TRANSACTION_FILE, {
|
|
519
|
+
version: 1,
|
|
520
|
+
createdAt: new Date().toISOString(),
|
|
521
|
+
config: guardConfig,
|
|
522
|
+
...(proposals ? { proposals } : {}),
|
|
523
|
+
});
|
|
524
|
+
writeGuardConfigUnlocked(root, guardConfig);
|
|
525
|
+
if (proposals)
|
|
526
|
+
writeGuardArtifactUnlocked(root, GUARD_PROPOSALS_FILE, proposals);
|
|
527
|
+
unlinkSync(resolve(root, GUARD_POLICY_TRANSACTION_FILE));
|
|
203
528
|
});
|
|
204
529
|
}
|
|
530
|
+
export function validateGuardProposalApproval(policy, proposals) {
|
|
531
|
+
if (policy?.mode !== 'protected' || !policy.requireDistinctActor)
|
|
532
|
+
return undefined;
|
|
533
|
+
const actor = process.env.GUARD_APPROVER?.trim();
|
|
534
|
+
if (!actor) {
|
|
535
|
+
return 'Protected Guard policy requires GUARD_APPROVER for a distinct approval actor.';
|
|
536
|
+
}
|
|
537
|
+
if (proposals?.generatedBy && proposals.generatedBy === actor) {
|
|
538
|
+
return 'The proposal author and approval actor must be different.';
|
|
539
|
+
}
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
export function getPendingGuardProposals(proposals) {
|
|
543
|
+
if (!proposals)
|
|
544
|
+
return [];
|
|
545
|
+
const decisions = new Map();
|
|
546
|
+
for (const decision of proposals.decisions ?? []) {
|
|
547
|
+
const current = decisions.get(`${decision.type}:${decision.id}`);
|
|
548
|
+
if (!current || decision.decidedAt >= current.decidedAt) {
|
|
549
|
+
decisions.set(`${decision.type}:${decision.id}`, decision);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const matchesCurrentProposal = (decision) => decision !== undefined &&
|
|
553
|
+
(proposals.proposalId === undefined || decision.proposalId === proposals.proposalId) &&
|
|
554
|
+
(proposals.contentFingerprint === undefined ||
|
|
555
|
+
decision.proposalFingerprint === proposals.contentFingerprint) &&
|
|
556
|
+
(proposals.revision === undefined || decision.revision === proposals.revision);
|
|
557
|
+
return [
|
|
558
|
+
...proposals.rules
|
|
559
|
+
.filter((rule) => rule.status === 'proposed' && !matchesCurrentProposal(decisions.get(`rule:${rule.id}`)))
|
|
560
|
+
.map((rule) => ({ id: rule.id, type: 'rule' })),
|
|
561
|
+
...proposals.contracts
|
|
562
|
+
.filter((contract) => contract.status === 'proposed' &&
|
|
563
|
+
!matchesCurrentProposal(decisions.get(`contract:${contract.id}`)))
|
|
564
|
+
.map((contract) => ({ id: contract.id, type: 'contract' })),
|
|
565
|
+
];
|
|
566
|
+
}
|
|
205
567
|
export function buildGuardProposals(model, guardConfig) {
|
|
206
568
|
const capabilities = Object.keys(model.capabilities);
|
|
207
569
|
const questions = [];
|
|
@@ -249,6 +611,9 @@ export function buildGuardProposals(model, guardConfig) {
|
|
|
249
611
|
const proposal = {
|
|
250
612
|
version: 1,
|
|
251
613
|
generatedAt: new Date().toISOString(),
|
|
614
|
+
...(process.env.GUARD_PROPOSER?.trim()
|
|
615
|
+
? { generatedBy: process.env.GUARD_PROPOSER.trim() }
|
|
616
|
+
: {}),
|
|
252
617
|
rules: guardConfig.rules.filter((rule) => rule.status === 'proposed'),
|
|
253
618
|
contracts: [
|
|
254
619
|
...(guardConfig.contracts ?? []).filter((contract) => contract.status === 'proposed'),
|
|
@@ -347,14 +712,19 @@ export function buildConventionsMemory(model) {
|
|
|
347
712
|
}
|
|
348
713
|
export function buildGeneratedGuardConfig(model) {
|
|
349
714
|
const isPersistenceImport = (importPath) => /(?:^|\/)(?:database|db)(?:\/|$)/i.test(importPath) ||
|
|
350
|
-
/^(?:@prisma
|
|
715
|
+
/^(?:@prisma\/client|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
|
|
716
|
+
const moduleReferences = (module) => [
|
|
717
|
+
...module.imports,
|
|
718
|
+
...module.exports,
|
|
719
|
+
...module.dynamicImports,
|
|
720
|
+
];
|
|
351
721
|
const persistenceImports = [
|
|
352
|
-
...new Set(model.modules.flatMap((module) => module
|
|
722
|
+
...new Set(model.modules.flatMap((module) => moduleReferences(module)).filter(isPersistenceImport)),
|
|
353
723
|
].sort();
|
|
354
724
|
const clientPersistenceFiles = model.insights.boundaries
|
|
355
725
|
.find((boundary) => boundary.kind === 'client')
|
|
356
726
|
?.files.filter((file) => model.modules.some((module) => module.path === file &&
|
|
357
|
-
module.
|
|
727
|
+
moduleReferences(module).some((importPath) => persistenceImports.includes(importPath)))) ?? [];
|
|
358
728
|
const proposedRules = clientPersistenceFiles.length > 0
|
|
359
729
|
? [
|
|
360
730
|
{
|
|
@@ -372,23 +742,37 @@ export function buildGeneratedGuardConfig(model) {
|
|
|
372
742
|
return { ...defaultGuardConfig, rules: proposedRules };
|
|
373
743
|
}
|
|
374
744
|
export function writeGuardMemory(root, model) {
|
|
375
|
-
|
|
376
|
-
|
|
745
|
+
withGuardStateLock(root, () => {
|
|
746
|
+
writeGuardArtifact(root, GUARD_ARCHITECTURE_FILE, buildArchitectureMemory(model));
|
|
747
|
+
writeGuardArtifact(root, GUARD_CONVENTIONS_FILE, buildConventionsMemory(model));
|
|
748
|
+
});
|
|
377
749
|
}
|
|
378
750
|
export function writeBaseline(root, findings, model) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
751
|
+
withGuardStateLock(root, () => {
|
|
752
|
+
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
753
|
+
const fingerprints = [...new Set(findings.map((finding) => finding.fingerprint))].sort();
|
|
754
|
+
atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
|
|
755
|
+
atomicWriteFile(resolve(root, GUARD_BASELINE_META_FILE), `${JSON.stringify({
|
|
756
|
+
version: 1,
|
|
757
|
+
generatedAt: new Date().toISOString(),
|
|
758
|
+
findings: fingerprints.length,
|
|
759
|
+
...(model ? { projectFingerprint: fingerprintProjectModel(model) } : {}),
|
|
760
|
+
guardSchemaVersion: 1,
|
|
761
|
+
purpose: 'Initial Guard state; findings are suppressed unless they change fingerprint.',
|
|
762
|
+
}, null, 2)}\n`);
|
|
763
|
+
});
|
|
390
764
|
}
|
|
391
765
|
export function loadProjectModel(root) {
|
|
766
|
+
const current = readJson(resolve(root, GUARD_STATE_CURRENT_FILE));
|
|
767
|
+
if (current !== undefined &&
|
|
768
|
+
typeof current === 'object' &&
|
|
769
|
+
safeGeneration(current.generation)) {
|
|
770
|
+
const generated = readJson(resolve(root, GUARD_STATE_GENERATIONS_DIR, current.generation, 'project.json'));
|
|
771
|
+
if (generated !== undefined &&
|
|
772
|
+
typeof generated === 'object' &&
|
|
773
|
+
generated.version === 1)
|
|
774
|
+
return generated;
|
|
775
|
+
}
|
|
392
776
|
const value = readJson(resolve(root, GUARD_PROJECT_FILE));
|
|
393
777
|
if (value === null ||
|
|
394
778
|
typeof value !== 'object' ||
|
|
@@ -398,8 +782,40 @@ export function loadProjectModel(root) {
|
|
|
398
782
|
return value;
|
|
399
783
|
}
|
|
400
784
|
export function writeProjectModel(root, model) {
|
|
401
|
-
|
|
402
|
-
|
|
785
|
+
withGuardStateLock(root, () => {
|
|
786
|
+
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
787
|
+
atomicWriteFile(resolve(root, GUARD_PROJECT_FILE), `${JSON.stringify(model, null, 2)}\n`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Persist all derived project facts as one generation. Legacy root-level files remain as
|
|
792
|
+
* compatibility mirrors, while readers that understand generations always see a coherent set.
|
|
793
|
+
*/
|
|
794
|
+
export function writeProjectState(root, model, options = {}) {
|
|
795
|
+
return withGuardStateLock(root, () => {
|
|
796
|
+
const generationsRoot = resolve(root, GUARD_STATE_GENERATIONS_DIR);
|
|
797
|
+
mkdirSync(generationsRoot, { recursive: true });
|
|
798
|
+
const generation = `${Date.now()}-${randomUUID()}`;
|
|
799
|
+
const temporaryRoot = resolve(generationsRoot, `.tmp-${process.pid}-${randomUUID()}`);
|
|
800
|
+
mkdirSync(temporaryRoot, { recursive: true });
|
|
801
|
+
const derived = {
|
|
802
|
+
project: model,
|
|
803
|
+
architecture: buildArchitectureMemory(model),
|
|
804
|
+
conventions: buildConventionsMemory(model),
|
|
805
|
+
};
|
|
806
|
+
for (const [name, value] of Object.entries(derived)) {
|
|
807
|
+
writeFileSync(resolve(temporaryRoot, `${name}.json`), `${JSON.stringify(value, null, 2)}\n`);
|
|
808
|
+
}
|
|
809
|
+
writeFileSync(resolve(temporaryRoot, 'manifest.json'), `${JSON.stringify({ version: 1, generation, projectFingerprint: fingerprintProjectModel(model) }, null, 2)}\n`);
|
|
810
|
+
renameSync(temporaryRoot, resolve(generationsRoot, generation));
|
|
811
|
+
atomicWriteFile(resolve(root, GUARD_STATE_CURRENT_FILE), `${JSON.stringify({ version: 1, generation, projectFingerprint: fingerprintProjectModel(model) }, null, 2)}\n`);
|
|
812
|
+
atomicWriteFile(resolve(root, GUARD_PROJECT_FILE), `${JSON.stringify(model, null, 2)}\n`);
|
|
813
|
+
atomicWriteFile(resolve(root, GUARD_ARCHITECTURE_FILE), `${JSON.stringify(derived.architecture, null, 2)}\n`);
|
|
814
|
+
atomicWriteFile(resolve(root, GUARD_CONVENTIONS_FILE), `${JSON.stringify(derived.conventions, null, 2)}\n`);
|
|
815
|
+
// Snapshot persistence is defined below the generation writer to keep the public API grouped.
|
|
816
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
817
|
+
return writeProjectSnapshot(root, model);
|
|
818
|
+
}, options);
|
|
403
819
|
}
|
|
404
820
|
function projectRevision(root) {
|
|
405
821
|
try {
|
|
@@ -413,16 +829,61 @@ function projectRevision(root) {
|
|
|
413
829
|
}
|
|
414
830
|
}
|
|
415
831
|
export function writeProjectSnapshot(root, model) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
832
|
+
return withGuardStateLock(root, () => {
|
|
833
|
+
const baseRevision = projectRevision(root);
|
|
834
|
+
const revision = model.git.dirty && baseRevision !== 'working-tree'
|
|
835
|
+
? `${baseRevision}-working-tree-${createHash('sha256')
|
|
836
|
+
.update(JSON.stringify(model))
|
|
837
|
+
.digest('hex')
|
|
838
|
+
.slice(0, 12)}`
|
|
839
|
+
: baseRevision;
|
|
840
|
+
mkdirSync(resolve(root, GUARD_HISTORY_DIR), { recursive: true });
|
|
841
|
+
atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
|
|
842
|
+
return revision;
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
function isProjectPath(root, value) {
|
|
846
|
+
const normalized = value.replace(/\\/g, '/');
|
|
847
|
+
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const projectRoot = `${realpathSync(root)}${sep}`;
|
|
852
|
+
const targetPath = resolve(root, normalized);
|
|
853
|
+
let target;
|
|
854
|
+
try {
|
|
855
|
+
target = realpathSync(targetPath);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
target = realpathSync(dirname(targetPath));
|
|
859
|
+
}
|
|
860
|
+
return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
export function loadGuardArtifact(root, relativePath) {
|
|
867
|
+
if (!isProjectPath(root, relativePath))
|
|
868
|
+
return undefined;
|
|
869
|
+
const generatedName = relativePath === GUARD_PROJECT_FILE
|
|
870
|
+
? 'project.json'
|
|
871
|
+
: relativePath === GUARD_ARCHITECTURE_FILE
|
|
872
|
+
? 'architecture.json'
|
|
873
|
+
: relativePath === GUARD_CONVENTIONS_FILE
|
|
874
|
+
? 'conventions.json'
|
|
875
|
+
: undefined;
|
|
876
|
+
if (generatedName) {
|
|
877
|
+
const current = readJson(resolve(root, GUARD_STATE_CURRENT_FILE));
|
|
878
|
+
if (current !== undefined &&
|
|
879
|
+
typeof current === 'object' &&
|
|
880
|
+
safeGeneration(current.generation)) {
|
|
881
|
+
const generated = readJson(resolve(root, GUARD_STATE_GENERATIONS_DIR, current.generation, generatedName));
|
|
882
|
+
if (generated !== undefined)
|
|
883
|
+
return generated;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return readJson(resolve(root, relativePath));
|
|
426
887
|
}
|
|
427
888
|
export function validateGuardContracts(root, contracts = []) {
|
|
428
889
|
const issues = [];
|
|
@@ -445,8 +906,18 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
445
906
|
message: 'Required-call contracts need at least one mustCall pattern.',
|
|
446
907
|
});
|
|
447
908
|
}
|
|
909
|
+
if (contract.kind === 'package-boundary' &&
|
|
910
|
+
((contract.fromPackages?.length ?? 0) === 0 ||
|
|
911
|
+
(contract.mustNotImportPackages?.length ?? 0) === 0)) {
|
|
912
|
+
issues.push({
|
|
913
|
+
contractId: contract.id,
|
|
914
|
+
field: 'definition',
|
|
915
|
+
value: contract.kind,
|
|
916
|
+
message: 'Package-boundary contracts need fromPackages and mustNotImportPackages patterns.',
|
|
917
|
+
});
|
|
918
|
+
}
|
|
448
919
|
for (const scope of contract.scope ?? []) {
|
|
449
|
-
if (!existsSync(resolve(root, scope))) {
|
|
920
|
+
if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
|
|
450
921
|
issues.push({
|
|
451
922
|
contractId: contract.id,
|
|
452
923
|
field: 'scope',
|
|
@@ -456,7 +927,7 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
456
927
|
}
|
|
457
928
|
}
|
|
458
929
|
for (const entrypoint of contract.entrypoints ?? []) {
|
|
459
|
-
if (!existsSync(resolve(root, entrypoint))) {
|
|
930
|
+
if (!isProjectPath(root, entrypoint) || !existsSync(resolve(root, entrypoint))) {
|
|
460
931
|
issues.push({
|
|
461
932
|
contractId: contract.id,
|
|
462
933
|
field: 'scope',
|
|
@@ -466,7 +937,7 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
466
937
|
}
|
|
467
938
|
}
|
|
468
939
|
for (const excluded of contract.exclude ?? []) {
|
|
469
|
-
if (!existsSync(resolve(root, excluded))) {
|
|
940
|
+
if (!isProjectPath(root, excluded) || !existsSync(resolve(root, excluded))) {
|
|
470
941
|
issues.push({
|
|
471
942
|
contractId: contract.id,
|
|
472
943
|
field: 'scope',
|
|
@@ -476,7 +947,7 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
476
947
|
}
|
|
477
948
|
}
|
|
478
949
|
for (const reference of contract.references ?? []) {
|
|
479
|
-
if (!existsSync(resolve(root, reference))) {
|
|
950
|
+
if (!isProjectPath(root, reference) || !existsSync(resolve(root, reference))) {
|
|
480
951
|
issues.push({
|
|
481
952
|
contractId: contract.id,
|
|
482
953
|
field: 'reference',
|
|
@@ -488,6 +959,51 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
488
959
|
}
|
|
489
960
|
return issues;
|
|
490
961
|
}
|
|
962
|
+
export function validateGuardBudgets(root, budgets = []) {
|
|
963
|
+
const issues = [];
|
|
964
|
+
for (const budget of budgets) {
|
|
965
|
+
if (!budget.reason.trim()) {
|
|
966
|
+
issues.push({
|
|
967
|
+
contractId: `budget:${budget.id}`,
|
|
968
|
+
field: 'definition',
|
|
969
|
+
value: budget.id,
|
|
970
|
+
message: 'Budgets require a written reason.',
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
for (const rawScope of budget.scope) {
|
|
974
|
+
const scope = rawScope.replace(/\*+$/, '') || '.';
|
|
975
|
+
if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
|
|
976
|
+
issues.push({
|
|
977
|
+
contractId: `budget:${budget.id}`,
|
|
978
|
+
field: 'scope',
|
|
979
|
+
value: rawScope,
|
|
980
|
+
message: `Budget scope does not exist inside the project: ${rawScope}`,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return issues;
|
|
986
|
+
}
|
|
987
|
+
export function validateGuardPolicy(root, guardConfig) {
|
|
988
|
+
const issues = [];
|
|
989
|
+
for (const rule of guardConfig.rules) {
|
|
990
|
+
for (const file of rule.files ?? []) {
|
|
991
|
+
if (!isProjectPath(root, file)) {
|
|
992
|
+
issues.push({
|
|
993
|
+
contractId: `rule:${rule.id}`,
|
|
994
|
+
field: 'files',
|
|
995
|
+
value: file,
|
|
996
|
+
message: `Rule file scope must stay inside the project: ${file}`,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return [
|
|
1002
|
+
...issues,
|
|
1003
|
+
...validateGuardContracts(root, guardConfig.contracts ?? []),
|
|
1004
|
+
...validateGuardBudgets(root, guardConfig.budgets ?? []),
|
|
1005
|
+
];
|
|
1006
|
+
}
|
|
491
1007
|
function isSourceFile(file) {
|
|
492
1008
|
return /\.(?:ts|tsx|js|jsx)$/.test(file) && !/\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/.test(file);
|
|
493
1009
|
}
|
|
@@ -541,6 +1057,32 @@ function importLine(sourceFile, importPath) {
|
|
|
541
1057
|
});
|
|
542
1058
|
return dynamicImport?.getStartLineNumber() ?? 1;
|
|
543
1059
|
}
|
|
1060
|
+
function moduleImportReferences(module) {
|
|
1061
|
+
const sources = [...new Set([...module.imports, ...module.exports, ...module.dynamicImports])];
|
|
1062
|
+
return sources.map((source) => ({ source, resolved: module.resolvedImportMap?.[source] }));
|
|
1063
|
+
}
|
|
1064
|
+
function packageSelectorMatches(value, selector) {
|
|
1065
|
+
if (!value)
|
|
1066
|
+
return false;
|
|
1067
|
+
return selector.endsWith('*') ? value.startsWith(selector.slice(0, -1)) : value === selector;
|
|
1068
|
+
}
|
|
1069
|
+
function packageForFile(model, file) {
|
|
1070
|
+
const packageInfo = model.project.workspacePackages
|
|
1071
|
+
.filter((workspace) => workspace.path === '.' ||
|
|
1072
|
+
file === `${workspace.path}/package.json` ||
|
|
1073
|
+
file.startsWith(`${workspace.path}/`))
|
|
1074
|
+
.sort((left, right) => right.path.length - left.path.length)[0] ?? null;
|
|
1075
|
+
return packageInfo === null ? undefined : (packageInfo.name ?? packageInfo.path);
|
|
1076
|
+
}
|
|
1077
|
+
function packageForImport(model, reference) {
|
|
1078
|
+
const resolvedPackage = reference.resolved
|
|
1079
|
+
? packageForFile(model, reference.resolved)
|
|
1080
|
+
: undefined;
|
|
1081
|
+
if (resolvedPackage)
|
|
1082
|
+
return resolvedPackage;
|
|
1083
|
+
return model.project.workspacePackages.find((workspace) => workspace.name === reference.source ||
|
|
1084
|
+
(workspace.name !== undefined && reference.source.startsWith(`${workspace.name}/`)))?.name;
|
|
1085
|
+
}
|
|
544
1086
|
function scanFile(root, file, rules, module, astProject) {
|
|
545
1087
|
if (!module)
|
|
546
1088
|
return [];
|
|
@@ -555,26 +1097,25 @@ function scanFile(root, file, rules, module, astProject) {
|
|
|
555
1097
|
}
|
|
556
1098
|
}
|
|
557
1099
|
const findings = [];
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
];
|
|
561
|
-
for (const importPath of importPaths) {
|
|
562
|
-
const line = sourceFile ? importLine(sourceFile, importPath) : 1;
|
|
1100
|
+
for (const reference of moduleImportReferences(module)) {
|
|
1101
|
+
const line = sourceFile ? importLine(sourceFile, reference.source) : 1;
|
|
563
1102
|
for (const rule of rules) {
|
|
564
1103
|
if (rule.status === 'proposed' || !ruleAppliesToFile(rule, file))
|
|
565
1104
|
continue;
|
|
566
1105
|
if (rule.kind === 'client-forbidden-import' && !isClient)
|
|
567
1106
|
continue;
|
|
568
|
-
if (!rule.patterns.some((pattern) => patternMatches(
|
|
1107
|
+
if (!rule.patterns.some((pattern) => patternMatches(reference.source, pattern) ||
|
|
1108
|
+
(reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))
|
|
569
1109
|
continue;
|
|
570
1110
|
findings.push({
|
|
571
1111
|
ruleId: rule.id,
|
|
572
1112
|
severity: rule.severity,
|
|
573
1113
|
file,
|
|
574
1114
|
line,
|
|
575
|
-
importPath,
|
|
1115
|
+
importPath: reference.source,
|
|
1116
|
+
...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
|
|
576
1117
|
message: rule.description,
|
|
577
|
-
fingerprint: `${rule.id}|${file}|${
|
|
1118
|
+
fingerprint: `${rule.id}|${file}|${reference.source}|${reference.resolved ?? ''}`,
|
|
578
1119
|
});
|
|
579
1120
|
}
|
|
580
1121
|
}
|
|
@@ -589,22 +1130,25 @@ function scanContracts(root, contracts, changed) {
|
|
|
589
1130
|
const severity = contract.severity ?? 'error';
|
|
590
1131
|
for (const module of model.modules.filter((item) => (!changed || changed.has(item.path)) && contractAppliesToFile(contract, item.path))) {
|
|
591
1132
|
if (contract.kind === 'import-boundary') {
|
|
592
|
-
for (const
|
|
593
|
-
if (contract.mustNotImport?.some((pattern) => patternMatches(
|
|
1133
|
+
for (const reference of moduleImportReferences(module)) {
|
|
1134
|
+
if (contract.mustNotImport?.some((pattern) => patternMatches(reference.source, pattern) ||
|
|
1135
|
+
(reference.resolved !== undefined && patternMatches(reference.resolved, pattern)))) {
|
|
594
1136
|
findings.push({
|
|
595
1137
|
ruleId: `contract:${contract.id}`,
|
|
596
1138
|
severity,
|
|
597
1139
|
file: module.path,
|
|
598
1140
|
line: 1,
|
|
599
|
-
importPath,
|
|
1141
|
+
importPath: reference.source,
|
|
1142
|
+
...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
|
|
600
1143
|
message: contract.statement,
|
|
601
|
-
fingerprint: `contract-import|${contract.id}|${module.path}|${
|
|
1144
|
+
fingerprint: `contract-import|${contract.id}|${module.path}|${reference.source}|${reference.resolved ?? ''}`,
|
|
602
1145
|
});
|
|
603
1146
|
}
|
|
604
1147
|
}
|
|
605
1148
|
if (contract.mustImport !== undefined &&
|
|
606
1149
|
contract.mustImport.length > 0 &&
|
|
607
|
-
!module.
|
|
1150
|
+
!moduleImportReferences(module).some((reference) => contract.mustImport?.some((pattern) => patternMatches(reference.source, pattern) ||
|
|
1151
|
+
(reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))) {
|
|
608
1152
|
findings.push({
|
|
609
1153
|
ruleId: `contract:${contract.id}`,
|
|
610
1154
|
severity,
|
|
@@ -630,6 +1174,28 @@ function scanContracts(root, contracts, changed) {
|
|
|
630
1174
|
fingerprint: `contract-required-call|${contract.id}|${module.path}`,
|
|
631
1175
|
});
|
|
632
1176
|
}
|
|
1177
|
+
if (contract.kind === 'package-boundary') {
|
|
1178
|
+
const sourcePackage = packageForFile(model, module.path);
|
|
1179
|
+
if (!contract.fromPackages?.some((selector) => packageSelectorMatches(sourcePackage, selector))) {
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
for (const reference of moduleImportReferences(module)) {
|
|
1183
|
+
const targetPackage = packageForImport(model, reference);
|
|
1184
|
+
if (!contract.mustNotImportPackages?.some((selector) => packageSelectorMatches(targetPackage, selector))) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
findings.push({
|
|
1188
|
+
ruleId: `contract:${contract.id}`,
|
|
1189
|
+
severity,
|
|
1190
|
+
file: module.path,
|
|
1191
|
+
line: 1,
|
|
1192
|
+
importPath: reference.source,
|
|
1193
|
+
...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
|
|
1194
|
+
message: contract.statement,
|
|
1195
|
+
fingerprint: `contract-package|${contract.id}|${module.path}|${reference.source}|${targetPackage ?? ''}`,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
633
1199
|
}
|
|
634
1200
|
}
|
|
635
1201
|
return findings;
|
|
@@ -657,6 +1223,30 @@ function changedFiles(root) {
|
|
|
657
1223
|
return undefined;
|
|
658
1224
|
}
|
|
659
1225
|
}
|
|
1226
|
+
function changedImpactScope(model, changed) {
|
|
1227
|
+
if (!changed)
|
|
1228
|
+
return undefined;
|
|
1229
|
+
const graph = buildModuleTargetGraph(model.modules);
|
|
1230
|
+
const reverse = new Map();
|
|
1231
|
+
for (const [from, targets] of graph) {
|
|
1232
|
+
for (const target of targets)
|
|
1233
|
+
reverse.set(target, [...(reverse.get(target) ?? []), from]);
|
|
1234
|
+
}
|
|
1235
|
+
const scope = new Set(changed);
|
|
1236
|
+
const queue = [...changed];
|
|
1237
|
+
while (queue.length > 0) {
|
|
1238
|
+
const current = queue.shift();
|
|
1239
|
+
if (!current)
|
|
1240
|
+
continue;
|
|
1241
|
+
for (const dependent of reverse.get(current) ?? []) {
|
|
1242
|
+
if (!scope.has(dependent)) {
|
|
1243
|
+
scope.add(dependent);
|
|
1244
|
+
queue.push(dependent);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return scope;
|
|
1249
|
+
}
|
|
660
1250
|
function architectureInsightFindings(root, changed) {
|
|
661
1251
|
const model = discoverProject(root);
|
|
662
1252
|
const findings = model.insights.cycles
|
|
@@ -677,7 +1267,9 @@ function architectureInsightFindings(root, changed) {
|
|
|
677
1267
|
for (const module of model.modules) {
|
|
678
1268
|
if (!clientFiles.has(module.path))
|
|
679
1269
|
continue;
|
|
680
|
-
for (const importPath of
|
|
1270
|
+
for (const importPath of [
|
|
1271
|
+
...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
|
|
1272
|
+
].filter((value) => serverOnlyImports.test(value))) {
|
|
681
1273
|
if (changed && !changed.has(module.path))
|
|
682
1274
|
continue;
|
|
683
1275
|
findings.push({
|
|
@@ -709,6 +1301,49 @@ function architectureInsightFindings(root, changed) {
|
|
|
709
1301
|
}
|
|
710
1302
|
return findings;
|
|
711
1303
|
}
|
|
1304
|
+
function budgetMatchesFile(budget, file) {
|
|
1305
|
+
return budget.scope.some((scope) => scope.endsWith('*')
|
|
1306
|
+
? file.startsWith(scope.slice(0, -1))
|
|
1307
|
+
: file === scope || file.startsWith(`${scope}/`));
|
|
1308
|
+
}
|
|
1309
|
+
function budgetValue(root, metric, file, module) {
|
|
1310
|
+
if (metric === 'bytes')
|
|
1311
|
+
return file.bytes;
|
|
1312
|
+
if (metric === 'imports') {
|
|
1313
|
+
return module
|
|
1314
|
+
? new Set([...module.imports, ...module.exports, ...module.dynamicImports]).size
|
|
1315
|
+
: 0;
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
return readFileSync(resolve(root, file.path), 'utf8').split(/\r?\n/).length;
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
return 0;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
function scanBudgets(root, model, budgets, changed) {
|
|
1325
|
+
const modules = new Map(model.modules.map((module) => [module.path, module]));
|
|
1326
|
+
return budgets
|
|
1327
|
+
.filter((budget) => budget.status !== 'proposed')
|
|
1328
|
+
.flatMap((budget) => model.files
|
|
1329
|
+
.filter((file) => budgetMatchesFile(budget, file.path) && (!changed || changed.has(file.path)))
|
|
1330
|
+
.flatMap((file) => {
|
|
1331
|
+
const value = budgetValue(root, budget.metric, file, modules.get(file.path));
|
|
1332
|
+
if (value <= budget.limit)
|
|
1333
|
+
return [];
|
|
1334
|
+
return [
|
|
1335
|
+
{
|
|
1336
|
+
ruleId: `budget:${budget.id}`,
|
|
1337
|
+
severity: budget.severity,
|
|
1338
|
+
file: file.path,
|
|
1339
|
+
line: 1,
|
|
1340
|
+
importPath: `budget:${budget.metric}`,
|
|
1341
|
+
message: `${budget.description} (${value} ${budget.metric}; limit ${budget.limit}).`,
|
|
1342
|
+
fingerprint: `budget|${budget.id}|${file.path}|${budget.metric}`,
|
|
1343
|
+
},
|
|
1344
|
+
];
|
|
1345
|
+
}));
|
|
1346
|
+
}
|
|
712
1347
|
function isSafeReviewFile(file) {
|
|
713
1348
|
return !(file === '.env' ||
|
|
714
1349
|
file.startsWith('.env.') ||
|
|
@@ -716,25 +1351,7 @@ function isSafeReviewFile(file) {
|
|
|
716
1351
|
/\.(?:pem|key|p12|pfx)$/i.test(file));
|
|
717
1352
|
}
|
|
718
1353
|
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
|
-
}
|
|
1354
|
+
return isProjectPath(root, file);
|
|
738
1355
|
}
|
|
739
1356
|
function parseGitChanges(root, args) {
|
|
740
1357
|
try {
|
|
@@ -780,6 +1397,9 @@ export function redactSensitiveText(value) {
|
|
|
780
1397
|
};
|
|
781
1398
|
replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]');
|
|
782
1399
|
replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]');
|
|
1400
|
+
replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED JWT]');
|
|
1401
|
+
replace(/\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, '[REDACTED URL CREDENTIALS]@');
|
|
1402
|
+
replace(/\b(?:AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})\b/g, '[REDACTED TOKEN]');
|
|
783
1403
|
replace(/((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\s*[:=]\s*["']?)[^\s"'`,}]+/gi, '$1[REDACTED]');
|
|
784
1404
|
return { value: redactedValue, redacted, redactionCount };
|
|
785
1405
|
}
|
|
@@ -898,9 +1518,10 @@ function reviewDiff(root, maxChars, base) {
|
|
|
898
1518
|
}
|
|
899
1519
|
}
|
|
900
1520
|
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
1521
|
const model = discoverProject(root);
|
|
1522
|
+
const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
|
|
1523
|
+
const scope = changedImpactScope(model, changed);
|
|
1524
|
+
const files = listSourceFiles(root).filter((file) => !scope || scope.has(file));
|
|
904
1525
|
const modules = new Map(model.modules.map((module) => [module.path, module]));
|
|
905
1526
|
const astProject = (() => {
|
|
906
1527
|
try {
|
|
@@ -912,8 +1533,9 @@ export function scanGuard(root, guardConfig, options = {}) {
|
|
|
912
1533
|
})();
|
|
913
1534
|
const allFindings = [
|
|
914
1535
|
...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
|
|
915
|
-
...scanContracts(root, guardConfig.contracts ?? [],
|
|
916
|
-
...(
|
|
1536
|
+
...scanContracts(root, guardConfig.contracts ?? [], scope),
|
|
1537
|
+
...scanBudgets(root, model, guardConfig.budgets ?? [], scope),
|
|
1538
|
+
...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
|
|
917
1539
|
];
|
|
918
1540
|
const baseline = options.baseline ?? new Set();
|
|
919
1541
|
const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
|
|
@@ -965,22 +1587,22 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
|
|
|
965
1587
|
};
|
|
966
1588
|
}
|
|
967
1589
|
export function initializeGuard(root, options = {}) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
|
|
1590
|
+
return withGuardStateLock(root, () => {
|
|
1591
|
+
if (existsSync(resolve(root, GUARD_BASELINE_FILE)) && !options.force) {
|
|
1592
|
+
throw new GuardAlreadyInitializedError();
|
|
1593
|
+
}
|
|
1594
|
+
const projectModel = discoverProject(root);
|
|
1595
|
+
const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
|
|
1596
|
+
ensureGeneratedStateIgnored(root, projectModel);
|
|
1597
|
+
writeGuardConfig(root, guardConfig);
|
|
1598
|
+
if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
|
|
1599
|
+
writeGuardAgentConfig(root);
|
|
1600
|
+
}
|
|
1601
|
+
writeProjectState(root, projectModel);
|
|
1602
|
+
writeGuardProposals(root, buildGuardProposals(projectModel, guardConfig));
|
|
1603
|
+
const initialReport = scanGuard(root, guardConfig, { includeArchitectureInsights: true });
|
|
1604
|
+
writeBaseline(root, initialReport.findings, projectModel);
|
|
1605
|
+
return { config: guardConfig, report: { ...initialReport, findings: [] } };
|
|
1606
|
+
}, options);
|
|
1607
|
+
}
|
|
1608
|
+
export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };
|