Skip to content

Scanner Testing — the Published Adversarial Corpus

The threat model claims that community content is gated at one chokepoint before it can reach the live skills tree. This document is the evidence for that claim, and it is deliberately public: the corpus advertises exactly how the gate is attacked, which is worth more than the obscurity it costs. A control whose tests nobody can read is a control nobody can audit.

Two things are being tested, and they are not the same thing:

  1. The scanner refuses what it claims to refuse — five attack classes, each with at least one test that fails if the corresponding control stops firing.
  2. The corpus itself is load-bearing — weaken one control and a corpus test must turn red. A suite that stays green against a broken scanner is worse than no suite, because it certifies nothing while looking like it does.

Everything below is reproducible from a clean clone with no credentials, no network, and no vendor SDK.

Terminal window
pip install -e ".[dev]"
python -m pytest tests/security/

This runs in one process, pytest’s default here. The corpus is parallel-safe, so -n auto works too, but one process keeps the integrity-race output readable. To run one class:

Terminal window
python -m pytest tests/security/ -k invisible-char

The corpus also runs unattended: .github/workflows/breadth.yml carries a security-corpus job that executes tests/security/ plus the scanner unit tests on every nightly schedule: run (09:37 UTC) and on workflow_dispatch, and .github/workflows/ci.yml runs the same job on every push to main. It is a separate job so a corpus regression is legible on its own rather than buried in the full-suite matrix.

Cases live in tests/security/corpus/<class>/<case>.json — one directory per attack class, one JSON file per case. The harness is tests/security/test_scanner_adversarial.py.

A case is inert data, never a program. It describes a payload (files[].path + files[].contents, or variants[] of script text) and names, in expect, the refusal being asserted. The harness materializes the payload into a tmp_path staging tree and feeds it to the real gate. Nothing under corpus/ is executable, and TestCorpusIsComplete::test_corpus_payloads_are_inert enforces that — a non-JSON artifact or an executable bit reds CI.

expect is a key into the harness’s HANDLERS table. A case whose expect has no handler is a fixture nobody asserts on, and test_every_case_is_wired_to_an_assertion reds. This is the corpus’s vacuity floor: an emptied class directory, an orphaned fixture, or a class that quietly stopped being exercised fails loudly instead of collecting zero tests and reading like a pass.

ClassThe attackThe control that must refuse it
archiveZip-slip and absolute-path entries, traversal hidden mid-path (assets/../../..), and a case-collision pair (setup.sh / SETUP.SH) whose surviving twin differs by filesystemmarketplace.py::_stage_files and marketplace.py::install_skill_files both reject .. and leading /; the case-collision pair must reach DANGEROUS on a case-sensitive and a case-insensitive filesystem
integrity-raceA source that serves clean bytes to the scan and malicious bytes to a re-fetch; a concurrent writer that rewrites the quarantine directory the moment the scan is done with it; an in-process writer that mutates the payload between scan and commit; bytes edited on disk after a clean installmarketplace.py::install_scanned commits the scanned in-memory payload and never re-fetches; install_skill_files’s per-file scan catches the in-process mutation; marketplace.py::verify_skill_integrity catches the post-install edit against the _write_lock baseline
verdict-evasionWhitespace and flag-order variants of rm -rf /; base64 -d | sh; the read-credentials-then-egress pipeline spread over three lines; trust-tier laundering (the same malware declaring builtin); force=True against the non-overridable floorsupply_chain.py::_scan_script patterns, the proximity heuristic in the same function, and supply_chain.py::SkillScanner._aggregate — which must never downgrade DANGEROUS for any tier
invisible-charA bidi override in manifest prose and in a script; a zero-width space inside rm that takes the pattern rules out of playsupply_chain.py::SkillScanner._scan_invisible — bidi is DANGEROUS, zero-width is WARNING, and a community install must not proceed on the warning unconfirmed
degenerate-manifestA dangerous script padded past the 512 KB the scanner once stopped reading at; frontmatter missing, unclosed, or carrying a name outside the allowed charset; a payload with no SKILL.md at allSkillScanner.scan reads every file a rule reads whole, up to supply_chain.py::_MAX_FILE_BYTES (16 MB), and refuses the padded blob itself (past that cap a file is an unscanned_file finding, never a pass), and the uncapped commit-side gate in install_skill_files refuses it too; marketplace.py::_validate_skill_md rejects the degenerate manifests
baseline-tamperThe packaged command denylist itself: a self-consistent rewrite of baseline_denylist.json (patterns and sha256 both changed), a digest that disagrees with its patterns, an honest digest over zero patterns, an in-process .clear() of the live list, and the no-trusted-source-left state where the live list, the import-time snapshot and the file are all unverifiable at oncesecurity.py::_read_packaged_baseline raises on a bad digest or an empty list; baseline_denied_command_patterns() heals the live list from the verified snapshot and, when nothing verifies, enforces the union rather than shrinking; verify_baseline_denylist() compares the file against the fingerprint captured at import and refuses to adopt a divergent one — every branch writing baseline_denylist_reasserted or baseline_denylist_tamper_attempt to the SEL

