From 4f56420d200fffe8c240bcfadcbce5f825350982 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:22:05 +0000 Subject: [PATCH 1/2] fix(scripts): stop truncating content dumps by default A skill flagged by PERM-001 matched on a line past the 4,000-char excerpt, so the visible portion produced no finding at all and the verdict looked inexplicable. A diagnostic that hides the evidence it exists to surface is worse than none. Full file by default; MAX_CHARS trims deliberately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNoTXU8k3pfSBzR7aJubqL --- scripts/inspect-detail-shape.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/inspect-detail-shape.py b/scripts/inspect-detail-shape.py index cd3ca43..52cd2b3 100755 --- a/scripts/inspect-detail-shape.py +++ b/scripts/inspect-detail-shape.py @@ -23,6 +23,7 @@ import asyncio import json +import os import sys import httpx @@ -138,7 +139,7 @@ async def survey(slugs: list[str]) -> None: ) -async def dump_content(slugs: list[str], max_chars: int = 4000) -> None: +async def dump_content(slugs: list[str], max_chars: int | None = None) -> None: """Print each skill's SKILL.md so a verdict can be checked against the file. An LLM second opinion agreeing with a rule is not ground truth; both can be @@ -149,6 +150,12 @@ async def dump_content(slugs: list[str], max_chars: int = 4000) -> None: The registry is public, so this prints content that is already published. Truncated per skill to keep a cohort readable in one job log. """ + # Truncation hid the evidence once already: a skill was flagged on a line + # that fell outside the excerpt, so the visible portion produced no match + # and the finding looked unexplainable. Default to the whole file and let + # the caller trim deliberately. + if max_chars is None: + max_chars = int(os.environ.get("MAX_CHARS", "0")) or 10**9 async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client: for slug in slugs: print(f"\n{'=' * 70}\n{slug}\n{'=' * 70}", flush=True) From a8d1310c22ce2e84f9828a1102a3af09af9de221 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:36:16 +0000 Subject: [PATCH 2/2] fix(rules): PERM-001 convicted a skill for a flag it was warning about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MALWAR-PERM-001 matched the bare token `--yolo` anywhere on a line. A live skill -- a Chinese HR resume-grading tool with no permission behaviour at all -- was rated MALICIOUS at risk 100 on this single line, in its deployment notes: 扫描 verdict 达到 CAUTION 且为 community source 时会 BLOCKED,需 `--yolo` 绕过或修复 "When the scan verdict hits CAUTION on a community source it gets BLOCKED; you need --yolo to bypass, or fix it." The author is documenting a registry publishing gate, not instructing an agent. This is the same defect as MULTI-001 reading consent as evasion: a technique discussed is not a technique performed, and I shipped it again one rule later. Bypass-flag matches now require an execution context: inside a fenced code block, or reached from a command name or imperative on the same line without crossing sentence-ending punctuation (full-width stops included, since much of the registry is Chinese). Negation before the flag disarms it, because "Never run with --yolo" is advice and parses identically to an instruction -- and only text *before* the flag counts, so "Run claude --flag to avoid prompts" still fires. The existing test asserted "Run the agent with --dangerously-skip-permissions" fires, which the first version of this gate broke because "the agent" is not a command name; imperatives are matched for exactly that case. Verified against 22 cases in both directions, the benign corpus (unchanged), and the malicious control (still MALICIOUS at 100). The vendor connector fleet that motivated the rule still reads SUSPICIOUS 52 on its allowlist edit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNoTXU8k3pfSBzR7aJubqL --- .../rule_engine/rules/permission_scope.py | 58 ++++++++++++++++++- tests/unit/detectors/test_rule_engine.py | 46 +++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/malwar/detectors/rule_engine/rules/permission_scope.py b/src/malwar/detectors/rule_engine/rules/permission_scope.py index 3f564ab..498dfcc 100644 --- a/src/malwar/detectors/rule_engine/rules/permission_scope.py +++ b/src/malwar/detectors/rule_engine/rules/permission_scope.py @@ -45,7 +45,37 @@ class PermissionScopeExpansion(BaseRule): "priming a confirmation so later actions run unprompted" ) - PATTERNS: list[tuple[re.Pattern[str], str]] = [ + # A fenced code block is something the agent runs; prose is something the + # author is talking about. Distinguishing them is the difference between + # "pass --yolo" and "the scanner blocks this unless you pass --yolo", + # which is a sentence from a real skill's deployment-notes section that + # this rule convicted as MALICIOUS. + _FENCE = re.compile(r"^\s*(?:```|~~~)") + # An invocation written inline in prose ("Run claude --flag ..."). The + # command name must reach the flag without crossing sentence-ending + # punctuation, so a tool named in one clause cannot lend authority to a + # flag merely discussed in the next. Full-width stops are included because + # much of the registry is written in Chinese. + _COMMAND_CONTEXT = re.compile( + r"\b(?:claude|openclaw|codex|aider|npx|npm|pnpm|yarn|node|deno|bun|uv" + r"|python3?|bash|sh|zsh" + # Imperatives, so "Run the agent with --flag" still counts even though + # "the agent" is not a command name. "use" is deliberately absent: it + # appears far more often in "this skill never uses --flag". + r"|run|execute|start|launch|invoke|pass|append|add)" + r"\b[^.。!?!?\n]*?--", # noqa: RUF001 - CJK stops are real input + re.IGNORECASE, + ) + # Documentation that warns *against* a flag reads exactly like an + # instruction to use it, so a negation anywhere before the flag disarms + # the context. "Never run with --yolo" is advice, not a setup step. + _NEGATED = re.compile( + r"\b(?:never|not|n't|without|avoid|refrain)\b|不要|禁止|切勿|请勿", + re.IGNORECASE, + ) + + # (pattern, what it means, requires an execution context) + PATTERNS: list[tuple[re.Pattern[str], str, bool]] = [ # Editing the agent's tool allowlist / permission config. ( re.compile( @@ -54,6 +84,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "writes to the agent's tool allowlist", + False, ), ( re.compile( @@ -61,6 +92,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "sets an allowlist or auto-approve key", + False, ), # Approval / sandbox settings turned off in a config file. The skill # that motivated this rule instructs the user to set @@ -76,6 +108,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "sets the approval policy to never ask", + False, ), ( re.compile( @@ -84,6 +117,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "disables or fully opens the sandbox", + False, ), # Explicit approval bypass flags. ( @@ -93,6 +127,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "passes an approval-bypass flag", + True, ), # Turning confirmation off in prose or config. ( @@ -102,6 +137,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "disables confirmation prompts", + False, ), # Priming consent: run something harmless first so the approval is # already spent when it matters. The tell is an explicit claim that @@ -115,6 +151,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "primes an approval so later actions run unprompted", + False, ), # Same idea expressed as establishing durable trust. ( @@ -124,6 +161,7 @@ class PermissionScopeExpansion(BaseRule): re.IGNORECASE, ), "establishes standing trust to avoid later prompts", + False, ), # A large share of the registry is written in Chinese, and the skill # that prompted this rule is entirely so. English-only patterns would @@ -135,17 +173,33 @@ class PermissionScopeExpansion(BaseRule): ( re.compile(r"无需(?:再次)?确认|免确认|跳过确认|跳过权限|自动(?:批准|确认|授权)"), "states that later actions need no confirmation (zh)", + False, ), ( re.compile(r"建立信任[^。\n]{0,40}(?:无需|不用|不需)"), "establishes standing trust to avoid later prompts (zh)", + False, ), ] def check(self, skill: SkillContent) -> list[Finding]: findings: list[Finding] = [] + in_fence = False for line_num, line in enumerate(skill.raw_content.splitlines(), 1): - for pattern, what in self.PATTERNS: + if self._FENCE.match(line): + in_fence = not in_fence + continue + # Only text *before* the flag can negate it. "Run claude --flag to + # avoid prompts" is an instruction whose tail happens to contain a + # negative word; "Never run with --flag" is not. + prefix = line.split("--", 1)[0] + executable = in_fence or ( + bool(self._COMMAND_CONTEXT.search(line)) + and not self._NEGATED.search(prefix) + ) + for pattern, what, needs_execution in self.PATTERNS: + if needs_execution and not executable: + continue if pattern.search(line): findings.append(Finding( id=f"{self.rule_id}-L{line_num}", diff --git a/tests/unit/detectors/test_rule_engine.py b/tests/unit/detectors/test_rule_engine.py index 6e46762..1b9c219 100644 --- a/tests/unit/detectors/test_rule_engine.py +++ b/tests/unit/detectors/test_rule_engine.py @@ -1180,6 +1180,52 @@ def test_detects_approval_and_sandbox_disabled_in_config(self, rule_instance): ): assert len(rule_instance.check(_make_skill(text))) >= 1, text + def test_bypass_flag_only_counts_when_actually_invoked(self, rule_instance): + # The line below is copied from a live skill's deployment-notes section. + # It explains that the registry's own scanner blocks community uploads + # at CAUTION and that --yolo bypasses it. The rule matched the bare + # token and convicted an HR resume-grading skill as MALICIOUS at risk + # 100 -- the same defect as MULTI-001 reading consent as evasion: a + # technique *discussed* is not a technique performed. + discussed = ( + "- 扫描 verdict 达到 CAUTION 且为 community source 时会 BLOCKED," # noqa: RUF001 + "需 `--yolo` 绕过或修复" + ) + assert rule_instance.check(_make_skill(discussed)) == [] + for text in ( + "If the scanner blocks you, --yolo will bypass it, or just fix the finding.", + "This skill never uses --dangerously-skip-permissions.", + "Claude Code will prompt you. The --yolo flag exists but we do not use it.", + ): + assert rule_instance.check(_make_skill(text)) == [], text + + def test_warning_against_a_flag_is_not_an_instruction(self, rule_instance): + # Security guidance reads syntactically like a setup step. Only the + # text *before* the flag can negate it, so an instruction whose tail + # happens to say "to avoid prompts" still counts. + for text in ( + "Never run the agent with --yolo.", + "Do not pass --dangerously-skip-permissions.", + "Avoid running with --yolo in production.", + "切勿使用 --yolo 运行", + ): + assert rule_instance.check(_make_skill(text)) == [], text + assert len(rule_instance.check(_make_skill( + "Run claude --dangerously-skip-permissions to avoid prompts." + ))) >= 1 + + def test_bypass_flag_in_a_command_still_fires(self, rule_instance): + # The gate must not cost real detections: a flag inside a code fence, + # or reached from a command name without crossing a sentence boundary, + # is an instruction to run it. + for text in ( + "Run claude --dangerously-skip-permissions to avoid prompts.", + "```bash\nclaude --dangerously-skip-permissions\n```", + "```\nopenclaw --yolo start\n```", + "`npx agent --allow-all-tools`", + ): + assert len(rule_instance.check(_make_skill(text))) >= 1, text + def test_restrictive_settings_are_not_a_finding(self, rule_instance): # The same keys set to safe values, and a skill narrowing its own tool # scope, are the behaviour we want and must never fire.