@codapult/guard 0.8.1 → 0.10.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 +28 -13
- package/dist/cli/commands/guard.d.ts +1 -0
- package/dist/cli/commands/guard.js +21 -1
- package/dist/cli/index.js +2 -1
- package/dist/core/discovery/discovery.js +20 -2
- package/dist/core/guard.d.ts +21 -0
- package/dist/core/guard.js +107 -3
- package/dist/core/history/history.js +20 -2
- package/dist/core/history/runs.d.ts +43 -1
- package/dist/core/history/runs.js +167 -27
- package/dist/core/verification/verify.js +125 -18
- package/dist/mcp/context.d.ts +48 -0
- package/dist/mcp/context.js +49 -0
- package/dist/mcp/resources.js +9 -6
- package/dist/mcp/tools/guard.js +53 -13
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
## Architecture guardrails for AI coding agents
|
|
11
11
|
|
|
12
|
-
`@codapult/guard` is
|
|
13
|
-
|
|
12
|
+
`@codapult/guard` is local-first, model-agnostic **architecture guardrail software for AI coding
|
|
13
|
+
agents**. Use it to detect architectural drift, enforce approved software architecture rules, and
|
|
14
|
+
check code changes in JavaScript and TypeScript projects.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
changes
|
|
16
|
+
Guard learns how your codebase is structured, records project architecture as reviewable memory, and
|
|
17
|
+
checks future changes against the rules your team approves. It connects AI coding agents to those
|
|
18
|
+
rules and returns deterministic evidence when a change introduces a covered architecture
|
|
19
|
+
regression. Run the same project-specific checks locally and in CI.
|
|
17
20
|
|
|
18
21
|
### See the value in one change
|
|
19
22
|
|
|
@@ -148,10 +151,13 @@ Guard stores policy at the project level, while each policy item can target a na
|
|
|
148
151
|
| Waiver | One active finding fingerprint, with owner, reason, and expiry |
|
|
149
152
|
|
|
150
153
|
Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
|
|
151
|
-
change policy are explicit: `init`, `rules
|
|
154
|
+
change policy are explicit: `init`, `rules approve`, `contracts approve|reject`, `baseline accept|remove`, and
|
|
152
155
|
`waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
|
|
153
156
|
project's configured commands and records a local run manifest; neither changes approved policy.
|
|
154
157
|
|
|
158
|
+
For the complete mutation contract and host/CI controls, see
|
|
159
|
+
[Policy mutation control](docs/policy-mutations.md).
|
|
160
|
+
|
|
155
161
|
## What Guard discovers
|
|
156
162
|
|
|
157
163
|
The model is framework-aware without being framework-dependent:
|
|
@@ -325,6 +331,12 @@ task finished
|
|
|
325
331
|
→ repeat until pass or bounded iteration limit
|
|
326
332
|
```
|
|
327
333
|
|
|
334
|
+
`codapult_guard_context` keeps the project state explicit for the host: `observations` are
|
|
335
|
+
discovered facts only, `proposals.pendingRules` and `proposals.pendingContracts` are unapproved
|
|
336
|
+
policy suggestions, `policy` contains only active rules/contracts plus baseline and waiver state,
|
|
337
|
+
and `verification` reports whether a check has actually run. A context response with
|
|
338
|
+
`verification.status: "not-run"` and `findings: null` is not a passing check.
|
|
339
|
+
|
|
328
340
|
For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, see
|
|
329
341
|
[`docs/integrations/`](docs/integrations/).
|
|
330
342
|
|
|
@@ -398,7 +410,9 @@ cp node_modules/@codapult/guard/docs/guard-ci.yml .github/workflows/guard.yml
|
|
|
398
410
|
```
|
|
399
411
|
|
|
400
412
|
Or copy it from [`docs/guard-ci.yml`](docs/guard-ci.yml). It runs project verification and exports
|
|
401
|
-
Guard findings as SARIF for GitHub code scanning.
|
|
413
|
+
Guard findings as SARIF for GitHub code scanning. Pull requests install dependencies without
|
|
414
|
+
lifecycle scripts and use deterministic Guard checks without executing PR-owned scripts; trusted
|
|
415
|
+
pushes may run the configured project checks.
|
|
402
416
|
|
|
403
417
|
The package repository separately runs its own unit, typecheck, build, and packaging checks in
|
|
404
418
|
[`.github/workflows/ci.yml`](.github/workflows/ci.yml), while the fixture workflow tests Guard on
|
|
@@ -420,7 +434,7 @@ real project shapes.
|
|
|
420
434
|
| `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
|
|
421
435
|
| `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
|
|
422
436
|
| `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
|
|
423
|
-
| `rules` / `contracts` | Approve or reject
|
|
437
|
+
| `rules` / `contracts` | Approve rules; approve or reject contracts. |
|
|
424
438
|
| `baseline` | Review or intentionally accept existing findings. |
|
|
425
439
|
| `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
|
|
426
440
|
|
|
@@ -444,6 +458,13 @@ audits this metadata and reports missing or overlapping declared actors. It cont
|
|
|
444
458
|
or external telemetry. These manifests make a failed run explainable without turning Guard into a
|
|
445
459
|
production tracing system or an identity/signature ledger.
|
|
446
460
|
|
|
461
|
+
The manifest also contains a `verification-receipt` object. It is a compact, machine-readable
|
|
462
|
+
record of the commit, policy base and fingerprint, decision IDs, check statuses, finding counts,
|
|
463
|
+
contract issues, waiver state, and Guard version. It intentionally excludes command output and
|
|
464
|
+
source code. The receipt describes what Guard decided; it is not a cryptographic signature or proof
|
|
465
|
+
of CI identity. When a policy base is supplied, it separates the base diff from dirty working-tree
|
|
466
|
+
files so local verification cannot silently present an incomplete change set.
|
|
467
|
+
|
|
447
468
|
## Security and data handling
|
|
448
469
|
|
|
449
470
|
- Deterministic discovery and checks run locally.
|
|
@@ -488,12 +509,6 @@ Starting a SaaS from scratch? [Codapult](https://codapult.dev) is a production-r
|
|
|
488
509
|
foundation built for AI-assisted development — with MCP context, built-in AI, modular
|
|
489
510
|
architecture, and the source code under your control.
|
|
490
511
|
|
|
491
|
-
## Project status
|
|
492
|
-
|
|
493
|
-
`@codapult/guard` starts at `0.1.0` as a public alpha. The deterministic core is usable, tested,
|
|
494
|
-
and intended for real projects, while policy schema and integration surfaces may still evolve
|
|
495
|
-
before `1.0.0`.
|
|
496
|
-
|
|
497
512
|
## License
|
|
498
513
|
|
|
499
514
|
MIT. See [LICENSE](LICENSE).
|
|
@@ -64,6 +64,7 @@ export declare function guardBaselineCommand(action: 'list' | 'accept' | 'remove
|
|
|
64
64
|
export declare function guardWaiverCommand(action: 'list' | 'add' | 'remove' | 'renew', value?: string, options?: GuardWaiverOptions): void;
|
|
65
65
|
export declare function guardRulesApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
|
|
66
66
|
export declare function guardPolicyExplainCommand(id: string, options?: GuardOutputOptions): void;
|
|
67
|
+
export declare function guardPolicyMutationsCommand(options?: GuardOutputOptions): void;
|
|
67
68
|
export declare function guardContractsApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
|
|
68
69
|
export declare function guardContractsRejectCommand(ids?: string, options?: GuardApprovalOptions): void;
|
|
69
70
|
export declare function guardReviewCommand(options?: GuardReviewOptions): void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { relative, resolve } from 'node:path';
|
|
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
|
+
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, getGuardPolicyMutationPolicy, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
|
|
5
5
|
import { guardErrorPayload } from '../../core/errors.js';
|
|
6
6
|
import { runGuardVerification, } from '../../core/verification/verify.js';
|
|
7
7
|
import { diagnoseGuard } from '../../core/analysis/doctor.js';
|
|
@@ -719,6 +719,26 @@ export function guardPolicyExplainCommand(id, options = {}) {
|
|
|
719
719
|
}
|
|
720
720
|
process.exitCode = 0;
|
|
721
721
|
}
|
|
722
|
+
export function guardPolicyMutationsCommand(options = {}) {
|
|
723
|
+
const root = getRoot();
|
|
724
|
+
const loaded = loadConfigSafely(root, options);
|
|
725
|
+
if (loaded.invalid)
|
|
726
|
+
return;
|
|
727
|
+
const policy = getGuardPolicyMutationPolicy(loaded.config);
|
|
728
|
+
if (options.json) {
|
|
729
|
+
console.log(JSON.stringify(policy, null, 2));
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
heading('Guard Policy Mutations');
|
|
733
|
+
info(policy.message);
|
|
734
|
+
for (const operation of policy.operations) {
|
|
735
|
+
info(`${operation.id}: ${operation.surfaces.join(', ')}; ${operation.guardBlocksMcp === true
|
|
736
|
+
? 'MCP blocked in protected mode'
|
|
737
|
+
: 'external control required'}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
process.exitCode = 0;
|
|
741
|
+
}
|
|
722
742
|
export function guardContractsApproveCommand(ids, options = {}) {
|
|
723
743
|
const root = getRoot();
|
|
724
744
|
const loaded = loadConfigSafely(root);
|
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, guardGovernanceCommand, 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, guardPolicyMutationsCommand, 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
7
|
import { GuardAgentInstallError } from '../adapters/agents/agent-integration.js';
|
|
@@ -56,6 +56,7 @@ const rules = guard.command('rules');
|
|
|
56
56
|
rules.command('approve [ids]').option('--all').action(guardRulesApproveCommand);
|
|
57
57
|
const policy = guard.command('policy');
|
|
58
58
|
policy.command('explain <id>').option('--json').action(guardPolicyExplainCommand);
|
|
59
|
+
policy.command('mutations').option('--json').action(guardPolicyMutationsCommand);
|
|
59
60
|
const contracts = guard.command('contracts');
|
|
60
61
|
contracts.command('approve [ids]').option('--all').action(guardContractsApproveCommand);
|
|
61
62
|
contracts.command('reject [ids]').option('--all').action(guardContractsRejectCommand);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { basename, dirname, extname, join, normalize, relative, resolve } from 'node:path';
|
|
5
5
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
6
6
|
import { config } from '../config.js';
|
|
@@ -61,6 +61,23 @@ const DISCOVERY_CACHE_VERSION = 3;
|
|
|
61
61
|
function discoveryCachePath(root) {
|
|
62
62
|
return resolve(root, `.${config.appName}/guard/cache.json`);
|
|
63
63
|
}
|
|
64
|
+
function canPersistDiscoveryCache(root) {
|
|
65
|
+
const parent = resolve(root, `.${config.appName}`);
|
|
66
|
+
const directory = dirname(discoveryCachePath(root));
|
|
67
|
+
for (const path of [parent, directory]) {
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
continue;
|
|
70
|
+
try {
|
|
71
|
+
const stats = lstatSync(path);
|
|
72
|
+
if (stats.isSymbolicLink() || !stats.isDirectory())
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
64
81
|
export function clearDiscoveryCache(root) {
|
|
65
82
|
rmSync(discoveryCachePath(root), { force: true });
|
|
66
83
|
}
|
|
@@ -941,7 +958,8 @@ function discoverProjectOnce(root, options = {}, attempt = 0) {
|
|
|
941
958
|
}
|
|
942
959
|
discoveryCache.set(cacheKey, { signature, model });
|
|
943
960
|
discoveryReuse.set(cacheKey, reusedModules);
|
|
944
|
-
if (
|
|
961
|
+
if (canPersistDiscoveryCache(root) &&
|
|
962
|
+
(options.persistCache ?? existsSync(resolve(root, `.${config.appName}/guard`)))) {
|
|
945
963
|
try {
|
|
946
964
|
mkdirSync(resolve(root, `.${config.appName}/guard`), { recursive: true });
|
|
947
965
|
const cachePath = discoveryCachePath(root);
|
package/dist/core/guard.d.ts
CHANGED
|
@@ -87,11 +87,32 @@ export interface GuardConfig {
|
|
|
87
87
|
approval?: GuardApprovalPolicy | undefined;
|
|
88
88
|
waiverPolicy?: GuardWaiverPolicy | undefined;
|
|
89
89
|
}
|
|
90
|
+
export declare function getGuardPolicyMutationPolicy(guardConfig: GuardConfig | undefined): GuardPolicyMutationPolicy;
|
|
90
91
|
export interface GuardApprovalPolicy {
|
|
91
92
|
mode: GuardApprovalMode;
|
|
92
93
|
allowMcpApproval: boolean;
|
|
93
94
|
requireDistinctActor: boolean;
|
|
94
95
|
}
|
|
96
|
+
export type GuardPolicyMutationId = 'init.force' | 'proposal.approve' | 'proposal.reject' | 'rules.approve' | 'contracts.approve' | 'contracts.reject' | 'baseline.accept' | 'baseline.remove' | 'waiver.add' | 'waiver.renew' | 'waiver.remove' | 'policy-file.edit';
|
|
97
|
+
export interface GuardPolicyMutationDescriptor {
|
|
98
|
+
id: GuardPolicyMutationId;
|
|
99
|
+
surfaces: ('cli' | 'mcp' | 'file')[];
|
|
100
|
+
changesPolicy: true;
|
|
101
|
+
guardConfirmation: boolean;
|
|
102
|
+
reasonRequired: boolean;
|
|
103
|
+
guardBlocksMcp?: boolean | undefined;
|
|
104
|
+
externalApprovalRequired: true;
|
|
105
|
+
externalControl: 'host-or-ci';
|
|
106
|
+
}
|
|
107
|
+
export interface GuardPolicyMutationPolicy {
|
|
108
|
+
version: 1;
|
|
109
|
+
status: 'declared';
|
|
110
|
+
humanOnlyEnforcedByGuard: false;
|
|
111
|
+
message: string;
|
|
112
|
+
approvalMode: GuardApprovalMode;
|
|
113
|
+
requireDistinctActor: boolean;
|
|
114
|
+
operations: GuardPolicyMutationDescriptor[];
|
|
115
|
+
}
|
|
95
116
|
export interface GuardProposalFile {
|
|
96
117
|
version: 1;
|
|
97
118
|
generatedAt: string;
|
package/dist/core/guard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
5
5
|
import { dirname, extname, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
7
7
|
import { buildModuleTargetGraph } from './discovery/discovery.js';
|
|
@@ -19,6 +19,79 @@ export function classifyGuardOutcome(input) {
|
|
|
19
19
|
return 'needs-review';
|
|
20
20
|
return (input.warnings ?? 0) > 0 ? 'warning' : 'pass';
|
|
21
21
|
}
|
|
22
|
+
export function getGuardPolicyMutationPolicy(guardConfig) {
|
|
23
|
+
const approval = guardConfig?.approval ?? {
|
|
24
|
+
mode: 'local',
|
|
25
|
+
allowMcpApproval: true,
|
|
26
|
+
requireDistinctActor: false,
|
|
27
|
+
};
|
|
28
|
+
const protectedMode = approval.mode === 'protected';
|
|
29
|
+
const mcpApprovalBlocked = protectedMode && !approval.allowMcpApproval;
|
|
30
|
+
const descriptor = (id, surfaces, options) => ({
|
|
31
|
+
id,
|
|
32
|
+
surfaces,
|
|
33
|
+
changesPolicy: true,
|
|
34
|
+
...options,
|
|
35
|
+
...(options.guardBlocksMcp !== undefined ? { guardBlocksMcp: options.guardBlocksMcp } : {}),
|
|
36
|
+
externalApprovalRequired: true,
|
|
37
|
+
externalControl: 'host-or-ci',
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
version: 1,
|
|
41
|
+
status: 'declared',
|
|
42
|
+
humanOnlyEnforcedByGuard: false,
|
|
43
|
+
message: 'Guard identifies policy mutations and applies its configured checks; a host or CI system must enforce human/external approval.',
|
|
44
|
+
approvalMode: approval.mode,
|
|
45
|
+
requireDistinctActor: approval.requireDistinctActor,
|
|
46
|
+
operations: [
|
|
47
|
+
descriptor('init.force', ['cli', 'mcp'], {
|
|
48
|
+
guardConfirmation: true,
|
|
49
|
+
reasonRequired: false,
|
|
50
|
+
guardBlocksMcp: mcpApprovalBlocked,
|
|
51
|
+
}),
|
|
52
|
+
descriptor('proposal.approve', ['mcp'], {
|
|
53
|
+
guardConfirmation: true,
|
|
54
|
+
reasonRequired: false,
|
|
55
|
+
guardBlocksMcp: mcpApprovalBlocked,
|
|
56
|
+
}),
|
|
57
|
+
descriptor('proposal.reject', ['mcp'], {
|
|
58
|
+
guardConfirmation: true,
|
|
59
|
+
reasonRequired: false,
|
|
60
|
+
guardBlocksMcp: false,
|
|
61
|
+
}),
|
|
62
|
+
descriptor('rules.approve', ['cli'], { guardConfirmation: false, reasonRequired: false }),
|
|
63
|
+
descriptor('contracts.approve', ['cli'], {
|
|
64
|
+
guardConfirmation: false,
|
|
65
|
+
reasonRequired: false,
|
|
66
|
+
}),
|
|
67
|
+
descriptor('contracts.reject', ['cli'], {
|
|
68
|
+
guardConfirmation: false,
|
|
69
|
+
reasonRequired: false,
|
|
70
|
+
}),
|
|
71
|
+
descriptor('baseline.accept', ['cli'], { guardConfirmation: false, reasonRequired: true }),
|
|
72
|
+
descriptor('baseline.remove', ['cli'], { guardConfirmation: false, reasonRequired: true }),
|
|
73
|
+
descriptor('waiver.add', ['cli', 'mcp'], {
|
|
74
|
+
guardConfirmation: true,
|
|
75
|
+
reasonRequired: true,
|
|
76
|
+
guardBlocksMcp: mcpApprovalBlocked,
|
|
77
|
+
}),
|
|
78
|
+
descriptor('waiver.renew', ['cli', 'mcp'], {
|
|
79
|
+
guardConfirmation: true,
|
|
80
|
+
reasonRequired: true,
|
|
81
|
+
guardBlocksMcp: mcpApprovalBlocked,
|
|
82
|
+
}),
|
|
83
|
+
descriptor('waiver.remove', ['cli', 'mcp'], {
|
|
84
|
+
guardConfirmation: true,
|
|
85
|
+
reasonRequired: true,
|
|
86
|
+
guardBlocksMcp: mcpApprovalBlocked,
|
|
87
|
+
}),
|
|
88
|
+
descriptor('policy-file.edit', ['file'], {
|
|
89
|
+
guardConfirmation: false,
|
|
90
|
+
reasonRequired: false,
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
22
95
|
export const GUARD_DIR = `.${config.appName}/guard`;
|
|
23
96
|
export const GUARD_RULES_FILE = `${GUARD_DIR}/rules.json`;
|
|
24
97
|
export const GUARD_BASELINE_FILE = `${GUARD_DIR}/baseline.json`;
|
|
@@ -144,8 +217,24 @@ function getActiveStateRoot(path) {
|
|
|
144
217
|
function safeGeneration(value) {
|
|
145
218
|
return typeof value === 'string' && /^[A-Za-z0-9._-]+$/.test(value);
|
|
146
219
|
}
|
|
220
|
+
function assertGuardStateDirectory(root) {
|
|
221
|
+
const stateParent = resolve(root, dirname(GUARD_DIR));
|
|
222
|
+
const stateDirectory = resolve(root, GUARD_DIR);
|
|
223
|
+
for (const path of [stateParent, stateDirectory]) {
|
|
224
|
+
if (!existsSync(path))
|
|
225
|
+
continue;
|
|
226
|
+
const stats = lstatSync(path);
|
|
227
|
+
if (stats.isSymbolicLink()) {
|
|
228
|
+
throw new GuardConfigError(`${path} must not be a symbolic link.`);
|
|
229
|
+
}
|
|
230
|
+
if (!stats.isDirectory()) {
|
|
231
|
+
throw new GuardConfigError(`${path} must be a directory.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
147
235
|
export function withGuardStateLock(root, callback, options = {}) {
|
|
148
236
|
const normalizedRoot = resolve(root);
|
|
237
|
+
assertGuardStateDirectory(normalizedRoot);
|
|
149
238
|
const activeDepth = activeStateLocks.get(normalizedRoot);
|
|
150
239
|
if (activeDepth !== undefined) {
|
|
151
240
|
activeStateLocks.set(normalizedRoot, activeDepth + 1);
|
|
@@ -222,6 +311,15 @@ function isGuardConfig(value) {
|
|
|
222
311
|
return guardConfigSchema.safeParse(value).success;
|
|
223
312
|
}
|
|
224
313
|
function readJson(filePath) {
|
|
314
|
+
let stats;
|
|
315
|
+
try {
|
|
316
|
+
stats = lstatSync(filePath);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
if (stats.isSymbolicLink())
|
|
322
|
+
throw new GuardConfigError(filePath);
|
|
225
323
|
try {
|
|
226
324
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
227
325
|
}
|
|
@@ -555,9 +653,15 @@ export function loadBaseline(root) {
|
|
|
555
653
|
if (!existsSync(path))
|
|
556
654
|
return new Set();
|
|
557
655
|
const value = readJson(path);
|
|
558
|
-
if (!Array.isArray(value))
|
|
656
|
+
if (!Array.isArray(value)) {
|
|
559
657
|
throw new GuardConfigError(GUARD_BASELINE_FILE);
|
|
560
|
-
|
|
658
|
+
}
|
|
659
|
+
const entries = value;
|
|
660
|
+
const stringEntries = entries.filter((item) => typeof item === 'string');
|
|
661
|
+
if (stringEntries.length !== entries.length) {
|
|
662
|
+
throw new GuardConfigError(GUARD_BASELINE_FILE);
|
|
663
|
+
}
|
|
664
|
+
return new Set(stringEntries);
|
|
561
665
|
}
|
|
562
666
|
function validateGuardWaiversFile(value) {
|
|
563
667
|
const ids = new Set();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { GUARD_HISTORY_DIR } from '../guard.js';
|
|
4
4
|
function snapshotInsights(model) {
|
|
@@ -18,7 +18,10 @@ function readSnapshot(root, revision) {
|
|
|
18
18
|
if (!/^[A-Za-z0-9._-]+$/.test(revision))
|
|
19
19
|
return undefined;
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
const path = resolve(root, GUARD_HISTORY_DIR, `${revision}.json`);
|
|
22
|
+
if (!lstatSync(path).isFile())
|
|
23
|
+
return undefined;
|
|
24
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
22
25
|
}
|
|
23
26
|
catch {
|
|
24
27
|
return undefined;
|
|
@@ -28,8 +31,23 @@ export function listGuardSnapshots(root) {
|
|
|
28
31
|
const directory = resolve(root, GUARD_HISTORY_DIR);
|
|
29
32
|
if (!existsSync(directory))
|
|
30
33
|
return [];
|
|
34
|
+
try {
|
|
35
|
+
if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
31
41
|
return readdirSync(directory)
|
|
32
42
|
.filter((file) => file.endsWith('.json'))
|
|
43
|
+
.filter((file) => {
|
|
44
|
+
try {
|
|
45
|
+
return lstatSync(resolve(directory, file)).isFile();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
})
|
|
33
51
|
.sort()
|
|
34
52
|
.flatMap((file) => {
|
|
35
53
|
const revision = file.slice(0, -5);
|
|
@@ -13,6 +13,47 @@ export interface GuardRunPolicy {
|
|
|
13
13
|
changedFromBase?: boolean | undefined;
|
|
14
14
|
decisionIds?: string[] | undefined;
|
|
15
15
|
}
|
|
16
|
+
export interface GuardVerificationReceiptCheck {
|
|
17
|
+
status: 'passed' | 'failed' | 'not-configured';
|
|
18
|
+
exitCode: number;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
export interface GuardVerificationReceipt {
|
|
22
|
+
version: 1;
|
|
23
|
+
kind: 'verification-receipt';
|
|
24
|
+
runId: string;
|
|
25
|
+
generatedAt: string;
|
|
26
|
+
outcome: GuardRunManifest['outcome'];
|
|
27
|
+
gate: string;
|
|
28
|
+
subject: {
|
|
29
|
+
commit?: string | undefined;
|
|
30
|
+
baseRef?: string | undefined;
|
|
31
|
+
baseCommit?: string | undefined;
|
|
32
|
+
changedFiles: string[];
|
|
33
|
+
baseChangedFiles?: string[] | undefined;
|
|
34
|
+
workingTreeFiles?: string[] | undefined;
|
|
35
|
+
workingTreeDirty: boolean;
|
|
36
|
+
};
|
|
37
|
+
policy?: GuardRunPolicy | undefined;
|
|
38
|
+
stages: Record<string, 'ok' | 'fail' | 'skipped'>;
|
|
39
|
+
checks: Record<string, GuardVerificationReceiptCheck>;
|
|
40
|
+
findings: {
|
|
41
|
+
total: number;
|
|
42
|
+
errors: number;
|
|
43
|
+
warnings: number;
|
|
44
|
+
waived: number;
|
|
45
|
+
waiverWarnings: number;
|
|
46
|
+
expiredWaivers: number;
|
|
47
|
+
fingerprints: string[];
|
|
48
|
+
ruleIds: string[];
|
|
49
|
+
};
|
|
50
|
+
contractIssues: string[];
|
|
51
|
+
decisionIds: string[];
|
|
52
|
+
tool: {
|
|
53
|
+
name: string;
|
|
54
|
+
version: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
16
57
|
export interface GuardRunManifest {
|
|
17
58
|
version: 1;
|
|
18
59
|
runId: string;
|
|
@@ -25,6 +66,7 @@ export interface GuardRunManifest {
|
|
|
25
66
|
stages: Record<string, GuardRunStage>;
|
|
26
67
|
commit?: string | undefined;
|
|
27
68
|
policy?: GuardRunPolicy | undefined;
|
|
69
|
+
receipt?: GuardVerificationReceipt | undefined;
|
|
28
70
|
}
|
|
29
71
|
export interface GuardRunContext {
|
|
30
72
|
runId: string;
|
|
@@ -49,4 +91,4 @@ export declare function writeGuardRun(root: string, manifest: GuardRunManifest):
|
|
|
49
91
|
/** Reads locally persisted run manifests without reaching a remote service. */
|
|
50
92
|
export declare function listGuardRuns(root: string, limit?: number): GuardRunManifest[];
|
|
51
93
|
export declare function summarizeGuardRuns(root: string, limit?: number): GuardRunSummary;
|
|
52
|
-
export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string, policy?: GuardRunPolicy): GuardRunManifest;
|
|
94
|
+
export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string, policy?: GuardRunPolicy, receipt?: Omit<GuardVerificationReceipt, 'runId' | 'generatedAt' | 'outcome' | 'gate' | 'subject'> & Pick<GuardVerificationReceipt, 'subject'>): GuardRunManifest;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, lstatSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { config } from '../config.js';
|
|
6
|
+
import { withGuardStateLock } from '../guard.js';
|
|
6
7
|
export const GUARD_RUNS_DIR = `.${config.appName}/guard/history/runs`;
|
|
7
8
|
export const GUARD_RUN_RETENTION = 200;
|
|
8
9
|
export class GuardRunManifestError extends Error {
|
|
@@ -11,11 +12,109 @@ export class GuardRunManifestError extends Error {
|
|
|
11
12
|
this.name = 'GuardRunManifestError';
|
|
12
13
|
}
|
|
13
14
|
}
|
|
15
|
+
function isGuardRunPolicy(value) {
|
|
16
|
+
if (value === null || typeof value !== 'object')
|
|
17
|
+
return false;
|
|
18
|
+
const policy = value;
|
|
19
|
+
return (typeof policy.fingerprint === 'string' &&
|
|
20
|
+
policy.fingerprint.length > 0 &&
|
|
21
|
+
(policy.source === 'working-tree' || policy.source === 'git-ref') &&
|
|
22
|
+
(policy.revision === undefined ||
|
|
23
|
+
(Number.isInteger(policy.revision) && policy.revision >= 0)) &&
|
|
24
|
+
(policy.ref === undefined || typeof policy.ref === 'string') &&
|
|
25
|
+
(policy.changedFromBase === undefined || typeof policy.changedFromBase === 'boolean') &&
|
|
26
|
+
(policy.decisionIds === undefined ||
|
|
27
|
+
(Array.isArray(policy.decisionIds) &&
|
|
28
|
+
new Set(policy.decisionIds).size === policy.decisionIds.length &&
|
|
29
|
+
policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0) &&
|
|
30
|
+
(policy.source === 'working-tree' || policy.decisionIds.length === 0))));
|
|
31
|
+
}
|
|
14
32
|
function isGuardRunManifest(value) {
|
|
15
33
|
if (value === null || typeof value !== 'object')
|
|
16
34
|
return false;
|
|
17
35
|
const manifest = value;
|
|
18
36
|
const stages = manifest.stages;
|
|
37
|
+
const receipt = manifest.receipt;
|
|
38
|
+
const validReceipt = receipt === undefined ||
|
|
39
|
+
(receipt !== null &&
|
|
40
|
+
typeof receipt === 'object' &&
|
|
41
|
+
receipt.version === 1 &&
|
|
42
|
+
receipt.kind === 'verification-receipt' &&
|
|
43
|
+
typeof receipt.runId === 'string' &&
|
|
44
|
+
receipt.runId === manifest.runId &&
|
|
45
|
+
typeof receipt.generatedAt === 'string' &&
|
|
46
|
+
Number.isFinite(Date.parse(receipt.generatedAt)) &&
|
|
47
|
+
(receipt.outcome === 'pass' ||
|
|
48
|
+
receipt.outcome === 'fail' ||
|
|
49
|
+
receipt.outcome === 'warning' ||
|
|
50
|
+
receipt.outcome === 'needs-review' ||
|
|
51
|
+
receipt.outcome === 'not-configured') &&
|
|
52
|
+
typeof receipt.gate === 'string' &&
|
|
53
|
+
receipt.subject !== null &&
|
|
54
|
+
typeof receipt.subject === 'object' &&
|
|
55
|
+
Array.isArray(receipt.subject.changedFiles) &&
|
|
56
|
+
receipt.subject.changedFiles.every((file) => typeof file === 'string') &&
|
|
57
|
+
(receipt.subject.baseChangedFiles === undefined ||
|
|
58
|
+
(Array.isArray(receipt.subject.baseChangedFiles) &&
|
|
59
|
+
receipt.subject.baseChangedFiles.every((file) => typeof file === 'string'))) &&
|
|
60
|
+
(receipt.subject.workingTreeFiles === undefined ||
|
|
61
|
+
(Array.isArray(receipt.subject.workingTreeFiles) &&
|
|
62
|
+
receipt.subject.workingTreeFiles.every((file) => typeof file === 'string'))) &&
|
|
63
|
+
typeof receipt.subject.workingTreeDirty === 'boolean' &&
|
|
64
|
+
(receipt.subject.commit === undefined || typeof receipt.subject.commit === 'string') &&
|
|
65
|
+
(receipt.subject.baseRef === undefined || typeof receipt.subject.baseRef === 'string') &&
|
|
66
|
+
(receipt.subject.baseCommit === undefined ||
|
|
67
|
+
typeof receipt.subject.baseCommit === 'string') &&
|
|
68
|
+
(receipt.policy === undefined || isGuardRunPolicy(receipt.policy)) &&
|
|
69
|
+
receipt.stages !== null &&
|
|
70
|
+
typeof receipt.stages === 'object' &&
|
|
71
|
+
!Array.isArray(receipt.stages) &&
|
|
72
|
+
Object.values(receipt.stages).every((status) => status === 'ok' || status === 'fail' || status === 'skipped') &&
|
|
73
|
+
receipt.checks !== null &&
|
|
74
|
+
typeof receipt.checks === 'object' &&
|
|
75
|
+
!Array.isArray(receipt.checks) &&
|
|
76
|
+
Object.values(receipt.checks).every((check) => {
|
|
77
|
+
if (check === null || typeof check !== 'object')
|
|
78
|
+
return false;
|
|
79
|
+
const candidate = check;
|
|
80
|
+
return ((candidate.status === 'passed' ||
|
|
81
|
+
candidate.status === 'failed' ||
|
|
82
|
+
candidate.status === 'not-configured') &&
|
|
83
|
+
Number.isInteger(candidate.exitCode) &&
|
|
84
|
+
typeof candidate.durationMs === 'number' &&
|
|
85
|
+
Number.isFinite(candidate.durationMs) &&
|
|
86
|
+
candidate.durationMs >= 0);
|
|
87
|
+
}) &&
|
|
88
|
+
receipt.findings !== null &&
|
|
89
|
+
typeof receipt.findings === 'object' &&
|
|
90
|
+
['total', 'errors', 'warnings', 'waived', 'waiverWarnings', 'expiredWaivers'].every((key) => {
|
|
91
|
+
const countValue = receipt.findings[key];
|
|
92
|
+
return typeof countValue === 'number' && Number.isInteger(countValue) && countValue >= 0;
|
|
93
|
+
}) &&
|
|
94
|
+
Array.isArray(receipt.findings.fingerprints) &&
|
|
95
|
+
receipt.findings.fingerprints.every((fingerprint) => typeof fingerprint === 'string') &&
|
|
96
|
+
Array.isArray(receipt.findings.ruleIds) &&
|
|
97
|
+
receipt.findings.ruleIds.every((ruleId) => typeof ruleId === 'string') &&
|
|
98
|
+
Array.isArray(receipt.contractIssues) &&
|
|
99
|
+
receipt.contractIssues.every((issue) => typeof issue === 'string') &&
|
|
100
|
+
Array.isArray(receipt.decisionIds) &&
|
|
101
|
+
new Set(receipt.decisionIds).size === receipt.decisionIds.length &&
|
|
102
|
+
receipt.decisionIds.every((decisionId) => typeof decisionId === 'string') &&
|
|
103
|
+
receipt.tool !== null &&
|
|
104
|
+
typeof receipt.tool === 'object' &&
|
|
105
|
+
typeof receipt.tool.name === 'string' &&
|
|
106
|
+
typeof receipt.tool.version === 'string');
|
|
107
|
+
const receiptMatchesManifest = receipt === undefined ||
|
|
108
|
+
(receipt.outcome === manifest.outcome &&
|
|
109
|
+
receipt.gate === manifest.gate &&
|
|
110
|
+
(manifest.commit === undefined
|
|
111
|
+
? receipt.subject.commit === undefined
|
|
112
|
+
: receipt.subject.commit === manifest.commit) &&
|
|
113
|
+
(manifest.policy === undefined
|
|
114
|
+
? receipt.policy === undefined
|
|
115
|
+
: receipt.policy?.fingerprint === manifest.policy.fingerprint &&
|
|
116
|
+
receipt.policy.source === manifest.policy.source &&
|
|
117
|
+
receipt.policy.revision === manifest.policy.revision));
|
|
19
118
|
const validStages = stages !== null &&
|
|
20
119
|
typeof stages === 'object' &&
|
|
21
120
|
!Array.isArray(stages) &&
|
|
@@ -50,22 +149,9 @@ function isGuardRunManifest(value) {
|
|
|
50
149
|
manifest.outcome === 'not-configured') &&
|
|
51
150
|
typeof manifest.gate === 'string' &&
|
|
52
151
|
validStages &&
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
typeof manifest.policy.fingerprint === 'string' &&
|
|
57
|
-
manifest.policy.fingerprint.length > 0 &&
|
|
58
|
-
(manifest.policy.source === 'working-tree' || manifest.policy.source === 'git-ref') &&
|
|
59
|
-
(manifest.policy.revision === undefined ||
|
|
60
|
-
(Number.isInteger(manifest.policy.revision) && manifest.policy.revision >= 0)) &&
|
|
61
|
-
(manifest.policy.ref === undefined || typeof manifest.policy.ref === 'string') &&
|
|
62
|
-
(manifest.policy.changedFromBase === undefined ||
|
|
63
|
-
typeof manifest.policy.changedFromBase === 'boolean') &&
|
|
64
|
-
(manifest.policy.decisionIds === undefined ||
|
|
65
|
-
(manifest.policy.source === 'working-tree' &&
|
|
66
|
-
Array.isArray(manifest.policy.decisionIds) &&
|
|
67
|
-
new Set(manifest.policy.decisionIds).size === manifest.policy.decisionIds.length &&
|
|
68
|
-
manifest.policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0))))));
|
|
152
|
+
validReceipt &&
|
|
153
|
+
receiptMatchesManifest &&
|
|
154
|
+
(manifest.policy === undefined || isGuardRunPolicy(manifest.policy)));
|
|
69
155
|
}
|
|
70
156
|
export function startGuardRun() {
|
|
71
157
|
return {
|
|
@@ -85,11 +171,15 @@ export function recordGuardRunStage(context, name, status, startedAtMs, detail)
|
|
|
85
171
|
function pruneGuardRuns(root) {
|
|
86
172
|
const directory = resolve(root, GUARD_RUNS_DIR);
|
|
87
173
|
try {
|
|
174
|
+
if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
|
|
175
|
+
return;
|
|
88
176
|
const files = readdirSync(directory)
|
|
89
177
|
.filter((file) => file.endsWith('.json'))
|
|
90
178
|
.map((file) => ({
|
|
91
179
|
file,
|
|
92
|
-
modifiedAt:
|
|
180
|
+
modifiedAt: lstatSync(resolve(directory, file)).isSymbolicLink()
|
|
181
|
+
? 0
|
|
182
|
+
: statSync(resolve(directory, file)).mtimeMs,
|
|
93
183
|
}))
|
|
94
184
|
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
95
185
|
for (const entry of files.slice(GUARD_RUN_RETENTION)) {
|
|
@@ -108,13 +198,20 @@ function pruneGuardRuns(root) {
|
|
|
108
198
|
export function writeGuardRun(root, manifest) {
|
|
109
199
|
if (!isGuardRunManifest(manifest))
|
|
110
200
|
throw new GuardRunManifestError();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
201
|
+
withGuardStateLock(root, () => {
|
|
202
|
+
const directory = resolve(root, GUARD_RUNS_DIR);
|
|
203
|
+
if (existsSync(directory)) {
|
|
204
|
+
const stats = lstatSync(directory);
|
|
205
|
+
if (stats.isSymbolicLink() || !stats.isDirectory())
|
|
206
|
+
throw new GuardRunManifestError();
|
|
207
|
+
}
|
|
208
|
+
mkdirSync(directory, { recursive: true });
|
|
209
|
+
const path = resolve(directory, `${manifest.runId}.json`);
|
|
210
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${manifest.runId}`;
|
|
211
|
+
writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
212
|
+
renameSync(temporaryPath, path);
|
|
213
|
+
pruneGuardRuns(root);
|
|
214
|
+
});
|
|
118
215
|
}
|
|
119
216
|
/** Reads locally persisted run manifests without reaching a remote service. */
|
|
120
217
|
export function listGuardRuns(root, limit = 20) {
|
|
@@ -123,8 +220,19 @@ export function listGuardRuns(root, limit = 20) {
|
|
|
123
220
|
const boundedLimit = Math.min(limit, 1_000);
|
|
124
221
|
const directory = resolve(root, GUARD_RUNS_DIR);
|
|
125
222
|
try {
|
|
223
|
+
const directoryStats = lstatSync(directory);
|
|
224
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory())
|
|
225
|
+
return [];
|
|
126
226
|
return readdirSync(directory)
|
|
127
227
|
.filter((file) => file.endsWith('.json'))
|
|
228
|
+
.filter((file) => {
|
|
229
|
+
try {
|
|
230
|
+
return lstatSync(resolve(directory, file)).isFile();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
})
|
|
128
236
|
.map((file) => {
|
|
129
237
|
try {
|
|
130
238
|
const value = JSON.parse(readFileSync(resolve(directory, file), 'utf8'));
|
|
@@ -154,7 +262,7 @@ export function summarizeGuardRuns(root, limit = 100) {
|
|
|
154
262
|
...(total > 0 ? { last: runs[0] } : {}),
|
|
155
263
|
};
|
|
156
264
|
}
|
|
157
|
-
export function finishGuardRun(root, context, outcome, gate, command = 'verify', policy) {
|
|
265
|
+
export function finishGuardRun(root, context, outcome, gate, command = 'verify', policy, receipt) {
|
|
158
266
|
let commit;
|
|
159
267
|
try {
|
|
160
268
|
commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
|
@@ -164,18 +272,50 @@ export function finishGuardRun(root, context, outcome, gate, command = 'verify',
|
|
|
164
272
|
catch {
|
|
165
273
|
commit = undefined;
|
|
166
274
|
}
|
|
275
|
+
const completedAt = new Date().toISOString();
|
|
276
|
+
const completedReceipt = {
|
|
277
|
+
version: 1,
|
|
278
|
+
kind: 'verification-receipt',
|
|
279
|
+
runId: context.runId,
|
|
280
|
+
generatedAt: completedAt,
|
|
281
|
+
outcome,
|
|
282
|
+
gate,
|
|
283
|
+
subject: {
|
|
284
|
+
...receipt?.subject,
|
|
285
|
+
...(commit ? { commit } : {}),
|
|
286
|
+
changedFiles: receipt?.subject.changedFiles ?? [],
|
|
287
|
+
workingTreeDirty: receipt?.subject.workingTreeDirty ?? false,
|
|
288
|
+
},
|
|
289
|
+
...(policy ? { policy } : {}),
|
|
290
|
+
stages: receipt?.stages ?? {},
|
|
291
|
+
checks: receipt?.checks ?? {},
|
|
292
|
+
findings: receipt?.findings ?? {
|
|
293
|
+
total: 0,
|
|
294
|
+
errors: 0,
|
|
295
|
+
warnings: 0,
|
|
296
|
+
waived: 0,
|
|
297
|
+
waiverWarnings: 0,
|
|
298
|
+
expiredWaivers: 0,
|
|
299
|
+
fingerprints: [],
|
|
300
|
+
ruleIds: [],
|
|
301
|
+
},
|
|
302
|
+
contractIssues: receipt?.contractIssues ?? [],
|
|
303
|
+
decisionIds: receipt?.decisionIds ?? policy?.decisionIds ?? [],
|
|
304
|
+
tool: receipt?.tool ?? { name: '@codapult/guard', version: 'unknown' },
|
|
305
|
+
};
|
|
167
306
|
const manifest = {
|
|
168
307
|
version: 1,
|
|
169
308
|
runId: context.runId,
|
|
170
309
|
command,
|
|
171
310
|
startedAt: context.startedAt,
|
|
172
|
-
completedAt
|
|
311
|
+
completedAt,
|
|
173
312
|
durationMs: Math.max(0, Date.now() - context.startedAtMs),
|
|
174
313
|
outcome,
|
|
175
314
|
gate,
|
|
176
315
|
stages: context.stages,
|
|
177
316
|
...(commit ? { commit } : {}),
|
|
178
317
|
...(policy ? { policy } : {}),
|
|
318
|
+
receipt: completedReceipt,
|
|
179
319
|
};
|
|
180
320
|
try {
|
|
181
321
|
writeGuardRun(root, manifest);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
1
3
|
import {} from '../../adapters/command.js';
|
|
2
4
|
import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
|
|
3
5
|
import { loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
|
|
4
6
|
import { analyzeProjectImpact } from '../analysis/impact.js';
|
|
5
7
|
import { discoverProject } from '../discovery/discovery.js';
|
|
6
8
|
import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const packageJson = require('../../../package.json');
|
|
7
11
|
function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
|
|
8
12
|
return {
|
|
9
13
|
...(snapshot.revision !== undefined ? { revision: snapshot.revision } : {}),
|
|
@@ -14,6 +18,94 @@ function buildRunPolicy(snapshot, base, changedFromBase, decisionIds = []) {
|
|
|
14
18
|
...(decisionIds.length > 0 ? { decisionIds } : {}),
|
|
15
19
|
};
|
|
16
20
|
}
|
|
21
|
+
function receiptCheck(result) {
|
|
22
|
+
return {
|
|
23
|
+
status: result.status,
|
|
24
|
+
exitCode: result.exitCode,
|
|
25
|
+
durationMs: result.durationMs,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function resolveGitCommit(root, ref) {
|
|
29
|
+
if (!ref)
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
const commit = execFileSync('git', ['rev-parse', `${ref}^{commit}`], {
|
|
33
|
+
cwd: root,
|
|
34
|
+
stdio: 'pipe',
|
|
35
|
+
timeout: 5_000,
|
|
36
|
+
})
|
|
37
|
+
.toString()
|
|
38
|
+
.trim();
|
|
39
|
+
return /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function changedFilesSince(root, baseRef) {
|
|
46
|
+
if (!baseRef)
|
|
47
|
+
return undefined;
|
|
48
|
+
try {
|
|
49
|
+
return execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], {
|
|
50
|
+
cwd: root,
|
|
51
|
+
stdio: 'pipe',
|
|
52
|
+
timeout: 5_000,
|
|
53
|
+
})
|
|
54
|
+
.toString()
|
|
55
|
+
.split('\n')
|
|
56
|
+
.map((file) => file.trim().replace(/\\/g, '/'))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function buildVerificationReceipt(root, policy, impact, checks, workspaceChecks, adapters, architecture, contractIssues, stages) {
|
|
65
|
+
const baseCommit = resolveGitCommit(root, policy.ref);
|
|
66
|
+
const baseChangedFiles = changedFilesSince(root, policy.ref);
|
|
67
|
+
const workingTreeFiles = impact.requestedFiles;
|
|
68
|
+
const receiptChecks = {};
|
|
69
|
+
for (const [name, result] of Object.entries(checks)) {
|
|
70
|
+
receiptChecks[name] = receiptCheck(result);
|
|
71
|
+
}
|
|
72
|
+
for (const [workspacePackage, packageChecks] of Object.entries(workspaceChecks)) {
|
|
73
|
+
for (const [name, result] of Object.entries(packageChecks)) {
|
|
74
|
+
receiptChecks[`${workspacePackage}:${name}`] = receiptCheck(result);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const [name, result] of Object.entries(adapters)) {
|
|
78
|
+
receiptChecks[`adapter:${name}`] = receiptCheck(result);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
kind: 'verification-receipt',
|
|
83
|
+
subject: {
|
|
84
|
+
...(policy.ref ? { baseRef: policy.ref } : {}),
|
|
85
|
+
...(baseCommit ? { baseCommit } : {}),
|
|
86
|
+
...(baseChangedFiles ? { baseChangedFiles } : {}),
|
|
87
|
+
...(workingTreeFiles.length > 0 ? { workingTreeFiles } : {}),
|
|
88
|
+
changedFiles: [...new Set([...(baseChangedFiles ?? []), ...workingTreeFiles])].sort(),
|
|
89
|
+
workingTreeDirty: workingTreeFiles.length > 0,
|
|
90
|
+
},
|
|
91
|
+
policy,
|
|
92
|
+
stages,
|
|
93
|
+
checks: receiptChecks,
|
|
94
|
+
findings: {
|
|
95
|
+
total: architecture.findings.length,
|
|
96
|
+
errors: architecture.findings.filter((finding) => finding.severity === 'error').length,
|
|
97
|
+
warnings: architecture.findings.filter((finding) => finding.severity === 'warning').length,
|
|
98
|
+
waived: architecture.waived,
|
|
99
|
+
waiverWarnings: architecture.waiverWarnings.length,
|
|
100
|
+
expiredWaivers: architecture.expiredWaivers.length,
|
|
101
|
+
fingerprints: architecture.findings.map((finding) => finding.fingerprint).sort(),
|
|
102
|
+
ruleIds: [...new Set(architecture.findings.map((finding) => finding.ruleId))].sort(),
|
|
103
|
+
},
|
|
104
|
+
contractIssues: contractIssues.map((issue) => issue.contractId),
|
|
105
|
+
decisionIds: policy.decisionIds ?? [],
|
|
106
|
+
tool: { name: '@codapult/guard', version: packageJson.version },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
17
109
|
export function runGuardVerification(root, options = {}) {
|
|
18
110
|
const runContext = startGuardRun();
|
|
19
111
|
const requirement = {
|
|
@@ -65,11 +157,12 @@ export function runGuardVerification(root, options = {}) {
|
|
|
65
157
|
};
|
|
66
158
|
}
|
|
67
159
|
if (!workingTreeState) {
|
|
68
|
-
const
|
|
160
|
+
const policyEnforcementFailed = options.failOnPolicyChange === true;
|
|
161
|
+
const run = finishGuardRun(root, runContext, policyEnforcementFailed ? 'fail' : 'not-configured', policyEnforcementFailed ? 'policy-change' : 'configuration');
|
|
69
162
|
return {
|
|
70
|
-
status: 'not-configured',
|
|
71
|
-
outcome: 'not-configured',
|
|
72
|
-
errorCode: 'GUARD_NOT_CONFIGURED',
|
|
163
|
+
status: policyEnforcementFailed ? 'fail' : 'not-configured',
|
|
164
|
+
outcome: policyEnforcementFailed ? 'fail' : 'not-configured',
|
|
165
|
+
errorCode: policyEnforcementFailed ? 'GUARD_POLICY_CHANGED' : 'GUARD_NOT_CONFIGURED',
|
|
73
166
|
recoverable: true,
|
|
74
167
|
checks: {},
|
|
75
168
|
workspaceChecks: {},
|
|
@@ -193,20 +286,27 @@ export function runGuardVerification(root, options = {}) {
|
|
|
193
286
|
const commandFailed = Object.values(results).some((result) => result.status === 'failed');
|
|
194
287
|
const workspaceCommandFailed = Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'failed'));
|
|
195
288
|
const adapterFailed = Object.values(adapters).some((result) => result.status === 'failed');
|
|
196
|
-
const
|
|
197
|
-
|
|
289
|
+
const missingProjectChecks = projectChecks &&
|
|
290
|
+
(checks.length === 0 ||
|
|
291
|
+
Object.values(results).some((result) => result.status === 'not-configured') ||
|
|
292
|
+
Object.values(workspaceChecks).some((packageResults) => Object.values(packageResults).some((result) => result.status === 'not-configured')));
|
|
293
|
+
const missingAdapterTools = options.strict && Object.values(adapters).some((result) => result.status === 'not-configured');
|
|
294
|
+
const runtimeFailed = projectChecks && !runtime.compatible;
|
|
198
295
|
const architectureFailed = architecture.findings.some((finding) => finding.severity === 'error');
|
|
296
|
+
const expiredWaivers = architecture.expiredWaivers.length > 0;
|
|
199
297
|
const architectureWarnings = architecture.findings.some((finding) => finding.severity === 'warning') ||
|
|
200
298
|
(architecture.waiverWarnings ?? []).length > 0;
|
|
201
|
-
const failed =
|
|
299
|
+
const failed = runtimeFailed ||
|
|
202
300
|
commandFailed ||
|
|
203
301
|
workspaceCommandFailed ||
|
|
204
302
|
adapterFailed ||
|
|
205
303
|
architectureFailed ||
|
|
304
|
+
expiredWaivers ||
|
|
206
305
|
contractIssues.length > 0 ||
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
306
|
+
missingProjectChecks ||
|
|
307
|
+
missingAdapterTools;
|
|
308
|
+
const projectChecksFailed = commandFailed || workspaceCommandFailed || missingProjectChecks || runtimeFailed;
|
|
309
|
+
const adaptersFailed = adapterFailed || missingAdapterTools;
|
|
210
310
|
runContext.stages['project-checks'] = {
|
|
211
311
|
...runContext.stages['project-checks'],
|
|
212
312
|
status: projectChecks ? (projectChecksFailed ? 'fail' : 'ok') : 'skipped',
|
|
@@ -215,15 +315,19 @@ export function runGuardVerification(root, options = {}) {
|
|
|
215
315
|
...runContext.stages.adapters,
|
|
216
316
|
status: toolMode === 'off' ? 'skipped' : adaptersFailed ? 'fail' : 'ok',
|
|
217
317
|
};
|
|
318
|
+
runContext.stages.runtime = {
|
|
319
|
+
...runContext.stages.runtime,
|
|
320
|
+
status: runtimeFailed ? 'fail' : 'ok',
|
|
321
|
+
};
|
|
218
322
|
runContext.stages['architecture-policy'] = {
|
|
219
323
|
...runContext.stages['architecture-policy'],
|
|
220
|
-
status: architectureFailed || contractIssues.length > 0 ? 'fail' : 'ok',
|
|
324
|
+
status: architectureFailed || expiredWaivers || contractIssues.length > 0 ? 'fail' : 'ok',
|
|
221
325
|
};
|
|
222
326
|
const outcome = classifyGuardOutcome({
|
|
223
327
|
errors: failed ? 1 : 0,
|
|
224
328
|
warnings: architectureWarnings ? 1 : 0,
|
|
225
329
|
});
|
|
226
|
-
const gate = architectureFailed
|
|
330
|
+
const gate = architectureFailed || expiredWaivers
|
|
227
331
|
? 'architecture'
|
|
228
332
|
: contractIssues.length > 0
|
|
229
333
|
? 'contracts'
|
|
@@ -231,12 +335,15 @@ export function runGuardVerification(root, options = {}) {
|
|
|
231
335
|
? 'project-checks'
|
|
232
336
|
: adapterFailed
|
|
233
337
|
? 'adapters'
|
|
234
|
-
:
|
|
235
|
-
? '
|
|
236
|
-
:
|
|
237
|
-
? '
|
|
238
|
-
:
|
|
239
|
-
|
|
338
|
+
: missingProjectChecks
|
|
339
|
+
? 'project-checks'
|
|
340
|
+
: missingAdapterTools
|
|
341
|
+
? 'required-tools'
|
|
342
|
+
: runtimeFailed
|
|
343
|
+
? 'runtime'
|
|
344
|
+
: 'none';
|
|
345
|
+
const receipt = buildVerificationReceipt(root, policy, impact, results, workspaceChecks, adapters, architecture, contractIssues, Object.fromEntries(Object.entries(runContext.stages).map(([name, stage]) => [name, stage.status])));
|
|
346
|
+
const run = finishGuardRun(root, runContext, outcome, gate, 'verify', policy, receipt);
|
|
240
347
|
return {
|
|
241
348
|
status: failed ? 'fail' : 'ok',
|
|
242
349
|
outcome,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ProjectModel } from '../core/discovery/discovery.js';
|
|
2
|
+
import { getPendingGuardProposals, type GuardConfig, type GuardProposalFile, type GuardWaiver } from '../core/guard.js';
|
|
3
|
+
export interface GuardDiscoveryMetrics {
|
|
4
|
+
durationMs: number;
|
|
5
|
+
files: number;
|
|
6
|
+
modules: number;
|
|
7
|
+
changedFiles: number;
|
|
8
|
+
reusedModules: number;
|
|
9
|
+
cacheHit: boolean;
|
|
10
|
+
cacheAvailable: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface GuardContextState {
|
|
13
|
+
observations: {
|
|
14
|
+
status: 'observed-only';
|
|
15
|
+
project: ProjectModel;
|
|
16
|
+
architecture: Record<string, unknown>;
|
|
17
|
+
conventions: Record<string, unknown>;
|
|
18
|
+
discovery: GuardDiscoveryMetrics | undefined;
|
|
19
|
+
};
|
|
20
|
+
proposals: {
|
|
21
|
+
status: 'not-configured' | 'none' | 'pending-approval' | 'stale' | 'decided';
|
|
22
|
+
requiresApproval: boolean;
|
|
23
|
+
freshness: 'current' | 'stale' | 'unknown' | 'none';
|
|
24
|
+
proposalId?: string | undefined;
|
|
25
|
+
generatedBy?: string | undefined;
|
|
26
|
+
pendingRules: GuardConfig['rules'];
|
|
27
|
+
pendingContracts: NonNullable<GuardConfig['contracts']>;
|
|
28
|
+
questions: string[];
|
|
29
|
+
pending: ReturnType<typeof getPendingGuardProposals>;
|
|
30
|
+
};
|
|
31
|
+
policy: {
|
|
32
|
+
status: 'not-configured' | 'active';
|
|
33
|
+
rules: GuardConfig['rules'];
|
|
34
|
+
contracts: NonNullable<GuardConfig['contracts']>;
|
|
35
|
+
baseline: {
|
|
36
|
+
status: 'not-configured' | 'active';
|
|
37
|
+
count: number;
|
|
38
|
+
};
|
|
39
|
+
waivers: GuardWaiver[];
|
|
40
|
+
};
|
|
41
|
+
verification: {
|
|
42
|
+
status: 'not-run';
|
|
43
|
+
findings: null;
|
|
44
|
+
message: string;
|
|
45
|
+
next: 'codapult_guard_init' | 'codapult_guard_check';
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export declare function buildGuardContextState(project: ProjectModel, discovery: GuardDiscoveryMetrics | undefined, architecture: Record<string, unknown>, conventions: Record<string, unknown>, config: GuardConfig | undefined, proposals: GuardProposalFile | undefined, baseline: Set<string>, waivers: GuardWaiver[]): GuardContextState;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getGuardProposalFreshness, getPendingGuardProposals, } from '../core/guard.js';
|
|
2
|
+
export function buildGuardContextState(project, discovery, architecture, conventions, config, proposals, baseline, waivers) {
|
|
3
|
+
const pending = getPendingGuardProposals(proposals);
|
|
4
|
+
const freshness = proposals ? getGuardProposalFreshness(project, proposals) : 'none';
|
|
5
|
+
const pendingRuleIds = new Set(pending.filter((item) => item.type === 'rule').map((item) => item.id));
|
|
6
|
+
const pendingContractIds = new Set(pending.filter((item) => item.type === 'contract').map((item) => item.id));
|
|
7
|
+
const pendingRules = proposals?.rules.filter((rule) => rule.status === 'proposed' && pendingRuleIds.has(rule.id)) ??
|
|
8
|
+
[];
|
|
9
|
+
const pendingContracts = proposals?.contracts.filter((contract) => contract.status === 'proposed' && pendingContractIds.has(contract.id)) ?? [];
|
|
10
|
+
const proposalStatus = config === undefined
|
|
11
|
+
? 'not-configured'
|
|
12
|
+
: proposals === undefined
|
|
13
|
+
? 'none'
|
|
14
|
+
: freshness === 'stale'
|
|
15
|
+
? 'stale'
|
|
16
|
+
: pending.length > 0
|
|
17
|
+
? 'pending-approval'
|
|
18
|
+
: 'decided';
|
|
19
|
+
return {
|
|
20
|
+
observations: { status: 'observed-only', project, architecture, conventions, discovery },
|
|
21
|
+
proposals: {
|
|
22
|
+
status: proposalStatus,
|
|
23
|
+
requiresApproval: pending.length > 0 || freshness === 'stale',
|
|
24
|
+
freshness,
|
|
25
|
+
...(proposals?.proposalId ? { proposalId: proposals.proposalId } : {}),
|
|
26
|
+
...(proposals?.generatedBy ? { generatedBy: proposals.generatedBy } : {}),
|
|
27
|
+
pendingRules,
|
|
28
|
+
pendingContracts,
|
|
29
|
+
questions: proposals?.questions ?? [],
|
|
30
|
+
pending,
|
|
31
|
+
},
|
|
32
|
+
policy: {
|
|
33
|
+
status: config === undefined ? 'not-configured' : 'active',
|
|
34
|
+
rules: config?.rules.filter((rule) => rule.status === 'active') ?? [],
|
|
35
|
+
contracts: config?.contracts?.filter((contract) => contract.status === 'active') ?? [],
|
|
36
|
+
baseline: {
|
|
37
|
+
status: config === undefined ? 'not-configured' : 'active',
|
|
38
|
+
count: baseline.size,
|
|
39
|
+
},
|
|
40
|
+
waivers,
|
|
41
|
+
},
|
|
42
|
+
verification: {
|
|
43
|
+
status: 'not-run',
|
|
44
|
+
findings: null,
|
|
45
|
+
message: 'Context contains facts and policy state; it does not run verification.',
|
|
46
|
+
next: config === undefined ? 'codapult_guard_init' : 'codapult_guard_check',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/mcp/resources.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { buildArchitectureMemory, buildConventionsMemory, discoverProject, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadGuardAgentConfig, loadGuardArtifact, loadGuardConfig, loadGuardProposals, loadGuardWaivers, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
|
|
1
|
+
import { buildArchitectureMemory, buildConventionsMemory, discoverProject, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, getGuardPolicyMutationPolicy, loadBaseline, loadGuardAgentConfig, loadGuardArtifact, loadGuardConfig, loadGuardProposals, loadGuardWaivers, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
|
|
2
|
+
import { buildGuardContextState } from './context.js';
|
|
2
3
|
import { guardErrorPayload } from '../core/errors.js';
|
|
3
4
|
import { DiscoveryStaleError } from '../core/discovery/discovery.js';
|
|
4
5
|
import { listGuardRuns, summarizeGuardRuns } from '../core/history/runs.js';
|
|
@@ -62,15 +63,17 @@ export function registerGuardResources(server) {
|
|
|
62
63
|
try {
|
|
63
64
|
const project = discoverProject(root);
|
|
64
65
|
const config = loadGuardConfig(root);
|
|
66
|
+
const architecture = (loadGuardArtifact(root, GUARD_ARCHITECTURE_FILE) ??
|
|
67
|
+
buildArchitectureMemory(project));
|
|
68
|
+
const conventions = (loadGuardArtifact(root, GUARD_CONVENTIONS_FILE) ??
|
|
69
|
+
buildConventionsMemory(project));
|
|
70
|
+
const state = buildGuardContextState(project, undefined, architecture, conventions, config, loadGuardProposals(root), loadBaseline(root), loadGuardWaivers(root));
|
|
65
71
|
return jsonResource('codapult://guard/context', {
|
|
66
72
|
version: 1,
|
|
67
73
|
root,
|
|
68
74
|
configured: config !== undefined,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
conventions: loadGuardArtifact(root, GUARD_CONVENTIONS_FILE) ?? buildConventionsMemory(project),
|
|
72
|
-
rules: config?.rules ?? [],
|
|
73
|
-
contracts: config?.contracts ?? [],
|
|
75
|
+
mutationPolicy: getGuardPolicyMutationPolicy(config),
|
|
76
|
+
...state,
|
|
74
77
|
agent: loadGuardAgentConfig(root),
|
|
75
78
|
});
|
|
76
79
|
}
|
package/dist/mcp/tools/guard.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { buildArchitectureMemory, buildConventionsMemory, buildGeneratedGuardConfig, buildGuardProposals, buildGuardReviewPacket, discoverProject, discoverProjectWithMetrics, fingerprintProjectModel, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadBaseline, loadGuardArtifact, loadGuardConfig, loadGuardAgentConfig, loadGuardProposals, loadGuardWaivers, getPendingGuardProposals, getGuardProposalFreshness, loadProjectModel, writeGuardProposals, initializeGuard, GuardAlreadyInitializedError, GuardStateBusyError, GuardStateStaleError, applyGuardProposalDecision, validateGuardProposalApproval, writeProjectState, scanGuard, updateGuardWaivers, validateGuardPolicy, classifyGuardOutcome, } from '../../core/guard.js';
|
|
2
|
+
import { buildArchitectureMemory, buildConventionsMemory, buildGeneratedGuardConfig, buildGuardProposals, buildGuardReviewPacket, discoverProject, discoverProjectWithMetrics, fingerprintProjectModel, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadBaseline, loadGuardArtifact, loadGuardConfig, loadGuardAgentConfig, loadGuardProposals, loadGuardWaivers, getPendingGuardProposals, getGuardProposalFreshness, loadProjectModel, writeGuardProposals, initializeGuard, GuardAlreadyInitializedError, GuardStateBusyError, GuardStateStaleError, applyGuardProposalDecision, validateGuardProposalApproval, writeProjectState, scanGuard, updateGuardWaivers, validateGuardPolicy, classifyGuardOutcome, getGuardPolicyMutationPolicy, } from '../../core/guard.js';
|
|
3
3
|
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
7
|
import { auditGuardGovernance } from '../../core/audit/governance.js';
|
|
8
|
+
import { buildGuardContextState } from '../context.js';
|
|
8
9
|
const rootSchema = z.string().trim().min(1).optional().describe('Project path or workspace root.');
|
|
10
|
+
function asGuardContextRecord(value) {
|
|
11
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
+
? value
|
|
13
|
+
: {};
|
|
14
|
+
}
|
|
9
15
|
function getGuardRoot(root) {
|
|
10
16
|
return root ? findGuardRoot(root) : findGuardRoot();
|
|
11
17
|
}
|
|
@@ -61,6 +67,21 @@ function loadBaselineSafely(root) {
|
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
69
|
}
|
|
70
|
+
function loadWaiversSafely(root) {
|
|
71
|
+
try {
|
|
72
|
+
return { waivers: loadGuardWaivers(root) };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
error: jsonToolResult(guardErrorPayload('GUARD_CONFIG_INVALID', error instanceof Error ? error.message : String(error), {
|
|
77
|
+
configured: true,
|
|
78
|
+
outcome: 'error',
|
|
79
|
+
recoverable: true,
|
|
80
|
+
hint: 'Repair waivers.json, then run codapult-guard doctor.',
|
|
81
|
+
}), true),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
64
85
|
function stateErrorResult(error) {
|
|
65
86
|
const isBusy = error instanceof GuardStateBusyError;
|
|
66
87
|
const isStale = error instanceof GuardStateStaleError;
|
|
@@ -247,6 +268,22 @@ export function registerGuardTools(server) {
|
|
|
247
268
|
? discoverProjectWithMetrics(root, { persistCache: true })
|
|
248
269
|
: { model: loadProjectModel(root) ?? discoverProject(root), metrics: undefined };
|
|
249
270
|
const project = discovery.model;
|
|
271
|
+
const architecture = asGuardContextRecord(refresh
|
|
272
|
+
? buildArchitectureMemory(project)
|
|
273
|
+
: (loadGuardArtifact(root, GUARD_ARCHITECTURE_FILE) ?? buildArchitectureMemory(project)));
|
|
274
|
+
const conventions = asGuardContextRecord(refresh
|
|
275
|
+
? buildConventionsMemory(project)
|
|
276
|
+
: (loadGuardArtifact(root, GUARD_CONVENTIONS_FILE) ?? buildConventionsMemory(project)));
|
|
277
|
+
const loadedProposals = loadProposalsSafely(root);
|
|
278
|
+
if ('error' in loadedProposals)
|
|
279
|
+
return loadedProposals.error;
|
|
280
|
+
const loadedBaseline = loadBaselineSafely(root);
|
|
281
|
+
if (loadedBaseline.error)
|
|
282
|
+
return loadedBaseline.error;
|
|
283
|
+
const loadedWaivers = loadWaiversSafely(root);
|
|
284
|
+
if (loadedWaivers.error)
|
|
285
|
+
return loadedWaivers.error;
|
|
286
|
+
const state = buildGuardContextState(project, discovery.metrics, architecture, conventions, config, loadedProposals.proposals, loadedBaseline.baseline ?? new Set(), loadedWaivers.waivers ?? []);
|
|
250
287
|
return {
|
|
251
288
|
content: [
|
|
252
289
|
{
|
|
@@ -259,18 +296,8 @@ export function registerGuardTools(server) {
|
|
|
259
296
|
tools: agentConfig.tools,
|
|
260
297
|
tooling: agentConfig.tooling,
|
|
261
298
|
completionGate: agentConfig.completionGate,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
architecture: refresh
|
|
265
|
-
? buildArchitectureMemory(project)
|
|
266
|
-
: (loadGuardArtifact(root, GUARD_ARCHITECTURE_FILE) ??
|
|
267
|
-
buildArchitectureMemory(project)),
|
|
268
|
-
conventions: refresh
|
|
269
|
-
? buildConventionsMemory(project)
|
|
270
|
-
: (loadGuardArtifact(root, GUARD_CONVENTIONS_FILE) ??
|
|
271
|
-
buildConventionsMemory(project)),
|
|
272
|
-
project,
|
|
273
|
-
discovery: discovery.metrics,
|
|
299
|
+
mutationPolicy: getGuardPolicyMutationPolicy(config),
|
|
300
|
+
...state,
|
|
274
301
|
}, null, 2),
|
|
275
302
|
},
|
|
276
303
|
],
|
|
@@ -337,6 +364,19 @@ export function registerGuardTools(server) {
|
|
|
337
364
|
const root = getGuardRoot(requestedRoot);
|
|
338
365
|
if (!confirm)
|
|
339
366
|
return jsonToolResult({ status: 'needs-confirmation', root, force });
|
|
367
|
+
const loadedConfig = loadConfigSafely(root);
|
|
368
|
+
if (loadedConfig.error)
|
|
369
|
+
return loadedConfig.error;
|
|
370
|
+
if (force &&
|
|
371
|
+
loadedConfig.config?.approval?.mode === 'protected' &&
|
|
372
|
+
!loadedConfig.config.approval.allowMcpApproval) {
|
|
373
|
+
return jsonToolResult({
|
|
374
|
+
status: 'approval-required',
|
|
375
|
+
message: 'Protected Guard policy requires forced initialization outside the MCP agent surface.',
|
|
376
|
+
source: 'mcp',
|
|
377
|
+
force,
|
|
378
|
+
}, true);
|
|
379
|
+
}
|
|
340
380
|
try {
|
|
341
381
|
const result = initializeGuard(root, { force, waitMs: wait_ms, noWait: no_wait });
|
|
342
382
|
return jsonToolResult({ status: 'ok', root, report: result.report });
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codapult/guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
7
|
-
"packageManager": "pnpm@12.
|
|
7
|
+
"packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=20.19.0"
|
|
10
10
|
},
|
|
@@ -98,13 +98,13 @@
|
|
|
98
98
|
"access": "public"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
101
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
102
102
|
"commander": "^15.0.0",
|
|
103
103
|
"picocolors": "^1.1.1",
|
|
104
104
|
"semver": "^7.8.5",
|
|
105
105
|
"ts-morph": "^28.0.0",
|
|
106
106
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
107
|
-
"zod": "^4.6.
|
|
107
|
+
"zod": "^4.6.7"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
110
|
"@js-toolkit/config-utils": "^1.0.1",
|
|
@@ -112,15 +112,15 @@
|
|
|
112
112
|
"@js-toolkit/prettier-config": "^1.0.1",
|
|
113
113
|
"@js-toolkit/tsconfig": "^1.0.1",
|
|
114
114
|
"@release-it/conventional-changelog": "^12.0.2",
|
|
115
|
-
"@types/node": "^26.6.
|
|
115
|
+
"@types/node": "^26.6.5",
|
|
116
116
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
117
|
-
"@vitest/coverage-v8": "^5.0.
|
|
118
|
-
"eslint": "^10.
|
|
117
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
118
|
+
"eslint": "^10.13.0",
|
|
119
119
|
"eslint-config-prettier": "^10.1.8",
|
|
120
|
-
"eslint-plugin-prettier": "^5.5.
|
|
121
|
-
"prettier": "^3.9.
|
|
122
|
-
"release-it": "^21.1.
|
|
123
|
-
"typescript-eslint": "^8.
|
|
124
|
-
"vitest": "^5.0.
|
|
120
|
+
"eslint-plugin-prettier": "^5.5.7",
|
|
121
|
+
"prettier": "^3.9.10",
|
|
122
|
+
"release-it": "^21.1.1",
|
|
123
|
+
"typescript-eslint": "^8.71.2",
|
|
124
|
+
"vitest": "^5.0.5"
|
|
125
125
|
}
|
|
126
126
|
}
|