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:
- 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.
- 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.
How to run it
Section titled “How to run it”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:
python -m pytest tests/security/ -k invisible-charThe 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.
What the corpus is made of
Section titled “What the corpus is made of”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.
The six attack classes
Section titled “The six attack classes”| Class | The attack | The control that must refuse it |
|---|---|---|
archive | Zip-slip and absolute-path entries, traversal hidden mid-path (assets/../../..), and a case-collision pair (setup.sh / SETUP.SH) whose surviving twin differs by filesystem | marketplace.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-race | A 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 install | marketplace.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-evasion | Whitespace 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 floor | supply_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-char | A bidi override in manifest prose and in a script; a zero-width space inside rm that takes the pattern rules out of play | supply_chain.py::SkillScanner._scan_invisible — bidi is DANGEROUS, zero-width is WARNING, and a community install must not proceed on the warning unconfirmed |
degenerate-manifest | A 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 all | SkillScanner.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-tamper | The 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 once | security.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:
- Detected —
verify_baseline_denylist()reportsfile_verified: false, or the real_read_packaged_baseline()raises. - Audited — the SEL carries exactly one
baseline_denylist_tamper_attempt(orbaseline_denylist_reassertedfor the self-heal case) with the expectedmetadata.reason. A tamper that raises but is never logged is invisible. - Still enforcing — the commands the case names in
variants[]are still refused bydenied_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.
Proving the corpus is load-bearing
Section titled “Proving the corpus is load-bearing”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 this | And this reds |
|---|---|
Drop destructive_root from supply_chain.py::_DANGEROUS_SCRIPT | verdict-evasion/destructive-root-variants |
Empty supply_chain.py::_INVISIBLE_CHARS | invisible-char/bidi-override-in-manifest |
Make marketplace.py::_validate_skill_md return no errors | degenerate-manifest/missing-frontmatter |
Make marketplace.py::_stage_files skip an unsafe path instead of raising | archive/zip-slip-parent-escape |
| Have the commit write anything other than the scanned bytes | integrity-race/refetch-swap |
Rebind shell_denylist.py::_BASELINE_SHA256 to the tampered file’s own digest | baseline-tamper/self-consistent-file-rewrite |
Make shell_denylist.py::_note_baseline_tamper always return False | baseline-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:
| Clause | What must hold | What it closes |
|---|---|---|
| L1 literal, not code | The file parses as Python and every match of this rule lies wholly inside a str constant | A 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 sink | The 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 program | The 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 export | The 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 it | Code 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 trustworthy | No 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 bundle | L3 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-op | The module’s top level calls nothing outside a small pure allowlist | The 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:
| Clause | What must hold | What it closes |
|---|---|---|
| R not run by the app | L1 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:
git -C /path/to/PersonalClawApps fetch origin mainPYTHONPATH=src:. python tools/measure_scanner_scope.py \ /path/to/PersonalClawApps --ref origin/mainThe 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.
Proving each clause is load-bearing
Section titled “Proving each clause is load-bearing”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 this | And this reds |
|---|---|
Replace supply_chain.py::_scope_by_reachability with the identity | the two blocked bundle shapes become uninstallable again |
Widen L1’s str_spans to the whole file | payload in a comment, which is not rescued |
Clear L2’s _FileFacts.sinks | payload built at runtime and then executed, and payload passed straight to os.system |
Stub L3’s _BundleReach._referenced_elsewhere to None | inert 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.dynamic | inert payload module in a bundle that imports importlib |
Clear L5’s _FileFacts.top_level_calls | inert-looking payload module that runs code on import |
| Report a parse failure as a parsed file with a whole-file span | the unparseable-file rail |
Force _BundleReach.decide to answer UNREACHABLE | every 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:
| Rule | The claim it makes |
|---|---|
destructive_delete | A 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_walk | An 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_truncate | A 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.
Proving the native rules are load-bearing
Section titled “Proving the native rules are load-bearing”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 this | And this reds |
|---|---|
_scan_native_destruction returns [] | every native payload |
Empty _HOME_LITERALS / _HOME_ENV_KEYS | the ~ and the os.environ["HOME"] payloads |
Drop the _DRIVE_ROOT_RE check | the bare-C:\ payload |
Stop consulting _SYSTEM_TREES | the truncating write and the /usr removal |
Stop consulting _TRANSIENT_PATHS | the precision floor — open("/dev/null", "w") becomes terminal |
Empty _DELETE_FUNCS / _DELETE_METHODS / _WALK_METHODS / _OPEN_FUNCS | the 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 None | every native payload |
Stop unwrapping _PATH_PASSTHROUGH | every payload that spells its target as Path("/") |
Stop resolving .. segments | the /tmp/.. escape |
Match _longest_root without a path boundary | the precision floor — /optimized/report.txt becomes terminal |
_truncating_mode returns True | the precision floor — reading and appending become terminal |
_deletes_within returns True | the precision floor — walking the root and printing becomes terminal |
Force the .glob branch’s ** check true | the 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 entirely | the 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 family | every native payload becomes a consentable warning |
| Add a native rule without a gloss | the 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.
A warning rule does not report commentary
Section titled “A warning rule does not report commentary”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.
Residual risks
Section titled “Residual risks”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_execused to read Ruby’s and Perl’sexec(by accident, and never theirsystem(or backticks. The shell band andcurl_networkstill 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 meansshutil.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 strictxfail:os.system("rm -" + "rf /")andsubprocess.run(["rm", "-rf", "/"]). The native rules do not reach them, and that is not a technicality — the destructive call is the spawnedrm’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
WARNINGwhere it previously gotDANGEROUS. 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: theDANGEROUSband is a literal matcher, so the same attacker already getscleanfromP = "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
DANGEROUStoWARNING. A zero-width space insidermdefeats 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, soforce=Trueinstalls it — and on a source the owner has declaredofficialortrusted,_aggregatelowers that warning tolowand the install proceeds. The corpus asserts the refusal and the forced install, and checks that.pclaw-lock.jsonrecordsverdict: 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.jsonand nothing more.
Adding a case
Section titled “Adding a case”- Drop a JSON file in
tests/security/corpus/<class>/. Required keys:id(prefixed with the class),class,summary,expect, and eitherfilesorvariants. - Point
expectat an existing handler, or write a new one and register it inHANDLERS. An unregisteredexpectreds — deliberately. - Run
python -m pytest tests/security/. - 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.