@codapult/guard 0.6.2 → 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 +105 -10
- 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 +13 -0
- package/dist/cli/commands/guard.js +163 -4
- package/dist/cli/index.js +37 -4
- package/dist/core/analysis/doctor.d.ts +1 -1
- package/dist/core/analysis/doctor.js +35 -2
- 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 +55 -2
- package/dist/core/guard.js +319 -8
- package/dist/core/history/runs.d.ts +10 -1
- package/dist/core/history/runs.js +18 -2
- package/dist/core/model/types.d.ts +22 -0
- package/dist/core/output/sarif.d.ts +7 -2
- package/dist/core/output/sarif.js +23 -14
- package/dist/core/policy/schemas.d.ts +59 -0
- package/dist/core/policy/schemas.js +37 -0
- package/dist/core/verification/verify.d.ts +6 -2
- package/dist/core/verification/verify.js +114 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/resources.js +18 -1
- package/dist/mcp/tools/guard.js +138 -2
- 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.
|
|
@@ -231,12 +248,51 @@ Guard state is stored in `.codapult/guard/`:
|
|
|
231
248
|
├── proposals.json evidence and approval history
|
|
232
249
|
├── baseline.json accepted pre-existing findings
|
|
233
250
|
├── agent.json AI-host completion-gate configuration
|
|
251
|
+
├── waivers.json time-bounded exceptions to active findings
|
|
234
252
|
└── history/ project snapshots and local verification run manifests
|
|
235
253
|
```
|
|
236
254
|
|
|
237
255
|
Commit policy and baseline files when the team wants shared guardrails. Treat cache artifacts as
|
|
238
256
|
disposable according to the project’s policy, and never commit secrets.
|
|
239
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
|
+
|
|
263
|
+
### Temporary waivers
|
|
264
|
+
|
|
265
|
+
Baseline records legacy findings that existed when Guard was introduced. A waiver is different: it
|
|
266
|
+
temporarily suppresses one active finding and must have an owner, reason, and expiry date.
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
codapult-guard waiver add <fingerprint> \
|
|
270
|
+
--owner platform-team \
|
|
271
|
+
--reason "Migration in progress" \
|
|
272
|
+
--expires 2026-12-28
|
|
273
|
+
codapult-guard waiver list
|
|
274
|
+
codapult-guard waiver renew <id> --expires 2027-01-31 --reason "Migration continues"
|
|
275
|
+
codapult-guard waiver remove <id> --reason "Migration completed"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Guard warns when a waiver is within the configured warning window (14 days by default). Once it
|
|
279
|
+
expires, the original finding is active again. Protected policy can forbid waiver changes through
|
|
280
|
+
MCP so an agent can propose a waiver but cannot approve it itself. Waivers are intentionally
|
|
281
|
+
fingerprint-specific; changing the violating code invalidates the waiver.
|
|
282
|
+
|
|
283
|
+
Teams can optionally cap each new waiver or renewal with `waiverPolicy.maxDays` in `rules.json`:
|
|
284
|
+
|
|
285
|
+
```json
|
|
286
|
+
"waiverPolicy": { "warningDays": 14, "maxDays": 90 }
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The cap applies to the requested grant from the current operation; it does not silently delete or
|
|
290
|
+
rewrite existing waivers. Boundary findings with severity `error` fail `check`; warning findings
|
|
291
|
+
and waiver-expiry reminders remain advisory.
|
|
292
|
+
|
|
293
|
+
Run `codapult-guard doctor` to detect an explicitly downgraded client or package boundary. Doctor
|
|
294
|
+
reports it as a warning and leaves the project policy unchanged.
|
|
295
|
+
|
|
240
296
|
### Optional scoped budgets
|
|
241
297
|
|
|
242
298
|
Budgets are policy, not a universal style rule. Add them only for a named risk boundary such as a
|
|
@@ -281,9 +337,10 @@ Register that command in the MCP client from the project root. For example, Curs
|
|
|
281
337
|
{
|
|
282
338
|
"mcpServers": {
|
|
283
339
|
"codapult-guard": {
|
|
340
|
+
"type": "stdio",
|
|
284
341
|
"command": "pnpm",
|
|
285
342
|
"args": ["exec", "codapult-guard", "mcp-server"],
|
|
286
|
-
"cwd": "
|
|
343
|
+
"cwd": "${workspaceFolder}"
|
|
287
344
|
}
|
|
288
345
|
}
|
|
289
346
|
}
|
|
@@ -310,11 +367,11 @@ For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, s
|
|
|
310
367
|
|
|
311
368
|
### MCP Registry
|
|
312
369
|
|
|
313
|
-
Guard is
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
370
|
+
Guard is published in the official [MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.codapult%2Fguard)
|
|
371
|
+
under the server name `io.github.codapult/guard`. The Registry is the discovery layer for the
|
|
372
|
+
published `@codapult/guard` npm package and its stdio MCP server; it does not replace npm
|
|
373
|
+
installation or the host-specific configuration above. For deterministic setup, install the npm
|
|
374
|
+
package directly and use the MCP configuration shown below.
|
|
318
375
|
|
|
319
376
|
For workflows where the authoring agent must not approve its own policy proposals, set Guard to
|
|
320
377
|
protected mode in `rules.json`:
|
|
@@ -335,6 +392,37 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
|
|
|
335
392
|
These variables provide declared provenance only; external branch protection or signed identity
|
|
336
393
|
remains responsible for proving who approved the change.
|
|
337
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
|
+
|
|
338
426
|
See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
|
|
339
427
|
adapter, approval-governance, and observability integrations.
|
|
340
428
|
|
|
@@ -360,16 +448,18 @@ real project shapes.
|
|
|
360
448
|
| `init` | Create project memory and the initial baseline. |
|
|
361
449
|
| `analyze` | Refresh facts without changing policy or baseline. |
|
|
362
450
|
| `propose` | Generate evidence-based rule and contract proposals. |
|
|
363
|
-
| `check --changed`
|
|
451
|
+
| `check [--changed]` | Enforce active policy; `--changed` limits findings to changed and untracked files. |
|
|
364
452
|
| `audit` | Scan the complete current project, including baseline findings. |
|
|
365
453
|
| `review` | Create a bounded semantic-review packet for an AI host. |
|
|
366
454
|
| `verify` | Run the configured completion gate. |
|
|
455
|
+
| `governance` | Audit declared proposal/approval provenance and decision-to-run reachability. |
|
|
367
456
|
| `doctor` | Diagnose invalid or missing Guard artifacts. |
|
|
368
457
|
| `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
|
|
369
458
|
| `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
|
|
370
459
|
| `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
|
|
371
460
|
| `rules` / `contracts` | Approve or reject proposed policy. |
|
|
372
461
|
| `baseline` | Review or intentionally accept existing findings. |
|
|
462
|
+
| `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
|
|
373
463
|
|
|
374
464
|
Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
|
|
375
465
|
discovery cache; it does not change rules, contracts, baseline, or source files.
|
|
@@ -383,8 +473,13 @@ Run `pnpm exec codapult-guard <command> --help` for command-specific options.
|
|
|
383
473
|
|
|
384
474
|
Every `verify` run also writes a local manifest under
|
|
385
475
|
`.codapult/guard/history/runs/<run-id>.json`. It records stage durations, the outcome, and the
|
|
386
|
-
policy gate that decided the result. It
|
|
387
|
-
|
|
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.
|
|
388
483
|
|
|
389
484
|
## Security and data handling
|
|
390
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;
|
|
@@ -26,6 +31,12 @@ interface GuardBaselineOptions extends GuardOutputOptions {
|
|
|
26
31
|
all?: boolean | undefined;
|
|
27
32
|
reason?: string | undefined;
|
|
28
33
|
}
|
|
34
|
+
export interface GuardWaiverOptions extends GuardOutputOptions {
|
|
35
|
+
owner?: string | undefined;
|
|
36
|
+
reason?: string | undefined;
|
|
37
|
+
expires?: string | undefined;
|
|
38
|
+
issue?: string | undefined;
|
|
39
|
+
}
|
|
29
40
|
interface GuardApprovalOptions {
|
|
30
41
|
all?: boolean | undefined;
|
|
31
42
|
}
|
|
@@ -43,12 +54,14 @@ export declare function guardDoctorCommand(options?: GuardOutputOptions & {
|
|
|
43
54
|
}): void;
|
|
44
55
|
export declare function guardHistoryCommand(): void;
|
|
45
56
|
export declare function guardRunsCommand(options?: GuardOutputOptions): void;
|
|
57
|
+
export declare function guardGovernanceCommand(options?: GuardGovernanceOptions): void;
|
|
46
58
|
export declare function guardImpactCommand(files: string[], options?: GuardOutputOptions): void;
|
|
47
59
|
export declare function guardHistoryDiffCommand(from: string, to: string, options?: GuardOutputOptions): void;
|
|
48
60
|
export declare function guardVerifyCommand(options?: GuardVerifyOptions): void;
|
|
49
61
|
export declare function guardCheckCommand(options?: GuardCheckOptions): void;
|
|
50
62
|
export declare function guardAuditCommand(options?: GuardOutputOptions): void;
|
|
51
63
|
export declare function guardBaselineCommand(action: 'list' | 'accept' | 'remove', ids?: string, options?: GuardBaselineOptions): void;
|
|
64
|
+
export declare function guardWaiverCommand(action: 'list' | 'add' | 'remove' | 'renew', value?: string, options?: GuardWaiverOptions): void;
|
|
52
65
|
export declare function guardRulesApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
|
|
53
66
|
export declare function guardPolicyExplainCommand(id: string, options?: GuardOutputOptions): void;
|
|
54
67
|
export declare function guardContractsApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { relative, resolve } from 'node:path';
|
|
3
|
-
import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, updateBaseline, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
|
|
4
|
+
import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, GuardWaiverError, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, loadGuardWaivers, updateBaseline, updateGuardWaivers, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
|
|
4
5
|
import { guardErrorPayload } from '../../core/errors.js';
|
|
5
6
|
import { runGuardVerification, } from '../../core/verification/verify.js';
|
|
6
7
|
import { diagnoseGuard } from '../../core/analysis/doctor.js';
|
|
@@ -10,6 +11,7 @@ import { dim, fail, heading, info, success, warn } from '../ui.js';
|
|
|
10
11
|
import { guardFindingsToSarif } from '../../core/output/sarif.js';
|
|
11
12
|
import { analyzeProjectImpact } from '../../core/analysis/impact.js';
|
|
12
13
|
import { listGuardRuns, summarizeGuardRuns } from '../../core/history/runs.js';
|
|
14
|
+
import { auditGuardGovernance } from '../../core/audit/governance.js';
|
|
13
15
|
function renderFindings(findings) {
|
|
14
16
|
for (const finding of findings) {
|
|
15
17
|
const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
|
|
@@ -19,6 +21,16 @@ function renderFindings(findings) {
|
|
|
19
21
|
dim(` resolved: ${finding.resolvedPath}`);
|
|
20
22
|
}
|
|
21
23
|
}
|
|
24
|
+
function renderWaiverStatus(report) {
|
|
25
|
+
for (const waiver of report.waiverWarnings ?? []) {
|
|
26
|
+
warn(`Waiver ${waiver.id} expires soon: ${waiver.expiresAt} (${waiver.owner})`);
|
|
27
|
+
dim(` ${waiver.fingerprint}: ${waiver.reason}`);
|
|
28
|
+
}
|
|
29
|
+
for (const waiver of report.expiredWaivers ?? []) {
|
|
30
|
+
warn(`Waiver ${waiver.id} expired: ${waiver.expiresAt} (${waiver.owner})`);
|
|
31
|
+
dim(` The original finding is active again: ${waiver.fingerprint}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
22
34
|
function getRoot() {
|
|
23
35
|
return findGuardRoot();
|
|
24
36
|
}
|
|
@@ -174,6 +186,8 @@ export function guardDoctorCommand(options = {}) {
|
|
|
174
186
|
success(`${item.path}: ok`);
|
|
175
187
|
else if (item.status === 'missing')
|
|
176
188
|
warn(`${item.path}: missing`);
|
|
189
|
+
else if (item.status === 'warning')
|
|
190
|
+
warn(`${item.path}: ${item.message}`);
|
|
177
191
|
else
|
|
178
192
|
fail(`${item.path}: invalid`);
|
|
179
193
|
}
|
|
@@ -208,6 +222,32 @@ export function guardRunsCommand(options = {}) {
|
|
|
208
222
|
info(`${run.runId}: ${run.outcome}, gate=${run.gate}, ${run.durationMs} ms`);
|
|
209
223
|
}
|
|
210
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
|
+
}
|
|
211
251
|
export function guardImpactCommand(files, options = {}) {
|
|
212
252
|
const root = getRoot();
|
|
213
253
|
if (files.length === 0) {
|
|
@@ -270,6 +310,8 @@ export function guardVerifyCommand(options = {}) {
|
|
|
270
310
|
tools: options.tools,
|
|
271
311
|
strict: options.strict,
|
|
272
312
|
projectChecks: options.projectChecks,
|
|
313
|
+
policyBase: options.policyBase,
|
|
314
|
+
failOnPolicyChange: options.failOnPolicyChange,
|
|
273
315
|
});
|
|
274
316
|
if (options.json) {
|
|
275
317
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -282,6 +324,12 @@ export function guardVerifyCommand(options = {}) {
|
|
|
282
324
|
process.exitCode = 1;
|
|
283
325
|
return;
|
|
284
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
|
+
}
|
|
285
333
|
for (const [name, check] of Object.entries(result.checks)) {
|
|
286
334
|
if (check.status === 'passed')
|
|
287
335
|
success(`${name}: passed`);
|
|
@@ -303,6 +351,8 @@ export function guardVerifyCommand(options = {}) {
|
|
|
303
351
|
success('architecture: no new regressions');
|
|
304
352
|
else
|
|
305
353
|
renderFindings(findings);
|
|
354
|
+
if (result.architecture)
|
|
355
|
+
renderWaiverStatus(result.architecture);
|
|
306
356
|
for (const issue of result.contractIssues)
|
|
307
357
|
warn(`[${issue.contractId}] ${issue.message}`);
|
|
308
358
|
if (result.contractIssues.length > 0)
|
|
@@ -340,7 +390,8 @@ export function guardCheckCommand(options = {}) {
|
|
|
340
390
|
});
|
|
341
391
|
const contractIssues = validateGuardPolicy(root, config);
|
|
342
392
|
const errors = report.findings.filter((finding) => finding.severity === 'error').length;
|
|
343
|
-
const warnings = report.findings.filter((finding) => finding.severity === 'warning').length
|
|
393
|
+
const warnings = report.findings.filter((finding) => finding.severity === 'warning').length +
|
|
394
|
+
(report.waiverWarnings ?? []).length;
|
|
344
395
|
const contractFindings = contractIssues.map((issue) => ({
|
|
345
396
|
ruleId: `contract:${issue.contractId}`,
|
|
346
397
|
severity: 'error',
|
|
@@ -351,7 +402,19 @@ export function guardCheckCommand(options = {}) {
|
|
|
351
402
|
fingerprint: `contract-invalid|${issue.contractId}|${issue.field}|${issue.value}`,
|
|
352
403
|
}));
|
|
353
404
|
if (options.sarif) {
|
|
354
|
-
|
|
405
|
+
const waiverNotices = [
|
|
406
|
+
...(report.waiverWarnings ?? []).map((waiver) => ({
|
|
407
|
+
ruleId: 'guard-waiver-expiring',
|
|
408
|
+
level: 'warning',
|
|
409
|
+
message: `Waiver ${waiver.id} for ${waiver.ruleId} expires at ${waiver.expiresAt}. Owner: ${waiver.owner}.`,
|
|
410
|
+
})),
|
|
411
|
+
...(report.expiredWaivers ?? []).map((waiver) => ({
|
|
412
|
+
ruleId: 'guard-waiver-expired',
|
|
413
|
+
level: 'warning',
|
|
414
|
+
message: `Waiver ${waiver.id} for ${waiver.ruleId} expired at ${waiver.expiresAt}; the finding is active again.`,
|
|
415
|
+
})),
|
|
416
|
+
];
|
|
417
|
+
console.log(JSON.stringify(guardFindingsToSarif([...report.findings, ...contractFindings], waiverNotices), null, 2));
|
|
355
418
|
process.exitCode = errors > 0 || contractIssues.length > 0 ? 1 : 0;
|
|
356
419
|
return;
|
|
357
420
|
}
|
|
@@ -370,6 +433,7 @@ export function guardCheckCommand(options = {}) {
|
|
|
370
433
|
heading(`Codapult Guard${options.changed ? ' (changed files)' : ''}`);
|
|
371
434
|
dim(`Scanned ${report.scannedFiles} source file(s); suppressed ${report.suppressed} baseline finding(s).`);
|
|
372
435
|
renderFindings(report.findings);
|
|
436
|
+
renderWaiverStatus(report);
|
|
373
437
|
for (const issue of contractIssues)
|
|
374
438
|
warn(`[${issue.contractId}] ${issue.message}`);
|
|
375
439
|
if (errors > 0 || contractIssues.length > 0)
|
|
@@ -406,7 +470,8 @@ export function guardAuditCommand(options = {}) {
|
|
|
406
470
|
const report = scanGuard(root, config, { includeArchitectureInsights: true });
|
|
407
471
|
const contractIssues = validateGuardPolicy(root, config);
|
|
408
472
|
const errors = report.findings.filter((finding) => finding.severity === 'error').length;
|
|
409
|
-
const warnings = report.findings.filter((finding) => finding.severity === 'warning').length
|
|
473
|
+
const warnings = report.findings.filter((finding) => finding.severity === 'warning').length +
|
|
474
|
+
(report.waiverWarnings ?? []).length;
|
|
410
475
|
if (options.json) {
|
|
411
476
|
console.log(JSON.stringify({
|
|
412
477
|
status: errors > 0 || contractIssues.length > 0 ? 'fail' : warnings > 0 ? 'warning' : 'ok',
|
|
@@ -425,6 +490,7 @@ export function guardAuditCommand(options = {}) {
|
|
|
425
490
|
heading('Codapult Guard Audit');
|
|
426
491
|
dim(`Scanned ${report.scannedFiles} source file(s); baseline is ignored for this full audit.`);
|
|
427
492
|
renderFindings(report.findings);
|
|
493
|
+
renderWaiverStatus(report);
|
|
428
494
|
for (const issue of contractIssues)
|
|
429
495
|
warn(`[${issue.contractId}] ${issue.message}`);
|
|
430
496
|
if (errors > 0)
|
|
@@ -480,6 +546,99 @@ export function guardBaselineCommand(action, ids, options = {}) {
|
|
|
480
546
|
success(`${action === 'accept' ? 'Accepted' : 'Removed'} ${selected.length} baseline fingerprint(s).`);
|
|
481
547
|
process.exitCode = 0;
|
|
482
548
|
}
|
|
549
|
+
export function guardWaiverCommand(action, value, options = {}) {
|
|
550
|
+
const root = getRoot();
|
|
551
|
+
const waivers = loadGuardWaivers(root);
|
|
552
|
+
const reason = options.reason?.trim();
|
|
553
|
+
if (action === 'list') {
|
|
554
|
+
const result = { count: waivers.length, waivers };
|
|
555
|
+
if (options.json)
|
|
556
|
+
console.log(JSON.stringify(result, null, 2));
|
|
557
|
+
else {
|
|
558
|
+
heading('Codapult Guard Waivers');
|
|
559
|
+
for (const waiver of waivers) {
|
|
560
|
+
const status = Date.parse(waiver.expiresAt) <= Date.now() ? 'expired' : 'active';
|
|
561
|
+
info(`${waiver.id} [${status}] ${waiver.owner} → ${waiver.expiresAt}`);
|
|
562
|
+
dim(` ${waiver.fingerprint}: ${waiver.reason}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
process.exitCode = 0;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!reason)
|
|
569
|
+
throw new GuardWaiverError('A reason is required for waiver changes.');
|
|
570
|
+
const config = loadGuardConfig(root);
|
|
571
|
+
if (!config)
|
|
572
|
+
throw new GuardWaiverError('Guard is not initialized.');
|
|
573
|
+
if (config.approval?.mode === 'protected' &&
|
|
574
|
+
config.approval.requireDistinctActor &&
|
|
575
|
+
!process.env.GUARD_APPROVER?.trim()) {
|
|
576
|
+
throw new GuardWaiverError('Protected Guard policy requires GUARD_APPROVER for waiver changes.');
|
|
577
|
+
}
|
|
578
|
+
if (action === 'add') {
|
|
579
|
+
const owner = options.owner?.trim();
|
|
580
|
+
if (!value || !owner || !options.expires) {
|
|
581
|
+
throw new GuardWaiverError('Add requires <fingerprint>, --owner, and --expires.');
|
|
582
|
+
}
|
|
583
|
+
const expires = new Date(options.expires);
|
|
584
|
+
if (!Number.isFinite(expires.getTime()))
|
|
585
|
+
throw new GuardWaiverError('Invalid waiver expiry date.');
|
|
586
|
+
const expiresAt = expires.toISOString();
|
|
587
|
+
const finding = scanGuard(root, config, {
|
|
588
|
+
waivers: [],
|
|
589
|
+
includeArchitectureInsights: true,
|
|
590
|
+
}).findings.find((candidate) => candidate.fingerprint === value);
|
|
591
|
+
if (!finding)
|
|
592
|
+
throw new GuardWaiverError(`Finding fingerprint not found: ${value}`);
|
|
593
|
+
const waiver = {
|
|
594
|
+
id: `waiver-${randomUUID()}`,
|
|
595
|
+
fingerprint: value,
|
|
596
|
+
ruleId: finding.ruleId,
|
|
597
|
+
owner,
|
|
598
|
+
reason,
|
|
599
|
+
createdAt: new Date().toISOString(),
|
|
600
|
+
expiresAt,
|
|
601
|
+
...(options.issue ? { issue: options.issue } : {}),
|
|
602
|
+
...(process.env.GUARD_APPROVER?.trim()
|
|
603
|
+
? { approvedBy: process.env.GUARD_APPROVER.trim() }
|
|
604
|
+
: {}),
|
|
605
|
+
};
|
|
606
|
+
updateGuardWaivers(root, {
|
|
607
|
+
action,
|
|
608
|
+
waiverId: waiver.id,
|
|
609
|
+
waiver,
|
|
610
|
+
reason,
|
|
611
|
+
actor: process.env.GUARD_APPROVER,
|
|
612
|
+
maxLifetimeDays: config.waiverPolicy?.maxDays,
|
|
613
|
+
});
|
|
614
|
+
if (options.json)
|
|
615
|
+
console.log(JSON.stringify({ action, waiver }, null, 2));
|
|
616
|
+
else
|
|
617
|
+
success(`Added waiver ${waiver.id}; expires ${waiver.expiresAt}.`);
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
if (!value)
|
|
621
|
+
throw new GuardWaiverError(`Waiver id is required to ${action}.`);
|
|
622
|
+
const expires = action === 'renew' ? new Date(options.expires ?? '') : undefined;
|
|
623
|
+
if (expires && !Number.isFinite(expires.getTime())) {
|
|
624
|
+
throw new GuardWaiverError('Invalid waiver expiry date.');
|
|
625
|
+
}
|
|
626
|
+
const expiresAt = expires?.toISOString();
|
|
627
|
+
updateGuardWaivers(root, {
|
|
628
|
+
action,
|
|
629
|
+
waiverId: value,
|
|
630
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
631
|
+
reason,
|
|
632
|
+
actor: process.env.GUARD_APPROVER,
|
|
633
|
+
maxLifetimeDays: config.waiverPolicy?.maxDays,
|
|
634
|
+
});
|
|
635
|
+
if (options.json)
|
|
636
|
+
console.log(JSON.stringify({ action, waiverId: value, expiresAt }, null, 2));
|
|
637
|
+
else
|
|
638
|
+
success(`${action === 'renew' ? 'Renewed' : 'Removed'} waiver ${value}.`);
|
|
639
|
+
}
|
|
640
|
+
process.exitCode = 0;
|
|
641
|
+
}
|
|
483
642
|
export function guardRulesApproveCommand(ids, options = {}) {
|
|
484
643
|
const root = getRoot();
|
|
485
644
|
const loaded = loadConfigSafely(root);
|
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, } 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
|
-
import { GuardBaselineReasonError, GuardConfigError, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
|
|
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
|
|
@@ -72,6 +76,30 @@ baseline
|
|
|
72
76
|
.option('--reason <text>')
|
|
73
77
|
.option('--json')
|
|
74
78
|
.action((ids, options) => guardBaselineCommand('remove', ids, options));
|
|
79
|
+
const waivers = guard.command('waiver');
|
|
80
|
+
waivers
|
|
81
|
+
.command('list')
|
|
82
|
+
.option('--json')
|
|
83
|
+
.action((options) => guardWaiverCommand('list', undefined, options));
|
|
84
|
+
waivers
|
|
85
|
+
.command('add <fingerprint>')
|
|
86
|
+
.requiredOption('--owner <owner>')
|
|
87
|
+
.requiredOption('--reason <text>')
|
|
88
|
+
.requiredOption('--expires <date>')
|
|
89
|
+
.option('--issue <url>')
|
|
90
|
+
.option('--json')
|
|
91
|
+
.action((fingerprint, options) => guardWaiverCommand('add', fingerprint, options));
|
|
92
|
+
waivers
|
|
93
|
+
.command('remove <id>')
|
|
94
|
+
.requiredOption('--reason <text>')
|
|
95
|
+
.option('--json')
|
|
96
|
+
.action((id, options) => guardWaiverCommand('remove', id, options));
|
|
97
|
+
waivers
|
|
98
|
+
.command('renew <id>')
|
|
99
|
+
.requiredOption('--expires <date>')
|
|
100
|
+
.requiredOption('--reason <text>')
|
|
101
|
+
.option('--json')
|
|
102
|
+
.action((id, options) => guardWaiverCommand('renew', id, options));
|
|
75
103
|
program
|
|
76
104
|
.command('mcp-server')
|
|
77
105
|
.description('start the Guard MCP server over stdio')
|
|
@@ -85,10 +113,13 @@ catch (error) {
|
|
|
85
113
|
if (error instanceof GuardConfigError ||
|
|
86
114
|
error instanceof GuardBaselineReasonError ||
|
|
87
115
|
error instanceof GuardStateBusyError ||
|
|
88
|
-
error instanceof GuardStateStaleError
|
|
116
|
+
error instanceof GuardStateStaleError ||
|
|
117
|
+
error instanceof GuardWaiverError ||
|
|
118
|
+
error instanceof GuardAgentInstallError) {
|
|
89
119
|
const isBusy = error instanceof GuardStateBusyError;
|
|
90
120
|
const isStale = error instanceof GuardStateStaleError;
|
|
91
121
|
const isInvalidInput = error instanceof GuardBaselineReasonError;
|
|
122
|
+
const isInstallError = error instanceof GuardAgentInstallError;
|
|
92
123
|
console.error(JSON.stringify(guardErrorPayload(isBusy
|
|
93
124
|
? 'GUARD_STATE_BUSY'
|
|
94
125
|
: isStale
|
|
@@ -103,7 +134,9 @@ catch (error) {
|
|
|
103
134
|
? 'Re-read Guard state and retry the operation.'
|
|
104
135
|
: isInvalidInput
|
|
105
136
|
? 'Provide a written reason for the baseline decision.'
|
|
106
|
-
:
|
|
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.',
|
|
107
140
|
}), null, 2));
|
|
108
141
|
process.exitCode = 1;
|
|
109
142
|
}
|