export const id = 985;
export const ids = [985];
export const modules = {

/***/ 3985:
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {


// EXPORTS
__webpack_require__.d(__webpack_exports__, {
  runStdio: () => (/* binding */ runStdio)
});

// EXTERNAL MODULE: external "node:fs"
var external_node_fs_ = __webpack_require__(3024);
// EXTERNAL MODULE: external "node:crypto"
var external_node_crypto_ = __webpack_require__(7598);
// EXTERNAL MODULE: external "node:path"
var external_node_path_ = __webpack_require__(6760);
// EXTERNAL MODULE: external "node:url"
var external_node_url_ = __webpack_require__(3136);
// EXTERNAL MODULE: external "node:fs/promises"
var promises_ = __webpack_require__(1455);
// EXTERNAL MODULE: ./src/posture/fix-history.js
var fix_history = __webpack_require__(4407);
// EXTERNAL MODULE: ./src/posture/integrity.js
var integrity = __webpack_require__(1130);
;// CONCATENATED MODULE: ./src/mcp/redact.js
// Secret redactor for MCP tool outputs and audit log argument summaries.
//
// OWASP MCP01 + MCP10: the scanner reads source code, and findings carry
// `snippet` / `description` / `trace` strings that may contain hardcoded
// credentials, API keys, JWTs, private keys, etc. When those flow back to
// the agent through tools/call responses they land in the agent's context
// — exposing the secret to model logs, transcripts, and any downstream tool
// the agent passes them to.
//
// We replace high-confidence secret shapes with [REDACTED:<kind>] before
// emitting them. The original full content is still on disk (scanner
// findings); the MCP surface is the bottleneck we control.
//
// Patterns deliberately stay narrow: high-precision so we don't garble
// non-secret long strings (UUIDs, SHAs, base64-encoded scan IDs).

const PATTERNS = [
  // Provider-specific high-entropy keys (anchored prefixes give very low FP)
  [/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
  [/ASIA[0-9A-Z]{16}/g, 'aws-temp-key'],
  [/gh[pousr]_[A-Za-z0-9]{36,255}/g, 'github-token'],
  [/xox[abprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
  [/sk-ant-[A-Za-z0-9_-]{20,}/g, 'anthropic-key'],
  [/sk-proj-[A-Za-z0-9_-]{20,}/g, 'openai-project-key'],
  [/sk-[A-Za-z0-9]{32,}/g, 'openai-or-stripe-key'],
  [/sk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-key'],
  [/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
  [/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
  [/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
  // JWT — three dot-separated b64url segments starting with eyJ
  [/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
  // PEM-encoded private keys
  [/-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g, 'private-key-block'],
  // Authorization headers — common copy-paste shape
  [/(?:Authorization|authorization)\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/g, 'bearer-token'],
  // Hardcoded password literals — assignment shape with quoted value
  [/(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\n]{6,}["']/gi, 'hardcoded-credential'],
];

const SNIPPET_MAX = 2000;
// OWASP A03 — cap input before running 14 regex patterns over it. A forged
// last-scan.json could plant a 50MB description string; without this cap a
// single explain_finding/query_taint call would peg CPU. After truncation
// the snippet still gets the final SNIPPET_MAX trim downstream.
const INPUT_MAX = 100_000;

function redactString(s) {
  if (typeof s !== 'string') return s;
  let out = s;
  if (out.length > INPUT_MAX) out = out.slice(0, INPUT_MAX) + `…(+${out.length - INPUT_MAX})`;
  for (const [re, kind] of PATTERNS) {
    out = out.replace(re, `[REDACTED:${kind}]`);
  }
  if (out.length > SNIPPET_MAX) out = out.slice(0, SNIPPET_MAX) + `…(+${out.length - SNIPPET_MAX})`;
  return out;
}

// Deep-redact every string in a finding-like object (mutates returned copy).
function redactFinding(f) {
  if (!f || typeof f !== 'object') return f;
  const out = { ...f };
  for (const k of ['snippet', 'description', 'remediation', 'title', 'vuln', 'message']) {
    if (typeof out[k] === 'string') out[k] = redactString(out[k]);
  }
  if (out.trace) {
    try { out.trace = JSON.parse(redactString(JSON.stringify(out.trace))); }
    catch { /* keep as-is if not round-trippable */ }
  }
  return out;
}

// Redact a freeform JSON-stringified argument blob (used by audit log).
function redactArgsBlob(s) {
  return redactString(s);
}

;// CONCATENATED MODULE: ./src/posture/agents-memory.js
// AGENTS.md — writable continual-learning memory (harness-anatomy #2).
//
// LangChain post:
//   "Harnesses support memory file standards like AGENTS.md which get
//    injected into context on agent start. As agents add and edit this file,
//    harnesses load the updated file into context. This is a form of
//    continual learning where agents durably store knowledge from one
//    session and inject that knowledge into future sessions."
//
// Distinct from CLAUDE.md:
//   - CLAUDE.md = human-authored project conventions, gotchas, layout.
//   - AGENTS.md = agent-authored notes ("what worked / didn't work / I'd try
//                  differently next time"). Append-only. Bounded.
//
// Lives at `<project>/.agentic-security/AGENTS.md`.
//
// Bounds:
//   - MAX_BYTES (default 20 KB) — past this, the oldest entries rotate to
//     `AGENTS.md.archive` (also bounded; oldest archive entries are dropped).
//   - MAX_ENTRY_BYTES (default 2 KB) — caps a single appendage.
//   - Entries are append-only with an ISO timestamp + section divider, so
//     readers can grep / slice by date without parsing.
//
// We deliberately avoid tying AGENTS.md to a session-id namespace. The post's
// recommendation is FLAT continual learning — the whole project's agents see
// each other's notes. Subagents that want session-scoped scratch use the
// agent-scratchpad surface instead.




const MEMORY_FILE = '.agentic-security/AGENTS.md';
const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
const MAX_BYTES = 20 * 1024;
const MAX_ENTRY_BYTES = 2 * 1024;
const ARCHIVE_MAX_BYTES = 200 * 1024;
const HEADER = '# AGENTS.md\n\nAgent-authored continual-learning notes. Each entry: timestamp + agent name + one short paragraph. New entries appended at the bottom; oldest entries rotate to AGENTS.md.archive when this file exceeds 20 KB.\n\n';

function _resolve(scanRoot) { return external_node_path_.join(scanRoot, MEMORY_FILE); }
function _archivePath(scanRoot) { return external_node_path_.join(scanRoot, ARCHIVE_FILE); }

function readAgentsMemory(scanRoot) {
  const fp = _resolve(scanRoot);
  if (!external_node_fs_.existsSync(fp)) return '';
  try { return external_node_fs_.readFileSync(fp, 'utf8'); } catch { return ''; }
}

function appendAgentsMemory(scanRoot, { agent, body }) {
  if (typeof agent !== 'string' || !agent.length) {
    return { ok: false, reason: 'agent: required string' };
  }
  if (!/^[A-Za-z0-9_.-]{1,64}$/.test(agent)) {
    return { ok: false, reason: 'agent: must match [A-Za-z0-9_.-]{1,64}' };
  }
  if (typeof body !== 'string' || !body.trim().length) {
    return { ok: false, reason: 'body: required non-empty string' };
  }
  let snippet = body.trim();
  // Strip control chars and cap.
  snippet = snippet.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, ' ');
  if (snippet.length > MAX_ENTRY_BYTES) {
    snippet = snippet.slice(0, MAX_ENTRY_BYTES) + '…';
  }
  const ts = new Date().toISOString();
  const entry = `\n## ${ts}  agent: ${agent}\n\n${snippet}\n`;
  try {
    const fp = _resolve(scanRoot);
    external_node_fs_.mkdirSync(external_node_path_.dirname(fp), { recursive: true });
    if (!external_node_fs_.existsSync(fp)) external_node_fs_.writeFileSync(fp, HEADER);
    external_node_fs_.appendFileSync(fp, entry);
    _maybeRotate(scanRoot);
    const stat = external_node_fs_.statSync(fp);
    return { ok: true, entryBytes: entry.length, fileSize: stat.size };
  } catch (e) {
    return { ok: false, reason: `write-failed: ${e.message}` };
  }
}

function _maybeRotate(scanRoot) {
  const fp = _resolve(scanRoot);
  let body;
  try { body = external_node_fs_.readFileSync(fp, 'utf8'); } catch { return; }
  if (body.length <= MAX_BYTES) return;
  // Split on the `## ` entry headers. Keep the most-recent N until the head
  // (everything before the cut) drops below MAX_BYTES/2; move the head to
  // the archive.
  const head = HEADER;
  const trailing = body.slice(head.length);
  const sections = trailing.split(/(?=\n## )/g).filter(s => s.length);
  // Walk from the end, accumulating until we have roughly MAX_BYTES/2 of
  // recent entries. Everything else goes to the archive.
  let kept = '', archive = '', accum = 0;
  for (let i = sections.length - 1; i >= 0; i--) {
    if (accum + sections[i].length <= MAX_BYTES / 2) {
      kept = sections[i] + kept;
      accum += sections[i].length;
    } else {
      archive = sections.slice(0, i + 1).join('') + archive;
      break;
    }
  }
  try {
    external_node_fs_.writeFileSync(fp, head + kept);
    if (archive.length) {
      const arcFp = _archivePath(scanRoot);
      let existing = '';
      try { existing = external_node_fs_.existsSync(arcFp) ? external_node_fs_.readFileSync(arcFp, 'utf8') : ''; } catch {}
      let next = existing + archive;
      if (next.length > ARCHIVE_MAX_BYTES) {
        // Drop oldest entries until under cap.
        const oldestSplit = next.split(/(?=\n## )/g).filter(s => s.length);
        while (oldestSplit.length && next.length > ARCHIVE_MAX_BYTES) {
          oldestSplit.shift();
          next = oldestSplit.join('');
        }
      }
      external_node_fs_.writeFileSync(arcFp, next);
    }
  } catch { /* best-effort rotation */ }
}

// Public summary helper for the SessionStart hook. Returns a tail aligned
// to a section header (no leading partial entry, no leading newline).
function summarizeForSession(scanRoot, { maxBytes = 6 * 1024 } = {}) {
  const body = readAgentsMemory(scanRoot);
  if (!body) return null;
  if (body.length <= maxBytes) return body;
  const tail = body.slice(-maxBytes);
  const firstSection = tail.indexOf('\n## ');
  if (firstSection < 0) return tail;
  // Slice past the leading `\n` so the result starts with `## `.
  return tail.slice(firstSection + 1);
}

const _internals = { MAX_BYTES, MAX_ENTRY_BYTES, MEMORY_FILE, ARCHIVE_FILE };

// EXTERNAL MODULE: external "node:os"
var external_node_os_ = __webpack_require__(8161);
;// CONCATENATED MODULE: ./src/posture/cve-lookup.js
// CVE lookup — read-only against the per-install OSV / KEV / EPSS caches.
//
// LangChain harness-anatomy post:
//   "Knowledge cutoffs mean that models can't directly access new data like
//    updated library versions without the user providing them directly."
//
// The validator and any subagent reasoning about an SCA finding can call
// `lookup_cve(cve_id)` to get the most recently-cached OSV advisory, the
// CISA KEV entry if listed, and the EPSS exploit-prediction percentile, all
// with `staleness` metadata so the caller can decide whether to trust the
// cached value.
//
// This module deliberately NEVER triggers a network fetch — the scan
// pipeline is the only thing that populates the cache. If a CVE isn't
// cached, we return `present: false` for that source rather than blocking
// on a fetch and risking a multi-second MCP timeout.






const CACHE_DIR = external_node_path_.join(external_node_os_.homedir(), '.claude', 'agentic-security', 'osv-cache');

function _keyToPath(key) {
  const safe = external_node_crypto_.createHash('sha256').update(key).digest('hex');
  return external_node_path_.join(CACHE_DIR, safe + '.json');
}

function _readCache(key) {
  const fp = _keyToPath(key);
  if (!external_node_fs_.existsSync(fp)) return { present: false };
  let body;
  try { body = external_node_fs_.readFileSync(fp, 'utf8'); }
  catch { return { present: false, error: 'unreadable' }; }
  let parsed;
  try { parsed = JSON.parse(body); }
  catch { return { present: false, error: 'unparseable' }; }
  let mtime = null;
  try { mtime = external_node_fs_.statSync(fp).mtimeMs; } catch {}
  return { present: true, data: parsed, cachedAt: mtime, ageMs: mtime ? Date.now() - mtime : null };
}

function _stalenessTier(ageMs) {
  if (ageMs === null || ageMs === undefined) return 'unknown';
  if (ageMs < 24 * 3600 * 1000) return 'fresh';        // <1d
  if (ageMs < 7 * 24 * 3600 * 1000) return 'recent';   // <1w
  if (ageMs < 30 * 24 * 3600 * 1000) return 'stale';   // <1mo
  return 'very-stale';
}

const CVE_RE = /^CVE-\d{4}-\d{1,7}$/i;

function lookupCve(rawId) {
  if (typeof rawId !== 'string' || !CVE_RE.test(rawId)) {
    return { ok: false, reason: 'invalid-cve-id', expected: 'CVE-YYYY-NNNN' };
  }
  const cve = rawId.toUpperCase();

  // KEV catalog — single cached blob keyed at 'kev:catalog'.
  const kevCacheRaw = _readCache('kev:catalog');
  let kev = { present: false };
  if (kevCacheRaw.present) {
    // The blob shape from engine.js: { ts, byCve: { 'CVE-XXX': { ... } } }
    // sessionStorage shim stores the value as the JSON-stringified inner
    // object directly (no extra wrapper).
    const blob = kevCacheRaw.data;
    const byCve = blob?.byCve || null;
    if (byCve && byCve[cve]) {
      kev = {
        present: true,
        ...byCve[cve],
        cachedAt: kevCacheRaw.cachedAt,
        ageMs: kevCacheRaw.ageMs,
        staleness: _stalenessTier(kevCacheRaw.ageMs),
      };
    } else if (byCve) {
      // Catalog is cached but doesn't list this CVE — meaningful negative.
      kev = {
        present: false, listedInCatalog: false,
        cachedAt: kevCacheRaw.cachedAt, ageMs: kevCacheRaw.ageMs,
        staleness: _stalenessTier(kevCacheRaw.ageMs),
      };
    }
  }

  // EPSS — per-CVE cache at 'epss:CVE-XXX'.
  const epssRaw = _readCache('epss:' + cve);
  let epss = { present: false };
  if (epssRaw.present) {
    epss = {
      present: epssRaw.data !== false,   // engine stores `false` for "looked up, no record"
      score: epssRaw.data?.score ?? null,
      percentile: epssRaw.data?.percentile ?? null,
      cachedAt: epssRaw.cachedAt,
      ageMs: epssRaw.ageMs,
      staleness: _stalenessTier(epssRaw.ageMs),
    };
  }

  // OSV — entries are keyed by vuln id (GHSA-... or CVE-...). The engine
  // caches them at 'vuln:<id>'. We do a direct CVE lookup AND a soft probe
  // for any known alias the caller provided implicitly through the KEV
  // hit's vendor/product (no — we keep this simple: direct lookup only).
  const osvRaw = _readCache('vuln:' + cve);
  let osv = { present: false };
  if (osvRaw.present) {
    osv = {
      present: true,
      data: osvRaw.data,
      cachedAt: osvRaw.cachedAt, ageMs: osvRaw.ageMs,
      staleness: _stalenessTier(osvRaw.ageMs),
    };
  }

  return {
    ok: true,
    cve,
    kev,
    epss,
    osv,
    sourcesFound: [kev.present, epss.present, osv.present].filter(Boolean).length,
    note: (kev.present || epss.present || osv.present)
      ? 'cached values only; staleness tier per source. The MCP tool does NOT trigger a network fetch.'
      : 'no cached data for this CVE on the current install. Run a scan against a project that depends on the affected package, or set $AGENTIC_SECURITY_OFFLINE=0 and run a scan to populate the cache.',
  };
}

const cve_lookup_internals = { CACHE_DIR, CVE_RE, _stalenessTier };

;// CONCATENATED MODULE: ./src/mcp/tools.js
// MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
// Top 10 (see ./redact.js, ./audit.js, ./server.js for sibling controls).
//
// Trust model:
//   - Session root fixed at server boot. No per-call retargeting.
//   - Path arguments lstat-checked (symlinks refused, OWASP MCP05) and
//     realpath-confined to session root.
//   - Tool outputs marked _meta.untrusted_excerpts:true (OWASP MCP03/MCP06)
//     because they may contain text from scanned files, which is adversary-
//     controlled in any context where the agent might read malicious code.
//   - Secret-shaped strings redacted on the way out (OWASP MCP01/MCP10).
//   - `apply_fix` requires confirm:true, valid HMAC signature on
//     last-scan.json, non-shadow finding, and confined file path.









// Lazy-loaded: these transitively pull in npm packages (fast-glob,
// @babel/core) that aren't available in the plugin-cache install path
// (no node_modules). Deferring keeps the MCP server bootable everywhere;
// the import only runs when a tool that needs them is actually called.
let _runScan;
async function getRunScan() {
  if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 9099))).runScan;
  return _runScan;
}
let _verifyFixCore;
async function getVerifyFixCore() {
  if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */ 838).then(__webpack_require__.bind(__webpack_require__, 7838))).verifyFix;
  return _verifyFixCore;
}

const MAX_FILES_PER_SCAN = 1024;
const MAX_FILE_BYTES = 500_000;
const MAX_TOTAL_SCAN_BYTES = 50_000_000;
const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };

// OWASP A01 — refuse writes to paths that could subvert the security tool
// itself or the host's source-control / dependency state. A forged finding
// could otherwise tell apply_fix to overwrite our own rules.yml, our audit
// log, a .git/hooks/post-commit payload, a CI workflow, an IaC file, or a
// dependency manifest (premortem #3 expansion).
//
// Two kinds of guard:
//   - DIR-prefix matches anywhere under one of these directories
//   - FILE-suffix matches any path whose basename ends with one of these
const RESERVED_WRITE_PREFIXES = [
  '.git/',
  '.github/',
  '.gitlab/',
  '.circleci/',
  '.buildkite/',
  '.agentic-security/',
  'node_modules/',
  '.terraform/',
  '.aws/',
  'k8s/',
  'kubernetes/',
];
const RESERVED_WRITE_BASENAMES = new Set([
  'Dockerfile',
  'Jenkinsfile',
  '.gitlab-ci.yml',
  '.gitlab-ci.yaml',
  'package.json',
  'package-lock.json',
  'yarn.lock',
  'pnpm-lock.yaml',
  'pyproject.toml',
  'Pipfile',
  'Pipfile.lock',
  'poetry.lock',
  'requirements.txt',
  'go.mod',
  'go.sum',
  'Cargo.toml',
  'Cargo.lock',
  'composer.json',
  'composer.lock',
  'Gemfile',
  'Gemfile.lock',
  'pom.xml',
  'build.gradle',
  'build.gradle.kts',
]);
const RESERVED_WRITE_SUFFIXES = [
  '.tf',
  '.tfvars',
  'docker-compose.yml',
  'docker-compose.yaml',
];
function _isReservedWritePath(sessionRoot, absFile) {
  // Resolve sessionRoot symlinks so the relative path is computed against
  // the same canonical root as `absFile` (which _confine already realpath'd).
  // On macOS /tmp → /private/tmp; without this normalization the relative
  // would contain "../" and the prefix check would miss the reserved path.
  const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
  const rel = external_node_path_.relative(rootReal, absFile).replace(/\\/g, '/');
  if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
  const base = rel.split('/').pop() || '';
  if (RESERVED_WRITE_BASENAMES.has(base)) return true;
  if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
  return false;
}

// LangChain harness-anatomy recommendation: the filesystem is the right
// collaboration / scratchpad surface for subagents. We carve out one writable
// directory inside the otherwise-reserved `.agentic-security/` tree —
// `.agentic-security/agent-scratchpad/<agent>/<session>/` — and expose
// `append_scratchpad` / `read_scratchpad` for in-progress agent state.
//
// Confinement rules:
//   - relative path required (no absolute / no `..`)
//   - must start with `agent-scratchpad/<agent>/<session>/`
//   - `<agent>` and `<session>` are restricted to `[A-Za-z0-9_.-]{1,64}`
//     (no slashes — keeps the prefix exactly three components deep)
//   - file basename: same charset rules
//   - max scratchpad bytes per file: SCRATCHPAD_MAX_FILE_BYTES
const SCRATCHPAD_PREFIX = '.agentic-security/agent-scratchpad/';
const SCRATCHPAD_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
const SCRATCHPAD_MAX_FILE_BYTES = 2 * 1024 * 1024;   // 2 MB per file
const SCRATCHPAD_MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB per scan root

function _validateScratchpadPath(relPath) {
  if (typeof relPath !== 'string' || !relPath.length) {
    return { ok: false, reason: 'path: not a string' };
  }
  if (external_node_path_.isAbsolute(relPath)) return { ok: false, reason: 'path: must be relative' };
  if (relPath.includes('..')) return { ok: false, reason: 'path: must not contain ..' };
  const normalized = relPath.replace(/\\/g, '/');
  if (!normalized.startsWith(SCRATCHPAD_PREFIX)) {
    return { ok: false, reason: `path: must start with "${SCRATCHPAD_PREFIX}"` };
  }
  const rest = normalized.slice(SCRATCHPAD_PREFIX.length);
  const parts = rest.split('/');
  if (parts.length < 3) {
    return { ok: false, reason: 'path: must be agent-scratchpad/<agent>/<session>/<file>' };
  }
  const [agent, session, ...fileParts] = parts;
  if (!SCRATCHPAD_NAME_RE.test(agent)) return { ok: false, reason: `path: agent name "${agent}" not in [A-Za-z0-9_.-]{1,64}` };
  if (!SCRATCHPAD_NAME_RE.test(session)) return { ok: false, reason: `path: session id "${session}" not in [A-Za-z0-9_.-]{1,64}` };
  for (const p of fileParts) {
    if (!SCRATCHPAD_NAME_RE.test(p)) return { ok: false, reason: `path: file part "${p}" not in [A-Za-z0-9_.-]{1,64}` };
  }
  return { ok: true, agent, session, fileParts };
}

function _scratchpadAbs(sessionRoot, relPath) {
  return external_node_path_.resolve(sessionRoot, relPath.replace(/\\/g, '/'));
}

function _scratchpadTotalBytes(sessionRoot) {
  const base = external_node_path_.join(sessionRoot, '.agentic-security', 'agent-scratchpad');
  if (!external_node_fs_.existsSync(base)) return 0;
  let total = 0;
  const walk = (dir) => {
    let entries;
    try { entries = external_node_fs_.readdirSync(dir, { withFileTypes: true }); } catch { return; }
    for (const e of entries) {
      const fp = external_node_path_.join(dir, e.name);
      try {
        if (e.isFile()) { total += external_node_fs_.statSync(fp).size; }
        else if (e.isDirectory()) walk(fp);
      } catch { /* skip */ }
    }
  };
  walk(base);
  return total;
}

// ─── Path confinement ────────────────────────────────────────────────────────
// Lexical check + lstat symlink reject + realpath re-check. OWASP MCP05.
//
// For non-existent paths (apply_fix to a new file is a possible legitimate
// case; in practice we re-check existence at the use-site) we walk up the
// deepest existing ancestor and realpath that, so a parent-symlink can't
// silently relocate writes.
function _confine(sessionRoot, candidate, label) {
  if (typeof candidate !== 'string' || !candidate) throw new Error(`${label}: not a string`);
  const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
  const abs = external_node_path_.isAbsolute(candidate) ? candidate : external_node_path_.resolve(rootReal, candidate);

  // Lexical pre-check: rejects "../../etc/passwd" before any fs call.
  const relLex = external_node_path_.relative(rootReal, external_node_path_.resolve(abs));
  if (relLex === '' || relLex.startsWith('..') || external_node_path_.isAbsolute(relLex)) {
    throw new Error(`${label}: path "${candidate}" escapes session root`);
  }

  // If the path exists, the leaf must not be a symlink and its realpath
  // must still be under rootReal.
  if (external_node_fs_.existsSync(abs)) {
    if (external_node_fs_.lstatSync(abs).isSymbolicLink()) {
      throw new Error(`${label}: path "${candidate}" is a symbolic link (refused)`);
    }
    const real = external_node_fs_.realpathSync(abs);
    if (external_node_path_.relative(rootReal, real).startsWith('..')) {
      throw new Error(`${label}: path "${candidate}" resolves outside session root via symlink`);
    }
    return real;
  }

  // Path doesn't exist — walk up to the deepest existing ancestor and
  // realpath that. If a parent dir is a symlink pointing outside rootReal
  // we catch it here.
  let parent = external_node_path_.dirname(abs);
  while (parent !== external_node_path_.dirname(parent) && !external_node_fs_.existsSync(parent)) {
    parent = external_node_path_.dirname(parent);
  }
  const parentReal = external_node_fs_.realpathSync(parent);
  if (external_node_path_.relative(rootReal, parentReal).startsWith('..')) {
    throw new Error(`${label}: path "${candidate}" parent resolves outside session root`);
  }
  const suffix = external_node_path_.relative(parent, abs);
  return external_node_path_.resolve(parentReal, suffix);
}

function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
  const stateDir = external_node_path_.join(sessionRoot, '.agentic-security');
  const scanFile = external_node_path_.join(stateDir, 'last-scan.json');
  const sigFile = scanFile + '.sig';
  if (!external_node_fs_.existsSync(scanFile)) return { scan: null, status: 'missing' };
  const body = external_node_fs_.readFileSync(scanFile, 'utf8');
  const ok = (0,integrity/* verifyLastScan */.Ef)(body, sigFile);
  if (ok === false) return { scan: null, status: 'tampered' };
  if (ok === null && !allowUnsigned) return { scan: null, status: 'unsigned' };
  let parsed;
  try { parsed = JSON.parse(body); }
  catch { return { scan: null, status: 'unparseable' }; }
  return { scan: parsed, status: ok ? 'verified' : 'unsigned' };
}

function _findById(scan, id) {
  if (!scan) return null;
  return (scan.findings || []).find(f => f.id === id)
      || (scan.secrets || []).find(f => f.id === id)
      || null;
}

// ─── Tool-output offloading (harness-anatomy #1) ────────────────────────────
// LangChain post: "the harness keeps the head and tail tokens of tool outputs
// above a threshold number of tokens and offloads the full output to the
// filesystem." We apply this to any MCP tool response whose findings array
// exceeds OFFLOAD_THRESHOLD entries: write the full list to a scratchpad
// file, return only head[0..3] + tail[-2..] + total + path. The agent can
// call `read_scratchpad(path)` to page through the rest.
//
// Design choices:
//   - Threshold is conservative (10) — anything bigger than a casual UI page
//     gets offloaded. Tunable via $AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD.
//   - Offload location is the agent-scratchpad (not a separate dir) so the
//     same cleanup + size caps apply.
//   - File names are deterministic per response (sha256 of JSON.stringify)
//     so two identical responses share the same offload file.
//   - The session id is process.pid + boot timestamp short hash — collides
//     only across restarts within a millisecond, which is fine for cache.
const OFFLOAD_THRESHOLD = (() => {
  const v = parseInt(process.env.AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD || '10', 10);
  return Number.isFinite(v) && v >= 1 ? v : 10;
})();
const MCP_SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;

function _maybeOffload(sessionRoot, toolName, items) {
  if (!Array.isArray(items) || items.length <= OFFLOAD_THRESHOLD) {
    return { offloaded: false, items, total: items.length };
  }
  const head = items.slice(0, 3);
  const tail = items.slice(-2);
  const json = JSON.stringify({ tool: toolName, total: items.length, items }, null, 2);
  const hashShort = external_node_crypto_.createHash('sha256').update(json).digest('hex').slice(0, 10);
  const rel = `.agentic-security/agent-scratchpad/mcp-offload/${MCP_SESSION_ID}/${toolName}-${hashShort}.json`;
  const abs = external_node_path_.resolve(sessionRoot, rel);
  try {
    external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
    external_node_fs_.writeFileSync(abs, json);
  } catch (e) {
    // If we can't write to disk for some reason, fall back to returning
    // everything — the alternative would be silently dropping data, which
    // is worse than blowing the context.
    return { offloaded: false, items, total: items.length, offloadError: e.message };
  }
  return {
    offloaded: true,
    head, tail, total: items.length,
    scratchpadPath: rel,
    pagingHint: `call read_scratchpad({ path: "${rel}", offset, limit }) to page through; the file is { tool, total, items: [...] } JSON`,
  };
}

// ─── scan_diff ───────────────────────────────────────────────────────────────
const scan_diff = {
  name: 'scan_diff',
  description: 'Scan a list of files for security findings. Use BEFORE writing a Write/Edit to disk so the agent can self-correct. Returns findings with severity, file:line, title, remediation. Snippets are redacted of obvious secret patterns. Paths confined to the session root; symlinks are refused.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      files: {
        type: 'array', minItems: 1, maxItems: MAX_FILES_PER_SCAN,
        items: { type: 'string', minLength: 1, maxLength: 4096 },
      },
      severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
    },
    required: ['files'],
  },
  async handler({ files, severity }, ctx) {
    const sessionRoot = ctx.sessionRoot;
    const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));

    const fileContents = {};
    let totalBytes = 0;
    for (const a of abs) {
      let stat;
      try { stat = external_node_fs_.statSync(a); } catch { continue; }
      if (!stat.isFile()) continue;
      if (stat.size > MAX_FILE_BYTES) continue;
      totalBytes += stat.size;
      if (totalBytes > MAX_TOTAL_SCAN_BYTES) {
        throw new Error(`scan_diff: total scan size exceeds ${MAX_TOTAL_SCAN_BYTES} bytes`);
      }
      let content;
      try { content = external_node_fs_.readFileSync(a, 'utf8'); } catch { continue; }
      const rel = external_node_path_.relative(sessionRoot, a).replace(/\\/g, '/');
      fileContents[rel] = content;
    }

    const runScan = await getRunScan();
    const result = await runScan(sessionRoot, { network: false, fileContents });
    const wantSet = new Set(Object.keys(fileContents));
    const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
    const min = sevRank[severity] ?? 0;
    const findings = (result.scan.findings || [])
      .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
      .map(f => redactFinding({
        id: f.id, severity: f.severity, file: f.file, line: f.line,
        title: f.title || f.vuln, cwe: f.cwe,
        description: f.description, remediation: f.remediation,
      }));
    // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
    // The agent gets a head+tail preview plus a path it can page through;
    // the full finding list lives on disk. This is the documented fix for
    // "context rot" — large tool outputs eat the model's attention budget.
    const off = _maybeOffload(sessionRoot, 'scan_diff', findings);
    if (off.offloaded) {
      return {
        _meta: META,
        scannedFiles: Object.keys(fileContents).length,
        findingCount: off.total,
        offloaded: true,
        head: off.head, tail: off.tail,
        scratchpadPath: off.scratchpadPath,
        pagingHint: off.pagingHint,
      };
    }
    return {
      _meta: META,
      scannedFiles: Object.keys(fileContents).length,
      findingCount: findings.length,
      findings,
    };
  },
};

// ─── query_taint ─────────────────────────────────────────────────────────────
const query_taint = {
  name: 'query_taint',
  description: 'Query whether the last verified scan found a taint path involving a given source and sink. Paginated — returns up to `limit` matches (default 10, max 50) starting at `offset` (default 0); set `truncated:true` and `totalMatches` tell you when to page.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      source: { type: 'string', minLength: 1, maxLength: 256 },
      sink: { type: 'string', minLength: 1, maxLength: 256 },
      limit: { type: 'integer', minimum: 1, maximum: 50 },
      offset: { type: 'integer', minimum: 0, maximum: 10000 },
    },
    required: ['source', 'sink'],
  },
  async handler({ source, sink, limit, offset }, ctx) {
    const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
    if (!scan) {
      return { _meta: META, hasResult: false, status, message: `No usable scan state (${status}).` };
    }
    const lim = Number.isInteger(limit) ? Math.min(50, Math.max(1, limit)) : 10;
    const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
    const srcL = String(source).toLowerCase();
    const sinkL = String(sink).toLowerCase();
    // Filter first (cheap), then paginate (so totalMatches is accurate).
    // Harness-engineering note (post-derived): "context window != context
    // attention." Returning hundreds of matches to the agent in one shot
    // dilutes its reasoning; the agent receives a bounded slice plus the
    // cursor to fetch the rest if it wants.
    const all = (scan.findings || []).filter(f => {
      const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
      return hay.includes(srcL) && hay.includes(sinkL);
    });
    const page = all.slice(off, off + lim).map(f => redactFinding({
      id: f.id, severity: f.severity, file: f.file, line: f.line,
      title: f.title || f.vuln, description: f.description,
      trace: f.trace || null,
    }));
    return {
      _meta: META,
      hasResult: true,
      integrity: status,
      scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
      totalMatches: all.length,
      matchCount: page.length,
      offset: off,
      limit: lim,
      truncated: off + page.length < all.length,
      nextOffset: off + page.length < all.length ? off + page.length : null,
      matches: page,
    };
  },
};

// ─── explain_finding ─────────────────────────────────────────────────────────
const explain_finding = {
  name: 'explain_finding',
  description: 'Return full details for a single finding from the last verified scan. Snippet/description redacted of secret patterns.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      finding_id: { type: 'string', minLength: 1, maxLength: 256 },
    },
    required: ['finding_id'],
  },
  async handler({ finding_id }, ctx) {
    const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
    if (!scan) throw new Error(`No usable scan state (${status}).`);
    const f = _findById(scan, finding_id);
    if (!f) throw new Error(`Finding not found: ${finding_id}`);
    const redacted = redactFinding({
      id: f.id, severity: f.severity, file: f.file, line: f.line,
      title: f.title || f.vuln, cwe: f.cwe,
      description: f.description, remediation: f.remediation,
      snippet: f.snippet || null,
      trace: f.trace || null,
    });
    // Harness-anatomy #1: explain_finding's trace is the most-likely-large
    // field on a single finding. Offload when it crosses the threshold so
    // the agent gets a head/tail preview, not a 50-step trace dumped into
    // its context.
    let traceTrimmed = redacted.trace;
    let traceMeta = null;
    if (Array.isArray(redacted.trace) && redacted.trace.length > OFFLOAD_THRESHOLD) {
      const off = _maybeOffload(ctx.sessionRoot, 'explain_finding-trace', redacted.trace);
      if (off.offloaded) {
        traceTrimmed = [...off.head, { _gap: `... ${off.total - off.head.length - off.tail.length} more steps elided; read scratchpad ...` }, ...off.tail];
        traceMeta = {
          totalSteps: off.total,
          scratchpadPath: off.scratchpadPath,
          pagingHint: off.pagingHint,
        };
      }
    }
    return {
      _meta: META,
      ...redacted,
      trace: traceTrimmed,
      traceOffload: traceMeta,
      confidence: f.confidence ?? null,
      hasReplacementFix: typeof f.fix?.replacement === 'string',
      integrity: status,
    };
  },
};

