Skip to content

feat(discovery): ADR Discovery as a standalone top-level module - #53

Closed
pengyuzhang wants to merge 15 commits into
mainfrom
feat/discovery-standalone-module
Closed

feat(discovery): ADR Discovery as a standalone top-level module#53
pengyuzhang wants to merge 15 commits into
mainfrom
feat/discovery-standalone-module

Conversation

@pengyuzhang

Copy link
Copy Markdown
Collaborator

Adds the ADR Discovery endpoint collector and makes it a top-level module alongside Sensor/ and Detection/, rather than a subpackage of the Sensor.

What is here

The collector. Discovery/adr_discovery/ inventories the AI tools present on an endpoint: desktop apps, CLI coding agents, IDE and browser extensions, local model runtimes, MCP servers, and the skills, hooks and scheduled jobs that make an agent programmable. Ten probes feed a six-stage pipeline — enumerate, fingerprint, infer, resolve, rank, report — against a 42-entry catalog, with an open-world scorer that queues probable-AI-but-unrecognized surfaces for review instead of dropping them.

Its own package. adr-discovery console script, own pyproject.toml and lockfile. The tomli dependency and the catalog.json package-data entry moved off the Sensor, since both existed only for discovery. Standard-library-only on Python 3.11+, so it deploys to hosts that cannot reach PyPI.

Why it is not part of the Sensor. Nothing under discovery/ ever imported from adr_sensor; the only coupling in the other direction was a single discover subcommand dispatch. The two planes have different dependencies and different deployment stories.

Testing

Two instruments, documented separately:

  • Discovery/tests/FIXTURE_SUITE.md — the fast per-commit suite. 241 cases build synthetic endpoints on disk and run a real scan through the same discover() the CLI uses. 490 checks, about four seconds, any CI box.
  • Discovery/tests/README.md — the end-to-end measurement. Three VMs (macOS, Ubuntu, Windows), a manifest of real tools installed per OS covering all 42 catalog entries, scored as TP/FP/FN/DUP against what the collector reports. This one is a spec; it is not implemented yet.

They are complementary. Fixtures have a perfect oracle but only contain situations someone imagined, so they catch regressions. The VM run has input nobody predicted, so it discovers defects — which is not theoretical: the usr-merge duplication and the missing /usr/lib/node_modules root in this branch were both found by scanning a real Ubuntu container while 400+ fixture checks stayed green.

CI gains a Discovery job on the same 3.9–3.13 matrix as the Sensor.

Verification

  • 241 fidelity cases / 490 checks pass
  • Sensor 123 tests unaffected
  • both packages lint clean
  • live scan: 31 assets, 0 errors
  • built wheel contains catalog.json

Note on #52

This supersedes #52, which proposed the README Discovery/ row against main back when the directory did not exist. The same README changes are included here, and the links now resolve. #52 should be closed rather than merged.

🤖 Generated with Claude Code

pengyuzhang and others added 15 commits August 10, 2026 19:50
Adds ADR Discovery as a fifth capability: finding which AI tools are
present on an endpoint, as distinct from collecting telemetry from the
tools already known to be there.

Marked as not included in the current open-source release, consistent
with how ADR Prevention is listed.
Discovery answers the prior question the other four capabilities assume
away — which AI tools are on an endpoint at all — so it reads more
naturally ahead of Observability. Reorders the list and the lead-in
sentence to match; no wording changes to the items themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GK2DWwdgH8UcGQLMBYpRsT
Implements Plane A of ADR Discovery: an inventory of the AI tools and agents
present on an endpoint, following the architecture in the design doc.

Probes read the machine only through an injected DiscoveryEnv (filesystem root,
process table, socket table, registry view, HTTP prober, subprocess runner), so
the pipeline can be pointed at a fixture world and graded rather than only run.

  Stage 0  DiscoveryEnv          bounded reads, depth caps, symlink-loop safe
  Stage 1  probes                mcp, cli_agent, app, runtime, extension
  Stage 2  openworld             weak signals over what the catalog rejected
  Stage 2b process               the agent-to-MCP-server binding, observed
  Stage 3  resolver              union-find on identity keys, conflict blocking
  Stage 4  liveness              running / installed / declared_only
  Stage 5  diff                  deltas per asset_id, plus fleet fan-out

Fingerprints ship as catalog.json rather than code, so a newly released tool is
a catalog update instead of a client release.

Redaction runs in the collector before a snapshot is written: argv keeps flag
names and drops their values, URLs lose query strings, env blocks keep names
only, and personal paths are denied centrally in BaseProbe.

Run with `adr-sensor discover`; `--dry-run --explain` prints exactly what would
leave the machine, per probe and per field.
…suite

