Skip to content

feat(download-guard): refuse disk-writing shell downloads when a forwarder is configured - #511

Open
BeiWay1145 wants to merge 1 commit into
T-Auto:mainfrom
BeiWay1145:work/download-guard
Open

BeiWay1145 wants to merge 1 commit into
T-Auto:mainfrom
BeiWay1145:work/download-guard

Conversation

@BeiWay1145

Copy link
Copy Markdown

Closes #506.

What this does

Adds a download-guard row that refuses a shell call which writes a download to
disk, and names the faster downloader the operator configured instead.

The trigger for the issue was a measurement: the same ISO moved at 0.13 MB/s
through a model-invoked curl -o and 11.78 MB/s through a downloader built
for the job — about 90x. The rule "use the downloader" has only ever existed as
prose, and prose did not stop it.

This does not hardcode any downloader. The operator supplies a command
template containing {url} (and optionally {file}); everything in the refusal
is derived from it. With no template the row registers no listener at all.

Structure

Path Role
src/download-guard/detect.ts Pure function: does this command string write a download to disk? No host imports.
src/download-guard/policy.ts Hit + operator config → refusal text, or allow.
src/components/download-guard/index.ts The row: mounts the tools/pre-execute listener.

Splitting detection from the decision is what lets the rules be tested
exhaustively without mounting anything, and keeps "does this look like a
download" answerable by evidence rather than by policy taste.

False negatives are the safer error

Missing a download leaves the status quo; refusing an ordinary command breaks a
working session. So every rule requires a write signal, and the must-allow
suite is larger than the must-refuse one. It covers the shapes a naive rule gets
wrong:

  • curl URL → stdout, not a file
  • curl -o - and wget -qO- → streams into a pipe
  • node download.cjs --help → the word "download" proves nothing
  • sort < input.txt → redirecting from a file
  • Invoke-RestMethod without -OutFile → returns an object, writes nothing
  • script_path calls → the file's contents are not in the call, so it is skipped
    rather than guessed at

Verification

$ node test/run.mjs download-guard
=== download-guard-detect.test.mjs
PASS download-guard detection: 56 checks
=== download-guard-policy.test.mjs
PASS download-guard policy: 15 checks
=== download-guard-row.test.mjs
dsh-ops download-guard row: all 7 checks passed
=== download-guard-mount.test.mjs
dsh-ops download-guard row via the composition root: all 5 checks passed
dsh-ops test: 4 suite(s) passed in 1s

$ node scripts/validate-manifest.mjs
validate-manifest: all 84 checks passed

$ node test/run.mjs manifest config host-metadata
dsh-ops test: 8 suite(s) passed

$ node scripts/check-file-size.mjs
check-file-size: 531 code file(s) checked, all at or under 1000 lines (longest: bench/setup/profile.mjs (927))

download-guard-mount goes through apply() — the real composition root — so it
fails if the row stops being reached from src/index.ts, if the
download-guard config section stops resolving, or if enabled: false stops
being honoured when a template is present.

Red/green: removing the listener registration fails row and mount; reverting
detect.ts to a "mentions a downloader" rule fails 5 must-allow cases; dropping
{url} validation fails the config assertion.

Size

Implementation 428 lines across 3 files (largest 242), tests 324, docs 2 lines
per language, package.json exports 4 lines. Well under both the 1000-line file
cap and the ~1000 implementation-line PR budget.

Note on the full suite

On this machine 13 suites fail, none related to this change: they require a
Rust worker (npm run build:ptc), a bash-ipc provenance receipt, or npm's
default cache directory which this machine denies (EPERM). I did not run
npm run verify-gate to completion for that reason, and I am stating it rather
than implying a green gate.

Follow-up, not in this PR

Issue #507 (blind sleep → job_output(wait)) is a separate change and is not
part of this slice. A design for its prompt-injection layer is being prepared
separately, as the maintainer asked.

…arder is configured

Closes T-Auto#506.

The model reaches for `curl -o` / `wget -O` / `Invoke-WebRequest -OutFile`
because those commands work. They also write one connection at a time: on the
reporting machine the same ISO moved at 0.13 MB/s that way and 11.78 MB/s
through a downloader built for the job, roughly 90x. The rule that says "use the
downloader" has only ever existed as prose, and prose did not stop it.

This row moves the rule into the runtime, with one deliberate difference from
the version that motivated the issue: it never names a downloader. The operator
supplies a command template, and every part of the refusal is derived from it.
An unconfigured install gets no listener at all.

WHY THE DECISION IS SPLIT FROM THE DETECTION

`src/download-guard/detect.ts` is a pure function over a command string, with no
host dependency, so the rules can be tested exhaustively without mounting
anything. `src/download-guard/policy.ts` turns a hit plus the operator's config
into a decision. Keeping the refusal out of the detector is what makes "does
this look like a download" a question that can be answered by evidence rather
than by policy taste.

FALSE NEGATIVES ARE THE SAFER ERROR

Missing a download leaves the status quo. Refusing an ordinary command breaks a
working session. So every rule requires a WRITE signal, and the suite of
must-allow cases is deliberately larger than the must-refuse one. It includes
the shapes that a naive rule gets wrong: `curl URL` printing to stdout, `-o -`
and `wget -qO-` streaming into a pipe, `node download.cjs --help` (the word
"download" proves nothing about where bytes go), redirecting *from* a file, and
`Invoke-RestMethod` returning an object instead of writing one.

SCOPE AND ITS LIMIT

Shell tools only, and `script_path` is skipped outright: the file's contents are
not in the call, and guessing at them would be the false positive this row is
trying to avoid.

WHAT IS VERIFIED

    node test/run.mjs download-guard

    download-guard-detect    56 checks
    download-guard-policy    15 checks
    download-guard-row        7 checks (real cordis waterfall)
    download-guard-mount      5 checks (through apply(), the composition root)

The mount suite is the one that matters for integration: it proves the row is
reached from `src/index.ts`, that the `download-guard` config section resolves,
and that `enabled: false` is honoured even when a template is present.

`node scripts/validate-manifest.mjs` - 84 checks. `node test/run.mjs manifest
config host-metadata` - 8 suites. `node scripts/check-file-size.mjs` - largest
new file 242 lines, total implementation 428.

NOT VERIFIED, AND STATED SO RATHER THAN IMPLIED

The full suite has 13 failing suites on this machine, none of them related to
this change: they need a Rust worker (`npm run build:ptc`), a bash-ipc
provenance receipt, or npm's default cache directory, which this machine denies
(EPERM). None touches the download guard.
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.

[请求] shell 层的大文件下载没有约束:手写 curl -o 落盘比专用下载器慢两个数量级

1 participant