@delegance/claude-autopilot 2.5.0 → 5.0.0-alpha.2

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +169 -106
  3. package/bin/_launcher.js +77 -0
  4. package/bin/claude-autopilot.js +3 -0
  5. package/bin/guardrail.js +3 -0
  6. package/package.json +23 -9
  7. package/presets/generic/guardrail.config.yaml +35 -0
  8. package/presets/generic/stack.md +40 -0
  9. package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
  10. package/scripts/autoregress.ts +27 -11
  11. package/skills/autopilot/SKILL.md +170 -0
  12. package/skills/claude-autopilot.md +80 -0
  13. package/skills/guardrail.md +39 -0
  14. package/skills/migrate/SKILL.md +83 -0
  15. package/src/adapters/council/claude.ts +41 -0
  16. package/src/adapters/council/openai.ts +40 -0
  17. package/src/adapters/council/types.ts +7 -0
  18. package/src/adapters/loader.ts +7 -7
  19. package/src/adapters/review-engine/auto.ts +2 -2
  20. package/src/adapters/review-engine/claude.ts +9 -11
  21. package/src/adapters/review-engine/codex.ts +9 -11
  22. package/src/adapters/review-engine/gemini.ts +9 -11
  23. package/src/adapters/review-engine/openai-compatible.ts +10 -12
  24. package/src/adapters/review-engine/parse-output.ts +32 -6
  25. package/src/adapters/review-engine/prompt-builder.ts +19 -0
  26. package/src/adapters/review-engine/types.ts +1 -1
  27. package/src/adapters/vcs-host/commit-status.ts +39 -0
  28. package/src/adapters/vcs-host/github.ts +2 -2
  29. package/src/cli/baseline.ts +125 -0
  30. package/src/cli/ci.ts +11 -8
  31. package/src/cli/costs.ts +2 -2
  32. package/src/cli/council.ts +96 -0
  33. package/src/cli/detector.ts +21 -5
  34. package/src/cli/explain.ts +197 -0
  35. package/src/cli/fix.ts +173 -111
  36. package/src/cli/hook.ts +72 -27
  37. package/src/cli/ignore-helper.ts +116 -0
  38. package/src/cli/index.ts +355 -31
  39. package/src/cli/init.ts +12 -12
  40. package/src/cli/lsp.ts +200 -0
  41. package/src/cli/mcp.ts +206 -0
  42. package/src/cli/pr-comment.ts +5 -5
  43. package/src/cli/pr-desc.ts +168 -0
  44. package/src/cli/pr-review-comments.ts +3 -3
  45. package/src/cli/pr.ts +76 -0
  46. package/src/cli/preflight.ts +109 -32
  47. package/src/cli/report.ts +186 -0
  48. package/src/cli/run.ts +140 -36
  49. package/src/cli/scan.ts +233 -0
  50. package/src/cli/setup.ts +121 -15
  51. package/src/cli/test-gen.ts +125 -0
  52. package/src/cli/triage.ts +137 -0
  53. package/src/cli/watch.ts +52 -31
  54. package/src/cli/worker.ts +109 -0
  55. package/src/core/cache/review-cache.ts +2 -2
  56. package/src/core/chunking/index.ts +2 -2
  57. package/src/core/config/loader.ts +10 -10
  58. package/src/core/config/preset-resolver.ts +6 -6
  59. package/src/core/config/schema.ts +103 -2
  60. package/src/core/config/types.ts +57 -2
  61. package/src/core/council/config.ts +71 -0
  62. package/src/core/council/context.ts +17 -0
  63. package/src/core/council/runner.ts +83 -0
  64. package/src/core/council/types.ts +45 -0
  65. package/src/core/detect/llm-key.ts +89 -0
  66. package/src/core/detect/workspaces.ts +103 -0
  67. package/src/core/errors.ts +4 -4
  68. package/src/core/fix/generator.ts +149 -0
  69. package/src/core/ignore/index.ts +4 -4
  70. package/src/core/mcp/concurrency.ts +16 -0
  71. package/src/core/mcp/handlers/fix-finding.ts +126 -0
  72. package/src/core/mcp/handlers/get-capabilities.ts +62 -0
  73. package/src/core/mcp/handlers/get-findings.ts +36 -0
  74. package/src/core/mcp/handlers/review-diff.ts +65 -0
  75. package/src/core/mcp/handlers/scan-files.ts +65 -0
  76. package/src/core/mcp/handlers/validate-fix.ts +41 -0
  77. package/src/core/mcp/run-store.ts +85 -0
  78. package/src/core/mcp/workspace.ts +35 -0
  79. package/src/core/persist/baseline.ts +112 -0
  80. package/src/core/persist/cost-log.ts +1 -1
  81. package/src/core/persist/findings-cache.ts +1 -1
  82. package/src/core/persist/triage.ts +112 -0
  83. package/src/core/phases/static-rules.ts +18 -5
  84. package/src/core/pipeline/review-phase.ts +65 -26
  85. package/src/core/pipeline/run.ts +42 -10
  86. package/src/core/runtime/lock.ts +2 -2
  87. package/src/core/runtime/state.ts +2 -2
  88. package/src/core/schema-alignment/detector.ts +59 -0
  89. package/src/core/schema-alignment/extractor/index.ts +24 -0
  90. package/src/core/schema-alignment/extractor/prisma.ts +21 -0
  91. package/src/core/schema-alignment/extractor/sql.ts +99 -0
  92. package/src/core/schema-alignment/llm-check.ts +91 -0
  93. package/src/core/schema-alignment/scanner.ts +107 -0
  94. package/src/core/schema-alignment/types.ts +43 -0
  95. package/src/core/shell.ts +3 -3
  96. package/src/core/static-rules/registry.ts +17 -8
  97. package/src/core/static-rules/rules/brand-tokens.ts +145 -0
  98. package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
  99. package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
  100. package/src/core/static-rules/rules/missing-auth.ts +70 -0
  101. package/src/core/static-rules/rules/schema-alignment.ts +132 -0
  102. package/src/core/static-rules/rules/sql-injection.ts +71 -0
  103. package/src/core/static-rules/rules/ssrf.ts +63 -0
  104. package/src/core/static-rules/tailwind-extractor.ts +38 -0
  105. package/src/core/test-gen/coverage-analyzer.ts +93 -0
  106. package/src/core/test-gen/framework-detector.ts +21 -0
  107. package/src/core/test-gen/test-writer.ts +33 -0
  108. package/src/core/ui/design-context-loader.ts +87 -0
  109. package/src/core/worker/client.ts +46 -0
  110. package/src/core/worker/lockfile.ts +38 -0
  111. package/src/core/worker/server.ts +81 -0
  112. package/src/formatters/junit.ts +52 -0
  113. package/src/formatters/sarif.ts +2 -2
  114. package/src/index.ts +1 -2
  115. package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
  116. package/tests/snapshots/index.json +3 -3
  117. package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
  118. package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
  119. package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
  120. package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
  121. package/bin/autopilot.js +0 -20
  122. package/skills/autopilot.md +0 -157
  123. /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  124. /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  125. /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  126. /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  127. /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
  128. /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
  129. /package/{src → scripts}/snapshots/serializer.ts +0 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared LLM API key detection. Used by setup, doctor/preflight, scan, and run so