// ─── apply_fix ───────────────────────────────────────────────────────────────
const apply_fix = {
  name: 'apply_fix',
  description: 'Apply the stored replacement fix for a finding. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if its file path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      finding_id: { type: 'string', minLength: 1, maxLength: 256 },
      confirm: { type: 'boolean' },
      dry_run: { type: 'boolean' },
    },
    required: ['finding_id', 'confirm'],
  },
  async handler({ finding_id, confirm, dry_run = false }, ctx) {
    if (confirm !== true) {
      return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
    }
    const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
    if (!scan) {
      return { _meta: META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
    }
    const f = _findById(scan, finding_id);
    if (!f) return { _meta: META, applied: false, reason: `Finding not found: ${finding_id}` };
    if (f._shadow === true) {
      return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
    }
    if (typeof f.fix?.replacement !== 'string') {
      // Premortem #2: templates are patch-shaped text. Same reasoning as
      // the replacement path — do NOT pass through redactString here.
      return {
        _meta: META, applied: false,
        reason: 'No full replacement available — only a template. Apply the template manually.',
        template: f.fix?.code || '',
        file: f.file, line: f.line,
      };
    }
    let absFile;
    try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
    catch (e) {
      return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` };
    }
    if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
      return { _meta: META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
    }
    if (!external_node_fs_.existsSync(absFile)) {
      return { _meta: META, applied: false, reason: `File not found: ${absFile}` };
    }
    const originalContent = await promises_.readFile(absFile, 'utf8');

    if (dry_run) {
      return {
        _meta: META,
        applied: false, dryRun: true,
        file: f.file,
        originalSize: originalContent.length,
        newSize: f.fix.replacement.length,
        diffSummary: `${originalContent.length} → ${f.fix.replacement.length} bytes`,
      };
    }

    let entry;
    try {
      entry = await (0,fix_history/* applyFix */.oM)({
        scanRoot: ctx.sessionRoot,
        file: f.file,
        originalContent,
        newContent: f.fix.replacement,
        findingId: f.id,
        stableId: f.stableId || null,   // premortem 4R-8
        ruleId: f.rule || null,
        vuln: f.vuln || f.title || null,
      });
    } catch (e) {
      // Harness-engineering: step-budget refusal (post-derived). The
      // deterministic layer enforces at-most-N attempts per stableId. When
      // exceeded, surface it as a structured `budget-exceeded` outcome the
      // agent can recognize — not a generic error.
      if (e && e.name === 'FixAttemptBudgetExceededError') {
        return {
          _meta: META,
          applied: false,
          reason: `budget-exceeded: ${e.message}`,
          budgetExceeded: true,
          attempts: e.attempts,
          maxAttempts: e.max,
          key: e.key,
        };
      }
      throw e;
    }
    return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal };
  },
};

// ─── verify_fix ──────────────────────────────────────────────────────────────
// Closed-loop verification of a proposed patch BEFORE the agent applies it.
// Re-scans the patched files in-memory (no disk write), confirms the original
// stableId is gone, and runs the project's existing linter on the patched
// files. Returns a structured verdict the agent can use to decide whether to
// proceed with apply_fix.
const verify_fix = {
  name: 'verify_fix',
  description: 'Verify a proposed patch before applying. Re-scans the patched files in memory and runs the project linter. Returns { ok, rescan, lint, summary }. No filesystem writes.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      stable_id: { type: 'string', minLength: 8, maxLength: 64 },
      files: {
        type: 'object',
        additionalProperties: { type: 'string', maxLength: 500_000 },
        minProperties: 1,
        maxProperties: 8,
      },
    },
    required: ['stable_id', 'files'],
  },
  async handler({ stable_id, files }, ctx) {
    // Confine every file path before passing to the verifier.
    const confined = {};
    for (const [relPath, content] of Object.entries(files || {})) {
      try {
        _confine(ctx.sessionRoot, relPath, 'files key');
      } catch (e) {
        return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` };
      }
      confined[relPath] = String(content);
    }
    try {
      const verifyFixCore = await getVerifyFixCore();
      const r = await verifyFixCore({
        scanRoot: ctx.sessionRoot,
        originalFindingStableId: stable_id,
        files: confined,
      });
      return {
        _meta: META,
        ok: r.ok,
        rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
        lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
        summary: r.summary,
      };
    } catch (e) {
      return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
    }
  },
};

