@codapult/guard 0.4.0 → 0.6.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
@@ -308,6 +308,14 @@ task finished
308
308
  For Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot, and generic hosts, see
309
309
  [`docs/integrations/`](docs/integrations/).
310
310
 
311
+ ### MCP Registry
312
+
313
+ Guard is also prepared for discovery through the official [MCP Registry](https://registry.modelcontextprotocol.io/).
314
+ The Registry entry points to the published `@codapult/guard` npm package and its stdio MCP server;
315
+ it does not replace npm installation or the host-specific configuration above. Registry metadata is
316
+ validated during CI and published after npm in the release workflow. The Registry is currently in
317
+ preview, so the canonical installation path remains npm.
318
+
311
319
  For workflows where the authoring agent must not approve its own policy proposals, set Guard to
312
320
  protected mode in `rules.json`:
313
321
 
@@ -327,6 +335,9 @@ and Guard rejects the same declared actor when a proposal was generated with `GU
327
335
  These variables provide declared provenance only; external branch protection or signed identity
328
336
  remains responsible for proving who approved the change.
329
337
 
338
+ See [the extension map](docs/extensions.md) for the supported AI-host, CI/PR, policy-pack, tool
339
+ adapter, approval-governance, and observability integrations.
340
+
330
341
  ## CI
331
342
 
332
343
  Copy the consumer workflow into a project that has installed and initialized Guard:
@@ -5,6 +5,7 @@ export interface CommandResult {
5
5
  exitCode: number;
6
6
  stdout: string;
7
7
  stderr: string;
8
+ durationMs: number;
8
9
  timedOut?: boolean | undefined;
9
10
  truncated?: boolean | undefined;
10
11
  }
@@ -24,6 +24,7 @@ function parseCommand(command) {
24
24
  return { executable: windowsExecutable, args };
25
25
  }
26
26
  export function runProjectCommand(command, cwd, options = {}) {
27
+ const startedAt = Date.now();
27
28
  const parsed = parseCommand(command);
28
29
  if (!parsed) {
29
30
  return {
@@ -33,6 +34,7 @@ export function runProjectCommand(command, cwd, options = {}) {
33
34
  exitCode: 2,
34
35
  stdout: '',
35
36
  stderr: 'Unsafe or empty project command rejected.',
37
+ durationMs: 0,
36
38
  };
37
39
  }
38
40
  try {
@@ -51,6 +53,7 @@ export function runProjectCommand(command, cwd, options = {}) {
51
53
  exitCode: 0,
52
54
  stdout: captured.value,
53
55
  stderr: '',
56
+ durationMs: Math.max(0, Date.now() - startedAt),
54
57
  truncated: captured.truncated,
55
58
  };
56
59
  }
@@ -68,6 +71,7 @@ export function runProjectCommand(command, cwd, options = {}) {
68
71
  exitCode,
69
72
  stdout: stdout.value,
70
73
  stderr: timedOut ? `${stderr.value}\n[command timed out]` : stderr.value,
74
+ durationMs: Math.max(0, Date.now() - startedAt),
71
75
  timedOut,
72
76
  truncated: stdout.truncated || stderr.truncated,
73
77
  };
@@ -12,6 +12,7 @@ function notConfigured(check) {
12
12
  exitCode: 0,
13
13
  stdout: '',
14
14
  stderr: `${check} script is not configured`,
15
+ durationMs: 0,
15
16
  };
16
17
  }
17
18
  function readPackageMetadata(root) {
@@ -119,6 +120,9 @@ const adapterScriptPatterns = {
119
120
  'dependency-graph': /(?:dep(?:endency)?[-:]?(?:check|graph|cruise)|madge|architecture)/i,
120
121
  security: /(?:security|semgrep|gitleaks|snyk|trivy|audit)/i,
121
122
  'dependency-hygiene': /(?:knip|dep(?:endency)?[-:]?(?:unused|hygiene))/i,
123
+ sast: /(?:semgrep|codeql|bearer|sast)/i,
124
+ 'secret-scanning': /(?:gitleaks|trufflehog|secret[-_ ]?scan)/i,
125
+ 'dependency-audit': /(?:npm audit|pnpm audit|yarn audit|osv|snyk|trivy|dependency[-_ ]?audit)/i,
122
126
  };
123
127
  /** Runs only explicitly configured project scripts; Guard does not recreate these tools. */
124
128
  export function runProjectAdapters(root, options = {}) {
@@ -126,6 +130,7 @@ export function runProjectAdapters(root, options = {}) {
126
130
  const manager = packageManager(root, metadata.packageManager);
127
131
  const scripts = metadata.scripts ?? {};
128
132
  const results = {};
133
+ const executed = new Map();
129
134
  for (const [adapter, pattern] of Object.entries(adapterScriptPatterns)) {
130
135
  const configured = options.tooling?.[adapter];
131
136
  const script = configured?.enabled
@@ -141,9 +146,13 @@ export function runProjectAdapters(root, options = {}) {
141
146
  continue;
142
147
  }
143
148
  const command = manager === 'npm' ? `npm run ${script}` : `${manager} run ${script}`;
144
- results[adapter] = runProjectCommand(command, root, {
145
- timeout: options.timeout ?? 120_000,
146
- });
149
+ const previous = executed.get(command);
150
+ const result = previous ??
151
+ runProjectCommand(command, root, {
152
+ timeout: options.timeout ?? 120_000,
153
+ });
154
+ executed.set(command, result);
155
+ results[adapter] = result;
147
156
  }
148
157
  return results;
149
158
  }
@@ -42,6 +42,7 @@ export declare function guardDoctorCommand(options?: GuardOutputOptions & {
42
42
  fixCache?: boolean | undefined;
43
43
  }): void;
44
44
  export declare function guardHistoryCommand(): void;
45
+ export declare function guardRunsCommand(options?: GuardOutputOptions): void;
45
46
  export declare function guardImpactCommand(files: string[], options?: GuardOutputOptions): void;
46
47
  export declare function guardHistoryDiffCommand(from: string, to: string, options?: GuardOutputOptions): void;
47
48
  export declare function guardVerifyCommand(options?: GuardVerifyOptions): void;
@@ -9,6 +9,7 @@ import { guardAgentTargets, installGuardAgentInstructions, installGuardAgentTarg
9
9
  import { dim, fail, heading, info, success, warn } from '../ui.js';
10
10
  import { guardFindingsToSarif } from '../../core/output/sarif.js';
11
11
  import { analyzeProjectImpact } from '../../core/analysis/impact.js';
12
+ import { listGuardRuns, summarizeGuardRuns } from '../../core/history/runs.js';
12
13
  function renderFindings(findings) {
13
14
  for (const finding of findings) {
14
15
  const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
@@ -194,6 +195,19 @@ export function guardHistoryCommand() {
194
195
  info(`${snapshot.revision}: ${snapshot.files} files, ${snapshot.modules} modules, ${snapshot.cycles} cycles`);
195
196
  }
196
197
  }
198
+ export function guardRunsCommand(options = {}) {
199
+ const root = getRoot();
200
+ const result = { summary: summarizeGuardRuns(root), runs: listGuardRuns(root) };
201
+ if (options.json) {
202
+ console.log(JSON.stringify(result, null, 2));
203
+ return;
204
+ }
205
+ heading('Codapult Guard Runs');
206
+ info(`${result.summary.total} run(s); ${result.summary.failed} failed, ${result.summary.warnings} warning, average ${result.summary.averageDurationMs} ms`);
207
+ for (const run of result.runs) {
208
+ info(`${run.runId}: ${run.outcome}, gate=${run.gate}, ${run.durationMs} ms`);
209
+ }
210
+ }
197
211
  export function guardImpactCommand(files, options = {}) {
198
212
  const root = getRoot();
199
213
  if (files.length === 0) {
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, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRulesApproveCommand, guardVerifyCommand, } from './commands/guard.js';
4
+ import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRunsCommand, guardRulesApproveCommand, guardVerifyCommand, } from './commands/guard.js';
5
5
  import { config } from '../core/config.js';
6
6
  import { GuardBaselineReasonError, GuardConfigError, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
7
7
  import { guardErrorPayload } from '../core/errors.js';
@@ -22,6 +22,7 @@ guard.command('propose').option('--json').action(guardProposeCommand);
22
22
  guard.command('install-agent [target]').option('--json').action(guardInstallAgentCommand);
23
23
  guard.command('doctor').option('--json').option('--fix-cache').action(guardDoctorCommand);
24
24
  guard.command('history').action(guardHistoryCommand);
25
+ guard.command('runs').option('--json').action(guardRunsCommand);
25
26
  guard.command('history-diff <from> <to>').option('--json').action(guardHistoryDiffCommand);
26
27
  guard.command('impact <files...>').option('--json').action(guardImpactCommand);
27
28
  guard
@@ -1,4 +1,5 @@
1
1
  export declare const GUARD_RUNS_DIR: string;
2
+ export declare const GUARD_RUN_RETENTION = 200;
2
3
  export interface GuardRunStage {
3
4
  durationMs: number;
4
5
  status: 'ok' | 'fail' | 'skipped';
@@ -22,7 +23,21 @@ export interface GuardRunContext {
22
23
  startedAtMs: number;
23
24
  stages: Record<string, GuardRunStage>;
24
25
  }
26
+ export interface GuardRunSummary {
27
+ total: number;
28
+ passed: number;
29
+ failed: number;
30
+ warnings: number;
31
+ averageDurationMs: number;
32
+ last?: GuardRunManifest | undefined;
33
+ }
34
+ export declare class GuardRunManifestError extends Error {
35
+ constructor();
36
+ }
25
37
  export declare function startGuardRun(): GuardRunContext;
26
38
  export declare function recordGuardRunStage(context: GuardRunContext, name: string, status: GuardRunStage['status'], startedAtMs: number, detail?: string): void;
27
39
  export declare function writeGuardRun(root: string, manifest: GuardRunManifest): void;
40
+ /** Reads locally persisted run manifests without reaching a remote service. */
41
+ export declare function listGuardRuns(root: string, limit?: number): GuardRunManifest[];
42
+ export declare function summarizeGuardRuns(root: string, limit?: number): GuardRunSummary;
28
43
  export declare function finishGuardRun(root: string, context: GuardRunContext, outcome: GuardRunManifest['outcome'], gate: string, command?: string): GuardRunManifest;
@@ -1,9 +1,40 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { config } from '../config.js';
6
6
  export const GUARD_RUNS_DIR = `.${config.appName}/guard/history/runs`;
7
+ export const GUARD_RUN_RETENTION = 200;
8
+ export class GuardRunManifestError extends Error {
9
+ constructor() {
10
+ super('Guard run manifest is invalid or contains an unsafe run ID.');
11
+ this.name = 'GuardRunManifestError';
12
+ }
13
+ }
14
+ function isGuardRunManifest(value) {
15
+ if (value === null || typeof value !== 'object')
16
+ return false;
17
+ const manifest = value;
18
+ return (manifest.version === 1 &&
19
+ typeof manifest.runId === 'string' &&
20
+ /^guard-[A-Za-z0-9-]+$/.test(manifest.runId) &&
21
+ typeof manifest.command === 'string' &&
22
+ typeof manifest.startedAt === 'string' &&
23
+ Number.isFinite(Date.parse(manifest.startedAt)) &&
24
+ typeof manifest.completedAt === 'string' &&
25
+ Number.isFinite(Date.parse(manifest.completedAt)) &&
26
+ typeof manifest.durationMs === 'number' &&
27
+ Number.isFinite(manifest.durationMs) &&
28
+ manifest.durationMs >= 0 &&
29
+ (manifest.outcome === 'pass' ||
30
+ manifest.outcome === 'fail' ||
31
+ manifest.outcome === 'warning' ||
32
+ manifest.outcome === 'needs-review' ||
33
+ manifest.outcome === 'not-configured') &&
34
+ typeof manifest.gate === 'string' &&
35
+ manifest.stages !== null &&
36
+ typeof manifest.stages === 'object');
37
+ }
7
38
  export function startGuardRun() {
8
39
  return {
9
40
  runId: `guard-${randomUUID()}`,
@@ -19,13 +50,77 @@ export function recordGuardRunStage(context, name, status, startedAtMs, detail)
19
50
  ...(detail ? { detail } : {}),
20
51
  };
21
52
  }
53
+ function pruneGuardRuns(root) {
54
+ const directory = resolve(root, GUARD_RUNS_DIR);
55
+ try {
56
+ const files = readdirSync(directory)
57
+ .filter((file) => file.endsWith('.json'))
58
+ .map((file) => ({
59
+ file,
60
+ modifiedAt: statSync(resolve(directory, file)).mtimeMs,
61
+ }))
62
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
63
+ for (const entry of files.slice(GUARD_RUN_RETENTION)) {
64
+ try {
65
+ unlinkSync(resolve(directory, entry.file));
66
+ }
67
+ catch {
68
+ // Retention is best effort; a concurrent reader may own the file.
69
+ }
70
+ }
71
+ }
72
+ catch {
73
+ // Diagnostics must never fail because retention cannot be completed.
74
+ }
75
+ }
22
76
  export function writeGuardRun(root, manifest) {
77
+ if (!isGuardRunManifest(manifest))
78
+ throw new GuardRunManifestError();
23
79
  const directory = resolve(root, GUARD_RUNS_DIR);
24
80
  mkdirSync(directory, { recursive: true });
25
81
  const path = resolve(directory, `${manifest.runId}.json`);
26
82
  const temporaryPath = `${path}.tmp-${process.pid}-${manifest.runId}`;
27
83
  writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
28
84
  renameSync(temporaryPath, path);
85
+ pruneGuardRuns(root);
86
+ }
87
+ /** Reads locally persisted run manifests without reaching a remote service. */
88
+ export function listGuardRuns(root, limit = 20) {
89
+ if (!Number.isInteger(limit) || limit < 1)
90
+ return [];
91
+ const boundedLimit = Math.min(limit, 1_000);
92
+ const directory = resolve(root, GUARD_RUNS_DIR);
93
+ try {
94
+ return readdirSync(directory)
95
+ .filter((file) => file.endsWith('.json'))
96
+ .map((file) => {
97
+ try {
98
+ const value = JSON.parse(readFileSync(resolve(directory, file), 'utf8'));
99
+ return isGuardRunManifest(value) ? value : undefined;
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ })
105
+ .filter((manifest) => manifest !== undefined)
106
+ .sort((left, right) => right.completedAt.localeCompare(left.completedAt))
107
+ .slice(0, boundedLimit);
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ export function summarizeGuardRuns(root, limit = 100) {
114
+ const runs = listGuardRuns(root, limit);
115
+ const total = runs.length;
116
+ return {
117
+ total,
118
+ passed: runs.filter((run) => run.outcome === 'pass').length,
119
+ failed: runs.filter((run) => run.outcome === 'fail').length,
120
+ warnings: runs.filter((run) => run.outcome === 'warning').length,
121
+ averageDurationMs: total === 0 ? 0 : Math.round(runs.reduce((sum, run) => sum + run.durationMs, 0) / total),
122
+ ...(total > 0 ? { last: runs[0] } : {}),
123
+ };
29
124
  }
30
125
  export function finishGuardRun(root, context, outcome, gate, command = 'verify') {
31
126
  let commit;
@@ -6,5 +6,5 @@ export type GuardContractKind = 'guidance' | 'import-boundary' | 'required-call'
6
6
  export type GuardBudgetMetric = 'lines' | 'bytes' | 'imports';
7
7
  export type GuardToolMode = 'auto' | 'on' | 'off';
8
8
  export type GuardApprovalMode = 'local' | 'protected';
9
- export type GuardAdapterName = 'dependency-graph' | 'security' | 'dependency-hygiene';
9
+ export type GuardAdapterName = 'dependency-graph' | 'security' | 'dependency-hygiene' | 'sast' | 'secret-scanning' | 'dependency-audit';
10
10
  export type ProjectCheck = 'lint' | 'typecheck' | 'test' | 'build';
@@ -1,6 +1,7 @@
1
1
  import { buildArchitectureMemory, buildConventionsMemory, discoverProject, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadGuardAgentConfig, loadGuardArtifact, loadGuardConfig, loadGuardProposals, GuardStateBusyError, GuardStateStaleError, } from '../core/guard.js';
2
2
  import { guardErrorPayload } from '../core/errors.js';
3
3
  import { DiscoveryStaleError } from '../core/discovery/discovery.js';
4
+ import { listGuardRuns, summarizeGuardRuns } from '../core/history/runs.js';
4
5
  function jsonResource(uri, value) {
5
6
  return {
6
7
  contents: [{ uri, text: JSON.stringify(value, null, 2), mimeType: 'application/json' }],
@@ -20,6 +21,19 @@ function resourceError(uri, error) {
20
21
  }));
21
22
  }
22
23
  export function registerGuardResources(server) {
24
+ server.registerResource('codapult_guard_runs', 'codapult://guard/runs', {
25
+ title: 'Guard Run History',
26
+ description: 'Local verification outcomes, gates, and stage timings.',
27
+ mimeType: 'application/json',
28
+ }, () => {
29
+ const root = findGuardRoot();
30
+ return jsonResource('codapult://guard/runs', {
31
+ version: 1,
32
+ root,
33
+ summary: summarizeGuardRuns(root),
34
+ runs: listGuardRuns(root),
35
+ });
36
+ });
23
37
  server.registerResource('codapult_guard_rules', 'codapult://guard/rules', {
24
38
  title: 'Architecture Guard Rules',
25
39
  description: 'Local architecture invariants available to CLI checks and AI agents',
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@codapult/guard",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
+ "mcpName": "io.github.codapult/guard",
4
5
  "description": "Local-first architecture guardrails for JavaScript and TypeScript projects, with first-class support for Next.js SaaS and AI-assisted development",
5
6
  "license": "MIT",
6
7
  "packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
@@ -53,7 +54,9 @@
53
54
  "lint:fix": "eslint --fix .",
54
55
  "format": "prettier --write .",
55
56
  "format:check": "prettier --check .",
56
- "release:check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build && npm pack --dry-run",
57
+ "release:check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build && npm pack --dry-run && pnpm mcp:validate",
58
+ "mcp:validate": "node scripts/mcp-registry.mjs validate",
59
+ "mcp:sync": "node scripts/mcp-registry.mjs sync",
57
60
  "test": "vitest run",
58
61
  "test:coverage": "vitest run --coverage",
59
62
  "test:guard:coverage": "vitest run src/core src/adapters/project-checks.test.ts --coverage --coverage.include='src/core/**/*.ts' --coverage.include='src/adapters/project-checks.ts'",