praxis-sec 1.2.1 → 1.2.4

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 (53) hide show
  1. package/README.md +84 -115
  2. package/ai-defense/cost-protection.md +6 -0
  3. package/ai-defense/llm-security-checklist.md +6 -0
  4. package/ai-defense/system-prompt-armor.md +7 -1
  5. package/assets/praxis-architecture.svg +304 -0
  6. package/assets/praxis-logo.svg +38 -0
  7. package/checklists/launch-day.md +6 -7
  8. package/cli/agents/agent-telemetry-agent.js +2 -0
  9. package/cli/agents/api-fuzzer.js +2 -2
  10. package/cli/agents/git-history-scanner.js +14 -15
  11. package/cli/agents/html-reporter.js +9 -8
  12. package/cli/agents/mcp-security-agent.js +600 -594
  13. package/cli/agents/memory-poisoning-agent.js +1 -5
  14. package/cli/agents/orchestrator.js +375 -360
  15. package/cli/commands/agent-fix.js +3 -1
  16. package/cli/commands/audit.js +1272 -1228
  17. package/cli/commands/autofix.js +32 -13
  18. package/cli/commands/baseline.js +4 -2
  19. package/cli/commands/benchmark.js +2 -1
  20. package/cli/commands/ci.js +12 -8
  21. package/cli/commands/diff.js +2 -1
  22. package/cli/commands/env-audit.js +4 -2
  23. package/cli/commands/fix.js +2 -1
  24. package/cli/commands/legal.js +2 -1
  25. package/cli/commands/mcp.js +54 -51
  26. package/cli/commands/openclaw.js +3 -6
  27. package/cli/commands/red-team.js +2 -1
  28. package/cli/commands/remediate.js +2 -1
  29. package/cli/commands/rotate.js +2 -1
  30. package/cli/commands/scan-mcp.js +20 -9
  31. package/cli/commands/scan-standard.js +3 -6
  32. package/cli/commands/scan.js +15 -7
  33. package/cli/commands/score.js +2 -1
  34. package/cli/commands/vibe-check.js +4 -2
  35. package/cli/commands/watch.js +8 -6
  36. package/cli/core/glob.js +7 -5
  37. package/cli/core/output/json.js +56 -48
  38. package/cli/core/paths.js +91 -0
  39. package/cli/core/web/jobs.js +2 -0
  40. package/cli/data/documented-secret-examples.json +14 -0
  41. package/cli/utils/cache-manager.js +2 -1
  42. package/cli/utils/entropy.js +19 -0
  43. package/cli/utils/hermes-tool-registry.js +11 -9
  44. package/configs/firebase/security-checklist.md +3 -3
  45. package/configs/supabase/security-checklist.md +19 -21
  46. package/docs/RELEASE-1.2.4.md +85 -0
  47. package/docs/RELEASING.md +51 -0
  48. package/docs/THIRD_PARTY_NOTICES.md +8 -0
  49. package/docs/THREAT_INTEL.md +4 -2
  50. package/docs/USAGE.md +97 -76
  51. package/package.json +6 -4
  52. package/snippets/README.md +6 -0
  53. package/snippets/auth/jwt-checklist.md +14 -13
@@ -29,6 +29,9 @@ import path from 'path';
29
29
  import { execFileSync, execSync } from 'child_process';
30
30
  import chalk from 'chalk';
31
31
  import * as output from '../utils/output.js';
32
+ import { resolveProjectFile } from '../core/fs.js';
33
+ import { isProtectedFixPath } from '../core/fix-plan.js';
34
+ import writeFileAtomic from 'write-file-atomic';
32
35
 
33
36
  // Severity rank for filtering
34
37
  const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
