diff --git a/README.md b/README.md index 039da2429c..490dd4fd97 100644 --- a/README.md +++ b/README.md @@ -1296,6 +1296,7 @@ If a trusted operator manages the configured skills directory through an externa Skill installs and agent-managed skill edits run through **SkillScan**, a native deterministic safety scanner before the LLM-based skill scanner. Phase 1 runs offline with no Semgrep/OpenGrep dependency, blocks high-confidence `CRITICAL` findings such as private keys or shell execution, and passes warning findings to the LLM scanner for contextual review. Code files (anything under `scripts/`, a script suffix such as `.py`, `.sh`, or `.js`, or an extensionless file starting with `#!`) that are not NUL-free UTF-8 text raise a warning and are still analyzed over a lossy decode, so a single stray byte cannot hide them from `CRITICAL` checks. The moderation adapter normalizes both plain-text model responses and LangChain Responses API text blocks before parsing the required JSON decision. Python instance-client exfiltration checks follow a minimal same-scope evidence chain: a simple name bound to a known client constructor, optional name-to-name aliases, and an actual outbound method or context-manager use supported by that constructor. Constructor roots must be proven imports; bare canonical-looking names are not inferred as modules. Nested scopes do not inherit client handles and inherit only constructor import aliases that are never rebound in the enclosing scope. Comprehensions, walrus-bearing statements, annotations, complex binding targets, unsupported operations, and ambiguous branch flows produce no finding from this signal; skipped constructs conservatively invalidate every name they may bind so stale client state cannot create a finding. A deterministic work budget or recursion limit reached by this best-effort analysis does not discard findings already collected for the file. Set `skill_scan.enabled: false` in `config.yaml` to disable only the deterministic analyzers; safe archive extraction and the LLM scanner still run. +Windows scripts (`.bat`, `.cmd`, `.ps1`, `.psm1`, `.js`, `.jse`, `.vbs`, `.vbe`, `.wsf`), HTML applications (`.hta`), and scriptlets (`.sct`) count as code even outside `scripts/`, regardless of filename case. They receive both SkillScan analysis and the installer's executable-code policy. SkillScan warns about remote downloads piped into common shells, including sudo, interpreter paths, and shell line continuations. Pipes to non-shell tools such as `jq` and `tee` do not trigger this warning. SkillScan treats HTTP hostnames case-insensitively and recognizes bracketed IPv6 diff --git a/backend/packages/harness/deerflow/skills/package_files.py b/backend/packages/harness/deerflow/skills/package_files.py index 3d6d91a77e..d6511a3050 100644 --- a/backend/packages/harness/deerflow/skills/package_files.py +++ b/backend/packages/harness/deerflow/skills/package_files.py @@ -8,7 +8,7 @@ from __future__ import annotations from pathlib import PurePath, PurePosixPath -CODE_SUFFIXES = frozenset({".bash", ".cjs", ".js", ".mjs", ".php", ".pl", ".ps1", ".py", ".rb", ".sh", ".ts", ".zsh"}) +CODE_SUFFIXES = frozenset({".bash", ".bat", ".cjs", ".cmd", ".hta", ".js", ".jse", ".mjs", ".php", ".pl", ".ps1", ".psm1", ".py", ".rb", ".sct", ".sh", ".ts", ".vbe", ".vbs", ".wsf", ".zsh"}) # Full magics per variant — a shorter shared prefix would also match # non-executable data files. _EXECUTABLE_MAGIC_PREFIXES = ( diff --git a/backend/tests/test_skill_package_paths.py b/backend/tests/test_skill_package_paths.py index c30acfee4f..043d467245 100644 --- a/backend/tests/test_skill_package_paths.py +++ b/backend/tests/test_skill_package_paths.py @@ -91,3 +91,38 @@ def test_skill_md_outside_eval_fixtures_is_still_reported(tmp_path: Path) -> Non assert [(finding["rule_id"], finding["severity"]) for finding in result["findings"]] == [("package-nested-skill-md", "CRITICAL")] assert result["blocked"] is True + + +_WINDOWS_SCRIPT_PATHS = ( + "hooks/install.bat", + "hooks/install.cmd", + "hooks/install.vbs", + "hooks/install.wsf", + "hooks/module.psm1", + "hooks/install.jse", + "hooks/install.vbe", + "assets/setup.hta", + "policy/install.sct", + "hooks/install.JSE", + "hooks/install.VBE", + "assets/setup.HTA", + "policy/install.SCT", +) +_STRAY_BYTES = (("invalid-utf8", b"\xff"), ("nul", b"\x00")) +_SECRET_ASSIGNMENT = b'api_key = "sk-live-9f3c77a1b0e4"' + + +@pytest.mark.parametrize("rel_path", _WINDOWS_SCRIPT_PATHS) +@pytest.mark.parametrize(("marker", "marker_bytes"), _STRAY_BYTES) +def test_windows_script_with_one_stray_byte_is_still_analyzed(tmp_path: Path, marker: str, marker_bytes: bytes, rel_path: str) -> None: + """Windows scripts, HTML applications and scriptlets must stay visible despite a stray byte.""" + root = tmp_path / "pkg" + root.mkdir(parents=True, exist_ok=True) + (root / "SKILL.md").write_text(_MINIMAL_SKILL_MD, encoding="utf-8") + script = root / rel_path + script.parent.mkdir(parents=True, exist_ok=True) + script.write_bytes(marker_bytes + _SECRET_ASSIGNMENT + b"\r\n") + + findings = sorted((finding["rule_id"], finding["severity"]) for finding in scan_skill_dir(root)["findings"] if finding["file"] == rel_path) + + assert findings == [("package-undecodable-script", "HIGH"), ("secret-env-assignment", "HIGH")] diff --git a/backend/tests/test_skills_installer.py b/backend/tests/test_skills_installer.py index a86cae2d4a..25521087bf 100644 --- a/backend/tests/test_skills_installer.py +++ b/backend/tests/test_skills_installer.py @@ -105,6 +105,20 @@ class TestCodeFileClassification: ("bin/notes.txt", b"#!/bin/sh\n", False), ("bin/scripts", b"echo", False), ("assets/logo.png", b"\x89PNG", False), + ("hooks/install.bat", b"@echo off\r\n", True), + ("hooks/install.CMD", b"@echo off\r\n", True), + ("hooks/install.vbs", b'CreateObject("WScript.Shell")\r\n', True), + ("hooks/install.wsf", b"\r\n", True), + ("hooks/module.psm1", b"Export-ModuleMember -Function *", True), + ("hooks/install.jse", b"plain", True), + ("hooks/install.JSE", b"plain", True), + ("hooks/install.vbe", b"plain", True), + ("hooks/install.VBE", b"plain", True), + ("assets/setup.hta", b"", True), + ("assets/setup.HTA", b"", True), + ("policy/install.sct", b"", True), + ("policy/install.SCT", b"", True), + ("references/notes.txt", b"plain", False), ], ) def test_installer_applies_the_shared_code_file_rule(self, tmp_path, rel_path, content, expected): @@ -571,17 +585,20 @@ class TestInstallSkillFromArchive: assert sniffed == ["tool"] - def test_code_file_outside_scripts_warn_prevents_install(self, tmp_path, monkeypatch): + @pytest.mark.parametrize("rel_path", ["lib/run.py", "hooks/install.jse", "hooks/install.vbe", "assets/setup.hta", "policy/install.sct"]) + def test_code_file_outside_scripts_warn_prevents_install(self, tmp_path, monkeypatch, rel_path): zip_path = tmp_path / "test-skill.skill" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n") # Benign payload on purpose: the native scanner must stay quiet so the # test exercises the LLM executable policy (warn != allow) on its own. - zf.writestr("test-skill/lib/run.py", "print('needs human review')\n") + zf.writestr(f"test-skill/{rel_path}", "print('needs human review')\n") skills_root = tmp_path / "skills" skills_root.mkdir() + calls = [] async def _scan(*args, executable, **kwargs): + calls.append((kwargs["location"], executable)) if executable: return ScanResult(decision="warn", reason="code needs review") return ScanResult(decision="allow", reason="ok") @@ -591,6 +608,7 @@ class TestInstallSkillFromArchive: with pytest.raises(SkillSecurityScanError, match="rejected executable.*code needs review"): get_or_new_skill_storage(skills_path=skills_root).install_skill_from_archive(zip_path) + assert (f"test-skill/{rel_path}", True) in calls assert not (skills_root / "custom" / "test-skill").exists() def test_executable_binary_prevents_install(self, tmp_path):