fix(security): Harden skill activation and loading flows
Harden batch activation, dev refresh gating, Microsoft sync path handling, and Jetski skill loading against command injection, symlink traversal, and client-side star tampering. Add regression coverage for the security-sensitive paths and update the internal triage addendum for the Jetski loader fix.
This commit is contained in:
20
tools/scripts/tests/activate_skills_batch_security.test.js
Normal file
20
tools/scripts/tests/activate_skills_batch_security.test.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "../..", "..");
|
||||
const batchScript = fs.readFileSync(
|
||||
path.join(repoRoot, "scripts", "activate-skills.bat"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
batchScript,
|
||||
/for %%s in \(!ESSENTIALS!\) do \(/,
|
||||
"activate-skills.bat must not iterate untrusted skills with tokenized FOR syntax",
|
||||
);
|
||||
assert.match(
|
||||
batchScript,
|
||||
/for \/f .*%%s in \("%SKILLS_LIST_FILE%"\) do \(/i,
|
||||
"activate-skills.bat should read one validated skill id per line from the temp file",
|
||||
);
|
||||
@@ -8,6 +8,10 @@ const apifySkill = fs.readFileSync(
|
||||
path.join(repoRoot, 'skills', 'apify-actorization', 'SKILL.md'),
|
||||
'utf8',
|
||||
);
|
||||
const apifyCliReference = fs.readFileSync(
|
||||
path.join(repoRoot, 'skills', 'apify-actorization', 'references', 'cli-actorization.md'),
|
||||
'utf8',
|
||||
);
|
||||
const audioExample = fs.readFileSync(
|
||||
path.join(repoRoot, 'skills', 'audio-transcriber', 'examples', 'basic-transcription.sh'),
|
||||
'utf8',
|
||||
@@ -165,6 +169,7 @@ assert.match(audioExample, /AUDIO_FILE_ENV/, 'audio example should pass shell va
|
||||
assert.strictEqual(/\|\s*(bash|sh)\b/.test(apifySkill), false, 'SKILL.md must not recommend pipe-to-shell installs');
|
||||
assert.strictEqual(/\|\s*iex\b/i.test(apifySkill), false, 'SKILL.md must not recommend PowerShell pipe-to-iex installs');
|
||||
assert.strictEqual(/apify login -t\b/.test(apifySkill), false, 'SKILL.md must not put tokens on the command line');
|
||||
assert.strictEqual(/\bcurl\b[\s\S]*?\|\s*(?:bash|sh)\b/i.test(apifyCliReference), false, 'cli reference must not recommend pipe-to-shell installs');
|
||||
|
||||
function violationCount(list) {
|
||||
return list.length;
|
||||
|
||||
@@ -105,6 +105,24 @@ async function main() {
|
||||
]),
|
||||
/Skill path escapes skills root/,
|
||||
);
|
||||
|
||||
const symlinkedDir = path.join(fixtureRoot, "skills", "symlinked");
|
||||
const outsideDir = path.join(fixtureRoot, "outside-symlink");
|
||||
fs.mkdirSync(symlinkedDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outsideDir, "secret.md"), "# secret\n", "utf8");
|
||||
fs.symlinkSync(
|
||||
path.join(outsideDir, "secret.md"),
|
||||
path.join(symlinkedDir, "SKILL.md"),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
loadSkillBodies(fixtureRoot, [
|
||||
{ id: "symlinked", path: "skills/symlinked", name: "symlinked" },
|
||||
]),
|
||||
/symlink|outside the skills root|regular file/i,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ assert.strictEqual(
|
||||
"tracked Python bytecode should not ship in skill directories",
|
||||
);
|
||||
assert.match(syncRecommended, /cp -RP/, "recommended skills sync should preserve symlinks instead of dereferencing them");
|
||||
assert.doesNotMatch(syncRecommended, /for item in \*\/; do\s+rm -rf "\$item"/, "recommended skills sync must not delete matched paths via naive glob iteration");
|
||||
assert.match(syncRecommended, /readlink|test -L|find .* -type d/, "recommended skills sync should explicitly avoid following directory symlinks during cleanup");
|
||||
assert.doesNotMatch(alphaVantage, /--- Unknown/, "alpha-vantage frontmatter should not contain malformed delimiters");
|
||||
|
||||
@@ -8,12 +8,15 @@ const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
const TOOL_SCRIPTS = path.join("tools", "scripts");
|
||||
const TOOL_TESTS = path.join(TOOL_SCRIPTS, "tests");
|
||||
const LOCAL_TEST_COMMANDS = [
|
||||
[path.join(TOOL_TESTS, "activate_skills_batch_security.test.js")],
|
||||
[path.join(TOOL_TESTS, "claude_plugin_marketplace.test.js")],
|
||||
[path.join(TOOL_TESTS, "jetski_gemini_loader.test.js")],
|
||||
[path.join(TOOL_TESTS, "npm_package_contents.test.js")],
|
||||
[path.join(TOOL_TESTS, "validate_skills_headings.test.js")],
|
||||
[path.join(TOOL_TESTS, "workflow_contracts.test.js")],
|
||||
[path.join(TOOL_TESTS, "docs_security_content.test.js")],
|
||||
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_bundle_activation_security.py")],
|
||||
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_sync_microsoft_skills_security.py")],
|
||||
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_validate_skills_headings.py")],
|
||||
];
|
||||
const NETWORK_TEST_COMMANDS = [
|
||||
|
||||
60
tools/scripts/tests/test_bundle_activation_security.py
Normal file
60
tools/scripts/tests/test_bundle_activation_security.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
TOOLS_SCRIPTS = REPO_ROOT / "tools" / "scripts"
|
||||
|
||||
|
||||
def load_module(module_path: pathlib.Path, module_name: str):
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
get_bundle_skills = load_module(
|
||||
TOOLS_SCRIPTS / "get-bundle-skills.py",
|
||||
"get_bundle_skills",
|
||||
)
|
||||
|
||||
|
||||
class BundleActivationSecurityTests(unittest.TestCase):
|
||||
def test_format_skills_for_batch_emits_newline_delimited_safe_ids(self):
|
||||
formatted = get_bundle_skills.format_skills_for_batch([
|
||||
"safe-skill",
|
||||
"nested.skill_2",
|
||||
"unsafe&calc",
|
||||
"another|bad",
|
||||
])
|
||||
|
||||
self.assertEqual(formatted, "safe-skill\nnested.skill_2\n")
|
||||
|
||||
def test_get_bundle_skills_rejects_unsafe_bundle_entries(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
bundles_path = pathlib.Path(temp_dir) / "bundles.md"
|
||||
bundles_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"### Essentials",
|
||||
"- [`safe-skill`](../../skills/safe-skill/)",
|
||||
"- [`unsafe&calc`](../../skills/unsafe/)",
|
||||
"- [`safe_two`](../../skills/safe_two/)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
skills = get_bundle_skills.get_bundle_skills(
|
||||
["Essentials"],
|
||||
bundles_path=bundles_path,
|
||||
)
|
||||
|
||||
self.assertEqual(skills, ["safe-skill", "safe_two"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -41,6 +41,60 @@ class SyncMicrosoftSkillsSecurityTests(unittest.TestCase):
|
||||
child.unlink()
|
||||
outside.rmdir()
|
||||
|
||||
def test_find_github_skills_ignores_symlinked_directories(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
github_skills = root / ".github" / "skills"
|
||||
github_skills.mkdir(parents=True)
|
||||
|
||||
safe_skill = github_skills / "safe-skill"
|
||||
safe_skill.mkdir()
|
||||
(safe_skill / "SKILL.md").write_text("---\nname: safe-skill\n---\n", encoding="utf-8")
|
||||
|
||||
outside = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
escaped = outside / "escaped-skill"
|
||||
escaped.mkdir()
|
||||
(escaped / "SKILL.md").write_text("---\nname: escaped\n---\n", encoding="utf-8")
|
||||
(github_skills / "escape").symlink_to(escaped, target_is_directory=True)
|
||||
|
||||
entries = sms.find_github_skills(root, set())
|
||||
relative_paths = {str(entry["relative_path"]) for entry in entries}
|
||||
|
||||
self.assertEqual(relative_paths, {".github/skills/safe-skill"})
|
||||
finally:
|
||||
for child in escaped.iterdir():
|
||||
child.unlink()
|
||||
escaped.rmdir()
|
||||
outside.rmdir()
|
||||
|
||||
def test_find_github_skills_ignores_symlinked_skill_markdown(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
github_skills = root / ".github" / "skills"
|
||||
github_skills.mkdir(parents=True)
|
||||
|
||||
safe_skill = github_skills / "safe-skill"
|
||||
safe_skill.mkdir()
|
||||
(safe_skill / "SKILL.md").write_text("---\nname: safe-skill\n---\n", encoding="utf-8")
|
||||
|
||||
linked_skill = github_skills / "linked-skill"
|
||||
linked_skill.mkdir()
|
||||
|
||||
outside = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
target = outside / "SKILL.md"
|
||||
target.write_text("---\nname: escaped\n---\n", encoding="utf-8")
|
||||
(linked_skill / "SKILL.md").symlink_to(target)
|
||||
|
||||
entries = sms.find_github_skills(root, set())
|
||||
relative_paths = {str(entry["relative_path"]) for entry in entries}
|
||||
|
||||
self.assertEqual(relative_paths, {".github/skills/safe-skill"})
|
||||
finally:
|
||||
target.unlink()
|
||||
outside.rmdir()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user