Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions .github/workflows/collect.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ name: collect
# a run - that one happened, when, how long it took, what the steps were called, that an
# artifact of N bytes exists - and not its subject or its contents.
#
# AND THE SIGNED-IN SURFACE IS DIFFERENT. MEASURED 2026-09-22, AND IT IS NOT SAFE.
#
# The paragraph above is scoped to ANONYMOUS REST, where it still holds. This comment used
# to leave the rest at "treat that as unknown rather than safe". It is no longer unknown.
#
# Runs 35762363585 and 35762574315, checked both ways on the same run:
#
# anonymous, web UI run page the input values appear NOWHERE in 236 KB of HTML,
# not even collapsed. Run name, status, duration, step
# names, annotations, artifact name/size/digest only.
# anonymous, web UI job log "Sign in to view logs" - refused, agreeing with REST 403.
# SIGNED IN, job log EVERY dispatch input passed through `env:` is echoed
# VERBATIM, because Actions prints a step's env block:
# TOPIC: probe
# PRIOR: PROBE-START length=1200 xxxxxxxx...
#
# So on a PUBLIC repository the topic and the prior are readable by anyone with a GitHub
# account. Not by a passer-by, and not a secret either. The 403 is an authentication wall,
# not a need-to-know one.
#
# Nothing here is the place to fix that - passing an input through `env:` is exactly the
# rule that keeps it out of a shell (see below), and routing it around the log would mean
# routing it around that protection. What changes is the CLAIM: do not read "the topic
# appeared in none of the readable responses" as "the topic is private". Research a subject
# you would not publish, and use a private repository or a different route.
#
# bin/disclosure.mjs cannot see this and should not: it probes unauthenticated on purpose,
# because a probe that quietly authenticated would answer a different question. A clean
# disclosure report means "a stranger learns the shape", never "nobody learns the subject".
#
# That is why the naming rules above are the primary control rather than a minor one: the
# names are the disclosure surface, and they are the part this workflow governs.
#
Expand Down Expand Up @@ -90,10 +120,24 @@ on:
# it - its own "10"s are about concurrency and background steps. So treat three as the
# remaining budget, not as a guarantee, and expect a loud failure rather than a quiet one.
#
# The other half of that check came back UNANSWERED: nothing found says whether a single
# input value has a length limit. It matters here because `prior` is free text, and
# silent truncation would corrupt a prediction while leaving it looking intact. Unknown,
# and recorded as unknown.
# PER-INPUT LENGTH: NOTHING TRUNCATES. MEASURED 2026-09-22, after the corpus failed.
#
# This was recorded as unknown, and search could not close it. The one on-topic capture
# was a 2024 community question asserting "its length is limited to 1024 characters",
# whose only reply is a dormancy bot - nobody ever confirmed it. So it was measured
# instead, by dispatching a payload of known length with a marker at the very end:
#
# 1200 chars run 35762363585 accepted, arrived as 1200, PROBE-END intact
# 8000 chars run 35762574315 accepted, arrived as 8000, PROBE-END intact
#
# The 1024 figure is NOT what this repository observes, and the dangerous case does not
# occur: no silent truncation, so a registered prior cannot be quietly corrupted while
# still looking intact.
#
# What is still unknown, stated rather than rounded off: WHERE the ceiling is. It is
# above 8000, and locating it costs one dispatch per probe, so it was not bisected. A
# realistic prior is a few hundred characters, which is an order of magnitude inside
# what has actually been shown to work.
inputs:
topic:
description: 'What to research. Visible to anyone who can read this repository.'
Expand Down
54 changes: 54 additions & 0 deletions docs/adr/0035-a-privacy-claim-is-measured-not-asserted.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,57 @@ cannot make — the same reason `client_ref` warns rather than refuses.
- `bin/disclosure.mjs` is read-only and unauthenticated, so it is safe to run against a
repository you do not own — which is also the only way to check somebody else's claim
about their own exposure.

## Amendment, 2026-09-22 — the signed-in surface was measured, and it is not safe

This ADR left a hole and named it: *"what a signed-in user with read access sees in the web
UI, which is a different surface from the REST API. Treat that as unknown rather than safe."*

It is no longer unknown. Runs `35762363585` and `35762574315`, checked both ways:

| surface | result |
|---|---|
| anonymous, web UI **run page** | the dispatch input values appear **nowhere** in 236 KB of HTML, not even collapsed |
| anonymous, web UI **job log** | *"Sign in to view logs"* — refused, agreeing with the REST `403` |
| **signed in**, job log | **every input passed through `env:` is echoed verbatim** |

The third row is the finding. GitHub Actions prints a step's `env:` block into the log, so
the log carries:

```
TOPIC: probe
PRIOR: PROBE-START length=1200 xxxxxxxx...
```