// ─── synthesize_fix ──────────────────────────────────────────────────────────
// Return the stored fix replacement + regression-test scaffold for a finding,
// WITHOUT applying anything. The agent can call verify_fix → apply_fix in
// sequence with the returned blob.
const synthesize_fix = {
  name: 'synthesize_fix',
  description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      finding_id: { type: 'string', minLength: 1, maxLength: 256 },
    },
    required: ['finding_id'],
  },
  async handler({ finding_id }, ctx) {
    const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
    if (!scan) {
      return { _meta: META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
    }
    const f = _findById(scan, finding_id);
    if (!f) return { _meta: META, ok: false, reason: `Finding not found: ${finding_id}` };
    if (f._shadow === true) return { _meta: META, ok: false, reason: 'shadow findings have no synthesized fix' };
    const fix = f.fix || {};
    const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
    // Patch bounds: count files touched + LoC delta.
    let touchedFiles = 1;
    let locDelta = 0;
    if (hasReplacement) {
      let orig = '';
      try {
        const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
        orig = external_node_fs_.readFileSync(abs, 'utf8');
      } catch { /* ignore — counts will reflect new-only LoC */ }
      locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
    }
    const oversized = touchedFiles > 3 || locDelta > 100;
    // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
    // not a finding excerpt. Running it through redactString silently corrupts
    // valid patches whose content happens to match a secret-shape (e.g. a
    // placeholder like `password = "loadFromEnv"`). Patches MUST pass through
    // verbatim. Snippet/description/etc. continue to be redacted in
    // explain_finding / scan_diff — that's the right surface for redaction.
    return {
      _meta: META,
      ok: true,
      stable_id: f.stableId || null,
      file: f.file, line: f.line,
      vuln: f.vuln,
      severity: f.severity,
      hasReplacement,
      replacement: hasReplacement ? fix.replacement : null,
      template: fix.code || null,
      remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
      patchBounds: { touchedFiles, locDelta, oversized },
      recommendsFixPlan: oversized && !hasReplacement,
    };
  },
};

