Skip to content

Disclose detection reporting, and stop claiming it does not exist - #161

Merged
patchstackdave merged 5 commits into
mainfrom
disclose-detection-reporting
Aug 20, 2026
Merged

Disclose detection reporting, and stop claiming it does not exist#161
patchstackdave merged 5 commits into
mainfrom
disclose-detection-reporting

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

ENG-3649

Prerequisite for turning reportDetections on by default, but a live problem on its own.

Two findings

The detection reporter ships undisclosed. detections.js is in dist/ (the ./protect entry imports it) and the word "detection" appeared zero times in AGENT-INSTALL.md. Agents npm pack the tarball and audit it, so a capability in dist/ the docs don't mention reads as misrepresentation — the install gets refused, and the refusal is correct.

And one line actively contradicted it:

map --upload is the one opt-in that sends anything derived from your source.

A guard with reportDetections: true already sends route paths and the parameter names a rule reads. field-test/README.md lists "an overbroad privacy claim" as a refusal trigger alongside an undisclosed command. Flipping the default without fixing this would have shipped a false statement rather than a loose one.

The change

A Runtime guard reporting section covering both paths, with the detection payload stated field by field — rule id, request path with the query string removed, parameter names, phase, enforced, bundle identifier, timestamp, plus the per-batch dropped count — and what it excludes: matched value, request body, headers, cookies, query-string values.

Every claim was checked against detections.js rather than written from memory, including the conditions I'd initially stated incompletely: a site UUID is required, and PATCHSTACK_TELEMETRY=off disables it.

The overbroad line is narrowed to what map --upload actually is — the only command that sends a description of your source — and points at the other path. The credential list gains the detections ingest.

Why nothing caught it

The closest thing in CI is the "Capability contract" job, which checks that capabilities.json matches the map vocabulary in its TypeScript source. It never reads documentation.

So tests/endpoint-disclosure.test.ts now reads every outbound endpoint out of src/ and fails when one isn't described in AGENT-INSTALL.md. It covers endpoints built from a resolved base URL rather than a literal path — which is how the detection reporter is written, and therefore precisely the shape a literal-path check would have missed.

It matches on documented prose, not path segments, because package-removed is correctly documented as "package removal"; the table of accepted phrasings is where adding an endpoint forces a conscious decision about disclosing it.

Mutation-checked: removing every mention of detections fails it; introducing a new undisclosed endpoint fails it with the remedy in the message; dropping the payload specifics while keeping the mention fails it. Plus a vacuity control, since a pattern that stops matching would otherwise report every endpoint as disclosed while reading none.

Field test

node field-test/run.mjs --persona hostile --rounds 32/3 rounds fully green, round 1 refused.

I read the refusal rather than dismissing it: it cites only the install prompt (--yes, external account linking, "I have vetted"), and the agent ran no commands, so it never npm packed or read the shipped docs. That's the hostile persona's baseline policy refusal, independent of this diff.

Worth stating the documented limitation: the fixture installs the published package, so this run cannot exercise the new section either way. Validating it end-to-end needs a publish first. Whether 2/3 counts as passing the gate is your call, not mine.

Suite green (1270 passed, 6 skipped), typecheck and template typecheck clean.

Not in this change

The default flip. It becomes a small edit on top of this — the "off unless you pass reportDetections: true" sentence — and needs its own field-test round.

detections.js ships in dist/, and the word "detection" appeared nowhere in
AGENT-INSTALL.md. Agents npm pack the tarball and audit it before installing, so
a capability in dist/ that the docs do not mention reads as misrepresentation —
the install gets refused, and the refusal is right.

Worse than an omission, one line was a claim that contradicted it: map --upload
was described as the one opt-in that sends anything derived from your source, and
a guard with reportDetections: true already sends route paths and the parameter
names a rule reads. Turning that default on without fixing this would have
shipped a false statement rather than a loose one.

