@codapult/guard 0.7.0 → 0.8.1
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 +72 -42
- 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 +34 -0
- package/dist/core/guard.js +176 -6
- package/dist/core/history/runs.d.ts +10 -1
- package/dist/core/history/runs.js +36 -3
- package/dist/core/policy/schemas.d.ts +8 -0
- package/dist/core/policy/schemas.js +41 -4
- package/dist/core/verification/verify.d.ts +6 -2
- package/dist/core/verification/verify.js +115 -10
- 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
|
@@ -57,43 +57,6 @@ The core is universal. The strongest first-class scenarios are Next.js SaaS and
|
|
|
57
57
|
development, including server/client boundaries, routes, persistence, authentication, billing,
|
|
58
58
|
background jobs, environment configuration, and AI integrations.
|
|
59
59
|
|
|
60
|
-
## Part of the Codapult ecosystem
|
|
61
|
-
|
|
62
|
-
Guard is an independent open-source project from [Codapult](https://codapult.dev). It does not
|
|
63
|
-
require Codapult and can be installed in any JavaScript or TypeScript repository.
|
|
64
|
-
|
|
65
|
-
The relationship is complementary:
|
|
66
|
-
|
|
67
|
-
| Project | Role |
|
|
68
|
-
| --------------------- | ---------------------------------------------------------------------------------------- |
|
|
69
|
-
| **`@codapult/guard`** | Universal architecture guardrails, project memory, contracts, MCP, and AI-agent context. |
|
|
70
|
-
| **`@codapult/cli`** | Codapult SaaS project CLI that includes Guard through a thin adapter. |
|
|
71
|
-
| **Codapult** | Full-source Next.js SaaS foundation with conventions Guard can discover and protect. |
|
|
72
|
-
|
|
73
|
-
Use standalone Guard for any compatible project. Use the Codapult CLI when working on a Codapult
|
|
74
|
-
SaaS project and you want project management, database, plugins, deployment, MCP, and Guard in one
|
|
75
|
-
CLI.
|
|
76
|
-
|
|
77
|
-
```text
|
|
78
|
-
Existing project
|
|
79
|
-
│
|
|
80
|
-
▼
|
|
81
|
-
deterministic discovery
|
|
82
|
-
AST · files · imports · Git
|
|
83
|
-
│
|
|
84
|
-
▼
|
|
85
|
-
project architecture
|
|
86
|
-
capabilities · graph · impact paths
|
|
87
|
-
│
|
|
88
|
-
▼
|
|
89
|
-
approved project policy
|
|
90
|
-
rules · contracts · conventions · baseline
|
|
91
|
-
│
|
|
92
|
-
▼
|
|
93
|
-
change verification
|
|
94
|
-
check · review packet · verify · CI
|
|
95
|
-
```
|
|
96
|
-
|
|
97
60
|
## Why Guard exists
|
|
98
61
|
|
|
99
62
|
AI agents can produce syntactically valid code that still violates the architecture of a real
|
|
@@ -172,6 +135,23 @@ Policy paths are validated as project-relative paths. Guard rejects traversal, a
|
|
|
172
135
|
symlinks escaping the project root; import-boundary checks include imports, re-exports, and literal
|
|
173
136
|
dynamic imports.
|
|
174
137
|
|
|
138
|
+
### Policy scope and state changes
|
|
139
|
+
|
|
140
|
+
Guard stores policy at the project level, while each policy item can target a narrower boundary:
|
|
141
|
+
|
|
142
|
+
| Policy item | Scope controls |
|
|
143
|
+
| ----------- | ---------------------------------------------------------------------- |
|
|
144
|
+
| Rule | `files`, import patterns, and rule kind |
|
|
145
|
+
| Contract | `scope`, `entrypoints`, import/package constraints, and required calls |
|
|
146
|
+
| Budget | `scope`, metric, limit, severity, and written reason |
|
|
147
|
+
| Baseline | Exact finding fingerprints already accepted as legacy debt |
|
|
148
|
+
| Waiver | One active finding fingerprint, with owner, reason, and expiry |
|
|
149
|
+
|
|
150
|
+
Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
|
|
151
|
+
change policy are explicit: `init`, `rules/contracts approve|reject`, `baseline accept|remove`, and
|
|
152
|
+
`waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
|
|
153
|
+
project's configured commands and records a local run manifest; neither changes approved policy.
|
|
154
|
+
|
|
175
155
|
## What Guard discovers
|
|
176
156
|
|
|
177
157
|
The model is framework-aware without being framework-dependent:
|
|
@@ -207,7 +187,7 @@ init once → edit → check --changed → review → verify → commit / merge
|
|
|
207
187
|
```
|
|
208
188
|
|
|
209
189
|
1. `init` builds project memory and establishes the initial baseline.
|
|
210
|
-
2. `check --changed` is the fast
|
|
190
|
+
2. `check` scans the current project; `check --changed` is the fast change-scoped architecture gate.
|
|
211
191
|
3. `review` prepares a bounded and redacted diff packet for an AI host; it does not call an LLM.
|
|
212
192
|
4. `verify` runs Guard policy, configured project checks, adapters, runtime diagnostics, and
|
|
213
193
|
contract validation.
|
|
@@ -238,6 +218,11 @@ Guard state is stored in `.codapult/guard/`:
|
|
|
238
218
|
Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
|
|
239
219
|
disposable according to the project’s policy, and never commit secrets.
|
|
240
220
|
|
|
221
|
+
Baseline fingerprints do not include line numbers, so formatting and line movement do not recreate
|
|
222
|
+
the same finding. They do include the rule and affected file/import identity, so a rename or module
|
|
223
|
+
move can produce a new fingerprint and should be reviewed explicitly before accepting it into the
|
|
224
|
+
baseline.
|
|
225
|
+
|
|
241
226
|
### Temporary waivers
|
|
242
227
|
|
|
243
228
|
Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
|
|
@@ -315,9 +300,10 @@ Register that command in the MCP client from the project root. For example, Curs
|
|
|
315
300
|
{
|
|
316
301
|
"mcpServers": {
|
|
317
302
|
"codapult-guard": {
|
|
303
|
+
"type": "stdio",
|
|
318
304
|
"command": "pnpm",
|
|
319
305
|
"args": ["exec", "codapult-guard", "mcp-server"],
|
|
320
|
-
"cwd": "
|
|
306
|
+
"cwd": "${workspaceFolder}"
|
|
321
307
|
}
|
|
322
308
|
}
|
|
323
309
|
}
|
|
@@ -369,6 +355,37 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
|
|
|
369
355
|
These variables provide declared provenance only; external branch protection or signed identity
|
|
370
356
|
remains responsible for proving who approved the change.
|
|
371
357
|
|
|
358
|
+
### Verifying against protected policy
|
|
359
|
+
|
|
360
|
+
In CI, verify pull requests against the policy from the protected base commit rather than the
|
|
361
|
+
copy carried by the branch:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
codapult-guard verify --policy-base "$BASE_SHA" --json
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Guard then evaluates the changed source with the base commit's rules, contracts, baseline, waivers,
|
|
368
|
+
and completion-gate configuration. The result reports `policy.source`, `policy.ref`,
|
|
369
|
+
`policy.revision`, `policy.fingerprint`, and whether the checked-out policy differs from that
|
|
370
|
+
base. A branch cannot relax its policy or disable the gate and use that relaxed copy for the same
|
|
371
|
+
verification run.
|
|
372
|
+
|
|
373
|
+
The supplied [`docs/guard-ci.yml`](docs/guard-ci.yml) fetches the full Git history and passes the
|
|
374
|
+
pull request base SHA automatically. Protect `.codapult/guard/` with CODEOWNERS or the equivalent
|
|
375
|
+
review control when policy changes require a separate approval. For example, a repository can add
|
|
376
|
+
`.codapult/guard/* @architecture-owners` to `CODEOWNERS`. Guard reports the policy change; the
|
|
377
|
+
hosting platform enforces reviewer authorization.
|
|
378
|
+
|
|
379
|
+
To make policy changes fail this invocation explicitly, add:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
codapult-guard verify --policy-base "$BASE_SHA" --fail-on-policy-change
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
This is useful for a dedicated policy-review gate. It is intentionally separate from using the
|
|
386
|
+
base policy for source verification, so teams can choose how their protected review workflow
|
|
387
|
+
handles an approved policy update.
|
|
388
|
+
|
|
372
389
|
See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
|
|
373
390
|
adapter, approval-governance, and observability integrations.
|
|
374
391
|
|
|
@@ -394,16 +411,18 @@ real project shapes.
|
|
|
394
411
|
| `init` | Create project memory and the initial baseline. |
|
|
395
412
|
| `analyze` | Refresh facts without changing policy or baseline. |
|
|
396
413
|
| `propose` | Generate evidence-based rule and contract proposals. |
|
|
397
|
-
| `check --changed`
|
|
414
|
+
| `check [--changed]` | Enforce active policy; `--changed` limits findings to changed and untracked files. |
|
|
398
415
|
| `audit` | Scan the complete current project, including baseline findings. |
|
|
399
416
|
| `review` | Create a bounded semantic-review packet for an AI host. |
|
|
400
417
|
| `verify` | Run the configured completion gate. |
|
|
418
|
+
| `governance` | Audit declared proposal/approval provenance and decision-to-run reachability. |
|
|
401
419
|
| `doctor` | Diagnose invalid or missing Guard artifacts. |
|
|
402
420
|
| `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
|
|
403
421
|
| `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
|
|
404
422
|
| `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
|
|
405
423
|
| `rules` / `contracts` | Approve or reject proposed policy. |
|
|
406
424
|
| `baseline` | Review or intentionally accept existing findings. |
|
|
425
|
+
| `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
|
|
407
426
|
|
|
408
427
|
Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
|
|
409
428
|
discovery cache; it does not change rules, contracts, baseline, or source files.
|
|
@@ -417,8 +436,13 @@ Run `pnpm exec codapult-guard <command> --help` for command-specific options.
|
|
|
417
436
|
|
|
418
437
|
Every `verify` run also writes a local manifest under
|
|
419
438
|
`.codapult/guard/history/runs/<run-id>.json`. It records stage durations, the outcome, and the
|
|
420
|
-
policy gate that decided the result. It
|
|
421
|
-
|
|
439
|
+
policy gate that decided the result. It also records the effective policy revision and fingerprint;
|
|
440
|
+
when a base ref was supplied, it records the ref and whether the working-tree policy differed.
|
|
441
|
+
When an approved policy decision caused the current policy, the manifest also retains its decision
|
|
442
|
+
ID, making the relationship traversable from approval to verification. The `governance` command
|
|
443
|
+
audits this metadata and reports missing or overlapping declared actors. It contains no source code
|
|
444
|
+
or external telemetry. These manifests make a failed run explainable without turning Guard into a
|
|
445
|
+
production tracing system or an identity/signature ledger.
|
|
422
446
|
|
|
423
447
|
## Security and data handling
|
|
424
448
|
|
|
@@ -458,6 +482,12 @@ pnpm release:check
|
|
|
458
482
|
- [Fixture matrix](docs/guard-fixtures.md)
|
|
459
483
|
- [Release process](docs/releasing.md)
|
|
460
484
|
|
|
485
|
+
## More from Codapult
|
|
486
|
+
|
|
487
|
+
Starting a SaaS from scratch? [Codapult](https://codapult.dev) is a production-ready Next.js SaaS
|
|
488
|
+
foundation built for AI-assisted development — with MCP context, built-in AI, modular
|
|
489
|
+
architecture, and the source code under your control.
|
|
490
|
+
|
|
461
491
|
## Project status
|
|
462
492
|
|
|
463
493
|
`@codapult/guard` starts at `0.1.0` as a public alpha. The deterministic core is usable, tested,
|
|
@@ -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: approved.length - 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,35 @@ 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
|
+
export interface GuardWorkingTreePolicyState {
|
|
251
|
+
config: GuardConfig;
|
|
252
|
+
snapshot: GuardPolicySnapshot;
|
|
253
|
+
proposals?: GuardProposalFile | undefined;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Fingerprints the effective policy used by a verification run.
|
|
257
|
+
*
|
|
258
|
+
* The rules revision alone is not sufficient: baseline and waiver changes also
|
|
259
|
+
* affect the verdict. Keeping them in this fingerprint makes a run auditable
|
|
260
|
+
* without pretending that a numeric revision covers every policy artifact.
|
|
261
|
+
*/
|
|
262
|
+
export declare function fingerprintGuardPolicy(guardConfig: GuardConfig, baseline?: Set<string>, waivers?: GuardWaiver[], agentConfig?: GuardAgentConfig): string;
|
|
263
|
+
export declare function createGuardPolicySnapshot(guardConfig: GuardConfig, baseline: Set<string>, waivers: GuardWaiver[], source?: GuardPolicySnapshot['source'], ref?: string, agentConfig?: GuardAgentConfig): GuardPolicySnapshot;
|
|
264
|
+
/**
|
|
265
|
+
* Loads policy artifacts from a trusted Git ref instead of the checked-out
|
|
266
|
+
* working tree. This is intended for CI verification of untrusted branches.
|
|
267
|
+
*/
|
|
268
|
+
export declare function loadGuardPolicyAtRevision(root: string, revision: string): GuardPolicySnapshot | undefined;
|
|
236
269
|
export declare function getGuardProposalFreshness(model: ProjectModel, proposals: GuardProposalFile | undefined): GuardProposalFreshness;
|
|
237
270
|
export declare function loadGuardConfig(root: string): GuardConfig | undefined;
|
|
238
271
|
export declare function loadGuardProposals(root: string): GuardProposalFile | undefined;
|
|
@@ -250,6 +283,7 @@ export interface GuardWaiverUpdateOptions {
|
|
|
250
283
|
maxLifetimeDays?: number | undefined;
|
|
251
284
|
}
|
|
252
285
|
export declare function loadGuardWaivers(root: string): GuardWaiver[];
|
|
286
|
+
export declare function loadGuardWorkingTreePolicy(root: string): GuardWorkingTreePolicyState | undefined;
|
|
253
287
|
export declare function writeGuardWaivers(root: string): void;
|
|
254
288
|
export declare function updateGuardWaivers(root: string, options: GuardWaiverUpdateOptions): GuardWaiver[];
|
|
255
289
|
export declare function updateBaseline(root: string, options?: GuardBaselineUpdateOptions): Set<string>;
|