// ─── find_rule_module ───────────────────────────────────────────────────────
// Codebase-navigation helper (C.6). Answers "which file under scanner/src/
// implements the detector for CWE-X / family Y" by scanning the SAST and
// posture sources for `cwe:` / `family:` literals. Cheaper and more reliable
// than asking the agent to grep — premortem note: "grep for a common function
// name in a large codebase returns thousands of matches."
//
// Read-only; no findings consumed. Output is a list of file paths + the
// matching literal lines so the agent can verify before editing.
const find_rule_module = {
  name: 'find_rule_module',
  description: 'Find the file(s) under scanner/src/{sast,posture}/ that emit findings for a given CWE id or family name. Use BEFORE editing a rule — answers "where is the SQL-injection detector?" without grepping the whole tree. Returns at most 20 hits; refine the query if too broad.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      cwe: { type: 'string', minLength: 5, maxLength: 16 },
      family: { type: 'string', minLength: 2, maxLength: 64 },
    },
  },
  async handler({ cwe, family }, ctx) {
    if (!cwe && !family) {
      return { _meta: META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
    }
    // Pattern enforcement — the mini-schema validator doesn't do `pattern`.
    if (cwe && !/^CWE-\d+$/.test(cwe)) {
      return { _meta: META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
    }
    if (family && !/^[a-z][a-z0-9-]+$/.test(family)) {
      return { _meta: META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
    }
    const sessionRoot = ctx.sessionRoot;
    const roots = [
      external_node_path_.join(sessionRoot, 'scanner', 'src', 'sast'),
      external_node_path_.join(sessionRoot, 'scanner', 'src', 'posture'),
    ];
    const hits = [];
    const cweLit = cwe ? new RegExp(`['"\`]${cwe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`) : null;
    // Family match is broader on purpose: detectors often emit findings
    // without an explicit `family:` field (it's backfilled by
    // posture/finding-defaults.js). We match the family literal anywhere in
    // the file (vuln-name strings, comments, ids) so e.g. searching for "csrf"
    // surfaces sast/csrf.js even though it doesn't tag findings with the field.
    const famLit = family ? new RegExp(`\\b${family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-_ ]?')}\\b`, 'i') : null;
    // Also try a filename-stem match when only family is given.
    const famFilename = family ? family.toLowerCase() : null;
    for (const root of roots) {
      if (!external_node_fs_.existsSync(root)) continue;
      let entries;
      try { entries = external_node_fs_.readdirSync(root); } catch { continue; }
      for (const entry of entries) {
        if (!entry.endsWith('.js')) continue;
        const abs = external_node_path_.join(root, entry);
        let stat;
        try { stat = external_node_fs_.statSync(abs); } catch { continue; }
        if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue;
        let body;
        try { body = external_node_fs_.readFileSync(abs, 'utf8'); } catch { continue; }
        const lines = body.split('\n');
        const matches = [];
        const stem = entry.replace(/\.js$/, '').toLowerCase();
        const filenameMatchesFamily = famFilename && (stem === famFilename || stem.includes(famFilename));
        if (filenameMatchesFamily) {
          matches.push({ line: 1, text: `<filename "${entry}" matches family>`, kind: 'filename' });
        }
        for (let i = 0; i < lines.length; i++) {
          const line = lines[i];
          if (cweLit && cweLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'cwe' });
          else if (famLit && famLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'family' });
          if (matches.length >= 5) break;
        }
        if (matches.length) {
          hits.push({
            file: external_node_path_.relative(sessionRoot, abs).replace(/\\/g, '/'),
            matchCount: matches.length,
            matches,
          });
          if (hits.length >= 20) break;
        }
      }
      if (hits.length >= 20) break;
    }
    return {
      _meta: META,
      ok: true,
      query: { cwe: cwe || null, family: family || null },
      hitCount: hits.length,
      hits,
      truncated: hits.length >= 20,
    };
  },
};