Implements every case in Appendix A of the design document: 192 cases across
AI tools, MCP servers, skills and AI agents, run as a scorecard rather than a
pass count. 365 checks, all passing.

The suite drove the module rather than the other way round. What it added:

  probes/agent_artifact.py   skills, commands, output styles, hooks across 30+
                             lifecycle events and 5 handler types, plugins and
                             what they bundle, instruction files and their
                             import graph, Cursor rules
  probes/scheduler.py        launchd, cron, systemd timers, Windows tasks, CI
                             workflows, dispatch sessions, cloud delegation
  probes/identity.py         which account an agent authenticates as, and how
  probes/location.py         devcontainer and remote-workspace declarations

and extended the existing probes to the current landscape: twelve more CLI
agents, AI browsers as their own kind, model weights as inventory, every
Chromium profile and Firefox XPIs, nvm/mise/uv/go install channels, WSL
locations; MCP configs for Windsurf, Zed, VS Code, goose, Cline and JetBrains,
enterprise managed settings on three platforms, MDM policy by preference domain
and registry key, .mcpb bundles, and richer supply-chain verdicts.

Three resolver corrections came out of cases that disagreed with each other:

  - conflict is checked across the whole merged group, not pairwise, so two
    users' agents no longer unite through one system-wide binary
  - attributes (state dirs, model stores, ports) bind to the install they
    belong to instead of standing alone as duplicate assets
  - version precedence is runtime over packaging, and a disagreement is
    recorded rather than silently resolved

Verified on a real endpoint as well as on fixtures: 31 assets, 0 errors, 2.3s,
inside the 3s budget.
Every finding reproduced, including the one I could not reproduce at first -
that was my test placing the canary a directory too high, not the report being
wrong. Fixes in the order they matter, with a regression case for each.

Filesystem boundary, the two critical ones. Paths are canonicalized before
access, and both root containment and the deny-list are enforced on the
resolved target rather than on the name we were handed. A permitted
~/.claude.json symlinked into ~/Documents no longer reads; a relative segment
inside a config no longer climbs out of the tree. A denied path asked for
outright is refused quietly, because enumerating a home directory meets those
constantly; a permitted path that resolves into one is recorded, because that
is the shape of a deliberate bypass.

Resolver conflict is now a property of the merged group for every identity
field, not just for owner. A bridge observation sharing a key with two
unrelated tools no longer unites them - which was the exact failure the
docstring on conflicts() warns about, left half-fixed when the owner case was
addressed.

Production collection: listening sockets are collected (every port-based
detection passed on fixtures and was inert on a real endpoint), the process
table is filtered to this user rather than merely described as being, and
subprocess output is bounded as well as timed.

Detection hardening: download-and-execute recognizes the spellings it actually
takes; hostnames are parsed and compared on a dot boundary, so evilcorp.example
is not corp.example and api.openai.com.evil.test is not a provider; redaction
covers JWTs, bearer headers, PEM blocks and credential flags by name; the nix
identity drops the version so an upgrade reads as a version change.

Reporting: the per-config cap applies wherever servers come from, and caps,
walk truncation and refusals appear in snapshot coverage. A file sitting
exactly on the read ceiling is whole, not truncated.

Listener probing is concurrent. Adding real socket collection took the live
scan to 10.7s; a stalled port now costs a shared timeout rather than one each,
and the scan is back to 2.8s inside the 3s budget.

406 checks pass across 208 cases, including 18 new hardening regressions.
All eight reproduce. Fixed in the order they hurt, with a regression each.

Failure isolation is now per record rather than per probe. The module promises
partial results plus an error record and enforced that at probe granularity
while failures happen per entry, so one malformed env block - a list where a
map belongs - erased every valid server beside it. That is a denial of
inventory an attacker can arrange with one config line.

Runtime MCP identification requires MCP-specific evidence. "npx" is not
evidence: it runs eslint far more often than it runs a server, and "server" as
a substring matched my-server-test.py. Ordinary children of an agent no longer
become high-severity undeclared findings. What strictness would have cost is
paid back by correlation: a declared server whose name says nothing is
recognized the moment a config on the host declares the command that is
running.

Project approval compares path components. A raw prefix test said
/dev/application lives under /dev/app, extending one project's approvals to a
neighbour that merely shares the first letters of its name. Approval is also
three-valued now - declared-but-unapproved is not a synonym for enabled.

Input validation: a string where an argument array belongs is one argument
rather than nine characters, which was changing both identity and the pinning
verdict; catalog fingerprints claimed twice are rejected at load and reported
rather than silently resolving to whichever entry came last.

Snapshot invariants: the resolver guarantees unique asset ids and marks any it
had to disambiguate; a diff refuses two hosts unless asked explicitly, refuses
an ambiguous snapshot, and normalizes every fingerprint field, so a stdio
server with no endpoint no longer raises.

