@codapult/guard 0.8.1 → 0.10.0

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