praxis-sec 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/assets/praxis-architecture.svg +304 -0
  2. package/assets/praxis-logo.svg +38 -0
  3. package/cli/agents/abom-generator.js +1 -1
  4. package/cli/agents/agent-attestation-agent.js +10 -1
  5. package/cli/agents/agent-config-scanner.js +1 -1
  6. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  7. package/cli/agents/base-agent.js +1 -1
  8. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  9. package/cli/agents/html-reporter.js +10 -10
  10. package/cli/agents/index.js +2 -2
  11. package/cli/agents/injection-tester.js +8 -1
  12. package/cli/agents/mcp-security-agent.js +600 -594
  13. package/cli/agents/memory-poisoning-agent.js +1 -1
  14. package/cli/agents/model-file-scanner.js +1 -1
  15. package/cli/agents/orchestrator.js +375 -355
  16. package/cli/agents/prompt-injection-prober.js +228 -228
  17. package/cli/bin/praxis.js +7 -3
  18. package/cli/commands/agent-fix.js +1091 -1245
  19. package/cli/commands/audit.js +1232 -1216
  20. package/cli/commands/baseline.js +3 -2
  21. package/cli/commands/benchmark.js +1 -1
  22. package/cli/commands/ci.js +50 -25
  23. package/cli/commands/deps.js +11 -5
  24. package/cli/commands/diff.js +2 -1
  25. package/cli/commands/env-audit.js +1 -1
  26. package/cli/commands/fix.js +1 -1
  27. package/cli/commands/legal.js +2 -1
  28. package/cli/commands/mcp.js +3 -2
  29. package/cli/commands/openclaw.js +3 -6
  30. package/cli/commands/red-team.js +351 -350
  31. package/cli/commands/remediate.js +1 -1
  32. package/cli/commands/rotate.js +1 -1
  33. package/cli/commands/rules.js +1 -1
  34. package/cli/commands/scan-standard.js +3 -6
  35. package/cli/commands/scan.js +554 -554
  36. package/cli/commands/score.js +1 -1
  37. package/cli/commands/undo.js +22 -77
  38. package/cli/commands/vibe-check.js +3 -2
  39. package/cli/commands/watch.js +6 -5
  40. package/cli/core/fix-plan.js +274 -0
  41. package/cli/core/fs.js +27 -0
  42. package/cli/core/git-clone.js +8 -6
  43. package/cli/core/glob.js +56 -0
  44. package/cli/core/output/html-theme.js +158 -158
  45. package/cli/core/output/json.js +56 -48
  46. package/cli/core/output/sarif.js +2 -2
  47. package/cli/core/paths.js +91 -0
  48. package/cli/core/web/jobs.js +2 -2
  49. package/cli/core/web/server.js +19 -8
  50. package/cli/data/threatpacks/latest.json +41 -41
  51. package/cli/integrations/github-action.js +136 -0
  52. package/cli/utils/cache-manager.js +2 -1
  53. package/cli/utils/plugin-loader.js +15 -95
  54. package/cli/utils/rule-import.js +227 -227
  55. package/cli/utils/rule-registry.js +425 -425
  56. package/cli/utils/scan-fingerprint.js +1 -1
  57. package/cli/utils/score-history.js +118 -118
  58. package/docs/USAGE.md +16 -9
  59. package/docs/design/WEB-UI.md +4 -5
  60. package/package.json +81 -71
