Skip to content

Releases: AmanSK5/shadow-ai-guard

v0.27.1 - a new plan hands its key to the import, and a headerless paste keeps its columns

Choose a tag to compare

@AmanSK5 AmanSK5 released this 04 Sep 12:52
345ebe3

What this fixes

Importing members after linking a second plan failed. Linking a plan with "CSV" as the source opened the import screen with no plan key: the wizard passed on the key it held, which is empty for a new plan because the receiver derives it from the plan name. With one plan on a tool the receiver fills the blank in itself, so this only appeared the moment a second plan was added, and the receiver then refused - rightly - to guess between the two. The API-key path had the same hole for its connection and first sync.

The wizard now reads the key back from the save's answer and hands it to the connection, the first sync and the import. The import screen names the plan it is importing into, and when it is opened without a key on a tool with several plans it asks which one before offering the import.

A headerless paste lost its role and seat tier. jane@corp.example,member,max 5 kept only the address, by the parser's own rules, which reads as a broken import. Columns are now named from their values: every value a role word makes the column the role, every value an account state makes it a status and it is ignored, and when exactly one column is left it is the seat tier. Anything less certain stays unread and the preview says so.

Not a bug

The tool picker lists tools, not plans. A tool that already has plans stays selectable and lists them; a further plan on the same tool is added by choosing the tool again with the new plan name.

Release notes

  • Portal-only change; the receiver is unchanged from 0.27.0.

Verification

  • Portal: 655 tests.
  • Reproduced in the demo stack: Team already linked on Claude, Max 5 added with a CSV source, import succeeds and names the plan; the fallback picker appears for an import opened without a key; five paste shapes checked against the parser.

v0.27.0 - work-area navigation, a named estate, a reporting window, and a demo that boots signed-on

Choose a tag to compare

@AmanSK5 AmanSK5 released this 04 Sep 10:07
1452e2e

What this is

The portal's shell and pages, reworked around the jobs people do in it, plus the small receiver and demo changes that make it feel like a product from the first screen.

Portal

Shell and navigation

  • Pages are grouped into work areas in the sidebar: Overview, Discovery, Governance, Operations, Settings. Areas stay open independently; pages can be pinned to the top, and the pins follow the account.
  • The estate control beneath the logo names the organisation (a new central setting, see below), reports how many detection sources are reporting, and opens System health. The top-bar badge says "Monitoring active", "No sources reporting" or "Data unavailable" from the same read; none of it is fixed text.
  • The search box filters the page where there is a list, and on every page it is a finder for pages, tools, devices, people and MCP servers. Enter opens the first match.
  • The reporting window is a control: 24 hours to 90 days, on the Overview header and under Settings > Display & alerting, shown as a label on every other page. The choice is a preference of the account and is sent on every read that accepts one, so every page and both downloads agree. Every time label follows it.
  • The account control at the top right holds change password and sign out, and survives phone widths.
  • Light and dark palettes, typography and tables follow the reviewed design.

Pages

  • Overview: a posture panel with the estate's metrics. "Monitoring healthy" needs at least one source to have reported in the window; a silent fleet says "Nothing reporting". Export PDF prints the page. The AI spend card lists its linked plans.
  • AI register reworked as a decision workspace; Devices with a four-signal summary; Source coverage on one five-column schema; Budget wizard tightened; Evidence centre as an index of what the platform can show records for, with Export PDF beside Download JSON; System health as a dashboard whose count includes only the checks it runs.
  • Printed pages carry a stamp naming the estate, the window and the time of export, and hide their own controls.
  • The guided tour is rewritten for this navigation in plain wording, reaches every work area, has a stop on the Overview's Edit button, applies its account step only in login mode, and moves between steps without gliding or blinking. Its version is bumped, so everyone who saw the old tour is offered this one once.

Receiver

  • org_name is a central setting. It is served unauthenticated by GET /admin/setup alongside the SSO flags, because the sign-in screen greets the organisation, and it is capped at a name's length.
  • SETUP_CODE pins the first-boot setup code. It exists for the demo stack and the receiver prints a warning naming the risk on every boot it is set. Unset, nothing changes.