// ─── append_scratchpad / read_scratchpad ───────────────────────────────────
// LangChain harness-anatomy: the filesystem is the durable agent scratchpad.
// These tools expose a tightly-confined slice of the project tree for
// in-progress agent state: PLAN.md decompositions, offloaded tool outputs,
// session notes that survive context resets.
//
// Confinement (validated in `_validateScratchpadPath`):
//   ALL paths must start with `.agentic-security/agent-scratchpad/<agent>/<session>/`
//   and consist of [A-Za-z0-9_.-]{1,64} path components — no `..`, no
//   absolute paths, no shell metacharacters. This is the ONE place inside
//   the otherwise-reserved `.agentic-security/` tree where agents can write.
// Limits:
//   - 2 MB per file (write attempts beyond this are refused).
//   - 50 MB total across the scratchpad — protects against runaway agents.
// Operators who want to clean up: `rm -rf .agentic-security/agent-scratchpad`.
//
// The post: "Agents can store intermediate outputs and maintain state that
// outlasts a single session." This is that mechanism.

const append_scratchpad = {
  name: 'append_scratchpad',
  description: 'Append text to a file under .agentic-security/agent-scratchpad/<agent>/<session>/. The ONLY writable location for in-progress agent state (PLAN.md, notes, offloaded tool outputs, decision logs). Path must start with that prefix; <agent>/<session>/file parts are restricted to [A-Za-z0-9_.-]{1,64}. Caps: 2 MB per file, 50 MB total across the scratchpad.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      path: { type: 'string', minLength: 1, maxLength: 256 },
      content: { type: 'string', minLength: 1, maxLength: 256 * 1024 },
    },
    required: ['path', 'content'],
  },
  async handler({ path: relPath, content }, ctx) {
    const v = _validateScratchpadPath(relPath);
    if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
    const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
    const total = _scratchpadTotalBytes(ctx.sessionRoot);
    if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
      return {
        _meta: META, ok: false,
        reason: `scratchpad-total-exceeded: ${total} + ${content.length} > ${SCRATCHPAD_MAX_TOTAL_BYTES}. Clean up via "rm -rf .agentic-security/agent-scratchpad" or rotate sessions.`,
      };
    }
    let existing = 0;
    try { if (external_node_fs_.existsSync(abs)) existing = external_node_fs_.statSync(abs).size; } catch {}
    if (existing + content.length > SCRATCHPAD_MAX_FILE_BYTES) {
      return {
        _meta: META, ok: false,
        reason: `scratchpad-file-exceeded: ${existing} + ${content.length} > ${SCRATCHPAD_MAX_FILE_BYTES}. Start a new file.`,
      };
    }
    try {
      external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
      external_node_fs_.appendFileSync(abs, content);
      return {
        _meta: META, ok: true,
        path: relPath, bytesWritten: content.length, fileSize: existing + content.length,
        scratchpadTotal: total + content.length,
      };
    } catch (e) {
      return { _meta: META, ok: false, reason: `write-failed: ${e.message}` };
    }
  },
};

