Skip to content

fix(drift): the install script cannot reach the app token, and a per-note overlap no longer discards the run - #357

Merged
jpr5 merged 6 commits into
mainfrom
drift/ollama-and-pernote
Aug 6, 2026
Merged

fix(drift): the install script cannot reach the app token, and a per-note overlap no longer discards the run#357
jpr5 merged 6 commits into
mainfrom
drift/ollama-and-pernote

Conversation

@jpr5

@jpr5 jpr5 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Six proven fixes to Fix Drift, harvested onto the merged #348 main. One is a security fix.

What changes

The one unpinned executable in the job no longer runs while the app token is live.
sh <(curl https://ollama.com/install.sh) is arbitrary third-party code from a mutable URL, and it
sat six steps after an app token with contents: write + pull-requests: write was minted into
the job. Everything else in that job is pinned — every uses: by commit SHA, pnpm install by the
lockfile. This was the exception. Two changes, because neither alone closes it: the step now runs
before the mint, and the downloaded bytes are checked against a sha256 with a hard exit 1
before sh ever sees the file. Reordering alone leaves the real attack — a compromised script
plants a git/gh/node on PATH and the later token-holding steps run it.

A note some open PR already proposed no longer discards the rest of the run. The needs-human
step's per-note guard exit 0'd the whole step on the first overlapping note. On a mixed run — a
registry edit applied AND a different family deferred — the registry edit was committed locally,
never pushed, proposed to nobody, and re-abandoned identically every morning. The stand-down can
never be right (control only reaches that loop once the changeset guard has declined, so nothing
this run carries is proposed anywhere), so it is removed rather than narrowed. The overlap is still
reported, as a warning.

A rejection survives a human editing the closed PR that records it. Closing a drift PR rejects
its changeset, and the record is a marker in that closed PR's body. The marker self-heal that
exists to repair deleted markers listed --state open, so it never saw the closed PR — a human
writing down why they declined destroyed the rejection permanently and the bot re-proposed every
morning. Both self-heals now list --state all; candidacy is unchanged (every candidate still
carries this run's key as its branch suffix). MERGED reaches neither self-heal, and on the
needs-human step CLOSED is admitted through the branch-key anchor only, so the closed backlog
cannot drag a truncated file list into the fail-closed audit.

The persist step's note scan no longer needs a bash-4 builtin. mapfile was the only bash-4
construct anywhere in the workflow, and it made that step the one body a test could not execute on
a stock macOS box — which is exactly how a hand-written mirror gets written instead of a run.

Two test/doc commits. The alert payloads are now POSTed to a socket that records them, so the
method, header and body are measured on the wire instead of against a curl stub. And DRIFT.md no
longer offers deleting a closed PR's marker as a way to un-suppress a rejection, because the
self-heal now puts it back.

Red-green, re-run on this base

These commits were written against an older base. Every proof was re-run here and watched.

ollama. Pre-fix restored: the step sits at index 7 against the token's index 1, declares no
digest, and hands attacker-substituted bytes straight to sh (shRan=true). Post-fix the same
payload is refused with ::error::…does not match its pinned sha256, sh is never reached, and
the step exits 1 — while bytes that DO match still execute, so the gate is not "always refuse". The
pin was re-verified against the live URL rather than taken on trust: 15902 bytes,
25f64b81…82c9f, byte-for-byte.

per-note. Pre-fix restored: created=false, pushed=false, and
needs-human: note drift-proposals/openai-mythical-1.new-family.md already proposed in open PR #42 — not opening a duplicate. Post-fix it warns, pushes and opens exactly one PR; the negative
control (a re-fire of the SAME changeset) still opens no second PR.

rejection. Eight mutations — reverting either listing to --state open, dropping the MERGED
exclusion, relaxing or removing the state narrowing from either jq program — all red, none
survived. The pre-fix red is the workflow pushing a branch for a changeset a human had rejected.

alerts. Four mutations on the wire (-X PUT, dropped Content-Type, a literal \n, the wrong
-d variable) all red.

Not included

fd867dc ("a provider whose live listing was too short to trust is not a quiet day") is
deliberately absent — it needs the threshold fix that ships with it, and alone it would red the
daily cron every morning. scripts/drift-sync.ts is untouched here.

Verification

pnpm build, pnpm test (5044 passed), pnpm test:drift (119 passed / 66 skipped),
pnpm test:exports, tsc --noEmit, eslint, prettier --check, actionlint, bash -n on all
13 run: bodies, and zizmor --min-severity medium all exit 0. fix-drift-workflow.test.ts goes
130 → 150 tests.

Three files change. No package.json, CHANGELOG.md, pnpm-lock.yaml, charts/ or
.claude-plugin/ — this PR consumes no version.

Still unproven

The workflow has never executed on Actions; every proof here is a local run of a run: body with
gh, curl and git push stubbed. That Slack renders these messages as intended is also
unproven. And the ollama pin will red the cron on the next upstream publish — fail-closed, chosen
deliberately, with the digest printed in the error.

jpr5 added 6 commits August 5, 2026 18:45
…4 builtin

`mapfile` was the only bash-4-or-newer construct anywhere in fix-drift.yml. The
runner has bash 5 so it worked in production — but it also made this one step's
body the one body a test could not EXECUTE on a stock macOS dev box (bash 3.2),
and a guard that cannot be run gets hand-mirrored in TypeScript instead. That
mirror is what produced the bulk of this PR's review findings.

Replaced with a portable `read` loop that folds the drift-proposals/* filter in,
so the intermediate array and its loop are gone rather than rewritten. Proven
equivalent under bash 5.2 across six input shapes (empty, one note, mixed
registry-edit-plus-notes, a final line with NO trailing newline, no notes at all,
and a path containing a space); `|| [ -n "$f" ]` is what covers the unterminated
final line where a naive read loop drops it and mapfile does not.

Two tests keyed on the literal `mapfile -t COMMITTED` went red on that
behaviour-preserving swap — they were measuring an implementation choice, not the
fact they exist for. Re-anchored on the `git diff --name-only` invocation that
actually produces the note list.
…h the app token live

`sh <(curl https://ollama.com/install.sh)` is arbitrary third-party code from a
MUTABLE URL, executing as the runner user, and it sat six steps after an app token
with contents:write + pull-requests:write was minted into the job. Everything else
in the job's third-party surface is pinned — every `uses:` by commit SHA, and
`pnpm install --frozen-lockfile` by the lockfile's integrity hashes. This was the
exception.

Two changes, because neither alone closes it:

  - the step now runs BEFORE the mint, so the token does not exist while that code
    runs and cannot be read out of the job's state. It also starts the token's
    one-hour life after the slowest setup step rather than before it.
  - the downloaded bytes are verified against a sha256 reviewed at the URL, and a
    mismatch is a hard exit BEFORE `sh` sees the file. Reordering alone leaves the
    real attack: a compromised script can plant a `git`, `gh` or `node` earlier on
    PATH, and every later step — the ones that hold the token — would run it.

When Ollama publishes a new install.sh this step fails, loudly and alerted (the
end-of-job catch-all covers the infra/setup window). The error prints the digest it
got and names the file to re-pin. An unverified script must not run just because
verifying it was inconvenient.

Red-green: pre-fix the step is at index 7 against the token's index 1, declares no
digest, and handed an attacker-substituted payload straight to `sh` (`shRan=true`,
step exit 0). Post-fix the same payload is refused with `::error::…does not match
its pinned sha256`, `sh` is never reached, and the step exits 1 — while a download
whose bytes DO match still executes, so the gate is not just "always refuse".
Four mutations bound: inverting the comparison reds both the refusal and the
positive control; dropping the `exit 1` reds the refusal; comparing the pin to
itself instead of to the download reds two; moving the step back after the mint
reds the ordering guard.
…e rest of the run

The needs-human step's per-note guard `exit 0`'d the whole run on the FIRST note
some open PR already proposed. On a mixed run that lost work: drift-sync applies a
mechanical registry edit AND defers a different family to a human, and if the
deferred note is already proposed by a PR from an earlier run — whose outcome set
was different, so it hashes to a different changeset key and the primary guard
legitimately does not fire — the registry edit was committed locally, never pushed,
proposed to nobody, and re-detected and re-abandoned identically every morning. A
note set that merely GREW since the last run ({B} then {B,C}) lost C the same way.

The stand-down can never be right, which is why it is removed rather than narrowed:
control only reaches that loop after the primary changeset guard has declined, so by
construction no open PR carries this run's changeset and the run's content is
un-proposed. The overlap is still REPORTED — two open PRs carrying one note looks
exactly like the duplicate-PR spam this workflow exists to prevent, and the human
reading the second one deserves to know why it exists.

Also adds the harness the red-green needed: `observePrStep` EXECUTES either
PR-open step's own several-hundred-line body against a real git repository with a
real base commit and diff, the real jq, and a scripted `gh`. Only `gh` and
`git push` are stubbed. Hand-mirroring these guards in TypeScript is what produced
most of this PR's review findings, so "the step stood down" is now the observable
ABSENCE of a `pr create` call. A CANARY pins that the harness can make both bodies
push and open a PR, so the stand-down assertions cannot be satisfied by a harness
that runs nothing.

Red-green, EXECUTED: pre-fix the mixed run printed "note … already proposed in open
PR #42 — not opening a duplicate" and exited 0 with created=false, pushed=false;
post-fix it warns about the overlap, pushes, and opens exactly one PR. Two mutations
bound: restoring the run-wide `exit 0` reds three tests, and silencing the warning
reds one. The negative control — a re-fire of the SAME changeset — still opens no
second PR, so the dedup that matters is intact.
…ecords it

Closing a drift PR rejects its changeset, and the record of that decision is the
`<!-- drift-changeset: <key> -->` marker in the CLOSED PR's body. Two things then
conspired. The dedup listing finds closed PRs with `--search "<key> in:body"`,
which cannot match a body the key was deleted from. And the marker self-heal that
exists precisely to repair deleted markers listed `--state open`, so it never saw
the closed PR either. So a human editing a closed PR's body — a normal thing to do
to a PR you have just declined, e.g. writing down why — destroyed the only record
of the rejection PERMANENTLY, and the bot re-proposed the identical changeset the
next morning and every morning after. The one marker whose loss cannot be repaired
by any other path was the one marker the repair could not reach.

The head branch ends in "-<changeset key>" and a PR's head branch cannot be
renamed, so the closed PR is still identifiable body-independently. Both self-heals
now list `--state all`; candidacy is UNCHANGED and every candidate still has to
carry THIS run's key in its branch, which is what keeps a human's
`fix/drift-<slug>` branch out. Two narrowings keep the wider population safe, and
each is guarded by a test that reds without it:

  - MERGED reaches neither self-heal. A merged PR is an ACCEPTED decision, never a
    pending proposal and never a rejection, so no guard consults it and writing
    machine markers into it would be a write with no reader.
  - on the needs-human step, CLOSED is admitted through the branch-key anchor ONLY
    (`select_state`). The note-path anchor is the one that runs the fail-closed
    changed-file audit, and letting the accumulated closed backlog into that audit
    would turn a single 100-file closed PR into a red cron every morning.

Red-green, EXECUTED on both steps: pre-fix each pushed a branch and opened a PR for
a changeset a human had rejected, publishing no `rejected` output at all; post-fix
each repairs the marker on the closed PR — appended to the human's own prose, which
is asserted, not assumed — publishes `rejected=77`, and opens nothing. Eight
mutations bound: reverting either listing to `--state open` reds the executed
rejection guard plus the listing assertion; dropping the ok-applied MERGED
exclusion, relaxing `select_state` to `true`, admitting CLOSED through the
note-path anchor, dropping OPEN from it, and removing it from either the candidate
selection or the truncation audit each red at least one test. The first attempt at
this had NO guard on the needs-human narrowing — two mutations passed — so the
MERGED and closed-backlog cases were added until every branch could fail.
…heal now undoes

Two claims went stale with the two fixes on this branch.

DRIFT.md offered deleting the `<!-- drift-changeset: … -->` marker from a closed
PR's body as a way to un-suppress a rejected changeset. The marker self-heal now
covers closed PRs and puts it back, so that route is gone — deliberately: a missing
marker is far more often a human rewriting the body to record why they declined than
an un-suppression, and reading it as the latter resurrected the rejected changeset
every morning, permanently. Reopening the PR is the deliberate act and remains the
documented one. Deleting the separate `drift-suppression-reported` ack marker still
re-posts the notice; that marker is not in the repaired set.

And the per-note `drift-proposal-note:` marker is no longer described as a secondary
dedup guard, because it no longer decides anything — it reports an overlap and the
run proceeds.
…hem, not to a stub

Delivery is already proven POSITIVELY on this branch — each alert publishes
`posted=true` only after its curl returns 0, and the end-of-job catch-all stands
down on that output rather than on an exit code. What was never exercised is the
POST itself: every guard stubs curl with `exit N`, so the flags, the method, the
header and the JSON body have only ever been read, never run. "Slack would accept
this" was an inference from the value of `$MSG`.

So `SLACK_WEBHOOK` now points at a local HTTP server and the REAL curl runs. For
each of the six Slack-posting steps the request that arrives is asserted to be one
POST, `application/json`, a body that parses to an object with a non-empty `text`,
and — measured on the wire rather than on `$MSG` — a real newline with no literal
backslash-n, which is the form Slack renders as two characters in the channel. The
`posted=true` receipt is then checked against a POST that genuinely happened.

The first version used `spawnSync`, which blocks the event loop serving the sink:
curl connected, nothing ever answered, and all six steps died on `--max-time 15`
reporting "sent no request". That is also the vacuity check — the guard fails when
nothing arrives. Four mutations bound: `-X PUT` for `-X POST`, dropping the
Content-Type header, `-d "$MSG"` for `-d "$PAYLOAD"`, and turning one step's
`NL=$'\n'` into the literal two characters each red.

STILL UNPROVEN, and only a real webhook can close it: that Slack RENDERS these
messages as intended. Nothing in this repo has ever reached Slack. What is closed
is everything between the `MSG=` assignment and the socket.
@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@357

commit: 4934d2f

@jpr5
jpr5 marked this pull request as ready for review August 6, 2026 02:39
@jpr5
jpr5 merged commit f960fd6 into main Aug 6, 2026
30 checks passed
@jpr5
jpr5 deleted the drift/ollama-and-pernote branch August 6, 2026 02:39
jpr5 added a commit that referenced this pull request Aug 6, 2026
…artifact is pinned (#359)

Two follow-ups on `main` from the adversarial review of #357.

## 1. The rejection/dedup query is already saturated — silently

`gh pr list --limit N` is a window, not a ceiling: it returns the newest
N rows and drops the rest with no flag, no warning and no count. The
marker self-heal — the thing that makes a human's rejection survive a
body edit — was a plain `--state all --limit 200` whose state filter is
applied *client-side*, so every PR in the repo competed for those 200
slots and the merged ones won.

Measured live on this repo, 2026-08-05:

```
gh pr list --state all    --limit 200 -> 200 [MERGED=184 CLOSED=14 OPEN=2]  #125..#358
gh pr list --state closed --limit 200 -> 200 [MERGED=186 CLOSED=14]
gh pr list --state all    --limit 200 --search "is:unmerged"
                                      -> 28  [CLOSED=26 OPEN=2]             #1..#358
```

Two things the review did not have:

- **`--state closed` is not a fix.** gh maps it to CLOSED-**or**-MERGED
— 186 merged against 14 closed, just as full.
- **Twelve closed PRs are already invisible.** The repo has 26
unmerged-closed PRs; the current listing sees 14. The lost set is `#1,
30, 50, 56, 59, 61, 63, 94, 95, 96, 119, 120`, each confirmed CLOSED by
`gh pr view`. None is a drift PR, so no rejection is lost *today* — but
the mechanism is running now, and MERGED is the population that grows
daily.

**Fix.** Both self-heal listings additionally query `--search
"is:unmerged"` and union the two views. That excludes merged PRs
server-side: 200/200 slots consumed becomes 28/200, and 28 is the
*complete* unmerged population back to #1. `is:unmerged` is a STATE
predicate, so unlike `<key> in:body` it cannot be defeated by the very
body edit the self-heal repairs, and a just-closed PR matches under
either indexed state. The plain listing is unioned rather than replaced,
so the index-free view stays and plain entries win on collision.

Headroom is not a proof, so saturation is also made loud:
`assert_listing_complete` refuses any listing that comes back full at
its `--limit` rather than deciding on it. `--limit 200` also stopped
being a magic number — each step binds `PR_LIST_LIMIT` once and the
audit reads the same variable.

## 2. "the one unpinned executable in the job" was false

`89e5a07` pinned `ollama.com/install.sh`. That script then streams
`ollama.com/download/ollama-linux-<arch>.tar.zst` — mutable,
unversioned, no digest — through `zstd -d` into `sudo tar -x` under
`/usr/local`.

Run verbatim out of the pinned script's own bytes, with curl serving
attacker content:

```
STEP_EXIT=0
SUDO_RAN argv=tar -xf - -C ./dest
TAR_RAN  argv=-xf - -C ./dest
what tar was handed: ATTACKER-CONTROLLED TARBALL BYTES
```

A compromised tarball plants a root-owned `git`/`gh`/`node` on PATH
exactly as well as a compromised script would, and every later step
holds a `contents:write` + `pull-requests:write` app token those
binaries can reach.

It cannot be fixed in place — install.sh pipes the download straight
into `tar`, so it never holds the file and has nothing to verify;
`OLLAMA_VERSION` only appends `?version=`, a version pin rather than a
byte pin.

**Fix.** install.sh is not used. The release artifact is fetched from an
immutable release tag and sha256-checked before anything unpacks it.
Digest agreed by three independent sources: the v0.32.6 release's
`sha256sum.txt`, the GitHub release API's asset `digest` field, and
sha256 of the 1,420,686,963 downloaded bytes. Archive layout confirmed
by listing it (`bin/ollama` + `lib/ollama/*`, so `-C /usr/local` puts
the binary on PATH). Dropping install.sh also drops its NVIDIA CUDA repo
adds and `$PACKAGE_MANAGER -y install`; the step already ran `ollama
serve` itself rather than using its systemd unit.

The false claim is corrected in the workflow comment, the suite's header
comment, its describe name and its failure messages. Two further stale
claims about the self-heal listing `--state open` (false since #357) are
corrected too. The commit message that first asserted it is in history
and cannot be rewritten.

## Red-green

The `gh` stub could not previously express item 1's bug — it ignored
`--limit` and returned the whole population, so a saturated window
looked exactly like a healthy one. It now models what real gh does.

**RED (watched).** The existing rejection scenario plus 200 newer MERGED
PRs — both PR-open steps re-proposed the rejected changeset:

```
FAIL  a rejection is still found once MERGED PRs have filled the listing window
  stdio: Pushed branch main-1111222233334444 … Matched PR #56
FAIL  needs_human_pr: a rejection survives a full window there too
  stdio: Pushed needs-human branch … Opened needs-human PR #57
```

**GREEN.** 154 passed, exit 0.

**RE-BREAK (watched), each mutation restored and md5-verified:**

| mutation | tests red |
|---|---|
| neuter the union so unmerged rows are dropped | 2 |
| delete all four `assert_listing_complete` calls | 2 |
| raise the audit threshold out of reach | 1 |
| replace the tarball digest comparison with `if false` | 1 |
| point the fetch back at `ollama.com/install.sh` | 1 |

Item 2's guard now watches `tar`, not `sh`, and the positive control
asserts `tar` was handed exactly the verified bytes rather than merely
that it ran.

## Gates

`pnpm build` 0 · `pnpm test` 0 (**5048 passed / 171 files**) · `pnpm
test:drift` 0 · `tsc --noEmit` 0 · `pnpm lint` 0 · `pnpm test:exports` 0
· prettier clean · `actionlint` 0 · `zizmor` 0 findings · `bash -n` on
all 16 `run:` bodies 0 · commitlint 0.

## Not fixed here

`.github/workflows/test-drift.yml:177` does the same `sh install.sh`
with **no pin at all**, in a job holding five provider API keys, with
provisioning ahead of the steps that use them. Same defect, different
file — flagged, not touched.
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.

1 participant