429 checks pass across 216 cases. Live scan: 3.0s, 31 assets, no errors, and no
findings on a machine that has nothing to find - which is the point.
All seven reproduce on 0706a11, including both halves of the critical one.

Configuration arguments are now redacted the way command lines already were.
Sanitizing control characters is not redaction, and a config's argument array
carries credentials as readily as an argv does: --token and a bearer header
both reached the snapshot verbatim. Identity is built from the launch as
written and storage from the launch redacted, because an identity derived from
redacted text stops matching the moment a launch carries a credential flag.

That split is also what fixes correlation. The two channels were normalizing
differently - a config writes /usr/local/bin/node, a process table reports node
- so a declared server that was plainly running was never recognized. One
canonical launch identity now serves both, with the executable reduced to a
basename and .exe dropped.

Record-level isolation and type validation now apply to every MCP source rather
than to the main config loop alone. A managed policy of the wrong shape, or one
bundle manifest with a string where an object belongs, used to return an empty
inventory with the valid discoveries inside it.

Environment expansion parses whole variable tokens in one pass: substituting by
substring rewrote the names of other variables, so $PATH_EXTRA became
/bin_EXTRA once PATH was set.

realpath() applies the same denial decision as every other entry point, so
where a denied symlink points is no longer disclosed by the one method that
skipped the check.

And the check-then-open race is closed by comparing the opened descriptor
against the validated target: a path swapped in between is now a refusal with a
recorded reason rather than a read.

446 checks pass across 225 cases. Live scan: 2.8s, 31 assets, no errors.
All six defects and the accuracy concern reproduce on 2c5a096.

Credential-flag redaction now normalizes syntax before deciding. --api_key and
--api-key are one flag, and an inline value may be separated by = or by :. The
colon form was worse than reported: it leaked its own value and left the parser
one token out of step, so the next real flag was eaten as a value and that
value walked out in the clear. A flag is never treated as another flag's value
now.

Supply-chain pinning parses options before picking an operand. The image was
"the first argument containing / or :", which a volume mount or a published
port satisfies, so docker run -v /host:tag vendor/server:latest was called
pinned - a wrong verdict in the direction that hides risk. The same fix covers
npx and uvx, where a registry URL or a cache path supplied an apparent version.
Ambiguity now resolves toward unpinned, and the shared parser replaced a second
copy that had grown in the process probe.

Registry policy is validated after decoding, the way the macOS preference
payload already was. Windows executable identity is case-folded, so the same
node.exe reported in different casing no longer splits config from runtime.
Hostnames come from a URL parser rather than a split on the first colon, which
was turning http://[::1]:8000 into "[".

And a bundle that declares nothing runnable is recorded as a malformed bundle
rather than as an MCP server with an empty command - inventing a server inflates
the count and can raise a finding about something that cannot run.

463 checks pass across 231 cases. Live scan: 2.8s, 31 assets, no errors.
All six reproduce on cc96b79, and one of them is the consequence of an earlier
fix rather than an oversight.

Identity no longer contains secret material. Round three made identity use
unredacted arguments so the config and runtime channels would agree; that made
rotating a token read as an uninstall followed by an install, and made the
public asset id a hash of a credential. Both channels now redact through the
same function *inside* server_identity, so callers cannot disagree about it and
a rotated secret leaves identity untouched.

The handwritten TOML subset is gone. It split quoted keys on their dots and
arrays on commas inside strings, so [mcp_servers."team.server"] became a server
named "team" with no command and no arguments - silently. tomllib is used where
available with tomli behind it, added as a dependency for Python 3.9. The YAML
subset that remains now reports constructs it cannot represent instead of
mis-parsing them.

Pinning reads specifications rather than looking for an "@": 1.x, 1.2.*, beta,
npm:other, workspace:* and github:user/repo are all mutable and were all called
pinned. Docker option parsing is inverted to recognize booleans and treat
everything else as value-bearing, because a table of value-taking options is
only ever as complete as the day it was written - --runtime was already missing.
Ambiguity resolves toward unpinned.

Redaction handles a value that begins with a hyphen and Windows /flag:value
syntax, and one normalization now serves identity, correlation and
classification alike, so DOCKER.EXE and C:\Tools\npx.exe no longer fall through
to "unknown, pinned".

A live scan then caught a false positive the round-four tightening had missed:
a shell's argv is arbitrary user text, and a snapshot wrapper whose command line
mentioned a path containing "mcp" was being reported as an undeclared server. A
shell is never an MCP server; one a config genuinely declares is recovered by
correlation.

481 checks pass across 237 cases. Live scan: 2.8s, 31 assets, no errors, and
the two MCP servers this machine actually declares.
…ence

Found by scanning a real Ubuntu 24.04 container rather than a fixture.