const read_scratchpad = {
  name: 'read_scratchpad',
  description: 'Read a file under .agentic-security/agent-scratchpad/<agent>/<session>/. Paginated for large files via `offset` (default 0) and `limit` (default 4096 bytes, max 64 KB). Returns bytesRead, truncated, nextOffset for paging.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      path: { type: 'string', minLength: 1, maxLength: 256 },
      offset: { type: 'integer', minimum: 0, maximum: 100 * 1024 * 1024 },
      limit: { type: 'integer', minimum: 1, maximum: 64 * 1024 },
    },
    required: ['path'],
  },
  async handler({ path: relPath, offset, limit }, ctx) {
    const v = _validateScratchpadPath(relPath);
    if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
    const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
    if (!external_node_fs_.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
    let stat;
    try { stat = external_node_fs_.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
    if (!stat.isFile()) return { _meta: META, ok: false, reason: 'not-a-file' };
    const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
    const lim = Number.isInteger(limit) ? Math.min(64 * 1024, Math.max(1, limit)) : 4096;
    let buf;
    try {
      const fd = external_node_fs_.openSync(abs, 'r');
      const tmp = Buffer.alloc(lim);
      const read = external_node_fs_.readSync(fd, tmp, 0, lim, off);
      external_node_fs_.closeSync(fd);
      buf = tmp.slice(0, read);
    } catch (e) { return { _meta: META, ok: false, reason: `read-failed: ${e.message}` }; }
    const text = buf.toString('utf8');
    return {
      _meta: META, ok: true,
      path: relPath,
      offset: off, limit: lim, bytesRead: buf.length,
      totalSize: stat.size,
      truncated: off + buf.length < stat.size,
      nextOffset: off + buf.length < stat.size ? off + buf.length : null,
      content: text,
    };
  },
};

// ─── append_agents_memory / read_agents_memory ─────────────────────────────
// LangChain harness-anatomy #2: AGENTS.md as continual-learning surface.
// Lazy-import to keep the MCP module dependency-light.



const append_agents_memory = {
  name: 'append_agents_memory',
  description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      agent: { type: 'string', minLength: 1, maxLength: 64 },
      body: { type: 'string', minLength: 1, maxLength: 4096 },
    },
    required: ['agent', 'body'],
  },
  async handler({ agent, body }, ctx) {
    const r = appendAgentsMemory(ctx.sessionRoot, { agent, body });
    return { _meta: META, ...r };
  },
};

const read_agents_memory = {
  name: 'read_agents_memory',
  description: 'Read the AGENTS.md continual-learning file (and AGENTS.md.archive if needed). Returns the most-recent ~6 KB tail by default; pass `full: true` for everything. The SessionStart hook already surfaces a summary; use this when an agent wants to look up specifics mid-session.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      full: { type: 'boolean' },
    },
  },
  async handler({ full }, ctx) {
    const body = readAgentsMemory(ctx.sessionRoot);
    if (!body) return { _meta: META, present: false };
    if (full) return { _meta: META, present: true, length: body.length, content: body };
    // Tail-only — same logic as summarizeForSession but inlined to avoid a
    // second import surface.
    const limit = 6 * 1024;
    if (body.length <= limit) return { _meta: META, present: true, length: body.length, content: body };
    const tail = body.slice(-limit);
    const firstSection = tail.indexOf('\n## ');
    const slice = firstSection >= 0 ? tail.slice(firstSection) : tail;
    return { _meta: META, present: true, length: body.length, truncated: true, content: slice };
  },
};