**On a public repository, that makes the topic readable by anyone with a GitHub account.**
Not by a passer-by — the `403` is real — but the wall is authentication, not need-to-know.

### What this changes, and what it does not

**The measurement in the body above stands.** Every row of it was scoped to unauthenticated
REST and every row is still true. What was wrong is the inference a reader would draw from
it, and this ADR is the reason they would draw it: a table of refusals invites the
conclusion that the subject is private, and it is not.

**It is not fixed here, deliberately.** Passing an input through `env:` is the same rule
that keeps it out of a shell (ADR-0020). Routing it around the log would mean routing it
around that protection, and trading a disclosure to signed-in readers for a code-execution
surface is a bad trade. The honest response is the accurate claim, not a workaround.

**`bin/disclosure.mjs` is unchanged and should be.** It probes unauthenticated on purpose —
a probe that quietly authenticated would answer a different question while looking like this
one. What changes is how its output must be read: a clean report means *a stranger learns
the shape*, never *nobody learns the subject*.

### The rule this is the third instance of

Twice this ADR has recorded something that felt settled and turned out untrue when run —
first the header's claim that inputs are visible, then this. Both times the error was in the
step from *what was measured* to *what it means*, not in the measurement.

So the standing correction is narrower than "measure it": **state the scope of the
measurement in the same breath as its result**, because a true finding with its scope left
off is how an overstatement gets built on top of honest work.

**Practical consequence:** research a subject you would not publish, and this route is the
wrong one. Use a private repository, or collect locally.
22 changes: 19 additions & 3 deletions research-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,24 @@ That is why keeping the topic out of the run name and the artifact name is the *
control rather than a minor one: those names are the disclosure surface, and they are the
part the workflow governs.

**Not measured, and so not claimed:** what a signed-in user sees in the web UI, which is a
different surface from the REST API. Treat it as unknown rather than as safe.
**Measured 2026-09-22, and it is not safe: a signed-in reader sees the topic.** This used to
say the web UI was unknown. It is not. Actions prints a step's `env:` block into the job
log, and every dispatch input reaches a step through `env:` — so the log carries
`TOPIC: <your topic>` verbatim. Anonymously the log is refused (`403`, and the web UI says
*"Sign in to view logs"*), but on a **public repository that wall is authentication, not
need-to-know**: anyone with a GitHub account is through it.

The anonymous table above is still true, every row. What was wrong is the inference — a list
of refusals reads like "the subject is private", and it is not.

This is not worked around, deliberately: passing inputs through `env:` is the same rule that
keeps them out of a shell, and routing around the log would mean routing around that. And
`disclosure.mjs` cannot see it *by design* — it probes unauthenticated, because a probe that
quietly authenticated would answer a different question. Read a clean report as **"a
stranger learns the shape"**, never as "nobody learns the subject".

**So: to research something you would not publish, do not use this route.** Use a private
repository, or collect locally.

**Going private is not the reflex fix.** On a GitHub Free plan — which this account is on —
converting a repository to private makes its protection rules and environment secrets
Expand Down Expand Up @@ -397,7 +413,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

888 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
889 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
the assertions settled (ADR-0021), and each test is raced against a watchdog
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).

Expand Down
25 changes: 25 additions & 0 deletions research-kit/test/collector-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,31 @@ test('the caller reference is declared PUBLIC where a dispatcher will read it',
'the run should also warn on the run itself, where the reference was actually used');
});

test('the anonymous disclosure table never appears without the signed-in caveat', () => {
// The rule ADR-0035's amendment exists to enforce: state the SCOPE of a measurement in
// the same breath as its result.
//
// The anonymous table is true and was honestly obtained - 403 on logs, 401 on artifacts,
// the topic in none of the readable responses. Printed alone it still misleads, because a
// list of refusals reads as "the subject is private" and it is not: Actions echoes a
// step's `env:` block into the job log, every dispatch input arrives through `env:`, and
// on a public repository anyone with an account can read that log.
//
// Neither half is deleted. A correction that erases what it corrected teaches nobody, and
// this pins that the two travel together wherever the table is repeated.
const sources = [
['collect.yml', yaml ?? ''],
['research-kit/README.md', fs.readFileSync(path.join(KIT_ROOT, 'README.md'), 'utf8')],
];
for (const [name, text] of sources) {
if (!/job \*?\*?logs?\*?\*?[^\n]*403/i.test(text)) continue;
assert.ok(/signed[- ]in/i.test(text),
`${name} prints the anonymous refusal table without saying what a signed-in reader sees`);
assert.ok(/env:/.test(text) && /\blog\b/i.test(text),
`${name} does not name the mechanism - the job log echoing a step's env: block`);
}
});

// ---------------------------------------------------------------- correlation

test('the dispatch API version is pinned, and the documented one', () => {
Expand Down
Loading