The oversize row is the one worth reading twice. The quarantine walk used to skip the blob by size while the commit-side scan refused it, and that second control exists only on the skill path: an app install has none, so the same padding installed an app’s payload unread. A scan that silently skips what gets installed is the defect, whatever the reason for the skip. It skipped by size, and by folder name too (.git, node_modules, __pycache__ and virtualenvs). So the walk now reads everything it is handed, staging decides what that is (supply_chain.NEVER_INSTALLED_NAMES never installs), and the corpus pins the walk refusing the blob on its own as well as the commit-side gate behind it.

baseline-tamper is the odd one out and deliberately so: the other five attack an incoming artifact, this one attacks the denylist that judges it. Added alongside the mode-independence matrix in tests/security/test_mode_independence.py, which proves no approval mode — default, auto, yolo, acceptEdits — and no trust simulator can let a baseline-matched command run. That matrix is worthless if the baseline can be quietly shortened underneath it, so the two ship together.

Every baseline-tamper case asserts the same triple, and all three legs matter:

  1. Detected — verify_baseline_denylist() reports file_verified: false, or the real _read_packaged_baseline() raises.
  2. Audited — the SEL carries exactly one baseline_denylist_tamper_attempt (or baseline_denylist_reasserted for the self-heal case) with the expected metadata.reason. A tamper that raises but is never logged is invisible.
  3. Still enforcing — the commands the case names in variants[] are still refused by denied_command_reason(), and a benign control is still allowed. Without the benign half, a build that refused everything would pass leg 3.

The class also drives the tamper through the real _read_packaged_baseline by rooting shell_denylist.resources at a temp copy of the data file, rather than substituting a reader that re-implements the parse. The installed src/personalclaw/baseline_denylist.json is never written to — the copy lives under tmp_path, and TestBaselineTamperClass::test_the_tamper_never_touches_the_installed_data_file reds if a case ever leaks into the checkout.

The scanned-bytes == installed-bytes invariant

Section titled “The scanned-bytes == installed-bytes invariant”

The sharpest case is not a pattern; it is a swap. If content can change between the moment it is scanned and the moment it is installed, the verdict describes bytes that no longer exist.

install_scanned closes this by construction: it fetches once, stages to a quarantine directory, scans that directory, and then commits the same in-memory payload it staged. It never re-reads the staging tree and never re-fetches.

The corpus proves it rather than restating it. The harness wraps supply_chain.py::scan_dir (which install_scanned resolves at call time), takes a per-file sha256 digest of the staging tree as the scanner sees it, and compares that map to a digest of the installed tree afterwards. The assertion is map equality, not “the install succeeded”. Alongside it, the fetch counter must read exactly 1.

The swap is performed from a second thread, joined so the test stays deterministic; a swap thread that hangs or throws fails the test rather than silently doing nothing. The three windows an attacker could aim at:

  • Re-fetch. The hostile source serves malware to fetch #2. The install never makes fetch #2, so there is nothing to serve.
  • Quarantine rewrite. The attacker rewrites the staging tree right after the scan reads it. The commit does not read the staging tree, so the installed digest still matches the scanned digest.
  • In-memory payload mutation. The attacker mutates the payload list itself between scan and commit — the only window left, and one that requires already executing code in the process. The commit-side per-file scan refuses it, so nothing lands.

