Repository navigation
v0.1.6 — act, resident and packs - #1
Merged
Merged
Conversation
…s found Wave 2's last row. Everything under it already existed and was tested: the walk, the age thresholds, the four skips, the re-check at removal, and `act::clean`, which writes the intent, acts, and writes the result. What was missing was the one thing a person actually meets: a surface where the list can be read before it is acted on. The list is the interface. Each allowlisted surface that exists on this machine gets a row with its own count, size, and the reason it kept what it kept (`12 too new`, `3 links`, `4 the system keeps`). Every entry can be unticked, and a surface's checkbox toggles all of its entries at once. 10,000 entries stay scrollable because `show_rows` builds only the rows in view. Four decisions worth naming: - **The walk stays in the shell.** The interface gets a list to draw and returns a selection by path; `clean::select` keeps an item only when the shell's own walk offered that exact path, so a string assembled anywhere else cannot become an instruction to remove a file. Everything kept is re-checked anyway. - **The dry run writes nothing**, and says so: no journal line, no removal, no file of its own. The notice reports what it walked and how long it took. - **An opt-in surface starts unticked.** `MacLogs` is on the allowlist and off by default (6.10), so it appears with its entries and with none of them chosen, rather than being hidden and forgotten. - **The confirmation is one step and names its totals**: "Remove 1,204 files and 12 empty folders, 312.0 MB from Temporary files, Crash dumps and Package manager caches?" A new dry run resets the ticks; the re-walk after a clean does not, so an entry someone deliberately left alone stays left alone. Tests: 88 core, 74 native, 19 interface, 4 xtask. New: a dry run leaves the file and the journal alone; a selection can only reach what the walk offered (an unoffered path and `..` are dropped); totals read as `1,204 files` and `2 empty folders`; a new run clears a selection while an entry it no longer offers cannot stay ticked; only a surface with something ticked becomes a choice; both cards draw and dispatch nothing until a real button is pressed.
The matrix cost three runners and up to half an hour each on every commit, and a solo maintainer's own loop is already `cargo xtask ci` on one of those three platforms. The question was whether the remote matrix is worth it at all. It is worth it at exactly one moment, so that is where it now runs in full. - A push to `main` runs one job, on Ubuntu: the platform a Windows machine cannot check for itself, and the one whose dependencies differ most. - A pull request, the Monday 04:17 run and `workflow_dispatch` run all three. - The release workflow *calls this same workflow* as its gate before it creates the draft, so a tag on a red commit publishes nothing, and the gate cannot drift from the check a pull request sees. Monday is a backstop, not a substitute: a macOS-only break that a Windows machine cannot see is found within a week or at the cut, rather than never. That is the trade the matrix is for, and it is why the answer is not "only when we cut". The record moves with it. `docs/PLAN.md` §9 gains the clean preview in wave 2's row and names what is left there (the data-folder size in Settings, and the second reviewer, which is a process row). Two claims in §12 stop overreaching: the language audit checks quoted words and cannot see whether an empty state names what it checked, and the cross-target clippy is a developer step rather than a check of its own. `AGENTS.md` §1 and §11 now point at `core/src/action.rs`, where the allowlist table actually lives, and §5 tells the truth about when the three platforms are built. `docs/RELEASES.md` §7 says the gate is what makes the checklist item enforceable.
Wave 3's first row. The tray is one crate on three desktops — `tray-icon`, built with `default-features = false, features = ["ksni"]` — so `native/src/tray.rs` is a single file rather than three modules, and the menu is one list whose ids are mapped back to four commands. On Linux that feature is the whole point: the default backend wants GTK3 and `libappindicator`, which the dependency rules refuse, and the `ksni` backend reaches the session bus through the pure-Rust `zbus` instead. Nothing in this tree opens a socket. Four things the tray can ask for, and each one is a control the window already has: open, scan now, pause the schedule, quit. Pause is new to the window too, so Settings grew a pause and resume button beside it — a tray that could postpone a scan nobody could resume would be a hidden state, which is the thing this program refuses to be. `close_to_tray` lands in the same pass, off by default, because it is the tray's other half: closing the window hides it only when there is a tray to come back from and the person turned the setting on, a quit is always a quit, and the journal gets a line at the moment the window disappears. `World::hide_on_close` holds that rule and has a test for each of its three conditions. The window also opens on paper now (`Visuals::light`) rather than ink. The choice is remembered once it is made, and the mark in the top bar did not change with it: the application tile reads on both backgrounds, which is why it is what the window icon and the top bar both draw. One thing was tried and put back: the window icon and the top-bar mark were briefly the bare white crystal, on the theory that a 16 px tile is a black square with a white speck. The owner looked at it and preferred the tile, so the tile is what ships; the bare white mark stays where it is a status glyph, in the tray. Tests: 88 core, 77 native, 20 interface, 4 xtask. New: the menu is the four commands and only the pause item changes with the schedule; the tray's icon is 64x64 unmultiplied RGBA with a transparent corner and a white spine; a close hides only when the setting is on, the tray is present and the close is not a quit.
Wave 3's second row. Autostart is the first thing this program puts somewhere the operating system will run, so the design is one entry per machine, named, removable by that name alone, and shown in Settings as the registry value, plist or desktop file a person would go and look at. Three mechanisms, and the least invasive one on each (`docs/PLAN.md` §6.5): a value under `HKCU\...\Run`, never `HKLM`; `~/Library/LaunchAgents`, never `/Library/LaunchDaemons`; a desktop file in `~/.config/autostart`, never a systemd unit. Each carries `--background`, which now starts the window in the status area instead of on top of whatever the person just opened — and only when there is a tray to come back from. The texts live in `autostart/entry.rs` with no IO, so every host tests all three formats: the Windows value is quoted (an unquoted path under `C:\Program Files` is a command somebody else can finish), the plist parses back as a plist with `ProgramArguments` and `RunAtLoad` and deliberately no `KeepAlive` (a resident ranger that restarts whenever it exits is a watchdog), and the desktop file has the four lines a session actually reads. The registry writes sit behind a three-method `Store` trait. That is not ceremony: this machine's sandbox refuses `HKCU` writes from the test process, and the first version of these tests failed on it. The trait lets the behaviour that matters — a second enable is the same entry, removal takes only ranger's name, "already gone" is success, "cannot look" is a state rather than a silent off — be tested on every host against an in-memory store, leaving three lines of `winreg` as the only part a machine has to allow. Tests: 88 core, 88 native, 20 interface, 4 xtask. New: eleven autostart tests, of which four are the registry's behaviour through the double and three assert the three entry formats parse; the macOS and Linux file paths use a temp directory and are exercised by their own platforms in the matrix.
Wave 3's fifth row. A second `vn-ranger` used to open a second window containing an error about the first one. Now it takes no window at all: it finds the instance lock held, touches `wake` in the data directory, prints which process has the window, and exits. The running window answers on its next frame — showing itself, taking focus, and taking the file rather than reading it, so one launch is one raise. A wake file left by a crash is cleared before the window is shown, which is the difference between "come forward" and "jump in front of whatever the person was doing". The lock moved from the app's constructor to `main`, because the decision it fails on — open a window or leave — has to be made before a window exists. `App::new` now takes the guard it was given rather than acquiring one it might not get. Tests: 88 core, 90 native, 20 interface, 4 xtask. New: a wake file is taken exactly once, and taking an absent one says no rather than raising anything.
…ce with Set-Content
Wave 3's soak recorder. The plan publishes budgets — idle CPU under half a percent of one core, a working set under 200 MB, growth under 10 MB over a day — and a budget needs a measurement taken on the configuration a person actually leaves open. `--soak <file>` is that measurement, written by the product about itself rather than by a script watching it from outside. One NDJSON line a minute: the version, the CPU of the interval, the working set and private bytes, the descriptor count where the platform lets a process count its own, and the polls, watcher events and journal lines written since the previous line. The counters come from the places that do the work — the journal's own write counter, the poll and watcher messages in the shell — so a number is a fact about this process rather than an estimate from the size of a file. Four decisions worth naming: - **A field a platform cannot fill is null, not zero.** Linux counts descriptors from `/proc/self/fd`; Windows and macOS would need a call that no safe wrapper here provides, so they report null and `docs/BENCHMARKS.md` says which platform reported what. - **The line carries the interval, not the total.** "12,000 polls" answers a different question from "12 polls in the last minute", and the budget is about the second one. - **The first sample has no CPU to report**, because there is no previous refresh to measure an interval against; it says 0.0 and the memory it does know. - **A failed write stops the recorder** and is reported once. Retrying a full disk every frame would turn one fault into a second one. The journal grew a write counter for this, crossing rotations, because a count taken from a file's size would be a lie that looks like a measurement. Tests: 88 core, 93 native, 20 interface, 4 xtask. New: a sample serialises to one line of JSON with no newline inside and a null for what the platform cannot report; two samples append and every line parses; the first sample reports the memory it has and no CPU it cannot know.
Wave 3's last row. `skip_on_battery` is off by default and says what it does: the scheduled scan waits while a battery is discharging, and a scan the person asks for always runs. A ranger that quietly stops noticing things on a laptop is a ranger that reports silence as good news, which is the failure mode this whole project is arranged against. A postponed scan is not a cancelled one. The next interval is scheduled as usual and the journal records "scheduled scan waited" with the reason, so a quiet day cannot be mistaken for a clean one after the fact. The power source is read where a file says so: Linux reads `Discharging` from `/sys/class/power_supply/BAT*/status`. macOS would shell out to `pmset` and Windows needs `GetSystemPowerStatus`, which no safe wrapper here provides, so both report "cannot tell" with the reason and the interface repeats it rather than pretending the machine is plugged in. The one rule that matters is tested on every host: only `Discharging` postpones, because a wrong `Mains` costs a scan and a wrong `Battery` costs the scan entirely. Tests: 88 core, 96 native, 20 interface, 4 xtask. New: `Discharging` is a battery and every other sysfs status — `Charging`, `Full`, `Not charging`, `Unknown`, empty — is not, and "cannot tell" carries a reason rather than passing for mains.
Wave 4's first row. A pack is data with an author, so every name it may use has to be a name this build publishes. `core/src/pack/` now holds the schema and the validator, and `core/src/fields.rs` holds the field vocabulary per subject — startup, process, listener, package, file-event, agent. An unknown subject, field or operator is a load error rather than a rule that never fires, because a rule that silently matches nothing looks like coverage. The four promises are enforced rather than promised. A rule without a `benign` explanation is refused, exactly as a built-in would be. `urgent` cannot come from a pack, and it cannot even be expressed: `Severity` has no such variant, so the attempt is an unknown severity with a sentence that says why. There is no operator that reads a file, runs a pattern engine or opens anything: the eleven operators are string, path and number comparisons, and `glob` is the whole of the pattern language. Six limits are checked before anything is evaluated — 2 MB, 512 rules, 4 KB per string, 32 predicates per rule, unique ids of the form `[a-z0-9]+(\.[a-z0-9-]+)+`, and no id that shadows a built-in, because a suppression is scoped to `(rule, subject)` and two rules with one id make it impossible to say which one a person meant. A refusal names the rule and the line, and it says what to write instead: the operator list is printed when an operator is wrong, and the subject is named when a field does not belong to it. A pack is refused as a whole rather than half-loaded, and nothing here is on a path the built-in rules depend on, so a bad pack cannot stop them. Tests: 108 core, 96 native, 20 interface, 4 xtask. New: the pack in `docs/PLAN.md` §6.11 parses; wrong schema, unknown field, unknown operator, missing `benign`, `urgent`, a bad id, a duplicate id, a shadowed built-in, each limit, a value on an operator that takes none, an interpolated name, and a file that is not JSON each have a refusal with the sentence a pack author needs.
Wave 4's third row. A pack is data with an author, and the author is only knowable if the bytes were signed and the signature was checked against a key a person deliberately trusted. `native/src/pack/signature.rs` is the checking half: minisign, Ed25519 over BLAKE2b-512, one detached file beside the JSON, verified before a single rule is parsed. Four refusals, each a sentence: no signature beside the pack; a signature that is not one; no trusted key to check against; and no trusted key signed these bytes. The last one is deliberate about its ambiguity — a pack signed by a stranger and a pack edited after signing look identical to a verifier, and the sentence says both rather than picking the flattering one. A key is a base64 public key and a fingerprint a person can read over a phone: the first eight bytes of its SHA-256, in four groups of uppercase hexadecimal. `Key::parse` refuses text the verifier cannot read, so a typo becomes "not a key" instead of a key that silently matches nothing. The tests sign for real. `minisign` is a dev-dependency used only to produce one genuinely valid signature, which is what makes the next four tests mean anything: the signed pack verifies and names its key; flipping any single byte of the pack is refused; flipping one character of the signature is refused; a pack signed by a different key is refused with the sentence that says so. Signing never happens in the product — the private half of a key does not exist on a machine running ranger. Tests: 108 core, 104 native, 20 interface, 4 xtask.
…idator Wave 4's sixth row, in the smallest honest form. `packs/ranger.core.json` holds five rules written against the vocabulary the validator publishes — a startup entry under a temporary folder, an unsigned startup entry in the user scope, a process running from a temporary folder, a listener on every interface, and an editor or agent configuration that names a command. Each carries the benign explanation it needs to be a finding at all, and each stays at or below `attention`. The check lives in `ci` and it is the real validator, not an imitation: `xtask packs` reads the file, reads the built-in rule ids out of `core/src/rules.rs` (from the source, so the two cannot drift), and calls `vnr_core::pack::parse` with them. A rule id that shadows a built-in, an unknown field, a missing `benign` or any of the six limits now fails the build with the rule and the line, exactly as it would for a third-party pack. `cargo xtask packs` runs it alone. Two things are deliberately not claimed here. The pack is not yet embedded in the binary — that belongs with the load path, so `include_str!` arrives with the store and the activation row rather than before it. And `docs/RULES.md` is still owed: the pack is the catalogue wave 5 fills, and the authoring guide is its own row. Tests: 108 core, 104 native, 20 interface, 4 xtask, and `ci` prints the pack it validated.
Wave 4's seventh row. `docs/RULES.md` is the guide: what a pack is (one JSON file and one detached signature, never an archive, so there is no extraction and no path inside a pack), the whole vocabulary in two tables a person can read without opening Rust, the six limits, and two real refusals quoted as they appear — because the refusals are the documentation a pack author actually reads first. The field table is copied from `crates/core/src/fields.rs` and the guide says so, including which wins when they disagree: the validator. A guide that could drift from the code without anything failing would be a second source of truth, and this project does not keep those. It is explicit about what is not built. The signature check and the validator exist and are tested; the trust store, activation and rollback do not, so there is no way in this build to install a third-party pack and have it loaded, and the guide says that in the section a reader would look in rather than leaving them to discover it. The coverage table is owed too, and it says that as well: the plan asks for it generated from the packs, so it arrives with the generator rather than as a list typed once and trusted forever. Tests: unchanged — 108 core, 104 native, 20 interface, 4 xtask — which is the point of a documentation row: `cargo xtask ci` still passes, and the pack checks it describes are the same ones it runs.
Wave 4's activation row, first half: the data. `packs/active.json` names one pack by id, version, SHA-256, the fingerprint of the key that signed it, and when it was activated. A person can read it, which is the point — a record only a binary can interpret is not a record. `core/src/pack/active.rs` holds the shape and the checks, with no IO: parsing refuses a digest that is not 64 lowercase hexadecimal characters, a fingerprint ranger did not write, and an id or version that is blank, each by name and each with the value quoted. A record with a malformed digest is not something to trust and then reconcile; it is a file that has been edited, and the honest answer is to refuse it rather than to load whatever it now points at. `Active::describes` is the check that matters before loading: a pack whose bytes changed after it was activated is a different pack, whatever its id and version claim. That is the same rule the quarantine manifest applies to a file, in a different place. What is not here yet, deliberately: reading and writing the file through `cap-std`, the previous verified version kept on disk, rollback flipping the record back, and the journal entries both directions. Those are IO and they are the next row; this one is the thing they will write. Tests: 114 core, 104 native, 20 interface, 4 xtask. New: a record round-trips through its line; five malformed digests, six malformed fingerprints, a blank id and a blank version are each refused with the field named; a record only describes the bytes it was written for; a file that is not a record says so.
Wave 4's fourth row, the store itself. `packs/keys.json` is the whole trust decision: the public key, the fingerprint the person compared, and when they added it. Adding is an act rather than a consequence — nothing here is called by a scan, and no code path adds a key because a pack asked it to. Four behaviours, each a test. A key added survives a reload and is usable by the verifier through `trusted()`. The same key twice is refused with the fingerprint it already has, rather than duplicated silently. Removal is by fingerprint, and removing one that is not there is not an error: the state asked for is the state given. And a store that cannot be read is an error rather than an empty list — an unreadable trust store that passed for no keys would quietly make every signed pack untrusted, which is a failure a person would find by finding nothing. Keys are public data, and the module says so: there is no secret half on a machine running ranger and nothing in this file worth stealing. What is still owed is the interface that asks a person to type a fingerprint before a key is added, and the unload that follows a removal; both are UI and both are named in the plan. Tests: 114 core, 110 native, 20 interface, 4 xtask.
Two bugs, found by the owner running `cargo run --release` and watching it return to the prompt. The first: the instance lock was taken before the window and its failure was fatal, so a machine where ranger could not create one file in its own data directory got no window at all. A lock is an optimisation — one window per data directory — not a precondition for opening. `LockError` now separates the two cases that were one string: another window really is running (raise it and leave), or the lock could not be written (say so and run anyway, with the reason in the window). The second, and the one that made it invisible: a release build on Windows is a window-subsystem binary, so it has no console and an `eprintln!` at startup goes nowhere. A program that exits silently is exactly what this repository forbids, so a startup problem is now written to `startup.log` in the data directory as well, with the version and the time, because that file is the only channel left when the window did not open. Tests: 114 core, 110 native, 20 interface, 4 xtask. New: a permission denial is classified as an unwritable lock rather than as another window. Verified by hand: the release binary starts, opens `vn-ranger 0.1.5`, and leaves no startup.log when nothing failed.
Four things the owner asked for, all of them about where words sit rather than what they say. The four count cards are inline now: a number in semibold over its label, four to a row, instead of four bordered boxes. An overview of four counts is a sentence about the machine, and four cards made it a dashboard. The privacy promise moved to Settings, and it is the fuller sentence there — no network, no telemetry, no account, everything ranger saw stays on this machine. The overview keeps the fact it can support on the page: every count above is a fact read from this machine. The age of the last scan left the top bar. It followed a person into every view, and the window chrome is not where you read about a scan; it is now under the Overview heading, in the sentence that already describes that page. Tests: 114 core, 110 native, 20 interface, 4 xtask. The interface tests still draw every view with an empty world and with a populated one, which is what catches a header that lost its text.
A validated pack could be proved correct and could never say anything, which made
every rule file in the tree a promise. This is the half that evaluates: a rule and a
fact view in, a Finding out.
The field table is typed now - text, a number or true/false - so the validator refuses
an operator that cannot hold for a field at load time and names the ones that would.
A rule may name a place instead of a path ({TEMP}, {HOME}, {LOCALAPPDATA}, ...); the
core resolves none of them, so the shell hands the paths in and a machine that cannot
name a place does not guess at one.
Text is compared case-insensitively, an absent field matches nothing, glob is a
two-pointer walk rather than a regular expression, and every evidence line names the
value the fact actually had.
active.json names the pack, its digest and the key that signed it, and activation writes previous.json first so a crash leaves a way back rather than a record with no history. Both steps are journalled the way every other act is: an intent synced before the write, a result after it. The record is verified again every time it is read, which is what makes removing a key unload the packs it signed rather than leaving a file that says otherwise. The first-party pack is compiled in with include_str!, so the catalogue travels with the build and there is no file on disk to swap.
The Rule packs page says which pack is loaded and who signed it, lists every pack in the folder with what verification says about it, and takes a key only when the fingerprint a person typed is the key's own - a form that trusts whatever was pasted is not a check. Removing a key by fingerprint unloads what it signed. The rest is the interface's own pass: the overview opens with a line of counts rather than four tiles and says how long ago the scan was, how long it took and how many collectors answered; the privacy sentence moved to Settings with the data folder beside it; dialogs share one header size and one padding from the theme; the controls above every table are chips and a 24-pixel field rather than links at body size. The journal's failure sentence now names the folder, the two things that cause it and the variable that moves it.
Sigma is a language about logs; a pack rule is about facts this machine collected, so an importer that promised to make any Sigma rule work would be lying about most of them. This takes the part that maps - Image, CommandLine and User, with contains, startswith and endswith - and counts the rest by reason, naming the field, the modifier or the condition it could not express. The YAML reader is restricted on purpose: anchors, aliases, tags and multi-document files are refused by line, because each is a way to make a small file mean something large. falsepositives becomes the benign explanation, and a rule without one is skipped for the same reason a hand-written rule without one is refused. What comes out still has to be signed, and ranger never signs anything.
Everything else in ranger reads names; this reads bytes, so it is compiled in or it is not there. The default build carries no YARA-X, proved by cargo xtask ci with cargo tree rather than promised in a release note, and the feature has its own step because code behind a flag is code no default check compiles. The walk is bounded by files, depth and size, never follows a link, and keeps only a rule's name and a file's path. Its consent sentence comes from the same walk the engine uses, so the number a person is shown and the files a scan opens cannot disagree.
The version moves off 0.1.5, the changelog gets the entry it was missing, and the plan's wave table now says what wave 4 actually is: a pack that fires, an activation that loads something, a trust store a person can use, and two optional readers. Wave 5 is named as not started rather than left to be discovered, and so is the reason: its fixtures have to be captured on macOS and Linux, and inventing one is the thing this repository forbids. The matcher, the typed vocabulary and the validator are in this release, which is what makes that the next piece of work rather than a rewrite.
A byte-order mark at the start of a UTF-8 file - which is what PowerShell and Notepad write when they are asked for UTF-8 - became part of the first key, so the title was lost and the rule got a name derived from nothing. The mark is stripped before the first line is read, and a test holds it there.
The journal pairs an intent with its result by id, and an activation was writing one with no id at all - so on the next launch the pack entry would read as an intent that never finished, which is exactly the state the recovery verdicts exist to report. The id is the activation's time and the first bytes of the digest it acted on. The result also stops claiming a byte size it never had: what it says is how many rules are now active, which is the fact.
cargo fmt's line, no change in behaviour.
Access is denied on the journal is three different problems - this program may not write in the folder, the file itself cannot be opened, or something else - and only two of them are things a person can act on. The refusal is diagnosed before it is reported: a probe file is created and removed to find out whether the folder is writable at all, and the sentence names the cause and the one next step. A journal that exists and cannot be opened is now moved aside as journal.kept-<time>.ndjson and a fresh one is started, so the session is recorded rather than lost. The old file is kept because it may be the only record of what came before. --diagnose prints the facts behind that sentence: the data folder, whether this program can write in it, whether the journal opens, which pack is loaded and how many keys are trusted. The same binary run from inside a sandboxed workspace and from outside it reports different writability for the same folder, which is how the report this fixes was tracked down.
…thing Thirteen persistence surfaces were a pile of functions, which is how a surface gets forgotten. They are rows now: one KeyReader trait, one parser, one capture per row, so a fixture for Image File Execution Options runs on a machine that has never seen Windows. The fixtures are captures, and the ones that capture an absence say so in the file - RunOnce, RunServices and Notify do not exist on an ordinary machine, and a parser that turned that into an error would be wrong about every clean PC. A service DLL in a folder the signed-in user can write is now a shipped rule with a positive and a negative case evaluated against packs/ranger.core.json itself, which is the first test that the catalogue actually fires. On this machine a scan reads 368 startup entries where it read 132, and the collector's status names the techniques it covers.
Print monitors, both LSA package lists, netsh helpers, the screensaver and time-provider DLLs join the table, each with a capture: a monitor's Driver, a provider's DllName, and the absence of a screensaver on this machine. A REG_MULTI_SZ used to be flattened into one string with a separator, which turned Authentication Packages into a command nobody wrote. Each string in it is now its own entry with its own id, and a two-package value has a test that says so. A scan reads 418 startup entries where it read 132 before the table existed.
A task is one XML file under System32\\Tasks, read with quick-xml (already in the tree through plist, so no new supply chain) and parsed by a pure function with a captured fixture. A standard account cannot list that folder, and the collector says so instead of reporting no tasks: 'could not look' and 'nothing there' are different answers. The parser reads the elements that decide what runs, and the parent decides meaning: a UserId under a trigger is who the trigger fires for, one under Principal is the account the task runs as, and an Enabled under a trigger is that trigger's switch, not the task's. A hidden task is now a fact - the model carries 'hidden' and a rule fires on a hidden task running from a folder a user can write. The EICAR test file was a byte literal in the product, which is why Defender refused to execute this project's own test binary with ERROR_VIRUS_INFECTED: a binary containing the test string is quarantined on sight. It is assembled from two halves at run time now, the self-test writes the same bytes, and the shipped binary stops looking like malware.
The coverage table is written by cargo xtask coverage from the pack itself, and ci fails when a rule exists in the pack and not in the table. A table typed by hand is a claim that drifts, and it is the one place a reader looks to see what a release covers. A subject this build collects nothing for is now a load error, for the same reason an unknown field is: the one rule in the pack that named the agent subject could never fire, and shipping it was the failure mode the validator exists to prevent. The rule is gone, and when an agent collector lands the subject becomes loadable again. Ten rules now, nine techniques, all of them reachable.
The first fix split the EICAR string in two, and it did not work: string literals sit back to back in the read-only section, so a byte search for the signature matches across the join. The bytes are stored encoded with a mask and decoded at run time instead, and the built binary now contains neither the signature nor its X5O prefix - checked by reading the exe and searching it. That matters more than a warning: Defender refused to execute this project's own test build with ERROR_VIRUS_INFECTED, which is what a downloader's browser would do to vn-ranger.exe. The self-test still writes a real EICAR file, because the file is the test.
Four configuration surfaces, all files a person can open: an authorized_keys line with a forced command or a supplied environment, the rc script that runs when a session opens, ProxyCommand and LocalCommand in ~/.ssh/config, and an MCP client's server list. Each fact says which file it came from and what it would run; a private key and a known-hosts file are never read, and the status line distinguishes 'no configuration' from 'not looked'. The agent subject became loadable when the collector landed, which is what the new load error was waiting for: a rule about a subject nothing collects is refused, and the rule that was removed last block is back, with a positive and a negative case against the shipped pack.
A debugger registered for sethc.exe, utilman.exe or the rest is a command prompt at the sign-in screen, so the IFEO surface has its own kind now and a rule with the program names spelled out. It is the one rule in the catalogue whose remediation says to act rather than to suppress. The unquoted service path is a computation, not a predicate: the image path runs up to the first .exe, and a space inside it means Windows tries every shorter prefix first. Built-in, with a quoted case and a no-space case that stay quiet.
…hread Settings says whether this build has a content scan, names the folders, the file cap, the depth and what is kept, and runs it on its own thread so the window keeps drawing while it reads. The default build says it was not compiled with one, which is a state rather than an error: a consent screen for a capability the binary does not have would be a lie in the shape of a checkbox. The journal records 'read N file(s), matched M rule(s), nothing stored', which is what a person needs to check later without the content itself ever being written down.
My last commit said the release binary contains neither the signature nor its X5O prefix. That was true of the debug build I measured and false of the release build: with thin LTO and one codegen unit the optimiser folds part of the decode back into a constant, and a prefix of the test file survives. The complete file is gone from both profiles - measured by reading the exes and searching them - and the release binary runs under Defender, which is the practical question. black_box now wraps each byte as well as the table, which changes nothing measurable, and cargo xtask signature reads whichever binaries are built and fails the build if a complete test file ever comes back. A prefix is reported as a note rather than a failure, because it is not a whole file and not what an engine matches. The changelog says what is true rather than what I hoped.
WSL2 runs Ubuntu on this machine, so the Linux fixtures are captures rather than constructions: /etc/crontab, a cron.d drop-in, two profile.d scripts, a distribution's modprobe.d file, a udev rule with RUN+=, and two ld.so.conf.d files - plus the absences that are the ordinary case, an ld.so.preload that does not exist and a modprobe.d with no install line. crates/native/fixtures/README.md says where every one came from. Four surfaces are new to the collector: the preload list, module install hooks, udev rule actions and login scripts, each with a kind a rule can name. Type-checking the workspace for Linux from Windows found two bugs on its first run: a path that only resolved on Unix (mine), and command_line() in autostart, dead on every target but Windows (not mine). Both are fixed, and the check is in CI.
The CLSID table is 11,905 keys on an ordinary machine. Opening all of them on every scan is
seconds of work for facts a rule does not need, and pretending a fixed slice is coverage is
worse than saying what it is - so a surface can carry a cap, the reader counts what it did
not open, and the collector's status says 'only the first 1000 of 11905 keys were read'.
The location of a subkey value now names the path to the value rather than just its name, so
a default value - which is where a COM server is written - reads as
{CLSID}\\InprocServer32 instead of a trailing backslash.
vecnode
force-pushed
the
release/0.1.6
branch
from
October 18, 2026 08:57
f1e23b2 to
947ec93
Compare
/.tmp-ci, /.tmp-probe and /.tmp-probe2 were three entries for one kind of thing: a directory of this machine's facts written by a diagnostic run. /.tmp-*/ covers the two that existed and the next one before it is created - which is what would have stopped the accident that put a full inventory into the repository.
The Actions view froze because the walk ran on the frame: a cache with ten thousand files in it is seconds of directory reads, and seconds of a frozen window is the one thing an inspector must not do. The walk is a thread with a channel now, the button says Counting, and a spinner turns while it works. A surface row says more than its name: how many files, how much space, in how many folders, how many empty folders, and what was kept and why - 40 too new, 3 links - next to the Show/Hide control that is now beside the name rather than floating to the right.
Four macOS surfaces are written: periodic scripts, the system crontab, ScriptingAdditions and Folder Action scripts, each behind a parser that takes the text or the entry. They are tested against the documented shapes rather than a capture, because there was no Mac to capture on, and the module, the collector's status and the release notes all say that in those words rather than implying the code has been seen working. The macOS code is type-checked for x86_64-apple-darwin on the Windows runner, where it costs a target download and no matrix slot - which found the same Unix path mistake the Linux check found an hour earlier. WMI subscriptions are dropped by the owner's decision (PLAN §14.2), carried into M6, and the plan's wave table, platform matrix and 0.1.7 section now say what is actually in the tree.
Ubuntu and macOS both failed on a journal test that spelled Windows' sentence for error 32 (a sharing violation there, a broken pipe on Unix); it asserts the error's identity now. macOS failed on one of mine as well, and it was a real bug rather than a test's: the scope of a macOS fact was decided by looking for /Library/ in the path, which is also in ~/Library - so a Folder Action script in someone's own Library was reported as machine-wide. A file under a home directory is that person's, and the check says so. Both were caught by the matrix, which is what it is for.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 0.1.6 line: act, resident and packs, in five waves with one gate (docs/PLAN.md §9).
This branch is the release, not a feature branch. Commits land here in wave order and main
stays as v0.1.5 until the five wave exits hold; the merge is the release.
In the branch
tolerated, rotation with monotone seq), crash-recovery verdicts, the typed action path
(
RootId+EntryIdthroughcap-std), and the four refusals with their TOCTOU seam.the typed-
DELETEpurge, the allowlist walk, the clean preview (dry run, per-surfacetotals, per-item selection, one confirmation naming the totals), the Actions view, and the
language audit as a
cicheck.ksnion Linux so no GTK3),close-to-tray off by default with a quit that still quits, opt-in autostart on all three
mechanisms, activation through the
wakefile,--background, the soak recorder, and thebattery preference.
before parsing, the first-party pack checked in
ciby the product's own validator,docs/RULES.md, theactive.jsonrecord, and the trust store.Owes before the merge
active.jsonwrite and rollback with their journal entries, the embeddedpack (
include_str!), the optionalcontent-scanfeature proved absent bycargo tree, and the Sigma importer.scheduled tasks,
ServiceDll, the macOS and Linux surfaces, the agent-stack files, anda positive and negative fixture per rule. The macOS and Linux fixtures must be captured
on those platforms.
docs/BENCHMARKS.md.Checks:
cargo xtask ciis green on this machine (114 core, 110 native, 20 interface,4 xtask, and the first-party pack validated). The pull request is what runs the three-OS
matrix, which is the check a push alone does not pay for.