{"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/gates.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AACpE,OAAO,EAAE,KAAK,gBAAgB,EAAkB,MAAM,eAAe,CAAC;AAEtE,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAChC;;;;;OAKG;IACH,QAAQ,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;IACvC,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,kFAAkF;IAClF,WAAW,EAAE,wBAAwB,GAAG,YAAY,CAAC;IACrD,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,WAAW;IAC3B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,8EAA8E;IAC9E,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAyED,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMjE;AA2KD,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,EAAE,MAAM,UAAQ,GAAG,OAAO,CAEtF;AAED,6CAA6C;AAC7C,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,UAAU,CAI9E;AAED,6DAA6D;AAC7D,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,WAAW,EAAE,EAAE,MAAM,UAAQ,GAAG,UAAU,CAG1G;AAED,kEAAkE;AAClE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAM7D","sourcesContent":["/**\n * Plugin eval gates G1 and G2 — the green signal an authored or installed plugin\n * must produce before anything activates it.\n *\n * Nothing used to run between \"the model writes a plugin\" and \"the plugin is\n * live\". These gates fill that gap, and they run against the **draft dir**, not\n * a production home: a failure discards the draft, so a plugin that does not\n * pass never reaches `~/.claude/skills/` where Claude Code would load it.\n *\n *   G1  structural + conformant — does it parse, is the id legal, is the content\n *       portable, and does the *target platform* accept it\n *   G2  static safety — can the executable content plausibly run, and is it\n *       obviously dangerous\n *\n * G1 measures conformance against the vendor where it can. Round-tripping\n * through our own parser only proves the emitter and the reader agree with each\n * other; it says nothing about whether Claude Code will accept the artifact. So\n * when the `claude` CLI is present, its own `plugin validate` is the authority —\n * it is the exact check the vendor's review pipeline runs, and inventing a\n * second opinion where the vendor ships one would just be a different set of\n * bugs. Copilot has no equivalent, so `github` targets get round-trip + schema.\n *\n * G3 (sandboxed behavioral smoke) and G4 (trigger eval) are separate; see\n * docs/plugin-system-architecture.md §4.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport { classifyAllowlist } from \"./authoring.js\";\nimport type { PluginPlatform } from \"./formats/platform-targets.js\";\nimport { type NormalizedPlugin, parsePluginDir } from \"./manifest.js\";\n\nexport interface GateFinding {\n\tgate: \"G1\" | \"G2\" | \"G3\" | \"G4\";\n\t/**\n\t * `error` fails the gate. `warning` is reported and passes, unless `strict`.\n\t * `info` always passes — it exists so a *successful* check can be shown to the\n\t * human in the confirmation prompt without `strict` treating \"the hook ran\n\t * fine\" as a reason to refuse.\n\t */\n\tseverity: \"error\" | \"warning\" | \"info\";\n\tmessage: string;\n}\n\nexport interface GateResult {\n\tok: boolean;\n\tfindings: GateFinding[];\n\t/** How conformance was established, for the record and for the confirm prompt. */\n\tconformance: \"claude-plugin-validate\" | \"round-trip\";\n\tplugin: NormalizedPlugin | null;\n}\n\nexport interface GateOptions {\n\t/** Target platform, which decides whether a vendor validator applies. */\n\tplatform?: PluginPlatform;\n\t/** Treat warnings as errors. Used on the publish path. */\n\tstrict?: boolean;\n\t/** Skip the vendor validator (tests, and callers that must stay hermetic). */\n\tskipVendorValidator?: boolean;\n}\n\n/**\n * Opt out of the vendor validator process-wide.\n *\n * It costs a subprocess of a few hundred milliseconds per plugin — fine once per\n * authored plugin, ruinous across a test suite that authors dozens. Also the\n * escape hatch for a CI image that has the `claude` binary but should not spend\n * the time, or a sandbox where spawning it is undesirable. The gate falls back to\n * round-trip, and says so in `conformance` rather than pretending it validated.\n */\nconst ENV_SKIP_VENDOR_VALIDATE = \"HOOCODE_PLUGIN_SKIP_VENDOR_VALIDATE\";\n\nfunction vendorValidatorDisabled(): boolean {\n\tconst v = process.env[ENV_SKIP_VENDOR_VALIDATE];\n\treturn v === \"1\" || v === \"true\";\n}\n\nconst MAX_ID_LENGTH = 64;\nconst VALID_ID = /^[a-z0-9][a-z0-9-]*$/;\n\n/** Files worth scanning for portability and secret problems. Binary content is skipped. */\nconst TEXT_EXTENSIONS = new Set([\".md\", \".json\", \".txt\", \".yaml\", \".yml\", \".sh\", \".js\", \".ts\", \".toml\"]);\n\n/**\n * Absolute paths that only exist on the author's machine. A plugin carrying one\n * works exactly once — for whoever wrote it — which is the failure mode\n * portability guidance was meant to prevent and could not, being only advice.\n */\nconst MACHINE_PATHS = [/\\/home\\/[a-z0-9._-]+\\//i, /\\/Users\\/[a-z0-9._-]+\\//i, /\\b[A-Z]:\\\\Users\\\\/];\n\n/** Credential shapes worth refusing outright rather than shipping to a marketplace. */\nconst SECRET_PATTERNS: ReadonlyArray<{ re: RegExp; what: string }> = [\n\t{ re: /\\bsk-[A-Za-z0-9]{16,}/, what: \"an API key (sk-…)\" },\n\t{ re: /\\bghp_[A-Za-z0-9]{20,}/, what: \"a GitHub token (ghp_…)\" },\n\t{ re: /\\bAKIA[0-9A-Z]{12,}/, what: \"an AWS access key id\" },\n\t{ re: /\\bAWS_SECRET_ACCESS_KEY\\s*[=:]\\s*\\S+/, what: \"an AWS secret\" },\n\t{ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, what: \"a private key\" },\n];\n\n/**\n * Shell constructs refused in a hook or MCP command. Not a sandbox and not a\n * general shell analysis — a determined command can evade any of these. It\n * catches the accident and the obvious, which is what a static gate can honestly\n * claim; G3 is where behavior gets checked.\n */\nconst DANGEROUS_COMMANDS: ReadonlyArray<{ re: RegExp; what: string }> = [\n\t{ re: /\\brm\\s+(-[a-zA-Z]*\\s+)*-[a-zA-Z]*[rR][a-zA-Z]*f|\\brm\\s+-fr\\b/, what: \"a recursive force delete\" },\n\t{ re: /\\b(curl|wget)\\b[^|]*\\|\\s*(ba)?sh\\b/, what: \"piping a download into a shell\" },\n\t{ re: /\\bmkfs(\\.[a-z0-9]+)?\\b|\\bdd\\s+[^|]*of=\\/dev\\//, what: \"a raw device write\" },\n\t{ re: />\\s*\\/dev\\/(sd|nvme|disk)/, what: \"a raw device write\" },\n\t{ re: /\\bchmod\\s+(-[a-zA-Z]+\\s+)*777\\b/, what: \"a world-writable chmod\" },\n\t{ re: /\\bsudo\\b/, what: \"a privilege escalation\" },\n];\n\nfunction walkFiles(root: string, out: string[] = []): string[] {\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = readdirSync(root, { withFileTypes: true });\n\t} catch {\n\t\treturn out;\n\t}\n\tfor (const entry of entries) {\n\t\tconst full = path.join(root, entry.name);\n\t\tif (entry.isDirectory()) {\n\t\t\twalkFiles(full, out);\n\t\t} else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) {\n\t\t\tout.push(full);\n\t\t}\n\t}\n\treturn out;\n}\n\n/** First executable token of a shell command, ignoring `VAR=x` prefixes. */\nexport function commandBinary(command: string): string | undefined {\n\tfor (const token of command.trim().split(/\\s+/)) {\n\t\tif (!token || /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) continue;\n\t\treturn token;\n\t}\n\treturn undefined;\n}\n\n/** Whether `binary` resolves — on PATH, or as a path relative to the plugin. */\nfunction binaryResolves(binary: string, root: string): boolean {\n\t// A template variable is resolved at run time, so it cannot be checked here.\n\tif (binary.includes(\"${\")) return true;\n\tif (binary.includes(\"/\") || binary.includes(\"\\\\\")) {\n\t\treturn existsSync(path.isAbsolute(binary) ? binary : path.resolve(root, binary));\n\t}\n\t// Shell builtins never appear on PATH.\n\tif ([\"echo\", \"cd\", \"true\", \"false\", \"test\", \"set\", \"export\", \":\"].includes(binary)) return true;\n\ttry {\n\t\texecFileSync(process.platform === \"win32\" ? \"where\" : \"which\", [binary], {\n\t\t\tstdio: \"ignore\",\n\t\t\ttimeout: 5_000,\n\t\t});\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** G1: parses, legal id, portable content, and accepted by the target platform. */\nfunction structural(\n\tdir: string,\n\topts: GateOptions,\n): { findings: GateFinding[]; conformance: GateResult[\"conformance\"]; plugin: NormalizedPlugin | null } {\n\tconst findings: GateFinding[] = [];\n\tconst err = (message: string) => findings.push({ gate: \"G1\", severity: \"error\", message });\n\tconst warn = (message: string) => findings.push({ gate: \"G1\", severity: \"warning\", message });\n\n\tconst plugin = parsePluginDir(dir);\n\tif (!plugin) {\n\t\terr(`No recognizable plugin manifest at ${dir}.`);\n\t\treturn { findings, conformance: \"round-trip\", plugin: null };\n\t}\n\n\tif (plugin.id.length > MAX_ID_LENGTH) err(`Plugin id \"${plugin.id}\" exceeds ${MAX_ID_LENGTH} characters.`);\n\tif (!VALID_ID.test(plugin.id)) {\n\t\terr(`Plugin id \"${plugin.id}\" must be lowercase letters, digits and hyphens, and start with one.`);\n\t}\n\n\tfor (const file of walkFiles(dir)) {\n\t\tlet content: string;\n\t\ttry {\n\t\t\tif (statSync(file).size > 512 * 1024) continue;\n\t\t\tcontent = readFileSync(file, \"utf8\");\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tconst rel = path.relative(dir, file);\n\t\tfor (const re of MACHINE_PATHS) {\n\t\t\tconst hit = re.exec(content);\n\t\t\tif (hit) {\n\t\t\t\terr(`${rel} contains a machine-specific path (\"${hit[0]}\"); the plugin would only work for its author.`);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tfor (const { re, what } of SECRET_PATTERNS) {\n\t\t\tif (re.test(content)) {\n\t\t\t\terr(`${rel} appears to contain ${what}.`);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Vendor conformance where one exists. Round-trip alone proves only that our\n\t// emitter and our reader agree.\n\tlet conformance: GateResult[\"conformance\"] = \"round-trip\";\n\tif (opts.platform === \"claude\" && !opts.skipVendorValidator && !vendorValidatorDisabled()) {\n\t\tconst vendor = runClaudeValidate(dir, opts.strict === true);\n\t\tif (vendor) {\n\t\t\tconformance = \"claude-plugin-validate\";\n\t\t\tif (!vendor.ok) err(`claude plugin validate rejected the plugin:\\n${vendor.output}`);\n\t\t\telse if (vendor.output.trim()) warn(`claude plugin validate: ${vendor.output.trim()}`);\n\t\t}\n\t}\n\n\treturn { findings, conformance, plugin };\n}\n\n/** Run the vendor validator, or undefined when the CLI is not installed. */\nfunction runClaudeValidate(dir: string, strict: boolean): { ok: boolean; output: string } | undefined {\n\tconst args = [\"plugin\", \"validate\", dir, ...(strict ? [\"--strict\"] : [])];\n\ttry {\n\t\tconst output = execFileSync(\"claude\", args, { encoding: \"utf8\", timeout: 60_000, stdio: \"pipe\" });\n\t\treturn { ok: true, output };\n\t} catch (error) {\n\t\tconst e = error as { code?: string; status?: number; stdout?: string; stderr?: string };\n\t\t// No CLI on this machine, or it predates `plugin validate` — not a failure\n\t\t// of the plugin, so the gate falls back to round-trip rather than blocking.\n\t\tif (e.code === \"ENOENT\") return undefined;\n\t\tconst output = `${e.stdout ?? \"\"}${e.stderr ?? \"\"}`;\n\t\tif (/unknown command|unrecognized|not a valid/i.test(output)) return undefined;\n\t\treturn { ok: false, output };\n\t}\n}\n\n/** G2: the executable content can plausibly run and is not obviously dangerous. */\nfunction safety(plugin: NormalizedPlugin | null): GateFinding[] {\n\tconst findings: GateFinding[] = [];\n\tif (!plugin) return findings;\n\tconst err = (message: string) => findings.push({ gate: \"G2\", severity: \"error\", message });\n\tconst warn = (message: string) => findings.push({ gate: \"G2\", severity: \"warning\", message });\n\n\tconst checkCommand = (label: string, command: string) => {\n\t\tfor (const { re, what } of DANGEROUS_COMMANDS) {\n\t\t\tif (re.test(command)) err(`${label} performs ${what}: ${command}`);\n\t\t}\n\t\tconst binary = commandBinary(command);\n\t\tif (!binary) {\n\t\t\terr(`${label} has no runnable command.`);\n\t\t\treturn;\n\t\t}\n\t\tif (!binaryResolves(binary, plugin.root)) {\n\t\t\t// A warning, not an error: the binary may be installed later, or by the\n\t\t\t// plugin's own prerequisites. Refusing outright would block a plugin that\n\t\t\t// documents its dependency honestly.\n\t\t\twarn(`${label} runs \"${binary}\", which is not on PATH or in the plugin.`);\n\t\t}\n\t};\n\n\tfor (const [event, groups] of Object.entries(plugin.hooks ?? {})) {\n\t\tfor (const group of groups) {\n\t\t\tfor (const cmd of group.hooks) checkCommand(`hook ${event}`, cmd.command);\n\t\t}\n\t}\n\tfor (const [name, raw] of Object.entries(plugin.mcpServers ?? {})) {\n\t\tconst cfg = raw as { command?: unknown };\n\t\tif (typeof cfg.command === \"string\") checkCommand(`mcp server \"${name}\"`, cfg.command);\n\t}\n\n\t// Subagent grants are classified rather than judged: the confirm gate already\n\t// decides on them, and recording the classification is what lets the human see\n\t// *why* they were asked.\n\tfor (const agentFile of listAgentFiles(plugin)) {\n\t\tconst tools = readToolsFrontmatter(agentFile);\n\t\tif (tools === undefined) continue;\n\t\tconst cls = classifyAllowlist(tools);\n\t\tif (cls.pluginTools.length > 0) {\n\t\t\terr(\n\t\t\t\t`subagent \"${path.basename(agentFile)}\" grants capability-acquisition tools: ${cls.pluginTools.join(\", \")}`,\n\t\t\t);\n\t\t} else if (cls.risk === \"mutating\") {\n\t\t\twarn(`subagent \"${path.basename(agentFile)}\" holds a mutating grant (${cls.reason}).`);\n\t\t}\n\t}\n\n\treturn findings;\n}\n\nfunction listAgentFiles(plugin: NormalizedPlugin): string[] {\n\tif (!plugin.agentsDir || !existsSync(plugin.agentsDir)) return [];\n\ttry {\n\t\treturn readdirSync(plugin.agentsDir)\n\t\t\t.filter((f) => f.endsWith(\".md\"))\n\t\t\t.map((f) => path.join(plugin.agentsDir as string, f));\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction readToolsFrontmatter(file: string): string | undefined {\n\ttry {\n\t\tconst match = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---/.exec(readFileSync(file, \"utf8\"));\n\t\treturn /^tools:\\s*(.+)$/m.exec(match?.[1] ?? \"\")?.[1]?.trim();\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** Whether a set of findings fails the gate. `info` never does. */\nexport function findingsFail(findings: readonly GateFinding[], strict = false): boolean {\n\treturn findings.some((f) => f.severity === \"error\" || (strict && f.severity === \"warning\"));\n}\n\n/** Run G1 and G2 over a plugin directory. */\nexport function runStaticGates(dir: string, opts: GateOptions = {}): GateResult {\n\tconst { findings: g1, conformance, plugin } = structural(dir, opts);\n\tconst findings = [...g1, ...safety(plugin)];\n\treturn { ok: !findingsFail(findings, opts.strict === true), findings, conformance, plugin };\n}\n\n/** Fold additional findings (G3) into an existing result. */\nexport function withFindings(result: GateResult, extra: readonly GateFinding[], strict = false): GateResult {\n\tconst findings = [...result.findings, ...extra];\n\treturn { ...result, findings, ok: !findingsFail(findings, strict) };\n}\n\n/** Render findings for a tool result or a confirmation prompt. */\nexport function formatGateFindings(result: GateResult): string {\n\tif (result.findings.length === 0) {\n\t\treturn `Checks passed (${result.conformance}).`;\n\t}\n\tconst lines = result.findings.map((f) => `  [${f.gate}/${f.severity}] ${f.message}`);\n\treturn `${result.ok ? \"Checks passed with warnings\" : \"Checks failed\"} (${result.conformance}):\\n${lines.join(\"\\n\")}`;\n}\n"]}