Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .opencode/tools/skillspector_scan_lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import path from "node:path"

export const TIMEOUT_MS = 120_000
// The CLI's default workflow budget is 600s. Allow time to start and report
// after that deadline; static-only scans retain the shorter process limit.
export const LLM_TIMEOUT_MS = 630_000
export const MAX_STDOUT = 12_000
export const MAX_STDERR = 6_000
export const INSTALL_HINT =
Expand Down Expand Up @@ -169,6 +172,7 @@ export function formatExecError(
bin: string,
err: unknown,
env: Env = process.env,
timeoutMs: number = TIMEOUT_MS,
): string {
const e = err as ExecFailure
const partialOut = typeof e.stdout === "string" ? e.stdout : ""
Expand All @@ -185,7 +189,7 @@ export function formatExecError(
}
if (e.killed) {
return (
`SkillSpector scan timed out after ${TIMEOUT_MS / 1000}s (killed).` +
`SkillSpector scan timed out after ${timeoutMs / 1000}s (killed).` +
(evidence ? ` Partial output:\n${evidence}` : "")
)
}
Expand Down Expand Up @@ -518,6 +522,7 @@ export async function executeScan(
? pathApi.resolve(baseDir, configuredBin)
: configuredBin
const cliArgs = buildCliArgs(prepared)
const timeoutMs = (args.noLlm ?? true) ? TIMEOUT_MS : LLM_TIMEOUT_MS

for (const request of buildPermissionRequests(prepared, {
directory: baseDir,
Expand Down Expand Up @@ -552,7 +557,7 @@ export async function executeScan(

try {
const result = await deps.runFile(bin, cliArgs, {
timeout: TIMEOUT_MS,
timeout: timeoutMs,
maxBuffer: 32 * 1024 * 1024,
cwd: baseDir,
signal: context.abort,
Expand All @@ -561,6 +566,6 @@ export async function executeScan(
})
return formatSuccess(output, result.stdout, result.stderr, env)
} catch (err: unknown) {
return formatExecError(bin, err, env)
return formatExecError(bin, err, env, timeoutMs)
}
}
5 changes: 5 additions & 0 deletions docs/OPENCODE_EXTENSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ the subprocess starts.
- `output`: optional report path.
- `noLlm`: default `true`.

Static scans have a 120-second subprocess limit. With `noLlm=false`, the limit
is 630 seconds to allow the CLI's default 600-second workflow to finish and
write its report. Session cancellation still stops the scan; a longer configured
CLI workflow budget does not extend this tool limit.

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
Expand Down
5 changes: 5 additions & 0 deletions docs/PI_EXTENSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ Equivalent CLI:

Static scan is default. To use semantic LLM analysis, configure provider credentials in your shell before launching Pi, then call the tool with `noLlm=false` and a provider.

Static scans have a 120-second process limit. Explicit LLM scans have a
630-second limit, allowing the CLI's default 600-second workflow budget plus
startup and report writing. This tool limit stays fixed even if
`SKILLSPECTOR_MAX_WORKFLOW_SECONDS` is configured above 600.

Example:

```text
Expand Down
3 changes: 2 additions & 1 deletion extensions/skillspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ export default function (pi: ExtensionAPI) {
cwd: ctx.cwd,
env,
signal,
timeout: 120000,
// Match the CLI's 600s workflow budget plus startup/report headroom.
timeout: (params.noLlm ?? true) ? 120000 : 630000,
});

const stdout = truncateText(redactSecrets(result.stdout ?? ""));
Expand Down
33 changes: 33 additions & 0 deletions tests/opencode/skillspector_scan_lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import assert from "node:assert/strict"
import path from "node:path"
import {
CREDENTIAL_ENV_NAMES,
LLM_TIMEOUT_MS,
MAX_STDERR,
MAX_STDOUT,
TIMEOUT_MS,
Expand Down Expand Up @@ -257,6 +258,37 @@ describe("permissioned execution", () => {
}
}

it("lets opted-in LLM scans finish the CLI budget while bounding static scans", async () => {
for (const noLlm of [undefined, true, false]) {
await executeScan(
{ target: "./skill", noLlm },
context(async () => {}),
{
runFile: async (_bin, _args, options) => {
assert.equal(options.timeout, noLlm === false ? 630_000 : 120_000)
return { stdout: "complete report", stderr: "" }
},
env: {},
},
)
}
})

it("reports the actual LLM process limit and retains partial output on timeout", async () => {
const out = await executeScan(
{ target: "./skill", noLlm: false },
context(async () => {}),
{
runFile: async () => {
throw { killed: true, stdout: "partial report" }
},
env: {},
},
)
assert.ok(out.includes("timed out after 630s"))
assert.ok(out.includes("partial report"))
})

it("requests scoped host, external-path, process, and LLM permissions", async () => {
const requests: PermissionRequest[] = []
let runCalls = 0
Expand Down Expand Up @@ -688,6 +720,7 @@ describe("childProcessEnv", () => {
describe("constants", () => {
it("keeps the documented caps", () => {
assert.equal(TIMEOUT_MS, 120_000)
assert.equal(LLM_TIMEOUT_MS, 630_000)
assert.equal(MAX_STDOUT, 12_000)
assert.equal(MAX_STDERR, 6_000)
})
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/test_pi_extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ test("finds the Windows virtualenv executable without a PATH fallback", async (t
}
});

for (const [noLlm, timeout] of [[undefined, 120000], [true, 120000], [false, 630000]]) {
test(`uses ${timeout}ms for noLlm=${noLlm}`, async (t) => {
const ctx = await setup(t);
await ctx.scan({ noLlm });
assert.equal(ctx.calls[0].options.timeout, timeout);
assert.equal(ctx.calls[0].args.includes("--no-llm"), noLlm ?? true);
});
}

test("uses an absolute operator override and preserves URL targets", async (t) => {
const ctx = await setup(t);
process.env.SKILLSPECTOR_BIN = join(ctx.root, "custom-cli");
Expand Down