3
+ * every surface agrees on which env vars count as "have a key."
4
+ *
5
+ * Before this unified helper, doctor only checked ANTHROPIC_API_KEY + OPENAI_API_KEY
6
+ * while setup/scan/run checked all 5 providers — producing contradictory messages
7
+ * ("LLM API key: detected" from setup, "No LLM API key" from doctor moments later).
8
+ */
9
+
10
+ import * as fs from 'node:fs';
11
+
12
+ /** All env var names guardrail recognizes as LLM API keys, ordered by preference. */
13
+ export const LLM_KEY_NAMES = [
14
+ 'ANTHROPIC_API_KEY',
15
+ 'OPENAI_API_KEY',
16
+ 'GEMINI_API_KEY',
17
+ 'GOOGLE_API_KEY',
18
+ 'GROQ_API_KEY',
19
+ ] as const;
20
+
21
+ export type LLMKeyName = typeof LLM_KEY_NAMES[number];
22
+
23
+ export interface KeyDetectionOptions {
24
+ /** Additional key→value map to check alongside process.env (e.g. parsed .env.local). */
25
+ extraEnv?: Record<string, string | undefined>;
26
+ }
27
+
28
+ export interface KeyDetectionResult {
29
+ /** True if any recognized LLM key is set to a non-empty value. */
30
+ hasKey: boolean;
31
+ /** Preferred key that was detected, or null. Follows LLM_KEY_NAMES order. */
32
+ preferred: LLMKeyName | null;
33
+ /** All keys that were detected, in LLM_KEY_NAMES order. */
34
+ detected: LLMKeyName[];
35
+ }
36
+
37
+ function readEnvFileSync(filePath: string): Record<string, string> {
38
+ const vars: Record<string, string> = {};
39
+ try {
40
+ const content = fs.readFileSync(filePath, 'utf-8');
41
+ for (const line of content.split('\n')) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed || trimmed.startsWith('#')) continue;
44
+ const eq = trimmed.indexOf('=');
45
+ if (eq < 0) continue;
46
+ vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, '');
47
+ }
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ return vars;
52
+ }
53
+
54
+ /** Load an env file into a plain object without mutating process.env. */
55
+ export function loadEnvFile(filePath: string): Record<string, string> {
56
+ return readEnvFileSync(filePath);
57
+ }
58
+
59
+ /** Detect whether any recognized LLM API key is set. */
60
+ export function detectLLMKey(options: KeyDetectionOptions = {}): KeyDetectionResult {
61
+ const extra = options.extraEnv ?? {};
62
+ const detected: LLMKeyName[] = [];
63
+ for (const name of LLM_KEY_NAMES) {
64
+ // Treat empty string as "not set" so an env file value can supply the key when
65
+ // the shell has `FOO=` exported. `??` would shadow the env file here because it
66
+ // only falls through on null/undefined — matching the old `!! || !!` semantics.
67
+ const fromProcess = process.env[name];
68
+ const value = (fromProcess && fromProcess.length > 0) ? fromProcess : extra[name];
69
+ if (value && value.length > 0) detected.push(name);
70
+ }
71
+ return {
72
+ hasKey: detected.length > 0,
73
+ preferred: detected[0] ?? null,
74
+ detected,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Human-readable list of providers and signup URLs, used by every "no key" message.
80
+ * Must cover every entry in LLM_KEY_NAMES so users see the same set of options across
81
+ * preflight, setup, scan, and run.
82
+ */
83
+ export const LLM_KEY_HINTS: Array<{ name: LLMKeyName; url: string; note?: string }> = [
84
+ { name: 'ANTHROPIC_API_KEY', url: 'https://console.anthropic.com/' },
85
+ { name: 'OPENAI_API_KEY', url: 'https://platform.openai.com/api-keys' },
86
+ { name: 'GEMINI_API_KEY', url: 'https://aistudio.google.com/app/apikey' },
87
+ { name: 'GOOGLE_API_KEY', url: 'https://aistudio.google.com/app/apikey', note: 'legacy alias for GEMINI_API_KEY' },
88
+ { name: 'GROQ_API_KEY', url: 'https://console.groq.com/keys', note: 'fast free tier' },
89
+ ];
@@ -0,0 +1,103 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ export interface Workspace {
5
+ name: string;
6
+ dir: string; // absolute path
7
+ rel: string; // relative to root
8
+ testCommand?: string;
9
+ }
10
+
11
+ function readJson(p: string): Record<string, unknown> | null {
12
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')) as Record<string, unknown>; } catch { return null; }
13
+ }
14
+
15
+ function globDirs(root: string, patterns: string[]): string[] {
16
+ const results: string[] = [];
17
+ for (const pattern of patterns) {
18
+ // Support "packages/*" and "apps/*" style globs (one level deep only)
19
+ const parts = pattern.split('/');
20
+ if (parts.length === 2 && parts[1] === '*') {
21
+ const base = path.join(root, parts[0]!);
22
+ if (!fs.existsSync(base)) continue;
23
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
24
+ if (entry.isDirectory()) results.push(path.join(base, entry.name));
25
+ }
26
+ } else {
27
+ const abs = path.join(root, pattern);
28
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) results.push(abs);
29
+ }
30
+ }
31
+ return results;
32
+ }
33
+
34
+ function detectTestCommand(dir: string): string | undefined {
35
+ const pkg = readJson(path.join(dir, 'package.json'));
36
+ if (pkg?.scripts && typeof (pkg.scripts as Record<string, unknown>).test === 'string') {
37
+ return `npm test --prefix ${dir}`;
38
+ }
39
+ if (fs.existsSync(path.join(dir, 'go.mod'))) return `go test ./... -C ${dir}`;
40
+ if (fs.existsSync(path.join(dir, 'Cargo.toml'))) return `cargo test --manifest-path ${path.join(dir, 'Cargo.toml')}`;
41
+ return undefined;
42
+ }
43
+
44
+ /** Detect npm/yarn/pnpm workspaces, Turborepo, Nx, Go multi-module. */
45
+ export function detectWorkspaces(cwd: string): Workspace[] | null {
46
+ const pkg = readJson(path.join(cwd, 'package.json')) as { workspaces?: string[] | { packages?: string[] }; name?: string } | null;
47
+
48
+ // npm/yarn workspaces
49
+ let wsDirs: string[] = [];
50
+ if (pkg?.workspaces) {
51
+ const patterns = Array.isArray(pkg.workspaces) ? pkg.workspaces : (pkg.workspaces.packages ?? []);
52
+ wsDirs = globDirs(cwd, patterns);
53
+ }
54
+
55
+ // Turborepo — pnpm-workspace.yaml or turbo.json
56
+ if (wsDirs.length === 0 && fs.existsSync(path.join(cwd, 'pnpm-workspace.yaml'))) {
57
+ try {
58
+ const raw = fs.readFileSync(path.join(cwd, 'pnpm-workspace.yaml'), 'utf8');
59
+ const matches = raw.match(/^\s*-\s*['"]?([^'"#\n]+)['"]?/gm) ?? [];
60
+ const patterns = matches.map(m => m.replace(/^\s*-\s*['"]?/, '').replace(/['"]?\s*$/, '').trim());
61
+ wsDirs = globDirs(cwd, patterns);
62
+ } catch { /* ignore */ }
63
+ }
64
+
65
+ // Turborepo fallback: packages/ and apps/ dirs
66
+ if (wsDirs.length === 0 && fs.existsSync(path.join(cwd, 'turbo.json'))) {
67
+ wsDirs = globDirs(cwd, ['packages/*', 'apps/*']);
68
+ }
69
+
70
+ // Nx: check nx.json + libs/ + apps/
71
+ if (wsDirs.length === 0 && fs.existsSync(path.join(cwd, 'nx.json'))) {
72
+ wsDirs = globDirs(cwd, ['libs/*', 'apps/*', 'packages/*']);
73
+ }
74
+
75
+ if (wsDirs.length === 0) return null;
76
+
77
+ return wsDirs
78
+ .filter(d => fs.existsSync(d))
79
+ .map(d => {
80
+ const rel = path.relative(cwd, d);
81
+ const pkgJson = readJson(path.join(d, 'package.json')) as { name?: string } | null;
82
+ return {
83
+ name: pkgJson?.name ?? rel,
84
+ dir: d,
85
+ rel,
86
+ testCommand: detectTestCommand(d),
87
+ };
88
+ });
89
+ }
90
+
91
+ /** Given a list of touched files, return which workspaces they belong to. */
92
+ export function mapFilesToWorkspaces(files: string[], workspaces: Workspace[], cwd: string): Map<Workspace, string[]> {
93
+ const result = new Map<Workspace, string[]>();
94
+ for (const file of files) {
95
+ const abs = path.isAbsolute(file) ? file : path.resolve(cwd, file);
96
+ const ws = workspaces.find(w => abs.startsWith(w.dir + path.sep) || abs === w.dir);
97
+ if (ws) {
98
+ if (!result.has(ws)) result.set(ws, []);
99
+ result.get(ws)!.push(file);
100
+ }
101
+ }
102
+ return result;
103
+ }
@@ -4,7 +4,7 @@ export type ErrorCode =
4
4
  | 'auth' | 'rate_limit' | 'transient_network' | 'invalid_config'
5
5
  | 'adapter_bug' | 'user_input' | 'budget_exceeded' | 'concurrency_lock' | 'superseded';
6
6
 
7
- export interface AutopilotErrorOptions {
7
+ export interface GuardrailErrorOptions {
8
8
  code: ErrorCode;
9
9
  retryable?: boolean;
10
10
  provider?: string;
@@ -18,16 +18,16 @@ const DEFAULT_RETRYABLE: Record<ErrorCode, boolean> = {
18
18
  concurrency_lock: false, superseded: false,
19
19
  };
20
20
 
21
- export class AutopilotError extends Error {
21
+ export class GuardrailError extends Error {
22
22
  code: ErrorCode;
23
23
  retryable: boolean;
24
24
  provider?: string;
25
25
  step?: string;
26
26
  details: Record<string, unknown>;
27
27
 
28
- constructor(message: string, options: AutopilotErrorOptions) {
28
+ constructor(message: string, options: GuardrailErrorOptions) {
29
29
  super(message);
30
- this.name = 'AutopilotError';
30
+ this.name = 'GuardrailError';
31
31
  this.code = options.code;
32
32
  this.retryable = options.retryable ?? DEFAULT_RETRYABLE[options.code];
33
33
  this.provider = options.provider;
@@ -0,0 +1,149 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { Finding } from '../findings/types.ts';
4
+ import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
5
+
6
+ export const CONTEXT_LINES = 20;
7
+
8
+ // LLM error / refusal phrases that indicate a bad output
9
+ const REFUSAL_PHRASES = [
10
+ 'i cannot', "i can't", 'i am unable', 'as an ai', 'as a language model',
11
+ 'i apologize', "i'm sorry", 'cannot safely', 'would require', 'error:',
12
+ ];
13
+
14
+ export interface GenerateResult {
15
+ status: 'ok' | 'cannot_fix' | 'rejected' | 'error';
16
+ reason?: string;
17
+ originalLines?: string[];
18
+ replacementLines?: string[];
19
+ startLine?: number;
20
+ endLine?: number;
21
+ }
22
+
23
+ export function validateReplacement(original: string[], replacement: string[], _finding: Finding): string | null {
24
+ if (replacement.length === 0) return 'LLM returned empty output';
25
+
26
+ // Reject obvious LLM refusals
27
+ const joined = replacement.join(' ').toLowerCase();
28
+ for (const phrase of REFUSAL_PHRASES) {
29
+ if (joined.includes(phrase)) return `LLM refused: "${replacement[0]?.slice(0, 60)}"`;
30
+ }
31
+
32
+ // Reject if line count ballooned more than 3x (likely hallucination)
33
+ if (replacement.length > original.length * 3 + 10) {
34
+ return `Suspicious: replacement is ${replacement.length} lines vs original ${original.length}`;
35
+ }
36
+
37
+ // Reject if the replacement is identical (LLM made no change)
38
+ if (replacement.join('\n') === original.join('\n')) {
39
+ return 'LLM returned identical code — no change made';
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ export function buildUnifiedDiff(
46
+ original: string[],
47
+ replacement: string[],
48
+ filePath: string,
49
+ startLine: number,
50
+ opts: { color?: boolean } = {},
51
+ ): string {
52
+ // Colors default on for CLI ergonomics; MCP callers pass color:false so ANSI
53
+ // escapes don't leak into JSON responses that machine clients must parse.
54
+ const useColor = opts.color !== false;
55
+ const C = {
56
+ reset: '\x1b[0m', green: '\x1b[32m', red: '\x1b[31m',
57
+ };
58
+ const fmt = (c: keyof typeof C, t: string) => useColor ? `${C[c]}${t}${C.reset}` : t;
59
+ const lines: string[] = [`--- ${filePath}`, `+++ ${filePath} (proposed fix)`, `@@ -${startLine},${original.length} +${startLine},${replacement.length} @@`];
60
+ for (const l of original) lines.push(fmt('red', `- ${l}`));
61
+ for (const l of replacement) lines.push(fmt('green', `+ ${l}`));
62
+ return lines.join('\n');
63
+ }
64
+
65
+ export async function generateFix(finding: Finding, engine: ReviewEngine, cwd: string): Promise<GenerateResult> {
66
+ // MCP handlers can pass through findings loaded from disk — validate required
67
+ // fields rather than trusting the CLI pre-filter path.
68
+ if (!finding.file || typeof finding.file !== 'string') {
69
+ return { status: 'cannot_fix', reason: 'finding.file missing' };
70
+ }
71
+ if (typeof finding.line !== 'number' || !Number.isFinite(finding.line) || finding.line < 1) {
72
+ return { status: 'cannot_fix', reason: 'finding.line missing or invalid' };
73
+ }
74
+
75
+ const absPath = path.resolve(cwd, finding.file);
76
+ let fileLines: string[];
77
+ try {
78
+ fileLines = fs.readFileSync(absPath, 'utf8').split('\n');
79
+ } catch {
80
+ return { status: 'cannot_fix', reason: 'file not readable' };
81
+ }
82
+
83
+ const lineIdx = finding.line - 1;
84
+ if (lineIdx < 0 || lineIdx >= fileLines.length) {
85
+ return { status: 'cannot_fix', reason: 'line out of range' };
86
+ }
87
+
88
+ const startIdx = Math.max(0, lineIdx - CONTEXT_LINES);
89
+ const endIdx = Math.min(fileLines.length - 1, lineIdx + CONTEXT_LINES);
90
+ const contextLines = fileLines.slice(startIdx, endIdx + 1);
91
+ const startLine = startIdx + 1;
92
+
93
+ const numbered = contextLines.map((l, i) => {
94
+ const n = startLine + i;
95
+ return `${n === finding.line ? '>>>' : ' '} ${String(n).padStart(4)}: ${l}`;
96
+ }).join('\n');
97
+
98
+ const prompt = [
99
+ `File: ${finding.file}`,
100
+ `Finding (line ${finding.line}): [${finding.severity.toUpperCase()}] ${finding.message}`,
101
+ finding.suggestion ? `Suggestion: ${finding.suggestion}` : '',
102
+ '',
103
+ 'Relevant lines (>>> marks the finding):',
104
+ '```',
105
+ numbered,
106
+ '```',
107
+ '',
108
+ `Rewrite ONLY lines ${startLine}–${endIdx + 1} to fix this finding.`,
109
+ 'Rules:',
110
+ '- Output ONLY the replacement lines with no explanation, no markdown fences, no line numbers',
111
+ '- Preserve indentation exactly',
112
+ '- Make the minimal change needed — do not refactor unrelated code',
113
+ '- If the fix cannot be done safely in this context, output exactly: CANNOT_FIX',
114
+ ].filter(Boolean).join('\n');
115
+
116
+ let rawOutput: string;
117
+ try {
118
+ const output = await engine.review({ content: prompt, kind: 'file-batch' });
119
+ rawOutput = output.rawOutput.trim();
120
+ } catch (err) {
121
+ return { status: 'error', reason: `LLM error: ${err instanceof Error ? err.message : String(err)}` };
122
+ }
123
+
124
+ if (rawOutput === 'CANNOT_FIX' || rawOutput.startsWith('CANNOT_FIX')) {
125
+ return { status: 'cannot_fix', reason: 'LLM: cannot fix safely in this context' };
126
+ }
127
+
128
+ // Strip markdown fences if the model added them despite instructions
129
+ const cleaned = rawOutput
130
+ .replace(/^```[a-zA-Z]*\n?/, '')
131
+ .replace(/\n?```$/, '')
132
+ .trimEnd();
133
+
134
+ const replacementLines = cleaned.split('\n');
135
+ const originalLines = contextLines;
136
+
137
+ const validationError = validateReplacement(originalLines, replacementLines, finding);
138
+ if (validationError) {
139
+ return { status: 'rejected', reason: validationError };
140
+ }
141
+
142
+ return {
143
+ status: 'ok',
144
+ originalLines,
145
+ replacementLines,
146
+ startLine,
147
+ endLine: endIdx + 1,
148
+ };
149
+ }
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { minimatch } from 'minimatch';
4
4
  import type { Finding } from '../findings/types.ts';
5
- import type { AutopilotConfig } from '../config/types.ts';
5
+ import type { GuardrailConfig } from '../config/types.ts';
6
6
 
7
7
  export interface IgnoreRule {
8
8
  ruleId: string | '*'; // finding id prefix or '*' for any
@@ -10,7 +10,7 @@ export interface IgnoreRule {
10
10
  }
11
11
 
12
12
  export function loadIgnoreRules(cwd: string): IgnoreRule[] {
13
- const filePath = path.join(cwd, '.autopilot-ignore');
13
+ const filePath = path.join(cwd, '.guardrail-ignore');
14
14
  if (!fs.existsSync(filePath)) return [];
15
15
 
16
16
  const rules: IgnoreRule[] = [];
@@ -37,8 +37,8 @@ function matchesRule(finding: Finding, rule: IgnoreRule): boolean {
37
37
  return minimatch(finding.file.replace(/\\/g, '/'), rule.pathGlob, { matchBase: true });
38
38
  }
39
39
 
40
- /** Convert `ignore:` entries from autopilot.config.yaml into IgnoreRules. */
41
- export function parseConfigIgnore(entries: AutopilotConfig['ignore']): IgnoreRule[] {
40
+ /** Convert `ignore:` entries from guardrail.config.yaml into IgnoreRules. */
41
+ export function parseConfigIgnore(entries: GuardrailConfig['ignore']): IgnoreRule[] {
42
42
  if (!entries || entries.length === 0) return [];
43
43
  return entries.map(entry => {
44
44
  if (typeof entry === 'string') {
@@ -0,0 +1,16 @@
1
+ const mutexes = new Map<string, Promise<void>>();
2
+
3
+ export async function withWriteLock<T>(workspace: string, fn: () => Promise<T>): Promise<T> {
4
+ let unlock!: () => void;
5
+ const current = new Promise<void>(resolve => { unlock = resolve; });
6
+ const prev = mutexes.get(workspace) ?? Promise.resolve();
7
+ mutexes.set(workspace, current);
8
+
9
+ await prev;
10
+ try {
11
+ return await fn();
12
+ } finally {
13
+ unlock();
14
+ if (mutexes.get(workspace) === current) mutexes.delete(workspace);
15
+ }
16
+ }
@@ -0,0 +1,126 @@
1
+ // src/core/mcp/handlers/fix-finding.ts
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { resolveWorkspace, assertInWorkspace } from '../workspace.ts';
5
+ import { loadRun, checksumFile } from '../run-store.ts';
6
+ import { withWriteLock } from '../concurrency.ts';
7
+ import { generateFix, buildUnifiedDiff } from '../../fix/generator.ts';
8
+ import type { ReviewEngine } from '../../../adapters/review-engine/types.ts';
9
+ import type { GuardrailConfig } from '../../config/types.ts';
10
+
11
+ export interface FixFindingResult {
12
+ schema_version: 1;
13
+ status: 'fixed' | 'reverted' | 'human_required' | 'skipped';
14
+ reason?: string;
15
+ patch?: string;
16
+ commitSha?: string;
17
+ appliedFiles: string[];
18
+ }
19
+
20
+ export async function handleFixFinding(
21
+ input: { run_id: string; finding_id: string; cwd?: string; dry_run?: boolean },
22
+ config: GuardrailConfig,
23
+ engine: ReviewEngine,
24
+ ): Promise<FixFindingResult> {
25
+ const workspace = resolveWorkspace(input.cwd);
26
+
27
+ const record = loadRun(workspace, input.run_id);
28
+ if (!record) {
29
+ throw Object.assign(
30
+ new Error(`run_not_found: no run with id "${input.run_id}"`),
31
+ { code: 'run_not_found' },
32
+ );
33
+ }
34
+
35
+ const finding = record.findings.find(f => f.id === input.finding_id);
36
+ if (!finding) {
37
+ throw Object.assign(
38
+ new Error(`finding_not_found: no finding with id "${input.finding_id}"`),
39
+ { code: 'finding_not_found' },
40
+ );
41
+ }
42
+
43
+ // Protected path check
44
+ if (finding.protectedPath) {
45
+ return { schema_version: 1, status: 'human_required', reason: 'protected_path', appliedFiles: [] };
46
+ }
47
+
48
+ // Validate finding.file against workspace boundary (run records could be tampered)
49
+ const absFile = assertInWorkspace(workspace, finding.file);
50
+
51
+ // Look up checksum under both the raw finding.file and its relative form,
52
+ // so the drift check works whether older runs stored absolute or relative keys.
53
+ const relKey = path.relative(workspace, absFile);
54
+ const savedChecksum = record.fileChecksums[finding.file] ?? record.fileChecksums[relKey] ?? '';
55
+
56
+ // Dry-run path: generate fix, return patch, no mutations
57
+ if (input.dry_run) {
58
+ const currentChecksum = checksumFile(absFile);
59
+ if (savedChecksum && currentChecksum !== savedChecksum) {
60
+ return { schema_version: 1, status: 'human_required', reason: 'file_changed', appliedFiles: [] };
61
+ }
62
+ const genResult = await generateFix(finding, engine, workspace);
63
+ if (genResult.status !== 'ok') {
64
+ return { schema_version: 1, status: 'human_required', reason: genResult.reason, appliedFiles: [] };
65
+ }
66
+ const patch = buildUnifiedDiff(
67
+ genResult.originalLines!,
68
+ genResult.replacementLines!,
69
+ finding.file,
70
+ genResult.startLine!,
71
+ { color: false }, // MCP clients parse patch text — no ANSI
72
+ );
73
+ return { schema_version: 1, status: 'skipped', reason: 'dry_run', patch, appliedFiles: [] };
74
+ }
75
+
76
+ // Apply path: checksum validation + fix generation run INSIDE the lock to
77
+ // prevent TOCTOU between two concurrent fix_finding calls on the same file.
78
+ return withWriteLock(workspace, async () => {
79
+ const currentChecksum = checksumFile(absFile);
80
+ if (savedChecksum && currentChecksum !== savedChecksum) {
81
+ return { schema_version: 1 as const, status: 'human_required' as const, reason: 'file_changed', appliedFiles: [] };
82
+ }
83
+
84
+ const genResult = await generateFix(finding, engine, workspace);
85
+ if (genResult.status !== 'ok') {
86
+ return { schema_version: 1 as const, status: 'human_required' as const, reason: genResult.reason, appliedFiles: [] };
87
+ }
88
+
89
+ const patch = buildUnifiedDiff(
90
+ genResult.originalLines!,
91
+ genResult.replacementLines!,
92
+ finding.file,
93
+ genResult.startLine!,
94
+ { color: false },
95
+ );
96
+
97
+ const originalContent = fs.readFileSync(absFile, 'utf8');
98
+ const allLines = originalContent.split('\n');
99
+ const newLines = [
100
+ ...allLines.slice(0, genResult.startLine! - 1),
101
+ ...genResult.replacementLines!,
102
+ ...allLines.slice(genResult.endLine!),
103
+ ];
104
+
105
+ const tmpFile = absFile + '.guardrail.tmp';
106
+ fs.writeFileSync(tmpFile, newLines.join('\n'), 'utf8');
107
+ fs.renameSync(tmpFile, absFile);
108
+
109
+ // Test verification
110
+ if (config.testCommand) {
111
+ const { spawnSync } = await import('node:child_process');
112
+ const testResult = spawnSync('/bin/sh', ['-c', config.testCommand], {
113
+ cwd: workspace,
114
+ shell: false,
115
+ timeout: 120_000,
116
+ encoding: 'utf8',
117
+ });
118
+ if (testResult.status !== 0) {
119
+ fs.writeFileSync(absFile, originalContent, 'utf8');
120
+ return { schema_version: 1 as const, status: 'reverted' as const, patch, appliedFiles: [] };
121
+ }
122
+ }
123
+
124
+ return { schema_version: 1 as const, status: 'fixed' as const, patch, appliedFiles: [finding.file] };
125
+ });
126
+ }
@@ -0,0 +1,62 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as child_process from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolveWorkspace } from '../workspace.ts';
6
+ import type { GuardrailConfig, StaticRuleReference } from '../../config/types.ts';
7
+
8
+ export interface CapabilitiesResult {
9
+ schema_version: 1;
10
+ adapter: string;
11
+ enabledRules: string[];
12
+ writeable: boolean;
13
+ gitAvailable: boolean;
14
+ testCommandConfigured: boolean;
15
+ guardrailVersion: string;
16
+ }
17
+
18
+ function readVersion(): string {
19
+ try {
20
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../../package.json');
21
+ return (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string }).version;
22
+ } catch {
23
+ return 'unknown';
24
+ }
25
+ }
26
+
27
+ function isGitAvailable(workspace: string): boolean {
28
+ try {
29
+ // Safe: no user input, static arguments only
30
+ child_process.execFileSync('git', ['rev-parse', '--git-dir'], {
31
+ cwd: workspace,
32
+ stdio: 'ignore',
33
+ });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function extractRuleName(rule: StaticRuleReference): string {
41
+ return typeof rule === 'string' ? rule : rule.adapter;
42
+ }
43
+
44
+ export async function handleGetCapabilities(
45
+ input: { cwd?: string },
46
+ config: GuardrailConfig,
47
+ adapterName: string,
48
+ ): Promise<CapabilitiesResult> {
49
+ const workspace = resolveWorkspace(input.cwd);
50
+ const staticRules = config.staticRules ?? [];
51
+ const enabledRules = staticRules.map(extractRuleName);
52
+
53
+ return {
54
+ schema_version: 1,
55
+ adapter: adapterName,
56
+ enabledRules,
57
+ writeable: true,
58
+ gitAvailable: isGitAvailable(workspace),
59
+ testCommandConfigured: !!config.testCommand,
60
+ guardrailVersion: readVersion(),
61
+ };
62
+ }
@@ -0,0 +1,36 @@
1
+ import { resolveWorkspace } from '../workspace.ts';
2
+ import { loadRun } from '../run-store.ts';
3
+ import type { Finding, Severity } from '../../findings/types.ts';
4
+
5
+ export interface GetFindingsResult {
6
+ schema_version: 1;
7
+ run_id: string;
8
+ findings: Finding[];
9
+ cachedAt: string;
10
+ }
11
+
12
+ // Severity order: index 0 = highest priority
13
+ const SEVERITY_ORDER = ['critical', 'warning', 'note'] as const;
14
+
15
+ export async function handleGetFindings(input: {
16
+ run_id: string;
17
+ severity?: Severity;
18
+ cwd?: string;
19
+ }): Promise<GetFindingsResult> {
20
+ const workspace = resolveWorkspace(input.cwd);
21
+ const record = loadRun(workspace, input.run_id);
22
+ if (!record) {
23
+ throw Object.assign(
24
+ new Error(`run_not_found: no run with id "${input.run_id}"`),
25
+ { code: 'run_not_found' }
26
+ );
27
+ }
28
+
29
+ let findings = record.findings;
30
+ if (input.severity) {
31
+ const minIdx = SEVERITY_ORDER.indexOf(input.severity);
32
+ findings = findings.filter(f => SEVERITY_ORDER.indexOf(f.severity) <= minIdx);
33
+ }
34
+
35
+ return { schema_version: 1, run_id: input.run_id, findings, cachedAt: record.createdAt };
36
+ }