diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a79867f6d..3b15b4e44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ concurrency: env: UV_VERSION: "0.10.x" PYTHON_VERSION: "3.12" + NODE_VERSION: "22.20.0" UV_CACHE_DIR: .uv-cache UV_LINK_MODE: copy @@ -90,6 +91,18 @@ jobs: - run: uv run skillspector --version - run: uv run make test-ci + test-opencode: + name: OpenCode TypeScript Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Node.js + # Pinned to a full commit SHA (third-party action); comment tracks the tag. + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + - run: node --test tests/opencode/skillspector_scan_lib.test.ts + docker-smoke: needs: changes if: needs.changes.outputs.docker == 'true' diff --git a/.opencode/commands/skillspector.md b/.opencode/commands/skillspector.md new file mode 100644 index 000000000..05da86df6 --- /dev/null +++ b/.opencode/commands/skillspector.md @@ -0,0 +1,5 @@ +--- +description: Scan an AI agent skill for security risks with SkillSpector (static analysis by default); use when vetting a skill before install or auditing one in use. +--- +Scan the skill at $ARGUMENTS with the skillspector_scan tool (static analysis only, no LLM calls). +To opt into LLM semantic analysis instead, call skillspector_scan with noLlm false and SKILLSPECTOR_PROVIDER/SKILLSPECTOR_MODEL set. diff --git a/.opencode/tools/skillspector_scan.ts b/.opencode/tools/skillspector_scan.ts new file mode 100644 index 000000000..ad465da5b --- /dev/null +++ b/.opencode/tools/skillspector_scan.ts @@ -0,0 +1,24 @@ +import { tool } from "@opencode-ai/plugin" +import { execFile } from "node:child_process" +import fs from "node:fs" +import { promisify } from "node:util" +import { executeScan, type RunFile } from "./skillspector_scan_lib.ts" + +const runFile = promisify(execFile) as unknown as RunFile + +export default tool({ + description: "Scan an AI agent skill for security risks with SkillSpector. Static analysis only by default; opt into LLM analysis explicitly.", + args: { + target: tool.schema.string().describe("Skill to scan: local path, .md/.zip file, or Git/file URL"), + format: tool.schema.enum(["terminal", "json", "markdown", "sarif"]).default("json").describe("Report format"), + noLlm: tool.schema.boolean().default(true).describe("Skip LLM analysis (static checks only). Set false to opt into LLM semantic analysis via SKILLSPECTOR_PROVIDER/SKILLSPECTOR_MODEL"), + output: tool.schema.string().optional().describe("Write the report to this file instead of returning it (resolved against the session directory if relative)"), + }, + async execute(args, context) { + return executeScan(args, context, { + runFile, + existsSync: fs.existsSync, + lstatSync: fs.lstatSync, + }) + }, +}) diff --git a/.opencode/tools/skillspector_scan_lib.ts b/.opencode/tools/skillspector_scan_lib.ts new file mode 100644 index 000000000..b562042de --- /dev/null +++ b/.opencode/tools/skillspector_scan_lib.ts @@ -0,0 +1,566 @@ +// Pure helpers for the skillspector_scan tool. Dependency-free (no +// @opencode-ai/plugin import) so this module runs under plain node --test. + +import path from "node:path" + +export const TIMEOUT_MS = 120_000 +export const MAX_STDOUT = 12_000 +export const MAX_STDERR = 6_000 +export const INSTALL_HINT = + "uv tool install git+https://github.com/NVIDIA/skillspector.git" + +type Env = Record +type PathApi = typeof path.posix + +function pathFor(platform: string): PathApi { + return platform === "win32" ? path.win32 : path.posix +} + +// Credentials that SkillSpector can consume directly, through a provider, or +// through the standard AWS/LangChain credential chains. Keep this explicit: +// reading arbitrary *_KEY variables would widen the host-data boundary. +export const CREDENTIAL_ENV_NAMES = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_PROXY_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_SECRET_ACCESS_KEY", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", + "AZURE_OPENAI_API_KEY", + "LANGCHAIN_API_KEY", + "LANGSMITH_API_KEY", + "NVIDIA_INFERENCE_KEY", + "NVIDIA_INFERENCE_METADATA_KEY", + "OPENAI_API_KEY", + "SKILLSPECTOR_COMPAT_API_KEY", +] as const + +export function truncate(text: string, max: number): string { + if (text.length <= max) return text + return text.slice(0, max) + `\n...[truncated ${text.length - max} chars]` +} + +export function redact(text: string, env: Env = process.env): string { + const credentialValues = [...new Set( + CREDENTIAL_ENV_NAMES.map((name) => env[name]?.trim()).filter( + (value): value is string => Boolean(value && value.length >= 4), + ), + )].sort((left, right) => right.length - left.length) + const credentialPattern = credentialValues.length + ? new RegExp( + credentialValues + .map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"), + "g", + ) + : undefined + const redacted = credentialPattern ? text.replace(credentialPattern, "[REDACTED]") : text + return redacted + .replace(/sk-ant-[A-Za-z0-9_-]+/g, "[REDACTED]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[REDACTED]") + .replace( + /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|ACCESS_KEY_ID|SECRET_ACCESS_KEY|INFERENCE_KEY))(\s*[:=]\s*["']?)[^"'\s,}]+/g, + "$1$2[REDACTED]", + ) +} + +export function isScpGitTarget(target: string): boolean { + // SkillSpector accepts the conventional git@host:owner/repo.git form. Keep + // this narrow so strings such as "notes:today" remain ordinary local paths. + return /^git@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?:[A-Za-z0-9._~/-]+\.git\/?$/.test( + target, + ) +} + +export function isRemoteTarget( + target: string, + platform: string = process.platform, +): boolean { + // A Windows drive path can use forward slashes (C://path) and otherwise + // looks like a URI scheme. Host-path classification must win first. + if (pathFor(platform).isAbsolute(target)) return false + // Match only the remote forms accepted by SkillSpector's input handler. + // Treating arbitrary schemes as remote can turn a valid local filename into + // a network-only permission request and bypass the corresponding read gate. + return target.startsWith("https://") || isScpGitTarget(target) +} + +export function isUrlOrAbsolute( + target: string, + platform: string = process.platform, +): boolean { + return pathFor(platform).isAbsolute(target) || isRemoteTarget(target, platform) +} + +export function isFilesystemRoot( + candidate: string, + platform: string = process.platform, +): boolean { + const pathApi = pathFor(platform) + if (!pathApi.isAbsolute(candidate)) return false + const resolved = pathApi.resolve(candidate) + return resolved === pathApi.parse(resolved).root +} + +export interface ResolveBinaryOpts { + env?: Env + platform?: string + existsSync?: (p: string) => boolean +} + +export function resolveBinary( + worktree: string, + opts: ResolveBinaryOpts = {}, +): string { + const env = opts.env ?? process.env + const platform = opts.platform ?? process.platform + const pathApi = pathFor(platform) + const existsSync = opts.existsSync + const fromEnv = env.SKILLSPECTOR_BIN?.trim() + if (fromEnv) return fromEnv + // Repo-checkout fallback: /.venv (Scripts/skillspector.exe on Windows, bin/skillspector elsewhere). + const binDir = platform === "win32" ? "Scripts" : "bin" + const exe = platform === "win32" ? "skillspector.exe" : "skillspector" + const venvBin = pathApi.join(worktree, ".venv", binDir, exe) + if (existsSync ? existsSync(venvBin) : false) return venvBin + return "skillspector" +} + +export interface ScanArgs { + target: string + format?: string + noLlm?: boolean + output?: string +} + +export function buildCliArgs(args: ScanArgs): string[] { + // The host may omit declared defaults, so re-apply them here. noLlm + // defaults to true: LLM analysis must stay strictly opt-in. + const format = args.format ?? "json" + const noLlm = args.noLlm ?? true + const cliArgs = ["scan", args.target, "--format", format] + if (noLlm) cliArgs.push("--no-llm") + if (args.output) cliArgs.push("--output", args.output) + return cliArgs +} + +export interface ExecFailure { + code?: unknown + killed?: boolean + name?: string + stdout?: unknown + stderr?: unknown + message?: string +} + +function boundedStreams(stdout: string, stderr: string, env: Env): string { + const parts: string[] = [] + if (stdout) parts.push(truncate(redact(stdout, env), MAX_STDOUT)) + if (stderr) parts.push(`stderr:\n${truncate(redact(stderr, env), MAX_STDERR)}`) + return parts.join("\n") +} + +function isProvenUsageError(stderr: string): boolean { + return /Usage:/i.test(stderr) && /(?:Error:|Try .+--help)/i.test(stderr) +} + +export function formatExecError( + bin: string, + err: unknown, + env: Env = process.env, +): string { + const e = err as ExecFailure + const partialOut = typeof e.stdout === "string" ? e.stdout : "" + const partialErr = typeof e.stderr === "string" ? e.stderr : "" + const evidence = boundedStreams(partialOut, partialErr, env) + if (e.code === "ENOENT") { + return `SkillSpector CLI not found (tried "${bin}"). Install it with \`${INSTALL_HINT}\`, or point SKILLSPECTOR_BIN at the binary.` + } + if (e.name === "AbortError" || e.code === "ABORT_ERR") { + return ( + "SkillSpector scan canceled by the OpenCode session." + + (evidence ? ` Partial output:\n${evidence}` : "") + ) + } + if (e.killed) { + return ( + `SkillSpector scan timed out after ${TIMEOUT_MS / 1000}s (killed).` + + (evidence ? ` Partial output:\n${evidence}` : "") + ) + } + if (e.code === 1 && partialOut) { + // Findings above the risk threshold: the report is the answer, not a crash. + return evidence + } + if (e.code === 2 && !partialOut && isProvenUsageError(partialErr)) { + return `SkillSpector usage error (exit 2):\n${evidence}` + } + const detail = evidence || truncate(redact(e.message || String(err), env), MAX_STDERR) + return `SkillSpector scan failed${e.code === 2 ? " (exit 2)" : ""}:\n${detail}` +} + +export function formatSuccess( + output: string | undefined, + stdout: string, + stderr: string, + env: Env = process.env, +): string { + const report = stdout + ? truncate(redact(stdout, env), MAX_STDOUT) + : output + ? `Report saved to: ${output}` + : "" + const warning = stderr + ? `stderr:\n${truncate(redact(stderr, env), MAX_STDERR)}` + : "" + return [report, warning].filter(Boolean).join("\n") +} + +export interface PermissionRequest { + permission: string + patterns: string[] + always: string[] + metadata: Record +} + +export interface ScanContext { + directory?: string + worktree?: string + abort: AbortSignal + ask(input: PermissionRequest): Promise +} + +export interface RunFileOptions { + timeout: number + maxBuffer: number + cwd: string + signal: AbortSignal + encoding: "utf8" + env: Env +} + +export type RunFile = ( + file: string, + args: string[], + options: RunFileOptions, +) => Promise<{ stdout: string; stderr: string }> + +export interface ExecuteScanDeps { + runFile: RunFile + existsSync?: (p: string) => boolean + lstatSync?: (p: string) => { isSymbolicLink(): boolean } + env?: Env + cwd?: string + platform?: string +} + +interface PreparedScan extends ScanArgs { + target: string + output?: string +} + +interface PermissionBoundary { + directory: string + worktree?: string + platform: string +} + +export function childProcessEnv(env: Env = process.env): Env { + // LangChain/LangSmith tracing can upload graph inputs and state even when + // SkillSpector's LLM analysis is disabled. The OpenCode tool has a separate + // explicit egress gate, so never let ambient tracing controls, endpoints, + // projects, sampling settings, tags, or tracing credentials reach the CLI. + return Object.fromEntries( + Object.entries(env).filter( + ([name]) => !/^(?:LANGCHAIN|LANGSMITH)_/i.test(name), + ), + ) +} + +function isPathLikeBinary(candidate: string, platform: string): boolean { + const pathApi = pathFor(platform) + if (pathApi.isAbsolute(candidate) || pathApi.dirname(candidate) !== ".") { + return true + } + if (candidate === "." || candidate === ".." || candidate.includes(pathApi.sep)) { + return true + } + // Node accepts either separator on Windows, even though path.win32.sep is + // a backslash. Only a true bare command name should be delegated to PATH. + return platform === "win32" && candidate.includes("/") +} + +function isWithin(root: string, candidate: string, pathApi: PathApi): boolean { + const relative = pathApi.relative(root, candidate) + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${pathApi.sep}`) && + !pathApi.isAbsolute(relative)) + ) +} + +function usableWorktree(boundary: PermissionBoundary): string | undefined { + if (!boundary.worktree || isFilesystemRoot(boundary.worktree, boundary.platform)) { + return undefined + } + return boundary.worktree +} + +function isInternalPath(boundary: PermissionBoundary, candidate: string): boolean { + const pathApi = pathFor(boundary.platform) + if (isWithin(boundary.directory, candidate, pathApi)) return true + const worktree = usableWorktree(boundary) + return Boolean(worktree && isWithin(worktree, candidate, pathApi)) +} + +function displayPath(boundary: PermissionBoundary, candidate: string): string { + const pathApi = pathFor(boundary.platform) + const worktree = usableWorktree(boundary) + const permissionRoot = + worktree && isWithin(worktree, candidate, pathApi) + ? worktree + : isWithin(boundary.directory, candidate, pathApi) + ? boundary.directory + : undefined + if (!permissionRoot) return candidate + return pathApi.relative(permissionRoot, candidate).replaceAll(pathApi.sep, "/") || "." +} + +function localScope( + boundary: PermissionBoundary, + candidate: string, + recursive: boolean, +): string[] { + const displayed = displayPath(boundary, candidate) + return recursive ? [displayed, `${displayed.replace(/\/$/, "")}/**`] : [displayed] +} + +function assertNoSymlinkedPath( + candidate: string, + platform: string, + lstatSync: (p: string) => { isSymbolicLink(): boolean }, + operation: string, +): void { + const pathApi = pathFor(platform) + const normalized = pathApi.resolve(candidate) + const parsed = pathApi.parse(normalized) + let current = parsed.root + for (const component of normalized.slice(parsed.root.length).split(pathApi.sep).filter(Boolean)) { + current = pathApi.join(current, component) + try { + if (lstatSync(current).isSymbolicLink()) { + throw new Error(`Refusing to ${operation} through a symlinked path: ${current}`) + } + } catch (error: unknown) { + if ((error as { code?: string }).code === "ENOENT") return + throw error + } + } +} + +function safeEndpoint(raw: string): string { + try { + const parsed = new URL(raw) + parsed.username = "" + parsed.password = "" + parsed.search = "" + parsed.hash = "" + return parsed.toString() + } catch { + return raw + } +} + +export function llmPermissionMetadata(env: Env = process.env): { + provider: string + model: string + destination: string +} { + const provider = env.SKILLSPECTOR_PROVIDER?.trim().toLowerCase() || "nv_build" + const model = env.SKILLSPECTOR_MODEL?.trim() || "provider-default" + const destinations: Record = { + anthropic: env.ANTHROPIC_BASE_URL?.trim() || "https://api.anthropic.com/", + anthropic_proxy: + env.ANTHROPIC_PROXY_ENDPOINT_URL?.trim() || "configured-anthropic-proxy", + azure_openai: env.AZURE_OPENAI_ENDPOINT?.trim() || "configured-azure-openai", + bedrock: `aws-bedrock:${env.AWS_REGION?.trim() || "us-west-2"}`, + claude_cli: "local-claude-cli", + codex_cli: "local-codex-cli", + gemini_cli: "local-gemini-cli", + nv_build: "https://integrate.api.nvidia.com/v1/", + ollama: env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434/v1/", + openai: env.OPENAI_BASE_URL?.trim() || "https://api.openai.com/", + openai_compatible: + env.SKILLSPECTOR_COMPAT_BASE_URL?.trim() || "configured-openai-compatible", + } + return { + provider, + model, + destination: safeEndpoint(destinations[provider] || `configured-provider:${provider}`), + } +} + +export function buildPermissionRequests( + args: PreparedScan, + options: PermissionBoundary & { bin: string; cliArgs: string[]; env?: Env }, +): PermissionRequest[] { + const { bin, cliArgs } = options + const env = options.env ?? process.env + const requests: PermissionRequest[] = [] + + if (isRemoteTarget(args.target, options.platform)) { + requests.push({ + permission: "webfetch", + patterns: [args.target], + always: [args.target], + metadata: { operation: "scan remote skill", target: args.target }, + }) + } else { + const patterns = localScope(options, args.target, true) + if (!isInternalPath(options, args.target)) { + requests.push({ + permission: "external_directory", + patterns, + always: patterns, + metadata: { operation: "read scan target", target: args.target }, + }) + } + requests.push({ + permission: "read", + patterns, + always: patterns, + metadata: { operation: "read scan target", target: args.target }, + }) + } + + if (args.output) { + const patterns = localScope(options, args.output, false) + if (!isInternalPath(options, args.output)) { + requests.push({ + permission: "external_directory", + patterns, + always: patterns, + metadata: { operation: "write scan report", output: args.output }, + }) + } + requests.push({ + permission: "edit", + patterns, + always: patterns, + metadata: { operation: "write scan report", output: args.output }, + }) + } + + if (!(args.noLlm ?? true)) { + const llm = llmPermissionMetadata(env) + const pattern = `skillspector-llm:${llm.provider}:${llm.destination}:${llm.model}` + requests.push({ + permission: "webfetch", + patterns: [pattern], + always: [], + metadata: { + operation: "send analyzer-eligible skill content for LLM analysis", + target: args.target, + ...llm, + }, + }) + } + + const pathApi = pathFor(options.platform) + if (pathApi.isAbsolute(bin) && !isInternalPath(options, bin)) { + const patterns = localScope(options, bin, false) + requests.push({ + permission: "external_directory", + patterns, + always: patterns, + metadata: { operation: "execute SkillSpector CLI", binary: bin }, + }) + } + + const commandPattern = `${bin} scan *` + requests.push({ + permission: "bash", + patterns: [commandPattern], + always: [commandPattern], + metadata: { operation: "run SkillSpector CLI", command: [bin, ...cliArgs] }, + }) + return requests +} + +export async function executeScan( + args: ScanArgs, + context: ScanContext, + deps: ExecuteScanDeps, +): Promise { + const env = deps.env ?? process.env + const platform = deps.platform ?? process.platform + const pathApi = pathFor(platform) + const baseDir = context.directory ?? context.worktree ?? deps.cwd ?? process.cwd() + const worktree = context.worktree + const binaryRoot = worktree && !isFilesystemRoot(worktree, platform) ? worktree : baseDir + const target = isUrlOrAbsolute(args.target, platform) + ? args.target + : pathApi.resolve(baseDir, args.target) + const output = args.output + ? pathApi.isAbsolute(args.output) + ? args.output + : pathApi.resolve(baseDir, args.output) + : undefined + const prepared = { ...args, target, output } + const configuredBin = resolveBinary(binaryRoot, { + env, + existsSync: deps.existsSync, + platform, + }) + const bin = isPathLikeBinary(configuredBin, platform) + ? pathApi.resolve(baseDir, configuredBin) + : configuredBin + const cliArgs = buildCliArgs(prepared) + + for (const request of buildPermissionRequests(prepared, { + directory: baseDir, + worktree, + platform, + bin, + cliArgs, + env, + })) { + // A rejection is intentionally not caught: OpenCode owns the denial result, + // and the process must never start after any denied capability. + await context.ask(request) + } + + if (output && deps.lstatSync) { + assertNoSymlinkedPath(output, platform, deps.lstatSync, "write a report") + } + if (!isRemoteTarget(target, platform) && deps.lstatSync) { + // Core InputHandler also rejects local symlink targets. Enforce the same + // boundary here before process launch so the OpenCode permission grant + // cannot be redirected through a symlink or Windows junction. + assertNoSymlinkedPath(target, platform, deps.lstatSync, "scan a local target") + } + if (pathApi.isAbsolute(bin) && deps.lstatSync) { + assertNoSymlinkedPath( + bin, + platform, + deps.lstatSync, + "execute the SkillSpector CLI", + ) + } + + try { + const result = await deps.runFile(bin, cliArgs, { + timeout: TIMEOUT_MS, + maxBuffer: 32 * 1024 * 1024, + cwd: baseDir, + signal: context.abort, + encoding: "utf8", + env: childProcessEnv(env), + }) + return formatSuccess(output, result.stdout, result.stderr, env) + } catch (err: unknown) { + return formatExecError(bin, err, env) + } +} diff --git a/README.md b/README.md index f4b915344..b202f0438 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SkillSpector is part of the [NVIDIA Verified Skills pipeline](https://docs.nvidi - **[Development guide](docs/DEVELOPMENT.md)** — Architecture, package layout, and how to extend the analyzer pipeline. - **[Analysis resource bounds](docs/ANALYSIS_RESOURCE_BOUNDS.md)** — Fail-closed bundle, parser, nested-artifact, ledger, and finding ceilings. - **[Pi extension](docs/PI_EXTENSION.md)** — Install SkillSpector as a Pi tool for scanning skills from inside agent sessions. +- **[OpenCode extension](docs/OPENCODE_EXTENSION.md)** — Install SkillSpector as an OpenCode tool and `/skillspector` command for scanning skills from inside agent sessions. ## Features diff --git a/docs/OPENCODE_EXTENSION.md b/docs/OPENCODE_EXTENSION.md new file mode 100644 index 000000000..957891767 --- /dev/null +++ b/docs/OPENCODE_EXTENSION.md @@ -0,0 +1,92 @@ +# SkillSpector OpenCode Extension + +SkillSpector can be installed into OpenCode as a local extension. The extension registers a `skillspector_scan` tool and a `/skillspector` slash command that run the existing SkillSpector CLI. + +## Requirements + +- OpenCode installed. +- Python `>=3.12,<3.15`. +- `uv` recommended. +- This repo checked out locally. +- Node 22+ to run the extension unit tests (type stripping, no extra dependencies). + +## Install + +Copy this repo's `.opencode/` directory into your project (or `~/.config/opencode/` for global use): + +```bash +cp -r /path/to/SkillSpector/.opencode /path/to/my-project/ +``` + +Make sure `skillspector` is on PATH, or point `SKILLSPECTOR_BIN` at the binary: + +```bash +export SKILLSPECTOR_BIN=/path/to/SkillSpector/.venv/bin/skillspector +``` + +Then reload OpenCode or start a new session; `/skillspector` is auto-discovered. + +## Basic scan + +In OpenCode: + +```text +/skillspector ./my-skill +``` + +Equivalent CLI (static analysis only): + +```bash +skillspector scan ./my-skill --no-llm +``` + +Before starting the CLI, the tool asks OpenCode for the capabilities used by +that invocation: target reads (and remote fetches), report writes, external +paths, and the CLI subprocess. A denied request stops the invocation before +the subprocess starts. + +## Tool parameters + +- `target`: path, URL, zip, Git repo, or `SKILL.md` to scan. +- `format`: `terminal`, `json`, `markdown`, or `sarif`. Default: `json`. +- `output`: optional report path. +- `noLlm`: default `true`. + +Unlike the [Pi extension](PI_EXTENSION.md), this tool has no `provider`, `model`, `yaraRulesDir`, or `verbose` parameters: LLM-backed analysis is configured through the environment instead (see below). + +## LLM-backed analysis + +Static scan is default. To use semantic LLM analysis, configure a supported +provider before launching OpenCode, then call the tool with `noLlm` false. The +tool makes a separate permission request naming the provider, model, and +credential-free destination before analyzer-eligible skill content can leave +the host: + +```text +Use skillspector_scan on ./my-skill with noLlm=false. +``` + +```bash +export SKILLSPECTOR_PROVIDER=nv_build +export NVIDIA_INFERENCE_KEY=nvapi-... +# Optional; omit to use nv_build's bundled default model. +# export SKILLSPECTOR_MODEL=z-ai/glm-5.2 +``` + +Other valid providers and their credential variables are listed in the main +[LLM Analysis](../README.md#llm-analysis) table. The extension passes the +environment to the existing SkillSpector CLI, but never puts credentials in a +permission request. Model-visible output is bounded and redacts the supported +provider credential values and names. + +## Unit tests + +Pure tool helpers live in dependency-free `.opencode/tools/skillspector_scan_lib.ts`, covered by `tests/opencode/skillspector_scan_lib.test.ts` via stdlib `node --test` (zero new dependencies): + +```bash +node --test tests/opencode/skillspector_scan_lib.test.ts +``` + +## Remove + +Delete the copied `.opencode/tools/skillspector_scan.*` and `.opencode/commands/skillspector.md` files. diff --git a/tests/opencode/skillspector_scan_lib.test.ts b/tests/opencode/skillspector_scan_lib.test.ts new file mode 100644 index 000000000..84a58f7a4 --- /dev/null +++ b/tests/opencode/skillspector_scan_lib.test.ts @@ -0,0 +1,694 @@ +// Unit tests for the OpenCode plugin helpers. Stdlib only: +// node --test tests/opencode/skillspector_scan_lib.test.ts +// Requires Node 22+ (type stripping). + +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import path from "node:path" +import { + CREDENTIAL_ENV_NAMES, + MAX_STDERR, + MAX_STDOUT, + TIMEOUT_MS, + buildCliArgs, + childProcessEnv, + executeScan, + formatExecError, + formatSuccess, + isFilesystemRoot, + isRemoteTarget, + isScpGitTarget, + isUrlOrAbsolute, + llmPermissionMetadata, + redact, + resolveBinary, + truncate, + type PermissionRequest, + type RunFile, +} from "../../.opencode/tools/skillspector_scan_lib.ts" + +describe("buildCliArgs", () => { + it("re-applies omitted defaults: json format, LLM off", () => { + assert.deepEqual(buildCliArgs({ target: "skill" }), [ + "scan", + "skill", + "--format", + "json", + "--no-llm", + ]) + }) + + it("passes explicit values through, including LLM opt-in", () => { + assert.deepEqual( + buildCliArgs({ + target: "skill", + format: "terminal", + noLlm: false, + output: "out.json", + }), + ["scan", "skill", "--format", "terminal", "--output", "out.json"], + ) + }) + + it("keeps explicit --no-llm", () => { + assert.ok(buildCliArgs({ target: "s", noLlm: true }).includes("--no-llm")) + }) +}) + +describe("truncate", () => { + it("leaves short text alone", () => { + assert.equal(truncate("hi", 10), "hi") + }) + + it("caps long text with remaining count", () => { + const out = truncate("x".repeat(MAX_STDOUT + 5), MAX_STDOUT) + assert.ok(out.startsWith("x".repeat(MAX_STDOUT))) + assert.ok(out.endsWith("[truncated 5 chars]")) + }) +}) + +describe("redact", () => { + it("redacts keys and tokens, keeps prose", () => { + const out = redact( + 'sk-ant-secret123 and sk-abcdef OPENAI_API_KEY="hunter2" X_TOKEN: abc plain words', + ) + assert.ok(!out.includes("secret123")) + assert.ok(!out.includes("hunter2")) + assert.ok(!out.includes(" abc")) + assert.ok(out.includes("plain words")) + assert.ok(out.includes("[REDACTED]")) + }) + + it("redacts values for every supported credential environment name", () => { + const env = Object.fromEntries( + CREDENTIAL_ENV_NAMES.map((name, index) => [name, `credential-value-${index}`]), + ) + const out = redact(Object.values(env).join(" "), env) + for (const value of Object.values(env)) assert.ok(!out.includes(value)) + assert.equal(out.match(/\[REDACTED\]/g)?.length, CREDENTIAL_ENV_NAMES.length) + }) + + it("redacts NVIDIA and AWS assignments even when the environment is unavailable", () => { + const out = redact( + "NVIDIA_INFERENCE_KEY=nvapi-secret AWS_SECRET_ACCESS_KEY: aws-secret", + {}, + ) + assert.ok(!out.includes("nvapi-secret")) + assert.ok(!out.includes("aws-secret")) + }) + + it("redacts overlapping and regex-bearing credential values in one pass", () => { + const out = redact("long-secret-value secret-value a+b*c?[x] REDA", { + OPENAI_API_KEY: "long-secret-value", + ANTHROPIC_API_KEY: "secret-value", + NVIDIA_INFERENCE_KEY: "a+b*c?[x]", + AWS_SECRET_ACCESS_KEY: "REDA", + }) + assert.equal(out, "[REDACTED] [REDACTED] [REDACTED] [REDACTED]") + }) +}) + +describe("resolveBinary", () => { + it("prefers SKILLSPECTOR_BIN", () => { + assert.equal( + resolveBinary("/wt", { env: { SKILLSPECTOR_BIN: " /bin/custom " } }), + "/bin/custom", + ) + }) + + it("falls back to the checkout venv when present", () => { + assert.equal( + resolveBinary("/wt", { + env: {}, + platform: "win32", + existsSync: () => true, + }), + path.win32.join("/wt", ".venv", "Scripts", "skillspector.exe"), + ) + }) + + it("falls back to PATH lookup", () => { + assert.equal( + resolveBinary("/wt", { env: {}, existsSync: () => false }), + "skillspector", + ) + }) +}) + +describe("isUrlOrAbsolute", () => { + it("accepts URLs and absolute paths, rejects relatives", () => { + assert.equal(isUrlOrAbsolute("https://example.com/skill"), true) + assert.equal(isUrlOrAbsolute("custom://example.com/skill"), false) + assert.equal(isUrlOrAbsolute("./relative"), false) + assert.equal(isUrlOrAbsolute("relative/path"), false) + }) + + it("accepts supported SCP-style Git targets without accepting arbitrary colons", () => { + const target = "git@github.com:NVIDIA/SkillSpector.git" + assert.equal(isScpGitTarget(target), true) + assert.equal(isRemoteTarget(target), true) + assert.equal(isUrlOrAbsolute(target), true) + assert.equal(isScpGitTarget("notes:today"), false) + assert.equal(isUrlOrAbsolute("notes:today"), false) + assert.equal(isScpGitTarget("git@github.com:missing-git-suffix"), false) + }) + + it("recognizes POSIX and Windows filesystem-root sentinels", () => { + assert.equal(isFilesystemRoot("/", "linux"), true) + assert.equal(isFilesystemRoot("/work", "linux"), false) + assert.equal(isFilesystemRoot("C:\\", "win32"), true) + assert.equal(isFilesystemRoot("C:\\work", "win32"), false) + }) +}) + +describe("formatExecError", () => { + it("maps ENOENT to the install hint", () => { + const out = formatExecError("/bin/missing", { code: "ENOENT" }) + assert.ok(out.includes('tried "/bin/missing"')) + assert.ok(out.includes("SKILLSPECTOR_BIN")) + }) + + it("maps kills to the timeout message", () => { + const out = formatExecError("bin", { killed: true, stdout: "part" }) + assert.ok(out.includes(`${TIMEOUT_MS / 1000}s`)) + assert.ok(out.includes("part")) + }) + + it("returns exit-1 stdout as the report", () => { + assert.equal(formatExecError("bin", { code: 1, stdout: '{"a":1}' }), '{"a":1}') + }) + + it("labels exit 2 as usage only when CLI usage evidence proves it", () => { + const out = formatExecError("bin", { + code: 2, + stderr: "Usage: skillspector scan [OPTIONS] INPUT\nError: No such option: --bad", + }) + assert.ok(out.includes("usage error")) + assert.ok(out.includes("No such option")) + }) + + it("preserves report and diagnostics produced before exit 2", () => { + const out = formatExecError("bin", { + code: 2, + stdout: '{"execution_successful":false,"findings":[{"rule_id":"SC1"}]}', + stderr: "scan accounting was incomplete", + }) + assert.ok(out.includes('"execution_successful":false')) + assert.ok(out.includes('"rule_id":"SC1"')) + assert.ok(out.includes("stderr:")) + assert.ok(out.includes("accounting was incomplete")) + assert.ok(!out.includes("usage error")) + }) + + it("distinguishes session cancellation and preserves partial evidence", () => { + const out = formatExecError("bin", { + name: "AbortError", + code: "ABORT_ERR", + stdout: "partial report", + stderr: "provider request canceled", + }) + assert.ok(out.includes("canceled by the OpenCode session")) + assert.ok(out.includes("partial report")) + assert.ok(out.includes("provider request canceled")) + }) + + it("redacts secrets in failure output", () => { + const out = formatExecError("bin", { + code: 9, + stderr: "GROQ_API_KEY=hunter2", + }) + assert.ok(!out.includes("hunter2")) + }) +}) + +describe("formatSuccess", () => { + it("reports the saved path when output is silent", () => { + assert.equal(formatSuccess("r.json", "", ""), "Report saved to: r.json") + }) + + it("returns truncated stdout", () => { + assert.equal(formatSuccess(undefined, "ok", ""), "ok") + assert.ok(formatSuccess(undefined, "y".repeat(MAX_STDOUT + 1), "").endsWith("]")) + }) + + it("returns successful stdout and stderr warnings with independent caps", () => { + const out = formatSuccess( + undefined, + '{"findings":[]}', + `baseline detected ${"w".repeat(MAX_STDERR + 1)}`, + ) + assert.ok(out.includes('{"findings":[]}')) + assert.ok(out.includes("stderr:\nbaseline detected")) + assert.ok(out.includes("[truncated ")) + }) +}) + +describe("permissioned execution", () => { + function context( + ask: (request: PermissionRequest) => Promise, + abort = new AbortController().signal, + paths: { directory?: string; worktree?: string } = {}, + ) { + return { + directory: paths.directory ?? "/work/project", + worktree: paths.worktree ?? "/work/project", + abort, + ask, + } + } + + it("requests scoped host, external-path, process, and LLM permissions", async () => { + const requests: PermissionRequest[] = [] + let runCalls = 0 + const runFile: RunFile = async () => { + runCalls += 1 + return { stdout: "ok", stderr: "" } + } + const out = await executeScan( + { + target: "/outside/skill", + output: "/outside/report.json", + noLlm: false, + }, + context(async (request) => { + requests.push(request) + }), + { + runFile, + existsSync: () => false, + env: { + SKILLSPECTOR_PROVIDER: "openai", + SKILLSPECTOR_MODEL: "gpt-test", + OPENAI_BASE_URL: "https://user:password@example.test/v1?secret=query", + }, + }, + ) + + assert.equal(out, "ok") + assert.equal(runCalls, 1) + assert.deepEqual( + requests.map((request) => request.permission), + ["external_directory", "read", "external_directory", "edit", "webfetch", "bash"], + ) + const llm = requests[4] + assert.equal(llm.metadata.provider, "openai") + assert.equal(llm.metadata.model, "gpt-test") + assert.equal(llm.metadata.destination, "https://example.test/v1") + assert.ok(!JSON.stringify(llm).includes("password")) + assert.ok(!JSON.stringify(llm).includes("secret=query")) + }) + + it("asks for remote-target network access and preserves SCP input", async () => { + const requests: PermissionRequest[] = [] + let seenArgs: string[] = [] + await executeScan( + { target: "git@github.com:NVIDIA/SkillSpector.git" }, + context(async (request) => { + requests.push(request) + }), + { + runFile: async (_bin, args) => { + seenArgs = args + return { stdout: "ok", stderr: "" } + }, + existsSync: () => false, + env: {}, + }, + ) + assert.deepEqual(requests.map((request) => request.permission), ["webfetch", "bash"]) + assert.equal(seenArgs[1], "git@github.com:NVIDIA/SkillSpector.git") + }) + + it("never starts the process when any permission is denied", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: "./skill" }, + context(async () => { + throw new Error("permission denied") + }), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: () => false, + env: {}, + }, + ), + /permission denied/, + ) + assert.equal(runCalls, 0) + }) + + it("requests external-directory permission for a relative binary outside the session", async () => { + const requests: PermissionRequest[] = [] + await executeScan( + { target: "./skill" }, + context(async (request) => { + requests.push(request) + }), + { + runFile: async () => ({ stdout: "ok", stderr: "" }), + existsSync: () => false, + env: { SKILLSPECTOR_BIN: "../tools/skillspector" }, + }, + ) + assert.deepEqual( + requests.map((request) => request.permission), + ["read", "external_directory", "bash"], + ) + assert.equal(requests[1].metadata.binary, "/work/tools/skillspector") + }) + + it("passes the OpenCode abort signal to the child and reports cancellation", async () => { + const controller = new AbortController() + let seenSignal: AbortSignal | undefined + const out = await executeScan( + { target: "./skill" }, + context(async () => {}, controller.signal), + { + runFile: async (_bin, _args, options) => { + seenSignal = options.signal + throw { name: "AbortError", code: "ABORT_ERR" } + }, + existsSync: () => false, + env: {}, + }, + ) + assert.equal(seenSignal, controller.signal) + assert.ok(out.includes("canceled by the OpenCode session")) + }) + + it("strips ambient LangChain and LangSmith tracing from the child environment", async () => { + let seenEnv: Record = {} + await executeScan( + { target: "./skill" }, + context(async () => {}), + { + runFile: async (_bin, _args, options) => { + seenEnv = options.env + return { stdout: "ok", stderr: "" } + }, + existsSync: () => false, + env: { + PATH: "/usr/bin", + OPENAI_API_KEY: "provider-secret", + LANGCHAIN_TRACING: "true", + LANGCHAIN_TRACING_V2: "true", + LANGSMITH_TRACING: "true", + LANGSMITH_TRACING_V2: "true", + LANGCHAIN_ENDPOINT: "https://trace.example.test", + langsmith_api_key: "trace-secret", + LANGSMITH_PROJECT: "sensitive-project", + LANGCHAIN_TAGS_EXTRA: "sensitive-tag", + }, + }, + ) + + assert.equal(seenEnv.PATH, "/usr/bin") + assert.equal(seenEnv.OPENAI_API_KEY, "provider-secret") + assert.deepEqual( + Object.keys(seenEnv).filter((name) => /^(?:LANGCHAIN|LANGSMITH)_/i.test(name)), + [], + ) + }) + + it("ignores OpenCode's POSIX root worktree sentinel for external paths", async () => { + const requests: PermissionRequest[] = [] + await executeScan( + { target: "/etc/skill", output: "/tmp/report.json" }, + context( + async (request) => { + requests.push(request) + }, + new AbortController().signal, + { directory: "/work/project", worktree: "/" }, + ), + { + runFile: async () => ({ stdout: "ok", stderr: "" }), + existsSync: () => false, + env: {}, + platform: "linux", + }, + ) + assert.deepEqual( + requests.map((request) => request.permission), + ["external_directory", "read", "external_directory", "edit", "bash"], + ) + }) + + it("ignores a Windows drive-root worktree sentinel for external paths", async () => { + const requests: PermissionRequest[] = [] + await executeScan( + { target: "C:\\Windows\\skill", output: "D:\\tmp\\report.json" }, + context( + async (request) => { + requests.push(request) + }, + new AbortController().signal, + { directory: "C:\\work\\project", worktree: "C:\\" }, + ), + { + runFile: async () => ({ stdout: "ok", stderr: "" }), + existsSync: () => false, + env: {}, + platform: "win32", + }, + ) + assert.deepEqual( + requests.map((request) => request.permission), + ["external_directory", "read", "external_directory", "edit", "bash"], + ) + }) + + it("treats a forward-slash Windows drive path as local, not as a URL", async () => { + const requests: PermissionRequest[] = [] + await executeScan( + { target: "C://Windows/skill" }, + context( + async (request) => { + requests.push(request) + }, + new AbortController().signal, + { directory: "C:\\work\\project", worktree: "C:\\" }, + ), + { + runFile: async () => ({ stdout: "ok", stderr: "" }), + existsSync: () => false, + env: {}, + platform: "win32", + }, + ) + assert.deepEqual( + requests.map((request) => request.permission), + ["external_directory", "read", "bash"], + ) + }) + + it("rejects an existing symlink in the output path before process launch", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: "./skill", output: "./linked/report.json" }, + context(async () => {}), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: () => false, + lstatSync: (candidate) => ({ + isSymbolicLink: () => candidate === "/work/project/linked", + }), + env: {}, + }, + ), + /symlinked path/, + ) + assert.equal(runCalls, 0) + }) + + it("rejects a local target redirected through a symlink before process launch", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: "./linked-skill" }, + context(async () => {}), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: () => false, + lstatSync: (candidate) => ({ + isSymbolicLink: () => candidate === "/work/project/linked-skill", + }), + env: {}, + }, + ), + /scan a local target through a symlinked path/, + ) + assert.equal(runCalls, 0) + }) + + it("allows a non-symlink filesystem-root target", async () => { + let runCalls = 0 + const out = await executeScan( + { target: "/" }, + context(async () => {}), + { + runFile: async () => { + runCalls += 1 + return { stdout: "ok", stderr: "" } + }, + existsSync: () => false, + lstatSync: () => ({ isSymbolicLink: () => false }), + env: {}, + platform: "linux", + }, + ) + assert.equal(out, "ok") + assert.equal(runCalls, 1) + }) + + it("rejects a configured CLI reached through a symlink before process launch", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: "./skill" }, + context(async () => {}), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: () => false, + lstatSync: (candidate) => ({ + isSymbolicLink: () => candidate === "/work/project/bin", + }), + env: { SKILLSPECTOR_BIN: "/work/project/bin/skillspector" }, + }, + ), + /execute the SkillSpector CLI through a symlinked path/, + ) + assert.equal(runCalls, 0) + }) + + it("rejects a symlinked checkout venv CLI before process launch", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: "./skill" }, + context(async () => {}), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: (candidate) => candidate.endsWith("/.venv/bin/skillspector"), + lstatSync: (candidate) => ({ + isSymbolicLink: () => candidate === "/work/project/.venv", + }), + env: {}, + }, + ), + /execute the SkillSpector CLI through a symlinked path/, + ) + assert.equal(runCalls, 0) + }) + + it("resolves and rejects a POSIX dot-relative symlinked CLI", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: "./skill" }, + context(async () => {}), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: () => false, + lstatSync: (candidate) => ({ + isSymbolicLink: () => candidate === "/work/project/skillspector", + }), + env: { SKILLSPECTOR_BIN: "./skillspector" }, + platform: "linux", + }, + ), + /execute the SkillSpector CLI through a symlinked path/, + ) + assert.equal(runCalls, 0) + }) + + it("resolves and rejects a Windows dot-relative symlinked CLI", async () => { + let runCalls = 0 + await assert.rejects( + executeScan( + { target: ".\\skill" }, + context( + async () => {}, + new AbortController().signal, + { directory: "C:\\work\\project", worktree: "C:\\work\\project" }, + ), + { + runFile: async () => { + runCalls += 1 + return { stdout: "unexpected", stderr: "" } + }, + existsSync: () => false, + lstatSync: (candidate) => ({ + isSymbolicLink: () => candidate === "C:\\work\\project\\skillspector.exe", + }), + env: { SKILLSPECTOR_BIN: ".\\skillspector.exe" }, + platform: "win32", + }, + ), + /execute the SkillSpector CLI through a symlinked path/, + ) + assert.equal(runCalls, 0) + }) +}) + +describe("LLM permission metadata", () => { + it("uses a real supported provider and strips endpoint credentials", () => { + assert.deepEqual( + llmPermissionMetadata({ + SKILLSPECTOR_PROVIDER: "anthropic", + SKILLSPECTOR_MODEL: "claude-test", + ANTHROPIC_BASE_URL: "https://user:pass@example.test/v1?q=secret", + }), + { + provider: "anthropic", + model: "claude-test", + destination: "https://example.test/v1", + }, + ) + }) +}) + +describe("childProcessEnv", () => { + it("retains provider settings while removing every tracing namespace alias", () => { + assert.deepEqual( + childProcessEnv({ + SKILLSPECTOR_PROVIDER: "openai", + OPENAI_API_KEY: "provider-secret", + LANGCHAIN_CALLBACKS_BACKGROUND: "true", + LangSmith_Sampling_Rate: "1", + }), + { + SKILLSPECTOR_PROVIDER: "openai", + OPENAI_API_KEY: "provider-secret", + }, + ) + }) +}) + +describe("constants", () => { + it("keeps the documented caps", () => { + assert.equal(TIMEOUT_MS, 120_000) + assert.equal(MAX_STDOUT, 12_000) + assert.equal(MAX_STDERR, 6_000) + }) +})