@codapult/guard 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -5
- package/dist/adapters/agents/agent-integration.d.ts +3 -0
- package/dist/adapters/agents/agent-integration.js +29 -8
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +35 -0
- package/dist/cli/index.js +11 -3
- package/dist/core/audit/governance.d.ts +37 -0
- package/dist/core/audit/governance.js +166 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/guard.d.ts +28 -0
- package/dist/core/guard.js +157 -6
- package/dist/core/history/runs.d.ts +10 -1
- package/dist/core/history/runs.js +18 -2
- package/dist/core/policy/schemas.d.ts +8 -0
- package/dist/core/policy/schemas.js +4 -0
- package/dist/core/verification/verify.d.ts +6 -2
- package/dist/core/verification/verify.js +112 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/tools/guard.js +26 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -172,6 +172,23 @@ Policy paths are validated as project-relative paths. Guard rejects traversal, a
|
|
|
172
172
|
symlinks escaping the project root; import-boundary checks include imports, re-exports, and literal
|
|
173
173
|
dynamic imports.
|
|
174
174
|
|
|
175
|
+
### Policy scope and state changes
|
|
176
|
+
|
|
177
|
+
Guard stores policy at the project level, while each policy item can target a narrower boundary:
|
|
178
|
+
|
|
179
|
+
| Policy item | Scope controls |
|
|
180
|
+
| ----------- | ---------------------------------------------------------------------- |
|
|
181
|
+
| Rule | `files`, import patterns, and rule kind |
|
|
182
|
+
| Contract | `scope`, `entrypoints`, import/package constraints, and required calls |
|
|
183
|
+
| Budget | `scope`, metric, limit, severity, and written reason |
|
|
184
|
+
| Baseline | Exact finding fingerprints already accepted as legacy debt |
|
|
185
|
+
| Waiver | One active finding fingerprint, with owner, reason, and expiry |
|
|
186
|
+
|
|
187
|
+
Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
|
|
188
|
+
change policy are explicit: `init`, `rules/contracts approve|reject`, `baseline accept|remove`, and
|
|
189
|
+
`waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
|
|
190
|
+
project's configured commands and records a local run manifest; neither changes approved policy.
|
|
191
|
+
|
|
175
192
|
## What Guard discovers
|
|
176
193
|
|
|
177
194
|
The model is framework-aware without being framework-dependent:
|
|
@@ -207,7 +224,7 @@ init once → edit → check --changed → review → verify → commit / merge
|
|
|
207
224
|
```
|
|
208
225
|
|
|
209
226
|
1. `init` builds project memory and establishes the initial baseline.
|
|
210
|
-
2. `check --changed` is the fast
|
|
227
|
+
2. `check` scans the current project; `check --changed` is the fast change-scoped architecture gate.
|
|
211
228
|
3. `review` prepares a bounded and redacted diff packet for an AI host; it does not call an LLM.
|
|
212
229
|
4. `verify` runs Guard policy, configured project checks, adapters, runtime diagnostics, and
|
|
213
230
|
contract validation.
|
|
@@ -238,6 +255,11 @@ Guard state is stored in `.codapult/guard/`:
|
|
|
238
255
|
Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
|
|
239
256
|
disposable according to the project’s policy, and never commit secrets.
|
|
240
257
|
|
|
258
|
+
Baseline fingerprints do not include line numbers, so formatting and line movement do not recreate
|
|
259
|
+
the same finding. They do include the rule and affected file/import identity, so a rename or module
|
|
260
|
+
move can produce a new fingerprint and should be reviewed explicitly before accepting it into the
|
|
261
|
+
baseline.
|
|
262
|
+
|
|
241
263
|
### Temporary waivers
|
|
242
264
|
|
|
243
265
|
Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
|
|
@@ -315,9 +337,10 @@ Register that command in the MCP client from the project root. For example, Curs
|
|
|
315
337
|
{
|
|
316
338
|
"mcpServers": {
|
|
317
339
|
"codapult-guard": {
|
|
340
|
+
"type": "stdio",
|
|
318
341
|
"command": "pnpm",
|
|
319
342
|
"args": ["exec", "codapult-guard", "mcp-server"],
|
|
320
|
-
"cwd": "
|
|
343
|
+
"cwd": "${workspaceFolder}"
|
|
321
344
|
}
|
|
322
345
|
}
|
|
323
346
|
}
|
|
@@ -369,6 +392,37 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
|
|
|
369
392
|
These variables provide declared provenance only; external branch protection or signed identity
|
|
370
393
|
remains responsible for proving who approved the change.
|
|
371
394
|
|
|
395
|
+
### Verifying against protected policy
|
|
396
|
+
|
|
397
|
+
In CI, verify pull requests against the policy from the protected base commit rather than the
|
|
398
|
+
copy carried by the branch:
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
codapult-guard verify --policy-base "$BASE_SHA" --json
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Guard then evaluates the changed source with the base commit's rules, contracts, baseline, waivers,
|
|
405
|
+
and completion-gate configuration. The result reports `policy.source`, `policy.ref`,
|
|
406
|
+
`policy.revision`, `policy.fingerprint`, and whether the checked-out policy differs from that
|
|
407
|
+
base. A branch cannot relax its policy or disable the gate and use that relaxed copy for the same
|
|
408
|
+
verification run.
|
|
409
|
+
|
|
410
|
+
The supplied [`docs/guard-ci.yml`](docs/guard-ci.yml) fetches the full Git history and passes the
|
|
411
|
+
pull request base SHA automatically. Protect `.codapult/guard/` with CODEOWNERS or the equivalent
|
|
412
|
+
review control when policy changes require a separate approval. For example, a repository can add
|
|
413
|
+
`.codapult/guard/* @architecture-owners` to `CODEOWNERS`. Guard reports the policy change; the
|
|
414
|
+
hosting platform enforces reviewer authorization.
|
|
415
|
+
|
|
416
|
+
To make policy changes fail this invocation explicitly, add:
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
codapult-guard verify --policy-base "$BASE_SHA" --fail-on-policy-change
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
This is useful for a dedicated policy-review gate. It is intentionally separate from using the
|
|
423
|
+
base policy for source verification, so teams can choose how their protected review workflow
|
|
424
|
+
handles an approved policy update.
|
|
425
|
+
|
|
372
426
|
See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
|
|
373
427
|
adapter, approval-governance, and observability integrations.
|
|
374
428
|
|
|
@@ -394,16 +448,18 @@ real project shapes.
|
|
|
394
448
|
| `init` | Create project memory and the initial baseline. |
|
|
395
449
|
| `analyze` | Refresh facts without changing policy or baseline. |
|
|
396
450
|
| `propose` | Generate evidence-based rule and contract proposals. |
|
|
397
|
-
| `check --changed`
|
|
451
|
+
| `check [--changed]` | Enforce active policy; `--changed` limits findings to changed and untracked files. |
|
|
398
452
|
| `audit` | Scan the complete current project, including baseline findings. |
|
|
399
453
|
| `review` | Create a bounded semantic-review packet for an AI host. |
|
|
400
454
|
| `verify` | Run the configured completion gate. |
|
|
455
|
+
| `governance` | Audit declared proposal/approval provenance and decision-to-run reachability. |
|
|
401
456
|
| `doctor` | Diagnose invalid or missing Guard artifacts. |
|
|
402
457
|
| `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
|
|
403
458
|
| `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
|
|
404
459
|
| `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
|
|
405
460
|
| `rules` / `contracts` | Approve or reject proposed policy. |
|
|
406
461
|
| `baseline` | Review or intentionally accept existing findings. |
|
|
462
|
+
| `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
|
|
407
463
|
|
|
408
464
|
Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
|
|
409
465
|
discovery cache; it does not change rules, contracts, baseline, or source files.
|
|
@@ -417,8 +473,13 @@ Run `pnpm exec codapult-guard <command> --help` for command-specific options.
|
|
|
417
473
|
|
|
418
474
|
Every `verify` run also writes a local manifest under
|
|
419
475
|
`.codapult/guard/history/runs/<run-id>.json`. It records stage durations, the outcome, and the
|
|
420
|
-
policy gate that decided the result. It
|
|
421
|
-
|
|
476
|
+
policy gate that decided the result. It also records the effective policy revision and fingerprint;
|
|
477
|
+
when a base ref was supplied, it records the ref and whether the working-tree policy differed.
|
|
478
|
+
When an approved policy decision caused the current policy, the manifest also retains its decision
|
|
479
|
+
ID, making the relationship traversable from approval to verification. The `governance` command
|
|
480
|
+
audits this metadata and reports missing or overlapping declared actors. It contains no source code
|
|
481
|
+
or external telemetry. These manifests make a failed run explainable without turning Guard into a
|
|
482
|
+
production tracing system or an identity/signature ledger.
|
|
422
483
|
|
|
423
484
|
## Security and data handling
|
|
424
485
|
|
|
@@ -4,6 +4,9 @@ export interface GuardAgentInstallResult {
|
|
|
4
4
|
path: string;
|
|
5
5
|
action: 'created' | 'updated';
|
|
6
6
|
}
|
|
7
|
+
export declare class GuardAgentInstallError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
7
10
|
export declare function installGuardAgentInstructions(root: string, target: GuardAgentTarget): GuardAgentInstallResult;
|
|
8
11
|
export declare function installGuardAgentTargets(root: string, targetsToInstall: GuardAgentTarget[]): GuardAgentInstallResult[];
|
|
9
12
|
export declare const guardAgentTargets: GuardAgentTarget[];
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
|
+
export class GuardAgentInstallError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'GuardAgentInstallError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
3
9
|
const START_MARKER = '<!-- codapult-guard:start -->';
|
|
4
10
|
const END_MARKER = '<!-- codapult-guard:end -->';
|
|
5
11
|
const instruction = `${START_MARKER}
|
|
6
12
|
## Codapult Guard completion gate
|
|
7
13
|
|
|
8
|
-
After completing a coding task,
|
|
9
|
-
then run Guard verification. Use the host's Guard MCP tools when available:
|
|
14
|
+
After completing a coding task, use the host's Guard MCP tools when available:
|
|
10
15
|
|
|
11
|
-
1. Call \`
|
|
12
|
-
2.
|
|
13
|
-
3. Call \`
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
16
|
+
1. Call \`codapult_guard_next_action\` and follow its bounded next step.
|
|
17
|
+
2. Normally call \`codapult_guard_context\`.
|
|
18
|
+
3. Call \`codapult_guard_review\` with the requirement and changed diff.
|
|
19
|
+
4. Call \`codapult_guard_verify\`.
|
|
20
|
+
5. If an error is reported, fix it and repeat steps 3–4 up to \`completionGate.maxIterations\`.
|
|
21
|
+
6. Report warnings and unresolved requirements; never hide them or activate proposals silently.
|
|
16
22
|
|
|
17
23
|
Guard does not edit source files or invoke an LLM. The host agent owns the repair loop. CI remains
|
|
18
24
|
the independent final gate.
|
|
@@ -31,8 +37,23 @@ const targets = {
|
|
|
31
37
|
function renderContent(target) {
|
|
32
38
|
return `${targets[target].prefix ?? ''}${instruction}\n`;
|
|
33
39
|
}
|
|
40
|
+
function assertNoSymlinkPath(root, relativePath) {
|
|
41
|
+
let current = resolve(root);
|
|
42
|
+
const rootStats = lstatSync(current, { throwIfNoEntry: false });
|
|
43
|
+
if (rootStats?.isSymbolicLink()) {
|
|
44
|
+
throw new GuardAgentInstallError(`Refusing to update ${relativePath}: project root is a symbolic link.`);
|
|
45
|
+
}
|
|
46
|
+
for (const segment of relativePath.split('/')) {
|
|
47
|
+
current = resolve(current, segment);
|
|
48
|
+
const stats = lstatSync(current, { throwIfNoEntry: false });
|
|
49
|
+
if (stats?.isSymbolicLink()) {
|
|
50
|
+
throw new GuardAgentInstallError(`Refusing to update ${relativePath}: ${current} is a symbolic link.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
34
54
|
export function installGuardAgentInstructions(root, target) {
|
|
35
55
|
const relativePath = targets[target].path;
|
|
56
|
+
assertNoSymlinkPath(root, relativePath);
|
|
36
57
|
const path = resolve(root, relativePath);
|
|
37
58
|
const nextBlock = instruction;
|
|
38
59
|
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
@@ -17,6 +17,11 @@ interface GuardVerifyOptions extends GuardOutputOptions {
|
|
|
17
17
|
tools?: GuardToolMode | undefined;
|
|
18
18
|
strict?: boolean | undefined;
|
|
19
19
|
projectChecks?: boolean | undefined;
|
|
20
|
+
policyBase?: string | undefined;
|
|
21
|
+
failOnPolicyChange?: boolean | undefined;
|
|
22
|
+
}
|
|
23
|
+
interface GuardGovernanceOptions extends GuardOutputOptions {
|
|
24
|
+
strict?: boolean | undefined;
|
|
20
25
|
}
|
|
21
26
|
interface GuardCheckOptions extends GuardOutputOptions {
|
|
22
27
|
changed?: boolean | undefined;
|
|
@@ -49,6 +54,7 @@ export declare function guardDoctorCommand(options?: GuardOutputOptions & {
|
|
|
49
54
|
}): void;
|
|
50
55
|
export declare function guardHistoryCommand(): void;
|
|
51
56
|
export declare function guardRunsCommand(options?: GuardOutputOptions): void;
|
|
57
|
+
export declare function guardGovernanceCommand(options?: GuardGovernanceOptions): void;
|
|
52
58
|
export declare function guardImpactCommand(files: string[], options?: GuardOutputOptions): void;
|
|
53
59
|
export declare function guardHistoryDiffCommand(from: string, to: string, options?: GuardOutputOptions): void;
|
|
54
60
|
export declare function guardVerifyCommand(options?: GuardVerifyOptions): void;
|
|
@@ -11,6 +11,7 @@ import { dim, fail, heading, info, success, warn } from '../ui.js';
|
|
|
11
11
|
import { guardFindingsToSarif } from '../../core/output/sarif.js';
|
|
12
12
|
import { analyzeProjectImpact } from '../../core/analysis/impact.js';
|
|
13
13
|
import { listGuardRuns, summarizeGuardRuns } from '../../core/history/runs.js';
|
|
14
|
+
import { auditGuardGovernance } from '../../core/audit/governance.js';
|
|
14
15
|
function renderFindings(findings) {
|
|
15
16
|
for (const finding of findings) {
|
|
16
17
|
const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
|
|
@@ -221,6 +222,32 @@ export function guardRunsCommand(options = {}) {
|
|
|
221
222
|
info(`${run.runId}: ${run.outcome}, gate=${run.gate}, ${run.durationMs} ms`);
|
|
222
223
|
}
|
|
223
224
|
}
|
|
225
|
+
export function guardGovernanceCommand(options = {}) {
|
|
226
|
+
const root = getRoot();
|
|
227
|
+
let result;
|
|
228
|
+
try {
|
|
229
|
+
result = auditGuardGovernance(root);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (options.json) {
|
|
237
|
+
console.log(JSON.stringify(result, null, 2));
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
heading('Codapult Guard Governance');
|
|
241
|
+
info(`Approvals: ${result.approvals.approved} approved, ${result.approvals.actors.length} declared actor(s), ${result.approvals.missingActors} missing actor(s).`);
|
|
242
|
+
info(`Declared actor/proposal-author overlap: ${result.approvals.actorAuthorOverlap.length}.`);
|
|
243
|
+
info(`Policy-linked approvals: ${result.reachability.approvedLinkedToRun}/${result.reachability.approvedWithPolicyFingerprint} linked to a retained verification run.`);
|
|
244
|
+
for (const warning of result.warnings)
|
|
245
|
+
warn(warning);
|
|
246
|
+
if (result.warnings.length === 0)
|
|
247
|
+
success('No governance completeness warnings.');
|
|
248
|
+
}
|
|
249
|
+
process.exitCode = options.strict && result.warnings.length > 0 ? 1 : 0;
|
|
250
|
+
}
|
|
224
251
|
export function guardImpactCommand(files, options = {}) {
|
|
225
252
|
const root = getRoot();
|
|
226
253
|
if (files.length === 0) {
|
|
@@ -283,6 +310,8 @@ export function guardVerifyCommand(options = {}) {
|
|
|
283
310
|
tools: options.tools,
|
|
284
311
|
strict: options.strict,
|
|
285
312
|
projectChecks: options.projectChecks,
|
|
313
|
+
policyBase: options.policyBase,
|
|
314
|
+
failOnPolicyChange: options.failOnPolicyChange,
|
|
286
315
|
});
|
|
287
316
|
if (options.json) {
|
|
288
317
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -295,6 +324,12 @@ export function guardVerifyCommand(options = {}) {
|
|
|
295
324
|
process.exitCode = 1;
|
|
296
325
|
return;
|
|
297
326
|
}
|
|
327
|
+
if (result.policy?.source === 'git-ref') {
|
|
328
|
+
dim(`policy: ${result.policy.ref ?? 'Git ref'} (${result.policy.fingerprint})`);
|
|
329
|
+
if (result.policyChanged) {
|
|
330
|
+
warn('The working-tree policy differs from the base policy; the base policy was used.');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
298
333
|
for (const [name, check] of Object.entries(result.checks)) {
|
|
299
334
|
if (check.status === 'passed')
|
|
300
335
|
success(`${name}: passed`);
|
package/dist/cli/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
|
-
import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, guardWaiverCommand, } from './commands/guard.js';
|
|
4
|
+
import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardGovernanceCommand, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, guardWaiverCommand, } from './commands/guard.js';
|
|
5
5
|
import { config } from '../core/config.js';
|
|
6
6
|
import { GuardBaselineReasonError, GuardConfigError, GuardStateBusyError, GuardStateStaleError, GuardWaiverError, } from '../core/guard.js';
|
|
7
|
+
import { GuardAgentInstallError } from '../adapters/agents/agent-integration.js';
|
|
7
8
|
import { guardErrorPayload } from '../core/errors.js';
|
|
8
9
|
const program = new Command()
|
|
9
10
|
.name(config.commandName)
|
|
@@ -23,6 +24,7 @@ guard.command('install-agent [target]').option('--json').action(guardInstallAgen
|
|
|
23
24
|
guard.command('doctor').option('--json').option('--fix-cache').action(guardDoctorCommand);
|
|
24
25
|
guard.command('history').action(guardHistoryCommand);
|
|
25
26
|
guard.command('runs').option('--json').action(guardRunsCommand);
|
|
27
|
+
guard.command('governance').option('--json').option('--strict').action(guardGovernanceCommand);
|
|
26
28
|
guard.command('history-diff <from> <to>').option('--json').action(guardHistoryDiffCommand);
|
|
27
29
|
guard.command('impact <files...>').option('--json').action(guardImpactCommand);
|
|
28
30
|
guard
|
|
@@ -40,6 +42,8 @@ guard
|
|
|
40
42
|
.option('--no-project-checks')
|
|
41
43
|
.option('--requirement <file>')
|
|
42
44
|
.option('--changed')
|
|
45
|
+
.option('--policy-base <ref>', 'read policy artifacts from this Git ref')
|
|
46
|
+
.option('--fail-on-policy-change', 'fail when the working-tree policy differs from the base ref')
|
|
43
47
|
.option('--json')
|
|
44
48
|
.action(guardVerifyCommand);
|
|
45
49
|
guard
|
|
@@ -110,10 +114,12 @@ catch (error) {
|
|
|
110
114
|
error instanceof GuardBaselineReasonError ||
|
|
111
115
|
error instanceof GuardStateBusyError ||
|
|
112
116
|
error instanceof GuardStateStaleError ||
|
|
113
|
-
error instanceof GuardWaiverError
|
|
117
|
+
error instanceof GuardWaiverError ||
|
|
118
|
+
error instanceof GuardAgentInstallError) {
|
|
114
119
|
const isBusy = error instanceof GuardStateBusyError;
|
|
115
120
|
const isStale = error instanceof GuardStateStaleError;
|
|
116
121
|
const isInvalidInput = error instanceof GuardBaselineReasonError;
|
|
122
|
+
const isInstallError = error instanceof GuardAgentInstallError;
|
|
117
123
|
console.error(JSON.stringify(guardErrorPayload(isBusy
|
|
118
124
|
? 'GUARD_STATE_BUSY'
|
|
119
125
|
: isStale
|
|
@@ -128,7 +134,9 @@ catch (error) {
|
|
|
128
134
|
? 'Re-read Guard state and retry the operation.'
|
|
129
135
|
: isInvalidInput
|
|
130
136
|
? 'Provide a written reason for the baseline decision.'
|
|
131
|
-
:
|
|
137
|
+
: isInstallError
|
|
138
|
+
? 'Remove the symbolic link or choose a regular project path, then retry.'
|
|
139
|
+
: 'Repair the invalid Guard artifact, then run codapult-guard doctor.',
|
|
132
140
|
}), null, 2));
|
|
133
141
|
process.exitCode = 1;
|
|
134
142
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface GuardGovernanceAudit {
|
|
2
|
+
version: 1;
|
|
3
|
+
generatedAt: string;
|
|
4
|
+
configured: boolean;
|
|
5
|
+
policy?: {
|
|
6
|
+
revision?: number | undefined;
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
} | undefined;
|
|
9
|
+
proposals: {
|
|
10
|
+
currentId?: string | undefined;
|
|
11
|
+
currentAuthor?: string | undefined;
|
|
12
|
+
decisions: number;
|
|
13
|
+
authors: string[];
|
|
14
|
+
missingAuthors: number;
|
|
15
|
+
};
|
|
16
|
+
approvals: {
|
|
17
|
+
total: number;
|
|
18
|
+
approved: number;
|
|
19
|
+
rejected: number;
|
|
20
|
+
actors: string[];
|
|
21
|
+
missingActors: number;
|
|
22
|
+
actorAuthorOverlap: string[];
|
|
23
|
+
commitAuthors: string[];
|
|
24
|
+
actorCommitAuthorOverlap: string[];
|
|
25
|
+
missingCommits: number;
|
|
26
|
+
sources: Record<string, number>;
|
|
27
|
+
};
|
|
28
|
+
reachability: {
|
|
29
|
+
approvedWithPolicyFingerprint: number;
|
|
30
|
+
approvedLinkedToRun: number;
|
|
31
|
+
approvedWithoutRun: number;
|
|
32
|
+
runsWithPolicy: number;
|
|
33
|
+
runsWithoutPolicy: number;
|
|
34
|
+
};
|
|
35
|
+
warnings: string[];
|
|
36
|
+
}
|
|
37
|
+
export declare function auditGuardGovernance(root: string): GuardGovernanceAudit;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { createGuardPolicySnapshot, loadBaseline, loadGuardAgentConfig, loadGuardConfig, loadGuardProposals, loadGuardWaivers, withGuardStateLock, GUARD_DIR, } from '../guard.js';
|
|
5
|
+
import { GUARD_RUN_RETENTION, listGuardRuns } from '../history/runs.js';
|
|
6
|
+
function commitAuthor(root, commit, cache) {
|
|
7
|
+
if (!commit || !/^[0-9a-f]{7,64}$/i.test(commit))
|
|
8
|
+
return undefined;
|
|
9
|
+
if (cache.has(commit))
|
|
10
|
+
return cache.get(commit);
|
|
11
|
+
try {
|
|
12
|
+
const value = execFileSync('git', ['show', '-s', '--format=%ae', commit], {
|
|
13
|
+
cwd: root,
|
|
14
|
+
stdio: 'pipe',
|
|
15
|
+
timeout: 5_000,
|
|
16
|
+
maxBuffer: 64 * 1024,
|
|
17
|
+
})
|
|
18
|
+
.toString()
|
|
19
|
+
.trim();
|
|
20
|
+
const author = value || undefined;
|
|
21
|
+
cache.set(commit, author);
|
|
22
|
+
return author;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
cache.set(commit, undefined);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function currentProposalAuthor(proposals, decision) {
|
|
30
|
+
if (decision.proposalAuthor)
|
|
31
|
+
return decision.proposalAuthor;
|
|
32
|
+
if (decision.proposalId && decision.proposalId === proposals?.proposalId) {
|
|
33
|
+
return proposals.generatedBy;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
function auditGuardGovernanceUnlocked(root) {
|
|
38
|
+
const config = loadGuardConfig(root);
|
|
39
|
+
const proposals = loadGuardProposals(root);
|
|
40
|
+
const decisions = proposals?.decisions ?? [];
|
|
41
|
+
const approved = decisions.filter((decision) => decision.decision === 'approved');
|
|
42
|
+
const authors = [
|
|
43
|
+
...new Set(decisions
|
|
44
|
+
.map((decision) => currentProposalAuthor(proposals, decision))
|
|
45
|
+
.filter((author) => Boolean(author))),
|
|
46
|
+
].sort();
|
|
47
|
+
const actors = [
|
|
48
|
+
...new Set(approved
|
|
49
|
+
.map((decision) => decision.actor?.trim())
|
|
50
|
+
.filter((actor) => Boolean(actor))),
|
|
51
|
+
].sort();
|
|
52
|
+
const commitAuthorCache = new Map();
|
|
53
|
+
const enrichedApprovals = approved.map((decision) => ({
|
|
54
|
+
decision,
|
|
55
|
+
author: currentProposalAuthor(proposals, decision),
|
|
56
|
+
commitAuthor: commitAuthor(root, decision.commit, commitAuthorCache),
|
|
57
|
+
}));
|
|
58
|
+
const commitAuthors = [
|
|
59
|
+
...new Set(enrichedApprovals
|
|
60
|
+
.map((item) => item.commitAuthor)
|
|
61
|
+
.filter((author) => Boolean(author))),
|
|
62
|
+
].sort();
|
|
63
|
+
const actorAuthorOverlap = [
|
|
64
|
+
...new Set(enrichedApprovals
|
|
65
|
+
.filter((item) => item.decision.actor && item.author && item.decision.actor === item.author)
|
|
66
|
+
.map((item) => item.decision.actor)
|
|
67
|
+
.filter((actor) => Boolean(actor))),
|
|
68
|
+
].sort();
|
|
69
|
+
const actorCommitAuthorOverlap = [
|
|
70
|
+
...new Set(enrichedApprovals
|
|
71
|
+
.filter((item) => item.decision.actor && item.commitAuthor === item.decision.actor)
|
|
72
|
+
.map((item) => item.decision.actor)
|
|
73
|
+
.filter((actor) => Boolean(actor))),
|
|
74
|
+
].sort();
|
|
75
|
+
const runs = listGuardRuns(root, GUARD_RUN_RETENTION);
|
|
76
|
+
const runDecisionBindings = new Map();
|
|
77
|
+
for (const run of runs) {
|
|
78
|
+
const fingerprint = run.policy?.fingerprint;
|
|
79
|
+
if (!fingerprint)
|
|
80
|
+
continue;
|
|
81
|
+
for (const decisionId of run.policy?.decisionIds ?? []) {
|
|
82
|
+
const fingerprints = runDecisionBindings.get(decisionId) ?? new Set();
|
|
83
|
+
fingerprints.add(fingerprint);
|
|
84
|
+
runDecisionBindings.set(decisionId, fingerprints);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const approvedWithPolicyFingerprint = approved.filter((decision) => decision.policyFingerprint !== undefined).length;
|
|
88
|
+
const approvedLinkedToRun = approved.filter((decision) => decision.policyFingerprint !== undefined &&
|
|
89
|
+
runDecisionBindings
|
|
90
|
+
.get(decision.decisionId ??
|
|
91
|
+
`${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
|
|
92
|
+
?.has(decision.policyFingerprint)).length;
|
|
93
|
+
const warnings = [];
|
|
94
|
+
if (approved.some((decision) => !decision.actor)) {
|
|
95
|
+
warnings.push('Some approved decisions do not declare an approval actor.');
|
|
96
|
+
}
|
|
97
|
+
if (approved.some((decision) => !currentProposalAuthor(proposals, decision))) {
|
|
98
|
+
warnings.push('Some approved decisions do not retain a proposal author.');
|
|
99
|
+
}
|
|
100
|
+
if (actorAuthorOverlap.length > 0) {
|
|
101
|
+
warnings.push('At least one declared approval actor also authored an approved proposal.');
|
|
102
|
+
}
|
|
103
|
+
if (approved.some((decision) => !decision.policyFingerprint)) {
|
|
104
|
+
warnings.push('Some approved decisions are not bound to a resulting policy fingerprint.');
|
|
105
|
+
}
|
|
106
|
+
if (approved.some((decision) => decision.policyFingerprint === undefined ||
|
|
107
|
+
!runDecisionBindings
|
|
108
|
+
.get(decision.decisionId ??
|
|
109
|
+
`${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
|
|
110
|
+
?.has(decision.policyFingerprint))) {
|
|
111
|
+
warnings.push('Some approved decisions have no retained verification run linked by decision ID.');
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
version: 1,
|
|
115
|
+
generatedAt: new Date().toISOString(),
|
|
116
|
+
configured: config !== undefined,
|
|
117
|
+
...(config
|
|
118
|
+
? {
|
|
119
|
+
policy: (() => {
|
|
120
|
+
const snapshot = createGuardPolicySnapshot(config, loadBaseline(root), loadGuardWaivers(root), 'working-tree', undefined, loadGuardAgentConfig(root));
|
|
121
|
+
return {
|
|
122
|
+
...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
|
|
123
|
+
fingerprint: snapshot.fingerprint,
|
|
124
|
+
};
|
|
125
|
+
})(),
|
|
126
|
+
}
|
|
127
|
+
: {}),
|
|
128
|
+
proposals: {
|
|
129
|
+
...(proposals?.proposalId ? { currentId: proposals.proposalId } : {}),
|
|
130
|
+
...(proposals?.generatedBy ? { currentAuthor: proposals.generatedBy } : {}),
|
|
131
|
+
decisions: decisions.length,
|
|
132
|
+
authors,
|
|
133
|
+
missingAuthors: decisions.filter((decision) => !currentProposalAuthor(proposals, decision))
|
|
134
|
+
.length,
|
|
135
|
+
},
|
|
136
|
+
approvals: {
|
|
137
|
+
total: decisions.length,
|
|
138
|
+
approved: approved.length,
|
|
139
|
+
rejected: decisions.filter((decision) => decision.decision === 'rejected').length,
|
|
140
|
+
actors,
|
|
141
|
+
missingActors: approved.filter((decision) => !decision.actor).length,
|
|
142
|
+
actorAuthorOverlap,
|
|
143
|
+
commitAuthors,
|
|
144
|
+
actorCommitAuthorOverlap,
|
|
145
|
+
missingCommits: approved.filter((decision) => !decision.commit).length,
|
|
146
|
+
sources: decisions.reduce((counts, decision) => {
|
|
147
|
+
const source = decision.source ?? 'unknown';
|
|
148
|
+
counts[source] = (counts[source] ?? 0) + 1;
|
|
149
|
+
return counts;
|
|
150
|
+
}, {}),
|
|
151
|
+
},
|
|
152
|
+
reachability: {
|
|
153
|
+
approvedWithPolicyFingerprint,
|
|
154
|
+
approvedLinkedToRun,
|
|
155
|
+
approvedWithoutRun: approvedWithPolicyFingerprint - approvedLinkedToRun,
|
|
156
|
+
runsWithPolicy: runs.filter((run) => run.policy !== undefined).length,
|
|
157
|
+
runsWithoutPolicy: runs.filter((run) => run.policy === undefined).length,
|
|
158
|
+
},
|
|
159
|
+
warnings,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function auditGuardGovernance(root) {
|
|
163
|
+
if (!existsSync(resolve(root, GUARD_DIR)))
|
|
164
|
+
return auditGuardGovernanceUnlocked(root);
|
|
165
|
+
return withGuardStateLock(root, () => auditGuardGovernanceUnlocked(root));
|
|
166
|
+
}
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | 'GUARD_INVALID_INPUT';
|
|
1
|
+
export type GuardErrorCode = 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_STATE_BUSY' | 'GUARD_STATE_STALE' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | 'GUARD_INVALID_INPUT';
|
|
2
2
|
export interface GuardErrorPayload {
|
|
3
3
|
status: 'error';
|
|
4
4
|
outcome: 'not-configured' | 'error';
|
package/dist/core/guard.d.ts
CHANGED
|
@@ -106,6 +106,7 @@ export interface GuardProposalFile {
|
|
|
106
106
|
decisions?: GuardProposalDecision[] | undefined;
|
|
107
107
|
}
|
|
108
108
|
export interface GuardProposalDecision {
|
|
109
|
+
decisionId?: string | undefined;
|
|
109
110
|
id: string;
|
|
110
111
|
type: 'rule' | 'contract';
|
|
111
112
|
decision: 'approved' | 'rejected';
|
|
@@ -114,8 +115,11 @@ export interface GuardProposalDecision {
|
|
|
114
115
|
proposalFingerprint?: string | undefined;
|
|
115
116
|
revision?: number | undefined;
|
|
116
117
|
source?: 'cli' | 'mcp' | 'external' | undefined;
|
|
118
|
+
proposalAuthor?: string | undefined;
|
|
117
119
|
actor?: string | undefined;
|
|
118
120
|
commit?: string | undefined;
|
|
121
|
+
policyFingerprint?: string | undefined;
|
|
122
|
+
policyRevision?: number | undefined;
|
|
119
123
|
}
|
|
120
124
|
export type GuardProposalFreshness = 'current' | 'stale' | 'unknown';
|
|
121
125
|
export interface GuardAgentConfig {
|
|
@@ -233,6 +237,30 @@ export declare function isGuardContractsFile(value: unknown): value is {
|
|
|
233
237
|
export declare function isGuardProposalFile(value: unknown): value is GuardProposalFile;
|
|
234
238
|
export declare function fingerprintProjectModel(model: ProjectModel): string;
|
|
235
239
|
export declare function fingerprintGuardConfig(guardConfig: GuardConfig): string;
|
|
240
|
+
export interface GuardPolicySnapshot {
|
|
241
|
+
config: GuardConfig;
|
|
242
|
+
agentConfig: GuardAgentConfig;
|
|
243
|
+
baseline: Set<string>;
|
|
244
|
+
waivers: GuardWaiver[];
|
|
245
|
+
revision?: number | undefined;
|
|
246
|
+
fingerprint: string;
|
|
247
|
+
source: 'working-tree' | 'git-ref';
|
|
248
|
+
ref?: string | undefined;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Fingerprints the effective policy used by a verification run.
|
|
252
|
+
*
|
|
253
|
+
* The rules revision alone is not sufficient: baseline and waiver changes also
|
|
254
|
+
* affect the verdict. Keeping them in this fingerprint makes a run auditable
|
|
255
|
+
* without pretending that a numeric revision covers every policy artifact.
|
|
256
|
+
*/
|
|
257
|
+
export declare function fingerprintGuardPolicy(guardConfig: GuardConfig, baseline?: Set<string>, waivers?: GuardWaiver[], agentConfig?: GuardAgentConfig): string;
|
|
258
|
+
export declare function createGuardPolicySnapshot(guardConfig: GuardConfig, baseline: Set<string>, waivers: GuardWaiver[], source?: GuardPolicySnapshot['source'], ref?: string, agentConfig?: GuardAgentConfig): GuardPolicySnapshot;
|
|
259
|
+
/**
|
|
260
|
+
* Loads policy artifacts from a trusted Git ref instead of the checked-out
|
|
261
|
+
* working tree. This is intended for CI verification of untrusted branches.
|
|
262
|
+
*/
|
|
263
|
+
export declare function loadGuardPolicyAtRevision(root: string, revision: string): GuardPolicySnapshot | undefined;
|
|
236
264
|
export declare function getGuardProposalFreshness(model: ProjectModel, proposals: GuardProposalFile | undefined): GuardProposalFreshness;
|
|
237
265
|
export declare function loadGuardConfig(root: string): GuardConfig | undefined;
|
|
238
266
|
export declare function loadGuardProposals(root: string): GuardProposalFile | undefined;
|
package/dist/core/guard.js
CHANGED
|
@@ -271,7 +271,9 @@ function atomicWriteFile(path, content) {
|
|
|
271
271
|
}
|
|
272
272
|
function writeGuardArtifactUnlocked(root, relativePath, value) {
|
|
273
273
|
mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
|
|
274
|
-
|
|
274
|
+
const path = resolve(root, relativePath);
|
|
275
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
276
|
+
atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
275
277
|
}
|
|
276
278
|
function writeGuardArtifact(root, relativePath, value) {
|
|
277
279
|
withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
|
|
@@ -308,16 +310,136 @@ export function isGuardProposalFile(value) {
|
|
|
308
310
|
export function fingerprintProjectModel(model) {
|
|
309
311
|
return createHash('sha256').update(JSON.stringify(model)).digest('hex');
|
|
310
312
|
}
|
|
313
|
+
function stableSerialize(value) {
|
|
314
|
+
if (Array.isArray(value)) {
|
|
315
|
+
return `[${value.map((item) => stableSerialize(item)).join(',')}]`;
|
|
316
|
+
}
|
|
317
|
+
if (value !== null && typeof value === 'object') {
|
|
318
|
+
const record = value;
|
|
319
|
+
return `{${Object.keys(record)
|
|
320
|
+
.sort()
|
|
321
|
+
.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`)
|
|
322
|
+
.join(',')}}`;
|
|
323
|
+
}
|
|
324
|
+
return JSON.stringify(value) ?? 'null';
|
|
325
|
+
}
|
|
311
326
|
export function fingerprintGuardConfig(guardConfig) {
|
|
312
327
|
return createHash('sha256')
|
|
313
|
-
.update(
|
|
314
|
-
rules: guardConfig.rules,
|
|
315
|
-
contracts: guardConfig.contracts ?? [],
|
|
316
|
-
budgets: guardConfig.budgets ?? [],
|
|
328
|
+
.update(stableSerialize({
|
|
329
|
+
rules: [...guardConfig.rules].sort((left, right) => left.id.localeCompare(right.id)),
|
|
330
|
+
contracts: [...(guardConfig.contracts ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
|
|
331
|
+
budgets: [...(guardConfig.budgets ?? [])].sort((left, right) => left.id.localeCompare(right.id)),
|
|
317
332
|
approval: guardConfig.approval ?? defaultGuardConfig.approval,
|
|
318
333
|
}))
|
|
319
334
|
.digest('hex');
|
|
320
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Fingerprints the effective policy used by a verification run.
|
|
338
|
+
*
|
|
339
|
+
* The rules revision alone is not sufficient: baseline and waiver changes also
|
|
340
|
+
* affect the verdict. Keeping them in this fingerprint makes a run auditable
|
|
341
|
+
* without pretending that a numeric revision covers every policy artifact.
|
|
342
|
+
*/
|
|
343
|
+
export function fingerprintGuardPolicy(guardConfig, baseline = new Set(), waivers = [], agentConfig = defaultGuardAgentConfig) {
|
|
344
|
+
return createHash('sha256')
|
|
345
|
+
.update(stableSerialize({
|
|
346
|
+
config: fingerprintGuardConfig(guardConfig),
|
|
347
|
+
agentConfig,
|
|
348
|
+
baseline: [...baseline].sort(),
|
|
349
|
+
waivers: [...waivers].sort((left, right) => left.id.localeCompare(right.id)),
|
|
350
|
+
}))
|
|
351
|
+
.digest('hex');
|
|
352
|
+
}
|
|
353
|
+
export function createGuardPolicySnapshot(guardConfig, baseline, waivers, source = 'working-tree', ref, agentConfig = defaultGuardAgentConfig) {
|
|
354
|
+
return {
|
|
355
|
+
config: guardConfig,
|
|
356
|
+
agentConfig,
|
|
357
|
+
baseline,
|
|
358
|
+
waivers,
|
|
359
|
+
...(typeof guardConfig.revision === 'number' ? { revision: guardConfig.revision } : {}),
|
|
360
|
+
fingerprint: fingerprintGuardPolicy(guardConfig, baseline, waivers, agentConfig),
|
|
361
|
+
source,
|
|
362
|
+
...(ref ? { ref } : {}),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function readGitFileAtRevision(root, revision, relativePath) {
|
|
366
|
+
try {
|
|
367
|
+
return execFileSync('git', ['show', `${revision}:${relativePath}`], {
|
|
368
|
+
cwd: root,
|
|
369
|
+
stdio: 'pipe',
|
|
370
|
+
maxBuffer: 4_000_000,
|
|
371
|
+
}).toString();
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function parseGitJsonAtRevision(root, revision, relativePath) {
|
|
378
|
+
const text = readGitFileAtRevision(root, revision, relativePath);
|
|
379
|
+
if (text === undefined)
|
|
380
|
+
return undefined;
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(text);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
throw new GuardConfigError(`${relativePath} at ${revision}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Loads policy artifacts from a trusted Git ref instead of the checked-out
|
|
390
|
+
* working tree. This is intended for CI verification of untrusted branches.
|
|
391
|
+
*/
|
|
392
|
+
export function loadGuardPolicyAtRevision(root, revision) {
|
|
393
|
+
if (!/^[\w./@-]+$/.test(revision) || revision.startsWith('-')) {
|
|
394
|
+
throw new GuardConfigError(`unsafe policy ref: ${revision}`);
|
|
395
|
+
}
|
|
396
|
+
const rulesValue = parseGitJsonAtRevision(root, revision, GUARD_RULES_FILE);
|
|
397
|
+
if (rulesValue === undefined || !isGuardConfig(rulesValue)) {
|
|
398
|
+
if (rulesValue === undefined)
|
|
399
|
+
return undefined;
|
|
400
|
+
throw new GuardConfigError(`${GUARD_RULES_FILE} at ${revision}`);
|
|
401
|
+
}
|
|
402
|
+
const contractsValue = parseGitJsonAtRevision(root, revision, GUARD_CONTRACTS_FILE);
|
|
403
|
+
if (contractsValue !== undefined && !isGuardContractsFile(contractsValue)) {
|
|
404
|
+
throw new GuardConfigError(`${GUARD_CONTRACTS_FILE} at ${revision}`);
|
|
405
|
+
}
|
|
406
|
+
const guardConfig = {
|
|
407
|
+
...rulesValue,
|
|
408
|
+
contracts: contractsValue ? contractsValue.contracts : (rulesValue.contracts ?? []),
|
|
409
|
+
budgets: rulesValue.budgets ?? [],
|
|
410
|
+
approval: rulesValue.approval ?? defaultGuardConfig.approval,
|
|
411
|
+
};
|
|
412
|
+
const agentValue = parseGitJsonAtRevision(root, revision, GUARD_AGENT_FILE);
|
|
413
|
+
const agentConfig = agentValue === undefined
|
|
414
|
+
? defaultGuardAgentConfig
|
|
415
|
+
: // eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
416
|
+
isGuardAgentConfig(agentValue)
|
|
417
|
+
? agentValue
|
|
418
|
+
: (() => {
|
|
419
|
+
throw new GuardConfigError(`${GUARD_AGENT_FILE} at ${revision}`);
|
|
420
|
+
})();
|
|
421
|
+
const baselineValue = parseGitJsonAtRevision(root, revision, GUARD_BASELINE_FILE);
|
|
422
|
+
const baselineEntries = [];
|
|
423
|
+
if (baselineValue !== undefined) {
|
|
424
|
+
if (!Array.isArray(baselineValue)) {
|
|
425
|
+
throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
|
|
426
|
+
}
|
|
427
|
+
for (const item of baselineValue) {
|
|
428
|
+
if (typeof item !== 'string') {
|
|
429
|
+
throw new GuardConfigError(`${GUARD_BASELINE_FILE} at ${revision}`);
|
|
430
|
+
}
|
|
431
|
+
baselineEntries.push(item);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const waiversValue = parseGitJsonAtRevision(root, revision, GUARD_WAIVERS_FILE);
|
|
435
|
+
const parsedWaivers = guardWaiversFileSchema.safeParse(waiversValue ?? { version: 1, waivers: [], decisions: [] });
|
|
436
|
+
if (!parsedWaivers.success)
|
|
437
|
+
throw new GuardConfigError(`${GUARD_WAIVERS_FILE} at ${revision}`);
|
|
438
|
+
// The validator is declared with the waiver file model below the policy readers.
|
|
439
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
440
|
+
validateGuardWaiversFile(parsedWaivers.data);
|
|
441
|
+
return createGuardPolicySnapshot(guardConfig, new Set(baselineEntries), parsedWaivers.data.waivers, 'git-ref', revision, agentConfig);
|
|
442
|
+
}
|
|
321
443
|
export function getGuardProposalFreshness(model, proposals) {
|
|
322
444
|
if (!proposals?.projectFingerprint)
|
|
323
445
|
return 'unknown';
|
|
@@ -607,15 +729,24 @@ function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
|
|
|
607
729
|
return undefined;
|
|
608
730
|
}
|
|
609
731
|
})();
|
|
732
|
+
const currentRevision = readGuardConfigFilesUnlocked(root)?.revision ?? 0;
|
|
733
|
+
const policyFingerprint = options.resultingPolicy
|
|
734
|
+
? fingerprintGuardPolicy(options.resultingPolicy, loadBaseline(root), readGuardWaiversUnlocked(root).waivers, loadGuardAgentConfig(root))
|
|
735
|
+
: undefined;
|
|
610
736
|
const nextDecisions = decisions.map((decision) => ({
|
|
611
737
|
...decision,
|
|
738
|
+
decisionId: randomUUID(),
|
|
612
739
|
decidedAt,
|
|
613
740
|
source: options.source,
|
|
614
741
|
...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
|
|
615
742
|
...(commit ? { commit } : {}),
|
|
743
|
+
...(proposals.generatedBy ? { proposalAuthor: proposals.generatedBy } : {}),
|
|
616
744
|
...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
|
|
617
745
|
...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
|
|
618
746
|
...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
|
|
747
|
+
...(decision.decision === 'approved' && policyFingerprint
|
|
748
|
+
? { policyFingerprint, policyRevision: currentRevision + 1 }
|
|
749
|
+
: {}),
|
|
619
750
|
}));
|
|
620
751
|
return {
|
|
621
752
|
...proposals,
|
|
@@ -635,7 +766,10 @@ export function recordGuardProposalDecision(root, decisions, options = { source:
|
|
|
635
766
|
}
|
|
636
767
|
export function applyGuardProposalDecision(root, guardConfig, decisions, options) {
|
|
637
768
|
withGuardStateLock(root, () => {
|
|
638
|
-
const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions,
|
|
769
|
+
const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, {
|
|
770
|
+
...options,
|
|
771
|
+
resultingPolicy: guardConfig,
|
|
772
|
+
});
|
|
639
773
|
writeGuardArtifactUnlocked(root, GUARD_POLICY_TRANSACTION_FILE, {
|
|
640
774
|
version: 1,
|
|
641
775
|
createdAt: new Date().toISOString(),
|
|
@@ -1107,6 +1241,23 @@ export function validateGuardBudgets(root, budgets = []) {
|
|
|
1107
1241
|
}
|
|
1108
1242
|
export function validateGuardPolicy(root, guardConfig) {
|
|
1109
1243
|
const issues = [];
|
|
1244
|
+
const validateUniqueIds = (items, kind) => {
|
|
1245
|
+
const seen = new Set();
|
|
1246
|
+
for (const item of items) {
|
|
1247
|
+
if (seen.has(item.id)) {
|
|
1248
|
+
issues.push({
|
|
1249
|
+
contractId: `${kind}:${item.id}`,
|
|
1250
|
+
field: 'definition',
|
|
1251
|
+
value: item.id,
|
|
1252
|
+
message: `${kind} IDs must be unique: ${item.id}`,
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
seen.add(item.id);
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
validateUniqueIds(guardConfig.rules, 'rule');
|
|
1259
|
+
validateUniqueIds(guardConfig.contracts ?? [], 'contract');
|
|
1260
|
+
validateUniqueIds(guardConfig.budgets ?? [], 'budget');
|
|
1110
1261
|
for (const rule of guardConfig.rules) {
|
|
1111
1262
|
for (const file of rule.files ?? []) {
|
|
1112
1263
|
if (!isProjectPath(root, file)) {
|
|
@@ -5,6 +5,14 @@ export interface GuardRunStage {
|
|
|
5
5
|
status: 'ok' | 'fail' | 'skipped';
|
|
6
6
|
detail?: string | undefined;
|
|
7
7
|
}
|
|
8
|
+
export interface GuardRunPolicy {
|
|
9
|
+
revision?: number | undefined;
|
|
10
|
+
fingerprint: string;
|
|
11
|
+
source: 'working-tree' | 'git-ref';
|
|
12
|
+
ref?: string | undefined;
|
|
13
|
+
changedFromBase?: boolean | undefined;
|
|
14
|
+
decisionIds?: string[] | undefined;
|
|
15
|
+
}
|
|
8
16
|
export interface GuardRunManifest {
|
|
9
17
|
version: 1;
|
|
10
18
|
runId: string;
|
|
@@ -16,6 +24,7 @@ export interface GuardRunManifest {
|
|
|
16
24
|
gate: string;
|
|
17
25
|
stages: Record<string, GuardRunStage>;
|
|
18
26
|
commit?: string | undefined;
|
|
27
|
+
policy?: GuardRunPolicy | undefined;
|
|
19
28
|
}
|
|
20
29
|
export interface GuardRunContext {
|
|
21
30
|
runId: string;
|
|
@@ -40,4 +49,4 @@ export declare function writeGuardRun(root: string, manifest: GuardRunManifest):
|
|
|
40
49
|
/** Reads locally persisted run manifests without reaching a remote service. */
|
|
41
50
|
export declare function listGuardRuns(root: string, limit?: number): GuardRunManifest[];
|
|
42
51
|
export declare function summarizeGuardRuns(root: string, limit?: number): GuardRunSummary;
|
|
43
|
-
export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string): GuardRunManifest;
|
|
52
|
+
export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string, policy?: GuardRunPolicy): GuardRunManifest;
|
|
@@ -33,7 +33,22 @@ function isGuardRunManifest(value) {
|
|
|
33
33
|
manifest.outcome === 'not-configured') &&
|
|
34
34
|
typeof manifest.gate === 'string' &&
|
|
35
35
|
manifest.stages !== null &&
|
|
36
|
-
typeof manifest.stages === 'object'
|
|
36
|
+
typeof manifest.stages === 'object' &&
|
|
37
|
+
(manifest.policy === undefined ||
|
|
38
|
+
(manifest.policy !== null &&
|
|
39
|
+
typeof manifest.policy === 'object' &&
|
|
40
|
+
typeof manifest.policy.fingerprint === 'string' &&
|
|
41
|
+
manifest.policy.fingerprint.length > 0 &&
|
|
42
|
+
(manifest.policy.source === 'working-tree' || manifest.policy.source === 'git-ref') &&
|
|
43
|
+
(manifest.policy.revision === undefined ||
|
|
44
|
+
(Number.isInteger(manifest.policy.revision) && manifest.policy.revision >= 0)) &&
|
|
45
|
+
(manifest.policy.ref === undefined || typeof manifest.policy.ref === 'string') &&
|
|
46
|
+
(manifest.policy.changedFromBase === undefined ||
|
|
47
|
+
typeof manifest.policy.changedFromBase === 'boolean') &&
|
|
48
|
+
(manifest.policy.decisionIds === undefined ||
|
|
49
|
+
(Array.isArray(manifest.policy.decisionIds) &&
|
|
50
|
+
new Set(manifest.policy.decisionIds).size === manifest.policy.decisionIds.length &&
|
|
51
|
+
manifest.policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0))))));
|
|
37
52
|
}
|
|
38
53
|
export function startGuardRun() {
|
|
39
54
|
return {
|
|
@@ -122,7 +137,7 @@ export function summarizeGuardRuns(root, limit = 100) {
|
|
|
122
137
|
...(total > 0 ? { last: runs[0] } : {}),
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
|
-
export function finishGuardRun(root, context, outcome, gate, command = 'verify') {
|
|
140
|
+
export function finishGuardRun(root, context, outcome, gate, command = 'verify', policy) {
|
|
126
141
|
let commit;
|
|
127
142
|
try {
|
|
128
143
|
commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
|
@@ -143,6 +158,7 @@ export function finishGuardRun(root, context, outcome, gate, command = 'verify')
|
|
|
143
158
|
gate,
|
|
144
159
|
stages: context.stages,
|
|
145
160
|
...(commit ? { commit } : {}),
|
|
161
|
+
...(policy ? { policy } : {}),
|
|
146
162
|
};
|
|
147
163
|
try {
|
|
148
164
|
writeGuardRun(root, manifest);
|
|
@@ -266,6 +266,7 @@ export declare const guardContractsFileSchema: z.ZodObject<{
|
|
|
266
266
|
}, z.core.$strip>>;
|
|
267
267
|
}, z.core.$strip>;
|
|
268
268
|
export declare const guardProposalDecisionSchema: z.ZodObject<{
|
|
269
|
+
decisionId: z.ZodOptional<z.ZodString>;
|
|
269
270
|
id: z.ZodString;
|
|
270
271
|
type: z.ZodEnum<{
|
|
271
272
|
contract: "contract";
|
|
@@ -284,8 +285,11 @@ export declare const guardProposalDecisionSchema: z.ZodObject<{
|
|
|
284
285
|
external: "external";
|
|
285
286
|
mcp: "mcp";
|
|
286
287
|
}>>;
|
|
288
|
+
proposalAuthor: z.ZodOptional<z.ZodString>;
|
|
287
289
|
actor: z.ZodOptional<z.ZodString>;
|
|
288
290
|
commit: z.ZodOptional<z.ZodString>;
|
|
291
|
+
policyFingerprint: z.ZodOptional<z.ZodString>;
|
|
292
|
+
policyRevision: z.ZodOptional<z.ZodNumber>;
|
|
289
293
|
}, z.core.$strip>;
|
|
290
294
|
export declare const guardProposalSchema: z.ZodObject<{
|
|
291
295
|
version: z.ZodLiteral<1>;
|
|
@@ -357,6 +361,7 @@ export declare const guardProposalSchema: z.ZodObject<{
|
|
|
357
361
|
}, z.core.$strip>>;
|
|
358
362
|
questions: z.ZodArray<z.ZodString>;
|
|
359
363
|
decisions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
364
|
+
decisionId: z.ZodOptional<z.ZodString>;
|
|
360
365
|
id: z.ZodString;
|
|
361
366
|
type: z.ZodEnum<{
|
|
362
367
|
contract: "contract";
|
|
@@ -375,8 +380,11 @@ export declare const guardProposalSchema: z.ZodObject<{
|
|
|
375
380
|
external: "external";
|
|
376
381
|
mcp: "mcp";
|
|
377
382
|
}>>;
|
|
383
|
+
proposalAuthor: z.ZodOptional<z.ZodString>;
|
|
378
384
|
actor: z.ZodOptional<z.ZodString>;
|
|
379
385
|
commit: z.ZodOptional<z.ZodString>;
|
|
386
|
+
policyFingerprint: z.ZodOptional<z.ZodString>;
|
|
387
|
+
policyRevision: z.ZodOptional<z.ZodNumber>;
|
|
380
388
|
}, z.core.$strip>>>;
|
|
381
389
|
}, z.core.$strip>;
|
|
382
390
|
export declare const guardAgentConfigSchema: z.ZodObject<{
|
|
@@ -95,6 +95,7 @@ export const guardContractsFileSchema = z.object({
|
|
|
95
95
|
contracts: z.array(guardContractSchema),
|
|
96
96
|
});
|
|
97
97
|
export const guardProposalDecisionSchema = z.object({
|
|
98
|
+
decisionId: z.string().trim().min(1).optional(),
|
|
98
99
|
id: z.string().trim().min(1),
|
|
99
100
|
type: z.enum(['rule', 'contract']),
|
|
100
101
|
decision: z.enum(['approved', 'rejected']),
|
|
@@ -103,8 +104,11 @@ export const guardProposalDecisionSchema = z.object({
|
|
|
103
104
|
proposalFingerprint: z.string().optional(),
|
|
104
105
|
revision: z.number().optional(),
|
|
105
106
|
source: z.enum(['cli', 'mcp', 'external']).optional(),
|
|
107
|
+
proposalAuthor: z.string().trim().min(1).optional(),
|
|
106
108
|
actor: z.string().optional(),
|
|
107
109
|
commit: z.string().optional(),
|
|
110
|
+
policyFingerprint: z.string().trim().min(1).optional(),
|
|
111
|
+
policyRevision: z.number().int().nonnegative().optional(),
|
|
108
112
|
});
|
|
109
113
|
export const guardProposalSchema = z.object({
|
|
110
114
|
version: z.literal(1),
|
|
@@ -3,7 +3,7 @@ import type { ProjectCheck } from '../model/types.js';
|
|
|
3
3
|
import { type GuardAdapter, type ProjectCheckResults, type ProjectRuntimeDiagnostics } from '../../adapters/project-checks.js';
|
|
4
4
|
import { type GuardToolMode, type GuardContractIssue, type GuardReport } from '../guard.js';
|
|
5
5
|
import { type GuardImpactAnalysis } from '../analysis/impact.js';
|
|
6
|
-
import { type GuardRunManifest } from '../history/runs.js';
|
|
6
|
+
import { type GuardRunManifest, type GuardRunPolicy } from '../history/runs.js';
|
|
7
7
|
export type GuardVerificationCheck = ProjectCheck;
|
|
8
8
|
export interface GuardVerificationOptions {
|
|
9
9
|
checks?: GuardVerificationCheck[] | undefined;
|
|
@@ -13,11 +13,13 @@ export interface GuardVerificationOptions {
|
|
|
13
13
|
tools?: GuardToolMode | undefined;
|
|
14
14
|
strict?: boolean | undefined;
|
|
15
15
|
projectChecks?: boolean | undefined;
|
|
16
|
+
policyBase?: string | undefined;
|
|
17
|
+
failOnPolicyChange?: boolean | undefined;
|
|
16
18
|
}
|
|
17
19
|
export interface GuardVerificationResult {
|
|
18
20
|
status: 'ok' | 'fail' | 'not-configured';
|
|
19
21
|
outcome: 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
|
|
20
|
-
errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | undefined;
|
|
22
|
+
errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | undefined;
|
|
21
23
|
recoverable?: boolean | undefined;
|
|
22
24
|
tools?: GuardToolMode | undefined;
|
|
23
25
|
checks: ProjectCheckResults;
|
|
@@ -34,5 +36,7 @@ export interface GuardVerificationResult {
|
|
|
34
36
|
message: string;
|
|
35
37
|
};
|
|
36
38
|
run: GuardRunManifest;
|
|
39
|
+
policy?: GuardRunPolicy | undefined;
|
|
40
|
+
policyChanged?: boolean | undefined;
|
|
37
41
|
}
|
|
38
42
|
export declare function runGuardVerification(root: string, options?: GuardVerificationOptions): GuardVerificationResult;
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import {} from '../../adapters/command.js';
|
|
2
2
|
import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
|
|
3
|
-
import { loadBaseline, loadGuardAgentConfig, loadGuardConfig, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
|
|
3
|
+
import { loadBaseline, loadGuardPolicyAtRevision, createGuardPolicySnapshot, loadGuardWaivers, loadGuardAgentConfig, loadGuardConfig, loadGuardProposals, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
|
|
4
4
|
import { analyzeProjectImpact } from '../analysis/impact.js';
|
|
5
5
|
import { discoverProject } from '../discovery/discovery.js';
|
|
6
6
|
import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
|
|
7
|
+
function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
|
|
8
|
+
return {
|
|
9
|
+
...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
|
|
10
|
+
fingerprint: snapshot.fingerprint,
|
|
11
|
+
source: snapshot.source,
|
|
12
|
+
...(base ? { ref: base } : {}),
|
|
13
|
+
...(base ? { changedFromBase } : {}),
|
|
14
|
+
...(decisionIds.length > 0 ? { decisionIds } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
7
17
|
export function runGuardVerification(root, options = {}) {
|
|
8
18
|
const runContext = startGuardRun();
|
|
9
19
|
const requirement = {
|
|
@@ -16,6 +26,23 @@ export function runGuardVerification(root, options = {}) {
|
|
|
16
26
|
const runtimeStartedAt = Date.now();
|
|
17
27
|
const runtime = inspectProjectRuntime(root);
|
|
18
28
|
recordGuardRunStage(runContext, 'runtime', 'ok', runtimeStartedAt);
|
|
29
|
+
if (options.failOnPolicyChange && !options.policyBase) {
|
|
30
|
+
const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
|
|
31
|
+
return {
|
|
32
|
+
status: 'fail',
|
|
33
|
+
outcome: 'fail',
|
|
34
|
+
errorCode: 'GUARD_POLICY_BASE_INVALID',
|
|
35
|
+
recoverable: true,
|
|
36
|
+
checks: {},
|
|
37
|
+
workspaceChecks: {},
|
|
38
|
+
adapters: {},
|
|
39
|
+
runtime,
|
|
40
|
+
configError: '`failOnPolicyChange` requires `policyBase`.',
|
|
41
|
+
contractIssues: [],
|
|
42
|
+
requirement,
|
|
43
|
+
run,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
19
46
|
let config;
|
|
20
47
|
try {
|
|
21
48
|
config = loadGuardConfig(root);
|
|
@@ -53,7 +80,82 @@ export function runGuardVerification(root, options = {}) {
|
|
|
53
80
|
run,
|
|
54
81
|
};
|
|
55
82
|
}
|
|
56
|
-
|
|
83
|
+
let policySnapshot;
|
|
84
|
+
let workingTreeSnapshot;
|
|
85
|
+
let policyChanged;
|
|
86
|
+
let policyDecisionIds = [];
|
|
87
|
+
try {
|
|
88
|
+
workingTreeSnapshot = createGuardPolicySnapshot(config, loadBaseline(root), loadGuardWaivers(root), 'working-tree', undefined, loadGuardAgentConfig(root));
|
|
89
|
+
const baseSnapshot = options.policyBase
|
|
90
|
+
? loadGuardPolicyAtRevision(root, options.policyBase)
|
|
91
|
+
: undefined;
|
|
92
|
+
if (options.policyBase && !baseSnapshot) {
|
|
93
|
+
const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
|
|
94
|
+
return {
|
|
95
|
+
status: 'fail',
|
|
96
|
+
outcome: 'fail',
|
|
97
|
+
errorCode: 'GUARD_POLICY_BASE_INVALID',
|
|
98
|
+
recoverable: true,
|
|
99
|
+
checks: {},
|
|
100
|
+
workspaceChecks: {},
|
|
101
|
+
adapters: {},
|
|
102
|
+
runtime,
|
|
103
|
+
configError: `Guard policy was not found at Git ref: ${options.policyBase}`,
|
|
104
|
+
contractIssues: [],
|
|
105
|
+
requirement,
|
|
106
|
+
run,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
policySnapshot = baseSnapshot ?? workingTreeSnapshot;
|
|
110
|
+
policyChanged =
|
|
111
|
+
baseSnapshot !== undefined && baseSnapshot.fingerprint !== workingTreeSnapshot.fingerprint;
|
|
112
|
+
if (policySnapshot.source === 'working-tree') {
|
|
113
|
+
policyDecisionIds = (loadGuardProposals(root)?.decisions ?? [])
|
|
114
|
+
.filter((decision) => decision.policyFingerprint === policySnapshot.fingerprint)
|
|
115
|
+
.map((decision) => decision.decisionId ??
|
|
116
|
+
`${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
|
|
117
|
+
.sort();
|
|
118
|
+
}
|
|
119
|
+
if (options.failOnPolicyChange && policyChanged) {
|
|
120
|
+
const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
|
|
121
|
+
const run = finishGuardRun(root, runContext, 'fail', 'policy-change', 'verify', policy);
|
|
122
|
+
return {
|
|
123
|
+
status: 'fail',
|
|
124
|
+
outcome: 'fail',
|
|
125
|
+
errorCode: 'GUARD_POLICY_CHANGED',
|
|
126
|
+
recoverable: true,
|
|
127
|
+
checks: {},
|
|
128
|
+
workspaceChecks: {},
|
|
129
|
+
adapters: {},
|
|
130
|
+
runtime,
|
|
131
|
+
configError: 'Guard policy changed relative to the supplied Git base ref.',
|
|
132
|
+
contractIssues: [],
|
|
133
|
+
requirement,
|
|
134
|
+
run,
|
|
135
|
+
policy,
|
|
136
|
+
policyChanged,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
const run = finishGuardRun(root, runContext, 'fail', 'policy-base');
|
|
142
|
+
return {
|
|
143
|
+
status: 'fail',
|
|
144
|
+
outcome: 'fail',
|
|
145
|
+
errorCode: 'GUARD_POLICY_BASE_INVALID',
|
|
146
|
+
recoverable: true,
|
|
147
|
+
checks: {},
|
|
148
|
+
workspaceChecks: {},
|
|
149
|
+
adapters: {},
|
|
150
|
+
runtime,
|
|
151
|
+
configError: error instanceof Error ? error.message : String(error),
|
|
152
|
+
contractIssues: [],
|
|
153
|
+
requirement,
|
|
154
|
+
run,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const policy = buildRunPolicy(policySnapshot, options.policyBase, policyChanged, policyDecisionIds);
|
|
158
|
+
const agentConfig = policySnapshot.agentConfig;
|
|
57
159
|
const projectChecks = options.projectChecks ?? agentConfig.completionGate.projectChecks;
|
|
58
160
|
const checks = projectChecks ? (options.checks ?? agentConfig.completionGate.checks) : [];
|
|
59
161
|
const checksStartedAt = Date.now();
|
|
@@ -63,7 +165,7 @@ export function runGuardVerification(root, options = {}) {
|
|
|
63
165
|
const workspaceModel = discoverProject(root);
|
|
64
166
|
const impact = analyzeProjectImpact(workspaceModel, options.changedOnly === false
|
|
65
167
|
? workspaceModel.modules.map((module) => module.path)
|
|
66
|
-
: workspaceModel.git.changedFiles, config.contracts ?? []);
|
|
168
|
+
: workspaceModel.git.changedFiles, policySnapshot.config.contracts ?? []);
|
|
67
169
|
const workspaceChecks = runWorkspaceProjectChecks(root, workspaceModel.project.workspacePackages ?? [], checks, {
|
|
68
170
|
timeout: options.timeout,
|
|
69
171
|
rootResults: results,
|
|
@@ -80,12 +182,13 @@ export function runGuardVerification(root, options = {}) {
|
|
|
80
182
|
});
|
|
81
183
|
recordGuardRunStage(runContext, 'adapters', toolMode === 'off' ? 'skipped' : 'ok', adaptersStartedAt);
|
|
82
184
|
const architectureStartedAt = Date.now();
|
|
83
|
-
const architecture = scanGuard(root, config, {
|
|
185
|
+
const architecture = scanGuard(root, policySnapshot.config, {
|
|
84
186
|
changedOnly: options.changedOnly,
|
|
85
|
-
baseline:
|
|
187
|
+
baseline: policySnapshot.baseline,
|
|
86
188
|
includeArchitectureInsights: true,
|
|
189
|
+
waivers: policySnapshot.waivers,
|
|
87
190
|
});
|
|
88
|
-
const contractIssues = validateGuardPolicy(root, config);
|
|
191
|
+
const contractIssues = validateGuardPolicy(root, policySnapshot.config);
|
|
89
192
|
recordGuardRunStage(runContext, 'architecture-policy', 'ok', architectureStartedAt);
|
|
90
193
|
const commandFailed = Object.values(results).some((result) => result.status === 'failed');
|
|
91
194
|
const workspaceCommandFailed = Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'failed'));
|
|
@@ -133,7 +236,7 @@ export function runGuardVerification(root, options = {}) {
|
|
|
133
236
|
: !runtime.compatible && projectChecks
|
|
134
237
|
? 'runtime'
|
|
135
238
|
: 'none';
|
|
136
|
-
const run = finishGuardRun(root, runContext, outcome, gate);
|
|
239
|
+
const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy);
|
|
137
240
|
return {
|
|
138
241
|
status: failed ? 'fail' : 'ok',
|
|
139
242
|
outcome,
|
|
@@ -147,5 +250,7 @@ export function runGuardVerification(root, options = {}) {
|
|
|
147
250
|
contractIssues,
|
|
148
251
|
requirement,
|
|
149
252
|
run,
|
|
253
|
+
policy,
|
|
254
|
+
...(policy.changedFromBase !== undefined ? { policyChanged: policy.changedFromBase } : {}),
|
|
150
255
|
};
|
|
151
256
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,4 +11,5 @@ export * from './core/output/sarif.js';
|
|
|
11
11
|
export * from './core/analysis/doctor.js';
|
|
12
12
|
export * from './core/analysis/packs.js';
|
|
13
13
|
export * from './core/analysis/impact.js';
|
|
14
|
+
export * from './core/audit/governance.js';
|
|
14
15
|
export * from './adapters/project-checks.js';
|
package/dist/index.js
CHANGED
|
@@ -10,4 +10,5 @@ export * from './core/output/sarif.js';
|
|
|
10
10
|
export * from './core/analysis/doctor.js';
|
|
11
11
|
export * from './core/analysis/packs.js';
|
|
12
12
|
export * from './core/analysis/impact.js';
|
|
13
|
+
export * from './core/audit/governance.js';
|
|
13
14
|
export * from './adapters/project-checks.js';
|
package/dist/mcp/tools/guard.js
CHANGED
|
@@ -4,6 +4,7 @@ import { guardErrorPayload } from '../../core/errors.js';
|
|
|
4
4
|
import { analyzeProjectImpact } from '../../core/analysis/impact.js';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { runGuardVerification } from '../../core/verification/verify.js';
|
|
7
|
+
import { auditGuardGovernance } from '../../core/audit/governance.js';
|
|
7
8
|
const rootSchema = z.string().trim().min(1).optional().describe('Project path or workspace root.');
|
|
8
9
|
function getGuardRoot(root) {
|
|
9
10
|
return root ? findGuardRoot(root) : findGuardRoot();
|
|
@@ -450,9 +451,19 @@ export function registerGuardTools(server) {
|
|
|
450
451
|
.boolean()
|
|
451
452
|
.optional()
|
|
452
453
|
.describe('Override completionGate.projectChecks for this invocation.'),
|
|
454
|
+
policy_base: z
|
|
455
|
+
.string()
|
|
456
|
+
.trim()
|
|
457
|
+
.min(1)
|
|
458
|
+
.optional()
|
|
459
|
+
.describe('Git ref whose Guard policy should be used for this verification.'),
|
|
460
|
+
fail_on_policy_change: z
|
|
461
|
+
.boolean()
|
|
462
|
+
.optional()
|
|
463
|
+
.describe('Fail when the checked-out policy differs from policy_base.'),
|
|
453
464
|
iteration: z.number().int().positive().default(1),
|
|
454
465
|
},
|
|
455
|
-
}, ({ root: requestedRoot, checks, changed_only, requirement, tools, strict, project_checks, iteration, }) => {
|
|
466
|
+
}, ({ root: requestedRoot, checks, changed_only, requirement, tools, strict, project_checks, policy_base, fail_on_policy_change, iteration, }) => {
|
|
456
467
|
const root = getGuardRoot(requestedRoot);
|
|
457
468
|
const agent = loadAgentSafely(root);
|
|
458
469
|
if ('error' in agent)
|
|
@@ -464,6 +475,8 @@ export function registerGuardTools(server) {
|
|
|
464
475
|
tools,
|
|
465
476
|
strict,
|
|
466
477
|
projectChecks: project_checks,
|
|
478
|
+
policyBase: policy_base,
|
|
479
|
+
failOnPolicyChange: fail_on_policy_change,
|
|
467
480
|
});
|
|
468
481
|
const completionGate = agent.config.completionGate;
|
|
469
482
|
return jsonToolResult({
|
|
@@ -475,6 +488,18 @@ export function registerGuardTools(server) {
|
|
|
475
488
|
},
|
|
476
489
|
}, result.status === 'fail');
|
|
477
490
|
});
|
|
491
|
+
server.registerTool('codapult_guard_governance', {
|
|
492
|
+
title: 'Guard Governance Audit',
|
|
493
|
+
description: 'Audit declared proposal and approval provenance, policy bindings, and retained verification reachability. This reports metadata; it does not prove identity or signatures.',
|
|
494
|
+
inputSchema: { root: rootSchema },
|
|
495
|
+
}, ({ root: requestedRoot }) => {
|
|
496
|
+
try {
|
|
497
|
+
return jsonToolResult(auditGuardGovernance(getGuardRoot(requestedRoot)));
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
return jsonToolResult({ status: 'fail', message: error instanceof Error ? error.message : String(error) }, true);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
478
503
|
server.registerTool('codapult_guard_audit', {
|
|
479
504
|
title: 'Guard Audit',
|
|
480
505
|
description: 'Run a full Guard audit, ignoring baseline suppression and validating contracts.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codapult/guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"mcpName": "io.github.codapult/guard",
|
|
5
5
|
"description": "Local-first architecture guardrails for JavaScript and TypeScript projects, with first-class support for Next.js SaaS and AI-assisted development",
|
|
6
6
|
"license": "MIT",
|