@@ -1,41 +1,41 @@
1
- {
2
- "version": "1.1",
3
- "_refresh_policy": {
4
- "cadence": "Incident-driven + weekly review",
5
- "process": "1. Track OWASP LLM/Agentic releases and reported AI attack incidents. 2. Add signatures in own phrasing (never copy AGPL/copyrighted text verbatim). 3. Bump version. 4. Run npm test (prober tests enforce compilation + ReDoS guard).",
6
- "owner": "Praxis contributors",
7
- "lastReviewed": "2026-10-02"
8
- },
9
- "probes": [
10
- {
11
- "id": "TP-001",
12
- "category": "obfuscated-injection",
13
- "title": "Encoded-payload decode instruction (base64/hex/ROT)",
14
- "regex": "(?i)(?:decode|base64|rot1[0-9]|deobfuscate)[\\s\\S]{0,80}(?:then\\s+(?:respond|answer|execute|run)|before\\s+you\\s+(?:respond|answer))",
15
- "severity": "high",
16
- "description": "Source contains a decode-then-execute instruction that tells a model to decode an encoded payload before acting — the obfuscated-injection family used to smuggle instructions past content filters.",
17
- "fix": "Reject decode-first instructions from untrusted content. Never execute content decoded from user-controlled strings."
18
- },
19
- {
20
- "id": "TP-002",
21
- "category": "jailbreak-frame",
22
- "title": "System-prompt redefinition instruction",
23
- "regex": "(?i)(?:redefine|replace|rewrite)\\s+(?:your|the)\\s+(?:system|base)\\s+(?:prompt|instructions|rules)[\\s\\S]{0,120}(?:with|to)\\s+",
24
- "severity": "high",
25
- "description": "Source contains an instruction that asks the model to replace its own system prompt — a jailbreak frame that erases the developer's guardrails.",
26
- "fix": "Strip prompt-redefinition frames. Treat any instruction that modifies the system prompt as untrusted input."
27
- },
28
- {
29
- "id": "TP-003",
30
- "category": "indirect-injection",
31
- "title": "Document-payload split injection (multi-file chaining)",
32
- "regex": "(?i)(?:part\\s*\\d+\\s*of\\s*\\d+|continue\\s+(?:reading|with|in)\\s+(?:the\\s+)?(?:next|following|part|message)|when\\s+combined\\s+with\\s+(?:the\\s+)?(?:above|previous|preceding|earlier|these|prior|other)\\s+(?:instruction|instructions|rule|rules|prompt|prompts|text|content|contents|message|messages|directive|directives|command|commands|guideline|guidelines)\\b)",
33
- "severity": "medium",
34
- "description": "Source contains split-payload markers that chain instructions across documents/messages — the payload-splitting technique that defeats per-input content filters.",
35
- "fix": "Evaluate documents independently; do not concatenate instruction-like text across untrusted sources.",
36
- "_tightened": "P-IMP-057: the bare phrase \"when combined with\" matched ordinary English (e.g. the comment \"when combined with machine output\") and produced a medium-severity finding. It now requires a chaining referent (above/previous/preceding/earlier/these/prior/other) AND an instruction-like noun, so benign prose like \"merged when combined with the previous version\" no longer matches."
37
- }
38
- ],
39
- "eaa": [],
40
- "gateways": []
41
- }
1
+ {
2
+ "version": "1.1",
3
+ "_refresh_policy": {
4
+ "cadence": "Incident-driven + weekly review",
5
+ "process": "1. Track OWASP LLM/Agentic releases and reported AI attack incidents. 2. Add signatures in own phrasing (never copy AGPL/copyrighted text verbatim). 3. Bump version. 4. Run npm test (prober tests enforce compilation + ReDoS guard).",
6
+ "owner": "Praxis contributors",
7
+ "lastReviewed": "2026-10-02"
8
+ },
9
+ "probes": [
10
+ {
11
+ "id": "TP-001",
12
+ "category": "obfuscated-injection",
13
+ "title": "Encoded-payload decode instruction (base64/hex/ROT)",
14
+ "regex": "(?i)(?:decode|base64|rot1[0-9]|deobfuscate)[\\s\\S]{0,80}(?:then\\s+(?:respond|answer|execute|run)|before\\s+you\\s+(?:respond|answer))",
15
+ "severity": "high",
16
+ "description": "Source contains a decode-then-execute instruction that tells a model to decode an encoded payload before acting — the obfuscated-injection family used to smuggle instructions past content filters.",
17
+ "fix": "Reject decode-first instructions from untrusted content. Never execute content decoded from user-controlled strings."
18
+ },
19
+ {
20
+ "id": "TP-002",
21
+ "category": "jailbreak-frame",
22
+ "title": "System-prompt redefinition instruction",
23
+ "regex": "(?i)(?:redefine|replace|rewrite)\\s+(?:your|the)\\s+(?:system|base)\\s+(?:prompt|instructions|rules)[\\s\\S]{0,120}(?:with|to)\\s+",
24
+ "severity": "high",
25
+ "description": "Source contains an instruction that asks the model to replace its own system prompt — a jailbreak frame that erases the developer's guardrails.",
26
+ "fix": "Strip prompt-redefinition frames. Treat any instruction that modifies the system prompt as untrusted input."
27
+ },
28
+ {
29
+ "id": "TP-003",
30
+ "category": "indirect-injection",
31
+ "title": "Document-payload split injection (multi-file chaining)",
32
+ "regex": "(?i)(?:part\\s*\\d+\\s*of\\s*\\d+|continue\\s+(?:reading|with|in)\\s+(?:the\\s+)?(?:next|following|part|message)|when\\s+combined\\s+with\\s+(?:the\\s+)?(?:above|previous|preceding|earlier|these|prior|other)\\s+(?:instruction|instructions|rule|rules|prompt|prompts|text|content|contents|message|messages|directive|directives|command|commands|guideline|guidelines)\\b)",
33
+ "severity": "medium",
34
+ "description": "Source contains split-payload markers that chain instructions across documents/messages — the payload-splitting technique that defeats per-input content filters.",
35
+ "fix": "Evaluate documents independently; do not concatenate instruction-like text across untrusted sources.",
36
+ "_tightened": "The bare phrase \"when combined with\" matched ordinary English (e.g. the comment \"when combined with machine output\") and produced a medium-severity finding. It now requires a chaining referent (above/previous/preceding/earlier/these/prior/other) AND an instruction-like noun, so benign prose like \"merged when combined with the previous version\" no longer matches."
37
+ }
38
+ ],
39
+ "eaa": [],
40
+ "gateways": []
41
+ }
@@ -0,0 +1,136 @@
1
+ /** Marketplace runner: argv-only execution, release-pinned CLI, and one gate decision. */
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { spawnSync, execFileSync } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const cli = fileURLToPath(new URL('../bin/praxis.js', import.meta.url));
9
+ const severities = ['critical', 'high', 'medium', 'low'];
10
+
11
+ export function buildScanArgs(inputs, { base = false, sarif = null } = {}) {
12
+ const args = ['scan', 'ci', inputs.path || '.', '--json', '--threshold', base ? '0' : (inputs.threshold || '75')];
13
+ if (inputs.deps === 'false') args.push('--no-deps');
14
+ if (inputs.deep === 'true') args.push('--deep');
15
+ if (!base && inputs.baseline === 'true') args.push('--baseline');
16
+ if (!base && inputs.failOn) args.push('--fail-on', inputs.failOn);
17
+ if (!base && inputs.alwaysFailOn) args.push('--always-fail-on', inputs.alwaysFailOn);
18
+ if (inputs.netNew === 'true') args.push('--include-findings');
19
+ if (sarif) args.push('--sarif', sarif);
20
+ return args;
21
+ }
22
+
23
+ export function parseScanResult(result) {
24
+ if (result.error) throw result.error;
25
+ if (![0, 1].includes(result.status)) throw new Error(`Praxis scan failed (exit ${result.status})`);
26
+ let report;
27
+ try { report = JSON.parse(result.stdout); } catch { throw new Error('Praxis scan did not produce valid JSON'); } // praxis-ignore AGENT_NO_OUTPUT_SCHEMA — subprocess stdout, not LLM output; fields are checked on the next line
28
+ if (!report || typeof report.pass !== 'boolean' || !Number.isFinite(report.score)) {
29
+ throw new Error('Praxis scan produced an invalid report');
30
+ }
31
+ if (result.status === 1 && report.pass) throw new Error('Praxis scan exit status disagrees with report');
32
+ return report;
33
+ }
34
+
35
+ export function introducedFindings(base, head, floor = 'high') {
36
+ const limit = severities.indexOf(floor);
37
+ if (limit < 0) throw new Error(`Invalid fail-on-new severity: ${floor}`);
38
+ if (!Array.isArray(base.findings) || !Array.isArray(head.findings)) throw new Error('Net-new gate requires finding lists from both scans');
39
+ const identity = finding => `${finding.file}:${finding.rule}`;
40
+ const existing = new Set(base.findings.map(identity));
41
+ const introduced = head.findings.filter(finding => !existing.has(identity(finding)));
42
+ return {
43
+ total: introduced.length,
44
+ blocking: introduced.filter(finding => {
45
+ const severity = severities.indexOf(finding.severity);
46
+ return severity >= 0 && severity <= limit;
47
+ }).length,
48
+ };
49
+ }
50
+
51
+ export function evaluateGate(head, base, inputs) {
52
+ if (!base) return { pass: head.pass, introduced: null };
53
+ if (base.scanComplete === false) throw new Error('Base scan is incomplete; refusing to pass the net-new gate');
54
+ const introduced = introducedFindings(base, head, inputs.failOnNew || 'high');
55
+ return { pass: head.scanComplete !== false && head.floorPass !== false && introduced.blocking === 0, introduced };
56
+ }
57
+
58
+ function scan(inputs, cwd, options = {}) {
59
+ const result = spawnSync(process.execPath, [cli, ...buildScanArgs(inputs, options)], {
60
+ cwd, env: process.env, encoding: 'utf8', timeout: 20 * 60 * 1000, maxBuffer: 32 * 1024 * 1024,
61
+ });
62
+ // GitHub annotations go to stderr, keeping the JSON channel valid.
63
+ if (result.stderr) process.stderr.write(result.stderr);
64
+ return parseScanResult(result);
65
+ }
66
+
67
+ export async function runAction(env = process.env) {
68
+ const inputs = {
69
+ path: env.PRAXIS_INPUT_PATH, threshold: env.PRAXIS_INPUT_THRESHOLD,
70
+ failOn: env.PRAXIS_INPUT_FAIL_ON, alwaysFailOn: env.PRAXIS_INPUT_ALWAYS_FAIL_ON,
71
+ deps: env.PRAXIS_INPUT_DEPS, deep: env.PRAXIS_INPUT_DEEP, baseline: env.PRAXIS_INPUT_BASELINE,
72
+ netNew: env.PRAXIS_INPUT_NET_NEW, failOnNew: env.PRAXIS_INPUT_FAIL_ON_NEW,
73
+ };
74
+ const temp = fs.mkdtempSync(path.join(env.RUNNER_TEMP || os.tmpdir(), 'praxis-action-'));
75
+ const sarif = env.PRAXIS_INPUT_SARIF === 'true' ? path.join(temp, 'results.sarif') : null;
76
+ const root = env.GITHUB_WORKSPACE || process.cwd();
77
+ const head = scan(inputs, root, { sarif });
78
+ let base = null;
79
+ if (inputs.netNew === 'true') {
80
+ if (env.GITHUB_EVENT_NAME !== 'pull_request') throw new Error('net-new requires a pull_request event');
81
+ const sha = env.PRAXIS_BASE_SHA || '';
82
+ if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error('Invalid or missing pull request base SHA');
83
+ const worktree = path.join(temp, 'base');
84
+ let added = false;
85
+ try {
86
+ execFileSync('git', ['fetch', '--depth', '1', 'origin', sha], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
87
+ execFileSync('git', ['worktree', 'add', '--detach', worktree, sha], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
88
+ added = true;
89
+ base = scan(inputs, worktree, { base: true });
90
+ } finally {
91
+ if (added) execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
92
+ }
93
+ }
94
+ const gate = evaluateGate(head, base, inputs);
95
+ const count = category => head.categories?.[category]?.findingCount || 0;
96
+ const outputs = {
97
+ score: head.score, grade: head.grade, findings: head.totalFindings, secrets: count('secrets'),
98
+ vulns: count('injection') + count('auth'), cves: head.totalDepVulns,
99
+ 'sarif-file': sarif && fs.existsSync(sarif) ? sarif : '',
100
+ 'report-url': `${env.GITHUB_SERVER_URL || 'https://github.com'}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
101
+ fail: gate.pass ? '0' : '1',
102
+ };
103
+ if (env.GITHUB_OUTPUT) {
104
+ fs.appendFileSync(env.GITHUB_OUTPUT, Object.entries(outputs).map(([key, value]) => `${key}=${value ?? ''}`).join('\n') + '\n');
105
+ }
106
+ let summary = `## Praxis Security Report\n\nScore: **${head.score}/100 (${head.grade})** · Findings: **${head.totalFindings}** · CVEs: **${head.totalDepVulns}**\n\n`;
107
+ if (gate.introduced) summary += `Net-new gate: **${gate.introduced.total}** introduced finding(s), **${gate.introduced.blocking}** at or above ${inputs.failOnNew || 'high'}.\n\n`;
108
+ summary += gate.pass ? '**Passed** the configured security gate.\n' : '**Failed** the configured security gate.\n';
109
+ if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
110
+ console.log(summary);
111
+ if (env.PRAXIS_INPUT_COMMENT === 'true' && env.GITHUB_EVENT_NAME === 'pull_request') {
112
+ const pr = env.PRAXIS_PR_NUMBER;
113
+ const repository = env.GITHUB_REPOSITORY;
114
+ if (!/^\d+$/.test(pr || '') || !/^[\w.-]+\/[\w.-]+$/.test(repository || '')) throw new Error('Invalid PR context');
115
+ const marker = '<!-- praxis-security-report -->';
116
+ const body = `${marker}\n${summary}\n[Scanned by Praxis](https://github.com/Ganron007/Praxis)`;
117
+ const bodyPath = path.join(temp, 'comment.json');
118
+ fs.writeFileSync(bodyPath, JSON.stringify({ body }));
119
+ try {
120
+ const comments = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp', `repos/${repository}/issues/${pr}/comments`], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).flat();
121
+ const existing = comments.find(comment => comment.user?.type === 'Bot' && comment.body?.includes(marker));
122
+ const endpoint = existing ? `repos/${repository}/issues/comments/${existing.id}` : `repos/${repository}/issues/${pr}/comments`;
123
+ execFileSync('gh', ['api', endpoint, '-X', existing ? 'PATCH' : 'POST', '--input', bodyPath], { env, stdio: ['ignore', 'pipe', 'pipe'] });
124
+ } catch {
125
+ console.error('::warning::Could not post Praxis PR comment. Check pull-requests: write permissions.');
126
+ }
127
+ }
128
+ return gate;
129
+ }
130
+
131
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
132
+ runAction().catch(error => {
133
+ console.error(`::error::${String(error.message).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}`);
134
+ process.exitCode = 1;
135
+ });
136
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
+ import { displayPath } from '../core/paths.js';
20
21
  import crypto from 'crypto';
21
22
  import { toolVersion } from '../core/version.js';
22
23
 
@@ -200,7 +201,7 @@ export class CacheManager {
200
201
  // Group findings by file (relative paths)
201
202
  const lastFindings = {};
202
203
  for (const f of allFindings) {
203
- const relPath = path.relative(this.rootPath, f.file).replace(/\\/g, '/');
204
+ const relPath = displayPath(f.file, this.rootPath);
204
205
  if (!lastFindings[relPath]) lastFindings[relPath] = [];
205
206
  // Store a lightweight copy (no absolute paths)
206
207
  lastFindings[relPath].push({
@@ -3,10 +3,10 @@
3
3
  * ============================================
4
4
  *
5
5
  * Allows users to drop custom security agents into `.praxis/agents/` and
6
- * have them automatically loaded and run alongside the built-in agents.
6
+ * run them alongside the built-in agents after explicit local trust.
7
7
  *
8
8
  * HOW IT WORKS:
9
- * 1. On startup, loadPlugins(rootPath) scans `.praxis/agents/*.js`
9
+ * 1. With explicit trust, loadPlugins(rootPath) scans `.praxis/agents/*.js`
10
10
  * 2. Each file must export a default class that extends BaseAgent
11
11
  * 3. Validated plugins are instantiated and returned for registration
12
12
  * 4. buildOrchestrator() calls loadPlugins() and registers the results
@@ -21,7 +21,7 @@
21
21
  * EXAMPLE PLUGIN:
22
22
  *
23
23
  * // .praxis/agents/my-rule.js
24
- * import { BaseAgent, createFinding } from 'praxis';
24
+ * import { BaseAgent, createFinding } from 'praxis-sec';
25
25
  *
26
26
  * export default class MyCustomRule extends BaseAgent {
27
27
  * constructor() {
@@ -50,12 +50,12 @@
50
50
  * }
51
51
  *
52
52
  * PLUGIN ISOLATION:
53
- * Plugins run in the same process but each agent gets its own timeout (30s).
54
- * A crashing or hanging plugin does not affect other agents.
53
+ * Trusted plugins run in the same process with full operator permissions.
54
+ * Agent promise timeouts are not a sandbox and cannot stop synchronous loops.
55
55
  *
56
56
  * SECURITY NOTE:
57
- * Plugins are arbitrary code executed from the local filesystem. Never install
58
- * plugins from untrusted sources. praxis will warn if plugins are detected.
57
+ * Plugins are arbitrary code with full operator permissions. A normal scan
58
+ * never imports them; trusted local scans opt in with --trust-plugins.
59
59
  */
60
60
 
61
61
  import fs from 'fs';
@@ -69,10 +69,13 @@ const PLUGIN_DIR = '.praxis/agents';
69
69
  * Load custom agent plugins from .praxis/agents/*.js
70
70
  *
71
71
  * @param {string} rootPath — project root directory
72
- * @param {object} options — { verbose, quiet }
72
+ * @param {object} options — { verbose, quiet, trustPlugins }
73
73
  * @returns {Promise<object[]>} — array of instantiated agent objects
74
74
  */
75
75
  export async function loadPlugins(rootPath, options = {}) {
76
+ // Importing a module executes its top-level code before class validation.
77
+ // Scanned repositories are data, never implicit authorization to run plugins.
78
+ if (options.trustPlugins !== true) return [];
76
79
  const pluginDir = path.join(rootPath, PLUGIN_DIR);
77
80
 
78
81
  if (!fs.existsSync(pluginDir)) return [];
@@ -126,89 +129,6 @@ export async function loadPlugins(rootPath, options = {}) {
126
129
  instance.category = 'custom';
127
130
  }
128
131
 
129
- // Sandbox the analyze method to restrict file system access and block shell executions
130
- const originalAnalyze = instance.analyze;
131
- instance.analyze = async function(context) {
132
- const child_process = await import('child_process');
133
-
134
- const originalReadFileSync = fs.readFileSync;
135
- const originalWriteFileSync = fs.writeFileSync;
136
- const originalReadFile = fs.readFile;
137
- const originalWriteFile = fs.writeFile;
138
- const originalPromisesReadFile = fs.promises?.readFile;
139
- const originalPromisesWriteFile = fs.promises?.writeFile;
140
- const originalExec = child_process.exec;
141
- const originalExecSync = child_process.execSync;
142
- const originalSpawn = child_process.spawn;
143
- const originalSpawnSync = child_process.spawnSync;
144
-
145
- const isSafePath = (p) => {
146
- if (!p) return false;
147
- try {
148
- const resolved = path.resolve(context.rootPath, String(p));
149
- return resolved.startsWith(path.resolve(context.rootPath));
150
- } catch {
151
- return false;
152
- }
153
- };
154
-
155
- fs.readFileSync = (p, ...args) => {
156
- if (!isSafePath(p)) throw new Error(`Access denied (sandbox): read outside workspace path ${p}`);
157
- return originalReadFileSync(p, ...args);
158
- };
159
- fs.writeFileSync = (p, ...args) => {
160
- if (!isSafePath(p)) throw new Error(`Access denied (sandbox): write outside workspace path ${p}`);
161
- return originalWriteFileSync(p, ...args);
162
- };
163
- fs.readFile = (p, ...args) => {
164
- if (!isSafePath(p)) throw new Error(`Access denied (sandbox): read outside workspace path ${p}`);
165
- return originalReadFile(p, ...args);
166
- };
167
- fs.writeFile = (p, ...args) => {
168
- if (!isSafePath(p)) throw new Error(`Access denied (sandbox): write outside workspace path ${p}`);
169
- return originalWriteFile(p, ...args);
170
- };
171
- if (fs.promises) {
172
- fs.promises.readFile = async (p, ...args) => {
173
- if (!isSafePath(p)) throw new Error(`Access denied (sandbox): read outside workspace path ${p}`);
174
- return originalPromisesReadFile(p, ...args);
175
- };
176
- fs.promises.writeFile = async (p, ...args) => {
177
- if (!isSafePath(p)) throw new Error(`Access denied (sandbox): write outside workspace path ${p}`);
178
- return originalPromisesWriteFile(p, ...args);
179
- };
180
- }
181
-
182
- // Best-effort lockdown: ESM module namespaces are immutable, so this
183
- // only takes effect where child_process exposes mutable bindings.
184
- // The fs path guard above still applies in all cases.
185
- try {
186
- child_process.exec = () => { throw new Error('Access denied (sandbox): exec not allowed in plugins'); };
187
- child_process.execSync = () => { throw new Error('Access denied (sandbox): execSync not allowed in plugins'); };
188
- child_process.spawn = () => { throw new Error('Access denied (sandbox): spawn not allowed in plugins'); };
189
- child_process.spawnSync = () => { throw new Error('Access denied (sandbox): spawnSync not allowed in plugins'); };
190
- } catch { /* immutable ESM namespace */ }
191
-
192
- try {
193
- return await originalAnalyze.call(this, context);
194
- } finally {
195
- fs.readFileSync = originalReadFileSync;
196
- fs.writeFileSync = originalWriteFileSync;
197
- fs.readFile = originalReadFile;
198
- fs.writeFile = originalWriteFile;
199
- if (fs.promises) {
200
- fs.promises.readFile = originalPromisesReadFile;
201
- fs.promises.writeFile = originalPromisesWriteFile;
202
- }
203
- try {
204
- child_process.exec = originalExec;
205
- child_process.execSync = originalExecSync;
206
- child_process.spawn = originalSpawn;
207
- child_process.spawnSync = originalSpawnSync;
208
- } catch { /* immutable ESM namespace */ }
209
- }
210
- };
211
-
212
132
  plugins.push(instance);
213
133
 
214
134
  if (!options.quiet) {
@@ -283,8 +203,8 @@ export function scaffoldPlugin(rootPath, pluginName) {
283
203
  const template = `/**
284
204
  * Custom Praxis Agent: ${className}
285
205
  *
286
- * Drop this file in .praxis/agents/ to have it run automatically
287
- * as part of every \`praxis audit\` or \`praxis watch --deep\`.
206
+ * Drop this file in .praxis/agents/ and explicitly opt in with
207
+ * \`praxis scan full . --trust-plugins\` after reviewing its code.
288
208
  *
289
209
  * The \`analyze(context)\` method receives:
290
210
  * context.rootPath — absolute path to the project root
@@ -304,9 +224,9 @@ if (globalThis.__praxisAgentFramework) {
304
224
  ({ BaseAgent, createFinding } = globalThis.__praxisAgentFramework);
305
225
  } else {
306
226
  try {
307
- ({ BaseAgent, createFinding } = await import('praxis'));
227
+ ({ BaseAgent, createFinding } = await import('praxis-sec'));
308
228
  } catch {
309
- ({ BaseAgent, createFinding } = await import('praxis/cli/index.js'));
229
+ ({ BaseAgent, createFinding } = await import('praxis-sec/cli/index.js'));
310
230
  }
311
231
  }
312
232