@codapult/guard 0.8.1 → 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
@@ -148,10 +148,13 @@ Guard stores policy at the project level, while each policy item can target a na
148
148
  | Waiver | One active finding fingerprint, with owner, reason, and expiry |
149
149
 
150
150
  Routine checks do not change source code, rules, contracts, baseline, or waivers. Commands that
151
- change policy are explicit: `init`, `rules/contracts approve|reject`, `baseline accept|remove`, and
151
+ change policy are explicit: `init`, `rules approve`, `contracts approve|reject`, `baseline accept|remove`, and
152
152
  `waiver add|renew|remove`. `analyze` refreshes derived project facts, while `verify` may run the
153
153
  project's configured commands and records a local run manifest; neither changes approved policy.
154
154
 
155
+ For the complete mutation contract and host/CI controls, see
156
+ [Policy mutation control](docs/policy-mutations.md).
157
+
155
158
  ## What Guard discovers
156
159
 
157
160
  The model is framework-aware without being framework-dependent:
@@ -325,6 +328,12 @@ task finished
325
328
  → repeat until pass or bounded iteration limit
326
329
  ```
327
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
+
328
337
  For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, see
329
338
  [`docs/integrations/`](docs/integrations/).
330
339
 
@@ -398,7 +407,9 @@ cp node_modules/@codapult/guard/docs/guard-ci.yml .github/workflows/guard.yml
398
407
  ```
399
408
 
400
409
  Or copy it from [`docs/guard-ci.yml`](docs/guard-ci.yml). It runs project verification and exports
401
- Guard findings as SARIF for GitHub code scanning.
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.
402
413
 
403
414
  The package repository separately runs its own unit, typecheck, build, and packaging checks in
404
415
  [`.github/workflows/ci.yml`](.github/workflows/ci.yml), while the fixture workflow tests Guard on
@@ -420,7 +431,7 @@ real project shapes.
420
431
  | `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
421
432
  | `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
