Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8afd386
fix(pi): require approval for external scan inputs
yashrajp22 Oct 7, 2026
f651cad
fix(pi): keep git-prefixed local inputs canonical
yashrajp22 Oct 7, 2026
511b215
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 7, 2026
6d0a1d0
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 8, 2026
9fc1a3d
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 8, 2026
1486134
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 8, 2026
5667438
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 8, 2026
fc58fbe
fix(pi): close remaining external input permission gaps
yashrajp22 Oct 9, 2026
d74e1a3
test(yara): use matching strings in include regression
yashrajp22 Oct 9, 2026
12a20cd
style(test): format YARA fixture
yashrajp22 Oct 9, 2026
3ca6d5b
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 9, 2026
4711771
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 9, 2026
69527ef
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 9, 2026
faa207c
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 9, 2026
9e9e8a7
merge: preserve Pi permission fixes with latest upstream changes
yashrajp22 Oct 9, 2026
98be7bd
Merge branch 'main' into yashraj/fix-pi-input-permissions-20261007
github-actions[bot] Oct 9, 2026
d62606b
merge: preserve security fixes with latest review branch
yashrajp22 Oct 9, 2026
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
8 changes: 8 additions & 0 deletions docs/PI_EXTENSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ Equivalent CLI:
- `yaraRulesDir`: optional directory of extra YARA rules.
- `verbose`: optional detailed progress.

Inputs inside the session's working directory run without a prompt. Remote targets
and external paths require confirmation; redirected aliases show their resolved
destination too. Print and JSON sessions reject these requests because they cannot
show a dialog. Use TUI or RPC mode, move the skill into the working directory, or
run the CLI directly. Local targets retain the CLI's refusal of symlinked paths.
Missing rule directories fail before scanning, and YARA `include` directives are
disabled: put self-contained rule files in the selected directory.

## LLM-backed analysis

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.
Expand Down
105 changes: 87 additions & 18 deletions extensions/skillspector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { StringEnum } from "@earendil-works/pi-ai";
import { Type, type Static } from "typebox";
import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs";
Expand All @@ -7,7 +7,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
import { fileURLToPath } from "node:url";

