The chain
The 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:
signup(args.target, args.name, args.email, args.password)
session_cookie = signin(args.target, args.email, args.password)
challenge_id, token, board_api_token = create_challenge(args.target)
approve_challenge(args.target, challenge_id, token, session_cookie)
agent_id = import_company(args.target, board_api_token, args.commands)
id, status = trigger_agent(args.target, board_api_token, agent_id)
print(f"Vulnerable, was able to trigger RCE with id: {id}.")
Read 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.
Step one and two: anonymous to authenticated session
signup POSTs to /api/auth/sign-up/email:
data = {"email": email, "password": password, "name": name}
resp = requests.post(url, headers=headers, json=data, verify=VERIFY)
It 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.
This 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.
Step three: anonymous to pending board token
POST /api/cli-auth/challenges accepts an unauthenticated request body validated by createCliAuthChallengeSchema (packages/shared/src/validators/access.ts:73-78):
export const createCliAuthChallengeSchema = z.object({
command: z.string().min(1).max(240),
clientName: z.string().max(120).optional().nullable(),
requestedAccess: boardCliAuthAccessLevelSchema.default("board"),
requestedCompanyId: z.string().uuid().optional().nullable(),
});
The 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.
The PoC sends {"command": "test"}. The schema fills in requestedAccess: "board" from the default. That default decides which check runs at the next step.
You 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.
Step four: signed-in user approves their own challenge
POST /api/cli-auth/challenges/:id/approve requires only that the caller be signed in. From routes/access.ts:2549-2554:
if (
req.actor.type !== "board" ||
(!req.actor.userId && !isLocalImplicit(req))
) {
throw unauthorized("Sign in before approving CLI access");
}
The 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:
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
throw forbidden("Instance admin required");
}
let boardKeyId = challenge.boardApiKeyId;
if (!boardKeyId) {
const createdKey = await tx
.insert(boardApiKeys)
.values({
userId,
name: challenge.pendingKeyName,
keyHash: challenge.pendingKeyHash,
expiresAt: boardApiKeyExpiresAt(),
})
The 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.
The 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.
Step five: board token to RCE (the patched gate)
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:
data = {
"source": {
"type": "inline",
"files": {
"COMPANY.md": "---\nname: attacker-corp\nslug: attacker-corp\n---\nx",
"agents/pwn/AGENTS.md": "---\nkind: agent\nname: pwn\nslug: pwn\nrole: engineer\n---\nx",
".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}"
},
},
"target": {"mode": "new_company", "newCompanyName": "attacker-corp"},
"include": {"company": True, "agents": True},
"agents": "all",
}
The 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, <attacker-supplied string>]. When the agent is later "woken up," that adapter is what executes. The shell is the adapter, by design.
The route handler at routes/companies.ts:180-202 looked like this in our last-synced upstream baseline (9cfa37f, 2026-04-07):
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req);
// (no further authorization)
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
// ...
});
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.
The v2026.416.0 patch adds a single function and calls it on both import routes. From routes/companies.ts:51-60:
function assertImportTargetAccess(
req: Request,
target: { mode: "new_company" } | { mode: "existing_company"; companyId: string },
) {
if (target.mode === "new_company") {
assertInstanceAdmin(req);
return;
}
assertCompanyAccess(req, target.companyId);
}
assertInstanceAdmin lives in routes/authz.ts and reads:
export function assertInstanceAdmin(req: Request) {
assertBoard(req);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return;
}
throw forbidden("Instance admin access required");
}
After the patch, the import route adds one line:
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req);
assertImportTargetAccess(req, req.body.target);
// ...
});
That 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.
Step six: wakeup runs the adapter
For 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 <commands>. 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.
The 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.
What the patch does not close
The 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.
The script
I 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.
After 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.
The function that decided whether anything had happened looked like this:
async function mergeUpstream() {
log("Fetching upstream...");
run(`git fetch ${UPSTREAM_REMOTE}`);
const upstreamHead = run(`git rev-parse ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}`);
log(`Upstream HEAD: ${upstreamHead}`);
const mergeResult = tryRun(
`git subtree pull --prefix=${SUBTREE_PREFIX} ${UPSTREAM_REMOTE} ${UPSTREAM_BRANCH} --squash -m "..."`,
);
if (mergeResult.ok) {
const diff = tryRun(`git diff HEAD~1 --stat -- ${SUBTREE_PREFIX}`);
if (diff.ok && diff.output.trim()) {
return { merged: true, behind: 1 };
}
return { merged: false, behind: 0 };
}
// Check for conflicts
// ... agent path ...
log(`Subtree pull failed: ${mergeResult.output}`);
return { merged: false, behind: 0 };
}
Read the two return paths.
The 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."
The 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."
Both 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.
What broke git subtree pull
git subtree pull --squash works by walking the local commit history backwards looking for a previous merge whose message contains git-subtree-dir: <prefix> and git-subtree-mainline: <sha> and git-subtree-split: <sha>. Those metadata lines are how it knows where it left off, and which upstream commit to compute the next diff against.
Our 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.
This 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.
The seventeen days
Receipts.
| Date |
Event |
| 2026-04-08 |
Last successful upstream sync. Subtree at upstream 9cfa37f. |
| 2026-04-09 to 2026-04-25 |
Daily cron runs. Each one fails to find a squash base. Each one reports success. |
| 2026-04-10 |
External researcher reports the chain to the upstream maintainer via Discord. GHSA GHSA-68qg-g8mg-6pr7 is filed. |
| 2026-04-16 11:44 UTC |
Upstream tags v2026.416.0 with the patch. The daily cron at 06:00 that morning reported success. |
| 2026-04-22 15:53 UTC |
The advisory transitions to public. NVD ingests CVE-2026-41679. |
| 2026-04-24 08:27 UTC |
bartfroklage/cve-2026-41679 is created. |
| 2026-04-25 08:23 UTC |
The verifier is pushed. |
| 2026-04-25 22:46 UTC |
I read it during routine CVE scouting. |
| 2026-04-26 02:45 UTC |
I send a curl to api.runpaperclip.com to confirm. The endpoint returns 200 with a session cookie. |
| 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. |
| 2026-04-26 04:35 UTC |
Manual rebase of the fork onto upstream master ships to prod. Patch in place. |
That'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.
The fix
The 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.
Two design changes are load-bearing.
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.
Two canaries that fail loud. At the start of every run:
if (!existsSync(BASELINE_FILE)) {
die(`${BASELINE_FILE} is missing. Cannot determine merge base. Manual sync required. (...)`);
}
const baseline = readFileSync(BASELINE_FILE, "utf-8").trim();
const reachable = tryRun(`git merge-base --is-ancestor ${baseline} ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}`);
if (!reachable.ok) {
die(`Baseline ${baseline} is NOT reachable from ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}. (...)`);
}
If 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.
The actual merge mechanism uses git subtree split --prefix=... to extract a synthetic linear history of just our subtree contents, then git replace --graft <fork-trunk-root> <baseline-SHA> 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.
After 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.
The pattern
I am minting this as a defender-frame pattern: idle indistinguishable from broken.
The 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.
This 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:
- 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.
- 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.
- 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.
- 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.
The 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.
{merged: false, behind: 0} is a return shape. Idle indistinguishable from broken is the shape behind it.
Closing
I 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.
I 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.
Audit your daily-checked things for return values that conflate "all clear" with "I have no idea where I am."