wize-dev-kit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +1 -1
  3. package/src/security-overlay/_shared/allowlist.js +154 -0
  4. package/src/security-overlay/_shared/cli-runner.js +87 -0
  5. package/src/security-overlay/_shared/cvss.js +108 -0
  6. package/src/security-overlay/_shared/detect.js +125 -0
  7. package/src/security-overlay/_shared/install-script.js +205 -0
  8. package/src/security-overlay/_shared/invoke-phase.js +86 -0
  9. package/src/security-overlay/_shared/owasp.js +56 -0
  10. package/src/security-overlay/_shared/partial.js +225 -0
  11. package/src/security-overlay/_shared/preflight.js +175 -0
  12. package/src/security-overlay/_shared/scope-gate.js +172 -0
  13. package/src/security-overlay/_shared/scope-parser.js +120 -0
  14. package/src/security-overlay/agents/red-teamer/agent.yaml +51 -0
  15. package/src/security-overlay/agents/red-teamer/persona.md +43 -0
  16. package/src/security-overlay/data/common.txt +115 -0
  17. package/src/security-overlay/data/owasp-top10.json +15 -0
  18. package/src/security-overlay/data/tool-allowlist.json +31 -0
  19. package/src/security-overlay/skills/wize-sec-enumerate/scripts/run-enumerate.js +180 -0
  20. package/src/security-overlay/skills/wize-sec-enumerate/skill.md +32 -0
  21. package/src/security-overlay/skills/wize-sec-exploit/data/common.txt +117 -0
  22. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-ffuf.js +147 -0
  23. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nikto.js +145 -0
  24. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-nuclei.js +176 -0
  25. package/src/security-overlay/skills/wize-sec-exploit/scripts/run-sqlmap.js +139 -0
  26. package/src/security-overlay/skills/wize-sec-pentest/scripts/run-pipeline.js +157 -0
  27. package/src/security-overlay/skills/wize-sec-pentest/skill.md +52 -0
  28. package/src/security-overlay/skills/wize-sec-recon/scripts/run-gitleaks.js +139 -0
  29. package/src/security-overlay/skills/wize-sec-recon/scripts/run-osv.js +227 -0
  30. package/src/security-overlay/skills/wize-sec-recon/scripts/run-recon.js +162 -0
  31. package/src/security-overlay/skills/wize-sec-recon/skill.md +35 -0
  32. package/src/security-overlay/skills/wize-sec-report/scripts/render-report.js +999 -0
  33. package/tools/installer/onboarding.js +1 -0
  34. package/tools/installer/render-shared.js +5 -1
  35. package/tools/installer/wize-cli.js +8 -1
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ // install-script.js — generates a self-contained install script for the
4
+ // security-overlay pentest tools, using the CORRECT install source per
5
+ // tool. Most security tools are NOT in apt/brew: they are Go binaries
6
+ // shipped as GitHub releases. Treating everything as a PM package
7
+ // produces a script that fails (apt has no `gitleaks`/`nuclei`/`ffuf`/
8
+ // `osv-scanner`/`grype`). So each tool declares its real method:
9
+ //
10
+ // - 'pm' : available in the OS package manager (nmap, nikto, sqlmap, curl)
11
+ // - 'github' : a GitHub release asset (gitleaks, nuclei, ffuf, osv-scanner)
12
+ // - 'script' : an official installer script (grype)
13
+ //
14
+ // The generated bash script is idempotent and installs only what's missing.
15
+
16
+ const HEADER = `#!/usr/bin/env bash
17
+ # security-overlay — install pentest tools
18
+ # Generated by wize-sec-preflight. Safe to re-run (idempotent).
19
+ #
20
+ # Most of these tools are NOT in apt/brew — they are GitHub release
21
+ # binaries. This script installs each from its correct source.
22
+ # GitHub binaries go to ~/.local/bin (no sudo needed for those).
23
+ set -euo pipefail
24
+
25
+ LOCAL_BIN="\${HOME}/.local/bin"
26
+ mkdir -p "\${LOCAL_BIN}"
27
+ case ":\${PATH}:" in
28
+ *":\${LOCAL_BIN}:"*) : ;;
29
+ *) echo "NOTE: add \${LOCAL_BIN} to your PATH (e.g. in ~/.bashrc):"
30
+ echo ' export PATH="$HOME/.local/bin:$PATH"' ;;
31
+ esac
32
+
33
+ # Detect arch for GitHub release downloads.
34
+ ARCH="$(uname -m)"
35
+ case "\${ARCH}" in
36
+ x86_64|amd64) GH_ARCH="amd64"; GH_ARCH_ALT="x64" ;;
37
+ aarch64|arm64) GH_ARCH="arm64"; GH_ARCH_ALT="arm64" ;;
38
+ *) GH_ARCH="amd64"; GH_ARCH_ALT="x64" ;;
39
+ esac
40
+ OS_LC="$(uname -s | tr '[:upper:]' '[:lower:]')"
41
+
42
+ # Helper: download a tar.gz GitHub release and install one binary from it.
43
+ gh_targz() { # $1=url $2=binary-name-inside-archive
44
+ local url="$1" bin="$2" tmp
45
+ tmp="$(mktemp -d)"
46
+ echo " ↓ \${bin} <- \${url}"
47
+ curl -fsSL "\${url}" -o "\${tmp}/a.tgz"
48
+ tar -xzf "\${tmp}/a.tgz" -C "\${tmp}"
49
+ install -m 0755 "\${tmp}/\${bin}" "\${LOCAL_BIN}/\${bin}"
50
+ rm -rf "\${tmp}"
51
+ }
52
+
53
+ gh_zip() { # $1=url $2=binary-name-inside-archive
54
+ local url="$1" bin="$2" tmp
55
+ tmp="$(mktemp -d)"
56
+ echo " ↓ \${bin} <- \${url}"
57
+ curl -fsSL "\${url}" -o "\${tmp}/a.zip"
58
+ unzip -q -o "\${tmp}/a.zip" -d "\${tmp}"
59
+ install -m 0755 "\${tmp}/\${bin}" "\${LOCAL_BIN}/\${bin}"
60
+ rm -rf "\${tmp}"
61
+ }
62
+ `;
63
+
64
+ // Per-tool install recipe. Versions are pinned to known-good releases; the
65
+ // user can bump them. {GH_ARCH}/{GH_ARCH_ALT}/{OS} are substituted by the
66
+ // bash script's own variables at run time (we emit them as shell vars).
67
+ const TOOL_RECIPES = {
68
+ // --- package-manager tools ---
69
+ nmap: { via: 'pm' },
70
+ nikto: { via: 'pm' },
71
+ sqlmap: { via: 'pm' },
72
+ curl: { via: 'pm' },
73
+
74
+ // --- GitHub release binaries ---
75
+ gitleaks: {
76
+ via: 'github',
77
+ // gitleaks_8.18.4_linux_x64.tar.gz (note: x64/arm64, not amd64)
78
+ cmd: 'gh_targz "https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_${OS_LC}_${GH_ARCH_ALT}.tar.gz" gitleaks'
79
+ },
80
+ nuclei: {
81
+ via: 'github',
82
+ // nuclei_3.3.7_linux_amd64.zip
83
+ cmd: 'gh_zip "https://github.com/projectdiscovery/nuclei/releases/download/v3.3.7/nuclei_3.3.7_${OS_LC}_${GH_ARCH}.zip" nuclei'
84
+ },
85
+ ffuf: {
86
+ via: 'github',
87
+ // ffuf_2.1.0_linux_amd64.tar.gz
88
+ cmd: 'gh_targz "https://github.com/ffuf/ffuf/releases/download/v2.1.0/ffuf_2.1.0_${OS_LC}_${GH_ARCH}.tar.gz" ffuf'
89
+ },
90
+ 'osv-scanner': {
91
+ via: 'github',
92
+ // osv-scanner_linux_amd64 (raw binary, not archived)
93
+ cmd: 'echo " ↓ osv-scanner"; curl -fsSL "https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_${OS_LC}_${GH_ARCH}" -o "${LOCAL_BIN}/osv-scanner"; chmod 0755 "${LOCAL_BIN}/osv-scanner"'
94
+ },
95
+
96
+ // --- official installer script ---
97
+ grype: {
98
+ via: 'script',
99
+ cmd: 'curl -fsSL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "${LOCAL_BIN}"'
100
+ }
101
+ };
102
+
103
+ const TOOL_URLS = {
104
+ nmap: 'https://nmap.org/download.html',
105
+ nuclei: 'https://github.com/projectdiscovery/nuclei',
106
+ gitleaks: 'https://github.com/gitleaks/gitleaks',
107
+ 'osv-scanner': 'https://github.com/google/osv-scanner',
108
+ grype: 'https://github.com/anchore/grype',
109
+ nikto: 'https://github.com/sullo/nikto',
110
+ sqlmap: 'https://github.com/sqlmapproject/sqlmap',
111
+ ffuf: 'https://github.com/ffuf/ffuf'
112
+ };
113
+
114
+ const PM_INSTALL = {
115
+ apt: (pkgs) => `sudo apt-get update && sudo apt-get install -y ${pkgs.join(' ')}`,
116
+ dnf: (pkgs) => `sudo dnf install -y ${pkgs.join(' ')}`,
117
+ pacman: (pkgs) => `sudo pacman -Syu --noconfirm ${pkgs.join(' ')}`,
118
+ zypper: (pkgs) => `sudo zypper install -y ${pkgs.join(' ')}`,
119
+ apk: (pkgs) => `sudo apk add ${pkgs.join(' ')}`,
120
+ brew: (pkgs) => `brew install ${pkgs.join(' ')}`,
121
+ scoop: (pkgs) => `scoop install ${pkgs.join(' ')}`,
122
+ chocolatey: (pkgs) => `choco install -y ${pkgs.join(' ')}`
123
+ };
124
+
125
+ function generateInstallScript(preflight) {
126
+ const { os, arch, packageManager, missing = [] } = preflight;
127
+ const lines = [HEADER];
128
+
129
+ lines.push(`# OS: ${os} (${arch})`);
130
+ lines.push(`# Package manager: ${packageManager || 'none'}`);
131
+ lines.push(`# Missing tools: ${missing.length === 0 ? '(none)' : missing.join(', ')}`);
132
+ lines.push('');
133
+
134
+ if (missing.length === 0) {
135
+ lines.push('echo "All tools are already installed. Nothing to do."');
136
+ return lines.join('\n');
137
+ }
138
+
139
+ // Bucket the missing tools by install method.
140
+ const pmTools = [];
141
+ const ghTools = [];
142
+ const scriptTools = [];
143
+ const unknownTools = [];
144
+ for (const tool of missing) {
145
+ const r = TOOL_RECIPES[tool];
146
+ if (!r) { unknownTools.push(tool); continue; }
147
+ if (r.via === 'pm') pmTools.push(tool);
148
+ else if (r.via === 'github') ghTools.push(tool);
149
+ else if (r.via === 'script') scriptTools.push(tool);
150
+ }
151
+
152
+ // 1. Package-manager tools (need a known PM).
153
+ if (pmTools.length) {
154
+ const pmFn = PM_INSTALL[packageManager];
155
+ if (pmFn) {
156
+ lines.push(`echo "==> Installing via ${packageManager}: ${pmTools.join(', ')}"`);
157
+ lines.push(pmFn(pmTools));
158
+ } else {
159
+ lines.push(`echo "==> These need a package manager (none detected): ${pmTools.join(', ')}"`);
160
+ for (const t of pmTools) lines.push(`echo " ${t}: ${TOOL_URLS[t] || ''}"`);
161
+ }
162
+ lines.push('');
163
+ }
164
+
165
+ // 2. GitHub release binaries.
166
+ if (ghTools.length) {
167
+ lines.push(`echo "==> Installing GitHub-release binaries to \${LOCAL_BIN}: ${ghTools.join(', ')}"`);
168
+ for (const t of ghTools) {
169
+ lines.push(TOOL_RECIPES[t].cmd);
170
+ }
171
+ lines.push('');
172
+ }
173
+
174
+ // 3. Official installer scripts.
175
+ if (scriptTools.length) {
176
+ lines.push(`echo "==> Installing via official scripts: ${scriptTools.join(', ')}"`);
177
+ for (const t of scriptTools) {
178
+ lines.push(TOOL_RECIPES[t].cmd);
179
+ }
180
+ lines.push('');
181
+ }
182
+
183
+ // 4. Anything we don't have a recipe for.
184
+ if (unknownTools.length) {
185
+ lines.push(`echo "==> Manual install required for: ${unknownTools.join(', ')}"`);
186
+ for (const t of unknownTools) {
187
+ lines.push(`echo " ${t}: ${TOOL_URLS[t] || 'https://www.google.com/search?q=' + encodeURIComponent(t + ' install')}"`);
188
+ }
189
+ lines.push('');
190
+ }
191
+
192
+ // Verification.
193
+ lines.push('echo ""');
194
+ lines.push('echo "==> Verifying..."');
195
+ for (const tool of missing) {
196
+ lines.push(`command -v ${tool} >/dev/null 2>&1 && echo " ✓ ${tool}" || echo " ✗ ${tool} (not on PATH yet)"`);
197
+ }
198
+ lines.push('');
199
+ lines.push('echo "Done. If any tool shows ✗, ensure ~/.local/bin is on your PATH and re-open the shell."');
200
+ lines.push('echo "Then run /wize-sec-pentest."');
201
+
202
+ return lines.join('\n');
203
+ }
204
+
205
+ module.exports = { generateInstallScript, TOOL_RECIPES, TOOL_URLS, PM_INSTALL };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // invoke-phase.js — single point that spawns a phase skill as a Node
4
+ // subprocess. The orchestrator (wize-sec-pentest) calls invokePhase to
5
+ // run each phase in sequence. Failures return {ok:false, code} — the
6
+ // orchestrator decides whether to continue or abort.
7
+ //
8
+ // Security invariants (enforced by the canary test in
9
+ // test/security-overlay/invoke-phase.test.js):
10
+ // - NEVER uses shell:true (no command-injection escape).
11
+ // - Skill names cannot escape the kit root via path traversal.
12
+
13
+ const { spawn } = require('node:child_process');
14
+ const path = require('node:path');
15
+ const fs = require('node:fs');
16
+
17
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per phase
18
+
19
+ // resolvePhaseScript(skill, { kitRoot }) -> absolute path.
20
+ // Convention: src/security-overlay/skills/<skill>/scripts/<last-segment>.js
21
+ // E.g. wize-sec-recon -> .../skills/wize-sec-recon/scripts/recon.js.
22
+ // The path is computed even if the file does not exist (callers that want
23
+ // to verify existence should call fs.existsSync on the result).
24
+ function resolvePhaseScript(skill, opts = {}) {
25
+ if (typeof skill !== 'string' || !skill) return null;
26
+ // Reject anything that tries to escape via ../
27
+ if (skill.includes('..') || skill.includes('/') || skill.includes('\\') || path.isAbsolute(skill)) {
28
+ throw new Error(`phase-skill-name-traversal: refused ${JSON.stringify(skill)}`);
29
+ }
30
+ const kitRoot = opts.kitRoot || path.resolve(__dirname, '..', '..', '..');
31
+ // Convention: src/security-overlay/skills/<skill>/scripts/<scriptName>
32
+ // where scriptName is the explicit opts.scriptName (must include .js)
33
+ // if given, otherwise 'run-<lastSegment>.js'.
34
+ const scriptName = opts.scriptName || `run-${skill.split('-').pop()}.js`;
35
+ return path.join(kitRoot, 'src', 'security-overlay', 'skills', skill, 'scripts', scriptName);
36
+ }
37
+
38
+ // invokePhase(skill, opts) -> Promise<{ok, code, stdout, stderr, error?}>.
39
+ // opts: { kitRoot?, active?, extraArgs?: string[], timeout?: ms }
40
+ // Does NOT throw on subprocess failure — returns {ok:false, code, error}.
41
+ function invokePhase(skill, opts = {}) {
42
+ return new Promise(resolve => {
43
+ let script;
44
+ try {
45
+ script = resolvePhaseScript(skill, opts);
46
+ } catch (e) {
47
+ return resolve({ ok: false, code: -1, stdout: '', stderr: '', error: String(e.message || e) });
48
+ }
49
+ if (!script || !fs.existsSync(script)) {
50
+ return resolve({ ok: false, code: -1, stdout: '', stderr: '', error: `phase script not found for ${skill}` });
51
+ }
52
+
53
+ const argv = [];
54
+ if (opts.active) argv.push('--active');
55
+ if (opts.securityDir) argv.push(`--securityDir=${opts.securityDir}`);
56
+ if (opts.scopePath) argv.push(`--scope=${opts.scopePath}`);
57
+ if (Array.isArray(opts.extraArgs)) argv.push(...opts.extraArgs);
58
+
59
+ const child = spawn(process.execPath, [script, ...argv], {
60
+ stdio: ['ignore', 'pipe', 'pipe'],
61
+ // shell: false is the default in spawn(); we do not set it.
62
+ timeout: opts.timeout || DEFAULT_TIMEOUT_MS
63
+ });
64
+
65
+ let stdout = '';
66
+ let stderr = '';
67
+ child.stdout.on('data', d => { stdout += d.toString(); });
68
+ child.stderr.on('data', d => { stderr += d.toString(); });
69
+
70
+ child.on('error', err => {
71
+ resolve({ ok: false, code: -1, stdout, stderr, error: String(err.message || err) });
72
+ });
73
+ child.on('close', code => {
74
+ resolve({ ok: code === 0, code: code == null ? -1 : code, stdout, stderr });
75
+ });
76
+ });
77
+ }
78
+
79
+ // Exposed for tests that need to introspect the spawn call.
80
+ function _spawnForTest(...args) { return spawn(...args); }
81
+
82
+ module.exports = {
83
+ invokePhase,
84
+ resolvePhaseScript,
85
+ _spawnForTest
86
+ };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ // owasp.js — maps a finding (rule id / cve / poc) to an OWASP Top 10 (2021)
4
+ // category. Zero-dep: the category table is loaded from
5
+ // src/security-overlay/data/owasp-top10.json.
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const DATA_PATH = path.join(__dirname, '..', 'data', 'owasp-top10.json');
11
+
12
+ let _cache = null;
13
+ function _load() {
14
+ if (_cache) return _cache;
15
+ const raw = fs.readFileSync(DATA_PATH, 'utf8');
16
+ const parsed = JSON.parse(raw);
17
+ _cache = parsed.categories || [];
18
+ return _cache;
19
+ }
20
+
21
+ function listOwaspCategories() {
22
+ return _load().slice();
23
+ }
24
+
25
+ function listOwaspCategoryIds() {
26
+ return _load().map(c => c.id);
27
+ }
28
+
29
+ // Ordered rules: the FIRST match wins. We check cve before generic rule
30
+ // because a finding with a CVE is unambiguously A06 (vulnerable components).
31
+ const RULES = [
32
+ { match: f => !!f.cve, to: 'A06:2021' },
33
+ { match: f => /sqli|sql[-_ ]?injection|injection/i.test(f.rule || ''), to: 'A03:2021' },
34
+ { match: f => /\bxss\b/i.test(f.rule || ''), to: 'A03:2021' },
35
+ { match: f => /\bauth[-_ ]?bypass|auth[-_ ]?bypass|session/i.test(f.rule || ''), to: 'A07:2021' },
36
+ { match: f => /\btls|cert|cipher|\bssl\b/i.test(f.rule || ''), to: 'A02:2021' },
37
+ { match: f => /\bcors|\bcsp|headers?|\bhttponly/i.test(f.rule || ''), to: 'A05:2021' },
38
+ { match: f => /\bexposed|\bdisclosed|server-status|admin\b/i.test(f.rule || ''), to: 'A05:2021' },
39
+ { match: f => /\bssrf|redirect/i.test(f.rule || ''), to: 'A10:2021' }
40
+ ];
41
+
42
+ function tagOwasp(finding) {
43
+ if (!finding || typeof finding !== 'object') return 'UNKNOWN';
44
+ for (const r of RULES) {
45
+ try {
46
+ if (r.match(finding)) return r.to;
47
+ } catch (_) { /* defensive: never throw from a tagger */ }
48
+ }
49
+ return 'UNKNOWN';
50
+ }
51
+
52
+ module.exports = {
53
+ tagOwasp,
54
+ listOwaspCategories,
55
+ listOwaspCategoryIds
56
+ };
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ // partial.js — the contract between phase skills and the report render.
4
+ //
5
+ // All phase skills write their findings to <securityDir>/<phase>.md using
6
+ // this helper. The format is a YAML frontmatter (zero-dep serialization)
7
+ // followed by `## <heading>` sections in a stable order. The report
8
+ // (wize-sec-report) consumes these parciais and produces the final MD/HTML.
9
+ //
10
+ // Reruns are idempotent: writePartial overwrites the file in place; sections
11
+ // are rendered in the order provided by the caller.
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const PARTIALS_SUBDIR = '.wize/security';
17
+
18
+ // --- serialization helpers (zero-dep) ------------------------------------
19
+
20
+ // YAML-quote a string value (only when needed). Strings that look like
21
+ // numbers or booleans are still rendered as plain scalars — but the parse
22
+ // side uses a STRICT type-coercion only when the string came in as a
23
+ // non-string. That keeps version="2.9" round-tripping as a string.
24
+ function yamlScalar(value) {
25
+ if (value === null || value === undefined) return 'null';
26
+ if (typeof value === 'boolean') return String(value);
27
+ if (typeof value === 'number') return String(value);
28
+ if (typeof value === 'string') {
29
+ // Quote when the string contains characters that YAML would mis-parse
30
+ // OR when the string is empty / has leading/trailing whitespace.
31
+ if (/[:#&*?|<>=!%@`\n]/.test(value) || value === '' || /^\s|\s$/.test(value)) {
32
+ return JSON.stringify(value);
33
+ }
34
+ return value;
35
+ }
36
+ throw new Error(`yamlScalar: unsupported type ${typeof value}`);
37
+ }
38
+
39
+ // Build a nested YAML mapping under a fixed indent. Used for `tools:` and
40
+ // any other { name: { ... } } structure. Depth is unbounded; objects are
41
+ // rendered as nested mappings, scalars as flat `key: value` lines.
42
+ function renderNestedMap(obj, indent) {
43
+ const pad = ' '.repeat(indent);
44
+ const inner = ' '.repeat(indent + 2);
45
+ const out = [];
46
+ for (const [k, v] of Object.entries(obj)) {
47
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
48
+ out.push(`${pad}${k}:`);
49
+ for (const [k2, v2] of Object.entries(v)) {
50
+ if (v2 && typeof v2 === 'object' && !Array.isArray(v2)) {
51
+ out.push(`${inner}${k2}:`);
52
+ const inner2 = ' '.repeat(indent + 4);
53
+ for (const [k3, v3] of Object.entries(v2)) {
54
+ out.push(`${inner2}${k3}: ${yamlScalar(v3)}`);
55
+ }
56
+ } else {
57
+ out.push(`${inner}${k2}: ${yamlScalar(v2)}`);
58
+ }
59
+ }
60
+ } else {
61
+ out.push(`${pad}${k}: ${yamlScalar(v)}`);
62
+ }
63
+ }
64
+ return out.join('\n');
65
+ }
66
+
67
+ // Parse a flat YAML frontmatter into an object. We only accept the limited
68
+ // shape the helper writes: flat `key: value` lines plus a `tools:` block
69
+ // that may nest 2 levels deep (tool -> { key: value }). This is
70
+ // intentionally not a general YAML parser.
71
+ function parseFrontmatter(text) {
72
+ const lines = text.split('\n');
73
+ const out = {};
74
+ let inTools = false;
75
+ let tools = null;
76
+ let toolKey = null;
77
+ for (const raw of lines) {
78
+ if (inTools) {
79
+ if (raw.trim() === '') continue;
80
+ // 2-space-indented top-level key under `tools:`.
81
+ const toolKeyMatch = raw.match(/^ ([a-zA-Z_][a-zA-Z0-9_-]*):\s*$/);
82
+ if (toolKeyMatch) {
83
+ toolKey = toolKeyMatch[1];
84
+ tools[toolKey] = {};
85
+ continue;
86
+ }
87
+ // 4-space-indented mapping under the current tool.
88
+ const valMatch = raw.match(/^ ([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*?)\s*$/);
89
+ if (valMatch && toolKey) {
90
+ tools[toolKey][valMatch[1]] = coerceScalar(valMatch[2]);
91
+ continue;
92
+ }
93
+ // Anything else ends the tools block.
94
+ if (/^\S/.test(raw)) {
95
+ inTools = false;
96
+ toolKey = null;
97
+ // fall through to the top-level branch on the same line
98
+ } else {
99
+ continue;
100
+ }
101
+ }
102
+ if (/^[a-zA-Z_][a-zA-Z0-9_-]*:(\s|$)/.test(raw)) {
103
+ const m = raw.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*?)\s*$/);
104
+ if (!m) continue;
105
+ const [, k, vRaw] = m;
106
+ const v = vRaw.replace(/^['"]|['"]$/g, '');
107
+ if (k === 'tools') {
108
+ inTools = true;
109
+ tools = {};
110
+ out.tools = tools;
111
+ toolKey = null;
112
+ continue;
113
+ }
114
+ out[k] = coerceScalar(v);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function coerceScalar(v) {
121
+ // Strings are kept as strings (so version="2.9" round-trips as a string,
122
+ // not a number). Booleans and null are explicit.
123
+ if (v === 'true') return true;
124
+ if (v === 'false') return false;
125
+ if (v === 'null') return null;
126
+ // Inline JSON array: [a, b, c]
127
+ if (/^\[.*\]$/.test(v)) {
128
+ try { return JSON.parse(v); } catch (_) { /* fall through */ }
129
+ }
130
+ return v;
131
+ }
132
+
133
+ // --- public API ----------------------------------------------------------
134
+
135
+ function defaultSecurityDir() {
136
+ return path.join(process.cwd(), PARTIALS_SUBDIR);
137
+ }
138
+
139
+ // Refuse phase names that try to escape the securityDir.
140
+ function assertSafePhase(phase) {
141
+ if (typeof phase !== 'string' || !phase) {
142
+ throw new Error('invalid phase: empty');
143
+ }
144
+ if (phase.includes('..') || phase.includes('/') || phase.includes('\\') || path.isAbsolute(phase)) {
145
+ throw new Error(`partial phase-name-traversal: refused ${JSON.stringify(phase)}`);
146
+ }
147
+ }
148
+
149
+ // writePartial({ securityDir?, phase, mode, scope, status, tools?, sections })
150
+ // - sections: object of { heading: content } (preserves order via Object.keys).
151
+ // - Returns the absolute path of the written file.
152
+ function writePartial(opts) {
153
+ if (!opts || typeof opts !== 'object') throw new Error('writePartial: opts required');
154
+ const phase = opts.phase;
155
+ assertSafePhase(phase);
156
+ const sec = opts.securityDir || defaultSecurityDir();
157
+ fs.mkdirSync(sec, { recursive: true });
158
+
159
+ const scope = opts.scope || {};
160
+ const scopeSha = (scope.frontmatter && scope.frontmatter.scope_sha256) || (opts.scopeSha256 || '');
161
+
162
+ const fm = [];
163
+ fm.push('---');
164
+ fm.push(`phase: ${yamlScalar(phase)}`);
165
+ fm.push(`generated_at: ${new Date().toISOString()}`);
166
+ fm.push(`scope_sha256: ${yamlScalar(scopeSha)}`);
167
+ fm.push(`mode: ${yamlScalar(opts.mode || 'passive')}`);
168
+ fm.push(`partial_status: ${yamlScalar(opts.status || 'complete')}`);
169
+ if (Array.isArray(opts.dependsOn) && opts.dependsOn.length) {
170
+ const arr = '[' + opts.dependsOn.map(v => JSON.stringify(String(v))).join(', ') + ']';
171
+ fm.push(`depends_on: ${arr}`);
172
+ }
173
+ if (opts.tools && Object.keys(opts.tools).length) {
174
+ fm.push(renderNestedMap({ tools: opts.tools }, 0));
175
+ }
176
+ fm.push('---');
177
+ fm.push('');
178
+
179
+ const body = [];
180
+ for (const [heading, content] of Object.entries(opts.sections || {})) {
181
+ body.push(`## ${heading}`);
182
+ body.push('');
183
+ body.push(String(content == null ? '' : content).trimEnd());
184
+ body.push('');
185
+ }
186
+
187
+ const file = path.join(sec, `${phase}.md`);
188
+ fs.writeFileSync(file, fm.join('\n') + '\n' + body.join('\n'), 'utf8');
189
+ return file;
190
+ }
191
+
192
+ // loadPartial({ securityDir?, phase }) -> { frontmatter, body } or null.
193
+ function loadPartial(opts) {
194
+ const phase = opts.phase;
195
+ assertSafePhase(phase);
196
+ const sec = opts.securityDir || defaultSecurityDir();
197
+ const file = path.join(sec, `${phase}.md`);
198
+ if (!fs.existsSync(file)) return null;
199
+ const text = fs.readFileSync(file, 'utf8');
200
+ const fmMatch = text.match(/^---\n([\s\S]*?)\n---\n/);
201
+ if (!fmMatch) return null;
202
+ const frontmatter = parseFrontmatter(fmMatch[1]);
203
+ const body = text.slice(fmMatch[0].length);
204
+ return { frontmatter, body };
205
+ }
206
+
207
+ // listPartials({ securityDir? }) -> array of phase names (sorted).
208
+ function listPartials(opts = {}) {
209
+ const sec = opts.securityDir || defaultSecurityDir();
210
+ if (!fs.existsSync(sec)) return [];
211
+ const out = [];
212
+ for (const name of fs.readdirSync(sec)) {
213
+ if (name.endsWith('.md') && !name.startsWith('.')) {
214
+ out.push(name.replace(/\.md$/, ''));
215
+ }
216
+ }
217
+ return out.sort();
218
+ }
219
+
220
+ module.exports = {
221
+ writePartial,
222
+ loadPartial,
223
+ listPartials,
224
+ PARTIALS_SUBDIR
225
+ };