diff --git a/CHANGELOG.md b/CHANGELOG.md index 9349ce90..d6b90550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.1] - 2026-07-21 + +### Added + +- **Opt-in approval gate for paid Plus scans** (sable-9ddf). New `scan.plus_requires_approval` config flag (`.rafter.yml` or global `~/.rafter/config.json`), **off by default** so existing behavior is unchanged. When enabled, `rafter run --mode plus` (and the `rafter scan` / `rafter scan remote` aliases) requires explicit confirmation before spending credits: it prompts `[y/N]` on a TTY and refuses in a non-interactive/agent context with new exit code `5` unless `--yes` (`-y`) or `RAFTER_CONFIRM=1` is passed. Fast scans are never gated. Precedence is additive (OR) — a project `.rafter.yml` can turn the gate *on* but can never turn *off* a gate the machine owner enabled globally, so a hostile repo can't silently re-open the credit-burn hole. The rafter agent skill and injected instruction block now tell agents Plus is a paid tier and to ask the user before running it. Node + Python, tests in both suites. Addresses an external user whose agent auto-ran a Plus scan and consumed credits without asking. +- **SendGrid API Key secret pattern** (#24). New `SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}` detection pattern (severity `critical`) added to the built-in regex scanner in both Node and Python, with tests in each suite. Thanks to @perez-eduardo for the contribution. + ## [0.9.0] - 2026-07-08 ### Added diff --git a/node/package.json b/node/package.json index ad2f41e5..509e65c7 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@rafter-security/cli", - "version": "0.9.0", + "version": "0.9.1", "type": "module", "repository": { "type": "git", @@ -24,7 +24,10 @@ "build": "tsc -p tsconfig.json", "prepublishOnly": "pnpm run build", "postinstall": "echo '\n Run: rafter agent init --all\n Sets up secret scanning for Claude Code, Codex CLI, and git hooks.\n'", - "test": "vitest" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "clean": "rm -rf dist/ node_modules/.cache/" }, "engines": { "node": ">=18" @@ -44,6 +47,7 @@ "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^20.11.30", + "@vitest/coverage-v8": "4.1.0", "tsx": "^4.7.0", "typescript": "^5.4.5", "vitest": "^4.1.0" diff --git a/node/resources/rafter-security-skill.md b/node/resources/rafter-security-skill.md index 16aaa43a..09fcaf5d 100644 --- a/node/resources/rafter-security-skill.md +++ b/node/resources/rafter-security-skill.md @@ -1,7 +1,7 @@ --- name: rafter-security description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`. -version: 0.9.0 +version: 0.9.1 homepage: https://rafter.so metadata: openclaw: diff --git a/node/resources/skills/rafter-code-review/SKILL.md b/node/resources/skills/rafter-code-review/SKILL.md index ef38c22d..870f9839 100644 --- a/node/resources/skills/rafter-code-review/SKILL.md +++ b/node/resources/skills/rafter-code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: rafter-code-review -description: "REQUIRED before declaring a task done when the diff touches user input, SQL, shell, auth, credentials, file paths, serialization, or dependency surface. Walks OWASP/ASVS/MITRE questions the compiler and the test suite won't catch. Pair with `rafter run` on the same diff. A security-relevant task marked complete without both has been under-delivered — do not claim done." +description: "REQUIRED before declaring a task done when the diff touches user input, SQL, shell, auth, credentials, file paths, serialization, crypto, network endpoints, data deletion, or dependency surface. Judge by that surface, not the task label — research/experimental/local-only code with none of it can skip this. Walks OWASP/ASVS/MITRE questions the compiler and the test suite won't catch. Pair with `rafter run` on the same diff. When the diff has that surface, marking it complete without both has been under-delivered — do not claim done." version: 0.7.0 allowed-tools: [Bash, Read, Glob, Grep] --- @@ -11,6 +11,12 @@ A reviewer's skill, not an audit generator. Each sub-doc is a set of **questions > Pair with the `rafter` skill (detection: `rafter scan`, `rafter run`) and `rafter-secure-design` (prevention: design-phase walks). This skill is the middle stage — review before merge. +## When this applies (and when it doesn't) + +Scoped to the **security surface of the diff**, not the task's label. Walk it fully when the change touches: user / untrusted input, SQL or query building, shell / `exec` / subprocess, auth or access control, credentials / secrets / tokens, file paths or uploads, (de)serialization, crypto, network-facing endpoints or outbound fetchers, data deletion, or dependency / manifest changes. + +If **none** of those are present — research / experimental / exploratory / local-only / throwaway code such as training scripts, data analysis, plotting, model eval, notebooks, or pure computation over trusted local data — a quick surface check is enough; you don't need to walk the full review or pair `rafter run`. But the check is the surface, not the label: research code that reads a secret, shells out, hits the network, or parses untrusted bytes is back on the engage list and gets the full walk. + ## How to use this skill 1. Identify the category of code in front of you (below). diff --git a/node/resources/skills/rafter-secure-design/SKILL.md b/node/resources/skills/rafter-secure-design/SKILL.md index aebadfad..d25e96db 100644 --- a/node/resources/skills/rafter-secure-design/SKILL.md +++ b/node/resources/skills/rafter-secure-design/SKILL.md @@ -1,6 +1,6 @@ --- name: rafter-secure-design -description: "REQUIRED before writing code for any feature touching auth, payments, credentials, tokens, sessions, file upload, user data, or data deletion. Invoke it, record answers inline in the design doc or PR description, then write the code. Skipping this is how auth bypass, token leakage, and delete-the-wrong-record bugs ship. If the feature touches any of those surfaces and this hasn't run, the design is incomplete — do not proceed to implementation." +description: "REQUIRED before writing code for any feature touching auth, payments, credentials, tokens, sessions, file upload, user data, untrusted input, deserialization, network endpoints, or data deletion. Scope by that surface, not the task label — a research/experimental/local-only feature with none of it doesn't need this. Invoke it, record answers inline in the design doc or PR description, then write the code. Skipping this is how auth bypass, token leakage, and delete-the-wrong-record bugs ship. If the feature touches any of those surfaces and this hasn't run, the design is incomplete — do not proceed to implementation." version: 0.1.0 allowed-tools: [Read, Glob, Grep] --- @@ -11,6 +11,12 @@ A designer's skill, not a scanner. The goal is to catch the flaw in the whiteboa > Pair with `rafter-code-review` (structured review *during* PR) and the `rafter` skill (automated detection of what slipped through). This skill is the earliest stage — prevention before the code exists. +## When this applies (and when it doesn't) + +Engage this design walk when the feature you're about to build touches a real security surface: auth or access control, credentials / secrets / tokens / sessions, user or untrusted input, SQL or query construction, shell / `exec`, file paths or uploads, (de)serialization, crypto, network-facing endpoints or outbound fetchers, data deletion, or new dependencies. + +If the thing you're designing has **none** of that — a research / experimental / local-only / throwaway piece such as a training script, analysis pipeline, plot, model-eval harness, notebook, or pure computation over trusted local data — a quick surface check is enough and you can proceed to implementation without the full walk. Judge by the surface, not by whether the work is called "research": a research feature that stores user data, handles a token, or opens a network endpoint is back on the engage list. + ## How to use this skill 1. Identify what's being designed (below). If multiple apply, walk them in the order listed — `threat-modeling` last, as a capstone. diff --git a/node/resources/skills/rafter/SKILL.md b/node/resources/skills/rafter/SKILL.md index 89313267..2b4a647d 100644 --- a/node/resources/skills/rafter/SKILL.md +++ b/node/resources/skills/rafter/SKILL.md @@ -1,23 +1,33 @@ --- name: rafter -description: "Entry point for rafter. Invoke when a sub-skill is unclear, or when the task needs `rafter run` (remote SAST+SCA), `rafter secrets` (local secrets-only), `rafter audit`, policy checks, or command-risk evaluation. If a task is security-relevant and no rafter skill or CLI call has been made, invoke this before handing the task off — an un-evaluated \"done\" on security-relevant work is not done." +description: "Entry point for rafter. Invoke when a sub-skill is unclear, or when the task needs `rafter run` (remote SAST+SCA), `rafter secrets` (local secrets-only), `rafter audit`, policy checks, or command-risk evaluation. Scope by security surface, not task label: engage when the diff touches auth, credentials/secrets/tokens, untrusted input, SQL, shell/exec, file paths, deserialization, crypto, network endpoints, data deletion, or dependencies; for research/experimental/local-only code with none of that, a quick surface check is enough. When such surface IS present and no rafter skill or CLI call has been made, invoke this before handing the task off — an un-evaluated \"done\" on genuine security surface is not done." version: 0.7.0 allowed-tools: [Bash, Read] --- # Rafter — Security Toolkit for AI Workflows -## Picking the right tier — DO NOT stop at "local" +## When Rafter applies (and when it doesn't) + +Rafter is a **surface-driven** gate, not a task-label gate. Read the diff's actual security surface first; let that — not whether the work is called "research" — pick the branch. + +**Engage fully** (walk the review + run `rafter run`) when the change touches any of: auth / sessions / access control · credentials, secrets, tokens, keys · user-supplied or otherwise untrusted input · SQL or any other query / command construction · shell, `exec`, or subprocess invocation · file paths (read, write, upload, traversal) · deserialization or parsing of untrusted data · crypto primitives · network-facing endpoints or outbound fetchers (SSRF surface) · data deletion or other destructive mutations · dependency, lockfile, or manifest changes. + +**Back off** when the change touches **none** of those — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough; with no security surface, proceed **without** the full `rafter-code-review` + `rafter run`. -Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. +**The rule that decides it:** the "research" label buys nothing. Research code that reads a secret, shells out, hits the network, parses untrusted input, or bumps a dependency is on the engage list and gets the full gate. Judge by the surface of the diff, not by what the task is called. + +--- + +## Picking the right tier — DO NOT stop at "local" -1. **Local (`rafter secrets`)** — secrets only. Regex + betterleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. -2. **Remote fast (`rafter run`, default mode)** — SAST + SCA + secrets via the Rafter API. This is the real code-analysis pass: dataflow, taint, known-vulnerable dependencies, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. -3. **Remote plus (`rafter run --mode plus`)** — agentic deep-dive: LLM-guided investigation of suspicious patterns the rules engine flags. Slower, higher signal. Code is deleted server-side after the run. +Three tiers, **not interchangeable**. The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. -**Default expectation for a security-relevant task**: run `rafter run`. Fall back to `rafter secrets` only when no API key is available or you specifically need offline secret-hygiene. If you've only run the local scanner, say so explicitly — don't claim the code was "scanned" without qualification. +1. **`rafter secrets`** — hardcoded credentials only (regex + betterleaks). Fast, offline, no key. **NOT a code security scan** — it finds no SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. A clean `rafter secrets .` is secret-hygiene, not security review. +2. **`rafter run`** (default mode) — the real code-analysis pass: SAST + SCA + secrets (dataflow, taint, known-vulnerable deps, crypto misuse, injection sinks). Needs `RAFTER_API_KEY`. +3. **`rafter run --mode plus`** — agentic deep-dive: LLM-guided investigation of what the rules engine flags. Slower, higher signal; code is deleted server-side after the run. **PAID tier — consumes the user's credits; ask before running it.** If `scan.plus_requires_approval` is set, Plus refuses without `--yes` / `RAFTER_CONFIRM=1`. -Stable exit codes, stable JSON shapes, deterministic findings. Safe to chain in CI and in agent loops. +**Default for a security-relevant task: `rafter run`.** Fall back to `rafter secrets` only when no API key is available — and say so explicitly; don't claim the code was "scanned" without qualification. Deterministic findings, stable exit codes and JSON shapes — safe to chain in CI and in agent loops. --- diff --git a/node/src/commands/agent/components.ts b/node/src/commands/agent/components.ts index 969b8e94..633eee03 100644 --- a/node/src/commands/agent/components.ts +++ b/node/src/commands/agent/components.ts @@ -346,9 +346,11 @@ function codexHooks(): ComponentSpec { cfg.hooks.PostToolUse, (e) => hookEntryMatchesRafter(e, "rafter hook posttool"), ); - // Bash + apply_patch per Codex hook docs (rf-ovql verification). + // Bash + apply_patch per Codex hook docs (rf-ovql verification). PostToolUse + // mirrors that write/exec surface (narrowed from `.*` to skip Read/MCP log + // noise), matching claude-code posttool scoping (sable-h0ah). cfg.hooks.PreToolUse.push({ matcher: "Bash|apply_patch", hooks: [pre] }); - cfg.hooks.PostToolUse.push({ matcher: ".*", hooks: [post] }); + cfg.hooks.PostToolUse.push({ matcher: "Bash|apply_patch", hooks: [post] }); writeJson(hooksPath, cfg); }, uninstall: () => { diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts index 0b85eb4b..eadb97dc 100644 --- a/node/src/commands/agent/init.ts +++ b/node/src/commands/agent/init.ts @@ -108,7 +108,7 @@ function printDryRunPlan(plan: { if (plan.wantCodex) { console.log(); console.log("Codex CLI (--with-codex):"); - W(path.join(plan.root, ".codex", "hooks.json"), "PreToolUse: Bash|apply_patch, PostToolUse: .*"); + W(path.join(plan.root, ".codex", "hooks.json"), "PreToolUse: Bash|apply_patch, PostToolUse: Bash|apply_patch"); for (const s of AGENT_SKILLS) { W(path.join(plan.root, ".agents", "skills", s.name, "SKILL.md")); } @@ -383,10 +383,11 @@ function installCodexHooks(root: string): void { config.hooks.PreToolUse.push( { matcher: "Bash|apply_patch", hooks: [preHook] }, ); - // PostToolUse fires for the same tool surface; .* keeps all events in the - // audit log without filtering. + // PostToolUse mirrors the same write/exec surface (Bash + apply_patch); + // narrowed from `.*` so posttool doesn't fire on every Read/MCP call + // (log noise + latency), matching the claude-code posttool scoping (sable-h0ah). config.hooks.PostToolUse.push( - { matcher: ".*", hooks: [postHook] }, + { matcher: "Bash|apply_patch", hooks: [postHook] }, ); fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2), "utf-8"); diff --git a/node/src/commands/agent/instruction-block.ts b/node/src/commands/agent/instruction-block.ts index 9e15ca72..f14a18dc 100644 --- a/node/src/commands/agent/instruction-block.ts +++ b/node/src/commands/agent/instruction-block.ts @@ -6,18 +6,28 @@ export const RAFTER_MARKER_END = ""; /** Rafter security context block for agent instruction files (CLAUDE.md, AGENTS.md, etc.) */ export const RAFTER_INSTRUCTION_BLOCK = `${RAFTER_MARKER_START} -## Security: Rafter (required review gate) +## Security: Rafter (surface-driven review gate) -Rafter is this project's security review gate. A security-relevant task is -**not complete** until a rafter skill (or \`rafter run\`) has reviewed it. -Don't mark done, don't hand off, don't open a PR without that pass. +Rafter is this project's security review gate — driven by the change's **security +surface**, not by the task label. When a diff touches a real surface (below), it is +**not complete** until a rafter skill (or \`rafter run\`) has reviewed it: don't mark +done, don't hand off, don't open a PR without that pass. When it touches **none** of +that surface — research / experimental / local-only / throwaway code (training +scripts, data analysis, plotting, model eval, notebooks, pure computation over +trusted local data) — a quick surface check is enough; proceed without the full +gate. Judge by the diff's actual surface, not the "research" label: research code +that reads a secret, shells out, hits the network, parses untrusted input, or bumps +a dependency still gets the full gate. -**Stop and invoke before continuing:** +**Stop and invoke when the change touches that surface:** - Designing auth, payments, credentials, tokens, sessions, file upload, - user data, or data deletion → **\`rafter-secure-design\`** (before writing code). -- Diff touches user input, SQL, shell, auth, credentials, file paths, or - serialization → **\`rafter-code-review\`** + \`rafter run\` (before declaring done). + user/untrusted data, deserialization, network endpoints, or data deletion + → **\`rafter-secure-design\`** (before writing code). +- Diff touches user/untrusted input, SQL/query building, shell/exec, auth, + credentials, file paths, (de)serialization, crypto, network endpoints/outbound + fetchers (SSRF), data deletion, or dependencies → **\`rafter-code-review\`** + + \`rafter run\` (before declaring done). - About to install or forward a third-party SKILL.md, MCP manifest, Cursor rule, or agent config → **\`rafter-skill-review\`** (before copying anywhere). - Security-adjacent but the angle isn't clear → **\`rafter\`** (the router skill, @@ -28,7 +38,9 @@ Don't mark done, don't hand off, don't open a PR without that pass. - \`rafter run\` — remote SAST + SCA + secrets (real code analysis, needs \`RAFTER_API_KEY\`) - \`rafter secrets .\` — local secrets only (offline; NOT a code-security scan) - \`rafter run --mode plus\` — everything in default (\`--mode fast\`) plus - powerful agentic deep-dives (needs \`RAFTER_API_KEY\`) + powerful agentic deep-dives (needs \`RAFTER_API_KEY\`). **Plus is a PAID tier + and consumes the user's credits — ask the user before running it.** Enforced + when \`scan.plus_requires_approval\` is set (then pass \`--yes\` to confirm). ${RAFTER_MARKER_END}`; /** diff --git a/node/src/commands/backend/run.ts b/node/src/commands/backend/run.ts index 62e435fc..6fb73162 100644 --- a/node/src/commands/backend/run.ts +++ b/node/src/commands/backend/run.ts @@ -7,8 +7,12 @@ import { resolveKey, EXIT_GENERAL_ERROR, EXIT_QUOTA_EXHAUSTED, + EXIT_CONFIRMATION_REQUIRED, handle403 } from "../../utils/api.js"; +import { ConfigManager } from "../../core/config-manager.js"; +import { loadPolicy } from "../../core/policy-loader.js"; +import { askYesNo } from "../../utils/prompt.js"; import { handleScanStatus } from "./scan-status.js"; export interface RunOpts { @@ -22,12 +26,72 @@ export interface RunOpts { githubToken?: string; provider?: string; repoUrl?: string; + yes?: boolean; +} + +/** + * sable-9ddf — is the Plus-scan approval gate enabled? + * + * OR semantics across the machine-owner's global config and the project's + * `.rafter.yml`: if EITHER opts in, approval is required. A project policy can + * turn the gate ON but can NEVER turn it OFF — a hostile repo must not be able + * to silently re-open the credit-burn hole a user closed globally. + * + * Fails open (returns false) if config/policy can't be read, consistent with + * the OFF-by-default product behavior and the codebase's config-load fallback. + */ +export function plusApprovalGateEnabled(): boolean { + let globalFlag = false; + try { + globalFlag = new ConfigManager().load().agent?.scan?.plusRequiresApproval === true; + } catch { /* fail open */ } + let policyFlag = false; + try { + policyFlag = loadPolicy()?.scan?.plusRequiresApproval === true; + } catch { /* fail open */ } + return globalFlag || policyFlag; +} + +/** + * Gate a paid Plus scan behind explicit confirmation when the switch is on. + * Returns normally if the scan may proceed; calls process.exit otherwise. + * No-op for non-plus modes and when the gate is disabled (the default). + */ +export async function confirmPlusScan(opts: RunOpts): Promise { + if ((opts.mode ?? "fast") !== "plus") return; + if (!plusApprovalGateEnabled()) return; + + const envConfirm = process.env.RAFTER_CONFIRM; + const confirmed = opts.yes === true || envConfirm === "1" || envConfirm === "true"; + if (confirmed) return; + + if (process.stdin.isTTY) { + const ok = await askYesNo( + "Plus is a PAID scan tier and will consume your credits. Proceed?", + false + ); + if (ok) return; + console.error("Plus scan cancelled."); + process.exit(EXIT_CONFIRMATION_REQUIRED); + } + + console.error( + "Refusing to run a paid Plus scan: approval is required " + + "(scan.plus_requires_approval is enabled).\n" + + "Re-run with --yes (or set RAFTER_CONFIRM=1) to confirm the credit spend, " + + "or use the free --mode fast scan." + ); + process.exit(EXIT_CONFIRMATION_REQUIRED); } /** * Shared handler for the remote backend scan (used by both `rafter run` and `rafter scan` / `rafter scan remote`). */ export async function runRemoteScan(opts: RunOpts): Promise { + // sable-9ddf — gate paid Plus scans before doing any work (key resolution, + // repo detection, or the billable API call). + await confirmPlusScan(opts); + const key = resolveKey(opts.apiKey); const ghToken = opts.githubToken || process.env.RAFTER_GITHUB_TOKEN; let repo: string | undefined, branch: string | undefined; @@ -134,6 +198,7 @@ function addRunOptions(cmd: Command): Command { .option("--provider ", "git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)") .option("--repo-url ", "full https clone URL for non-github remotes (default: auto-detected)") .option("--skip-interactive", "do not wait for scan to complete") + .option("-y, --yes", "confirm a paid Plus scan without prompting (when scan.plus_requires_approval is on)") .option("--quiet", "suppress status messages"); } diff --git a/node/src/commands/scan/index.ts b/node/src/commands/scan/index.ts index 3dcf071e..f316dab9 100644 --- a/node/src/commands/scan/index.ts +++ b/node/src/commands/scan/index.ts @@ -28,6 +28,7 @@ export function createScanGroupCommand(): Command { .option("--provider ", "git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)") .option("--repo-url ", "full https clone URL for non-github remotes (default: auto-detected)") .option("--skip-interactive", "do not wait for scan to complete") + .option("-y, --yes", "confirm a paid Plus scan without prompting (when scan.plus_requires_approval is on)") .option("--quiet", "suppress status messages") .action(async (opts) => { await runRemoteScan(opts); @@ -46,6 +47,7 @@ export function createScanGroupCommand(): Command { .option("--provider ", "git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)") .option("--repo-url ", "full https clone URL for non-github remotes (default: auto-detected)") .option("--skip-interactive", "do not wait for scan to complete") + .option("-y, --yes", "confirm a paid Plus scan without prompting (when scan.plus_requires_approval is on)") .option("--quiet", "suppress status messages"); scanGroup.addCommand(localCmd, { hidden: true }); diff --git a/node/src/core/command-interceptor.ts b/node/src/core/command-interceptor.ts index 2eb81016..573468ee 100644 --- a/node/src/core/command-interceptor.ts +++ b/node/src/core/command-interceptor.ts @@ -1,6 +1,11 @@ import { ConfigManager } from "./config-manager.js"; import { AuditLogger } from "./audit-logger.js"; -import { assessCommandRisk, matchedCriticalPattern, CommandRiskLevel } from "./risk-rules.js"; +import { + assessCommandRisk, + matchedCriticalPattern, + sanitizeCommandForMatching, + CommandRiskLevel, +} from "./risk-rules.js"; export type { CommandRiskLevel } from "./risk-rules.js"; @@ -67,12 +72,19 @@ export class CommandInterceptor { }; } - // Check blocked patterns (always block) + // Check blocked patterns (always block). + // + // A deny-list match denies — that is what a deny-list is for — but it must + // NOT rewrite the command's risk. Reporting every deny-list hit as + // "critical" made the hook tell users a `gh pr create` was an irreversible + // system-damage command. The assessed risk is reported as assessed; the + // genuinely unconditional hard-blocks are the CRITICAL_PATTERNS handled + // above, and the default deny-list is exactly that set. for (const pattern of policy.blockedPatterns) { if (this.matchesPattern(command, pattern)) { return { command, - riskLevel: "critical", + riskLevel, allowed: false, requiresApproval: false, reason: `Matches blocked pattern: ${pattern}`, @@ -84,7 +96,6 @@ export class CommandInterceptor { // Check approval patterns for (const pattern of policy.requireApproval) { if (this.matchesPattern(command, pattern)) { - const riskLevel = this.assessRisk(command); return { command, riskLevel, @@ -96,46 +107,22 @@ export class CommandInterceptor { } } - // Check policy mode - if (policy.mode === "deny-list") { - // If not in blocked or approval lists, allow - return { - command, - riskLevel: this.assessRisk(command), - allowed: true, - requiresApproval: false - }; - } else if (policy.mode === "approve-dangerous") { - // Assess risk and require approval for high/critical - const riskLevel = this.assessRisk(command); - if (riskLevel === "high" || riskLevel === "critical") { - return { - command, - riskLevel, - allowed: false, - requiresApproval: true, - reason: `High risk command requires approval` - }; - } + // Check policy mode. `riskLevel` is the assessment made at the top of + // evaluate() — critical already returned, so it is high/medium/low here. + if (policy.mode === "approve-dangerous" && riskLevel === "high") { return { command, riskLevel, - allowed: true, - requiresApproval: false - }; - } else if (policy.mode === "allow-all") { - return { - command, - riskLevel: this.assessRisk(command), - allowed: true, - requiresApproval: false + allowed: false, + requiresApproval: true, + reason: `High risk command requires approval` }; } - // Default: allow + // deny-list / allow-all / unknown mode: not blocked, not approval-gated. return { command, - riskLevel: this.assessRisk(command), + riskLevel, allowed: true, requiresApproval: false }; @@ -154,15 +141,22 @@ export class CommandInterceptor { } /** - * Match command against pattern + * Match a command against a policy pattern. + * + * Matching runs against the SANITIZED command line, not the raw string: the + * policy patterns describe commands, so quoted text a command merely consumes + * as data (a commit message, a PR body) must not match them, while text a + * shell or eval wrapper executes (`bash -c "…"`) must. See + * `sanitizeCommandForMatching`. */ private matchesPattern(command: string, pattern: string): boolean { + const target = sanitizeCommandForMatching(command); try { const regex = new RegExp(pattern, "i"); - return regex.test(command); + return regex.test(target); } catch { // If pattern is not valid regex, try case-insensitive substring match - return command.toLowerCase().includes(pattern.toLowerCase()); + return target.toLowerCase().includes(pattern.toLowerCase()); } } diff --git a/node/src/core/config-manager.ts b/node/src/core/config-manager.ts index 7b0cd572..fddb03fd 100644 --- a/node/src/core/config-manager.ts +++ b/node/src/core/config-manager.ts @@ -141,6 +141,10 @@ function validateConfig(raw: any): RafterConfig { console.error('Warning: config "agent.scan.autoUpdateBetterleaks" must be a boolean — using default.'); delete scan.autoUpdateBetterleaks; } + if (scan.plusRequiresApproval !== undefined && typeof scan.plusRequiresApproval !== "boolean") { + console.error('Warning: config "agent.scan.plusRequiresApproval" must be a boolean — using default.'); + delete scan.plusRequiresApproval; + } } } @@ -335,6 +339,11 @@ export class ConfigManager { if (policy.scan.autoUpdateBetterleaks !== undefined) { config.agent.scan.autoUpdateBetterleaks = policy.scan.autoUpdateBetterleaks; } + // sable-9ddf — OR merge: a project policy may turn the Plus-approval gate + // ON, but must never turn OFF a gate the machine owner set globally. + if (policy.scan.plusRequiresApproval === true) { + config.agent.scan.plusRequiresApproval = true; + } } // Ignore rules — top-level policy key, applied per finding at scan time diff --git a/node/src/core/config-schema.ts b/node/src/core/config-schema.ts index 26a47c12..ab45fabf 100644 --- a/node/src/core/config-schema.ts +++ b/node/src/core/config-schema.ts @@ -117,6 +117,19 @@ export interface RafterConfig { * to opt out, e.g. in CI that provisions its own binary. */ autoUpdateBetterleaks?: boolean; + /** + * sable-9ddf — require explicit confirmation before a paid Plus scan + * (`rafter run --mode plus`). Default false (undefined) — existing + * behavior is unchanged unless opted in. When true, a Plus scan refuses + * in a non-interactive/agent context unless `--yes` or `RAFTER_CONFIRM=1` + * is present, and prompts when a TTY is attached. + * + * SECURITY: honored additively (OR) across global config and project + * `.rafter.yml` — a project policy can turn this ON but can NEVER turn OFF + * a gate the machine owner enabled globally. See runRemoteScan. + * YAML key: `scan.plus_requires_approval`. + */ + plusRequiresApproval?: boolean; }; /** * Fine-grained per-component install state. Keys are component IDs like diff --git a/node/src/core/pattern-engine.ts b/node/src/core/pattern-engine.ts index 10da9e26..17e42351 100644 --- a/node/src/core/pattern-engine.ts +++ b/node/src/core/pattern-engine.ts @@ -24,6 +24,24 @@ const VARIABLE_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/; const LOWERCASE_IDENT_RE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/; const QUOTED_VALUE_RE = /['"]([^'"]+)['"]/; +/** + * Matches an environment-assignment prefix in a command line: an identifier + * followed immediately by `=` and a run of non-whitespace (the value). Used to + * find `NAME=VALUE` tokens like `RAFTER_API_KEY= rafter ...` so their + * value can be redacted before the command is written to the audit log. + */ +const ENV_ASSIGN_RE = /(^|\s)([A-Za-z_][A-Za-z0-9_]*)=(\S+)/g; + +/** + * A `NAME` in a `NAME=VALUE` assignment is treated as secret-bearing when it + * ends in a credential-suggesting word (e.g. RAFTER_API_KEY, GITHUB_TOKEN, + * DB_PASSWORD, AUTH). Values behind such names are redacted even when they + * don't match any known secret pattern (that's the whole point — the value of + * a bespoke API key won't match a built-in pattern, but it's still a secret). + * Plain names like FOO or NODE_ENV do not match, so `FOO=bar` is left intact. + */ +const SECRET_ENV_NAME_RE = /(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$/i; + export class PatternEngine { private patterns: Pattern[]; @@ -85,10 +103,18 @@ export class PatternEngine { } /** - * Redact text by replacing sensitive patterns + * Redact text by replacing sensitive patterns. + * + * Two layers, both additive: + * 1. Env-assignment redaction: any `NAME=VALUE` whose NAME looks + * secret-bearing has its VALUE masked, even if the VALUE matches no + * known pattern. This catches leaks like `RAFTER_API_KEY= rafter …` + * in a logged command line, where the key's shape is unknown. + * 2. Pattern-based redaction: values that match a built-in secret pattern + * are masked wherever they appear. */ redactText(text: string): string { - let redacted = text; + let redacted = this.redactEnvAssignments(text); for (const pattern of this.patterns) { const regex = this.createRegex(pattern.regex); @@ -100,6 +126,16 @@ export class PatternEngine { return redacted; } + /** + * Mask the VALUE of every `NAME=VALUE` token whose NAME looks + * secret-bearing. Non-secret names (FOO, NODE_ENV, …) are left untouched. + */ + private redactEnvAssignments(text: string): string { + return text.replace(ENV_ASSIGN_RE, (full, prefix: string, name: string, value: string) => + SECRET_ENV_NAME_RE.test(name) ? `${prefix}${name}=${this.redact(value)}` : full + ); + } + /** * Check if text contains any sensitive patterns */ diff --git a/node/src/core/policy-loader.ts b/node/src/core/policy-loader.ts index 3297df3f..6c716a6b 100644 --- a/node/src/core/policy-loader.ts +++ b/node/src/core/policy-loader.ts @@ -41,6 +41,7 @@ export interface PolicyFile { excludePaths?: string[]; customPatterns?: PolicyCustomPattern[]; autoUpdateBetterleaks?: boolean; + plusRequiresApproval?: boolean; }; ignore?: PolicyIgnoreRule[]; audit?: { @@ -151,6 +152,9 @@ function mapPolicy(raw: Record): PolicyFile { if (typeof raw.scan.auto_update_betterleaks === "boolean") { policy.scan.autoUpdateBetterleaks = raw.scan.auto_update_betterleaks; } + if (typeof raw.scan.plus_requires_approval === "boolean") { + policy.scan.plusRequiresApproval = raw.scan.plus_requires_approval; + } } // sable-c1c — backend flat-shape compat. rafter-backend reads @@ -361,6 +365,12 @@ function validatePolicy(policy: PolicyFile, raw: Record): PolicyFil typeof raw.scan.auto_update_betterleaks !== "boolean") { console.error(`Warning: "scan.auto_update_betterleaks" must be a boolean — ignoring.`); } + // sable-9ddf — only a boolean reaches policy.scan (mapping guards the type). + if (raw.scan && typeof raw.scan === "object" && + raw.scan.plus_requires_approval !== undefined && + typeof raw.scan.plus_requires_approval !== "boolean") { + console.error(`Warning: "scan.plus_requires_approval" must be a boolean — ignoring.`); + } } if (policy.ignore !== undefined) { diff --git a/node/src/core/risk-rules.ts b/node/src/core/risk-rules.ts index 9691d5ba..491027dd 100644 --- a/node/src/core/risk-rules.ts +++ b/node/src/core/risk-rules.ts @@ -1,6 +1,12 @@ /** * Centralized risk assessment rules. * Single source of truth — imported by command-interceptor, audit-logger, and config-defaults. + * + * Risk patterns are matched against a *sanitized* view of the command line + * (see `sanitizeCommandForMatching`), not the raw string: quoted text that a + * command consumes as DATA (a commit message, a PR body, an `echo` argument) + * must not be mistaken for a command, while quoted text that a shell or eval + * wrapper EXECUTES (`bash -c "…"`, `eval "…"`) must still be scanned. */ export type CommandRiskLevel = "low" | "medium" | "high" | "critical"; @@ -8,21 +14,29 @@ export type CommandRiskLevel = "low" | "medium" | "high" | "critical"; /** Directories where `rm -rf /` is catastrophic (data loss / unbootable). */ const CRITICAL_DIRS = "home|etc|usr|boot|root|sys|proc|lib|lib64|bin|sbin|opt"; -export const CRITICAL_PATTERNS: RegExp[] = [ +/** + * Catastrophic, irreversible commands. These are hard-blocked unconditionally — + * no policy, mode, or deny-list can opt out of them. Kept as pattern *sources* + * so the default policy deny-list (`DEFAULT_BLOCKED_PATTERNS`) is exactly this + * set, byte for byte, and can never drift from it. + */ +const CRITICAL_PATTERN_SOURCES: string[] = [ // rm -rf / (root only, any flag order) - new RegExp(`rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(\\s|$)`), - new RegExp(`rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(\\s|$)`), + `rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(\\s|$)`, + `rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(\\s|$)`, // rm -rf on critical top-level directories - new RegExp(`rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`), - new RegExp(`rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`), - /:\(\)\{\s*:\|:&\s*\};:/, // fork bomb - /dd\s+if=.*of=\/dev\/sd/, - />\s*\/dev\/sd/, - /mkfs/, - /fdisk/, - /parted/, + `rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`, + `rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`, + `:\\(\\)\\{\\s*:\\|:&\\s*\\};:`, // fork bomb + `dd\\s+if=.*of=/dev/sd`, + `>\\s*/dev/sd`, + `mkfs`, + `fdisk`, + `parted`, ]; +export const CRITICAL_PATTERNS: RegExp[] = CRITICAL_PATTERN_SOURCES.map((s) => new RegExp(s)); + export const HIGH_PATTERNS: RegExp[] = [ /rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*/, // rm -rf, -fr, -r -f, -f -r (any path) /rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*/, // rm -fr, reversed @@ -50,12 +64,14 @@ export const MEDIUM_PATTERNS: RegExp[] = [ /killall/, ]; -export const DEFAULT_BLOCKED_PATTERNS: string[] = [ - "rm -rf /", - ":(){ :|:& };:", - "dd if=/dev/zero of=/dev/sda", - "> /dev/sda", -]; +/** + * Default policy deny-list. Identical to the built-in unconditional hard-block + * set: a deny-list entry hard-denies, so the *defaults* must never match a + * merely approval-grade command. (The old literals — e.g. the substring + * "rm -rf /" — hard-denied `rm -rf /tmp/build`, which is HIGH and belongs in + * DEFAULT_REQUIRE_APPROVAL, not in a deny-list.) + */ +export const DEFAULT_BLOCKED_PATTERNS: string[] = [...CRITICAL_PATTERN_SOURCES]; export const DEFAULT_REQUIRE_APPROVAL: string[] = [ "rm -rf", @@ -70,20 +86,389 @@ export const DEFAULT_REQUIRE_APPROVAL: string[] = [ "git push .* \\+", ]; -/** Read-only commands whose arguments should not trigger risk patterns. */ -const SAFE_PREFIX = /^(grep|egrep|fgrep|rg|ag|ack|echo|printf)\s/; +// --------------------------------------------------------------------------- +// Argument-aware command sanitizer +// --------------------------------------------------------------------------- +// +// The risk patterns above describe *shell commands*. Matching them against the +// raw command line treats quoted argument text as if it were a command, so +// +// gh pr create --body "…don't git push --force…" +// git commit -m "don't git push --force" +// +// were flagged as force-pushes. Simply ignoring quoted text is NOT a fix: a +// shell or eval wrapper *executes* its quoted argument, so `bash -c "rm -rf /"` +// must still hard-block. The sanitizer is therefore argument-aware: +// +// * tokenize respecting quotes, escapes, command substitution and redirects; +// * split on chain operators (`;` `&&` `||` `|` `&`), keeping them in place so +// pipeline rules (`curl … | bash`) still match; +// * a shell's `-c` argument IS a command → recursively sanitize and inline it; +// * `$(…)` / backticks (outside single quotes) ARE commands → same; +// * arguments a command consumes as text (`echo`/`grep` operands, `-m`, +// `--body`, …) and prose-shaped quoted arguments are DATA → redacted; +// * everything else is preserved byte for byte. +// +// Known limitation: an *unrecognized* evaluator that takes a bare quoted command +// string with no `-c`/`-e`-style flag (e.g. a bespoke `myrunner "rm -rf /"`) has +// its argument treated as data. Anything reached through a real shell, an eval +// flag, a substitution, or an unquoted argument is still scanned. + +/** Shells whose `-c` argument is a command string to execute. */ +const SHELL_EXECS = new Set(["bash", "sh", "zsh", "dash", "ksh", "ash", "fish", "su"]); + +/** Execs whose arguments are executable text (a remote command, a script). */ +const EVAL_EXECS = new Set(["eval", "exec", "ssh", "sshpass", "xargs"]); + +/** Flags carrying an executable string (`bash -c`, `python -c`, `mysql -e`, `find -exec`). */ +const EVAL_FLAGS = new Set(["-c", "-e", "--command", "--execute", "--eval", "-exec", "--exec"]); + +/** Prefix wrappers that delegate to the command that follows them. */ +const TAIL_WRAPPERS = new Set([ + "sudo", "doas", "env", "nohup", "timeout", "nice", "ionice", + "time", "watch", "setsid", "stdbuf", "chrt", "command", +]); + +/** Commands whose operands are pure text data — searching or printing, never executing. */ +const TEXT_EXECS = new Set(["echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "ack"]); + +/** Flags whose value is human prose (a message, a body, a title) — never a command. */ +const TEXT_FLAGS = new Set([ + "-m", "--message", "--body", "--body-text", "--title", "--description", + "--reason", "--notes", "--subject", "--comment", "--annotation", +]); + +/** Operators that chain independent commands. */ +const CHAIN_OPS = new Set([";", "&&", "||", "|", "&"]); + +/** Operators whose following token is a redirect target (a path — never data). */ +const REDIRECT_OPS = new Set([">", ">>", "<", "<<"]); + +/** Bound on recursion through nested shell wrappers / substitutions. */ +const MAX_SANITIZE_DEPTH = 8; + +interface Piece { + /** Span in the source string. */ + start: number; + end: number; + /** Operator text, or null for a word. */ + op: string | null; + /** Unquoted, unescaped word content (empty for operators). */ + text: string; + /** Whether any part of the word was quoted. */ + quoted: boolean; + /** Contents of any command substitutions that the shell would execute. */ + substs: string[]; +} + +function isOpChar(c: string): boolean { + return c === ";" || c === "&" || c === "|" || c === ">" || c === "<"; +} + +/** Read a `$(…)` substitution starting at `i`; returns its contents and the next index. */ +function readSubst(s: string, i: number): { inner: string; next: number } { + let j = i + 2; + let depth = 1; + let inner = ""; + while (j < s.length && depth > 0) { + const ch = s[j]; + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) { j++; break; } + } + inner += ch; + j++; + } + return { inner, next: j }; +} + +/** Read a backtick substitution starting at `i`. */ +function readBacktick(s: string, i: number): { inner: string; next: number } { + let j = i + 1; + let inner = ""; + while (j < s.length && s[j] !== "`") { inner += s[j]; j++; } + return { inner, next: Math.min(j + 1, s.length) }; +} + +/** Split a command line into words and operators, respecting quotes and substitutions. */ +function tokenize(s: string): Piece[] { + const pieces: Piece[] = []; + let i = 0; + + while (i < s.length) { + const c = s[i]; + + if (/\s/.test(c)) { i++; continue; } + + if (isOpChar(c)) { + const start = i; + const two = s.slice(i, i + 2); + const op = (two === "&&" || two === "||" || two === ">>" || two === "<<") ? two : c; + i += op.length; + pieces.push({ start, end: i, op, text: op, quoted: false, substs: [] }); + continue; + } + + const start = i; + let text = ""; + let quoted = false; + const substs: string[] = []; + + while (i < s.length) { + const ch = s[i]; + if (/\s/.test(ch) || isOpChar(ch)) break; -/** Shell operators that chain independent commands. */ -const CHAIN_OPERATORS = /[;|&]|&&|\|\|/; + if (ch === "\\") { + i++; + if (i < s.length) { text += s[i]; i++; } + continue; + } + + // Single quotes are inert: no expansion, no substitution. + if (ch === "'") { + i++; + quoted = true; + while (i < s.length && s[i] !== "'") { text += s[i]; i++; } + i++; + continue; + } + + // Double quotes are data, but `$( )` / backticks inside them DO execute. + if (ch === '"') { + i++; + quoted = true; + while (i < s.length && s[i] !== '"') { + if (s[i] === "\\") { + i++; + if (i < s.length) { text += s[i]; i++; } + continue; + } + if (s[i] === "$" && s[i + 1] === "(") { + const r = readSubst(s, i); substs.push(r.inner); i = r.next; continue; + } + if (s[i] === "`") { + const r = readBacktick(s, i); substs.push(r.inner); i = r.next; continue; + } + text += s[i]; + i++; + } + i++; + continue; + } + + if (ch === "$" && s[i + 1] === "(") { + const r = readSubst(s, i); substs.push(r.inner); i = r.next; continue; + } + if (ch === "`") { + const r = readBacktick(s, i); substs.push(r.inner); i = r.next; continue; + } + + text += ch; + i++; + } + + pieces.push({ start, end: i, op: null, text, quoted, substs }); + } + + return pieces; +} + +/** `/usr/bin/rm` → `rm`; used to classify the executable of a segment. */ +function execName(text: string): string { + const base = text.slice(text.lastIndexOf("/") + 1); + return base.toLowerCase(); +} + +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; +const SHELL_C_FLAG = /^-[a-z]*c$/; +const LONG_FLAG_WITH_VALUE = /^(--[a-z][a-z-]*)=/; + +interface Replacement { start: number; end: number; with: string; } + +/** + * Decide, for one segment (a single command in a chain), which spans are DATA + * and which are code, appending the resulting replacements. + */ +function processSegment(pieces: Piece[], depth: number, out: Replacement[]): void { + // A word is a redirect target when the piece before it is `>`/`>>`/`<`. + const isRedirectTarget = new Array(pieces.length).fill(false); + for (let i = 1; i < pieces.length; i++) { + const prev = pieces[i - 1]; + if (prev.op && REDIRECT_OPS.has(prev.op) && !pieces[i].op) isRedirectTarget[i] = true; + } + + // Effective executable: skip env assignments and prefix wrappers (`sudo`, + // `env`, `timeout 5`, `nice -n 15`, …) to reach the command they delegate to. + let execIdx = -1; + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (p.op || isRedirectTarget[i]) continue; + if (!p.quoted && ENV_ASSIGNMENT.test(p.text)) continue; + if (execIdx === -1 && !p.quoted && TAIL_WRAPPERS.has(execName(p.text))) { + // Skip the wrapper's own flags and their numeric/duration values. + let j = i + 1; + while (j < pieces.length) { + const q = pieces[j]; + if (q.op || isRedirectTarget[j]) { j++; continue; } + if (q.text.startsWith("-") || /^\d+[a-z]*$/i.test(q.text)) { j++; continue; } + break; + } + i = j - 1; + continue; + } + execIdx = i; + break; + } + + const exec = execIdx === -1 ? "" : execName(pieces[execIdx].text); + const isTextExec = TEXT_EXECS.has(exec); + + // A segment is code-carrying when some part of it is a command string the + // segment will execute: a shell, an eval-style flag, or an eval-style exec. + // Its quoted arguments are NOT prose and must stay visible to the patterns. + let hasShellExec = false; + let hasEvalFlag = false; + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (p.op || isRedirectTarget[i]) continue; + if (!p.quoted && SHELL_EXECS.has(execName(p.text))) hasShellExec = true; + if (!p.quoted && EVAL_FLAGS.has(p.text.toLowerCase())) hasEvalFlag = true; + } + const codeCarrying = hasShellExec || hasEvalFlag || EVAL_EXECS.has(exec); + + let seenShell = false; + let pendingScript = false; + let prevTextFlag = false; + + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (p.op) { prevTextFlag = false; continue; } + + const isExecTok = i === execIdx; + const flagName = p.text.toLowerCase(); + + if (!p.quoted && SHELL_EXECS.has(execName(p.text))) seenShell = true; + + // `bash -c