TestCorpusRedsOnAWeakenedScanner weakens one control per class in process, via monkeypatch, and asserts the matching corpus rail now fails. The shipped scanner is never modified, so the demonstration is permanent and reproducible rather than a branch someone has to remember to delete.

Each row below is also the recipe for reproducing the same result by hand — edit the file, run the class, watch it red, then restore.

Weaken thisAnd this reds
Drop destructive_root from supply_chain.py::_DANGEROUS_SCRIPTverdict-evasion/destructive-root-variants
Empty supply_chain.py::_INVISIBLE_CHARSinvisible-char/bidi-override-in-manifest
Make marketplace.py::_validate_skill_md return no errorsdegenerate-manifest/missing-frontmatter
Make marketplace.py::_stage_files skip an unsafe path instead of raisingarchive/zip-slip-parent-escape
Have the commit write anything other than the scanned bytesintegrity-race/refetch-swap
Rebind shell_denylist.py::_BASELINE_SHA256 to the tampered file’s own digestbaseline-tamper/self-consistent-file-rewrite
Make shell_denylist.py::_note_baseline_tamper always return Falsebaseline-tamper/snapshot-and-file-both-rebound

The last two are chosen for a reason. The archive weakness is the plausible one — a gate that quietly drops the bad entry and installs the rest, which looks like robustness and is a bypass. And the integrity mutation is not malicious: it appends a comment. Byte equality catches a post-scan substitution even when the substituted bytes would pass the scan, which a verdict-only assertion never could.

The two baseline-tamper rows split detection from audit on purpose. Rebinding the fingerprint is the exact bug the class exists to catch — a module that re-derives its fingerprint from the file it is verifying will happily adopt a self-consistent rewrite. Suppressing _note_baseline_tamper leaves detection and enforcement intact and only removes the audit trail; that is still a failure, so the rail must red on it rather than only on a shrink.

Execution-reachability scoping of the DANGEROUS band

Section titled “Execution-reachability scoping of the DANGEROUS band”

The DANGEROUS rules match a string shape and are blind to whether anything executes that string. A bundle whose test fixture holds a real attack string — the string that proves its input validation refuses the attack — was therefore judged identically to a bundle that runs it. DANGEROUS is terminal and non-consentable, so the fixture permanently blocked the install: two first-party bundles could not be installed at all, while a bundle that never tested its validation shipped clean. The rule as written taught authors to delete their adversarial tests or obfuscate the strings until the scanner stopped recognising them.

supply_chain.py closes that with a second pass that asks a narrower question: can this matched literal be executed at all, anywhere in this bundle — or, failing that, does anything the app runs ever load the file it sits in? Only on a positive answer of no is the finding re-scored — to WARNING, never lower, never dropped, with rule, path, surface and evidence intact, so the user is still told and still consents. A match in a file the app loads is never re-scored.

The predicate is stated on the AST and never on a filename. That is the whole basis of it: “skip test files” would create a place to park a payload, whereas “nothing here can execute this” cannot — parking a payload requires something that executes it. Renaming a payload test_evil.py therefore buys an attacker nothing, and the attack table in tests/security/test_scanner_reachability.py asserts exactly that.

Five conjunctive clauses, default-deny. Fail any one — or fail to evaluate one — and the finding stays DANGEROUS:

ClauseWhat must holdWhat it closes
L1 literal, not codeThe file parses as Python and every match of this rule lies wholly inside a str constantA shell script (its text is its program); a payload in a comment; a second, live occurrence of the same shape that the scanner did not report
L2 no execution sinkThe file contains no os.system/popen/exec*/spawn*/fork, no eval/exec/compile/__import__, no subprocess call whose shell is other than a literal False, and every spawn site’s argv[0] is a literal non-shell, non-interpreter, non-path programThe literal being handed to an interpreter — including after being concatenated into a larger string, which is why the clause is stated at module scope rather than on the literal’s argument position
L3 no exportThe app does not load the file (the runtime-use proof: an import, a dotted import of a package module, app.json, or the name the platform runs), no other module imports it, no sibling names its file or stem in a string, no script or config mentions it, app.json does not declare itCode the app runs holding the literal, however it is loaded — import pkg.payload names the module by its dotted path only, which a stem check never sees — and another file lifting the literal out. Prose is excluded on purpose: a README saying “run pytest test_provider.py” is documentation
L4 the graph is trustworthyNo eval/exec/compile/__import__/importlib/runpy/pickle/marshal/ctypes, no computed getattr, no import *, no sys.path mutation, and no Python or loader file the walk could not read — anywhere in the bundleL3 is a static claim; a bundle that can rewrite its own import graph, or a file nobody could read, makes it worthless
L5 importing it is a no-opThe module’s top level calls nothing outside a small pure allowlistThe payload firing on import — unreachable is not never-imported

