@codapult/guard 0.3.0 → 0.5.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 +58 -1
- package/dist/adapters/command.d.ts +11 -8
- package/dist/adapters/command.js +4 -0
- package/dist/adapters/project-checks.d.ts +23 -20
- package/dist/adapters/project-checks.js +12 -3
- package/dist/cli/commands/guard.d.ts +54 -60
- package/dist/cli/commands/guard.js +46 -19
- package/dist/cli/index.js +24 -10
- package/dist/core/analysis/doctor.d.ts +1 -1
- package/dist/core/analysis/doctor.js +2 -3
- package/dist/core/discovery/discovery.d.ts +18 -14
- package/dist/core/discovery/discovery.js +35 -15
- package/dist/core/errors.d.ts +2 -2
- package/dist/core/guard.d.ts +124 -72
- package/dist/core/guard.js +542 -89
- package/dist/core/history/runs.d.ts +43 -0
- package/dist/core/history/runs.js +154 -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 +69 -0
- package/dist/core/policy/schemas.js +25 -0
- package/dist/core/verification/verify.d.ts +20 -16
- package/dist/core/verification/verify.js +55 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp/resources.js +78 -29
- package/dist/mcp/tools/guard.js +147 -28
- package/package.json +7 -7
package/dist/core/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
3
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';
|
|
@@ -29,10 +30,20 @@ export const GUARD_CONVENTIONS_FILE = `${GUARD_DIR}/conventions.json`;
|
|
|
29
30
|
export const GUARD_AGENT_FILE = `${GUARD_DIR}/agent.json`;
|
|
30
31
|
export const GUARD_CONTRACTS_FILE = `${GUARD_DIR}/contracts.json`;
|
|
31
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`;
|
|
32
38
|
export const defaultGuardConfig = {
|
|
33
39
|
version: 1,
|
|
34
40
|
rules: [],
|
|
35
41
|
contracts: [],
|
|
42
|
+
approval: {
|
|
43
|
+
mode: 'local',
|
|
44
|
+
allowMcpApproval: true,
|
|
45
|
+
requireDistinctActor: false,
|
|
46
|
+
},
|
|
36
47
|
};
|
|
37
48
|
export const defaultGuardAgentConfig = {
|
|
38
49
|
version: 1,
|
|
@@ -63,6 +74,142 @@ export class GuardStateBusyError extends Error {
|
|
|
63
74
|
this.name = 'GuardStateBusyError';
|
|
64
75
|
}
|
|
65
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
|
+
}
|
|
66
213
|
function isGuardConfig(value) {
|
|
67
214
|
return guardConfigSchema.safeParse(value).success;
|
|
68
215
|
}
|
|
@@ -75,6 +222,12 @@ function readJson(filePath) {
|
|
|
75
222
|
}
|
|
76
223
|
}
|
|
77
224
|
function atomicWriteFile(path, content) {
|
|
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
|
+
}
|
|
78
231
|
const lockPath = `${path}.lock`;
|
|
79
232
|
let lockAcquired = false;
|
|
80
233
|
try {
|
|
@@ -103,14 +256,18 @@ function atomicWriteFile(path, content) {
|
|
|
103
256
|
renameSync(temporaryPath, path);
|
|
104
257
|
}
|
|
105
258
|
finally {
|
|
106
|
-
if (lockAcquired)
|
|
107
|
-
|
|
259
|
+
if (lockAcquired) {
|
|
260
|
+
removeLockFile(lockPath);
|
|
261
|
+
}
|
|
108
262
|
}
|
|
109
263
|
}
|
|
110
|
-
function
|
|
264
|
+
function writeGuardArtifactUnlocked(root, relativePath, value) {
|
|
111
265
|
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
112
266
|
atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
|
|
113
267
|
}
|
|
268
|
+
function writeGuardArtifact(root, relativePath, value) {
|
|
269
|
+
withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
|
|
270
|
+
}
|
|
114
271
|
function hasProjectTool(model, name) {
|
|
115
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)));
|
|
116
273
|
}
|
|
@@ -143,12 +300,22 @@ export function isGuardProposalFile(value) {
|
|
|
143
300
|
export function fingerprintProjectModel(model) {
|
|
144
301
|
return createHash('sha256').update(JSON.stringify(model)).digest('hex');
|
|
145
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
|
+
}
|
|
146
313
|
export function getGuardProposalFreshness(model, proposals) {
|
|
147
314
|
if (!proposals?.projectFingerprint)
|
|
148
315
|
return 'unknown';
|
|
149
316
|
return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
|
|
150
317
|
}
|
|
151
|
-
|
|
318
|
+
function readGuardConfigFilesUnlocked(root) {
|
|
152
319
|
const rulesPath = resolve(root, GUARD_RULES_FILE);
|
|
153
320
|
if (!existsSync(rulesPath))
|
|
154
321
|
return undefined;
|
|
@@ -160,89 +327,218 @@ export function loadGuardConfig(root) {
|
|
|
160
327
|
const contractFile = readJson(contractsPath);
|
|
161
328
|
if (!isGuardContractsFile(contractFile))
|
|
162
329
|
throw new GuardConfigError(GUARD_CONTRACTS_FILE);
|
|
163
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
...value,
|
|
332
|
+
contracts: contractFile.contracts,
|
|
333
|
+
budgets: value.budgets ?? [],
|
|
334
|
+
approval: value.approval ?? defaultGuardConfig.approval,
|
|
335
|
+
};
|
|
164
336
|
}
|
|
165
337
|
return {
|
|
166
338
|
...value,
|
|
167
339
|
contracts: value.contracts ?? [],
|
|
340
|
+
budgets: value.budgets ?? [],
|
|
341
|
+
approval: value.approval ?? defaultGuardConfig.approval,
|
|
168
342
|
};
|
|
169
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
|
+
}
|
|
170
400
|
export function loadGuardProposals(root) {
|
|
171
|
-
|
|
172
|
-
|
|
401
|
+
if (!existsSync(resolve(root, GUARD_DIR)))
|
|
402
|
+
return loadGuardProposalsUnlocked(root);
|
|
403
|
+
return withGuardStateLock(root, () => {
|
|
404
|
+
recoverGuardPolicyTransaction(root);
|
|
405
|
+
return loadGuardProposalsUnlocked(root);
|
|
406
|
+
});
|
|
173
407
|
}
|
|
174
408
|
export function isGuardAgentConfig(value) {
|
|
175
409
|
return guardAgentConfigSchema.safeParse(value).success;
|
|
176
410
|
}
|
|
177
411
|
export function loadGuardAgentConfig(root) {
|
|
178
|
-
const
|
|
179
|
-
|
|
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;
|
|
180
419
|
}
|
|
181
420
|
export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
|
|
182
421
|
writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
|
|
183
422
|
}
|
|
184
423
|
export function loadBaseline(root) {
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
424
|
+
const path = resolve(root, GUARD_BASELINE_FILE);
|
|
425
|
+
if (!existsSync(path))
|
|
187
426
|
return new Set();
|
|
427
|
+
const value = readJson(path);
|
|
428
|
+
if (!Array.isArray(value))
|
|
429
|
+
throw new GuardConfigError(GUARD_BASELINE_FILE);
|
|
188
430
|
return new Set(value.filter((item) => typeof item === 'string'));
|
|
189
431
|
}
|
|
190
432
|
export function updateBaseline(root, options = {}) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
});
|
|
219
467
|
}
|
|
220
|
-
export function writeGuardConfig(root, guardConfig) {
|
|
221
|
-
|
|
222
|
-
const { contracts = [], ...rulesConfig } = guardConfig;
|
|
223
|
-
atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify(rulesConfig, null, 2)}\n`);
|
|
224
|
-
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);
|
|
225
470
|
}
|
|
226
471
|
export function writeGuardProposals(root, proposals) {
|
|
227
472
|
writeGuardArtifact(root, GUARD_PROPOSALS_FILE, proposals);
|
|
228
473
|
}
|
|
229
|
-
|
|
230
|
-
const proposals =
|
|
474
|
+
function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
|
|
475
|
+
const proposals = loadGuardProposalsUnlocked(root);
|
|
231
476
|
if (!proposals || decisions.length === 0)
|
|
232
477
|
return;
|
|
233
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
|
+
})();
|
|
234
489
|
const nextDecisions = decisions.map((decision) => ({
|
|
235
490
|
...decision,
|
|
236
491
|
decidedAt,
|
|
492
|
+
source: options.source,
|
|
493
|
+
...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
|
|
494
|
+
...(commit ? { commit } : {}),
|
|
237
495
|
...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
|
|
238
496
|
...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
|
|
239
497
|
...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
|
|
240
498
|
}));
|
|
241
|
-
|
|
499
|
+
return {
|
|
242
500
|
...proposals,
|
|
243
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));
|
|
244
528
|
});
|
|
245
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
|
+
}
|
|
246
542
|
export function getPendingGuardProposals(proposals) {
|
|
247
543
|
if (!proposals)
|
|
248
544
|
return [];
|
|
@@ -315,6 +611,9 @@ export function buildGuardProposals(model, guardConfig) {
|
|
|
315
611
|
const proposal = {
|
|
316
612
|
version: 1,
|
|
317
613
|
generatedAt: new Date().toISOString(),
|
|
614
|
+
...(process.env.GUARD_PROPOSER?.trim()
|
|
615
|
+
? { generatedBy: process.env.GUARD_PROPOSER.trim() }
|
|
616
|
+
: {}),
|
|
318
617
|
rules: guardConfig.rules.filter((rule) => rule.status === 'proposed'),
|
|
319
618
|
contracts: [
|
|
320
619
|
...(guardConfig.contracts ?? []).filter((contract) => contract.status === 'proposed'),
|
|
@@ -443,23 +742,37 @@ export function buildGeneratedGuardConfig(model) {
|
|
|
443
742
|
return { ...defaultGuardConfig, rules: proposedRules };
|
|
444
743
|
}
|
|
445
744
|
export function writeGuardMemory(root, model) {
|
|
446
|
-
|
|
447
|
-
|
|
745
|
+
withGuardStateLock(root, () => {
|
|
746
|
+
writeGuardArtifact(root, GUARD_ARCHITECTURE_FILE, buildArchitectureMemory(model));
|
|
747
|
+
writeGuardArtifact(root, GUARD_CONVENTIONS_FILE, buildConventionsMemory(model));
|
|
748
|
+
});
|
|
448
749
|
}
|
|
449
750
|
export function writeBaseline(root, findings, model) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
});
|
|
461
764
|
}
|
|
462
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
|
+
}
|
|
463
776
|
const value = readJson(resolve(root, GUARD_PROJECT_FILE));
|
|
464
777
|
if (value === null ||
|
|
465
778
|
typeof value !== 'object' ||
|
|
@@ -469,8 +782,40 @@ export function loadProjectModel(root) {
|
|
|
469
782
|
return value;
|
|
470
783
|
}
|
|
471
784
|
export function writeProjectModel(root, model) {
|
|
472
|
-
|
|
473
|
-
|
|
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);
|
|
474
819
|
}
|
|
475
820
|
function projectRevision(root) {
|
|
476
821
|
try {
|
|
@@ -484,16 +829,18 @@ function projectRevision(root) {
|
|
|
484
829
|
}
|
|
485
830
|
}
|
|
486
831
|
export function writeProjectSnapshot(root, model) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
+
});
|
|
497
844
|
}
|
|
498
845
|
function isProjectPath(root, value) {
|
|
499
846
|
const normalized = value.replace(/\\/g, '/');
|
|
@@ -519,6 +866,23 @@ function isProjectPath(root, value) {
|
|
|
519
866
|
export function loadGuardArtifact(root, relativePath) {
|
|
520
867
|
if (!isProjectPath(root, relativePath))
|
|
521
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
|
+
}
|
|
522
886
|
return readJson(resolve(root, relativePath));
|
|
523
887
|
}
|
|
524
888
|
export function validateGuardContracts(root, contracts = []) {
|
|
@@ -595,6 +959,51 @@ export function validateGuardContracts(root, contracts = []) {
|
|
|
595
959
|
}
|
|
596
960
|
return issues;
|
|
597
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
|
+
}
|
|
598
1007
|
function isSourceFile(file) {
|
|
599
1008
|
return /\.(?:ts|tsx|js|jsx)$/.test(file) && !/\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/.test(file);
|
|
600
1009
|
}
|
|
@@ -892,6 +1301,49 @@ function architectureInsightFindings(root, changed) {
|
|
|
892
1301
|
}
|
|
893
1302
|
return findings;
|
|
894
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
|
+
}
|
|
895
1347
|
function isSafeReviewFile(file) {
|
|
896
1348
|
return !(file === '.env' ||
|
|
897
1349
|
file.startsWith('.env.') ||
|
|
@@ -1082,6 +1534,7 @@ export function scanGuard(root, guardConfig, options = {}) {
|
|
|
1082
1534
|
const allFindings = [
|
|
1083
1535
|
...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
|
|
1084
1536
|
...scanContracts(root, guardConfig.contracts ?? [], scope),
|
|
1537
|
+
...scanBudgets(root, model, guardConfig.budgets ?? [], scope),
|
|
1085
1538
|
...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
|
|
1086
1539
|
];
|
|
1087
1540
|
const baseline = options.baseline ?? new Set();
|
|
@@ -1134,22 +1587,22 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
|
|
|
1134
1587
|
};
|
|
1135
1588
|
}
|
|
1136
1589
|
export function initializeGuard(root, options = {}) {
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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);
|
|
1154
1607
|
}
|
|
1155
1608
|
export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };
|