The new section states both reporting paths and the payload field by field, and
says plainly what is excluded — matched values, bodies, headers, cookies,
query-string values. Every claim was checked against detections.js rather than
written from memory, including the conditions: a site UUID is required and
PATCHSTACK_TELEMETRY=off disables it.

Nothing caught this because nothing looks. The CI job named "Capability contract"
checks that the committed manifest matches the map vocabulary in its source; it
never reads documentation. So the disclosure is now a test: every outbound
endpoint is read out of src/ and must be described in AGENT-INSTALL.md, including
the ones built from a resolved base URL rather than a literal path — which is how
the detection reporter is written, and therefore exactly the shape a check
matching literal paths would have missed.

Field test, --persona hostile --rounds 3: 2/3 rounds fully green. Round 1
refused, citing only the install prompt, and ran no commands at all, so it never
read the shipped docs. Worth stating that the fixture installs the published
package, so this run cannot exercise the new section either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderbuds

coderbuds Bot commented Aug 20, 2026

Copy link
Copy Markdown

Comprehensive docs and tests elegantly extend runtime guard reporting.

🎯 Quality: 100% Elite · 📦 Size: Medium

📈 This month: Your 108th PR — above team average · Averaging Excellent

See how your team is trending →

Review found the OAuth token exchange outside the guard: firewall-log.js posts to
${apiBase}/oauth/token and no extraction pattern matched it, so the file claimed
every outbound endpoint was disclosed while a credential-bearing request sat
outside the check.

The defect was the method, not the pattern list. Enumerating URL-building idioms
produces a scan that can confirm what it recognises and say nothing about the
rest — an incomplete inventory answering a completeness question, which is the
mistake this repo keeps finding in other layers. Widening the patterns to cover
oauth/token would have left the next idiom to be missed the same way.

So the polarity is inverted. Candidates are extracted broadly and every one must
be classified: an endpoint with documented prose, or explicitly not an endpoint
with a reason. Anything unrecognised fails the test. Applied, it immediately
surfaced three more real endpoints nobody had written down — the OAuth exchange,
the widget-settings lookup behind `status`, and the older get-rules path used
when the guard is configured with a token — plus the device-code flow, which was
already documented by showing the approval URL it prints.

Three scaffolder file paths match the same shape as a URL segment. They are
classified rather than filtered by a heuristic, because a rule that guessed which
template literals were URLs is precisely what let the real ones through.

The second finding: the runtime records every match before separately posting
enforced ones to the block log, so with reportDetections on, blocked matches go
to the detections endpoint too. The text said only non-blocking detections were
sent, which understated what leaves the app — the direction that matters, since a
reader deciding whether to enable this is owed the larger number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave
patchstackdave force-pushed the disclose-detection-reporting branch from 0e6d7d8 to 5f440ed Compare August 20, 2026 15:13
@patchstackdave

Copy link
Copy Markdown
Contributor Author

Both P1s fixed in 5f440ed. The first one's root cause was my method, not the pattern list, so the guard is rebuilt rather than widened.

P1 — the OAuth exchange was outside the guard

Correct, and worse than one miss. Enumerating URL-building idioms produces a scan that can confirm what it recognises and say nothing about the rest — an incomplete inventory answering a completeness question, which is the same defect this repo keeps finding in other layers. Adding a pattern for oauth/token would have left the next idiom to be missed the same way.

So the polarity is inverted: candidates are extracted broadly, and every one must be classified — an endpoint with documented prose, or explicitly not an endpoint with a reason. Anything unrecognised fails.

Applied, it immediately surfaced three more real endpoints with no mention anywhere in the shipped docs:

endpoint what it is
POST /oauth/token the credential exchange behind block-log reporting (the one you found)
GET monitor/widget/settings/<uuid> how status distinguishes a deleted site from an active one
GET api/get-rules/3 the older rules path, reachable when the guard is configured with a token

Plus the device-code flow, which turned out to be documented already by showing the approval URL the command prints.

