praxis-sec 1.0.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/LICENSE +21 -0
- package/README.md +170 -0
- package/ai-defense/cost-protection.md +292 -0
- package/ai-defense/llm-security-checklist.md +324 -0
- package/ai-defense/prompt-injection-patterns.js +283 -0
- package/ai-defense/system-prompt-armor.md +327 -0
- package/checklists/launch-day.md +168 -0
- package/cli/agents/abom-generator.js +225 -0
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +787 -0
- package/cli/agents/agent-telemetry-agent.js +415 -0
- package/cli/agents/agentic-security-agent.js +296 -0
- package/cli/agents/agentic-supply-chain-agent.js +463 -0
- package/cli/agents/ai-infra-inventory-agent.js +449 -0
- package/cli/agents/api-fuzzer.js +345 -0
- package/cli/agents/auth-bypass-agent.js +348 -0
- package/cli/agents/base-agent.js +280 -0
- package/cli/agents/cicd-scanner.js +300 -0
- package/cli/agents/config-auditor.js +757 -0
- package/cli/agents/deep-analyzer.js +776 -0
- package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/git-history-scanner.js +169 -0
- package/cli/agents/governance-audits.js +138 -0
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/html-reporter.js +1125 -0
- package/cli/agents/index.js +147 -0
- package/cli/agents/injection-tester.js +502 -0
- package/cli/agents/legal-risk-agent.js +328 -0
- package/cli/agents/llm-redteam.js +199 -0
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/mcp-security-agent.js +588 -0
- package/cli/agents/memory-poisoning-agent.js +305 -0
- package/cli/agents/mobile-scanner.js +231 -0
- package/cli/agents/model-file-scanner.js +259 -0
- package/cli/agents/orchestrator.js +355 -0
- package/cli/agents/pii-compliance-agent.js +301 -0
- package/cli/agents/policy-engine.js +229 -0
- package/cli/agents/prompt-injection-prober.js +224 -0
- package/cli/agents/rag-security-agent.js +204 -0
- package/cli/agents/recon-agent.js +207 -0
- package/cli/agents/sbom-generator.js +265 -0
- package/cli/agents/scoring-engine.js +273 -0
- package/cli/agents/ssrf-prober.js +130 -0
- package/cli/agents/stateful-watcher.js +238 -0
- package/cli/agents/supabase-rls-agent.js +154 -0
- package/cli/agents/supply-chain-agent.js +857 -0
- package/cli/agents/swarm-orchestrator.js +200 -0
- package/cli/agents/verifier-agent.js +303 -0
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/praxis.js +866 -0
- package/cli/commands/abom.js +73 -0
- package/cli/commands/agent-fix.js +1245 -0
- package/cli/commands/audit.js +1180 -0
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/baseline.js +193 -0
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/checklist.js +223 -0
- package/cli/commands/ci.js +403 -0
- package/cli/commands/deps.js +516 -0
- package/cli/commands/diff.js +200 -0
- package/cli/commands/doctor.js +195 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/fix.js +218 -0
- package/cli/commands/guard.js +396 -0
- package/cli/commands/hooks.js +278 -0
- package/cli/commands/init.js +514 -0
- package/cli/commands/legal.js +158 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/mcp.js +660 -0
- package/cli/commands/openclaw.js +386 -0
- package/cli/commands/red-team.js +350 -0
- package/cli/commands/redteam.js +78 -0
- package/cli/commands/remediate.js +797 -0
- package/cli/commands/rotate.js +768 -0
- package/cli/commands/rules.js +196 -0
- package/cli/commands/scan-mcp.js +534 -0
- package/cli/commands/scan-skill.js +588 -0
- package/cli/commands/scan-standard.js +251 -0
- package/cli/commands/scan.js +524 -0
- package/cli/commands/score.js +449 -0
- package/cli/commands/shell.js +514 -0
- package/cli/commands/team-report.js +398 -0
- package/cli/commands/undo.js +161 -0
- package/cli/commands/update-intel.js +126 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +757 -0
- package/cli/commands/web.js +63 -0
- package/cli/core/ast/guardrail-detector.js +141 -0
- package/cli/core/ast/index.js +22 -0
- package/cli/core/ast/parser.js +676 -0
- package/cli/core/ast/scope-tree.js +287 -0
- package/cli/core/ast/taint-tracker.js +158 -0
- package/cli/core/branding.js +37 -0
- package/cli/core/env.js +38 -0
- package/cli/core/errors.js +61 -0
- package/cli/core/fs.js +62 -0
- package/cli/core/output/compliance.js +90 -0
- package/cli/core/output/html-theme.js +158 -0
- package/cli/core/output/index.js +57 -0
- package/cli/core/output/json.js +48 -0
- package/cli/core/output/sarif.js +240 -0
- package/cli/core/version.js +67 -0
- package/cli/core/web/jobs.js +183 -0
- package/cli/core/web/projects.js +146 -0
- package/cli/core/web/server.js +439 -0
- package/cli/data/atlas-knowledge.json +5640 -0
- package/cli/data/eaa-catalog.json +39 -0
- package/cli/data/known-mcps.json +26 -0
- package/cli/data/probes/prompt-injection-corpus.json +271 -0
- package/cli/data/threat-intel.json +85 -0
- package/cli/data/threatpacks/latest.json +41 -0
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +90 -0
- package/cli/providers/llm-provider.js +766 -0
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/cache-manager.js +310 -0
- package/cli/utils/compliance-map.js +191 -0
- package/cli/utils/entropy.js +132 -0
- package/cli/utils/fix-ledger.js +127 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/intel/cache.js +61 -0
- package/cli/utils/intel/http.js +88 -0
- package/cli/utils/intel/index.js +235 -0
- package/cli/utils/intel/merge.js +229 -0
- package/cli/utils/intel/sources/epss.js +54 -0
- package/cli/utils/intel/sources/ghsa.js +81 -0
- package/cli/utils/intel/sources/gitguardian.js +40 -0
- package/cli/utils/intel/sources/gitleaks.js +101 -0
- package/cli/utils/intel/sources/kev.js +38 -0
- package/cli/utils/intel/sources/nvd.js +84 -0
- package/cli/utils/intel/sources/osv.js +132 -0
- package/cli/utils/intel/sources/phylum.js +44 -0
- package/cli/utils/intel/sources/snyk.js +46 -0
- package/cli/utils/intel/sources/socket.js +69 -0
- package/cli/utils/intel/sources/sonatype.js +84 -0
- package/cli/utils/intel/sources/threatpack.js +69 -0
- package/cli/utils/mcp-trust.js +60 -0
- package/cli/utils/output.js +251 -0
- package/cli/utils/patterns.js +1130 -0
- package/cli/utils/pdf-generator.js +94 -0
- package/cli/utils/plugin-loader.js +364 -0
- package/cli/utils/rule-import.js +228 -0
- package/cli/utils/rule-registry.js +426 -0
- package/cli/utils/scan-fingerprint.js +109 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/score-history.js +119 -0
- package/cli/utils/secrets-verifier.js +247 -0
- package/cli/utils/security-memory.js +296 -0
- package/cli/utils/standards/atlas-knowledge.js +87 -0
- package/cli/utils/standards/index.js +127 -0
- package/cli/utils/standards/sources/avid.js +45 -0
- package/cli/utils/standards/sources/eu-ai-act.js +89 -0
- package/cli/utils/standards/sources/google-saif.js +39 -0
- package/cli/utils/standards/sources/iso-42001.js +94 -0
- package/cli/utils/standards/sources/mitre-atlas.js +54 -0
- package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
- package/cli/utils/standards/sources/owasp-llm.js +45 -0
- package/cli/utils/standards/sources/owasp-ml.js +45 -0
- package/cli/utils/threat-intel.js +265 -0
- package/configs/firebase/firestore-rules.txt +215 -0
- package/configs/firebase/security-checklist.md +236 -0
- package/configs/firebase/storage-rules.txt +206 -0
- package/configs/gitignore-template +258 -0
- package/configs/nextjs-security-headers.js +220 -0
- package/configs/praxisignore-template +50 -0
- package/configs/supabase/secure-client.ts +225 -0
- package/configs/supabase/security-checklist.md +278 -0
- package/docs/THIRD_PARTY_NOTICES.md +26 -0
- package/docs/THREAT_INTEL.md +292 -0
- package/docs/USAGE.md +1205 -0
- package/docs/design/WEB-UI.md +82 -0
- package/package.json +71 -0
- package/scripts/check-determinism.mjs +119 -0
- package/snippets/README.md +122 -0
- package/snippets/api-security/api-security-checklist.md +412 -0
- package/snippets/api-security/cors-config.ts +322 -0
- package/snippets/api-security/input-validation.ts +430 -0
- package/snippets/auth/jwt-checklist.md +322 -0
- package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
- package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Security Agent — Interactive Fix Loop
|
|
3
|
+
* ================================================
|
|
4
|
+
*
|
|
5
|
+
* Scans your codebase, then for each affected file:
|
|
6
|
+
* 1. Generates a precise multi-edit fix plan via LLM (one plan per file,
|
|
7
|
+
* addressing every finding in that file at once)
|
|
8
|
+
* 2. Shows you exactly what it will change (unified diff with line numbers)
|
|
9
|
+
* 3. Asks you to accept, skip, or quit
|
|
10
|
+
* 4. Applies the changes atomically
|
|
11
|
+
* 5. Re-scans to verify the findings are resolved
|
|
12
|
+
* 6. Logs every change to .praxis/fixes.jsonl
|
|
13
|
+
*
|
|
14
|
+
* USAGE:
|
|
15
|
+
* praxis agent [path] Interactive fix loop
|
|
16
|
+
* praxis agent . --plan-only Generate plans, never write
|
|
17
|
+
* praxis agent . --severity high Only fix high+ severity
|
|
18
|
+
* praxis agent . --branch fixes Create a branch, commit per file
|
|
19
|
+
* praxis agent . --pr After fixing, push and open a PR
|
|
20
|
+
* praxis agent . --provider deepseek-flash
|
|
21
|
+
*
|
|
22
|
+
* SAFETY:
|
|
23
|
+
* - Refuses to operate on a dirty git tree (use --allow-dirty to override)
|
|
24
|
+
* - Always shows a diff before any write
|
|
25
|
+
* - Re-scans after each batch to verify the fix
|
|
26
|
+
* - Plans may create new files (e.g., .env.example) but cannot edit
|
|
27
|
+
* .env, secrets, lockfiles, or build artifacts
|
|
28
|
+
* - Every applied change is logged for audit & undo (`praxis undo`)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'fs';
|
|
32
|
+
import { fileURLToPath } from 'url';
|
|
33
|
+
import path, { dirname } from 'path';
|
|
34
|
+
import { createInterface } from 'readline';
|
|
35
|
+
|
|
36
|
+
const __filename = fileURLToPath(import.meta.url); // praxis-ignore — module's own path via import.meta.url, not user input
|
|
37
|
+
const __dirname = dirname(__filename);
|
|
38
|
+
const praxisDir = path.resolve(__dirname, '../..');
|
|
39
|
+
import { execFileSync } from 'child_process';
|
|
40
|
+
import chalk from 'chalk';
|
|
41
|
+
import ora from 'ora';
|
|
42
|
+
import { autoDetectProvider } from '../providers/llm-provider.js';
|
|
43
|
+
import { auditCommand } from './audit.js';
|
|
44
|
+
import { ASTParser } from '../core/ast/index.js';
|
|
45
|
+
import * as output from '../utils/output.js';
|
|
46
|
+
|
|
47
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
48
|
+
const NEVER_EDIT = [
|
|
49
|
+
/(^|\/)\.env(\.|$)/i,
|
|
50
|
+
/\.pem$|\.key$|\.p12$|\.pfx$/i,
|
|
51
|
+
/package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
|
|
52
|
+
/(^|\/)node_modules\//,
|
|
53
|
+
/(^|\/)dist\//,
|
|
54
|
+
/(^|\/)build\//,
|
|
55
|
+
/\.min\.(js|css)$/,
|
|
56
|
+
];
|
|
57
|
+
// Files the agent IS allowed to create or update freely (companions to fixes)
|
|
58
|
+
const SAFE_NEW_FILES = [
|
|
59
|
+
/(^|\/)\.env\.example$/i,
|
|
60
|
+
/(^|\/)\.env\.sample$/i,
|
|
61
|
+
/(^|\/)\.gitignore$/i,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const FIX_LOG_DIR = '.praxis';
|
|
65
|
+
const FIX_LOG_FILE = 'fixes.jsonl';
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// MAIN
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
export async function agentFixCommand(targetPath = '.', options = {}) {
|
|
72
|
+
const root = path.resolve(targetPath);
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(root)) {
|
|
75
|
+
output.error(`Path does not exist: ${root}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log();
|
|
80
|
+
output.header('Praxis — Security Agent');
|
|
81
|
+
console.log(chalk.gray(' I will scan, plan each fix, ask before changing anything,'));
|
|
82
|
+
console.log(chalk.gray(' and verify the fix worked. You stay in control.'));
|
|
83
|
+
console.log();
|
|
84
|
+
|
|
85
|
+
// ── Git safety check ─────────────────────────────────────────────────────
|
|
86
|
+
const initialBranch = getCurrentBranch(root);
|
|
87
|
+
if (!options.allowDirty) {
|
|
88
|
+
const state = checkGitState(root);
|
|
89
|
+
if (state === 'not-a-repo') {
|
|
90
|
+
console.log(chalk.yellow(' Note: this is not a git repository.'));
|
|
91
|
+
console.log(chalk.gray(' Changes cannot be reverted automatically.'));
|
|
92
|
+
if (options.ci) {
|
|
93
|
+
console.log(chalk.gray(' CI mode active: automatically continuing.'));
|
|
94
|
+
} else {
|
|
95
|
+
const ok = await confirm(' Continue anyway?');
|
|
96
|
+
if (!ok) { console.log(chalk.gray(' Aborted.\n')); return; }
|
|
97
|
+
}
|
|
98
|
+
} else if (state === 'dirty') {
|
|
99
|
+
output.error('Working tree has uncommitted changes.');
|
|
100
|
+
console.log(chalk.gray(' Commit or stash first, or pass --allow-dirty.'));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Optional branch isolation ────────────────────────────────────────────
|
|
106
|
+
let branchCreated = null;
|
|
107
|
+
if (options.branch) {
|
|
108
|
+
if (!initialBranch) {
|
|
109
|
+
console.log(chalk.yellow(' --branch requires a git repository. Skipping branch creation.'));
|
|
110
|
+
} else {
|
|
111
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
|
112
|
+
const branchName = options.branch === true
|
|
113
|
+
? `praxis/fixes-${stamp}`
|
|
114
|
+
: String(options.branch);
|
|
115
|
+
try {
|
|
116
|
+
execFileSync('git', ['checkout', '-b', branchName], { cwd: root, stdio: 'pipe' });
|
|
117
|
+
branchCreated = branchName;
|
|
118
|
+
console.log(chalk.gray(` Branch: ${chalk.cyan(branchName)}`));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
output.error(`Could not create branch ${branchName}: ${err.message}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Load LLM provider ────────────────────────────────────────────────────
|
|
127
|
+
const provider = autoDetectProvider(root, {
|
|
128
|
+
provider: options.provider,
|
|
129
|
+
model: options.model,
|
|
130
|
+
think: options.think || false,
|
|
131
|
+
});
|
|
132
|
+
if (!provider) {
|
|
133
|
+
output.error('No LLM provider available.');
|
|
134
|
+
console.log(chalk.gray(' Set one of: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, MOONSHOT_API_KEY, XAI_API_KEY'));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
console.log(chalk.gray(` Provider: ${chalk.cyan(provider.name)}`));
|
|
138
|
+
|
|
139
|
+
if (options.sandbox) {
|
|
140
|
+
console.log(chalk.gray(' Sandbox: ' + chalk.green('active') + ' (verifying fixes in Docker container)'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Run the scan ─────────────────────────────────────────────────────────
|
|
144
|
+
const scanSpinner = ora({ text: 'Scanning for issues...', color: 'cyan' }).start();
|
|
145
|
+
let scanResult;
|
|
146
|
+
try {
|
|
147
|
+
scanResult = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
scanSpinner.fail('Scan failed');
|
|
150
|
+
output.error(err.message);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
scanSpinner.stop();
|
|
154
|
+
|
|
155
|
+
// ── Filter findings ──────────────────────────────────────────────────────
|
|
156
|
+
const minSev = options.severity || 'low';
|
|
157
|
+
const minRank = SEV_RANK[minSev] ?? 1;
|
|
158
|
+
|
|
159
|
+
const findings = (scanResult.findings ?? []).filter(f => {
|
|
160
|
+
if (!f.file) return false;
|
|
161
|
+
if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
|
|
162
|
+
const rel = f.file.replace(/\\/g, '/');
|
|
163
|
+
if (NEVER_EDIT.some(p => p.test(rel))) return false;
|
|
164
|
+
const abs = path.resolve(root, f.file);
|
|
165
|
+
return fs.existsSync(abs);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (findings.length === 0) {
|
|
169
|
+
output.success('No fixable findings at the requested severity.');
|
|
170
|
+
console.log();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Group by file ────────────────────────────────────────────────────────
|
|
175
|
+
const byFile = new Map();
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
const list = byFile.get(f.file) ?? [];
|
|
178
|
+
list.push(f);
|
|
179
|
+
byFile.set(f.file, list);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(chalk.cyan(` Found ${findings.length} fixable finding(s) across ${byFile.size} file(s)`));
|
|
183
|
+
console.log();
|
|
184
|
+
|
|
185
|
+
// ── Fix loop ─────────────────────────────────────────────────────────────
|
|
186
|
+
const applied = []; // { file, plan, verified }
|
|
187
|
+
const skipped = []; // { file, findings, reason }
|
|
188
|
+
let stopped = false;
|
|
189
|
+
let i = 0;
|
|
190
|
+
|
|
191
|
+
for (const [filePath, fileFindings] of byFile) {
|
|
192
|
+
i++;
|
|
193
|
+
if (stopped) break;
|
|
194
|
+
|
|
195
|
+
const idx = `[${i}/${byFile.size}]`;
|
|
196
|
+
console.log();
|
|
197
|
+
console.log(chalk.bold(` ${idx} ${chalk.cyan(filePath)} ${chalk.gray(`— ${fileFindings.length} finding(s)`)}`));
|
|
198
|
+
for (const f of fileFindings) {
|
|
199
|
+
console.log(` ${severityLabel(f.severity)} ${f.title}${f.line ? chalk.gray(` (line ${f.line})`) : ''}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Plan → apply → verify ladder, with one evidence-fed retry ──────────
|
|
203
|
+
const maxAttempts = options.maxAttempts || 2;
|
|
204
|
+
let retryEvidence = '';
|
|
205
|
+
let decision = null;
|
|
206
|
+
let finalPlan = null;
|
|
207
|
+
let finalVerified = null;
|
|
208
|
+
let snapshots = []; // pre-fix file state, captured once on first attempt
|
|
209
|
+
let ladderFailed = false; // verification failed with nothing resolved
|
|
210
|
+
|
|
211
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
212
|
+
if (retryEvidence) {
|
|
213
|
+
console.log(chalk.yellow(` Verification failed — retrying plan with failure evidence (attempt ${attempt}/${maxAttempts})`));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Generate plan
|
|
217
|
+
const planSpinner = ora({ text: retryEvidence ? 'Regenerating fix plan...' : 'Generating fix plan...', color: 'cyan', indent: 6 }).start();
|
|
218
|
+
const planResult = await generateBatchPlan(provider, root, filePath, fileFindings, retryEvidence);
|
|
219
|
+
planSpinner.stop();
|
|
220
|
+
|
|
221
|
+
if (!planResult.ok) {
|
|
222
|
+
const detail = describePlanFailure(planResult);
|
|
223
|
+
console.log(chalk.yellow(` ${detail.message}`));
|
|
224
|
+
logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), ...planResult });
|
|
225
|
+
skipped.push({ file: filePath, findings: fileFindings, reason: detail.reason });
|
|
226
|
+
finalPlan = null;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
const plan = planResult.plan;
|
|
230
|
+
|
|
231
|
+
// Validate (allows new safe files like .env.example)
|
|
232
|
+
const validation = validatePlan(root, plan);
|
|
233
|
+
if (!validation.ok) {
|
|
234
|
+
console.log(chalk.yellow(` Plan invalid: ${validation.reason}`));
|
|
235
|
+
logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), reason: 'validation-rejected', detail: validation.reason, plan });
|
|
236
|
+
skipped.push({ file: filePath, findings: fileFindings, reason: `plan-invalid: ${validation.reason}` });
|
|
237
|
+
finalPlan = null;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Show plan
|
|
242
|
+
printPlan(plan, root);
|
|
243
|
+
|
|
244
|
+
if (options.planOnly) {
|
|
245
|
+
console.log(chalk.gray(' (plan-only mode — not applying)'));
|
|
246
|
+
finalPlan = null;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Decision (prompt once; retries reuse the first decision)
|
|
251
|
+
if (decision === null) {
|
|
252
|
+
const risk = (plan.risk || 'medium').toLowerCase();
|
|
253
|
+
if (options.yolo || options.ci) {
|
|
254
|
+
decision = 'a';
|
|
255
|
+
console.log(chalk.gray(` (${options.ci ? 'ci' : 'yolo'}: auto-accepting)`));
|
|
256
|
+
} else if (options.autoLow && risk === 'low') {
|
|
257
|
+
decision = 'a';
|
|
258
|
+
console.log(chalk.gray(' (auto-low: low-risk, auto-accepting)'));
|
|
259
|
+
} else {
|
|
260
|
+
decision = await promptDecision(plan, root);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (decision === 'q' || decision === 'quit') {
|
|
265
|
+
console.log(chalk.gray(' Stopping.'));
|
|
266
|
+
stopped = true;
|
|
267
|
+
finalPlan = null;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
if (!['a', 'accept', 'y', 'yes'].includes(decision)) {
|
|
271
|
+
skipped.push({ file: filePath, findings: fileFindings, reason: 'user-skipped' });
|
|
272
|
+
finalPlan = null;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Apply — snapshot file state once (first attempt) so a failed ladder
|
|
277
|
+
// can restore the repo to its pre-fix state.
|
|
278
|
+
let applyErr = null;
|
|
279
|
+
if (attempt === 1) {
|
|
280
|
+
snapshots = plan.files.map(fc => {
|
|
281
|
+
const abs = path.resolve(root, fc.path);
|
|
282
|
+
return { abs, existed: fs.existsSync(abs), content: fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null };
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
for (const fileChange of plan.files) {
|
|
287
|
+
applyEdit(root, fileChange);
|
|
288
|
+
}
|
|
289
|
+
} catch (err) {
|
|
290
|
+
applyErr = err.message;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (applyErr) {
|
|
294
|
+
console.log(chalk.red(` Apply failed: ${applyErr}`));
|
|
295
|
+
skipped.push({ file: filePath, findings: fileFindings, reason: `apply-failed: ${applyErr}` });
|
|
296
|
+
finalPlan = null;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Verify — tiered ladder (build → tests → re-scan)
|
|
301
|
+
const verifySpinner = ora({ text: 'Verifying (build → tests → re-scan)...', color: 'cyan', indent: 6 }).start();
|
|
302
|
+
const verified = await verifyFile(root, filePath, fileFindings, options);
|
|
303
|
+
finalPlan = plan;
|
|
304
|
+
finalVerified = verified;
|
|
305
|
+
|
|
306
|
+
if (verified.allResolved) {
|
|
307
|
+
const ranTiers = (verified.tiers || []).map(t => t.tier);
|
|
308
|
+
verifySpinner.succeed(chalk.green(`Fix verified — ${fileFindings.length} finding(s) resolved (${[...ranTiers, 're-scan'].join(' → ')})`));
|
|
309
|
+
ladderFailed = false;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (verified.someResolved) {
|
|
314
|
+
verifySpinner.warn(chalk.yellow(`Partial: ${verified.resolvedCount}/${fileFindings.length} resolved`));
|
|
315
|
+
ladderFailed = false;
|
|
316
|
+
} else {
|
|
317
|
+
verifySpinner.warn(chalk.yellow(`Verification failed at tier "${verified.failedTier}"`));
|
|
318
|
+
ladderFailed = true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Evidence-fed retry
|
|
322
|
+
if (attempt < maxAttempts && verified.evidence) {
|
|
323
|
+
retryEvidence = verified.evidence;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Revert failed fixes (nothing resolved) so the repo stays consistent —
|
|
330
|
+
// even when the retry plan itself failed to generate.
|
|
331
|
+
if (ladderFailed && snapshots.length > 0) {
|
|
332
|
+
for (const s of snapshots) {
|
|
333
|
+
try {
|
|
334
|
+
if (s.existed) fs.writeFileSync(s.abs, s.content, 'utf8');
|
|
335
|
+
else if (fs.existsSync(s.abs)) fs.unlinkSync(s.abs);
|
|
336
|
+
} catch { /* best-effort restore */ }
|
|
337
|
+
}
|
|
338
|
+
console.log(chalk.yellow(' Reverted failed fix — file(s) restored to pre-fix state.'));
|
|
339
|
+
logFailure(root, {
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
file: filePath,
|
|
342
|
+
findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })),
|
|
343
|
+
reason: 'verification-failed',
|
|
344
|
+
detail: finalVerified?.evidence || 'verification failed',
|
|
345
|
+
plan: finalPlan,
|
|
346
|
+
});
|
|
347
|
+
skipped.push({ file: filePath, findings: fileFindings, reason: `verification-failed: ${finalVerified?.failedTier || 'unknown'}` });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!finalPlan || !finalVerified) continue;
|
|
352
|
+
|
|
353
|
+
// Per-fix commit (if branch isolation in use)
|
|
354
|
+
const written = finalPlan.files.map(fc => path.resolve(root, fc.path));
|
|
355
|
+
let commitHash = null;
|
|
356
|
+
if (branchCreated) {
|
|
357
|
+
try {
|
|
358
|
+
execFileSync('git', ['add', '--', ...written], { cwd: root, stdio: 'pipe' });
|
|
359
|
+
const titles = fileFindings.slice(0, 3).map(f => f.title).join(', ');
|
|
360
|
+
const more = fileFindings.length > 3 ? ` (+${fileFindings.length - 3} more)` : '';
|
|
361
|
+
const msg = `fix(security): ${filePath} — ${titles}${more}`;
|
|
362
|
+
execFileSync('git', ['commit', '-m', msg], { cwd: root, stdio: 'pipe' });
|
|
363
|
+
commitHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf-8' }).trim();
|
|
364
|
+
} catch {
|
|
365
|
+
// commit failed — most likely nothing staged because edits were no-ops
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Log
|
|
370
|
+
logFix(root, {
|
|
371
|
+
timestamp: new Date().toISOString(),
|
|
372
|
+
file: filePath,
|
|
373
|
+
findings: fileFindings.map(f => ({ title: f.title, line: f.line, severity: f.severity, rule: f.rule })),
|
|
374
|
+
plan: finalPlan,
|
|
375
|
+
verified: finalVerified.allResolved,
|
|
376
|
+
verificationClass: finalVerified.verification,
|
|
377
|
+
verificationTiers: (finalVerified.tiers || []).map(t => ({ tier: t.tier, command: t.command, ok: t.ok })),
|
|
378
|
+
branch: branchCreated,
|
|
379
|
+
commitHash,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
applied.push({ file: filePath, plan: finalPlan, verified: finalVerified });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Final report ─────────────────────────────────────────────────────────
|
|
386
|
+
console.log();
|
|
387
|
+
console.log();
|
|
388
|
+
output.header('Summary');
|
|
389
|
+
console.log();
|
|
390
|
+
console.log(` ${chalk.green('Applied:')} ${applied.length} file(s)`);
|
|
391
|
+
console.log(` ${chalk.gray('Skipped:')} ${skipped.length} file(s)`);
|
|
392
|
+
|
|
393
|
+
if (applied.length > 0) {
|
|
394
|
+
console.log();
|
|
395
|
+
console.log(chalk.gray(' Applied:'));
|
|
396
|
+
for (const a of applied) {
|
|
397
|
+
const mark = a.verified.allResolved ? chalk.green('✓') : chalk.yellow('?');
|
|
398
|
+
console.log(` ${mark} ${a.file}`);
|
|
399
|
+
}
|
|
400
|
+
console.log();
|
|
401
|
+
console.log(chalk.gray(` Audit log: ${path.join(FIX_LOG_DIR, FIX_LOG_FILE)}`));
|
|
402
|
+
if (branchCreated) {
|
|
403
|
+
console.log(chalk.gray(` Branch: ${chalk.cyan(branchCreated)}`));
|
|
404
|
+
console.log(chalk.gray(` Switch back: git checkout ${initialBranch}`));
|
|
405
|
+
} else {
|
|
406
|
+
console.log(chalk.gray(' Review: git diff'));
|
|
407
|
+
console.log(chalk.gray(' Undo last: praxis undo'));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── PR autopilot ─────────────────────────────────────────────────────────
|
|
412
|
+
if (options.pr && applied.length > 0 && branchCreated) {
|
|
413
|
+
console.log();
|
|
414
|
+
await openPullRequest(root, branchCreated, applied);
|
|
415
|
+
} else if (options.pr && !branchCreated) {
|
|
416
|
+
console.log();
|
|
417
|
+
console.log(chalk.yellow(' --pr requires --branch. Skipping PR creation.'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (skipped.length > 0 && applied.length === 0) {
|
|
421
|
+
console.log();
|
|
422
|
+
console.log(chalk.gray(' Tip: try a different provider with --provider, or run with --plan-only'));
|
|
423
|
+
console.log(chalk.gray(' to inspect what would change.'));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
console.log();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// =============================================================================
|
|
430
|
+
// PLAN GENERATION
|
|
431
|
+
// =============================================================================
|
|
432
|
+
|
|
433
|
+
// Generate a fix plan for a file. Returns either:
|
|
434
|
+
// { ok: true, plan } — plan generated successfully
|
|
435
|
+
// { ok: false, reason, raw, error } — failed; reason is one of:
|
|
436
|
+
// 'file-read-failed' | 'provider-error' | 'parse-error' |
|
|
437
|
+
// 'llm-declined' | 'empty-response'
|
|
438
|
+
// Caller decides what to do with the failure (log, persist, skip).
|
|
439
|
+
async function generateBatchPlan(provider, root, filePath, fileFindings, retryEvidence = '') {
|
|
440
|
+
const abs = path.resolve(root, filePath);
|
|
441
|
+
let content;
|
|
442
|
+
try {
|
|
443
|
+
content = fs.readFileSync(abs, 'utf8');
|
|
444
|
+
} catch (err) {
|
|
445
|
+
return { ok: false, reason: 'file-read-failed', error: err.message };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const fileForPrompt = windowFileContent(content, fileFindings);
|
|
449
|
+
|
|
450
|
+
const findingsBlock = fileFindings.map((f, i) => `
|
|
451
|
+
${i + 1}. [${f.severity.toUpperCase()}] ${f.title}${f.line ? ` (line ${f.line})` : ''}
|
|
452
|
+
Rule: ${f.rule ?? 'N/A'}
|
|
453
|
+
Description: ${f.description ?? 'N/A'}${f.fix ? `\n Suggested fix: ${f.fix}` : ''}
|
|
454
|
+
`).join('');
|
|
455
|
+
|
|
456
|
+
const systemPrompt = 'You are a security engineer. Produce precise code edits as structured JSON only. Never include prose, markdown, or code fences. Output a single JSON object.';
|
|
457
|
+
|
|
458
|
+
const userPrompt = `Fix all of these security findings in a single file by producing one coordinated plan.
|
|
459
|
+
|
|
460
|
+
FILE: ${filePath}
|
|
461
|
+
|
|
462
|
+
FINDINGS (${fileFindings.length}):
|
|
463
|
+
${findingsBlock}
|
|
464
|
+
|
|
465
|
+
CURRENT FILE CONTENT:
|
|
466
|
+
\`\`\`
|
|
467
|
+
${fileForPrompt}
|
|
468
|
+
\`\`\`
|
|
469
|
+
|
|
470
|
+
OUTPUT this exact JSON shape:
|
|
471
|
+
{
|
|
472
|
+
"summary": "one short sentence describing what you'll do across all findings",
|
|
473
|
+
"files": [
|
|
474
|
+
{
|
|
475
|
+
"path": "${filePath}",
|
|
476
|
+
"edits": [
|
|
477
|
+
{ "find": "EXACT verbatim substring", "replace": "new string", "reason": "addresses finding N" }
|
|
478
|
+
]
|
|
479
|
+
}
|
|
480
|
+
],
|
|
481
|
+
"risk": "low"
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
You MAY also include companion file changes (only these are allowed):
|
|
485
|
+
- .env.example — add placeholders for any secrets you moved to env vars
|
|
486
|
+
- .gitignore — add patterns for files that should not be committed
|
|
487
|
+
|
|
488
|
+
For companion files, use this shape (no "find" needed):
|
|
489
|
+
{ "path": ".env.example", "create": true, "content": "FULL FILE CONTENT" }
|
|
490
|
+
or to append:
|
|
491
|
+
{ "path": ".gitignore", "append": "PATTERN_TO_ADD\\n" }
|
|
492
|
+
|
|
493
|
+
RULES:
|
|
494
|
+
- Each "find" string must appear EXACTLY ONCE in the file. Include enough context (3+ lines) for uniqueness.
|
|
495
|
+
- "replace" must be the corrected code. Preserve indentation and surrounding style.
|
|
496
|
+
- Address each finding listed above with at least one edit (or explain in summary why a finding can't be mechanically fixed).
|
|
497
|
+
- GREP FOR SIBLING CALL SITES: search the file (and clearly-related helper files) for every other instance of the same dangerous pattern and fix all of them, not just the flagged line. A fix that leaves variant call sites behind fails verification.
|
|
498
|
+
- REGRESSION TEST: if this project has a test suite, include a test in your edits that fails before your fix and passes after it.
|
|
499
|
+
- Risk: "low" = mechanical, "medium" = behavior change, "high" = architectural. Use "high" sparingly.
|
|
500
|
+
- If you cannot produce a precise mechanical plan, return {"summary":"requires manual review","files":[],"risk":"high"}
|
|
501
|
+
- JSON only. No prose. No code fences.${retryEvidence ? `
|
|
502
|
+
|
|
503
|
+
PREVIOUS ATTEMPT FEEDBACK — your earlier plan was applied but automated verification failed:
|
|
504
|
+
|
|
505
|
+
${retryEvidence}
|
|
506
|
+
|
|
507
|
+
Produce a corrected plan against the CURRENT file content below. Diagnose the root cause of the
|
|
508
|
+
verification failure (build break, failing test, or finding still present) and fix it.` : ''}`;
|
|
509
|
+
|
|
510
|
+
let response;
|
|
511
|
+
try {
|
|
512
|
+
response = await provider.complete(systemPrompt, userPrompt, {
|
|
513
|
+
maxTokens: 3000,
|
|
514
|
+
jsonMode: true,
|
|
515
|
+
});
|
|
516
|
+
} catch (err) {
|
|
517
|
+
return { ok: false, reason: 'provider-error', error: err.message };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!response || !response.trim()) {
|
|
521
|
+
return { ok: false, reason: 'empty-response', raw: response ?? '' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const plan = parseJsonLoose(response);
|
|
525
|
+
if (!plan) {
|
|
526
|
+
return { ok: false, reason: 'parse-error', raw: response };
|
|
527
|
+
}
|
|
528
|
+
if (!Array.isArray(plan.files) || plan.files.length === 0) {
|
|
529
|
+
// The LLM returned valid JSON but explicitly declined to produce edits
|
|
530
|
+
// (typically with summary like "requires manual review"). This is a
|
|
531
|
+
// legitimate "I don't know" — not a bug.
|
|
532
|
+
return { ok: false, reason: 'llm-declined', raw: response, plan };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return { ok: true, plan };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function parseJsonLoose(response) {
|
|
539
|
+
if (!response) return null;
|
|
540
|
+
const cleaned = response.trim()
|
|
541
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
542
|
+
.replace(/```\s*$/i, '')
|
|
543
|
+
.trim();
|
|
544
|
+
try {
|
|
545
|
+
return JSON.parse(cleaned);
|
|
546
|
+
} catch {
|
|
547
|
+
const m = cleaned.match(/\{[\s\S]*\}/);
|
|
548
|
+
if (m) {
|
|
549
|
+
try { return JSON.parse(m[0]); } catch { return null; }
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function windowFileContent(content, fileFindings) {
|
|
556
|
+
if (content.length <= 8000) return content;
|
|
557
|
+
if (!Array.isArray(fileFindings) || fileFindings.length === 0) {
|
|
558
|
+
return content.slice(0, 8000);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const lines = content.split('\n');
|
|
562
|
+
const RADIUS = 40;
|
|
563
|
+
|
|
564
|
+
const targetLines = [...new Set(fileFindings.map(f => f.line).filter(Boolean))].sort((a, b) => a - b);
|
|
565
|
+
if (targetLines.length === 0) return content.slice(0, 8000);
|
|
566
|
+
|
|
567
|
+
const ranges = [];
|
|
568
|
+
for (const line of targetLines) {
|
|
569
|
+
const start = Math.max(0, line - 1 - RADIUS);
|
|
570
|
+
const end = Math.min(lines.length - 1, line - 1 + RADIUS);
|
|
571
|
+
if (ranges.length === 0) {
|
|
572
|
+
ranges.push({ start, end });
|
|
573
|
+
} else {
|
|
574
|
+
const last = ranges[ranges.length - 1];
|
|
575
|
+
if (start <= last.end + 5) {
|
|
576
|
+
last.end = Math.max(last.end, end);
|
|
577
|
+
} else {
|
|
578
|
+
ranges.push({ start, end });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const result = [];
|
|
584
|
+
let lastEnd = 0;
|
|
585
|
+
for (const r of ranges) {
|
|
586
|
+
if (r.start > lastEnd) {
|
|
587
|
+
result.push(`// ... [lines ${lastEnd + 1}-${r.start} truncated] ...`);
|
|
588
|
+
}
|
|
589
|
+
result.push(lines.slice(r.start, r.end + 1).join('\n'));
|
|
590
|
+
lastEnd = r.end + 1;
|
|
591
|
+
}
|
|
592
|
+
if (lastEnd < lines.length) {
|
|
593
|
+
result.push(`// ... [lines ${lastEnd + 1}-${lines.length} truncated] ...`);
|
|
594
|
+
}
|
|
595
|
+
return result.join('\n');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// =============================================================================
|
|
599
|
+
// PLAN VALIDATION
|
|
600
|
+
// =============================================================================
|
|
601
|
+
|
|
602
|
+
function validatePlan(root, plan) {
|
|
603
|
+
if (!Array.isArray(plan.files) || plan.files.length === 0) {
|
|
604
|
+
return { ok: false, reason: 'no files in plan' };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const f of plan.files) {
|
|
608
|
+
if (!f.path) return { ok: false, reason: 'file entry missing path' };
|
|
609
|
+
|
|
610
|
+
const rel = f.path.replace(/\\/g, '/');
|
|
611
|
+
const isSafeNew = SAFE_NEW_FILES.some(p => p.test(rel));
|
|
612
|
+
|
|
613
|
+
// Block protected paths unless this is a known-safe companion file
|
|
614
|
+
if (!isSafeNew && NEVER_EDIT.some(p => p.test(rel))) {
|
|
615
|
+
return { ok: false, reason: `protected path: ${f.path}` };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const abs = path.resolve(root, f.path);
|
|
619
|
+
const exists = fs.existsSync(abs);
|
|
620
|
+
|
|
621
|
+
// Companion file forms (create / append)
|
|
622
|
+
if (f.create || f.append !== undefined) {
|
|
623
|
+
if (!exists && !isSafeNew) {
|
|
624
|
+
return { ok: false, reason: `cannot create new file at ${f.path}` };
|
|
625
|
+
}
|
|
626
|
+
if (f.create && typeof f.content !== 'string') {
|
|
627
|
+
return { ok: false, reason: 'create entry missing content' };
|
|
628
|
+
}
|
|
629
|
+
if (f.append && typeof f.append !== 'string') {
|
|
630
|
+
return { ok: false, reason: 'append must be a string' };
|
|
631
|
+
}
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Standard edit form
|
|
636
|
+
if (!exists) return { ok: false, reason: `file not found: ${f.path}` };
|
|
637
|
+
if (!Array.isArray(f.edits) || f.edits.length === 0) {
|
|
638
|
+
return { ok: false, reason: `no edits for ${f.path}` };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
642
|
+
for (const e of f.edits) {
|
|
643
|
+
if (typeof e.find !== 'string' || typeof e.replace !== 'string') {
|
|
644
|
+
return { ok: false, reason: 'edit missing find/replace' };
|
|
645
|
+
}
|
|
646
|
+
if (e.find === e.replace) {
|
|
647
|
+
return { ok: false, reason: 'edit is a no-op' };
|
|
648
|
+
}
|
|
649
|
+
const match = locateFindString(content, e.find);
|
|
650
|
+
if (match.kind === 'missing') {
|
|
651
|
+
return { ok: false, reason: `find string not present in ${f.path}` };
|
|
652
|
+
}
|
|
653
|
+
if (match.kind === 'ambiguous') {
|
|
654
|
+
return { ok: false, reason: `find string is ambiguous (${match.count} matches) in ${f.path}` };
|
|
655
|
+
}
|
|
656
|
+
// Annotate the edit with the resolved match for use during apply
|
|
657
|
+
e._resolvedFind = match.matched;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { ok: true };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Try exact match first, then whitespace-normalized match if exact misses.
|
|
664
|
+
// Returns { kind: 'unique'|'ambiguous'|'missing', matched, count }
|
|
665
|
+
function locateFindString(haystack, needle) {
|
|
666
|
+
const exact = countOccurrences(haystack, needle);
|
|
667
|
+
if (exact === 1) return { kind: 'unique', matched: needle, count: 1 };
|
|
668
|
+
if (exact > 1) return { kind: 'ambiguous', matched: needle, count: exact };
|
|
669
|
+
|
|
670
|
+
// Whitespace-tolerant fallback: collapse whitespace runs and try again
|
|
671
|
+
const norm = (s) => s.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
|
|
672
|
+
const needleNorm = norm(needle);
|
|
673
|
+
if (!needleNorm) return { kind: 'missing', matched: null, count: 0 };
|
|
674
|
+
|
|
675
|
+
// Walk the haystack and check if any window normalizes to the same string
|
|
676
|
+
// To keep this cheap, only attempt when needle has at least one newline (likely a code block)
|
|
677
|
+
const lines = haystack.split('\n');
|
|
678
|
+
const needleLines = needleNorm.split('\n').length;
|
|
679
|
+
let foundIdx = -1;
|
|
680
|
+
let foundCount = 0;
|
|
681
|
+
for (let i = 0; i + needleLines <= lines.length; i++) {
|
|
682
|
+
const window = lines.slice(i, i + needleLines).join('\n');
|
|
683
|
+
if (norm(window) === needleNorm) {
|
|
684
|
+
foundIdx = i;
|
|
685
|
+
foundCount++;
|
|
686
|
+
if (foundCount > 1) break;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (foundCount === 1) {
|
|
690
|
+
const matched = lines.slice(foundIdx, foundIdx + needleLines).join('\n');
|
|
691
|
+
return { kind: 'unique', matched, count: 1 };
|
|
692
|
+
}
|
|
693
|
+
if (foundCount > 1) return { kind: 'ambiguous', matched: null, count: foundCount };
|
|
694
|
+
return { kind: 'missing', matched: null, count: 0 };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function countOccurrences(haystack, needle) {
|
|
698
|
+
if (!needle) return 0;
|
|
699
|
+
let count = 0, idx = 0;
|
|
700
|
+
while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }
|
|
701
|
+
return count;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// =============================================================================
|
|
705
|
+
// PRINTING
|
|
706
|
+
// =============================================================================
|
|
707
|
+
|
|
708
|
+
function printPlan(plan, _root) {
|
|
709
|
+
console.log();
|
|
710
|
+
console.log(chalk.bold(' Plan:'));
|
|
711
|
+
console.log(chalk.white(` ${plan.summary || '(no summary)'}`));
|
|
712
|
+
if (plan.risk) {
|
|
713
|
+
const riskColor = plan.risk === 'low' ? chalk.green : plan.risk === 'medium' ? chalk.yellow : chalk.red;
|
|
714
|
+
console.log(` Risk: ${riskColor(plan.risk)}`);
|
|
715
|
+
}
|
|
716
|
+
console.log();
|
|
717
|
+
|
|
718
|
+
for (const f of plan.files) {
|
|
719
|
+
if (f.create) {
|
|
720
|
+
console.log(chalk.bold(` ${chalk.green('+ ')}${f.path} ${chalk.gray('(new file)')}`));
|
|
721
|
+
printNewFilePreview(f.content);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (f.append !== undefined) {
|
|
725
|
+
console.log(chalk.bold(` ${f.path} ${chalk.gray('(append)')}`));
|
|
726
|
+
for (const l of f.append.split('\n')) {
|
|
727
|
+
if (l) console.log(chalk.green(` + ${l}`));
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
console.log(chalk.bold(` ${f.path}`));
|
|
732
|
+
for (const e of f.edits) {
|
|
733
|
+
console.log(chalk.gray(` — ${e.reason || 'edit'}`));
|
|
734
|
+
printDiff(e._resolvedFind || e.find, e.replace);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
console.log();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function printNewFilePreview(content) {
|
|
741
|
+
const lines = content.split('\n');
|
|
742
|
+
const max = 6;
|
|
743
|
+
const shown = lines.slice(0, max);
|
|
744
|
+
for (const l of shown) console.log(chalk.green(` + ${l}`));
|
|
745
|
+
if (lines.length > max) {
|
|
746
|
+
console.log(chalk.gray(` … +${lines.length - max} more line(s)`));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function printDiff(oldStr, newStr) {
|
|
751
|
+
const oldLines = oldStr.split('\n');
|
|
752
|
+
const newLines = newStr.split('\n');
|
|
753
|
+
for (const l of oldLines) console.log(chalk.red(` - ${l}`));
|
|
754
|
+
for (const l of newLines) console.log(chalk.green(` + ${l}`));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function severityLabel(sev) {
|
|
758
|
+
switch (sev) {
|
|
759
|
+
case 'critical': return chalk.red.bold('[CRITICAL]');
|
|
760
|
+
case 'high': return chalk.red('[HIGH]');
|
|
761
|
+
case 'medium': return chalk.yellow('[MEDIUM]');
|
|
762
|
+
case 'low': return chalk.blue('[LOW]');
|
|
763
|
+
default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// =============================================================================
|
|
768
|
+
// APPLY
|
|
769
|
+
// =============================================================================
|
|
770
|
+
|
|
771
|
+
function applyEdit(root, fileChange) {
|
|
772
|
+
const abs = path.resolve(root, fileChange.path);
|
|
773
|
+
|
|
774
|
+
if (fileChange.create) {
|
|
775
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
776
|
+
fs.writeFileSync(abs, fileChange.content, 'utf8');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (fileChange.append !== undefined) {
|
|
781
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
782
|
+
const existing = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
|
|
783
|
+
// Avoid duplicate appends
|
|
784
|
+
if (existing.includes(fileChange.append.trim())) return;
|
|
785
|
+
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
786
|
+
fs.writeFileSync(abs, existing + sep + fileChange.append, 'utf8');
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
let content = fs.readFileSync(abs, 'utf8');
|
|
791
|
+
for (const e of fileChange.edits) {
|
|
792
|
+
const find = e._resolvedFind || e.find;
|
|
793
|
+
if (!content.includes(find)) {
|
|
794
|
+
throw new Error(`find string drifted in ${fileChange.path} (file changed mid-plan)`);
|
|
795
|
+
}
|
|
796
|
+
content = content.replace(find, e.replace);
|
|
797
|
+
}
|
|
798
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// =============================================================================
|
|
802
|
+
// VERIFY — tiered ladder (P-IMP-032)
|
|
803
|
+
// =============================================================================
|
|
804
|
+
// Executable oracles only; no tier is decided by model judgment:
|
|
805
|
+
// Tier 1 build/lint — the project's own build (or lint) must still pass
|
|
806
|
+
// Tier 2 test suite — the project's test command must still pass
|
|
807
|
+
// Tier 3 re-scan — original findings must be gone from the fixed file
|
|
808
|
+
// Failing tier evidence is returned so the caller can feed it into a retry.
|
|
809
|
+
|
|
810
|
+
function isDockerAvailable() {
|
|
811
|
+
try {
|
|
812
|
+
execFileSync('docker', ['info'], { stdio: 'ignore' });
|
|
813
|
+
return true;
|
|
814
|
+
} catch {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function detectBuildCommand(root) {
|
|
820
|
+
try {
|
|
821
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
822
|
+
if (typeof pkg.scripts?.build === 'string') return { label: 'build', command: ['npm', 'run', 'build'] };
|
|
823
|
+
if (typeof pkg.scripts?.lint === 'string') return { label: 'lint', command: ['npm', 'run', 'lint'] };
|
|
824
|
+
} catch { /* no package.json */ }
|
|
825
|
+
if (fs.existsSync(path.join(root, 'Cargo.toml'))) return { label: 'build', command: ['cargo', 'build'] };
|
|
826
|
+
if (fs.existsSync(path.join(root, 'go.mod'))) return { label: 'build', command: ['go', 'build', './...'] };
|
|
827
|
+
if (fs.existsSync(path.join(root, 'Makefile'))) return { label: 'build', command: ['make'] };
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function detectTestCommand(root) {
|
|
832
|
+
try {
|
|
833
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
834
|
+
if (typeof pkg.scripts?.test === 'string') return { label: 'test', command: ['npm', 'test'] };
|
|
835
|
+
} catch { /* no package.json */ }
|
|
836
|
+
if (fs.existsSync(path.join(root, 'Cargo.toml'))) return { label: 'test', command: ['cargo', 'test'] };
|
|
837
|
+
if (fs.existsSync(path.join(root, 'go.mod'))) return { label: 'test', command: ['go', 'test', './...'] };
|
|
838
|
+
if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'pytest.ini'))) return { label: 'test', command: ['pytest'] };
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function runProjectCommand(root, command, timeoutMs = 300000) {
|
|
843
|
+
const [cmd0, ...args] = command;
|
|
844
|
+
try {
|
|
845
|
+
let stdout;
|
|
846
|
+
if (process.platform === 'win32' && /^npm$/.test(cmd0)) {
|
|
847
|
+
// npm is a .cmd shim on Windows — spawnSync of .cmd files returns EINVAL
|
|
848
|
+
// without a shell. Commands are fixed internal strings (no user input).
|
|
849
|
+
const line = `npm.cmd ${args.map(a => `"${a}"`).join(' ')}`;
|
|
850
|
+
stdout = execFileSync(line, {
|
|
851
|
+
cwd: root,
|
|
852
|
+
shell: true,
|
|
853
|
+
encoding: 'utf-8',
|
|
854
|
+
timeout: timeoutMs,
|
|
855
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
856
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
857
|
+
});
|
|
858
|
+
} else {
|
|
859
|
+
stdout = execFileSync(cmd0, args, {
|
|
860
|
+
cwd: root,
|
|
861
|
+
encoding: 'utf-8',
|
|
862
|
+
timeout: timeoutMs,
|
|
863
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
864
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return { ok: true, exitCode: 0, output: String(stdout).slice(-800) };
|
|
868
|
+
} catch (err) {
|
|
869
|
+
const output = String((err.stdout || '') + '\n' + (err.stderr || '')).slice(-800);
|
|
870
|
+
const timedOut = err.killed || /ETIMEDOUT|timed out/i.test(err.message);
|
|
871
|
+
return { ok: false, exitCode: err.status ?? -1, output, timedOut };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function rescanForFile(root, filePath, options) {
|
|
876
|
+
if (options.sandbox && isDockerAvailable()) {
|
|
877
|
+
const stdout = execFileSync('docker', [
|
|
878
|
+
'run', '--rm',
|
|
879
|
+
'-v', `${root}:/workspace`,
|
|
880
|
+
'-v', `${praxisDir}:/opt/praxis:ro`,
|
|
881
|
+
'-w', '/workspace',
|
|
882
|
+
'node:18-alpine',
|
|
883
|
+
'node', '/opt/praxis/cli/bin/praxis.js', 'scan', '.', '--json'
|
|
884
|
+
], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
885
|
+
|
|
886
|
+
const jsonStart = stdout.search(/^\s*\{/m);
|
|
887
|
+
if (jsonStart === -1) return { findings: [] };
|
|
888
|
+
try { return JSON.parse(stdout.slice(jsonStart)); } catch { return { findings: [] }; }
|
|
889
|
+
}
|
|
890
|
+
return auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export async function verifyFile(root, filePath, originalFindings, options = {}) {
|
|
894
|
+
const tiers = [];
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
// Tier 0 — AST syntax verification (P-IMP-010)
|
|
898
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
899
|
+
if (['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py'].includes(ext)) {
|
|
900
|
+
try {
|
|
901
|
+
const fileContent = fs.readFileSync(path.resolve(root, filePath), 'utf8');
|
|
902
|
+
const parsed = ASTParser.parse(fileContent, filePath);
|
|
903
|
+
const hasValidAST = parsed && parsed.ast && Array.isArray(parsed.ast.body);
|
|
904
|
+
tiers.push({ tier: 'ast_syntax', ok: hasValidAST });
|
|
905
|
+
if (!hasValidAST) {
|
|
906
|
+
return {
|
|
907
|
+
allResolved: false, someResolved: false, resolvedCount: 0,
|
|
908
|
+
verification: 'failed', failedTier: 'ast_syntax',
|
|
909
|
+
evidence: `AST syntax verification failed on fixed file: ${filePath}`,
|
|
910
|
+
tiers,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
} catch (err) {
|
|
914
|
+
return {
|
|
915
|
+
allResolved: false, someResolved: false, resolvedCount: 0,
|
|
916
|
+
verification: 'failed', failedTier: 'ast_syntax',
|
|
917
|
+
evidence: `AST syntax verification failed: ${err.message}`,
|
|
918
|
+
tiers,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Tier 1 — build/lint
|
|
924
|
+
const build = detectBuildCommand(root);
|
|
925
|
+
if (build) {
|
|
926
|
+
const r = runProjectCommand(root, build.command);
|
|
927
|
+
tiers.push({ tier: 'build', command: build.command.join(' '), ok: r.ok, output: r.output });
|
|
928
|
+
if (!r.ok) {
|
|
929
|
+
return {
|
|
930
|
+
allResolved: false, someResolved: false, resolvedCount: 0,
|
|
931
|
+
verification: 'failed', failedTier: 'build',
|
|
932
|
+
evidence: `Build failed (${build.label}): ${r.output}`.slice(0, 1200),
|
|
933
|
+
tiers,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Tier 2 — test suite
|
|
939
|
+
const test = detectTestCommand(root);
|
|
940
|
+
if (test) {
|
|
941
|
+
const r = runProjectCommand(root, test.command, 600000);
|
|
942
|
+
tiers.push({ tier: 'test', command: test.command.join(' '), ok: r.ok, output: r.output });
|
|
943
|
+
if (!r.ok) {
|
|
944
|
+
return {
|
|
945
|
+
allResolved: false, someResolved: false, resolvedCount: 0,
|
|
946
|
+
verification: 'failed', failedTier: 'test',
|
|
947
|
+
evidence: `Tests failed (${test.label}): ${r.output}`.slice(0, 1200),
|
|
948
|
+
tiers,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Tier 3 — re-scan: original findings must be gone from the fixed file
|
|
954
|
+
const result = await rescanForFile(root, filePath, options);
|
|
955
|
+
const remaining = (result.findings ?? []).filter(f => {
|
|
956
|
+
const fPath = path.resolve(root, f.file);
|
|
957
|
+
const targetPath = path.resolve(root, filePath);
|
|
958
|
+
return fPath === targetPath;
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
let resolvedCount = 0;
|
|
962
|
+
const survivors = [];
|
|
963
|
+
for (const orig of originalFindings) {
|
|
964
|
+
const stillThere = remaining.some(f =>
|
|
965
|
+
f.rule === orig.rule &&
|
|
966
|
+
Math.abs((f.line ?? 0) - (orig.line ?? 0)) <= 2,
|
|
967
|
+
);
|
|
968
|
+
if (!stillThere) resolvedCount++;
|
|
969
|
+
else survivors.push(orig.rule);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const allResolved = resolvedCount === originalFindings.length;
|
|
973
|
+
return {
|
|
974
|
+
allResolved,
|
|
975
|
+
someResolved: resolvedCount > 0,
|
|
976
|
+
resolvedCount,
|
|
977
|
+
verification: allResolved ? 'passed' : (resolvedCount > 0 ? 'partial' : 'failed'),
|
|
978
|
+
failedTier: allResolved ? null : 'rescan',
|
|
979
|
+
evidence: allResolved ? '' : `Re-scan still reports: ${survivors.join(', ')}`,
|
|
980
|
+
tiers,
|
|
981
|
+
};
|
|
982
|
+
} catch (err) {
|
|
983
|
+
return {
|
|
984
|
+
allResolved: false, someResolved: false, resolvedCount: 0,
|
|
985
|
+
verification: 'failed', failedTier: 'verify-error',
|
|
986
|
+
evidence: err.message || 'verification crashed',
|
|
987
|
+
tiers,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// =============================================================================
|
|
993
|
+
// LOGGING
|
|
994
|
+
// =============================================================================
|
|
995
|
+
|
|
996
|
+
function logFix(root, entry) {
|
|
997
|
+
const dir = path.join(root, FIX_LOG_DIR);
|
|
998
|
+
const file = path.join(dir, FIX_LOG_FILE);
|
|
999
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1000
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Append a structured record for every plan that didn't produce an apply.
|
|
1004
|
+
// Useful for debugging regressions (the LLM said X, we rejected because Y).
|
|
1005
|
+
function logFailure(root, entry) {
|
|
1006
|
+
const dir = path.join(root, FIX_LOG_DIR);
|
|
1007
|
+
const file = path.join(dir, 'failures.jsonl');
|
|
1008
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1009
|
+
// Truncate raw response to keep the log readable
|
|
1010
|
+
const truncated = { ...entry };
|
|
1011
|
+
if (typeof truncated.raw === 'string' && truncated.raw.length > 4000) {
|
|
1012
|
+
truncated.raw = truncated.raw.slice(0, 4000) + '... [truncated]';
|
|
1013
|
+
}
|
|
1014
|
+
fs.appendFileSync(file, JSON.stringify(truncated) + '\n', 'utf8');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Turn a structured plan failure into a user-facing one-liner.
|
|
1018
|
+
function describePlanFailure(failure) {
|
|
1019
|
+
switch (failure.reason) {
|
|
1020
|
+
case 'file-read-failed':
|
|
1021
|
+
return { message: `Could not read source file: ${failure.error}`, reason: 'file-read-failed' };
|
|
1022
|
+
case 'provider-error':
|
|
1023
|
+
return { message: `LLM provider error — ${failure.error}`, reason: 'provider-error' };
|
|
1024
|
+
case 'empty-response':
|
|
1025
|
+
return { message: 'LLM returned an empty response (try again, or switch provider).', reason: 'empty-response' };
|
|
1026
|
+
case 'parse-error':
|
|
1027
|
+
return { message: 'LLM returned unparseable JSON — saved to .praxis/failures.jsonl for inspection.', reason: 'parse-error' };
|
|
1028
|
+
case 'llm-declined': {
|
|
1029
|
+
const summary = failure.plan?.summary;
|
|
1030
|
+
return { message: summary
|
|
1031
|
+
? `LLM declined to fix this file — ${summary}`
|
|
1032
|
+
: 'LLM declined to fix this file (returned files=[] — needs manual review).',
|
|
1033
|
+
reason: 'llm-declined',
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
default:
|
|
1037
|
+
return { message: `Plan failed: ${failure.reason}`, reason: failure.reason };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// =============================================================================
|
|
1042
|
+
// PR AUTOPILOT
|
|
1043
|
+
// =============================================================================
|
|
1044
|
+
|
|
1045
|
+
async function openPullRequest(root, branch, applied) {
|
|
1046
|
+
const ghAvailable = (() => {
|
|
1047
|
+
try { execFileSync('gh', ['--version'], { stdio: 'pipe' }); return true; }
|
|
1048
|
+
catch { return false; }
|
|
1049
|
+
})();
|
|
1050
|
+
if (!ghAvailable) {
|
|
1051
|
+
console.log(chalk.yellow(' gh CLI not found. Install from https://cli.github.com to enable --pr.'));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Push branch
|
|
1056
|
+
console.log(chalk.gray(' Pushing branch...'));
|
|
1057
|
+
try {
|
|
1058
|
+
execFileSync('git', ['push', '-u', 'origin', branch], { cwd: root, stdio: 'pipe' });
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
console.log(chalk.red(` Push failed: ${err.message}`));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Build PR body
|
|
1065
|
+
const totalFindings = applied.reduce((n, a) => n + (a.plan.files?.[0]?.edits?.length ?? 0), 0);
|
|
1066
|
+
const body = [
|
|
1067
|
+
'## Praxis — Security Fixes',
|
|
1068
|
+
'',
|
|
1069
|
+
`Applied ${applied.length} file(s) of fixes (${totalFindings} edit(s)) generated and verified by the Praxis agent.`,
|
|
1070
|
+
'',
|
|
1071
|
+
'### Files changed',
|
|
1072
|
+
...applied.map(a => {
|
|
1073
|
+
const mark = a.verified.allResolved ? '✓' : '⚠';
|
|
1074
|
+
return `- ${mark} \`${a.file}\` — ${a.plan.summary || 'security fix'}`;
|
|
1075
|
+
}),
|
|
1076
|
+
'',
|
|
1077
|
+
'### Notes',
|
|
1078
|
+
'- Each fix was generated by an LLM and verified by re-scanning the file.',
|
|
1079
|
+
'- Files marked ⚠ have residual findings; review carefully before merging.',
|
|
1080
|
+
'- Full audit log: `.praxis/fixes.jsonl`',
|
|
1081
|
+
'',
|
|
1082
|
+
'Generated by `praxis agent`.',
|
|
1083
|
+
].join('\n');
|
|
1084
|
+
|
|
1085
|
+
const title = `Security fixes: ${applied.length} file(s)`;
|
|
1086
|
+
|
|
1087
|
+
console.log(chalk.gray(' Opening PR...'));
|
|
1088
|
+
let prUrl = null;
|
|
1089
|
+
try {
|
|
1090
|
+
prUrl = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
|
|
1091
|
+
console.log(chalk.green(` PR opened: ${prUrl}`));
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
console.log(chalk.red(` PR creation failed: ${err.message}`));
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// If we're running inside CI on a PR, leave a comment on the originating PR
|
|
1098
|
+
// pointing at our fix PR. Detect from common GitHub Actions env vars.
|
|
1099
|
+
const originPr = detectOriginPrNumber();
|
|
1100
|
+
if (originPr) {
|
|
1101
|
+
const note = [
|
|
1102
|
+
`### 🛡️ Praxis Agent — fix PR opened`,
|
|
1103
|
+
``,
|
|
1104
|
+
`The Praxis agent found fixable security issues triggered by this PR and opened **${prUrl}** with proposed fixes.`,
|
|
1105
|
+
``,
|
|
1106
|
+
`**Files changed:** ${applied.length}`,
|
|
1107
|
+
`**Total edits:** ${totalFindings}`,
|
|
1108
|
+
``,
|
|
1109
|
+
`Review the fix PR and merge if it looks good.`,
|
|
1110
|
+
].join('\n');
|
|
1111
|
+
try {
|
|
1112
|
+
execFileSync('gh', ['pr', 'comment', String(originPr), '--body', note], { cwd: root, stdio: 'pipe' });
|
|
1113
|
+
console.log(chalk.green(` Commented on origin PR #${originPr}`));
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
console.log(chalk.yellow(` Could not comment on origin PR #${originPr}: ${err.message}`));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Detect the PR number that triggered this CI run. Supports GitHub Actions'
|
|
1121
|
+
// pull_request and pull_request_target events. Returns null when not in CI
|
|
1122
|
+
// or when the event isn't a PR event.
|
|
1123
|
+
function detectOriginPrNumber() {
|
|
1124
|
+
// Explicit override (handy for testing or non-GHA CI providers)
|
|
1125
|
+
if (process.env.PRAXIS_ORIGIN_PR) return process.env.PRAXIS_ORIGIN_PR;
|
|
1126
|
+
|
|
1127
|
+
// GitHub Actions: GITHUB_REF looks like "refs/pull/<n>/merge" or "refs/pull/<n>/head"
|
|
1128
|
+
const ref = process.env.GITHUB_REF || '';
|
|
1129
|
+
const m = ref.match(/^refs\/pull\/(\d+)\//);
|
|
1130
|
+
if (m) return m[1];
|
|
1131
|
+
|
|
1132
|
+
// GitHub Actions PR event payload also exposes the number via GITHUB_EVENT_PATH
|
|
1133
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
1134
|
+
if (eventPath && fs.existsSync(eventPath)) {
|
|
1135
|
+
try {
|
|
1136
|
+
const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
|
1137
|
+
if (payload?.pull_request?.number) return String(payload.pull_request.number);
|
|
1138
|
+
if (payload?.number) return String(payload.number);
|
|
1139
|
+
} catch { /* malformed event payload */ }
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// =============================================================================
|
|
1146
|
+
// GIT
|
|
1147
|
+
// =============================================================================
|
|
1148
|
+
|
|
1149
|
+
function checkGitState(root) {
|
|
1150
|
+
try {
|
|
1151
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, stdio: 'pipe' });
|
|
1152
|
+
} catch {
|
|
1153
|
+
return 'not-a-repo';
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const out = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
|
|
1157
|
+
const meaningful = out.split('\n').filter(line => {
|
|
1158
|
+
const path = line.slice(3).trim();
|
|
1159
|
+
if (!path) return false;
|
|
1160
|
+
if (path.startsWith('.praxis/')) return false;
|
|
1161
|
+
if (path === 'praxis-report.html') return false;
|
|
1162
|
+
return true;
|
|
1163
|
+
});
|
|
1164
|
+
return meaningful.length === 0 ? 'clean' : 'dirty';
|
|
1165
|
+
} catch {
|
|
1166
|
+
return 'clean';
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function getCurrentBranch(root) {
|
|
1171
|
+
try {
|
|
1172
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
1173
|
+
} catch {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// =============================================================================
|
|
1179
|
+
// PROMPTS
|
|
1180
|
+
// =============================================================================
|
|
1181
|
+
|
|
1182
|
+
function prompt(question) {
|
|
1183
|
+
return new Promise(resolve => {
|
|
1184
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1185
|
+
rl.question(question, answer => { rl.close(); resolve(answer); });
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function confirm(question) {
|
|
1190
|
+
const a = (await prompt(`${question} [y/N] `)).trim().toLowerCase();
|
|
1191
|
+
return a === 'y' || a === 'yes';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Plan decision prompt with [e]dit support: opens the plan in $EDITOR for the
|
|
1195
|
+
// user to tweak, then re-validates. Loops until accept/skip/quit.
|
|
1196
|
+
async function promptDecision(plan, root) {
|
|
1197
|
+
while (true) {
|
|
1198
|
+
const raw = (await prompt(chalk.cyan(' [a]ccept [s]kip [e]dit [q]uit > '))).trim().toLowerCase();
|
|
1199
|
+
if (['a', 'accept', 'y', 'yes'].includes(raw)) return 'a';
|
|
1200
|
+
if (['s', 'skip', 'n', 'no'].includes(raw)) return 's';
|
|
1201
|
+
if (['q', 'quit'].includes(raw)) return 'q';
|
|
1202
|
+
if (['e', 'edit'].includes(raw)) {
|
|
1203
|
+
const edited = await editPlanInEditor(plan, root);
|
|
1204
|
+
if (!edited) {
|
|
1205
|
+
console.log(chalk.yellow(' Edit cancelled — keeping original plan.'));
|
|
1206
|
+
} else {
|
|
1207
|
+
// Mutate plan in place so the caller's reference picks up the changes
|
|
1208
|
+
plan.summary = edited.summary;
|
|
1209
|
+
plan.files = edited.files;
|
|
1210
|
+
plan.risk = edited.risk;
|
|
1211
|
+
// Re-validate then re-show
|
|
1212
|
+
const validation = validatePlan(root, plan);
|
|
1213
|
+
if (!validation.ok) {
|
|
1214
|
+
console.log(chalk.red(` Edited plan invalid: ${validation.reason}`));
|
|
1215
|
+
console.log(chalk.gray(' Returning to prompt — try editing again, or skip.'));
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
printPlan(plan, root);
|
|
1219
|
+
}
|
|
1220
|
+
// Loop back and re-prompt
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
console.log(chalk.gray(' Unknown choice. Type a, s, e, or q.'));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function editPlanInEditor(plan, root) {
|
|
1228
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
|
|
1229
|
+
const tmpFile = path.join(root, '.praxis', `plan-edit-${Date.now()}.json`);
|
|
1230
|
+
fs.mkdirSync(path.dirname(tmpFile), { recursive: true });
|
|
1231
|
+
// Strip _resolvedFind annotations before showing — they're internal
|
|
1232
|
+
const exportable = JSON.parse(JSON.stringify(plan, (k, v) => k === '_resolvedFind' ? undefined : v));
|
|
1233
|
+
fs.writeFileSync(tmpFile, JSON.stringify(exportable, null, 2), 'utf8');
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
execFileSync(editor, [tmpFile], { stdio: 'inherit' });
|
|
1237
|
+
const updated = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
|
|
1238
|
+
fs.unlinkSync(tmpFile);
|
|
1239
|
+
return updated;
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
try { fs.unlinkSync(tmpFile); } catch { /* best effort */ }
|
|
1242
|
+
console.log(chalk.red(` Editor failed: ${err.message}`));
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
}
|