// ─── lookup_cve ────────────────────────────────────────────────────────────
// LangChain harness-anatomy #8: bridge the knowledge-cutoff gap by exposing
// the local OSV / KEV / EPSS cache as a structured tool. Read-only — never
// triggers a network fetch from the MCP path.
const lookup_cve = {
  name: 'lookup_cve',
  description: 'Look up a CVE id in the local OSV / KEV / EPSS caches. Returns staleness-tiered cached data (fresh / recent / stale / very-stale). Read-only — does NOT fetch fresh data; the scan pipeline is the only thing that populates the cache. Use to inform reasoning about an SCA finding without relying on the model\'s training cutoff.',
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    properties: {
      cve: { type: 'string', minLength: 9, maxLength: 20 },
    },
    required: ['cve'],
  },
  async handler({ cve }, _ctx) {
    const r = lookupCve(cve);
    return { _meta: META, ...r };
  },
};

const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve];

;// CONCATENATED MODULE: ./src/mcp/validate.js
// Minimal JSON Schema validator — just the subset our tool schemas use.
// No deps. Throws on invalid input with a path-prefixed error message.
//
// Supported keywords: type (object/array/string/boolean/number),
// required, properties, items, enum, minItems, maxItems, maxLength,
// minLength, additionalProperties (only as `false` — strict).

const TYPE_OF = (v) => {
  if (v === null) return 'null';
  if (Array.isArray(v)) return 'array';
  return typeof v;
};