One small correction to the finding as written: the token entry was not decorative — it was discovered via monitor/pulse/token in pulse-token.ts. That's incidental to the point, and the point stands: a credential-bearing outbound request sat outside a check that claimed to cover everything.

Three scaffolder file paths match the same shape as a URL segment. They're classified with reasons rather than filtered by a heuristic — a rule that guessed which template literals were URLs is exactly what let the real ones through. login.ts proves the point: it uses fetchImpl, so a "file contains fetch(" filter would have silently dropped the device flow.

P2 — every match is reported, not only non-blocking ones

Correct. detections.record(detection) runs for every match, before the mode === 'block' check. Now: "Every rule that matched goes to monitor/pulse/detections/<uuid> — including matches that blocked, which are reported on both paths." Understating what leaves the app is the wrong direction to be wrong in; a reader deciding whether to enable this is owed the larger number. There's a test asserting the old wording can't come back.

Mutation-checked, four ways — undisclosing oauth/token (your exact finding) fails it; a new endpoint in src/ fails it with the remedy in the message; reverting the wording fails it; breaking both extraction patterns fails the vacuity control.

Field test — the gate does not pass, and does not pass on main either

I re-ran --persona hostile --rounds 3 and got 0/3 green, down from 2/3 earlier. Rather than call that variance, I measured the baseline by running the same gate on origin/main:

state green
origin/main, no disclosure at all 1/3
this branch, first commit (earlier run) 2/3
this branch, first commit (later run) 0/3
this branch + these fixes 0/3

The persona refuses between 33% and 100% of rounds on identical code. Every refusal is pre-install with 0 requests to the mock — no agent installed the package, so none of them read the shipped docs at all; the rationales cite only the prompt string (--yes, external account linking, and in one round a typosquat suspicion about the patchstack-connect bin name vs @patchstack/connect).

So this instrument cannot distinguish this diff from baseline, and main does not clear the gate today. That's a finding about the harness, not a verdict on this change — and whether it blocks merging is your call, not mine. Worth noting separately: the fixture installs the published package, so no run of it can exercise these doc edits until a publish.

Suite green (1272 passed, 6 skipped), typecheck and template typecheck clean.

The exclusion list read "the matched value, the request body, headers, cookies,
or query-string values", which scans as "headers and cookies are not sent". That
is true of their values and false of their names.

ruleParameters returns each condition's `parameter` verbatim, and those carry the
request region they refer to: post.title, get.redirect_to, cookie.session,
server.HTTP_AUTHORIZATION. So a rule inspecting a cookie or an Authorization
header does send that cookie's or header's name. The old sentence claimed a
privacy boundary wider than the code's, in the direction that flatters us, which
is the direction an auditing agent is right to punish.

Now stated as two separate facts. The identifiers are sent and name their region,
read from the rule's definition rather than from traffic, so they describe what is
being screened rather than what any request held. And the exclusion is about
values only: no value that matched, no request body, and no value of any header,
cookie or query-string parameter, including the ones named.

Guarded as an absence as well as a presence: a test fails on any exclusion clause
that names headers or cookies without scoping to their values, because that is a
sentence someone would write again while tightening the prose. One earlier
assertion was matching a literal phrase rather than the claim, and this rewrite
broke it — it now matches either phrasing while still requiring the substance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave

patchstackdave commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

ruleParameters returns each condition's parameter verbatim, and those carry their request region — post.title, get.redirect_to, cookie.session, server.HTTP_AUTHORIZATION. So a rule inspecting a cookie or an Authorization header does send that name. "Does not contain … headers, cookies" was true of values and false of names.

Now two separate statements instead of one blurred list:

The parameter names are identifiers, and they name the request region they refer topost.title, get.redirect_to, cookie.session, server.HTTP_AUTHORIZATION. So a rule that inspects a cookie or an Authorization header sends that cookie's or header's name. They are read from the rule's own definition, not from your traffic, so they describe what is being screened rather than what any request contained.