422
433
  | `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
423
- | `rules` / `contracts` | Approve or reject proposed policy. |
434
+ | `rules` / `contracts` | Approve rules; approve or reject contracts. |
424
435
  | `baseline` | Review or intentionally accept existing findings. |
425
436
  | `waiver` | Manage owner-attributed, time-bounded exceptions to exact findings. |
426
437
 
@@ -64,6 +64,7 @@ export declare function guardBaselineCommand(action: 'list' | 'accept' | 'remove
64
64
  export declare function guardWaiverCommand(action: 'list' | 'add' | 'remove' | 'renew', value?: string, options?: GuardWaiverOptions): void;
65
65
  export declare function guardRulesApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
66
66
  export declare function guardPolicyExplainCommand(id: string, options?: GuardOutputOptions): void;
67
+ export declare function guardPolicyMutationsCommand(options?: GuardOutputOptions): void;
67
68
  export declare function guardContractsApproveCommand(ids?: string, options?: GuardApprovalOptions): void;
68
69
  export declare function guardContractsRejectCommand(ids?: string, options?: GuardApprovalOptions): void;
69
70
  export declare function guardReviewCommand(options?: GuardReviewOptions): void;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { relative, resolve } from 'node:path';
4
- import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, GuardWaiverError, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, loadGuardWaivers, updateBaseline, updateGuardWaivers, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
4
+ import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, GuardWaiverError, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, loadGuardWaivers, updateBaseline, updateGuardWaivers, loadGuardConfig, loadGuardProposals, applyGuardProposalDecision, writeGuardProposals, validateGuardProposalApproval, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, getGuardPolicyMutationPolicy, validateGuardPolicy, writeProjectState, } from '../../core/guard.js';
5
5
  import { guardErrorPayload } from '../../core/errors.js';
6
6
  import { runGuardVerification, } from '../../core/verification/verify.js';
7
7
  import { diagnoseGuard } from '../../core/analysis/doctor.js';
@@ -719,6 +719,26 @@ export function guardPolicyExplainCommand(id, options = {}) {
719
719
  }
720
720
  process.exitCode = 0;
721
721
  }
722
+ export function guardPolicyMutationsCommand(options = {}) {
723
+ const root = getRoot();
724
+ const loaded = loadConfigSafely(root, options);
725
+ if (loaded.invalid)
726
+ return;
727
+ const policy = getGuardPolicyMutationPolicy(loaded.config);
728
+ if (options.json) {
729
+ console.log(JSON.stringify(policy, null, 2));
730
+ }
731
+ else {
732
+ heading('Guard Policy Mutations');
733
+ info(policy.message);
734
+ for (const operation of policy.operations) {
735
+ info(`${operation.id}: ${operation.surfaces.join(', ')}; ${operation.guardBlocksMcp === true
736
+ ? 'MCP blocked in protected mode'
737
+ : 'external control required'}`);
738
+ }
739
+ }
740
+ process.exitCode = 0;
741
+ }
722
742
  export function guardContractsApproveCommand(ids, options = {}) {
723
743
  const root = getRoot();
724
744
  const loaded = loadConfigSafely(root);
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import pc from 'picocolors';
4
- import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardGovernanceCommand, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, guardWaiverCommand, } from './commands/guard.js';
4
+ import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardGovernanceCommand, guardPolicyExplainCommand, guardPolicyMutationsCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, guardWaiverCommand, } from './commands/guard.js';
5
5
  import { config } from '../core/config.js';
6
6
  import { GuardBaselineReasonError, GuardConfigError, GuardStateBusyError, GuardStateStaleError, GuardWaiverError, } from '../core/guard.js';
7
7
  import { GuardAgentInstallError } from '../adapters/agents/agent-integration.js';
@@ -56,6 +56,7 @@ const rules = guard.command('rules');
56
56
  rules.command('approve [ids]').option('--all').action(guardRulesApproveCommand);
57
57
  const policy = guard.command('policy');
58
58
  policy.command('explain <id>').option('--json').action(guardPolicyExplainCommand);
59
+ policy.command('mutations').option('--json').action(guardPolicyMutationsCommand);
59
60
  const contracts = guard.command('contracts');
60
61
  contracts.command('approve [ids]').option('--all').action(guardContractsApproveCommand);
61
62
  contracts.command('reject [ids]').option('--all').action(guardContractsRejectCommand);
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, statSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, extname, join, normalize, relative, resolve } from 'node:path';
5
5
  import { Project, SyntaxKind } from 'ts-morph';
6
6
  import { config } from '../config.js';
@@ -61,6 +61,23 @@ const DISCOVERY_CACHE_VERSION = 3;
61
61
  function discoveryCachePath(root) {
62
62
  return resolve(root, `.${config.appName}/guard/cache.json`);
63
63
  }
64
+ function canPersistDiscoveryCache(root) {
65
+ const parent = resolve(root, `.${config.appName}`);
66
+ const directory = dirname(discoveryCachePath(root));
67
+ for (const path of [parent, directory]) {
68
+ if (!existsSync(path))
69
+ continue;
70
+ try {
71
+ const stats = lstatSync(path);
72
+ if (stats.isSymbolicLink() || !stats.isDirectory())
73
+ return false;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
64
81
  export function clearDiscoveryCache(root) {
65
82
  rmSync(discoveryCachePath(root), { force: true });
66
83
  }
@@ -941,7 +958,8 @@ function discoverProjectOnce(root, options = {}, attempt = 0) {
941
958
  }
942
959
  discoveryCache.set(cacheKey, { signature, model });
943
960
  discoveryReuse.set(cacheKey, reusedModules);
944
- if (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;
@@ -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();
@@ -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 {
@@ -85,11 +86,15 @@ export function recordGuardRunStage(context, name, status, startedAtMs, detail)
85
86
  function pruneGuardRuns(root) {
86
87
  const directory = resolve(root, GUARD_RUNS_DIR);
87
88
  try {
89
+ if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
90
+ return;
88
91
  const files = readdirSync(directory)
89
92
  .filter((file) => file.endsWith('.json'))
90
93
  .map((file) => ({
91
94
  file,
92
- modifiedAt: statSync(resolve(directory, file)).mtimeMs,
95
+ modifiedAt: lstatSync(resolve(directory, file)).isSymbolicLink()
96
+ ? 0
97
+ : statSync(resolve(directory, file)).mtimeMs,
93
98
  }))
94
99
  .sort((left, right) => right.modifiedAt - left.modifiedAt);
95
100
  for (const entry of files.slice(GUARD_RUN_RETENTION)) {
@@ -108,13 +113,20 @@ function pruneGuardRuns(root) {
108
113
  export function writeGuardRun(root, manifest) {
109
114
  if (!isGuardRunManifest(manifest))
110
115
  throw new GuardRunManifestError();
111
- const directory = resolve(root, GUARD_RUNS_DIR);
112
- mkdirSync(directory, { recursive: true });
113
- const path = resolve(directory, `${manifest.runId}.json`);
114
- const temporaryPath = `${path}.tmp-${process.pid}-${manifest.runId}`;
115
- writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
116
- renameSync(temporaryPath, path);
117
- 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
+ });
118
130
  }
119
131
  /** Reads locally persisted run manifests without reaching a remote service. */
120
132
  export function listGuardRuns(root, limit = 20) {
@@ -123,8 +135,19 @@ export function listGuardRuns(root, limit = 20) {
123
135
  const boundedLimit = Math.min(limit, 1_000);
124
136
  const directory = resolve(root, GUARD_RUNS_DIR);
125
137
  try {
138
+ const directoryStats = lstatSync(directory);
139
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory())
140
+ return [];
126
141
  return readdirSync(directory)
127
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
+ })
128
151
  .map((file) => {
129
152
  try {
130
153
  const value = JSON.parse(readFileSync(resolve(directory, file), 'utf8'));
@@ -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.1",
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
  }