Skip to content

feat: disarm scan — the one API built for scanning, pointed at files (#704) - #944

Merged
raeq merged 2 commits into
mainfrom
feat/704-scan
Sep 3, 2026
Merged

feat: disarm scan — the one API built for scanning, pointed at files (#704)#944
raeq merged 2 commits into
mainfrom
feat/704-scan

Conversation

@raeq

@raeq raeq commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

inspect_anomalies has always returned everything a scanner needs — a kind, a span, evidence and a plain-language reason — and there was no way to run it over a file. Every CLI subcommand took text from an argument or stdin; none took a path.

$ disarm scan src/ --fail
src/auth.py:41:17: bidi: "user\u{202e}gpj.exe" contains a bidirectional control character (U+202E)
scanned 212 file(s), 1 finding(s)
$ echo $?
1

Python rather than a Rust binary — #704's own recommendation: plumbing over an API that already exists ships now and can be measured against real repositories before anyone commits to a binary per tag.

The four rules, each asserted

git's ignore rules, all three sources. .gitignore in the scanned directory and every parent, .git/info/exclude, and core.excludesFile. A scanner reading only the nearest file makes scan src/ and scan . disagree on one tree. This delegates to git check-ignore rather than reimplementing the rule, so the two cannot disagree by construction. A test scans the same tree from the root and from a subdirectory and asserts they agree.

A defensible skip list. node_modules, __pycache__, .venv, .terraform hold no hand-written source. build, dist, out, target, bin, vendor are not skipped — a scanner that skips them by name reports clean on a tree it never read. Both halves parametrized.

Symlinks never followed, directory or file.

Something found ≠ something failed to read. 0 clean or found-without---fail, 1 found with --fail, 3 a path could not be read. 2 stays argparse's — docs/cli.md already assigns it.

What the tests caught

The first version got the read-error code wrong twice:

  • A missing root fell through root.is_file() into os.walk, which yields nothing and raises nothing for a nonexistent path — so a typo'd path scanned cleanly with exit 0.
  • An unlistable subdirectory was swallowed by os.walk's default onerror=None.

Both now surface as 3. Silence looks like clean, and the contract exists so that it doesn't.

--json

Byte spans become 1-based line and character column — what an editor's gutter shows. Pinned with a non-ASCII prefix where byte and character columns diverge (the trap #940's spans set).

Binary, non-UTF-8 and oversize files are skipped without error. Inline suppression is deliberately absent (#704 item 5): nothing to suppress until there is a scanner.

Verification

  • ruff check/format, mypy — clean
  • pytest -n 8 --dist loadfile7,234 passed, 42 skipped (38 new: 31 unit, 7 argv-level)
  • mkdocs build --strict, run_doc_tests.py 41/41 — clean
  • Real subprocess smoke: --help, --fail → 1, missing path → stderr + 3

Closes #704

🤖 Generated with Claude Code

…704)

`inspect_anomalies` has always returned everything a scanner needs: a kind, a
span, evidence and a plain-language reason. There was no way to run it over a
file. Every CLI subcommand took text from an argument or stdin; none took a
path. That single absence is most of why third-party tools in this space exist
as separate projects rather than thin wrappers: the detection is the hard part
and disarm has it, the file plumbing is the easy part and disarm had none of it.

Python rather than a Rust binary, which is #704's own recommendation and its
reasoning: plumbing over an API that already exists ships now and can be
measured against real repositories before anyone commits to a binary per tag.

The four rules #704 takes from `juriku/untrace`, each asserted:

git's ignore rules come from THREE sources — `.gitignore` in the scanned
directory and every parent, `.git/info/exclude`, and `core.excludesFile`. A
scanner reading only the nearest file makes `scan src/` and `scan .` disagree on
one tree, which users report as flakiness. This delegates to `git check-ignore`
rather than reimplementing the rule, so the two cannot disagree. A test scans
the same tree from the root and from a subdirectory and asserts they agree.

The skip list is defensible, not convenient. `node_modules`, `__pycache__`,
`.venv`, `.terraform` hold no hand-written source. `build`, `dist`, `out`,
`target`, `bin` and `vendor` are NOT skipped — hand-written in some projects,
and a scanner that skips them by name reports clean on a tree it never read.
Both halves are parametrized tests.

Symlinks are never followed, directory or file. A directory link could walk out
of the tree or loop; a file link would report a finding against a path whose
content lives somewhere else.

Something found is not something failed to read. Exit 0 clean or found without
`--fail`, 1 found with `--fail`, 3 a path could not be read; 2 stays argparse's,
since `docs/cli.md` already assigns it. The first version got the third code
wrong twice, and the tests caught both: a missing root fell through
`root.is_file()` into `os.walk`, which yields nothing and raises nothing for a
path that does not exist — so a typo'd path "scanned cleanly" with exit 0; and
an unlistable subdirectory was swallowed by `os.walk`'s default `onerror=None`.
Both now surface as unreadable. Silence looks like clean, and the contract
exists so that it does not.

`--json` converts the library's byte spans to 1-based line and CHARACTER column,
which is what an editor's gutter shows. Pinned with a non-ASCII prefix where
byte and character columns diverge — the same trap #940's spans set.

Binary, non-UTF-8 and oversize files are skipped without error: they are not
text, and `decode_to_utf8` is a different job.

Inline suppression is deliberately absent (#704 item 5): there is nothing to
suppress until there is a scanner.

Closes #704

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 3, 2026 14:21
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📄 Docs preview: https://345fe78e.disarm-docs.pages.dev

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The current implementation violates the “never follow symlinks” contract for symlink root paths, and a new test uses os.geteuid() in a way that breaks imports on non-POSIX platforms.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new Python CLI subcommand, disarm scan, that walks files/directories and runs inspect_anomalies over UTF-8 text files to surface bidi/invisible/smuggling findings with a stable location (1-based line + character column) and a CI-friendly exit-code contract.

Changes:

  • Introduces python/disarm/scan.py implementing file iteration (gitignore-aware via git check-ignore), scanning, JSON/plain output, and exit codes (0/1/3).
  • Extends the Python CLI (python/disarm/__main__.py) with scan / sc plus --fail, --json, and --no-gitignore.
  • Adds comprehensive unit + argv-level tests and updates docs/CHANGELOG to document the new command and exit-code behavior.
File summaries
File Description
tests/test_scan.py New unit tests for gitignore behavior, skip list, symlink handling, exit-code contract, and JSON shape.
tests/test_cli.py New subprocess/argv-level coverage for disarm scan behavior and CLI contract.
python/disarm/scan.py New scanning implementation (walk + gitignore delegation + reporting/exit codes).
python/disarm/main.py Registers the scan/sc subcommand and wires flags to disarm.scan.run.
docs/cli.md Documents scan usage, options, and exit codes; updates global exit-code table.
CHANGELOG.md Announces the new disarm scan command and its behavior/contract.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/disarm/scan.py
Comment thread tests/test_scan.py Outdated
)

Copilot review on #944, both real.

The contract says symlinks are never followed, and the walk refused symlinked
entries under a root — but `Path.is_file()` and `is_dir()` resolve links, so a
root that was itself a symlink was followed. `disarm scan <link>` walked
whatever it pointed at, outside the tree it was pointed at. Refused now, and
REPORTED through `on_error` rather than skipped: a root the scanner declined
must not look like a root that scanned clean, which is the same rule the
missing-path and unlistable-directory cases follow. Exit 3.

`os.geteuid()` in a `skipif` expression evaluates at import, and the function
does not exist on Windows — so the test module raised before any test could be
skipped. `hasattr` first; on Windows the test is skipped, which is right, since
`chmod 000` does not lock a directory there either.

Regression test for the root link covers both halves — a link to a directory
and a link to a file — and asserts the refusal is reported, not silent.

Refs #704

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@raeq
raeq merged commit 207ef23 into main Sep 3, 2026
25 checks passed
@raeq
raeq deleted the feat/704-scan branch September 3, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The CLI cannot open a file, so the one API built for scanning cannot be pointed at anything

2 participants