Demo

  • The seeder walks the first-boot path itself: it claims the pinned setup code, creates the owner account (gengar, address gengar@example.com), names the estate "Pallet Town Ltd", switches single sign-on on against the stand-in provider, links three Budget plans with seat tiers and member lists, and enrolls three devices. The portal opens on a sign-in screen with the Microsoft button already there; the password form still works. Re-runs sign in with the owner's password and refresh the data without touching the estate settings.
  • README and demo/README.md describe the new flow; the portal screenshot and walkthrough recording are current.

Release notes

  • Both the portal and the receiver images ship in this release.
  • Existing users are offered the new tour once.
  • The window preference and pinned pages are stored per account.

Verification

  • Portal: 652 tests. Receiver: 310 tests.
  • Walked in Chrome on the demo stack at desktop, medium and phone widths in both themes, including the single sign-on flow end to end and the printed PDFs.

v0.26.0 - say what the registry already thinks it is

Choose a tag to compare

@AmanSK5 AmanSK5 released this 03 Sep 09:14
a8edae8

stable arrived in the review queue with a picker offering every tool in the registry and no indication which one to choose. Answering it required knowing that Warp names its macOS binary after a release channel — which is not something an operator should have to research about their own estate.

The platform already knew

A process candidate is raised from a finding whose tool has already been resolved from the domain it reached:

main.py:3696   canonical = _DOMAIN_TO_TOOL.get(f.tool.lower())   ← app.warp.dev → warp
main.py:3714   _note_process_candidates(f)                       ← asks "what is stable?"

Eighteen lines apart. At the moment it asked the question, f.tool said warp — and the candidate threw it away.

What it does now

The candidate carries the suggestion. The card reads:

stable process
DNS lookup for app.warp.dev (via stable)
looks like Warp — it reached a host the registry knows as that tool, confirm or change below

…and the picker opens on Warp, so confirming is one click.

It stays a suggestion

curl reaching api.anthropic.com resolves to claude, and curl is not Claude — it is a script somebody wrote, which is the entire signal the Agentic view exists for. Applying this automatically would delete the finding this week has been about.

So the card says what the registry thinks and still waits for a human. Same trade discovery makes when it classifies an unknown domain with Claude and still queues it.

A tool the registry doesn't have is not suggested at all — the picker couldn't offer it, and the card would name something that doesn't exist.

Checked by rendering, not by reasoning

The row template was pulled out of the page and run against a stable, a curl and an MCP server:

stable: looks-like Warp    : true      curl : looks-like Claude  : true
stable: Warp preselected   : true      curl : Claude preselected : true
stable: evidence shown     : true      mcp  : no picker offered  : true

Not checking what a widget renders from is exactly what shipped an empty dropdown yesterday.

Suites: registry 23, portal 630, scanner 210, receiver 308. Plus collector parity and the identifier guard.

v0.25.2 - a picker with nothing in it

Choose a tag to compare

@AmanSK5 AmanSK5 released this 03 Sep 08:52
9107143

The belongs to… dropdown shipped yesterday, rendered on the overview, and was empty.

Why

Every fetch of the registry tool list lives inside a view:

budget()    registry()    register()    wizard()

The review queue is a widget on the overview, which visits none of them. So (REGTOOLS || []) was empty and the select rendered zero options — meaning the one action available for a process like Warp's stable was unavailable at the only place it is offered.

reloadDerived() had the same shape one layer down: it nulled REGTOOLS and left it for a view to fetch, which is the bug again.

The overview loads the list now, beside the candidates it already fetches.

And it says why when it can't

An empty dropdown reads as "no tools exist". That is never the answer — it means the list did not load, which is a different sentence. Both branches checked by rendering them:

WITH TOOLS -> select: true  | options: 3 | Link: true | Warp offered: true
NO TOOLS   -> select: false | says why: true | Link: false

With nothing to offer it shows belongs-to unavailable and drops the Link button, rather than inviting a click that cannot work.

Checked

Suites: registry 23, portal 630, scanner 210, receiver 305. Plus collector parity and the identifier guard.

The picker itself was confirmed rendering in a browser — that half of the standing browser-check debt is now closed. The budget page with two plans is still owed.

v0.25.1 - a process can belong to a tool you already have

Choose a tag to compare

@AmanSK5 AmanSK5 released this 03 Sep 08:27
623239b

Two things, both found by running the product.

stable arrived in the queue and none of the buttons fitted

