@codapult/guard 0.8.0 → 0.9.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 +20 -40
- 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/audit/governance.js +1 -1
- package/dist/core/discovery/discovery.js +20 -2
- package/dist/core/guard.d.ts +27 -0
- package/dist/core/guard.js +126 -3
- package/dist/core/history/history.js +20 -2
- package/dist/core/history/runs.js +52 -12
- package/dist/core/policy/schemas.js +37 -4
- package/dist/core/verification/verify.js +6 -6
- 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 +9 -9
package/README.md
CHANGED
|
@@ -57,43 +57,6 @@ The core is universal. The strongest first-class scenarios are Next.js SaaS and
|
|
|
57
57
|
development, including server/client boundaries, routes, persistence, authentication, billing,
|
|
58
58
|
background jobs, environment configuration, and AI integrations.
|
|
59
59
|
|
|
60
|
-
## Part of the Codapult ecosystem
|
|
61
|
-
|
|
62
|
-
Guard is an independent open-source project from [Codapult](https://codapult.dev). It does not
|
|
63
|
-
require Codapult and can be installed in any JavaScript or TypeScript repository.
|
|
64
|
-
|
|
65
|
-
The relationship is complementary:
|
|
66
|
-
|
|
67
|
-
| Project | Role |
|
|
68
|
-
| --------------------- | ---------------------------------------------------------------------------------------- |
|
|
69
|
-
| **`@codapult/guard`** | Universal architecture guardrails, project memory, contracts, MCP, and AI-agent context. |
|
|
70
|
-
| **`@codapult/cli`** | Codapult SaaS project CLI that includes Guard through a thin adapter. |
|
|
71
|
-
| **Codapult** | Full-source Next.js SaaS foundation with conventions Guard can discover and protect. |
|
|
72
|
-
|
|
73
|
-
Use standalone Guard for any compatible project. Use the Codapult CLI when working on a Codapult
|
|
74
|
-
SaaS project and you want project management, database, plugins, deployment, MCP, and Guard in one
|
|
75
|
-
CLI.
|
|
76
|
-
|
|
77
|
-
```text
|
|
78
|
-
Existing project
|
|
79
|
-
│
|
|
80
|
-
▼
|
|
81
|
-
deterministic discovery
|
|
82
|
-
AST · files · imports · Git
|
|
83
|
-
│
|
|
84
|
-
▼
|
|
85
|
-
project architecture
|
|
86
|
-
capabilities · graph · impact paths
|
|
87
|
-
│
|
|
88
|
-
▼
|
|
89
|
-
approved project policy
|
|
90
|
-
rules · contracts · conventions · baseline
|
|
91
|
-
│
|
|
92
|
-
▼
|
|
93
|
-
change verification
|
|
94
|
-
check · review packet · verify · CI
|
|
95
|
-
```
|
|
96
|
-
|
|
97
60
|
## Why Guard exists
|
|
98
61
|
|
|
99
62
|
AI agents can produce syntactically valid code that still violates the architecture of a real
|
|
@@ -185,10 +148,13 @@ Guard stores policy at the project level, while each policy item can target a na
|
|
|
185
148
|
| Waiver | One active finding fingerprint, with owner, reason, and expiry |
|
|
186
149
|
|
|
187
150
|
Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
|
|
188
|
-
change policy are explicit: `init`, `rules
|
|
151
|
+
change policy are explicit: `init`, `rules approve`, `contracts approve|reject`, `baseline accept|remove`, and
|
|
189
152
|
`waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
|
|
190
153
|
project's configured commands and records a local run manifest; neither changes approved policy.
|
|
191
154
|
|
|
155
|
+
For the complete mutation contract and host/CI controls, see
|
|
156
|
+
[Policy mutation control](docs/policy-mutations.md).
|
|
157
|
+
|
|
192
158
|
## What Guard discovers
|
|
193
159
|
|
|
194
160
|
The model is framework-aware without being framework-dependent:
|
|
@@ -362,6 +328,12 @@ task finished
|
|
|
362
328
|
→ repeat until pass or bounded iteration limit
|
|
363
329
|
```
|
|
364
330
|
|
|
331
|
+
`codapult_guard_context` keeps the project state explicit for the host: `observations` are
|
|
332
|
+
discovered facts only, `proposals.pendingRules` and `proposals.pendingContracts` are unapproved
|
|
333
|
+
policy suggestions, `policy` contains only active rules/contracts plus baseline and waiver state,
|
|
334
|
+
and `verification` reports whether a check has actually run. A context response with
|
|
335
|
+
`verification.status: "not-run"` and `findings: null` is not a passing check.
|
|
336
|
+
|
|
365
337
|
For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, see
|
|
366
338
|
[`docs/integrations/`](docs/integrations/).
|
|
367
339
|
|
|
@@ -435,7 +407,9 @@ cp node_modules/@codapult/guard/docs/guard-ci.yml .github/workflows/guard.yml
|
|
|
435
407
|
```
|
|
436
408
|
|
|
437
409
|
Or copy it from [`docs/guard-ci.yml`](docs/guard-ci.yml). It runs project verification and exports
|
|
438
|
-
Guard findings as SARIF for GitHub code scanning.
|
|
410
|
+
Guard findings as SARIF for GitHub code scanning. Pull requests install dependencies without
|
|
411
|
+
lifecycle scripts and use deterministic Guard checks without executing PR-owned scripts; trusted
|
|
412
|
+
pushes may run the configured project checks.
|
|
439
413
|
|
|
440
414
|
The package repository separately runs its own unit, typecheck, build, and packaging checks in
|
|
441
415
|
[`.github/workflows/ci.yml`](.github/workflows/ci.yml), while the fixture workflow tests Guard on
|
|
@@ -457,7 +431,7 @@ real project shapes.
|
|
|
457
431
|
| `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
|
|
458
432
|
| `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
|
|
459
433
|
| `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
|
|
460
|
-
| `rules` / `contracts` | Approve or reject
|
|
434
|
+
| `rules` / `contracts` | Approve rules; approve or reject contracts. |
|
|
461
435
|
| `baseline` | Review or intentionally accept existing findings. |
|
|
462
436
|
| `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
|
|
463
437
|
|
|
@@ -519,6 +493,12 @@ pnpm release:check
|
|
|
519
493
|
- [Fixture matrix](docs/guard-fixtures.md)
|
|
520
494
|
- [Release process](docs/releasing.md)
|
|
521
495
|
|
|
496
|
+
## More from Codapult
|
|
497
|
+
|
|
498
|
+
Starting a SaaS from scratch? [Codapult](https://codapult.dev) is a production-ready Next.js SaaS
|
|
499
|
+
foundation built for AI-assisted development — with MCP context, built-in AI, modular
|
|
500
|
+
architecture, and the source code under your control.
|
|
501
|
+
|
|
522
502
|
## Project status
|
|
523
503
|
|
|
524
504
|
`@codapult/guard` starts at `0.1.0` as a public alpha. The deterministic core is usable, tested,
|
|
@@ -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);
|
|
@@ -152,7 +152,7 @@ function auditGuardGovernanceUnlocked(root) {
|
|
|
152
152
|
reachability: {
|
|
153
153
|
approvedWithPolicyFingerprint,
|
|
154
154
|
approvedLinkedToRun,
|
|
155
|
-
approvedWithoutRun:
|
|
155
|
+
approvedWithoutRun: approved.length - approvedLinkedToRun,
|
|
156
156
|
runsWithPolicy: runs.filter((run) => run.policy !== undefined).length,
|
|
157
157
|
runsWithoutPolicy: runs.filter((run) => run.policy === undefined).length,
|
|
158
158
|
},
|
|
@@ -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;
|
|
@@ -247,6 +268,11 @@ export interface GuardPolicySnapshot {
|
|
|
247
268
|
source: 'working-tree' | 'git-ref';
|
|
248
269
|
ref?: string | undefined;
|
|
249
270
|
}
|
|
271
|
+
export interface GuardWorkingTreePolicyState {
|
|
272
|
+
config: GuardConfig;
|
|
273
|
+
snapshot: GuardPolicySnapshot;
|
|
274
|
+
proposals?: GuardProposalFile | undefined;
|
|
275
|
+
}
|
|
250
276
|
/**
|
|
251
277
|
* Fingerprints the effective policy used by a verification run.
|
|
252
278
|
*
|
|
@@ -278,6 +304,7 @@ export interface GuardWaiverUpdateOptions {
|
|
|
278
304
|
maxLifetimeDays?: number | undefined;
|
|
279
305
|
}
|
|
280
306
|
export declare function loadGuardWaivers(root: string): GuardWaiver[];
|
|
307
|
+
export declare function loadGuardWorkingTreePolicy(root: string): GuardWorkingTreePolicyState | undefined;
|
|
281
308
|
export declare function writeGuardWaivers(root: string): void;
|
|
282
309
|
export declare function updateGuardWaivers(root: string, options: GuardWaiverUpdateOptions): GuardWaiver[];
|
|
283
310
|
export declare function updateBaseline(root: string, options?: GuardBaselineUpdateOptions): Set<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();
|
|
@@ -597,6 +701,25 @@ export function loadGuardWaivers(root) {
|
|
|
597
701
|
return readGuardWaiversUnlocked(root).waivers;
|
|
598
702
|
return withGuardStateLock(root, () => readGuardWaiversUnlocked(root).waivers);
|
|
599
703
|
}
|
|
704
|
+
function readGuardWorkingTreePolicyUnlocked(root) {
|
|
705
|
+
recoverGuardPolicyTransaction(root);
|
|
706
|
+
const guardConfig = readGuardConfigFilesUnlocked(root);
|
|
707
|
+
if (!guardConfig)
|
|
708
|
+
return undefined;
|
|
709
|
+
const baseline = loadBaseline(root);
|
|
710
|
+
const waivers = readGuardWaiversUnlocked(root).waivers;
|
|
711
|
+
const agentConfig = loadGuardAgentConfig(root);
|
|
712
|
+
return {
|
|
713
|
+
config: guardConfig,
|
|
714
|
+
snapshot: createGuardPolicySnapshot(guardConfig, baseline, waivers, 'working-tree', undefined, agentConfig),
|
|
715
|
+
proposals: loadGuardProposalsUnlocked(root),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
export function loadGuardWorkingTreePolicy(root) {
|
|
719
|
+
if (!existsSync(resolve(root, GUARD_DIR)))
|
|
720
|
+
return readGuardWorkingTreePolicyUnlocked(root);
|
|
721
|
+
return withGuardStateLock(root, () => readGuardWorkingTreePolicyUnlocked(root));
|
|
722
|
+
}
|
|
600
723
|
export function writeGuardWaivers(root) {
|
|
601
724
|
withGuardStateLock(root, () => {
|
|
602
725
|
const file = { version: 1, waivers: [], decisions: [] };
|
|
@@ -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);
|
|
@@ -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 {
|
|
@@ -15,6 +16,22 @@ function isGuardRunManifest(value) {
|
|
|
15
16
|
if (value === null || typeof value !== 'object')
|
|
16
17
|
return false;
|
|
17
18
|
const manifest = value;
|
|
19
|
+
const stages = manifest.stages;
|
|
20
|
+
const validStages = stages !== null &&
|
|
21
|
+
typeof stages === 'object' &&
|
|
22
|
+
!Array.isArray(stages) &&
|
|
23
|
+
Object.values(stages).every((stage) => {
|
|
24
|
+
if (stage === null || typeof stage !== 'object')
|
|
25
|
+
return false;
|
|
26
|
+
const candidate = stage;
|
|
27
|
+
return (typeof candidate.durationMs === 'number' &&
|
|
28
|
+
Number.isFinite(candidate.durationMs) &&
|
|
29
|
+
candidate.durationMs >= 0 &&
|
|
30
|
+
(candidate.status === 'ok' ||
|
|
31
|
+
candidate.status === 'fail' ||
|
|
32
|
+
candidate.status === 'skipped') &&
|
|
33
|
+
(candidate.detail === undefined || typeof candidate.detail === 'string'));
|
|
34
|
+
});
|
|
18
35
|
return (manifest.version === 1 &&
|
|
19
36
|
typeof manifest.runId === 'string' &&
|
|
20
37
|
/^guard-[A-Za-z0-9-]+$/.test(manifest.runId) &&
|
|
@@ -23,6 +40,7 @@ function isGuardRunManifest(value) {
|
|
|
23
40
|
Number.isFinite(Date.parse(manifest.startedAt)) &&
|
|
24
41
|
typeof manifest.completedAt === 'string' &&
|
|
25
42
|
Number.isFinite(Date.parse(manifest.completedAt)) &&
|
|
43
|
+
Date.parse(manifest.completedAt) >= Date.parse(manifest.startedAt) &&
|
|
26
44
|
typeof manifest.durationMs === 'number' &&
|
|
27
45
|
Number.isFinite(manifest.durationMs) &&
|
|
28
46
|
manifest.durationMs >= 0 &&
|
|
@@ -32,8 +50,7 @@ function isGuardRunManifest(value) {
|
|
|
32
50
|
manifest.outcome === 'needs-review' ||
|
|
33
51
|
manifest.outcome === 'not-configured') &&
|
|
34
52
|
typeof manifest.gate === 'string' &&
|
|
35
|
-
|
|
36
|
-
typeof manifest.stages === 'object' &&
|
|
53
|
+
validStages &&
|
|
37
54
|
(manifest.policy === undefined ||
|
|
38
55
|
(manifest.policy !== null &&
|
|
39
56
|
typeof manifest.policy === 'object' &&
|
|
@@ -46,7 +63,8 @@ function isGuardRunManifest(value) {
|
|
|
46
63
|
(manifest.policy.changedFromBase === undefined ||
|
|
47
64
|
typeof manifest.policy.changedFromBase === 'boolean') &&
|
|
48
65
|
(manifest.policy.decisionIds === undefined ||
|
|
49
|
-
(
|
|
66
|
+
(manifest.policy.source === 'working-tree' &&
|
|
67
|
+
Array.isArray(manifest.policy.decisionIds) &&
|
|
50
68
|
new Set(manifest.policy.decisionIds).size === manifest.policy.decisionIds.length &&
|
|
51
69
|
manifest.policy.decisionIds.every((decisionId) => typeof decisionId === 'string' && decisionId.trim().length > 0))))));
|
|
52
70
|
}
|
|
@@ -68,11 +86,15 @@ export function recordGuardRunStage(context, name, status, startedAtMs, detail)
|
|
|
68
86
|
function pruneGuardRuns(root) {
|
|
69
87
|
const directory = resolve(root, GUARD_RUNS_DIR);
|
|
70
88
|
try {
|
|
89
|
+
if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
|
|
90
|
+
return;
|
|
71
91
|
const files = readdirSync(directory)
|
|
72
92
|
.filter((file) => file.endsWith('.json'))
|
|
73
93
|
.map((file) => ({
|
|
74
94
|
file,
|
|
75
|
-
modifiedAt:
|
|
95
|
+
modifiedAt: lstatSync(resolve(directory, file)).isSymbolicLink()
|
|
96
|
+
? 0
|
|
97
|
+
: statSync(resolve(directory, file)).mtimeMs,
|
|
76
98
|
}))
|
|
77
99
|
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
78
100
|
for (const entry of files.slice(GUARD_RUN_RETENTION)) {
|
|
@@ -91,13 +113,20 @@ function pruneGuardRuns(root) {
|
|
|
91
113
|
export function writeGuardRun(root, manifest) {
|
|
92
114
|
if (!isGuardRunManifest(manifest))
|
|
93
115
|
throw new GuardRunManifestError();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
withGuardStateLock(root, () => {
|
|
117
|
+
const directory = resolve(root, GUARD_RUNS_DIR);
|
|
118
|
+
if (existsSync(directory)) {
|
|
119
|
+
const stats = lstatSync(directory);
|
|
120
|
+
if (stats.isSymbolicLink() || !stats.isDirectory())
|
|
121
|
+
throw new GuardRunManifestError();
|
|
122
|
+
}
|
|
123
|
+
mkdirSync(directory, { recursive: true });
|
|
124
|
+
const path = resolve(directory, `${manifest.runId}.json`);
|
|
125
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${manifest.runId}`;
|
|
126
|
+
writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
127
|
+
renameSync(temporaryPath, path);
|
|
128
|
+
pruneGuardRuns(root);
|
|
129
|
+
});
|
|
101
130
|
}
|
|
102
131
|
/** Reads locally persisted run manifests without reaching a remote service. */
|
|
103
132
|
export function listGuardRuns(root, limit = 20) {
|
|
@@ -106,8 +135,19 @@ export function listGuardRuns(root, limit = 20) {
|
|
|
106
135
|
const boundedLimit = Math.min(limit, 1_000);
|
|
107
136
|
const directory = resolve(root, GUARD_RUNS_DIR);
|
|
108
137
|
try {
|
|
138
|
+
const directoryStats = lstatSync(directory);
|
|
139
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory())
|
|
140
|
+
return [];
|
|
109
141
|
return readdirSync(directory)
|
|
110
142
|
.filter((file) => file.endsWith('.json'))
|
|
143
|
+
.filter((file) => {
|
|
144
|
+
try {
|
|
145
|
+
return lstatSync(resolve(directory, file)).isFile();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
})
|
|
111
151
|
.map((file) => {
|
|
112
152
|
try {
|
|
113
153
|
const value = JSON.parse(readFileSync(resolve(directory, file), 'utf8'));
|
|
@@ -94,7 +94,8 @@ export const guardContractsFileSchema = z.object({
|
|
|
94
94
|
version: z.literal(1),
|
|
95
95
|
contracts: z.array(guardContractSchema),
|
|
96
96
|
});
|
|
97
|
-
export const guardProposalDecisionSchema = z
|
|
97
|
+
export const guardProposalDecisionSchema = z
|
|
98
|
+
.object({
|
|
98
99
|
decisionId: z.string().trim().min(1).optional(),
|
|
99
100
|
id: z.string().trim().min(1),
|
|
100
101
|
type: z.enum(['rule', 'contract']),
|
|
@@ -105,12 +106,29 @@ export const guardProposalDecisionSchema = z.object({
|
|
|
105
106
|
revision: z.number().optional(),
|
|
106
107
|
source: z.enum(['cli', 'mcp', 'external']).optional(),
|
|
107
108
|
proposalAuthor: z.string().trim().min(1).optional(),
|
|
108
|
-
actor: z.string().optional(),
|
|
109
|
-
commit: z.string().optional(),
|
|
109
|
+
actor: z.string().trim().min(1).optional(),
|
|
110
|
+
commit: z.string().trim().min(1).optional(),
|
|
110
111
|
policyFingerprint: z.string().trim().min(1).optional(),
|
|
111
112
|
policyRevision: z.number().int().nonnegative().optional(),
|
|
113
|
+
})
|
|
114
|
+
.superRefine((decision, context) => {
|
|
115
|
+
if (decision.policyFingerprint !== undefined && decision.decision !== 'approved') {
|
|
116
|
+
context.addIssue({
|
|
117
|
+
code: 'custom',
|
|
118
|
+
message: 'policyFingerprint is only valid for approved decisions.',
|
|
119
|
+
path: ['policyFingerprint'],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (decision.policyRevision !== undefined && decision.policyFingerprint === undefined) {
|
|
123
|
+
context.addIssue({
|
|
124
|
+
code: 'custom',
|
|
125
|
+
message: 'policyRevision requires policyFingerprint.',
|
|
126
|
+
path: ['policyRevision'],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
112
129
|
});
|
|
113
|
-
export const guardProposalSchema = z
|
|
130
|
+
export const guardProposalSchema = z
|
|
131
|
+
.object({
|
|
114
132
|
version: z.literal(1),
|
|
115
133
|
generatedAt: z.string(),
|
|
116
134
|
generatedBy: z.string().trim().min(1).optional(),
|
|
@@ -122,6 +140,21 @@ export const guardProposalSchema = z.object({
|
|
|
122
140
|
contracts: z.array(guardContractSchema),
|
|
123
141
|
questions: z.array(z.string()),
|
|
124
142
|
decisions: z.array(guardProposalDecisionSchema).optional(),
|
|
143
|
+
})
|
|
144
|
+
.superRefine((proposal, context) => {
|
|
145
|
+
const decisionIds = new Set();
|
|
146
|
+
for (const [index, decision] of (proposal.decisions ?? []).entries()) {
|
|
147
|
+
if (decision.decisionId === undefined)
|
|
148
|
+
continue;
|
|
149
|
+
if (decisionIds.has(decision.decisionId)) {
|
|
150
|
+
context.addIssue({
|
|
151
|
+
code: 'custom',
|
|
152
|
+
message: 'decisionId values must be unique.',
|
|
153
|
+
path: ['decisions', index, 'decisionId'],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
decisionIds.add(decision.decisionId);
|
|
157
|
+
}
|
|
125
158
|
});
|
|
126
159
|
export const guardAgentConfigSchema = z.object({
|
|
127
160
|
version: z.literal(1),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {} from '../../adapters/command.js';
|
|
2
2
|
import { runProjectAdapters, runProjectChecks, runWorkspaceProjectChecks, inspectProjectRuntime, } from '../../adapters/project-checks.js';
|
|
3
|
-
import {
|
|
3
|
+
import { loadGuardPolicyAtRevision, loadGuardWorkingTreePolicy, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
|
|
4
4
|
import { analyzeProjectImpact } from '../analysis/impact.js';
|
|
5
5
|
import { discoverProject } from '../discovery/discovery.js';
|
|
6
6
|
import { finishGuardRun, recordGuardRunStage, startGuardRun, } from '../history/runs.js';
|
|
@@ -43,9 +43,9 @@ export function runGuardVerification(root, options = {}) {
|
|
|
43
43
|
run,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
let
|
|
46
|
+
let workingTreeState;
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
workingTreeState = loadGuardWorkingTreePolicy(root);
|
|
49
49
|
}
|
|
50
50
|
catch (error) {
|
|
51
51
|
const run = finishGuardRun(root, runContext, 'fail', 'configuration');
|
|
@@ -64,7 +64,7 @@ export function runGuardVerification(root, options = {}) {
|
|
|
64
64
|
run,
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
-
if (!
|
|
67
|
+
if (!workingTreeState) {
|
|
68
68
|
const run = finishGuardRun(root, runContext, 'not-configured', 'configuration');
|
|
69
69
|
return {
|
|
70
70
|
status: 'not-configured',
|
|
@@ -85,7 +85,7 @@ export function runGuardVerification(root, options = {}) {
|
|
|
85
85
|
let policyChanged;
|
|
86
86
|
let policyDecisionIds = [];
|
|
87
87
|
try {
|
|
88
|
-
workingTreeSnapshot =
|
|
88
|
+
workingTreeSnapshot = workingTreeState.snapshot;
|
|
89
89
|
const baseSnapshot = options.policyBase
|
|
90
90
|
? loadGuardPolicyAtRevision(root, options.policyBase)
|
|
91
91
|
: undefined;
|
|
@@ -110,7 +110,7 @@ export function runGuardVerification(root, options = {}) {
|
|
|
110
110
|
policyChanged =
|
|
111
111
|
baseSnapshot !== undefined && baseSnapshot.fingerprint !== workingTreeSnapshot.fingerprint;
|
|
112
112
|
if (policySnapshot.source === 'working-tree') {
|
|
113
|
-
policyDecisionIds = (
|
|
113
|
+
policyDecisionIds = (workingTreeState.proposals?.decisions ?? [])
|
|
114
114
|
.filter((decision) => decision.policyFingerprint === policySnapshot.fingerprint)
|
|
115
115
|
.map((decision) => decision.decisionId ??
|
|
116
116
|
`${decision.proposalId ?? 'unknown'}:${decision.type}:${decision.id}`)
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codapult/guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -98,7 +98,7 @@
|
|
|
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",
|
|
@@ -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.3",
|
|
118
|
+
"eslint": "^10.12.0",
|
|
119
119
|
"eslint-config-prettier": "^10.1.8",
|
|
120
120
|
"eslint-plugin-prettier": "^5.5.6",
|
|
121
|
-
"prettier": "^3.9.
|
|
122
|
-
"release-it": "^21.1.
|
|
123
|
-
"typescript-eslint": "^8.
|
|
124
|
-
"vitest": "^5.0.
|
|
121
|
+
"prettier": "^3.9.10",
|
|
122
|
+
"release-it": "^21.1.1",
|
|
123
|
+
"typescript-eslint": "^8.71.1",
|
|
124
|
+
"vitest": "^5.0.3"
|
|
125
125
|
}
|
|
126
126
|
}
|