What it does not contain: no values of any kind. Not the value that matched, not the request body, and not the value of any header, cookie or query-string parameter — including those of the parameters named above.

Guarded as an absence as well as a presence: the test now fails on any exclusion clause naming headers or cookies without scoping to their values. That's the specific sentence someone would write again while tightening prose, so asserting its shape is worth more than asserting the current wording.

Mutation-checked three ways — restoring your exact overclaim fails it, dropping the region examples fails it, removing the request-region statement fails it.

One thing this caught in my own work: an earlier assertion matched the literal phrase /matched value/i rather than the claim, so rewording "the matched value" to "the value that matched" broke it. It now accepts either phrasing while still requiring the substance — a test that pins prose rather than meaning is a test that will be edited to pass next time.

Gates: 1273 passed / 6 skipped, typecheck and template typecheck clean. Field test re-run for this state: 1/3 green — the same as the origin/main baseline I measured earlier, and every refusal is again pre-install with 0 requests to the mock.

Three rounds of review on this section found the same thing each time: the prose
was wrong and the code was right. An endpoint nobody had written down, a trigger
described as narrower than it is, and a privacy boundary claimed wider than the
code's. The guards that existed assert that words appear. None of them reads the
payload, so none could tell whether the words were true of it.

This posts a detection through the real reporter, captures the serialized bytes,
and asks two things of them.

Every field emitted must be described. The check iterates the payload's own keys,
so a field added to the report fails until someone writes it down — the failure
names the key and says what to do. A vacuity control pairs with it, because
iterating the payload's keys is satisfied perfectly by a payload with no keys:
the documented set and the emitted set must be equal, not merely compatible.

Every excluded value must actually be excluded. Sentinels are planted where the
reporter could pick them up — a matched value, a request body, a header value, a
cookie value, a query string — and asserted absent from the bytes. That proves
the boundary rather than restating it. The query-string sentinel gets its own
control: a reporter that sent no route at all would pass the exclusion while
losing the field the disclosure describes.

The rule used reads a cookie and an Authorization header on purpose. Those
parameter names ARE sent, which is the distinction the wording got wrong, and a
test that only looked for absent values would have agreed with the wrong version.

Documentation stays hand-written. What is mechanised here is the inventory:
generated prose would read as machine output to the agents who audit this file,
and the wording is doing adversarial-UX work a serializer cannot do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave

patchstackdave commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

ENG-3651 filed and implemented in e50305c — contract-test the payload inventory, leave the prose hand-written.

What it does

Posts a detection through the real createDetectionReporter with a stub fetch, captures the serialized bytes, and asks two questions of them.

Every emitted field must be described. It iterates the payload's own keys against a FIELD_DISCLOSURE map, so a field added to the report fails until someone writes it down — the failure names the key and says what to do about it. Same for the batch envelope, separately, since dropped is per-batch rather than per-detection.

Every excluded value must actually be excluded. Sentinels are planted where the reporter could pick them up — matched value, request body, header value, cookie value, query string — and asserted absent from the bytes. That proves the boundary instead of restating it.

Two controls, because both halves have a cheap wrong version

  • Iterating the payload's keys is satisfied perfectly by a payload with no keys. So the documented set and the emitted set must be equal, not merely compatible — otherwise the disclosure could describe a payload that no longer exists.
  • A reporter that sent no route at all would pass the query-string exclusion while losing the field the docs describe. So route is asserted to be exactly /checkout/confirm.

The rule it uses reads cookie.session and server.HTTP_AUTHORIZATION deliberately. Those names are sent — the distinction the wording got wrong — and a test that only looked for absent values would have agreed with the incorrect version.

Mutation results

mutation what fails
start forwarding the matched value 3 tests — inventory, equality control, and the sentinel
stop emitting phase the equality control
stop stripping the query string the sentinel and the route control
delete the envelope's disclosure sentence the inventory