Warp's macOS binary is called stable, after its release channel. It was in the registry for one release and came out again in 0.25.0 — it is a word, and any process named stable was being silently excused as Warp. The queue asks instead. That was the point.

But the queue could only offer three answers and none of them was true:

why not
Add to registry invents a tool called stable with no domains, matching nothing, beside the Warp that already exists — and a portal entry cannot extend a shipped tool anyway, _merged_registry drops it on an id collision
Names nothing false. It names Warp.
Dismiss closes the card and leaves the process on the Agentic view for ever — the exact failure the third disposition was added to fix

So there's a fourth: belongs to a tool the registry knows, picked from the tools that exist. Refused if the tool doesn't — inventing one from a process name is what add to registry is for, and it produces something that matches nothing.

Recorded once, read by every view that reads a process name, and the receiver stops asking. Same trade the queue makes everywhere else: a maintained guess replaced by an observed fact somebody confirmed. Guessing stable in a release is precisely what went wrong.

A second plan could not cover the same tools

Not saved: claude-code is already covered by the claude Team subscription

Wrong, and mine. The covers rule I shipped in 0.25.0 excused only the tool itself when comparing two plans of one product, so the moment either named a second tool they collided — which is every real Claude subscription, since Team and Max both include Claude Code.

A Max seat includes Claude Code exactly as a Team seat does. Their covered sets are supposed to be identical: different contracts, different people, different member lists, and the spend is each subscription's own seats times its own price.

The rule exists to catch one licence modelled as two subscriptions, which happens between different tools. That half is untouched and still tested:

claude/Team covers claude-code, then claude/Max 20 covers claude-code  -> allowed
claude/Team covers claude-code, then cursor/Pro covers claude-code     -> 422

Checked

Suites: registry 23, portal 630, scanner 210, receiver 305. Plus collector parity, build.py --check, both byte-identical invariants, chart copies, identifier guard.

Not verified: the picker rendering in a browser. Same standing gap as the budget page — managed mode needs a login I've avoided.

v0.25.0 - budget by plan, and the answer the review queue could not give

Choose a tag to compare

@AmanSK5 AmanSK5 released this 02 Sep 19:42
682ad7a

Five things, batched as agreed so one release carries them.

1. An organisation does not have one Claude subscription

It has a Teams contract and a handful of individual Max seats, on different plans, different renewal dates, often different owners. budget_subscriptions keyed on tool_id alone:

CREATE TABLE budget_subscriptions (
  tool_id TEXT PRIMARY KEY,   -- ← why Claude greyed out the second time
  plan    TEXT NOT NULL DEFAULT '',

So the choice was track the contract and be blind to the seats, or the reverse. Both totals were wrong and neither said so.

Three tables move to (tool_id, plan_key). SQLite cannot alter a primary key, so each is rebuilt and copied — proven against a database in the old shape: stored vendor key intact, members and connection carried onto the plan their subscription named, no orphans, idempotent on restart.

Every card button carried the tool and every handler did find(s => s.tool_id === k), which returns whichever plan sorts first. Editing your Max seats would have opened the Teams contract; importing users into one would have replaced the other's list. They carry the subscription now.

The page's own bSubMonthly/bSpendParts, run against three plans on one tool:

per subscription : Team=1000  Max 20=270  Max 5=108
page total       : GBP 1378.00
old model showed : 1000   (one plan only)

Four decisions, all about not producing a plausible wrong number:

  • Ambiguity is refused, not guessed — a member write that doesn't name a plan, on a tool with two, gets claude has 2 subscriptions (max-20, team) - say which plan this is for. Guessing moves seats between contracts and the total still looks fine.
  • The picker no longer treats a tool as used up. It says which plans are linked and stays selectable. Only a licence genuinely billed twice is disabled — the covers rule still catches that, it just no longer mistakes two plans for one double-bill.
  • Renaming works — editing sends plan_key, so Team → Teams updates the row instead of quietly creating a second.
  • Orphan adoption — a key can still be stored and synced before the subscription is written down; those rows are adopted when the tool's first subscription appears.

2. The review queue's answer went nowhere

firezone-client-tunnel turned up on a real estate — the first time the process-candidate path has fired outside a seeded demo. It's a VPN tunnel daemon: it resolves DNS for everything behind the tunnel, so naming it says "something on this device, through the VPN", the same non-answer as a stub resolver.

But the queue offered only add to registry or dismiss, and dismissing cleared the card while leaving the process on the Agentic view for everagentic_from never consulted candidate decisions. A question whose answer went nowhere.

There's a third disposition now: the name identifies no program. That's a fact about the name, so it reaches every view that reads one, and the receiver stops asking about it.

3. VPN clients are a category, not a one-off

Tailscale, OpenVPN, WireGuard, Cloudflare WARP, AnyConnect, GlobalProtect, Zscaler, Netskope and the rest join the floor, on the same reasoning that put svchost and taskhostw there. The queue handles the tail.

4. stable comes out of Warp's exe_names

It solved the mystery it was added for, and it is a word. Any process literally named stable was being silently excused as Warp. The queue can ask instead.

5. The overview queue emptied itself

Adding a candidate to the registry set CAND = null and called render() — but the queue is only ever fetched by load(), behind a check for whether the widget is on the overview. So it drew an empty queue and left the rest invisible until a manual reload. Three handlers had that shape; there's one reloadDerived() now.

Checked

Suites: registry 23, portal 627, scanner 210, receiver 300. Plus collector parity, build.py --check, both byte-identical invariants, chart copies, shellcheck.

Two things worth knowing before this ships:

  • The migration runs on the live database at receiver start. Proven and idempotent, but it rebuilds three tables — worth a copy of the state volume first.
  • I have not seen the budget page render two plans in a browser. The arithmetic is exercised directly and the suites pass, but the managed-mode UI needs a login I avoided. That click-through is still owed.

v0.24.1 - the version it was reading was not the collector's

Choose a tag to compare

@AmanSK5 AmanSK5 released this 02 Sep 17:37
994272c

The page said "no device reports a collector at 2.1.0 or newer, so nothing here has looked yet" while the Fleet view beside it showed 2.1.0 on real machines. Two views of one estate disagreeing, and the one making the stronger claim was the wrong one.

What it was actually reading

scheduler_coverage parsed heartbeat version= out of finding evidence. That is the browser extension's paste-guard heartbeat — no collector emits one. Grepping for who writes it returns portal/extension-src/background.js and the paste guard's own docs, and nothing else.

So it read a paste-guard version (1.3.0), compared it against a collector threshold (2.1.0), found nothing at or above it, and reported an absence. Worse than reading nothing: it read something real and confidently wrong.

The collector's version arrives as agent_version on the enrol and report path, is stored on the device row, and is what Fleet shows. It reads that now — which means the portal has to ask the receiver, because a derivation over findings cannot. Pretending otherwise is exactly what produced a plausible number from the wrong field.

devices: 2.1.0, 2.1.0, 2.0.0, ""
  -> devices_reporting 3, capable 2, unknown 1

"12 of 31 devices run a collector that reads schedulers, so for those this
 is an answer rather than a gap. The other 19 report a version older than
 2.1.0, which may never have looked."

Unavailable is now distinct from zero

Classic mode has no receiver to ask, so scheduler_coverage(None) returns None and the page renders nothing — an empty page with no way to check the collectors is still an empty page, and asserting an absence from no data is the same overclaim this view keeps being fixed for. An empty device list still reads as zero, because that is a real answer.

The wrapping

Two paragraphs I added carried max-width in ch, so they broke into short lines beside a lede running the full width. The measure on the headline is deliberate and stays; the ones under it are gone.

Checked

Verified rendered, not just at the data layer: the coverage sentence, the full-width wrap, and scan_coverage: null in classic mode producing no sentence at all.

One test was removed rather than adapted — it asserted the paste-guard heartbeat path, which was the bug, so adapting it would have preserved the mistake in a new shape. Three replace it: the real source, the absent source, and a device row that has never carried a version.

Suites: registry 23, portal 617, scanner 210, receiver 291. Plus collector parity, build.py --check, chart copies and the identifier guard.

v0.24.0 - let the estate ask, instead of guessing on its behalf

Choose a tag to compare

@AmanSK5 AmanSK5 released this 02 Sep 14:47
965cdbe

Every list in 0.23.0 was one I wrote by hand after reading process names off a screen. That is the wrong direction — the estate had already observed all of them, and this platform already has the machinery to ask about what it does not recognise. It just was never wired to this surface.

Discovery does it for domains: unknown host, classify, queue, a human decides. The receiver does it for MCP servers, automatically on ingest. CandidateIn has carried a kind field the whole time.

_CANDIDATE_KINDS = ("domain", "mcp_server")        before
_CANDIDATE_KINDS = ("domain", "mcp_server", "process")   now

Processes become questions

The receiver derives every process name the registry can account for — CLI binaries, desktop app_names, Windows exe_names, allowed_processes — and anything else reaching a model host becomes a candidate carrying its evidence, deduped by name so one script on forty machines is one question rather than forty.

SearchHost.exe, Fireflies.exe and stable should have arrived that way rather than as a commit.

Those sets now live in three services that share no library, so there is a parity test asserting they do not drift — verified failing on a one-word divergence before it passes. Same class of bug as the collector dedupe key: a property that must hold in three places, checked in one.

inference_domains needed unpicking before it could help

build.py refused the field on anything but form: ide, and that rule was correct — the IDE signal was its only reader, and inert configuration reads as working. So the second reader came first: the agentic view now uses it to tell a tool being fetched from one being used. Only then is relaxing the rule honest rather than convenient.

Then the backfill, 2 tools → 15. Safe polarity throughout: a tool that declares nothing is never filtered, so a growing registry can never silently delete findings.

cursor  cursor.com       -> dropped (a fetch, not a run)
cursor  api2.cursor.sh   -> kept   (a model answered)
claude  anthropic.com    -> dropped
claude  api.anthropic.com-> kept

A scheduled job is now recognised three ways

Tried in order, all registry-driven:

  1. It names a binary the registry lists. The original signal.
  2. Its command names an inference host. A job curling api.anthropic.com on a timer is reaching a model whatever binary it runs. inference_domains only, never domains — a scheduled installer download is an acquisition, and matching any domain would put every one of them on a page about what runs unattended.
  3. Its definition hands a script a model credential. This closes the blind spot 0.23.0 could only state. A job running nightly-report.sh names nothing matchable, but a launchd EnvironmentVariables or systemd Environment= setting ANTHROPIC_API_KEY says what it reaches.

Only variables the registry names for a tool. A generic API_KEY would have fired on every scheduled backup in the estate — a worse false positive than the one this whole sequence started with.

Two limits, stated rather than left to be discovered. A Windows scheduled task declares no environment — an action carries a command and inherits the user's — so the credential path cannot exist there, and a wrapper script on Windows stays invisible; the page says so. And EnvironmentFile= is not opened: it points at a file of secrets, and reading the names would mean reading the values.

substrate.office.com suppresses on the domain

For any non-browser, regardless of process. This deletes a maintained list rather than growing one: Microsoft can ship another shell process and it stays quiet without an entry.

The cross-signal join is written down, not built

docs/agentic.md now carries the three reasons it is not sound yet: the join key is a process name (python3 joins against every other python3), it needs inventory rather than findings or it recreates the flood this sequence removed, and an ambiguous join has no honest rendering on a page made of four filled boxes. What the cheap signals miss on a real estate is the specification for it.

Checked

Verified on a real machine and in a container, five job shapes:

runs claude                          -> reported (binary)
curls api.anthropic.com              -> reported (inference host)
runs nightly.sh + ANTHROPIC_API_KEY  -> reported (credential)
curls cursor.com/install.sh          -> silent
runs backup.sh + AWS_SECRET_ACCESS_KEY -> silent

One job produces one finding: several tools name ANTHROPIC_API_KEY, so the first match wins and the evidence names the variable rather than guessing louder. I caught that as a duplicate during testing, before it shipped.

Suites: registry 23, portal 615, scanner 210, receiver 290. Plus collector parity, build.py --check, both byte-identical invariants, shellcheck, PSScriptAnalyzer, and the containerised Linux collector harness (14/14).

docs/agentic.md updated so the documented signals match the code.

v0.23.0 - the scheduler finding that never arrived

Choose a tag to compare

@AmanSK5 AmanSK5 released this 02 Sep 13:49
3903c13

The Agentic AI view reported nothing running on its own, on an estate that had just deployed the collectors built to find exactly that. It was not an empty estate and it was not a stale rollout. The finding was being made and then thrown away.

report_once dropped it on the floor

report_once on macOS deduped on surface|tool:

macos   :  local key="$1|$2"              ← the bug
linux   :  local k="$1|$2|${7:-}"
windows :  $key = "$Surface|$Tool|$Trigger"

A scheduled job and an installed tool are the same surface|tool pair. So the moment the account scan reported cli|claude-code, every scheduled-job finding for that tool on that machine was silently discarded — and a machine with a scheduled Claude Code is, necessarily, a machine with Claude Code installed.

The scan has therefore reported nothing on macOS since the day it shipped.

Traced on a real machine, with a decoy launchd job:

+ report_once cli claude-code '' '~/Library/LaunchAgents/zz.probe.plist' \
              autonomous '' 'launchd, every 6 hours' interval:21600
+ local 'key=cli|claude-code'
+ case "$SEEN" in
+ return 0                                  ← dropped here

The control: the same probe for a tool not otherwise present reported perfectly. claude suppressed, gemini reported, the only difference being whether the tool had already been seen.

Linux and Windows have keyed on the trigger since the first day. Only macOS was missed — and macOS is the one collector CI cannot run. There is now a static parity check across all three, which I verified fails on the old key before it passes on the new one.

The version never moved either

The scan shipped without touching COLLECTOR_VERSION, so a collector that reads schedulers and one that cannot both reported 2.0.0. An empty page could not be told from an undeployed one — the only question worth asking about a zero.

It is 2.1.0 everywhere now, and the page states how many devices report a collector new enough to have looked, with 2.0.0 counted as unknown rather than clean, because it genuinely is:

No device reports a collector at 2.1.0 or newer, so nothing here has looked yet. This is an absence, not a finding — update the collectors before reading the zero.

Two overclaims, same family as the last release

Reach was joined per device and drawn per row, so a curl fetching an installer was shown its machine's MCP servers as its own capability — it read as "this curl can reach your Atlassian." The box now says whose they are.

The day panel drew unrecognised rows solid beside genuinely unattended ones, re-merging the two claims the headline was split to separate. Those lanes are hollow now, with a legend entry that says why.

Two silences

The panel rendered nothing at all when no row had a readable schedule, because the "Not drawn" note was nested inside the scheduled.length ? ternary. Twenty-five undrawable rows produced no timeline and no reason — "no cadence" and "never deployed" looked identical. It now explains itself.

A scheduled job that runs a wrapper script cannot be seen by any endpoint-only approach, and reading the contents of somebody's scripts is the same intrusion the page already refuses for prompts. That limit is now stated beside the other things it does not answer. An unstated limit is the same failure as an unstated source.

The registry, finally read whole

Matching used cli.binaries — four tools have any — while app_names and exe_names sat populated and unread for nineteen more. A desktop app reaching its own vendor's gateway was a process nobody could account for, on a finding whose tool had just been resolved from the domain.

  • Warp added. Its binary is named for the release channel (Warp.app/Contents/MacOS/stable), so nothing derives the product from stable. This was the unexplained process name.
  • Fireflies gains the desktop names it always shipped under.
  • Windows shell and search join the allowlist; they reach substrate.office.com as background M365 activity.
  • Generic task hosts and Squirrel's Update.exe join the unattributable set instead — naming one says an application did something, not which. An allowlist entry would wrongly read as "this program is fine".
  • Dotted service names now offer their shorter prefixes, longest first, so OneDrive.Sync.Service.exe matches the OneDrive already listed — without Microsoft.Notes.exe quietly allowlisting everything Microsoft ships. That last one is asserted in a test.

Checked

Ten process names taken from a real estate, replayed through the built registry: one survives, and it is the curl that should.

Both new page states confirmed rendering, not just the data layer — the coverage note and the empty-day-panel explanation. The claude probe verified reporting mode=autonomous on a real machine after the fix, having been silently dropped before it.

Suites green: portal 612, scanner 209, receiver 280, registry 22. Plus collector parity, build.py --check, both chart copies, and shellcheck.

Not in this release

Deliberately, and they need a decision rather than a guess: the API-key-in-job-definition heuristic and the cross-signal join of scheduled executables against DNS-observed processes — both are new detection and belong together with thought about what they would falsely catch. Widening the scheduler matcher to domains is held with them for the same reason: matching a domain in a scheduled command line is real coverage, and it is also a fresh false-positive class one release after cutting one to remove one.

v0.22.1 - a process we cannot name is not one running itself

Choose a tag to compare

@AmanSK5 AmanSK5 released this 02 Sep 08:23
3fe7b06

The Agentic AI page went live against findings already in the log store, so it read months of DNS telemetry through a lens written for something else. It reported dozens of things running unsupervised. They were Office, the Linux stub resolver, and browser helper subprocesses.

Four separate faults, stacked. None of the fixes needs new signal.

The allowlist matched nothing on Windows

is_allowed_process was an exact lowercase set lookup against a list written in mac and Linux spelling:

onedrive.exe   -> allowed=False     the list has "OneDrive"
outlook.exe    -> allowed=False     the list has "Outlook"
chrome.exe     -> allowed=False     the list has "chrome"
powerpoint.exe -> allowed=False     never on the list at all

So on a Windows endpoint it matched nothing. Two consequences, and the second is the one nobody had noticed: the Microsoft-noise filter never fired there, and the bridge scan has been raising a high-risk finding on every browser that resolved api.github.com.

Names now normalise before matching — lowercased, path and .exe stripped, a macOS X Helper (Role) reduced to X — so one entry covers every shape a platform reports it in. The allowlist gains the Office binaries it never had.

A resolver names nothing, and the code already knew that

is_unattributable held the right idea and too narrow a list. A stub resolver is the querying process for every lookup its machine makes, so naming it says "this device resolved it" and nothing about what wanted the name — the same non-answer as the agent version string it already refused.

systemd-resolved, systemd-executor, mDNSResponder, svchost and the rest join it. Absolutely, not scoped to the Microsoft domains — that scoping was exactly why the Linux resolver survived on every other domain there is.

Half the registry was being read

The portal read cli.binaries and called that "a tool the registry knows". allowed_processes — the browsers, desktop apps and daemons the scanner already declines to raise findings about — was sitting unread in the file the portal loads, so every desktop application in the estate arrived as a mystery process with nobody behind it.

This is the half that matters for an existing deployment: it applies retroactively to findings already stored, so the page corrects on refresh rather than waiting out the lookback window.

The counter contradicted its own docstring

agentic_from documents at length that a blank identity must not be read as a machine identity, because doing so would manufacture the finding the page exists to report. The field obeyed. The counter did not — accountable is identity == "person", so silence fell into unaccountable and the headline said "answer to nobody" about every row where nobody had established anything either way.

The same headline then folded two claims of different strength into one sentence: a thing that reported running unattended, and a process reaching a model that says nothing about who started it. On an estate whose collectors predate mode, the second is every row — so the page asserted autonomy from evidence that never mentioned it.

They are counted apart now, and the weaker claim gets weaker words. authority is three states and unknown is one of them.

One found on the way

_VIA is bounded by ")", so it truncates (via Google Chrome Helper (Renderer)) at the inner bracket and delivers a role that never closes. The suffix match allows for that.

Checked

Replaying the observed process names plus one genuinely scheduled job through the real registry.yaml:

before:  11 things run without being asked. 11 answer to nobody.
after:    1 thing runs without being asked. 1 answers to nobody.
          1 more process reached a model without being a browser
          or a tool the registry knows.

Only curl survives as unrecognised, which is correct — that is the script-with-a-key case the branch exists to catch.

Verified in the running portal against seeded data, not just at the derive layer: headline, chain rows and the overview tile, no console errors. Suites green: portal 603, scanner 198, registry 22.

is_allowed_process had no test of any kind before this; it has 34 now.

This is 0.22.1

Not a choice so much as a consequence. The chart ships the built registry, so widening allowed_processes moves a file under charts/, and CI requires Chart.yaml to move with it — otherwise a repo index carries the change without offering it as an upgrade. The two guards are in the same job, so there is no version of this PR that changes the allowlist and leaves the chart alone.

The usual five, matching the release: 0.22.0 commit exactly: version and appVersion in Chart.yaml, the two receiver image tags in the Kubernetes deployment doc, and the one in receiver/deploy. The receiver:0.22.1 images do not exist until this is tagged.

Deploying

The two halves land differently. The portal fix ships with the chart and takes effect retroactively. The scanner fix does not — there are no CronJob templates in the chart, so the scanner and discovery image tags still need bumping by hand or new findings keep being written with (via onedrive.exe).