/bin is a symlink to usr/bin on any usrmerge system, so one binary has two
spellings. The npm bin symlink is relative, so resolving it against the literal
parent produced /lib/node_modules/... by way of /bin and /usr/lib/node_modules/...
by way of /usr/bin: two merge keys for one file, and every PATH-installed agent
counted twice on every mainstream Linux distribution. Relative link targets now
resolve against the canonicalized parent, and the PATH scan deduplicates
directories by identity rather than by spelling, which also stops it walking
every binary on the box twice.

/usr/lib/node_modules joins the global npm roots. Both the distro nodejs package
and the NodeSource builds install there, so its absence meant no package
observation was ever raised on mainstream Linux: the version had to come from
executing the binary, and the pkg: merge key was never available.

config_scope resolves by precedence rather than by arrival order. A server
pushed by enterprise policy that the user had also declared reported as "user"
because that config happened to be read second, inverting the one field an
operator uses to tell corporate policy from something an employee added.

Packaging: adr_sensor imports the observability plane lazily, so the collector
runs on an endpoint that has only the standard library, and catalog.json is
declared as package data so the installed wheel actually contains it.
Discovery was never part of the Sensor. It shared a package only because that
is where it was first written: nothing under discovery/ imported from
adr_sensor, and nothing in adr_sensor reached into discovery except one
subcommand dispatch in the CLI. The two planes have different dependencies and
different deployment stories, and the repository layout now says so.

Sensor/adr_sensor/discovery/ becomes Discovery/adr_discovery/, and
Sensor/tests/discovery/ becomes Discovery/tests/. The package ships its own
pyproject with an adr-discovery console script in place of `adr-sensor
discover`. The tomli dependency and the catalog.json package-data entry move
with it, because both existed only for discovery and the Sensor wheel should
not carry either.

With discovery gone the lazy-import scaffolding in adr_sensor is dead. Its
whole purpose was to let the collector run on an endpoint that has only the
standard library, without dragging in tabulate by way of .observer. The package
__init__ and the CLI both go back to importing the observability plane
directly.

CI gains a Discovery job on the same 3.9 through 3.13 matrix as the Sensor,
behind a stable "Discovery tests" check name.

Nothing about the collector's behaviour changed. 490 fidelity checks pass
across 241 cases from the new location, the Sensor's 123 tests are unaffected,
both packages lint clean, a live scan still reports 31 assets with no errors,
and the built wheel contains catalog.json.
The suite is the least self-explanatory part of Discovery. It reads like a
fixture pile until you notice that every case builds a real endpoint on disk
and runs a real scan through the same entry point the CLI uses, and that the
output is a scorecard rather than a pass count on purpose. tests/README.md
covers the World DSL, the expectation helpers, the five groups, and what makes
phantom and counting cases carry more weight than presence checks.

Writing it turned up a bug in the runner. `only` was argv[1] unconditionally,
so `run_suite.py -v` filtered for cases whose id starts with "-v" and reported
"0/0 checks pass" - the one invocation that asks to see every check silently
ran nothing, and said so in a way that looked like success. Only a bare word is
a filter now.

490 checks across 241 cases still pass, and the four documented invocations all
do what the README says they do.
…-end run

The old tests/README.md documented the fixture harness - the World builder,
the expectation helpers, how to add a case. Useful, but it described how the
fast suite is implemented rather than how the collector is measured, and
someone asking "is Discovery accurate?" found no answer in it.

tests/README.md is now the end-to-end methodology. Three VMs, one per
operating system, because launchd, the Windows registry, Task Scheduler and
GUI app bundles are exactly the surfaces four probes exist to read and no
container has any of them. A run restores a clean snapshot, scans for a
baseline, installs a manifest of real tools, scans again, and scores the delta
against the manifest. The comparison is the result.

The manifest is grouped the way the collector reports: AI tools split by
evidence channel, MCP servers split into declaration sites and launch forms,
the programmable surface, and agents by liveness. It covers all 42 catalog
entries - a catalog entry with no manifest row is a tool we claim to recognize
and never verify. Negative controls are a first-class table, since without
them a collector that reports everything scores perfectly.

Scoring separates DUP from TP rather than folding it in. A tool installed once
and reported twice is not a partial success; it inflates a fleet inventory,
and it is the defect class that has recurred most.

The fixture-harness documentation moves to tests/FIXTURE_SUITE.md, unchanged
apart from a pointer. The two instruments are complementary: fixtures have a
perfect oracle and only contain what someone imagined, so they catch
regressions; the VM run has input nobody predicted, so it discovers defects.
Defects found there should come back as R-group cases.
@pengyuzhang
pengyuzhang deleted the feat/discovery-standalone-module branch August 22, 2026 21:04
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.

2 participants