@@ -86,7 +89,11 @@ export async function autofixCommand(options = {}) {
86
89
  if (!f.deepAnalysis?.fix) return false;
87
90
  if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
88
91
  if (!f.file) return false;
89
- const absFile = path.resolve(rootPath, f.file);
92
+ let absFile;
93
+ try {
94
+ absFile = resolveProjectFile(rootPath, f.file);
95
+ if (isProtectedFixPath(path.relative(fs.realpathSync(rootPath), absFile)) || /^~[\\/]/.test(f.file)) return false;
96
+ } catch { return false; }
90
97
  if (NEVER_EDIT.some(p => p.test(absFile.replace(/\\/g, '/')))) return false;
91
98
  if (!fs.existsSync(absFile)) return false;
92
99
  return true;
@@ -160,11 +167,14 @@ export async function autofixCommand(options = {}) {
160
167
  const failed = [];
161
168
 
162
169
  for (const f of fixable) {
163
- const absFile = path.resolve(rootPath, f.file);
164
170
  const fix = f.deepAnalysis.fix;
165
171
 
166
172
  try {
167
- applyInlineAnnotation(absFile, f.line, fix);
173
+ const absFile = resolveProjectFile(rootPath, f.file);
174
+ if (!applyInlineAnnotation(absFile, f.line, fix)) {
175
+ console.log(chalk.gray(` Already annotated: ${f.file}:${f.line ?? ''}`));
176
+ continue;
177
+ }
168
178
  applied.push(f);
169
179
  console.log(chalk.green(` ✔ Annotated: ${f.file}:${f.line ?? ''}`));
170
180
  } catch (err) {
@@ -269,16 +279,20 @@ export async function autofixCommand(options = {}) {
269
279
  * Returns the count of files successfully annotated.
270
280
  * Exported for use by the --agentic audit loop.
271
281
  */
272
- export function applyInlineAnnotations(findings) {
273
- const NEVER_EDIT = new Set(['.env', '.env.local', '.env.production', 'secrets.json', '.npmrc', '.netrc']);
274
- const fixable = findings.filter(f =>
275
- f.fix && f.file && fs.existsSync(f.file) && !NEVER_EDIT.has(path.basename(f.file))
276
- );
282
+ export function applyInlineAnnotations(findings, rootPath = process.cwd()) {
283
+ const neverEdit = new Set(['secrets.json', '.npmrc', '.netrc']);
277
284
  let count = 0;
278
- for (const f of fixable.slice(0, 10)) {
285
+ // Insert from the bottom of each file so earlier insertions do not shift
286
+ // the original line numbers of later findings.
287
+ const ordered = [...findings].sort((a, b) => String(a.file).localeCompare(String(b.file)) || (b.line || 1) - (a.line || 1));
288
+ for (const f of ordered) {
289
+ if (count >= 10) break;
279
290
  try {
280
- applyInlineAnnotation(f.file, f.line, f.fix);
281
- count++;
291
+ if (!f.fix || !f.file || /^(?:~[\\/])/.test(f.file)) continue;
292
+ const absFile = resolveProjectFile(rootPath, f.file);
293
+ const relative = path.relative(fs.realpathSync(rootPath), absFile);
294
+ if (isProtectedFixPath(relative) || neverEdit.has(path.basename(absFile)) || !fs.statSync(absFile).isFile()) continue;
295
+ if (applyInlineAnnotation(absFile, f.line, f.fix)) count++;
282
296
  } catch { /* skip unwritable */ }
283
297
  }
284
298
  return count;
@@ -294,11 +308,15 @@ export function applyInlineAnnotation(filePath, lineNum, fix) {
294
308
  }
295
309
 
296
310
  // Already annotated?
297
- if (idx > 0 && /praxis-fix/i.test(lines[idx - 1])) return;
311
+ if (/^\s*(?:\/\/|#)\s*praxis-fix\b/i.test(lines[idx])) return false;
312
+ for (let previous = idx - 1; previous >= 0 && /^\s*(?:\/\/|#)/.test(lines[previous]); previous--) {
313
+ if (/praxis-fix/i.test(lines[previous])) return false;
314
+ }
298
315
 
299
316
  const indent = lines[idx].match(/^(\s*)/)?.[1] ?? '';
300
317
  const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(filePath);
301
318
  const isPy = /\.py$/.test(filePath);
319
+ if (!isJs && !isPy) throw new Error('Inline annotations require a supported source-code comment syntax');
302
320
 
303
321
  // Wrap fix in a structured annotation comment
304
322
  const fixLines = fix.split('\n').map(l => l.trim()).filter(Boolean);
@@ -317,7 +335,8 @@ export function applyInlineAnnotation(filePath, lineNum, fix) {
317
335
  }
318
336
 
319
337
  lines.splice(idx, 0, annotation);
320
- fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
338
+ writeFileAtomic.sync(filePath, lines.join('\n'), { encoding: 'utf8' });
339
+ return true;
321
340
  }
322
341
 
323
342
  function buildPRBody(applied, failed, reportPath) {
@@ -16,9 +16,10 @@ import fs from 'fs';
16
16
  import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
+ import { displayPath } from '../core/paths.js';
19
20
  import { buildOrchestrator } from '../agents/index.js';
20
21
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
21
- import { isHighEntropyMatch } from '../utils/entropy.js';
22
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
22
23
  import fg from '../core/glob.js';
23
24
 
24
25
  const BASELINE_FILE = '.praxis/baseline.json';
@@ -28,7 +29,7 @@ const BASELINE_FILE = '.praxis/baseline.json';
28
29
  * Uses rule + relative file path + first 40 chars of matched text.
29
30
  */
30
31
  function fingerprint(finding, rootPath) {
31
- const relFile = path.relative(rootPath, finding.file || '').replace(/\\/g, '/');
32
+ const relFile = displayPath(finding.file || '', rootPath);
32
33
  const matched = (finding.matched || '').slice(0, 40);
33
34
  return `${finding.rule}:${relFile}:${matched}`;
34
35
  }
@@ -60,6 +61,7 @@ async function quickScan(rootPath) {
60
61
  p.pattern.lastIndex = 0;
61
62
  let m;
62
63
  while ((m = p.pattern.exec(lines[i])) !== null) {
64
+ if (isDocumentedSecretExample(p.name, m[0])) continue;
63
65
  if (p.requiresEntropyCheck && !isHighEntropyMatch(m[0])) continue;
64
66
  findings.push({ file, line: i + 1, rule: p.name, matched: m[0], severity: p.severity });
65
67
  }
@@ -32,7 +32,7 @@ import {
32
32
  MAX_FILE_SIZE,
33
33
  loadGitignorePatterns
34
34
  } from '../utils/patterns.js';
35
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
35
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
36
36
  import * as output from '../utils/output.js';
37
37
  import fg from '../core/glob.js';
38
38
 
@@ -150,6 +150,7 @@ export async function benchmarkCommand(targetPath = '.', options = {}) {
150
150
  pattern.pattern.lastIndex = 0;
151
151
  let match;
152
152
  while ((match = pattern.pattern.exec(line)) !== null) {
153
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
153
154
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
154
155
  secretFindings.push({
155
156
  file, line: lineNum + 1, column: match.index + 1,
@@ -19,7 +19,9 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
+ import { displayPath } from '../core/paths.js';
22
23
  import { renderFindingsSARIF } from '../core/output/sarif.js';
24
+ import { validateDir } from '../core/fs.js';
23
25
  import { execFileSync } from 'child_process';
24
26
  import { buildOrchestrator } from '../agents/index.js';
25
27
  import { ScoringEngine } from '../agents/scoring-engine.js';
@@ -34,7 +36,7 @@ import {
34
36
  MAX_FILE_SIZE,
35
37
  loadGitignorePatterns
36
38
  } from '../utils/patterns.js';
37
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
39
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
38
40
  import { ThreatIntel } from '../utils/threat-intel.js';
39
41
  import * as intelOrchestrator from '../utils/intel/index.js';
40
42
  import fg from '../core/glob.js';
@@ -44,7 +46,7 @@ import fg from '../core/glob.js';
44
46
  // =============================================================================
45
47
 
46
48
  export async function ciCommand(targetPath = '.', options = {}) {
47
- const absolutePath = path.resolve(targetPath);
49
+ const absolutePath = validateDir(targetPath, { exitOnMissing: false });
48
50
  const threshold = options.threshold ?? 75;
49
51
  const failOn = options.failOn || null;
50
52
  const alwaysFailOn = options.alwaysFailOn || null;
@@ -56,8 +58,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
56
58
  process.exit(2);
57
59
  }
58
60
 
59
- if (!fs.existsSync(absolutePath)) {
60
- console.error(`[praxis] Path does not exist: ${absolutePath}`);
61
+ if (!absolutePath) {
62
+ console.error('[praxis] CI scans require an existing directory.');
61
63
  process.exit(1);
62
64
  }
63
65
 
@@ -87,6 +89,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
87
89
  pattern.pattern.lastIndex = 0;
88
90
  let match;
89
91
  while ((match = pattern.pattern.exec(line)) !== null) {
92
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
93
+
90
94
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
91
95
  secretFindings.push({
92
96
  file, line: lineNum + 1, column: match.index + 1,
@@ -181,7 +185,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
181
185
  }])),
182
186
  ...(options.includeFindings ? {
183
187
  findings: allFindings.map(f => ({
184
- file: path.relative(absolutePath, f.file).replace(/\\/g, '/'),
188
+ file: displayPath(f.file, absolutePath),
185
189
  line: f.line, rule: f.rule, severity: f.severity,
186
190
  })),
187
191
  } : {}),
@@ -203,7 +207,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
203
207
  if (critical > 0) {
204
208
  console.log(`[praxis] Critical findings:`);
205
209
  for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
206
- const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
210
+ const rel = displayPath(f.file, absolutePath);
207
211
  console.log(` - ${f.rule} at ${rel}:${f.line}`);
208
212
  }
209
213
  }
@@ -274,7 +278,7 @@ function emitGitHubAnnotations(findings, rootPath) {
274
278
  if (process.env.GITHUB_ACTIONS !== 'true') return;
275
279
  for (const f of findings) {
276
280
  if (!f.file || !f.line) continue;
277
- const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
281
+ const rel = displayPath(f.file, rootPath);
278
282
  const level = ['critical', 'high'].includes(f.severity) ? 'error' : 'warning';
279
283
  const col = f.column || 1;
280
284
  const escapeData = value => String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
@@ -350,7 +354,7 @@ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
350
354
  body += `### Critical & High Findings\n\n`;
351
355
  body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
352
356
  for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
353
- const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
357
+ const rel = displayPath(f.file, rootPath);
354
358
  body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
355
359
  }
356
360
  body += '\n';
@@ -16,6 +16,7 @@ import { execFileSync } from 'child_process';
16
16
  import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
+ import { displayPath } from '../core/paths.js';
19
20
  import { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_EXTENSIONS, SKIP_FILENAMES } from '../utils/patterns.js';
20
21
  import { buildOrchestrator } from '../agents/index.js';
21
22
  import { ScoringEngine } from '../agents/scoring-engine.js';
@@ -165,7 +166,7 @@ export async function diffCommand(ref, options) {
165
166
  const sevColor = f.severity === 'critical' ? chalk.red :
166
167
  f.severity === 'high' ? chalk.yellow :
167
168
  f.severity === 'medium' ? chalk.cyan : chalk.gray;
168
- const relPath = path.relative(absolutePath, f.file);
169
+ const relPath = displayPath(f.file, absolutePath);
169
170
  console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.title)}`);
170
171
  console.log(chalk.gray(` ${relPath}:${f.line} → ${f.fix || f.description}`));
171
172
  shown++;
@@ -27,6 +27,7 @@ import chalk from 'chalk';
27
27
  import ora from 'ora';
28
28
  import { execFileSync } from 'child_process';
29
29
  import { SECRET_PATTERNS, SKIP_DIRS } from '../utils/patterns.js';
30
+ import { isDocumentedSecretExample } from '../utils/entropy.js';
30
31
 
31
32
  // Minimum value length to cross-reference (skip short values like "true", "3000")
32
33
  const MIN_VALUE_LENGTH = 8;
@@ -138,8 +139,9 @@ export async function envAuditCommand(targetPath = '.', options) {
138
139
  const content = fs.readFileSync(pFile, 'utf-8');
139
140
  for (const pattern of SECRET_PATTERNS) {
140
141
  pattern.pattern.lastIndex = 0;
141
- const match = pattern.pattern.exec(content);
142
- if (match) {
142
+ let match;
143
+ while ((match = pattern.pattern.exec(content)) !== null) {
144
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
143
145
  const relPath = path.relative(absolutePath, pFile).replace(/\\/g, '/');
144
146
  findings.push({
145
147
  type: 'projects-manifest',
@@ -22,7 +22,7 @@ import {
22
22
  TEST_FILE_PATTERNS,
23
23
  MAX_FILE_SIZE
24
24
  } from '../utils/patterns.js';
25
- import { isHighEntropyMatch } from '../utils/entropy.js';
25
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
26
26
  import fg from '../core/glob.js';
27
27
  import * as output from '../utils/output.js';
28
28
 
@@ -111,6 +111,7 @@ async function scanFile(filePath) {
111
111
  pattern.pattern.lastIndex = 0;
112
112
  let match;
113
113
  while ((match = pattern.pattern.exec(line)) !== null) {
114
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
114
115
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
115
116
  findings.push({
116
117
  line: lineNum + 1,
@@ -19,6 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import chalk from 'chalk';
21
21
  import ora from 'ora';
22
+ import { displayPath } from '../core/paths.js';
22
23
  import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
23
24
  import * as output from '../utils/output.js';
24
25
 
@@ -136,7 +137,7 @@ export async function legalCommand(targetPath = '.', options = {}) {
136
137
  const riskLabel = RISK_LABELS[riskKey] || riskKey;
137
138
 
138
139
  console.log(` ${sevBadge} ${chalk.white.bold(f.title)}`);
139
- console.log(` ${riskColor(`[${riskLabel}]`)} ${chalk.gray(path.relative(absolutePath, f.file) || f.file)}`);
140
+ console.log(` ${riskColor(`[${riskLabel}]`)} ${chalk.gray(displayPath(f.file, absolutePath))}`);
140
141
  console.log();
141
142
  console.log(` ${chalk.gray(f.description)}`);
142
143
  console.log();
@@ -34,9 +34,12 @@
34
34
 
35
35
  import fs from 'fs';
36
36
  import path from 'path';
37
+ import writeFileAtomic from 'write-file-atomic';
38
+ import { displayPath } from '../core/paths.js';
39
+ import { validateDir } from '../core/fs.js';
37
40
  import fg from '../core/glob.js';
38
41
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
39
- import { isHighEntropyMatch } from '../utils/entropy.js';
42
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
40
43
  import { buildOrchestrator } from '../agents/index.js';
41
44
  import { ScoringEngine } from '../agents/scoring-engine.js';
42
45
  import { autoDetectProvider } from '../providers/llm-provider.js';
@@ -90,7 +93,7 @@ const TOOLS = [
90
93
  },
91
94
  {
92
95
  name: 'scan_repo',
93
- description: 'Run a full multi-agent security scan on a repository or directory. Runs all 20+ praxis security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
96
+ description: 'Run a full multi-agent security scan on a repository or directory. Runs the 28 built-in Praxis security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
94
97
  inputSchema: {
95
98
  type: 'object',
96
99
  properties: {
@@ -117,7 +120,7 @@ const TOOLS = [
117
120
  },
118
121
  {
119
122
  name: 'get_findings',
120
- description: 'Read and return findings from a praxis JSON report file previously saved by scan_repo or the praxis CLI (npx praxis-sec audit --json). Useful for reviewing or referencing a prior scan without re-running it.',
123
+ description: 'Read and return findings from a praxis JSON report file previously saved by scan_repo or the praxis CLI (praxis scan full --json). Useful for reviewing or referencing a prior scan without re-running it.',
121
124
  inputSchema: {
122
125
  type: 'object',
123
126
  properties: {
@@ -252,11 +255,10 @@ async function analyzeFile({ path: filePath }) {
252
255
  };
253
256
  }
254
257
 
255
- async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }) {
256
- const rootPath = path.resolve(targetPath);
257
-
258
- if (!fs.existsSync(rootPath)) {
259
- return { error: `Path does not exist: ${rootPath}` };
258
+ export async function mcpScanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }, orchestrator = buildOrchestrator()) {
259
+ const rootPath = validateDir(targetPath, { exitOnMissing: false });
260
+ if (!rootPath) {
261
+ return { error: 'Repository scans require an existing directory', scanComplete: false };
260
262
  }
261
263
 
262
264
  // MCP communicates over stdout as JSON-RPC. Suppress all console output during
@@ -269,16 +271,15 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
269
271
  console.log = console.warn = console.error = console.info = noop;
270
272
 
271
273
  try {
272
- const orchestrator = buildOrchestrator();
273
- const context = { rootPath };
274
-
275
274
  // Run all agents (quiet:true suppresses ora spinners; console is already nulled)
276
- const { findings, recon } = await orchestrator.runAll(rootPath, {
275
+ const { findings, recon, agentResults } = await orchestrator.runAll(rootPath, {
277
276
  agents: agentFilter,
278
277
  timeout: 30000,
279
278
  concurrency: 6,
280
279
  quiet: true,
281
280
  });
281
+ const scanErrors = (agentResults ?? []).filter(result => result.success === false)
282
+ .map(result => ({ stage: 'agent', agent: result.agent, message: result.error || 'Agent failed' }));
282
283
 
283
284
  // Optional: LLM deep analysis
284
285
  let deepStats = null;
@@ -288,12 +289,16 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
288
289
  const analyzer = new DeepAnalyzer({ provider, budgetCents: 50, verbose: false });
289
290
  await analyzer.analyze(findings, { rootPath, recon });
290
291
  deepStats = analyzer.getStats();
292
+ } else {
293
+ scanErrors.push({ stage: 'deep-analysis', message: 'Requested LLM analysis has no configured provider' });
291
294
  }
292
295
  }
293
296
 
294
297
  // Score
295
298
  const scorer = new ScoringEngine();
296
- const { score, grade } = scorer.score(findings);
299
+ const scoreResult = scorer.compute(findings);
300
+ const score = scoreResult.score;
301
+ const grade = scoreResult.grade.letter;
297
302
 
298
303
  const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
299
304
  const bySeverity = {};
@@ -304,6 +309,9 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
304
309
  const report = {
305
310
  scannedAt: new Date().toISOString(),
306
311
  rootPath,
312
+ scanComplete: scanErrors.length === 0,
313
+ scanErrors,
314
+ dependencyAudit: 'skipped',
307
315
  score,
308
316
  grade,
309
317
  totalFindings: findings.length,
@@ -313,7 +321,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
313
321
  severity: f.severity,
314
322
  category: f.category,
315
323
  rule: f.rule,
316
- file: f.file ? path.relative(rootPath, f.file) : null,
324
+ file: f.file ? displayPath(f.file, rootPath) : null,
317
325
  line: f.line,
318
326
  description: f.description,
319
327
  remediation: f.remediation,
@@ -321,7 +329,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
321
329
  ...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
322
330
  })),
323
331
  ...(deepStats ? { deepAnalysis: deepStats } : {}),
324
- summary: `Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
332
+ summary: `${scanErrors.length ? 'Incomplete scan. ' : ''}Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
325
333
  };
326
334
 
327
335
  if (outputFile) {
@@ -332,7 +340,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
332
340
 
333
341
  return report;
334
342
  } catch (err) {
335
- return { error: `Scan failed: ${err.message}` };
343
+ return { error: `Scan failed: ${err.message}`, scanComplete: false, scanErrors: [{ stage: 'scan', message: err.message }] };
336
344
  } finally {
337
345
  // Always restore console so other tool calls are not affected
338
346
  console.log = savedLog;
@@ -342,7 +350,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
342
350
  }
343
351
  }
344
352
 
345
- function getFindings({ reportPath, severity }) {
353
+ export function mcpGetFindings({ reportPath, severity }) {
346
354
  const absPath = path.resolve(reportPath);
347
355
 
348
356
  if (!fs.existsSync(absPath)) {
@@ -367,6 +375,9 @@ function getFindings({ reportPath, severity }) {
367
375
  scannedAt: report.scannedAt,
368
376
  score: report.score,
369
377
  grade: report.grade,
378
+ scanComplete: report.scanComplete,
379
+ scanErrors: report.scanErrors,
380
+ dependencyAudit: report.dependencyAudit,
370
381
  totalFindings: filtered.length,
371
382
  bySeverity: report.bySeverity,
372
383
  findings: filtered,
@@ -375,7 +386,7 @@ function getFindings({ reportPath, severity }) {
375
386
  };
376
387
  }
377
388
 
378
- function suppressFinding({ file, line, reason }) {
389
+ export function mcpSuppressFinding({ file, line, reason }) {
379
390
  const absPath = path.resolve(file);
380
391
 
381
392
  if (!fs.existsSync(absPath)) {
@@ -392,7 +403,7 @@ function suppressFinding({ file, line, reason }) {
392
403
  const lines = content.split('\n');
393
404
  const lineIdx = line - 1; // Convert to 0-indexed
394
405
 
395
- if (lineIdx < 0 || lineIdx >= lines.length) {
406
+ if (!Number.isInteger(line) || lineIdx < 0 || lineIdx >= lines.length) {
396
407
  return { error: `Line ${line} is out of range (file has ${lines.length} lines)` };
397
408
  }
398
409
 
@@ -410,7 +421,23 @@ function suppressFinding({ file, line, reason }) {
410
421
  return { error: `Cannot suppress critical severity finding: ${criticalFinding.description}` };
411
422
  }
412
423
 
413
- // Log suppression request to .praxis/suppressions.json for manual audit
424
+ const isSlashComment = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/i.test(file);
425
+ const isHashComment = /\.(py|rb|sh|bash|yaml|yml|toml)$/i.test(file);
426
+ if (!isSlashComment && !isHashComment) {
427
+ return { error: 'Suppression requires a supported source-code comment syntax' };
428
+ }
429
+ const safeReason = String(reason ?? '').replace(/[\r\n\u2028\u2029]/g, ' ').trim();
430
+ if (!safeReason) return { error: 'A suppression reason is required' };
431
+ const ignoreComment = `${isHashComment ? '#' : '//'} praxis-ignore — ${safeReason}`;
432
+ const cr = targetLine.endsWith('\r') ? '\r' : '';
433
+ lines[lineIdx] = `${targetLine.replace(/\r$/, '')} ${ignoreComment}${cr}`;
434
+ try {
435
+ writeFileAtomic.sync(absPath, lines.join('\n'), { encoding: 'utf8' });
436
+ } catch (err) {
437
+ return { error: `Cannot write file: ${err.message}` };
438
+ }
439
+
440
+ // Log only successful suppression writes for manual audit.
414
441
  const suppressionsDir = path.join(process.cwd(), '.praxis');
415
442
  if (!fs.existsSync(suppressionsDir)) {
416
443
  try {
@@ -427,7 +454,7 @@ function suppressFinding({ file, line, reason }) {
427
454
  suppressions.push({
428
455
  file: path.relative(process.cwd(), absPath),
429
456
  line,
430
- reason,
457
+ reason: safeReason,
431
458
  timestamp: new Date().toISOString(),
432
459
  status: 'pending_review'
433
460
  });
@@ -435,38 +462,13 @@ function suppressFinding({ file, line, reason }) {
435
462
  fs.writeFileSync(suppressionsFile, JSON.stringify(suppressions, null, 2), 'utf-8');
436
463
  } catch {}
437
464
 
438
- // Detect indentation and comment style
439
- const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
440
- const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(file);
441
- const isPy = /\.py$/.test(file);
442
- const isRb = /\.rb$/.test(file);
443
- const isHtml = /\.(html?|vue|svelte)$/.test(file);
444
-
445
- let ignoreComment;
446
- if (isHtml) {
447
- ignoreComment = `${indent}<!-- praxis-ignore — ${reason} -->`;
448
- } else if (isPy || isRb) {
449
- ignoreComment = `${indent}# praxis-ignore — ${reason}`;
450
- } else {
451
- ignoreComment = `${indent}// praxis-ignore — ${reason}`;
452
- }
453
-
454
- // Insert ignore comment on the line BEFORE the finding
455
- lines.splice(lineIdx, 0, ignoreComment);
456
-
457
- try {
458
- fs.writeFileSync(absPath, lines.join('\n'), 'utf-8');
459
- } catch (err) {
460
- return { error: `Cannot write file: ${err.message}` };
461
- }
462
-
463
465
  return {
464
466
  suppressed: true,
465
467
  file: absPath,
466
468
  originalLine: line,
467
- insertedLine: line, // The ignore comment is now on this line, original moved to line+1
469
+ insertedLine: line,
468
470
  comment: ignoreComment,
469
- message: `Added praxis-ignore comment before line ${line} in ${path.basename(file)}.`,
471
+ message: `Added trailing praxis-ignore comment on line ${line} in ${path.basename(file)}.`,
470
472
  };
471
473
  }
472
474
 
@@ -505,6 +507,7 @@ function scanFile(filePath) {
505
507
  pattern.pattern.lastIndex = 0;
506
508
  let match;
507
509
  while ((match = pattern.pattern.exec(line)) !== null) {
510
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
508
511
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
509
512
  findings.push({
510
513
  line: lineNum + 1,
@@ -628,13 +631,13 @@ async function handleRequest(request) {
628
631
  result = await analyzeFile(args);
629
632
  break;
630
633
  case 'scan_repo':
631
- result = await scanRepo(args);
634
+ result = await mcpScanRepo(args);
632
635
  break;
633
636
  case 'get_findings':
634
- result = getFindings(args);
637
+ result = mcpGetFindings(args);
635
638
  break;
636
639
  case 'suppress_finding':
637
- result = suppressFinding(args);
640
+ result = mcpSuppressFinding(args);
638
641
  break;
639
642
  case 'explain_and_fix':
640
643
  result = await explainAndFix(args);
@@ -15,6 +15,7 @@
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
17
  import chalk from 'chalk';
18
+ import { displayPath } from '../core/paths.js';
18
19
  import * as output from '../utils/output.js';
19
20
  import { AgentConfigScanner } from '../agents/agent-config-scanner.js';
20
21
  import { MCPSecurityAgent } from '../agents/mcp-security-agent.js';
@@ -138,11 +139,7 @@ async function runJsonMode(absolutePath, options) {
138
139
  const findings = [...configFindings, ...mcpFindings];
139
140
  const normFindings = findings.map(f => ({
140
141
  ...f,
141
- file: String(f.file || '')
142
- .replace(/\\/g, '/')
143
- .replace(/^[a-zA-Z]:\/+/, '')
144
- .replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
145
- .replace(/^.*\/Praxis\//, ''),
142
+ file: displayPath(f.file, absolutePath),
146
143
  }));
147
144
  const result = {
148
145
  findings: normFindings,
@@ -174,7 +171,7 @@ function printFindings(findings, rootPath) {
174
171
  findings.sort((a, b) => (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4));
175
172
 
176
173
  for (const f of findings) {
177
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
174
+ const relFile = displayPath(f.file, rootPath);
178
175
  const sevLabel = f.severity === 'critical' ? chalk.red.bold('CRITICAL')
179
176
  : f.severity === 'high' ? chalk.yellow('HIGH')
180
177
  : chalk.blue('MEDIUM');
@@ -18,6 +18,7 @@ import path from 'path';
18
18
  import { renderFindingsSARIF } from '../core/output/sarif.js';
19
19
  import chalk from 'chalk';
20
20
  import ora from 'ora';
21
+ import { displayPath } from '../core/paths.js';
21
22
  import { buildOrchestratorAsync } from '../agents/index.js';
22
23
  import { SwarmOrchestrator } from '../agents/swarm-orchestrator.js';
23
24
  import { ReconAgent } from '../agents/recon-agent.js';
@@ -267,7 +268,7 @@ function printResults(scoreResult, findings, recon, agentResults, depVulns, root
267
268
  console.log(chalk.yellow(' ' + '─'.repeat(58)));
268
269
 
269
270
  for (const f of findings.slice(0, 20)) {
270
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
271
+ const relFile = displayPath(f.file, rootPath);
271
272
  const sevColor = SEV_COLOR[f.severity] || chalk.white;
272
273
  const aiTag = f.aiClassification === 'FALSE_POSITIVE'
273
274
  ? chalk.gray(' [FP]')
@@ -47,7 +47,7 @@ import {
47
47
  TEST_FILE_PATTERNS,
48
48
  MAX_FILE_SIZE
49
49
  } from '../utils/patterns.js';
50
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
50
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
51
51
  import * as output from '../utils/output.js';
52
52
 
53
53
  // =============================================================================
@@ -468,6 +468,7 @@ async function scanFile(filePath) {
468
468
  pattern.pattern.lastIndex = 0;
469
469
  let match;
470
470
  while ((match = pattern.pattern.exec(line)) !== null) {
471
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
471
472
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
472
473
  findings.push({
473
474
  line: lineNum + 1,
@@ -34,7 +34,7 @@ import {
34
34
  TEST_FILE_PATTERNS,
35
35
  MAX_FILE_SIZE
36
36
  } from '../utils/patterns.js';
37
- import { isHighEntropyMatch } from '../utils/entropy.js';
37
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
38
38
  import * as output from '../utils/output.js';
39
39
 
40
40
  // =============================================================================
@@ -439,6 +439,7 @@ async function scanFile(filePath) {
439
439
  pattern.pattern.lastIndex = 0;
440
440
  let match;
441
441
  while ((match = pattern.pattern.exec(line)) !== null) {
442
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
442
443
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
443
444
  findings.push({
444
445
  line: lineNum + 1,