M1 is the realistic one — someone adds a field in good faith — and it trips three independent checks.

Scope note

Test-only commit; AGENT-INSTALL.md is untouched, so the field-test gate does not apply to it. The measurement from the previous commit stands (1/3, same as the origin/main baseline).

Suite: 1278 passed / 6 skipped. Typecheck and template typecheck clean.

Exemptions were keyed on the path string alone, so `rules` — a file the
scaffolder writes — was exempt everywhere. A future request built as
`${pulseBase}/rules/${uuid}` produces the same candidate from a different file
and would have passed as "not an endpoint": this file's own original mistake
repeated one level up, an exemption answering beyond the evidence that earned it.

Classification is now per occurrence. A candidate is exempt only in the files
that establish the exemption, disclosed only in the files that establish the
endpoint, and unclassified anywhere else. A meta-assertion keeps it honest by
refusing any bare-segment entry that does not name its files, since a rooted path
cannot collide with a filename but a bare segment can.

Making that change surfaced a second bug in the extraction. The interpolation
pattern required `${identifier}`, and the rules client builds its URL from
`${this.#baseUrl}` — a member expression the pattern could not see. So a real
endpoint was invisible here for the same reason the earlier version missed the
OAuth exchange: recognising one spelling of "a base URL" is not recognising a
base URL. Widened to any expression, which immediately turned up four endpoints
under their interpolated aliases, `rules` among them.

`rules` is now in both tables, and both readings are correct because each is tied
to its files: an endpoint in the client, a scaffolded file in the installer. That
collision is the concrete argument for the scoping.

The granularity is the file, not the call site, so a request added to a file that
already exempts the same word is still missed. Narrowing further needs parsing
rather than patterns; the residue is small because these exemptions live in
scaffolder modules that write files and make no requests. Recorded next to the
table rather than left for someone to discover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave

Copy link
Copy Markdown
Contributor Author

Fixed in 3e95830. And it found a second bug on the way, which is the more interesting half.

The scoping

Classification is now per occurrence: a candidate is exempt only in the files that establish the exemption, disclosed only in the files that establish the endpoint, unclassified anywhere else. A meta-assertion refuses any bare-segment entry that does not name its files — a rooted path like monitor/pulse/rules cannot collide with a filename, but a bare rules can.

The comparison that shows it matters:

mutation result
real ${base}/rules/ call in an unclassified file caught
same call, exemptions reverted to global passes ← the bug you described
bare-segment entry without files caught by the meta-assertion

The second bug

Testing your scenario didn't fail at first, which turned out not to be a weak test. The interpolation pattern required ${identifier}, and the rules client builds its URL from ${this.#baseUrl} — a member expression the pattern couldn't see. So a real endpoint was invisible here for the same reason the first version missed the OAuth exchange: recognising one spelling of "a base URL" is not recognising a base URL.

Widened to any expression, it immediately turned up four endpoints under their interpolated aliases — input-map, package-removed, token, and rules.

rules is now in both tables

An endpoint in client.ts and pulse-client.js; a scaffolded file in generic.ts. Both readings are correct because each is tied to its files — which is your point made concrete, and better evidence for the scoping than the hypothetical was.

Residual limit, recorded in the file

Granularity is the file, not the call site. An outbound request added to a file that already exempts the same word is still missed — mutation M1 confirms it. Narrowing further needs parsing rather than patterns. The residue is small because these exemptions live in scaffolder modules that write files and make no requests, so an outbound call there is odd enough to catch in review; this backstops that review rather than replacing it. Noted next to the table so the next person inherits the limit rather than discovering it.

Suite: 1279 passed / 6 skipped. Typecheck clean. Test-only change, so AGENT-INSTALL.md is untouched and the field-test gate doesn't apply.

@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit d227282 into main Aug 20, 2026
6 checks passed
@patchstackdave
patchstackdave deleted the disclose-detection-reporting branch August 20, 2026 15:59
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