L3-L5 ask what any importer could do with the file. An app’s own test fails them as a matter of course — it puts a shared test kit on sys.path and calls helpers when it is imported — while nothing the app runs ever imports it. So one more proof stands beside L1-L5, and it asks the install’s own question, what the app runs:

ClauseWhat must holdWhat it closes
R not run by the appL1 and L2 hold, and the runtime-use proof says nothing the app runs loads the file (unloaded, never untraceable)A refusal fixture in the app’s own tests (x; rm -rf / proving a revision is refused) blocking the install outright

R is exactly as wide as the runtime-use proof, so that proof is held to the same standard. A file is never unloaded when something the app runs could start or load it by a route the analysis cannot follow: a dynamic import, a sys.path edit, a test runner (a hook, a script, or a loaded module that imports pytest, unittest or doctest), a shell, an interpreter (python3.12 included), a launcher or package runner (sudo, nohup, npx, uv, docker…), a path, or an argv the source does not pin. A spawn of git through the SDK’s argv builder is pinned — unless the bundle ships anything an import of personalclaw could load, or stores to one of the platform’s names. A re-scored finding is reported as its own state (Reachability.NOT_RUN), apart from UNREACHABLE and COMMENTARY. The attack table in tests/security/test_scanner_judges_what_the_app_runs.py holds each route.

A file that does not parse is reported as its own outcome (Reachability.UNPARSEABLE), distinct from both REACHABLE and UNREACHABLE, and treated exactly like REACHABLE. “We could not tell” must never render as “safe”. scan_text gets no scoping at all: a bare blob has no bundle around it, so non-reachability cannot be proved and the default-deny answer stands.

Re-measure any checkout of bundles with:

Terminal window
git -C /path/to/PersonalClawApps fetch origin main
PYTHONPATH=src:. python tools/measure_scanner_scope.py \
/path/to/PersonalClawApps --ref origin/main

The command refuses a dirty checkout or a HEAD that does not match the requested ref, then prints the checkout root and full validated commit SHA before the verdict table. Every re-scored finding also prints the clauses that granted it, so neither the source revision nor a downgrade can be taken on trust.

TestRedsOnAWeakenedCheck in tests/security/test_scanner_reachability.py neuters one clause at a time, in process via monkeypatch, and asserts the matching rail fails. Each row is also the recipe for reproducing it by hand.

Weaken thisAnd this reds
Replace supply_chain.py::_scope_by_reachability with the identitythe two blocked bundle shapes become uninstallable again
Widen L1’s str_spans to the whole filepayload in a comment, which is not rescued
Clear L2’s _FileFacts.sinkspayload built at runtime and then executed, and payload passed straight to os.system
Stub L3’s _BundleReach._referenced_elsewhere to Noneinert payload module that a sibling names in a string
Stub _BundleReach.runtime_use to “could not tell” (L3’s runtime half)inert payload in the worker the platform runs by name
Clear L4’s _FileFacts.dynamicinert payload module in a bundle that imports importlib
Clear L5’s _FileFacts.top_level_callsinert-looking payload module that runs code on import
Report a parse failure as a parsed file with a whole-file spanthe unparseable-file rail
Force _BundleReach.decide to answer UNREACHABLEevery verdict-evasion corpus case