const scanSchema = Type.Object({
target: Type.String({ description: "Path, URL, zip, Git repo, or SKILL.md to scan." }),
target: Type.String({ description: "Path, URL, zip, Git repo, or SKILL.md to scan. External paths and remote targets require user confirmation." }),
format: Type.Optional(
StringEnum(["terminal", "json", "markdown", "sarif"] as const, {
description: "SkillSpector output format. Defaults to terminal.",
Expand All @@ -21,22 +21,12 @@ const scanSchema = Type.Object({
}),
),
model: Type.Optional(Type.String({ description: "Optional model override." })),
yaraRulesDir: Type.Optional(Type.String({ description: "Optional extra YARA rules directory." })),
yaraRulesDir: Type.Optional(Type.String({ description: "Optional extra YARA rules directory. External paths require user confirmation." })),
verbose: Type.Optional(Type.Boolean({ description: "Show detailed progress." })),
});

type SkillSpectorScanParams = Static<typeof scanSchema>;

function isLikelyUrl(value: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^[\w.-]+\/[\w.-]+(?:\.git)?(?:@.+)?$/i.test(value);
}

function resolveMaybePath(ctxCwd: string, value?: string): string | undefined {
if (!value) return undefined;
if (isLikelyUrl(value)) return value;
return isAbsolute(value) ? value : resolve(ctxCwd, value);
}

function redactSecrets(value: string): string {
return value
.replace(/(sk-ant-[A-Za-z0-9_-]{12,})/g, "[REDACTED_ANTHROPIC_KEY]")
Expand Down Expand Up @@ -71,6 +61,85 @@ function isWithin(root: string, path: string): boolean {
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}

async function approveScanInputs(
params: SkillSpectorScanParams,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<SkillSpectorScanParams> {
signal?.throwIfAborted();
const workspace = realpathSync(ctx.cwd);
const prepared = { ...params };
const localPaths: Array<{ field: "target" | "yaraRulesDir"; input: string; resolved?: string }> = [];
const requests: string[] = [];
const readRequest = (field: string, path: string) =>
`Read external ${field === "target" ? "scan target" : "YARA rules"}: ${JSON.stringify(path)}`;
async function approve(requests: string[]): Promise<void> {
if (!requests.length) return;
signal?.throwIfAborted();
if (!ctx.hasUI) throw new Error("External scan inputs require user confirmation in an interactive or RPC session.");
const approved = await ctx.ui.confirm(
"Allow SkillSpector external access?",
`${requests.join("\n")}\n\nScanned content and matching rule text can appear in the agent conversation.`,
{ signal },
);
signal?.throwIfAborted();
if (!approved) throw new Error("SkillSpector external access was not approved.");
}
for (const field of ["target", "yaraRulesDir"] as const) {
const value = params[field]?.trim();
if (field === "yaraRulesDir" && !value) {
prepared[field] = undefined;
continue;
}
if (!value) throw new Error("A scan target is required.");
// Match the CLI's remote forms. A local owner/repo path is not a URL.
const remote = !isAbsolute(value) && (value.startsWith("https://") || (value.startsWith("git@") && value.endsWith(".git")));
if (remote) {
if (field !== "target") throw new Error("YARA rules must be a local directory.");
prepared[field] = value;
requests.push(`Fetch remote scan target: ${JSON.stringify(value)}`);
} else {
const input = resolve(ctx.cwd, value);
// Ask before resolving external paths, which can probe the host or access
// a Windows network share even when the file is never opened.
if (!isWithin(resolve(ctx.cwd), input)) {
localPaths.push({ field, input });
requests.push(readRequest(field, input));
} else {
let resolved: string;
try {
resolved = realpathSync(input);
} catch {
throw new Error(`Could not resolve ${field === "target" ? "scan target" : "YARA rules directory"}. Check that it exists and is accessible.`);
}
localPaths.push({ field, input, resolved });
if (!isWithin(workspace, resolved)) requests.push(readRequest(field, resolved));
}
}
}
await approve(requests);
const aliasRequests: string[] = [];
for (const path of localPaths) {
try {
path.resolved ??= realpathSync(path.input);
} catch {
throw new Error(`Could not resolve ${path.field === "target" ? "scan target" : "YARA rules directory"}. Check that it exists and is accessible.`);
}
if (!isWithin(resolve(ctx.cwd), path.input) && !isWithin(workspace, path.resolved) && path.resolved !== path.input) {
aliasRequests.push(readRequest(path.field, path.resolved));
}
// Keep the original target path so the CLI can enforce its no-symlink
// input policy. YARA directories are canonicalised by the CLI too.
prepared[path.field] = path.field === "target" ? path.input : path.resolved;
}
await approve(aliasRequests);
signal?.throwIfAborted();
if (realpathSync(ctx.cwd) !== workspace || localPaths.some(({ input, resolved }) => realpathSync(input) !== resolved)) {
throw new Error("Scan input path changed while awaiting confirmation.");
}
return prepared;
}

function reportOutputPath(cwd: string, value?: string): string | undefined {
if (!value) return undefined;
const output = resolve(cwd, value);
Expand Down Expand Up @@ -102,17 +171,16 @@ function publishReport(source: string, destination: string): void {
}
}

function buildScanArgs(params: SkillSpectorScanParams, cwd: string, output?: string): string[] {
const args = ["scan", resolveMaybePath(cwd, params.target) ?? params.target];
function buildScanArgs(params: SkillSpectorScanParams, output?: string): string[] {
const args = ["scan", params.target];
args.push("--format", params.format ?? "terminal");

const noLlm = params.noLlm ?? true;
if (noLlm) args.push("--no-llm");

if (output) args.push("--output", output);

const yaraRulesDir = resolveMaybePath(cwd, params.yaraRulesDir);
if (yaraRulesDir) args.push("--yara-rules-dir", yaraRulesDir);
if (params.yaraRulesDir) args.push("--yara-rules-dir", params.yaraRulesDir);

if (params.verbose) args.push("--verbose");
return args;
Expand All @@ -132,10 +200,11 @@ export default function (pi: ExtensionAPI) {
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const bin = findSkillSpectorBin();
const outputPath = reportOutputPath(ctx.cwd, params.output);
const prepared = await approveScanInputs(params, ctx, signal);
const reportDir = outputPath ? mkdtempSync(join(tmpdir(), "skillspector-report-")) : undefined;
const reportPath = reportDir ? join(reportDir, "report") : undefined;
try {
const args = buildScanArgs(params, ctx.cwd, reportPath);
const args = buildScanArgs(prepared, reportPath);
const env: Record<string, string> = {};

if (params.provider) env.SKILLSPECTOR_PROVIDER = params.provider;
Expand Down
6 changes: 3 additions & 3 deletions src/skillspector/nodes/analyzers/static_yara.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ def _compile_rules(
"""
_enforce_rule_load_deadline()
try:
compiled = yara.compile(sources=sources)
compiled = yara.compile(sources=sources, includes=False)
_enforce_rule_load_deadline()
return compiled, 0
except yara.SyntaxError:
Expand All @@ -545,7 +545,7 @@ def _compile_rules(
for ns, source in sources.items():
_enforce_rule_load_deadline()
try:
yara.compile(source=source)
yara.compile(source=source, includes=False)
good[ns] = source
except (yara.SyntaxError, yara.Error) as exc:
skipped += 1
Expand All @@ -559,7 +559,7 @@ def _compile_rules(
)

_enforce_rule_load_deadline()
compiled = yara.compile(sources=good) if good else None
compiled = yara.compile(sources=good, includes=False) if good else None
_enforce_rule_load_deadline()
return compiled, skipped

Expand Down
31 changes: 31 additions & 0 deletions tests/nodes/analyzers/test_static_yara.py
Original file line number Diff line number Diff line change
Expand Up @@ -2409,6 +2409,37 @@ def test_build_namespace_map_skips_malformed_encoded_rules(self, tmp_path):
assert "invalid" not in ns_map
assert skipped == 1

@pytest.mark.parametrize("relative", [False, True])
def test_external_includes_are_rejected_without_dropping_valid_rules(
self, tmp_path, monkeypatch, relative
):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
outside = tmp_path / "private.yar"
outside.write_text('rule external_private_rule { strings: $a = "Local" condition: $a }')
include = "../private.yar" if relative else str(outside)
(rules_dir / "include.yar").write_text(f'include "{include}"')
(rules_dir / "good.yar").write_text(
'rule approved_local_rule { strings: $a = "Local" condition: $a }'
)
monkeypatch.chdir(rules_dir)
monkeypatch.setattr(static_yara, "_rule_cache", None)
monkeypatch.setattr(static_yara, "_BUILTIN_RULES_DIR", tmp_path / "empty_builtin")
result = static_yara.node(
{
"components": ["SKILL.md"],
"file_cache": {"SKILL.md": "Local sample skill."},
"yara_rules_dir": str(rules_dir),
}
)
assert any("approved_local_rule" in f.message for f in result["findings"])
assert not any("external_private_rule" in f.message for f in result["findings"])
assert result["analyzer_status_events"][0]["status"] != "completed"
assert any(
event.get("reason_code") == LedgerReason.READ_ERROR
for event in result["inspection_ledger"]
)

def test_malformed_rule_is_reported_not_silently_dropped(self, tmp_path, monkeypatch):
"""A custom rule that can't compile must not report a clean, SAFE scan (#554).

Expand Down
Loading
Loading