function validate(schema, value, path = 'arguments') {
  if (!schema) return;
  const t = schema.type;
  if (t === 'object') {
    if (TYPE_OF(value) !== 'object') throw new Error(`${path}: expected object, got ${TYPE_OF(value)}`);
    for (const req of schema.required || []) {
      if (!(req in value)) throw new Error(`${path}: missing required property "${req}"`);
    }
    if (schema.additionalProperties === false) {
      const allowed = new Set(Object.keys(schema.properties || {}));
      for (const k of Object.keys(value)) {
        if (!allowed.has(k)) throw new Error(`${path}: unexpected property "${k}"`);
      }
    }
    for (const [k, sub] of Object.entries(schema.properties || {})) {
      if (k in value) validate(sub, value[k], `${path}.${k}`);
    }
  } else if (t === 'array') {
    if (!Array.isArray(value)) throw new Error(`${path}: expected array, got ${TYPE_OF(value)}`);
    if (schema.minItems != null && value.length < schema.minItems) throw new Error(`${path}: minItems=${schema.minItems}, got length=${value.length}`);
    if (schema.maxItems != null && value.length > schema.maxItems) throw new Error(`${path}: maxItems=${schema.maxItems}, got length=${value.length}`);
    if (schema.items) for (let i = 0; i < value.length; i++) validate(schema.items, value[i], `${path}[${i}]`);
  } else if (t === 'string') {
    if (typeof value !== 'string') throw new Error(`${path}: expected string, got ${TYPE_OF(value)}`);
    if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: must be one of [${schema.enum.join(', ')}]`);
    if (schema.maxLength != null && value.length > schema.maxLength) throw new Error(`${path}: maxLength=${schema.maxLength}, got length=${value.length}`);
    if (schema.minLength != null && value.length < schema.minLength) throw new Error(`${path}: minLength=${schema.minLength}, got length=${value.length}`);
  } else if (t === 'boolean') {
    if (typeof value !== 'boolean') throw new Error(`${path}: expected boolean, got ${TYPE_OF(value)}`);
  } else if (t === 'number' || t === 'integer') {
    if (typeof value !== 'number') throw new Error(`${path}: expected number, got ${TYPE_OF(value)}`);
    if (t === 'integer' && !Number.isInteger(value)) throw new Error(`${path}: expected integer`);
    if (schema.minimum != null && value < schema.minimum) throw new Error(`${path}: < minimum (${schema.minimum})`);
    if (schema.maximum != null && value > schema.maximum) throw new Error(`${path}: > maximum (${schema.maximum})`);
  }
}

;// CONCATENATED MODULE: ./src/mcp/audit.js
// Append-only audit log of MCP tool calls — OWASP MCP08.
//
// Format: one JSON object per line (NDJSON) at
//   <sessionRoot>/.agentic-security/mcp-audit.log
//
// Each entry carries `prev` — the SHA-256 of the previous entry's serialized
// form. The first entry's prev is "GENESIS". Tampering with any line breaks
// the chain from that point forward; a reader can detect partial truncation
// or in-place edits.
//
// REMOTE SINK (post-recommendation #10). The local file alone cannot detect
// a total rewrite — an attacker with FS write can re-author the whole log
// with fresh hashes. Closing that blind spot requires an off-host witness.
// Set $AGENTIC_SECURITY_AUDIT_WEBHOOK to a POST endpoint; every entry is
// fire-and-forget POSTed there in addition to the local append. Failures
// to reach the webhook are best-effort — they NEVER block a tool call,
// because that would let a network outage become a denial of service. They
// DO get recorded as `_remoteSinkErr` on the local entry, so an operator
// reviewing the log later can spot a forging attempt that targeted the
// remote (any gap between local-sequence and remote-sequence is evidence).
//
// Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
// arguments cannot leak via the audit trail OR via the remote sink.






const MAX_ARG_BYTES = 1024;
const GENESIS = 'GENESIS';
const REMOTE_TIMEOUT_MS = 1500;

// Per-process session ID (harness-anatomy #9). Stamped on every audit entry
// so downstream metrics can aggregate by session and surface outliers like
// "200 apply_fix calls in one session." The ID is `<pid>-<short-ts>` — not
// cryptographically unique, but enough to disambiguate concurrent runs on
// the same host. Stable for the lifetime of this Node process.
const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;

function _summarize(args) {
  let s;
  try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
  s = redactArgsBlob(s);
  if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
  return s;
}

function _sha(s) { return external_node_crypto_.createHash('sha256').update(s).digest('hex'); }

function _readLastEntryHash(logFile) {
  if (!external_node_fs_.existsSync(logFile)) return GENESIS;
  try {
    const all = external_node_fs_.readFileSync(logFile, 'utf8');
    const lines = all.split('\n').filter(Boolean);
    if (!lines.length) return GENESIS;
    return _sha(lines[lines.length - 1]);
  } catch { return GENESIS; }
}

// Fire-and-forget POST to the remote sink. Resolves to null on success,
// to a short error string on failure. Never throws; never blocks longer
// than REMOTE_TIMEOUT_MS. The local audit append happens regardless.
async function _postRemote(url, entry) {
  try {
    const controller = new AbortController();
    const t = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(entry),
      signal: controller.signal,
    });
    clearTimeout(t);
    if (!r.ok) return `HTTP ${r.status}`;
    return null;
  } catch (e) {
    return String((e && e.message) || e).slice(0, 200);
  }
}

function auditCall({ sessionRoot, tool, args, outcome, reason }) {
  if (!sessionRoot) return;
  try {
    const dir = external_node_path_.join(sessionRoot, '.agentic-security');
    external_node_fs_.mkdirSync(dir, { recursive: true });
    const logFile = external_node_path_.join(dir, 'mcp-audit.log');
    const entry = {
      ts: new Date().toISOString(),
      sessionId: SESSION_ID,
      tool,
      outcome,
      ...(reason ? { reason } : {}),
      args: _summarize(args),
      prev: _readLastEntryHash(logFile),
    };
    external_node_fs_.appendFileSync(logFile, JSON.stringify(entry) + '\n');
    // Remote sink (post-recommendation #10). Fire-and-forget. We don't await
    // the promise so the tool call returns immediately; the remote POST runs
    // on its own microtask. Failures get logged to a sidecar file so the
    // operator can detect when the sink is unreachable.
    const webhook = process.env.AGENTIC_SECURITY_AUDIT_WEBHOOK;
    if (webhook) {
      _postRemote(webhook, entry).then((err) => {
        if (!err) return;
        try {
          const errFile = external_node_path_.join(dir, 'mcp-audit.remote-errors.log');
          external_node_fs_.appendFileSync(errFile, JSON.stringify({
            ts: new Date().toISOString(), entryTs: entry.ts, tool, err,
          }) + '\n');
        } catch { /* nothing else to do */ }
      });
    }
  } catch { /* audit failure must never break a tool call */ }
}

// Verify the chain from start to end. Returns
//   { ok: true, entries: N } if intact
//   { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
// Reader/operator-facing tool.
function verifyAuditLog(logFile) {
  if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
  const text = fs.readFileSync(logFile, 'utf8');
  const lines = text.split('\n').filter(Boolean);
  let expectedPrev = GENESIS;
  for (let i = 0; i < lines.length; i++) {
    let entry;
    try { entry = JSON.parse(lines[i]); }
    catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
    if (entry.prev !== expectedPrev) {
      return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
    }
    expectedPrev = _sha(lines[i]);
  }
  return { ok: true, entries: lines.length };
}

;// CONCATENATED MODULE: ./src/mcp/server.js
// MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
//
// Hardening posture (mapped to OWASP MCP Top 10):
//   - Session root chosen at server boot, no per-call retargeting (MCP02)
//   - Every tools/call argument validated against the tool's inputSchema (MCP02/MCP05)
//   - Every tools/call audited with a hash-chained log (MCP08)
//   - serverInfo.codeFingerprint = SHA-256 of MCP source files (MCP04/MCP09)
//     so a fleet can detect tampered or unauthorized server deployments
//   - AGENTIC_SECURITY_MCP_DISABLED=1 hard-disables all tool calls (MCP09)
//   - Stdio transport caps line/buffer size (./stdio.js) (MCP05 DoS)









const PROTOCOL_VERSION = '2025-03-26';
const SERVER_NAME = 'agentic-security';

// Premortem #6: read version from scanner/package.json at module load so the
// MCP `initialize` response can't silently drift from the shipped package
// version. A hardcoded constant rotted from 0.39.2 → wrong for every release
// that followed. Fall back to 'unknown' rather than a stale literal.
const SERVER_VERSION = (() => {
  try {
    const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
    // scanner/src/mcp/ → scanner/package.json
    const pkgPath = external_node_path_.resolve(here, '..', '..', 'package.json');
    const pkg = JSON.parse(external_node_fs_.readFileSync(pkgPath, 'utf8'));
    if (typeof pkg.version === 'string' && pkg.version.length) return pkg.version;
  } catch { /* fall through */ }
  return 'unknown';
})();

const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));

// Code fingerprint — SHA-256 of the MCP source files concatenated in a
// stable order. Embedded in `initialize` response so a fleet operator can
// detect when an unapproved build is running (OWASP MCP04/MCP09).
function _codeFingerprint() {
  try {
    const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
    const files = ['server.js', 'tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
    const h = external_node_crypto_.createHash('sha256');
    for (const f of files) {
      try { h.update(f); h.update(external_node_fs_.readFileSync(external_node_path_.join(here, f))); } catch {}
    }
    return h.digest('hex');
  } catch { return null; }
}
const CODE_FINGERPRINT = _codeFingerprint();

function _err(id, code, message, data) {
  const out = { jsonrpc: '2.0', id, error: { code, message } };
  if (data !== undefined) out.error.data = data;
  return out;
}

function _ok(id, result) {
  return { jsonrpc: '2.0', id, result };
}

function createServer({ sessionRoot = process.cwd() } = {}) {
  const ctx = { sessionRoot };

  async function handleRequest(msg) {
    if (!msg || typeof msg !== 'object') return _err(null, -32600, 'Invalid Request');
    if (msg.jsonrpc !== '2.0') return _err(msg.id ?? null, -32600, 'Invalid Request: jsonrpc must be "2.0"');

    const isNotification = msg.id === undefined || msg.id === null;
    const id = msg.id ?? null;
    const disabled = process.env.AGENTIC_SECURITY_MCP_DISABLED === '1';

    switch (msg.method) {
      case 'initialize':
        return _ok(id, {
          protocolVersion: PROTOCOL_VERSION,
          capabilities: { tools: {} },
          serverInfo: {
            name: SERVER_NAME,
            version: SERVER_VERSION,
            codeFingerprint: CODE_FINGERPRINT,
            disabled,
          },
        });

      case 'notifications/initialized':
        return null;

      case 'ping':
        return _ok(id, {});

      case 'tools/list':
        return _ok(id, {
          tools: ALL_TOOLS.map(t => ({
            name: t.name,
            description: t.description,
            inputSchema: t.inputSchema,
          })),
        });

      case 'tools/call': {
        const name = msg.params?.name;
        const args = msg.params?.arguments ?? {};
        if (disabled) {
          auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
          return _ok(id, {
            content: [{ type: 'text', text: 'MCP server is disabled (AGENTIC_SECURITY_MCP_DISABLED=1).' }],
            isError: true,
          });
        }
        const tool = TOOLS_BY_NAME[name];
        if (!tool) {
          auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
          return _err(id, -32602, `Unknown tool: ${name}`);
        }
        try { validate(tool.inputSchema, args); }
        catch (e) {
          auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
          return _ok(id, {
            content: [{ type: 'text', text: `Invalid arguments: ${e.message}` }],
            isError: true,
          });
        }
        try {
          const result = await tool.handler(args, ctx);
          auditCall({ sessionRoot, tool: name, args, outcome: 'ok' });
          return _ok(id, {
            content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
            isError: false,
          });
        } catch (e) {
          auditCall({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
          return _ok(id, {
            content: [{ type: 'text', text: `Error: ${e.message}` }],
            isError: true,
          });
        }
      }

      default:
        if (isNotification) return null;
        return _err(id, -32601, `Method not found: ${msg.method}`);
    }
  }

  return { handleRequest, sessionRoot };
}

// NOTE: no default-singleton export. Callers must use createServer({...})
// with an explicit sessionRoot. Removed because the prior default was bound
// to process.cwd() at module-load time — a footgun for any caller that
// imported `handleRequest` directly (OWASP A05).



;// CONCATENATED MODULE: ./src/mcp/stdio.js
// Stdio transport for the MCP server — newline-delimited JSON in/out.
//
// MCP's stdio transport is NDJSON: one JSON-RPC message per line on stdin,
// one response per line on stdout. stderr is reserved for logging.
//
// Hardening:
//   - Per-message line cap (MAX_LINE_BYTES). A line over the cap is dropped
//     and the buffer state is reset so a long oversize payload can't peg
//     the parser via `buf += chunk` growth.
//   - Buffer hard cap (MAX_BUFFER_BYTES). Reached if input arrives with no
//     newlines (e.g., a peer streaming a 4GB stream of `a`). On overflow we
//     emit a parse-error response and reset.



const MAX_LINE_BYTES = 4 * 1024 * 1024;        // 4 MB per JSON-RPC message
const MAX_BUFFER_BYTES = 8 * 1024 * 1024;      // 8 MB sliding buffer

function runStdio({
  stdin = process.stdin,
  stdout = process.stdout,
  stderr = process.stderr,
  sessionRoot = process.cwd(),
} = {}) {
  const server = createServer({ sessionRoot });
  let buf = '';
  let overflowSkip = false; // true while we are dropping bytes until the next newline

  stdin.setEncoding('utf8');

  stdin.on('data', async (chunk) => {
    if (overflowSkip) {
      const nl = chunk.indexOf('\n');
      if (nl === -1) return;
      // Resume after the next newline.
      chunk = chunk.slice(nl + 1);
      overflowSkip = false;
    }

    buf += chunk;

    // Hard buffer cap — only triggers if a peer is streaming without newlines.
    if (buf.length > MAX_BUFFER_BYTES) {
      stderr.write(`mcp: input buffer exceeded ${MAX_BUFFER_BYTES} bytes — dropping until next newline\n`);
      const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: input too large' } };
      stdout.write(JSON.stringify(errResponse) + '\n');
      buf = '';
      overflowSkip = true;
      return;
    }

    let nl;
    while ((nl = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, nl).trim();
      buf = buf.slice(nl + 1);
      if (!line) continue;
      if (line.length > MAX_LINE_BYTES) {
        stderr.write(`mcp: dropped oversize line (${line.length} > ${MAX_LINE_BYTES} bytes)\n`);
        const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: line too large' } };
        stdout.write(JSON.stringify(errResponse) + '\n');
        continue;
      }
      let msg;
      try { msg = JSON.parse(line); }
      catch (e) {
        stderr.write(`mcp: failed to parse line as JSON: ${e.message}\n`);
        const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
        stdout.write(JSON.stringify(errResponse) + '\n');
        continue;
      }
      try {
        const response = await server.handleRequest(msg);
        if (response !== null) stdout.write(JSON.stringify(response) + '\n');
      } catch (e) {
        stderr.write(`mcp: handler threw: ${e.message}\n`);
        const errResponse = { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: 'Internal error', data: e.message } };
        stdout.write(JSON.stringify(errResponse) + '\n');
      }
    }
  });

  stdin.on('end', () => { process.exit(0); });
}


/***/ })

};
