Skip to content

Commit e6385c5

Browse files
Merge branch 'main' into fix/claude-cli-session-cost
2 parents ef7c5d9 + 657788f commit e6385c5

5 files changed

Lines changed: 290 additions & 28 deletions

File tree

‎docs/PI_EXTENSION.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ Equivalent CLI:
4848
- `yaraRulesDir`: optional directory of extra YARA rules.
4949
- `verbose`: optional detailed progress.
5050

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

5361
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.

‎extensions/skillspector.ts‎

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
import { StringEnum } from "@earendil-works/pi-ai";
33
import { Type, type Static } from "typebox";
44
import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs";
@@ -7,7 +7,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
77
import { fileURLToPath } from "node:url";
88

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

2828
type SkillSpectorScanParams = Static<typeof scanSchema>;
2929

30-
function isLikelyUrl(value: string): boolean {
31-
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^[\w.-]+\/[\w.-]+(?:\.git)?(?:@.+)?$/i.test(value);
32-
}
33-
34-
function resolveMaybePath(ctxCwd: string, value?: string): string | undefined {
35-
if (!value) return undefined;
36-
if (isLikelyUrl(value)) return value;
37-
return isAbsolute(value) ? value : resolve(ctxCwd, value);
38-
}
39-
4030
function redactSecrets(value: string): string {
4131
return value
4232
.replace(/(sk-ant-[A-Za-z0-9_-]{12,})/g, "[REDACTED_ANTHROPIC_KEY]")
@@ -71,6 +61,85 @@ function isWithin(root: string, path: string): boolean {
7161
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
7262
}
7363

64+
async function approveScanInputs(
65+
params: SkillSpectorScanParams,
66+
ctx: ExtensionContext,
67+
signal?: AbortSignal,
68+
): Promise<SkillSpectorScanParams> {
69+
signal?.throwIfAborted();
70+
const workspace = realpathSync(ctx.cwd);
71+
const prepared = { ...params };
72+
const localPaths: Array<{ field: "target" | "yaraRulesDir"; input: string; resolved?: string }> = [];
73+
const requests: string[] = [];
74+
const readRequest = (field: string, path: string) =>
75+
`Read external ${field === "target" ? "scan target" : "YARA rules"}: ${JSON.stringify(path)}`;
76+
async function approve(requests: string[]): Promise<void> {
77+
if (!requests.length) return;
78+
signal?.throwIfAborted();
79+
if (!ctx.hasUI) throw new Error("External scan inputs require user confirmation in an interactive or RPC session.");
80+
const approved = await ctx.ui.confirm(
81+
"Allow SkillSpector external access?",
82+
`${requests.join("\n")}\n\nScanned content and matching rule text can appear in the agent conversation.`,
83+
{ signal },
84+
);
85+
signal?.throwIfAborted();
86+
if (!approved) throw new Error("SkillSpector external access was not approved.");
87+
}
88+
for (const field of ["target", "yaraRulesDir"] as const) {
89+
const value = params[field]?.trim();
90+
if (field === "yaraRulesDir" && !value) {
91+
prepared[field] = undefined;
92+
continue;
93+
}
94+
if (!value) throw new Error("A scan target is required.");
95+
// Match the CLI's remote forms. A local owner/repo path is not a URL.
96+
const remote = !isAbsolute(value) && (value.startsWith("https://") || (value.startsWith("git@") && value.endsWith(".git")));
97+
if (remote) {
98+
if (field !== "target") throw new Error("YARA rules must be a local directory.");
99+
prepared[field] = value;
100+
requests.push(`Fetch remote scan target: ${JSON.stringify(value)}`);
101+
} else {
102+
const input = resolve(ctx.cwd, value);
103+
// Ask before resolving external paths, which can probe the host or access
104+
// a Windows network share even when the file is never opened.
105+
if (!isWithin(resolve(ctx.cwd), input)) {
106+
localPaths.push({ field, input });
107+
requests.push(readRequest(field, input));
108+
} else {
109+
let resolved: string;
110+
try {
111+
resolved = realpathSync(input);
112+
} catch {
113+
throw new Error(`Could not resolve ${field === "target" ? "scan target" : "YARA rules directory"}. Check that it exists and is accessible.`);
114+
}
115+
localPaths.push({ field, input, resolved });
116+
if (!isWithin(workspace, resolved)) requests.push(readRequest(field, resolved));
117+
}
118+
}
119+
}
120+
await approve(requests);
121+
const aliasRequests: string[] = [];
122+
for (const path of localPaths) {
123+
try {
124+
path.resolved ??= realpathSync(path.input);
125+
} catch {
126+
throw new Error(`Could not resolve ${path.field === "target" ? "scan target" : "YARA rules directory"}. Check that it exists and is accessible.`);
127+
}
128+
if (!isWithin(resolve(ctx.cwd), path.input) && !isWithin(workspace, path.resolved) && path.resolved !== path.input) {
129+
aliasRequests.push(readRequest(path.field, path.resolved));
130+
}
131+
// Keep the original target path so the CLI can enforce its no-symlink
132+
// input policy. YARA directories are canonicalised by the CLI too.
133+
prepared[path.field] = path.field === "target" ? path.input : path.resolved;
134+
}
135+
await approve(aliasRequests);
136+
signal?.throwIfAborted();
137+
if (realpathSync(ctx.cwd) !== workspace || localPaths.some(({ input, resolved }) => realpathSync(input) !== resolved)) {
138+
throw new Error("Scan input path changed while awaiting confirmation.");
139+
}
140+
return prepared;
141+
}
142+
74143
function reportOutputPath(cwd: string, value?: string): string | undefined {
75144
if (!value) return undefined;
76145
const output = resolve(cwd, value);
@@ -102,17 +171,16 @@ function publishReport(source: string, destination: string): void {
102171
}
103172
}
104173

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

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

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

114-
const yaraRulesDir = resolveMaybePath(cwd, params.yaraRulesDir);
115-
if (yaraRulesDir) args.push("--yara-rules-dir", yaraRulesDir);
183+
if (params.yaraRulesDir) args.push("--yara-rules-dir", params.yaraRulesDir);
116184

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

141210
if (params.provider) env.SKILLSPECTOR_PROVIDER = params.provider;

‎src/skillspector/nodes/analyzers/static_yara.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ def _compile_rules(
533533
"""
534534
_enforce_rule_load_deadline()
535535
try:
536-
compiled = yara.compile(sources=sources)
536+
compiled = yara.compile(sources=sources, includes=False)
537537
_enforce_rule_load_deadline()
538538
return compiled, 0
539539
except yara.SyntaxError:
@@ -545,7 +545,7 @@ def _compile_rules(
545545
for ns, source in sources.items():
546546
_enforce_rule_load_deadline()
547547
try:
548-
yara.compile(source=source)
548+
yara.compile(source=source, includes=False)
549549
good[ns] = source
550550
except (yara.SyntaxError, yara.Error) as exc:
551551
skipped += 1
@@ -559,7 +559,7 @@ def _compile_rules(
559559
)
560560

561561
_enforce_rule_load_deadline()
562-
compiled = yara.compile(sources=good) if good else None
562+
compiled = yara.compile(sources=good, includes=False) if good else None
563563
_enforce_rule_load_deadline()
564564
return compiled, skipped
565565

‎tests/nodes/analyzers/test_static_yara.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2409,6 +2409,37 @@ def test_build_namespace_map_skips_malformed_encoded_rules(self, tmp_path):
24092409
assert "invalid" not in ns_map
24102410
assert skipped == 1
24112411

2412+
@pytest.mark.parametrize("relative", [False, True])
2413+
def test_external_includes_are_rejected_without_dropping_valid_rules(
2414+
self, tmp_path, monkeypatch, relative
2415+
):
2416+
rules_dir = tmp_path / "rules"
2417+
rules_dir.mkdir()
2418+
outside = tmp_path / "private.yar"
2419+
outside.write_text('rule external_private_rule { strings: $a = "Local" condition: $a }')
2420+
include = "../private.yar" if relative else str(outside)
2421+
(rules_dir / "include.yar").write_text(f'include "{include}"')
2422+
(rules_dir / "good.yar").write_text(
2423+
'rule approved_local_rule { strings: $a = "Local" condition: $a }'
2424+
)
2425+
monkeypatch.chdir(rules_dir)
2426+
monkeypatch.setattr(static_yara, "_rule_cache", None)
2427+
monkeypatch.setattr(static_yara, "_BUILTIN_RULES_DIR", tmp_path / "empty_builtin")
2428+
result = static_yara.node(
2429+
{
2430+
"components": ["SKILL.md"],
2431+
"file_cache": {"SKILL.md": "Local sample skill."},
2432+
"yara_rules_dir": str(rules_dir),
2433+
}
2434+
)
2435+
assert any("approved_local_rule" in f.message for f in result["findings"])
2436+
assert not any("external_private_rule" in f.message for f in result["findings"])
2437+
assert result["analyzer_status_events"][0]["status"] != "completed"
2438+
assert any(
2439+
event.get("reason_code") == LedgerReason.READ_ERROR
2440+
for event in result["inspection_ledger"]
2441+
)
2442+
24122443
def test_malformed_rule_is_reported_not_silently_dropped(self, tmp_path, monkeypatch):
24132444
"""A custom rule that can't compile must not report a clean, SAFE scan (#554).
24142445

0 commit comments

Comments
 (0)