@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 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/contracts approve|reject`, `baseline accept|remove`, and
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 proposed policy. |
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: approvedWithPolicyFingerprint - approvedLinkedToRun,
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 (options.persistCache ?? existsSync(resolve(root, `.${config.appName}/guard`))) {
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);
@@ -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>;
@@ -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
- return new Set(value.filter((item) => typeof item === 'string'));
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
- return JSON.parse(readFileSync(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), 'utf8'));
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
- manifest.stages !== null &&
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
- (Array.isArray(manifest.policy.decisionIds) &&
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: statSync(resolve(directory, file)).mtimeMs,
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
- const directory = resolve(root, GUARD_RUNS_DIR);
95
- mkdirSync(directory, { recursive: true });
96
- const path = resolve(directory, `${manifest.runId}.json`);
97
- const temporaryPath = `${path}.tmp-${process.pid}-${manifest.runId}`;
98
- writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
99
- renameSync(temporaryPath, path);
100
- pruneGuardRuns(root);
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.object({
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.object({
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 { loadBaseline, loadGuardPolicyAtRevision, createGuardPolicySnapshot, loadGuardWaivers, loadGuardAgentConfig, loadGuardConfig, loadGuardProposals, scanGuard, classifyGuardOutcome, validateGuardPolicy, } from '../guard.js';
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 config;
46
+ let workingTreeState;
47
47
  try {
48
- config = loadGuardConfig(root);
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 (!config) {
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 = createGuardPolicySnapshot(config, loadBaseline(root), loadGuardWaivers(root), 'working-tree', undefined, loadGuardAgentConfig(root));
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 = (loadGuardProposals(root)?.decisions ?? [])
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
+ }
@@ -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
- project,
70
- architecture: loadGuardArtifact(root, GUARD_ARCHITECTURE_FILE) ?? buildArchitectureMemory(project),
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
  }
@@ -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
- rules: config?.rules ?? [],
263
- contracts: config?.contracts ?? [],
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.8.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.30.0",
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.2",
115
+ "@types/node": "^26.6.5",
116
116
  "@typescript/native": "npm:typescript@^7.0.2",
117
- "@vitest/coverage-v8": "^5.0.1",
118
- "eslint": "^10.11.0",
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.8",
122
- "release-it": "^21.1.0",
123
- "typescript-eslint": "^8.70.1",
124
- "vitest": "^5.0.1"
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
  }