The L2 rows are the ones worth reading twice, and they are split for a reason. The simple shape — the literal sitting in the sink’s own argument list — is not the shape that decides the design. The deciding shape is a literal that is never an argument to anything: it is concatenated into a command at runtime and that result is executed. A check that asked “is this literal passed to a sink?” clears that file. Asking instead “can this file hand any string to an interpreter?” does not, which is why the clause is stated at module scope and why both shapes are pinned separately.

The terminal band is not shell-only: native destruction

Section titled “The terminal band is not shell-only: native destruction”

Everything above is about precision — not refusing benign content. The other direction has its own rail, tests/security/test_scanner_recall.py, because nothing asserted the scanner still catches anything and the terminal band could have rotted to nothing with every suite green. It had: destructive_root could not fire inside a quoted string for as long as it had shipped, and the entire band was shell syntax, so destruction written in the language bundles are actually written in scored clean, with zero findings (#2607).

Three rules close that, all at the same non-overridable severity as the shell rules — destruction is destruction, and the language it is spelled in is not the question:

RuleThe claim it makes
destructive_deleteA removal call (shutil.rmtree, os.remove/unlink/rmdir/removedirs, Path.unlink/rmdir) whose target is the filesystem root, the account root, or an OS-owned tree
destructive_walkAn unbounded recursive walk (rglob, glob("**/…"), os.walk, glob.glob(recursive=True)) rooted at one of those, with a removal call inside the loop or comprehension
destructive_truncateA truncating write (open(…, "w"), Path.write_text/write_bytes, Path.open("w"), os.truncate) to an OS-owned path

Two design choices carry the whole family.

Decided on the ast, never on the text. These are call sites, and a text scan cannot tell a call from a mention. shutil.rmtree appears in prose, in a comment warning against it, in a test asserting it is never called, and in denylists of forbidden writers — one shipped bundle carries exactly such a list. ast does not have to guess. Import aliases are resolved, so import shutil as sh and from shutil import rmtree as rt are not ways out, and a name the file binds exactly once is followed one hop, so the two-line home = expanduser("~"); rmtree(home) spelling is not either. A file that does not parse yields nothing here — the text catalogs still judge every byte of it. The family runs on any script surface whose extension does not name another language, so an extension-less scripts/setup is covered and a .js file is not: requiring a .py suffix would itself be a parking spot, and every other _SCRIPT_EXTS member has syntax ast.parse refuses anyway.

The severity turns on the TARGET, not the call. shutil.rmtree is legitimate; all nine of its call sites across the 64 shipped bundles pass a variable holding a staging directory, and refusing the call would refuse them all. So the line is the one the shell band already draws between rm -rf /tmp/build and rm -rf /: root, home and the OS-owned trees are terminal, while /tmp, /var/folders, /dev/null, ~/.cache/… and any target the analysis cannot resolve are not. That last clause is a recall limit, and it is why promoting this class to terminal blocks nothing that ships today.

It goes through the reachability pass, not around it, and the answer differs by direction on purpose. A live call reports reachable with the reason “a call is code” — L0/L1 ask whether the match is commentary or a string the file merely holds, and a call site is neither, so no clause can lower it. The same text commented out or quoted in a docstring produces no finding at all, which is stricter than the consentable WARNING a re-scored regex match earns: ast.parse discards commentary before the rule can see it, so there is nothing left to re-score.

TestRedsOnAWeakenedNativeRule monkeypatches one control at a time in process. The table below is the stronger form — each row was applied to the shipped source with every __pycache__ deleted between runs, because python -B is not sufficient: it stops writing bytecode but still reads a stale cache, and a same-length edit reverted inside one second is masked by (mtime, size) validation. All 24 red.

Weaken thisAnd this reds
_scan_native_destruction returns []every native payload
Empty _HOME_LITERALS / _HOME_ENV_KEYSthe ~ and the os.environ["HOME"] payloads
Drop the _DRIVE_ROOT_RE checkthe bare-C:\ payload
Stop consulting _SYSTEM_TREESthe truncating write and the /usr removal
Stop consulting _TRANSIENT_PATHSthe precision floor — open("/dev/null", "w") becomes terminal
Empty _DELETE_FUNCS / _DELETE_METHODS / _WALK_METHODS / _OPEN_FUNCSthe removal, walk and truncate payloads respectively
_import_aliases returns {}the import shutil as sh and from shutil import rmtree as rt payloads
_single_bindings returns {}the one-line-indirection payload
_classify_path_literal returns Noneevery native payload
Stop unwrapping _PATH_PASSTHROUGHevery payload that spells its target as Path("/")
Stop resolving .. segmentsthe /tmp/.. escape
Match _longest_root without a path boundarythe precision floor — /optimized/report.txt becomes terminal
_truncating_mode returns Truethe precision floor — reading and appending become terminal
_deletes_within returns Truethe precision floor — walking the root and printing becomes terminal
Force the .glob branch’s ** check truethe precision floor — a single-level glob becomes a tree sweep
Let a glob.glob pattern with nothing before its first * fall back to /the precision floor — a relative .pyc cleanup loop becomes terminal
Drop the language gate entirelythe precision floor — a .js file is judged by Python rules
Require a .py SUFFIX rather than “not another language”the extension-less payload — scripts/setup with a python shebang
Force _BundleReach.decide to answer UNREACHABLE for the familyevery native payload becomes a consentable warning
Add a native rule without a glossthe plain-language-gloss rail

Four of those rows exist because something survived. Dropping the drive-root check and forcing the ** check true both survived the whole suite on the first pass, so each is now pinned by a fixture. The relative-glob row was a real false positive found by re-reading the branch: a pattern with nothing before its first * is relative to the working directory, and reading it as / made glob.glob("**/*.pyc", recursive=True) + os.remove(p) terminal. The suffix row is the mirror image — a recall hole rather than a false positive: gating on a .py SUFFIX left scripts/setup with a python shebang unscanned, which is a payload parking spot, so the gate asks “is this some OTHER language?” instead. And one mutation found dead code — a literal set of root spellings POSIX normalisation already reached — which was deleted rather than tested.

A warning rule reads the language it names

Section titled “A warning rule reads the language it names”

The same gate applies to the two WARNING rules that name one language’s API (supply_chain.py::_RULE_LANGUAGE). python_exec reads Python and node_exec reads JavaScript, the child_process module every Node spawn comes from. A file with no suffix, or a bare blob, names no language, so both read it. Before this, python_exec read every script, and a UI bundle’s RegExp.prototype.exec( reached the install consent as “This code runs an external program on your machine.” Rules that describe the same thing in every language (eval_exec, curl_network, sudo_use, pipe_to_shell, crontab_write) keep reading all of them. tests/test_a_scanner_rule_reads_the_language_it_names.py pins both directions.

The WARNING rules describe what code does (“This code reads a file where credentials and keys are kept”), and their regexes read raw text. So a docstring explaining that a read of ~/.aws/credentials is refused reached the install consent as that read, and a first-party channel app showed two such findings. reads_sensitive_path had skipped a line that starts with #, and nothing else; the other warning rules skipped nothing.

supply_chain.py::_scope_warnings_to_code applies L0 to the band: in a Python file, a WARNING script finding whose every match lies in a COMMENT token or a module, class or function docstring is not reported. Those spans come from the tokeniser and the AST, as L0’s do, never from how a line looks, so X = "#" followed by code is code and a bare carriage return ends a line as it does for the interpreter. A match in a string the code holds is not commentary and still counts, a file that does not parse or tokenise keeps every finding, a rule with no pattern to re-run (the invisible-character rules, an unscanned file) is untouched, and a finding that also matches code shows that code as its evidence. It drops rather than lowers one band, unlike L0 on the terminal band: the commented form of a terminal pattern is worth a reviewer’s eye, a warning rule’s commentary is the same fact as the full-line comment reads_sensitive_path always ignored, and it is the answer the native rules already give commentary. A shell script’s text is its program (L1), so its warning rules are unchanged.

What stays: a test fixture that holds an attack string in a literal. Whether a literal is ever run as a command is not decidable from the file (subprocess.run(["curl", url]) runs its "curl"), so a literal counts until the whole-bundle proof above says otherwise, and the consent card groups it by whether the app loads its file. Every rail is in tests/test_the_scanner_does_not_count_commentary_as_behaviour.py: dropping the pass reds the docstring and comment cases, treating a string as commentary reds the literal case, and answering for a file that does not parse reds the unparseable case.

The corpus pins what holds. These are the gaps it also pins, honestly, so they are auditable rather than invisible. They are accepted, not unnoticed.

  • A Ruby, Perl or PowerShell script that starts a program earns no warning of its own. Only Python and JavaScript have a “runs an external program” rule. python_exec used to read Ruby’s and Perl’s exec( by accident, and never their system( or backticks. The shell band and curl_network still read those files, and a shipped bundle has none.

  • A destructive target the analysis cannot resolve is not flagged. The native family classifies a target it can name — a literal, a path wrapper around one, Path.home(), os.environ["HOME"], or a name the file binds exactly once. A target assembled at runtime, read from config, passed in as a parameter, or held in a name the file rebinds resolves to nothing and earns nothing. That is deliberate, and it is the direction that costs recall rather than precision, which is the right way round for a band that blocks installs outright — but it means shutil.rmtree(sys.argv[1]) is not terminal.

  • rm -rf / handed to a shell is still the shell band’s problem. Two spellings stay open and are pinned as strict xfail: os.system("rm -" + "rf /") and subprocess.run(["rm", "-rf", "/"]). The native rules do not reach them, and that is not a technicality — the destructive call is the spawned rm’s, so there is no Python call site naming a target to classify. Closing them wants the same AST treatment applied to the arguments of an execution sink, which is a different rule.

  • An unreferenced module of pure inert data becomes consentable rather than refused. A bundle can ship a file containing nothing but a literal attack string, referenced by nothing, and get WARNING where it previously got DANGEROUS. That is a real reduction in the floor for that one shape, and every route out of the file is closed by a clause and tested — but “nothing in this bundle can reach it” is not “nothing can ever reach it”. The counterweight is measured, not asserted: the DANGEROUS band is a literal matcher, so the same attacker already gets clean from P = "rm -" + "rf / ". The capability given up is refusing the attacker who wrote the payload plainly and left it unreachable and did not obfuscate it — and even they get the finding, the rule id, the evidence, and a required click. Scoping does not address the false-negative side, and should not be read as doing so.

  • R trusts the runtime-use proof. A string in a file nothing the app runs loads is consentable even when that file, run by hand (the bundle’s own test suite), would pass it to another module’s sink — and a way of loading a file the proof does not model would be missed. The same counterweight applies: the plain literal is the only shape the band reads at all. Each route the proof knows it cannot follow keeps the file untraceable, and so keeps the string terminal.

  • Zero-width splitting degrades DANGEROUS to WARNING. A zero-width space inside rm defeats the destructive-root regex; the invisible-codepoint rule still fires, so a community install refuses without an explicit override. But a warning is overridable by design, so force=True installs it — and on a source the owner has declared official or trusted, _aggregate lowers that warning to low and the install proceeds. The corpus asserts the refusal and the forced install, and checks that .pclaw-lock.json records verdict: warning, so the override leaves a trace. Trust tiers are owner-declared provenance; a control that ignored them would make the tier meaningless.

  • A warning-band in-process payload swap. The commit-side scan refuses only DANGEROUS, so an adversary already executing code inside the process could swap in warning-band bytes and break byte equality. This sits below the boundary — an attacker who can mutate a live Python list has no need of the skill installer. See the limitations note on an already-compromised host.

  • Static inspection is not containment. The scanner reads text; it does not run anything in a sandbox. A payload that is dangerous only at runtime, or through a dependency it fetches later, is outside what any pattern gate can see.

  • Binary entries are not scanned. A payload entry carrying data (bytes) has no scannable text; its provenance is the sha256 in .pclaw-lock.json and nothing more.

  1. Drop a JSON file in tests/security/corpus/<class>/. Required keys: id (prefixed with the class), class, summary, expect, and either files or variants.
  2. Point expect at an existing handler, or write a new one and register it in HANDLERS. An unregistered expect reds — deliberately.
  3. Run python -m pytest tests/security/.
  4. If the new case reveals a real bypass, it is a finding: fix the control in the same change, or record it under Residual risks above with the reasoning. A corpus case is never weakened to make the suite green.