Skip to content

docs(secrets): never paste live agent keys into board text + QA key delivery hygiene - #232

Open
claudegoogl-sudo wants to merge 272 commits into
masterfrom
docs/secrets-no-live-keys-in-comments
Open

docs(secrets): never paste live agent keys into board text + QA key delivery hygiene#232
claudegoogl-sudo wants to merge 272 commits into
masterfrom
docs/secrets-no-live-keys-in-comments

Conversation

@claudegoogl-sudo

Copy link
Copy Markdown
Owner

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Secrets management governs how API/agent keys are stored, delivered, and referenced across agents and cross-company test runs
  • A live scoped agent key was pasted in plaintext into a board-visible comment; soft-deleting it did not undo exposure because plaintext persists in DB backups and relayed copies
  • A follow-up secrets-handling review found there was no durable, discoverable rule in the engineering docs telling QA / cross-company runs how to deliver a key without ever putting the value in board text
  • This pull request adds a "Never Paste Live Secret Values Into Board Text" section to docs/deploy/secrets.md, next to the existing Custody Boundaries guidance, so the rule is discovered at the point of use
  • The benefit is that future QA and cross-company test runs mint keys safely, reference values by id/prefix only, and know the revoke-first exposure response — closing the gap that caused the leak

Linked Issues or Issue Description

(B) No public issue — describing the change in-PR.

Problem / motivation: During a cross-company test run, a live scoped agent/API key was pasted in plaintext into a board-visible issue comment. Soft-deleting the comment afterward did not neutralize the exposure: the plaintext remained in database backups and anywhere the comment had been quoted or relayed. Only revocation neutralizes a leaked key. The engineering secrets guidance had no durable subsection telling authors and QA/cross-company runs (a) never to paste live values into board-visible text, (b) how to deliver a key to a run without exposing the value, or (c) the correct exposure response order (revoke first). This docs change codifies that guidance where a QA run would encounter it.

What Changed

  • Added a "Never Paste Live Secret Values Into Board Text" section to docs/deploy/secrets.md, immediately after "Custody Boundaries".
  • States the core rule: never paste a live agent/API key into a comment, document, PR body, or attachment; soft-delete does not undo exposure (backups retain plaintext); revocation neutralizes a leaked key.
  • Documents preferred cross-company/QA key-delivery order: (1) mint-at-test-time inside the run and DELETE at run end, (2) operator relay/interaction artifact, (3) reference by key id or <=6-8 char prefix — never the full value.
  • Documents the exposure response order: revoke first (ungated), soft-delete, sweep siblings/digests/attachments/backups, then file the incident.

Verification

  • Docs-only change; no code or tests affected.
  • git diff scanned for secret shapes and internal ids — clean (0 matches for pcp_/xox/sk-/AKIA/Bearer/internal ticket ids).
  • MDX safety: the only < and { in the added lines (<=6-8, {id}) are inside backticks, so Mintlify/MDX parses them as inline code, not JSX.
  • Confirmed deploy/secrets is already registered in docs/docs.json navigation, so the new section is discoverable via the existing page TOC; no nav change required.

Risks

Low risk — additive documentation only. No runtime, schema, or API behavior changes.

Model Used

Claude Opus 4.8 (claude-opus-4-8) via Claude Code, extended thinking + tool use.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above — N/A: none found touching this doc section
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template — path (B)
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (docs/secrets-no-live-keys-in-comments) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass — N/A: docs-only, no tests affected
  • I have added or updated tests where applicable — N/A: docs-only change
  • I have updated relevant documentation to reflect my changes — this PR is the documentation
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — will confirm after checks run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — will address any comments before merge
  • I will address all Greptile and reviewer comments before requesting merge

Follows CONTRIBUTING.md PR template — sections present: Thinking Path, Linked Issues/Issue Description, What Changed, Verification, Risks, Model Used, Checklist.

claudegoogl-sudo and others added 30 commits May 26, 2026 13:18
PLA-551: gate release-canary dry run on PR runs. No required checks; admin-merging because GitHub Actions queue appears stalled on this fork post-suspension (latest run 10:39Z). The fix itself is the one-line if: gate verified by yaml.safe_load locally.
…uthz (#26)

* feat(PLA-574): SDK ctx.artifacts.fetch(attachmentId) helper

Add worker-side SDK surface for fetching issue-attachment bytes from
inside a tool handler. The worker sends only `{attachmentId, runId}` on
the wire — agentId/companyId are host-derived from the dispatch-time
runContext registry to keep the worker untrusted. The host returns
base64-encoded bytes which the SDK decodes back to a Uint8Array along
with `{filename, contentType, byteSize}` metadata.

- protocol.ts: add ArtifactsFetchParams / ArtifactsFetchResult types
- types.ts: add `artifacts: { fetch(attachmentId) }` to ToolRunContext
- host-client-factory.ts: register `artifacts.fetch` as a capability-free
  host method (tool-dispatch implies attachment-read; host enforces
  dispatching-agent authorization)
- worker-rpc-host.ts: inject `artifacts.fetch` into runCtx before
  calling the tool handler; reject empty attachmentId at the SDK
  boundary so no RPC fires
- testing.ts: default `runCtx.artifacts` in the executeTool helper to a
  stub that throws unless the test passes its own mock

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(PLA-574): host-mediated artifacts.fetch handler with 7 security gates

Add server-side handler for the `artifacts.fetch` worker RPC introduced
by the SDK in the previous commit. All authorization decisions use the
**dispatching agent's** identity (re-derived from an in-memory
registry populated at executeTool dispatch) — the worker's JWT is never
consulted, so a forged or replayed runId from another tenant fails
closed.

Seven security gates per SecurityEngineer checklist:

1. RunContext validation — deny-by-default when the composite key
   `(pluginDbId, runId)` is missing from the registry; no JWT fallback.
2. Dispatching-agent authorization — `dispatchingCompanyId` is read
   from the registry entry, never from worker-supplied params.
3. Single-resource shape — empty / non-string attachmentId rejected
   before any DB or storage call.
4. Dual-bucket rate limit — sliding-window 60/min per agent (global)
   plus 30/min per (agent, attachment-company) sub-bucket.
5. Six-field audit log on every call (allowed and denied) attributed to
   the dispatching tenant; includes outcome + deniedReason.
6. Typed errors — `runcontext_invalid | forbidden | not_found |
   rate_limited | too_large`; missing-attachment and cross-company
   access both collapse to `not_found` (no existence oracle).
7. No bytes / base64 in audit details — only byteSize is logged.

Wiring:
- plugin-run-context-registry.ts (new): in-memory registry with
  composite key, TTL sweep (.unref()'d), dispose()
- plugin-artifacts-handler.ts (new): the seven-gate fetch handler
- plugin-host-services.ts: build artifactsHandler when both
  storageService and runContextRegistry are supplied; expose via
  hostServices.artifacts
- plugin-tool-registry.ts / -dispatcher.ts: register/deregister run
  context around each worker executeTool call
- app.ts: wire registry into the dispatcher + host services and
  dispose on process exit

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* test(PLA-574): SDK wire + 7-gate handler unit coverage

Worker→host wire (artifacts-client.test.ts, 3 tests):
- Worker sends only `{attachmentId, runId}` on the RPC (no agentId /
  companyId — trust boundary)
- Base64 host response round-trips back to an exact Uint8Array with
  metadata (filename / contentType / byteSize)
- Empty attachmentId throws at the SDK boundary before any RPC fires

Host handler (plugin-artifacts-handler.test.ts, 12 tests) — one per
gate plus a trust-boundary check that the same runId registered against
a different plugin is invisible to ours, and a registry TTL test:
- runcontext_invalid (no registry entry, no audit because no identity)
- shape rejection (empty / null attachmentId)
- happy path — base64 + metadata returned; audit shows six fields;
  audit details contain no base64
- cross-company → not_found (collapse) with denied audit
- missing attachment → not_found (same shape as no-access)
- global per-agent rate_limited audit
- per-(agent, attachment-company) sub-bucket rate_limited audit
- too_large via maxByteSize cap (prevents OOM via base64 inflation)
- trust boundary: runId registered against another pluginDbId is
  invisible to our handler
- deregister invalidates immediately (no stale-runContext window)
- registry register/get composite key
- registry TTL expiry between sweeps

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs(PLA-574): document artifacts.fetch in PLUGIN_SPEC §13.11

Add reference section after §13.10 executeTool documenting:
- Wire payload: `{attachmentId, runId}` only (agentId/companyId are
  host-derived from the dispatch-time runContext registry)
- SDK return shape: `{bytes: Uint8Array, filename, contentType,
  byteSize}` decoded from the host's base64 response
- Authorization semantics: company-match against the dispatching
  agent's tenant; missing-attachment and cross-company access both
  collapse to `not_found` (no existence oracle)
- Rate limits: 60/min per agent (global) + 30/min per
  (agent, attachment-company) sub-bucket
- Six-field audit log on every call (allowed and denied) attributed to
  the dispatching tenant
- Capability requirement: none (tool-dispatch implies attachment-read)
- Typed error table for runcontext_invalid | forbidden | not_found |
  rate_limited | too_large

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(PLA-574): include toolName in artifacts.fetch audit details (B1)

SecurityEngineer review of fork PR #26 flagged that the six-field audit
schema documented in the coverage table was actually shipping only five
fields — `toolName` was missing from `audit()` details despite the
registered runContext entry carrying it. This is the lone blocker on
the conditional security sign-off.

Fix is a one-line pull-through:
- audit() input type gains a required `toolName: string`
- every call site reads `ctx.toolName` from the registry entry
- happy-path test now asserts `details.toolName: "lookup-screenshot"`
- Gate 4 cross-tenant deny test also asserts it (deny paths must carry
  toolName too — that's the field that lets a security analyst answer
  "which tool reached out for this attachment" without joining runId)

This satisfies the six-field schema:
  attachmentId, attachmentCompanyId, dispatchingAgentId,
  dispatchingCompanyId, pluginInstanceId (pluginDbId), toolName

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Stamp version bump for fork-build-9. Carries cumulative fork-master
commits since fb8:

  - feat(PLA-574): SDK ctx.artifacts.fetch + host-mediated cross-tenant
    authz handler (#26, 5fe77d1) — primary deliverable for this cut.
  - ci(PLA-551): skip release-canary dry run on PR runs (#24, 11b4c0e)
  - fix(PLA-518): SDK PluginHttpClient binary/FormData body support
    (#17, 4387d34)
  - fix(PLA-522): repair fork-master test-mock drift cascade (#22,
    458cfd1)

PLA-498 codification (rewriteForkBuildDeps in pack-public-packages.mjs)
has not landed on fork master as of fb9, so this cut re-uses the one-off
URL-rewrite shim pattern (same as fb1-fb8). Once PLA-498 lands, fb10+
will use the codified path.

Refs PLA-587, PLA-574, PLA-585.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Hygiene-merge under CEO authorization on PLA-575 (option (a) approved in comment 44f2d244).

Red e2e on this PR is tracked separately in PLA-597 (fork e2e flakes — Postgres deadlock during signoff PATCH status:done + playwright install hang). e2e failure is in signoff-policy.spec.ts:333/372, no overlap with server/src/middleware/error-handler.ts. Hygiene-merge under CEO authorization on PLA-575.

CI on this PR: policy ✅, verify ✅, e2e ❌ (three distinct flakes across attempts 1/3/4, all outside diff surface).

Change:
- server/src/middleware/error-handler.ts: 4xx pass-through for numeric err.status/err.statusCode; dedicated 413 response shape ({error, code, limit}) + warn log with {route, method, contentLength, limit, type}; 5xx numeric still routed to existing telemetry attach.
- server/src/__tests__/error-handler.test.ts: extended to 5 unit cases.
- server/src/__tests__/error-handler-413-integration.test.ts: supertest + express.json({limit:"1kb"}) end-to-end.

PLA-573 acceptance §4 ("413 surfaces cleanly") satisfied once the next host fork-build (fb10) carries this commit.
…lock retry + runbook (#28)

* fix(PLA-597): retry PATCH /issues/:id on transient Postgres deadlock

The signoff-policy e2e (signoff-policy.spec.ts) intermittently 500s on
`PATCH /api/issues/:id` with PostgresError 40P01 deadlock detected on
`heartbeat_runs`. Two concurrent transactions — the issue PATCH and the
heartbeat run-lifecycle path — take overlapping locks on the issue row
and the heartbeat_runs row in different order, so Postgres aborts one.
The rollback leaves no partial state, so a bounded retry from the app
recovers cleanly.

Adds services/pg-retry.ts: a `retryOnTransientPgError` helper that
recognises 40P01 (deadlock_detected) and 40001 (serialization_failure),
sleeps with exponential backoff + jitter, and rethrows after maxAttempts
(default 4). Logs each retry at warn with the call site label so flakes
remain visible in server.log.

Wraps both branches of the PATCH /issues/:id transaction block in
routes/issues.ts so the route is no-op when the transaction succeeds
first try and bounded-retries when Postgres throws a transient error.

Adds 9 unit tests covering retryable vs. non-retryable codes, retry
success after N failures, and exhaustion.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* ci(PLA-597): split playwright install to bound apt-deps hangs

`npx playwright install --with-deps chromium` runs the 167 MiB browser
download and a `sudo apt-get install` of system deps as a single step.
When apt/dpkg hangs (network blip, mirror flake) the combined step
silently eats the full 30-minute e2e job budget — the Chrome zip is
already on disk, but there is no signal in the logs and no per-step
timeout to bound it.

Split into two timeout-bounded steps in pr.yml and e2e.yml:

  - "Download Playwright browser (chromium)" — `playwright install
    chromium`, capped at 5 min
  - "Install Playwright system deps (chromium)" — `sudo playwright
    install-deps chromium`, capped at 10 min

An apt hang now fails the deps step in ≤10 min with a clear name, so
re-runs are quick and triage is unambiguous. No functional change to
the e2e job.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs(PLA-597): document CI rerun policy for infra flakes

PR #25's e2e check failed twice in a row with two unrelated infra
shapes (Postgres deadlock, playwright apt-deps hang), neither caused by
the PR's change surface. We had no written policy on when reruns are
allowed vs. when an infra-shaped failure should be treated as a real
bug.

Adds a short subsection to CONTRIBUTING.md "Tests Must Pass":

  1. A single rerun is allowed for infra-only failures; record the
     original failure on the PR thread first.
  2. Two failures in a row of the same step = real failure; file a
     follow-up issue, do not retry a third time.
  3. Never disable a check to land a PR.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(PLA-597): pin CI workers=1 + cache playwright browser + add runbook

CTO directive on PLA-597 sharpened the bar from "bounded retry" to
"deterministic test isolation" plus "pinned/cached browser install".
This commit lands the three missing pieces; the retry helper from
856dfd6 stays as defence-in-depth.

* tests/e2e/playwright.config.ts: `workers: process.env.CI ? 1 : undefined`.
  Cross-spec parallelism is what surfaces the issues <-> heartbeat_runs
  lock-order race. Pinning to one worker on CI makes spec files run
  strictly sequentially against the shared webServer. Local runs are
  unaffected; the suite stays fast for developers.

* .github/workflows/{pr,e2e}.yml: add `actions/cache@v4` on
  ~/.cache/ms-playwright keyed by hashFiles('pnpm-lock.yaml'). The
  browser-download step is now `if: cache-hit != 'true'`, so the common
  path is a no-op and the network dependency on cdn.playwright.dev is
  removed entirely for cache hits. apt-deps install keeps its 10-min
  bounded timeout.

* doc/runbooks/pla-597-e2e-flake.md: full runbook entry covering
  symptoms, both root causes (heartbeat_runs/issues lock-order race;
  bundled --with-deps step), the four landed fixes, verification gates,
  and a local-repro recipe for future regressions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
The first workflow_dispatch of `e2e.yml` against master @ `6ed85ef` (the
PLA-597 merge commit) failed in 5 seconds at the `pnpm/action-setup@v4`
step with:

  Error: Multiple versions of pnpm specified:
    - version 9 in the GitHub Action config with the key "version"
    - version pnpm@9.15.4 in the package.json with the key "packageManager"
  Remove one of these versions to avoid version mismatch errors

`package.json` has been pinned to `pnpm@9.15.4` via `packageManager` for
a while, and `pr.yml` does not pass an explicit `version:`. `e2e.yml`
still had `version: 9` from before the `packageManager` field landed,
and never hit this because the workflow is `workflow_dispatch`-only.

Drop the `version:` key so pnpm/action-setup@v4 reads `packageManager`,
matching `pr.yml`. No version change in practice — both pre/post resolve
to pnpm 9.15.4 — just removes the conflict that blocked the
PLA-597 3-green validation gate.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…rk-build-10) (#27)

Re-applies the PLA-39 stopgap that lets `claude_local` agent JWTs reach
`POST /api/plugins/tools/execute` and `GET /api/plugins/tools`. The patch
was applied to a live install in v2026.428.0 but did not survive the
fork-build-9 rebase (PLA-43, the upstream PR companion, was cancelled).

Two-part patch:

1. Actor-branch guard on both routes (mirrors PLA-39):
   - `POST /plugins/tools/execute`: agent path calls
     `assertCompanyAccess(req, body.runContext.companyId)` instead of
     `assertBoardOrgAccess`.
   - `GET /plugins/tools`: agent path scopes to JWT companyId.
   - Board callers keep the existing guard.

2. Body-shape adapter for v0.1.6 wake-comment clients:
   v0.1.5 sends `{tool, parameters, runContext}`; v0.1.6 sends
   `{name, parameters, runId}`. Route now accepts both, remapping
   `name → tool` and synthesising `runContext` from JWT claims
   (`projectId: "onboarding-fallback"`) for the v0.1.6 shape.

Defence-in-depth unchanged: `validateToolRunContextScope` still
cross-checks the run/agent/project tuple; `plugin-capability-validator`
still requires `agent.tools.execute`. SE approved on PLA-595.

Six new test cases in `plugin-routes-authz.test.ts` covering both
shapes, cross-company 403, missing-runContext 400, and a board-no-access
regression. 25/25 pass under `pnpm exec vitest run`.

Empty `ci(PLA-594):` commit (fa958e6) rotates the head SHA to clear a
GH Actions rerun-state hang on `Install Playwright`; CI infra flake
analysis on PLA-601 (heartbeat FK + signoff deadlock signatures, both
in tests with zero overlap with this diff).

Refs PLA-39, PLA-576, PLA-585, PLA-593, PLA-594, PLA-595, PLA-601, DPR-130.
* fix(PLA-597): wrap pre-auth lock helpers in deadlock retry

PLA-597 #28 wrapped the PATCH /issues/:id transaction block in
`retryOnTransientPgError`, but the first post-merge gate dispatch
on `776a972` still saw `signoff-policy.spec.ts:333` fail red with
`PATCH /api/issues/<uuid> 500 — deadlock detected` and the test's
`expect(changesRes.ok()).toBe(true)` missing. Crucially, the WebServer
log shows the deadlock error but NO `label=patch_issue` retry trace —
i.e. the retry never engaged.

Root cause: every mutation route calls `clearOrphanCheckoutLocksIfTerminal`
(and many call `clearExecutionRunIfTerminal`) BEFORE entering the
wrapped transaction block. These helpers themselves run a
`db.transaction` taking overlapping locks on `issues` then
`heartbeat_runs`, so they hit the same 40P01 race against the
heartbeat-run lifecycle — but the deadlock fires before any of the
route's own retry can see it.

Fix: wrap the helpers' transaction bodies in
`retryOnTransientPgError` internally. All four mutation-route call
sites (`routes/issues.ts:1932,2750,2826,3423` for clearOrphan, plus
the cancel/release paths in services/issues.ts for
clearExecutionRunIfTerminal) get retry-on-deadlock for free; no
caller changes needed.

Also bump `retryOnTransientPgError`'s default `maxAttempts` from 4
to 6 (≈1.6 s worst-case latency including jitter — still bounded
well under any user-visible timeout) because the CI failure showed
single-attempt-then-success was sometimes still losing the race
against the sweeper.

Test coverage: `pg-retry.test.ts` adds a 10th case asserting the
default-attempts contract. Server typecheck baseline preserved
(4 pre-existing errors in plugin-host-services.ts, unrelated).
Route tests (environment-selection-route-guards,
issue-comment-cancel-routes, issue-execution-policy-routes) pass —
the helper wrap is transparent to existing mocked callers.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs(PLA-597): note wrap-at-helper pattern per CTO endorsement

CTO endorsement of PR #31 (comment 47027caa) requested a one-liner in
the runbook documenting why this fix's wrap layer differs from #28's,
so the pattern sticks for future deadlock-retry work.

Adds a Pattern Note to the "Fix landed" §1 explaining:
- Wrap goes at the helper that opens db.transaction, not the route
- Wrapping a layer above only retries the layer's own transaction
- Updates the attempts default reference (4 → 6) to match shipped code

Cheap to bundle pre-merge; no functional change.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
readStreamToBuffer threw too_large after Gate 4 (authz) passed but
before the Gate 7 success audit, so an authorized fetcher exceeding
maxByteSize produced zero audit signal — asymmetric with every other
deny gate and a blind spot for a plugin OOM-stressing storage
retrieval against a large attachment.

Wrap the bounded read in try/catch: on ArtifactsError("too_large")
emit the six-field denied audit (toolName from the registered
runContext), then re-throw the original error unchanged. Non-too_large
errors propagate exactly as before.

Extend the Gate 6 test to assert the denied/too_large audit is emitted
alongside the throw (negative-control verified: the assertion fails
without the handler change).

Co-authored-by: Paperclip <noreply@paperclip.ing>
… file (#30)

Adds the scaffolding the PLA-589 upstream-sync routine will invoke. Sibling
task registers the weekly cron; this PR is the inert scaffold:

- scripts/upstream-sync.mjs — sync-tick entrypoint. Fetches upstream
  releases/latest with ETag (`If-None-Match`), 304s into a no-op, otherwise
  branches sync/upstream-<tag> off origin/master and merges upstream/<tag>.
  Trivial conflicts hand off to the resolver below; anything unresolved emits
  a JSON escalation report on stdout and exits non-zero.
- scripts/resolve-trivial-sync-conflicts.mjs — allow-list resolver
  (pnpm-lock.yaml regen, CHANGELOG concat, docs-take-theirs when prose-only).
- scripts/format-sync-pr-body.mjs — produces the sync PR body per the fork's
  CONTRIBUTING.md template (Thinking Path, What Changed, Verification, Risks,
  Model Used, Checklist).
- skills/upstream-sync/SKILL.md — workflow, conflict buckets, escalation
  routes, manual wake commands.
- .paperclip/upstream-sync.json — bootstrap state. lastSyncedTag is the
  latest upstream tag the fork is at-or-past (v2026.428.0; merge-base
  685ee84 is canary/v2026.430.0-canary.7+13 — pre-v2026.512.0). ETag for
  the script's exact Accept/X-GitHub-Api-Version headers makes the first
  scheduled tick a 304 no-op until upstream cuts a new release.
- .gitignore — narrow `.paperclip/` exclusion so the state file can be
  tracked while everything else under that path stays ignored.

Smoke: `node scripts/upstream-sync.mjs --dry-run` →
`no-op: still at v2026.428.0` (the "(or equivalent)" form acceptance allows
since the fork is not at-or-past v2026.525.0).

Co-authored-by: Paperclip <noreply@paperclip.ing>
…ync PR (#33)

Before any branch/push/PR work, upstream-sync.mjs now queries the fork for an
open PR with head sync/upstream-<tag> against master. If one exists it prints
`no-op: sync PR for <tag> already open (<url>)` and exits 0 — no branch, push,
commit, or PR. A stale remote branch with no open PR is likewise left as-is
(convergent no-op) instead of crashing on a non-fast-forward push.

The state file only advances when a sync PR *merges* (board-gated), so every
tick between "opened" and "merged" re-entered for the same tag and crashed on
a non-fast-forward push or a 422 "A pull request already exists", breaking the
PLA-604 3-consecutive-tick soak. main() is now guarded so the module is
importable; pickOpenSyncPr/findOpenSyncPr are unit-tested with a mocked pulls
query, wired into CI via the new test:upstream-sync step in pr.yml.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…cking ref (#34)

git merge ${UPSTREAM_REMOTE}/${tag} aborted with 'not something we can
merge' because release tags are fetched into refs/tags/, not as
remote-tracking branches. Merge refs/tags/${tag} so the real sync path
runs instead of crashing at the post-merge commit.

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Merge upstream release tag v2026.525.0 into the fork (428->525, 167-commit
delta) on top of our fork commits, off post-#34 master (PR #34 merge-ref fix
already landed). Non-destructive git merge --no-ff; no re-baseline/force-push.
Resolved 10 conflicts preserving both fork customizations and upstream intent:

- .github/workflows/pr.yml: adopt upstream's parallel CI structure
  (typecheck_release_registry / general_tests / build / verify aggregator /
  verify_serialized_server / canary_dry_run / e2e); re-apply fork patches
  PLA-620 (upstream-sync idempotency test), PLA-376 (scaffold manifest gate),
  PLA-551 (skip canary on PRs), PLA-597 (split Playwright install).
- package.json: keep upstream's expanded test:release-registry + fork's
  test:upstream-sync / test:scaffold-create-paperclip-plugin scripts.
- packages/plugins/sdk/README.md: keep fork's ctx.db Alpha note AND upstream's
  Trusted Local Folders section.
- server/src/app.ts: keep upstream's named shutdownAppServices refactor +
  fork's pluginRunContextRegistry teardown; pass storageService +
  runContextRegistry + manifest to buildHostServices.
- server/src/services/plugin-host-services.ts: merge options type to carry
  pluginWorkerManager + storageService + runContextRegistry + manifest.
- server/src/services/plugin-loader.ts: keep fork's loadManifestModule helper
  (PLA-159 cache-bust), equivalent to upstream's inline import.
- server/src/middleware/error-handler.ts: keep both logger and
  COMPANY_IMPORT_API_PATH imports (both used).
- server/src/routes/issues.ts: keep fork's retryOnTransientPgError import +
  upstream's unprocessable error helper.
- test files: keep both fork + upstream mock members / cases. plugin.test.ts:
  fork v1 slot floor (dashboardWidget/page) shadows upstream's new slot types
  (routeSidebar/detailTab/toolbarButton/companySettingsPage) - pinned current
  fork behavior; flagged to SecurityEngineer for a floor-expansion decision.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…/execute

The 525 merge auto-combined the fork's PLA-98 search_path-pinning query()/
execute() (which call db.transaction unguarded) with upstream's db type change
making `transaction` optional (PluginDatabaseRootClient = ... & Partial<Pick<
Db,"transaction">>), producing TS2722 "Cannot invoke an object which is possibly
'undefined'". Guard `typeof db.transaction === "function"` (mirroring the
existing migration-apply path) and fall back to pinning search_path on the
caller's transaction-scoped client. Preserves the fork's namespace-isolation
defense-in-depth while satisfying upstream's optional-transaction type.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…t block

An upstream release merge produced by the upstream-sync routine (PLA-589)
necessarily carries upstream's pnpm-lock.yaml, but the policy job's
"Block manual lockfile edits" step rejects any PR whose diff touches
pnpm-lock.yaml. The verify/build/e2e jobs install with --frozen-lockfile, so
the matching lockfile MUST travel with the sync branch — it cannot be dropped.
Extend the existing chore/refresh-lockfile exemption to the routine-owned
sync/upstream-* namespace so sync PRs can go green. Narrow prefix match; all
other branches remain blocked.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
sync(upstream): v2026.525.0 (428→525 fork sync)
…ted public deployments

assertCloudDatabaseContract threw when an authenticated+public deployment
ran on embedded PostgreSQL with no DATABASE_URL. This upstream cloud-product
policy crash-loops our single-box self-hosted public deployment, which has
deliberately run embedded PG + hourly backups across the 428 line. Replace
the throw with a startup warning and fall through to the embedded branch,
restoring pre-525 posture. The postgres-connection-string format check is
preserved for when a DATABASE_URL is supplied.

This is the fix already running in prod as 2026.525.0-fork.1; it was deployed
as an uncommitted working-tree change at build time (PLA-630). This commit
makes it reproducible from a committed ref.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
PR #35 merged the v2026.525.0 upstream sync into fork master (merge commit
2c4f9ed, lineage preserved) but did not touch the sync state file, so the
upstream-sync routine still thought the fork was at v2026.428.0. Bump
lastSyncedTag to v2026.525.0 and lastSyncedSha to the #35 merge commit, and
refresh the timestamps. The ETag is left pointing at upstream releases/latest
(v2026.525.0) so the next scheduled tick gets a 304 and exits no-op, which
unblocks the PLA-621 soak.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…nc-state

fix(PLA-633): warn-and-continue on embedded PG + advance upstream-sync state to v2026.525.0
… unwrap) (#39)

drizzle-orm 0.45 rewraps every failed query as a DrizzleQueryError whose
top-level `code` is undefined; the real postgres.js PostgresError (SQLSTATE
40P01) is carried on `.cause`. isRetryablePgError only checked the top-level
code, so the retry-wrapped issue-PATCH path (clearOrphanCheckoutLocksIfTerminal
-> heartbeat_runs SELECT ... FOR UPDATE) never retried the deadlock and it
surfaced as a user-facing 500 — the signoff-policy e2e flake and a real prod
risk on concurrent issue status PATCHes during heartbeats.

Walk the `.cause` chain (bounded depth, cycle-guarded) in a shared
findRetryablePgErrorCode helper used by both isRetryablePgError and the retry
loop; the loop now logs the resolved deep SQLSTATE so a wrapped deadlock is
visible in logs. Adds regression tests covering wrapped/nested-cause deadlocks
and the cyclic-cause guard.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…ias creation (#38)

ensureLinuxSharedLibraryAliases treats EACCES/EPERM/EROFS like EEXIST (warn once + continue) instead of rethrowing, so a non-root service user on a root-owned global install no longer crash-loops at startup. Adds a read-only-lib-dir regression test. Rolled onto master after the PLA-638 deadlock fix so e2e is genuinely green.
…de flag (#41)

assertCloudDatabaseContract() currently warns-and-continues when an
authenticated+public deployment runs on embedded PostgreSQL with no
DATABASE_URL (PLA-635 posture, which the live instance relies on). That
permissive fallback was a silent default with no way to opt into strict
refuse-to-boot behavior.

Introduce config.allowEmbeddedPostgresPublic, sourced from
PAPERCLIP_ALLOW_EMBEDDED_POSTGRES_PUBLIC. The default is permissive: absent
or any value other than the literal "false" preserves warn-and-continue, so
the live authenticated+public+embedded-PG host keeps booting unchanged. Only
an explicit PAPERCLIP_ALLOW_EMBEDDED_POSTGRES_PUBLIC=false flips the no-
DATABASE_URL branch of assertCloudDatabaseContract to throw, requiring an
external managed Postgres.

Documents the flag in the environment-variables reference and the
authenticated+public deployment-modes section. Adds a startup regression test
asserting that allowEmbeddedPostgresPublic=false refuses to boot on embedded
PostgreSQL and never constructs the DB.

Co-authored-by: Paperclip CEO <ceo@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
…tion 0090) (#42)

* fix(PLA-644): activity_log.run_id ON DELETE SET NULL (heartbeat_runs delete race)

activity_log.runId referenced heartbeat_runs.id with no onDelete, so deleting a
heartbeat_runs row while an activity_log row still referenced it raised SQLSTATE
23503 (activity_log_run_id_heartbeat_runs_id_fk). Every other run FK already uses
onDelete:"set null" (issues.checkoutRunId/executionRunId,
secret_access_events.heartbeatRunId). run_id is nullable, so SET NULL is safe.

- schema: add onDelete:"set null" to activity_log.runId
- migration 0090: idempotent DROP CONSTRAINT IF EXISTS + ADD (additive, re-runnable)
- test: delete a referenced heartbeat_run -> no 23503, audit row survives with
  run_id NULL; plus a migration re-runnability check

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs(PLA-651): tracking note for fork-only migration 0090

activity_log.runId ON DELETE SET NULL is carried on the fork ahead of upstream
(PLA-585 upstream-PR freeze). Add a tracking note on the migration + schema so the
divergence is discoverable and gets re-submitted upstream after the freeze thaws.

- migration 0090: leading SQL comment "fork-only divergence; re-submit upstream
  after PLA-585 thaw (tracking: PLA-644)"
- schema activity_log.runId: same note at the FK declaration

No behavior change; satisfies PLA-651 acceptance criterion 5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
…rd (#36)

The core-host systemd unit ships ExecStart=/usr/bin/npx paperclipai run, which
resolves the bare name from the public npm registry and can silently launch an
upstream release instead of the installed fork build (proven downtime on the
v525 landing, PLA-629). The unit itself is operator-provisioned outside this
repo; the run-path change is escalated to CTO. This adds the repo-side
defense-in-depth: paperclipai run now logs exactly which build is executing
(fork vs upstream, version) so logs make the active binary legible, and aborts
fast when the operator pins PAPERCLIP_REQUIRE_FORK_BUILD or
PAPERCLIP_EXPECTED_VERSION and the running binary does not satisfy it — turning
a silent wrong-binary crash loop into an immediate, legible error.

Co-authored-by: Paperclip <noreply@paperclip.ing>
Replace the unconditional PLUGIN_SECRET_REFS_DISABLED throw in the plugin
secrets host handler with a company-scoped resolver, per the SecurityEngineer
sign-off on PLA-655/PLA-656. Mirrors the PLA-574 artifacts.fetch primitive.

- Source the dispatching companyId only from the run-context registry keyed on
  (pluginDbId, runId); never trust a worker-supplied company.
- Per-company allow-list via company_secret_bindings (targetType "plugin"),
  resolving through secretService.resolveSecretValue with ctx.companyId.
- Collapse every failure (cross-company, missing, deleted, inactive,
  missing/inactive version, not-bound, provider error) to one opaque not_found
  at the worker boundary — no existence oracle (R1).
- Typed worker-facing codes only: runcontext_invalid | not_found |
  rate_limited | invalid_ref; never echo the raw ref/value (R2, PLA-190/193).
- Re-key the rate limiter off the runContext: per-agent global + per-(agent,
  company) sub-bucket, both before the DB lookup; never pluginId (R3).
- Value-free allow/deny audit mirroring artifacts.fetch.
- Thread runId through the SDK: secrets.resolve protocol params + worker-rpc
  client + PluginSecretsClient.resolve(secretRef, runId).
- Lift the kill-switch's config-save half: secret-refs are permitted in plugin
  config again (resolution is now binding-gated).

Tests: server/src/__tests__/plugin-secrets-handler.test.ts — isolation matrix
(same-company resolves; cross-company == nonexistent not_found; no/forged
runContext -> runcontext_invalid; not-bound -> not_found; rotation honored;
no ref/value leak; limiter keyed on (agent[,company]) not pluginId).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
The PLA-657 resolver moves secret authorization to per-company
company_secret_bindings at call time, so the config-save half of the
kill-switch is lifted: storing a secret-ref in plugin config is now a
permitted pointer (200), not a 422. Update the plugin-routes-authz
assertion from "rejects … secret references are disabled" to "permits …
(resolution is company-binding-gated)" and assert upsertConfig is called.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…f config

One-time, idempotent migration (0090) that derives company_secret_bindings rows
from secret-ref values already present in plugin config, so the PLA-657
company-scoped resolver authorizes them with no manual per-tenant insert. Runs at
DB level (sees secret owners the company-scoped agent JWT cannot).

- Detects secret-ref paths via a session-temp recursive helper mirroring
  collectSecretRefPaths (annotated `format: "secret-ref"` only; the collect-all-UUID
  fallback is intentionally not replicated, so a plugin with no secret-ref field
  gets no bindings).
- Reads both instance-wide plugin_config and per-company plugin_company_settings.
- Keys company_id off the secret's TRUE owner (self-correcting, isolation-safe);
  target_id = plugins.id to match the resolver lookup; config_path = manifest dot-path.
- ON CONFLICT (company_id, target_type, target_id, config_path) DO NOTHING:
  preserves operator/DPR-set rows, re-runs are no-ops. Orphan/non-UUID refs skipped
  silently; no raw ref or secret value is emitted.

Covers cad + klipper uniformly (klipper binding auto-created, no plugin-specific code).

Tested: 7 embedded-pg cases (instance-wide, per-company, idempotent, no-clobber,
orphan, no-secret-ref, nested/combinator) pass 3x; criterion-4 validated via the
real applyPendingMigrations path over a populated pre-state DB (additive + idempotent).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…crets

The plugin_company_settings candidate branch filtered only on plugin_id and
discarded company_id, so company A's settings referencing company B's secret
UUID at a secret-ref path fabricated a binding(company_id=B) B never authored
(LOW, least-privilege: confined to B's boundary, not a tenant-isolation leak).

Carry pcs.company_id into the candidate row and require it to equal the secret
owner for src=1; plugin_config (src=0) is instance-wide and keeps owner-keying.
Strict tightening: legitimate A->A bindings kept, only A->B-owned refs dropped.

Adds a mixed-owner regression test (A's settings hold A's own + B's secret =>
only the A->A binding is created). Negative-control verified: the test fails on
the pre-fix SQL (2 bindings) and passes after the WHERE tweak (1).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Wire upsertConfig/patchConfig/upsertCompanySettings ->
secretService.syncPluginSecretBindings so a secret-ref provisioned after
install gets a company_secret_bindings row (migration 0090 was previously
the sole writer; the registry never touched secretService). company_id is
keyed off the secret's true owner (isolation-safe, matches the backfill);
per-company settings bind only secrets that company owns. Idempotent;
clears/repoints revoke or update the row. Not syncSecretRefsForTarget,
whose env-prefix delete never matches flat plugin config paths.

Also flips the stale "model B" -> "model C (company_secret_bindings)"
docstring in plugin-secrets-handler (implementation was already C).

Tests (embedded-pg, pass 3 runs): instance binding owned by secret's
company, idempotent re-save, clear-removes, repoint, nested path, orphan
skipped, no-secret-ref-field no-op, per-company cross-company skip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fork/master landed PLA-651 0090_activity_log_run_id_set_null (#42), colliding
with this branch's 0090_backfill_plugin_secret_bindings. Resolved the only
conflict (_journal.json) by keeping master's 0090 and renumbering the backfill
to 0091 (idx 91), so the chain is 0089 -> 0090_activity_log -> 0091_backfill.
Renamed the .sql + updated the backfill test's filename reference. Unblocks
PR #43 (was CONFLICTING, so CI could not run). Migration chain + backfill +
forward-path tests pass 3 runs against the merged chain.
claudegoogl-sudo and others added 28 commits August 14, 2026 00:28
…ptions (#214)

Chat-relay plugins subscribe to the issue.interaction.created plugin event
but historically received only interactionId/interactionKind, so a plugin
that forwards interactions to an external channel could not render the
prompt or choices — the operator saw a contentless ping.

Attach a bounded, normalized projection of the interaction's
questions/options to the event payload, alongside the existing
interactionId/interactionKind. All four interaction kinds
(ask_user_questions, request_checkbox_confirmation, request_confirmation,
suggest_tasks) normalize into a common
{ id, kind, title?, questions: [{ id, prompt, selectionMode, options }] }
shape. Only questions/options (and an optional title) are projected —
target/href, secret refs, and result data are omitted to minimize plugin
visibility. Bounds cap option description length (200), options per
question (30), and question count (20) so the event stays small. The
projection never throws, so it cannot break the interaction-create request.

No new plugin capability is required; existing consumers are unaffected.

Co-authored-by: Paperclip <noreply@paperclip.ing>
Operator-delivery comments (body beginning with the operator-deliver
marker) are only forwarded to the operator by the messenger relay when
they are agent-authored. A host/board/user-token marked comment was
silently dropped by the relay yet still persisted and still woke the
issue assignee, so the thread looked delivered while nobody was paged.

Add a write-time guard that rejects a marked comment from a non-agent
actor with a 422 that names the reason (must be agent-authored; the
relay drops non-agent marked comments), before any persistence or wake.
This forces the sender onto the agent-token path, which both relays
correctly and (via the existing wake fan-out guard) does not echo the
outbound message back as fake inbound operator input.

Refine the wake fan-out comment to note that marked comments reaching it
are now agent-authored. Cover both guards in the comment-wakeup route
test: non-agent marked comment rejected + not persisted + no wake; and
agent-authored marked comment accepted + persisted + no assignee wake.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ke-suppression

fix(server): correct operator-delivery comment handling on comment POST
A non-returning MCP tool call (including external MCP servers such as
@playwright/mcp) could hang a run indefinitely because Claude Code leaves
tool calls effectively unbounded (~28h) by default. The hung run never
released its issue execution lock.

- claude-local adapter: inject MCP_TOOL_TIMEOUT (default 300s) and
  MCP_TIMEOUT (default 30s) into the Claude CLI env unless an operator set
  them explicitly; tunable via PAPERCLIP_MCP_TOOL_TIMEOUT_MS /
  PAPERCLIP_MCP_STARTUP_TIMEOUT_MS. Covers external MCP servers.
- mcp-server: wrap generic tool dispatch with a deadline (default 120s,
  PAPERCLIP_MCP_TOOL_TIMEOUT_MS), honoring an optional per-call
  timeoutSeconds; on timeout return a clear tool error instead of hanging.
- mcp-server: bound the outbound API fetch with an AbortController
  (default 60s, PAPERCLIP_MCP_FETCH_TIMEOUT_MS); abort surfaces as a 504.
- Tests for never-resolving tool call, fetch timeout, and env injection.
- Document the new env vars.

Co-authored-by: Paperclip <noreply@paperclip.ing>
The active-run output watchdog previously only detected a run that held an
issue's execution lock while emitting no output — it filed a review issue and
escalated priority but never terminated the process or released the lock, so a
wedged run could hold a lock for hours until a manual kill.

Add a privileged teardown path (default OFF, opt-in via
WATCHDOG_AUTO_TEARDOWN_ENABLED) that terminates the run's process group, marks
the run failed (errorCode watchdog_auto_teardown), and releases the source
issue's execution lock so it becomes re-pickable. The teardown is idempotent
(guarded status transition), records a `terminate` watchdog-decision row, emits
a heartbeat.watchdog_torn_down activity entry, a run event, and a source-issue
comment. A live snooze/continue decision always suppresses teardown.

The teardown threshold is a dedicated tunable (WATCHDOG_AUTO_TEARDOWN_SILENCE_MS,
default 45m, floored at 30m) — deliberately not the 4h critical detection
threshold. Board/assigned-recovery-owner actors can also trigger an authorized
manual terminate via the watchdog-decisions route regardless of the flag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…218)

Killing a run's parent left the Playwright MCP node server orphaned to init
(ppid==1) whenever the MCP had escaped the run's process group into its own
session, so `kill(-pgid)` on the run group missed it. These strays leaked
silently and accumulated across restarts.

- Add mcp-orphan-reaper: snapshot a run's MCP descendants before teardown
  (ppid links intact), then kill any that survived the process-group signal.
- Wire it into terminateHeartbeatRunProcess so both normal exit and
  watchdog/cancel teardown reap escaped MCP children.
- Add a startup sweep that kills pre-existing ppid==1 MCP strays. Matching is
  deliberately narrow (MCP package/bin name + ppid==1) so no unrelated node
  or `playwright test` process is ever signalled.

Verified with unit tests over the pure selectors and two real-process
integration tests: an MCP child that escaped its group is reaped on teardown,
and a double-forked ppid==1 stray is reaped by the startup sweep.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…down

feat(server): feature-flagged watchdog auto-teardown of wedged runs
…runs

The source-resolved watchdog cleanup reconstructs a process group from
persisted heartbeat_runs metadata when no live in-memory handle exists
(notably after a server restart). A stored pid/pgid is most likely to have
been recycled by the OS onto an unrelated process by then, and the
feature-flagged auto-teardown now signals it with no operator in the loop.
Blast radius is bounded to process groups owned by the paperclip uid, so the
worst case is SIGKILLing an innocent same-owner run.

Add verifyProcessStartIdentity: on the DB-only path, compare the kernel
start time (/proc/<pid>/stat field 22 + system boot time) against the spawn
timestamp recorded in process_started_at, within a tolerance that absorbs
clock-source skew but rejects recycled pids. Skip the kill (new
skipped_identity_unverified outcome) on any inability to positively confirm
identity; the trusted in-memory-handle path is unaffected, and non-Linux
hosts preserve prior behavior.

Regression test spawns a real process and asserts a recycled-pid fixture
(mismatched recorded start time) is classified "mismatch" and left
unsignalled.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
)

The watchdog auto-teardown path already gates its DB-only kill on
verifyProcessStartIdentity, but three sibling paths still signalled a
pid/pgid reconstructed from persisted run metadata with no live handle:
the process-loss reaper's descendant-group branch, and the cancelRun /
wakeup-cancel no-in-memory-handle branches. After a restart the OS may
recycle that pid/pgid onto an unrelated local process, so these blind
kills could SIGTERM/SIGKILL a stranger on the shared host.

Centralize the gate in terminateHeartbeatRunProcess with two identity
modes:

- "process" (cancelRun / wakeup-cancel no-handle branches): the persisted
  pid is expected to be alive, so any inability to positively match its
  kernel start time against the recorded spawn timestamp is a recycled
  live process and the kill is skipped (fail-closed).
- "descendant-group" (process-loss reaper): reached only after the parent
  pid is confirmed dead, so the only thing alive is an orphaned descendant
  group whose leader is gone. A dead leader cannot be a live recycled
  process, so we reap it; we skip only when the group leader is still
  alive with a mismatched start time. Verifying the dead leader as if it
  were a live pid would always mismatch and wrongly block reaping the
  legitimate orphans.

Trusted in-memory-handle callers pass trustedHandle:true and are
unchanged. The reaper only claims a descendant-group teardown when the
signal was actually sent, and a skipped kill logs a warn line (with the
identity mode) for observability.

Adds real-process regression coverage for both cancel no-handle sites
(fail-closed on mismatch), the descendant-group reaper (reaps a dead
leader; skips a live mismatched leader), the trusted-handle bypass, and
the genuine-survivor and missing-timestamp cases.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…lic packages (#221)

Two fork-build packaging defects that forced uncommitted local patches to cut a
clean release. Both are build-time only; no shipped runtime code changes.

1. hermes prepack collided with pack-public-packages. The packer applies a
   package's publishConfig onto the manifest and deletes the publishConfig key
   before `pnpm pack`, which fires the hermes adapter's own prepack. That prepack
   re-read the now-stripped publishConfig.exports and threw, failing the pack
   deterministically. Make the prepack idempotent: no-op when publishConfig is
   already absent (packer path); still transform normally on the standalone
   `npm publish` path. Refactor its core into an exported preparePublishPackage()
   and add fixture-based tests.

2. adapter-hermes-gateway packed at its literal 0.1.0 instead of the release
   version. pack-public-packages packs every public package, but set-version only
   stamped CI-published (publishFromCi) packages, so the deprecated gateway shim
   shipped unstamped. Decouple version-stamping from CI publishing: add an
   optional stampVersion manifest flag (defaults to publishFromCi) and stamp the
   shim without enrolling it in CI publish. The shim stays publishFromCi:false.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…#222)

The four real-cluster tests in embedded-postgres-auth.test.ts spin up an
embedded Postgres (initdb + start) with an internal retry loop of up to
five attempts, but relied on vitest's default 5000ms per-test timeout.
Under CI runner contention a slow initdb or a single retry exceeds 5s and
the test times out, taking master's Release/verify_canary job red.

Match the established convention in client.test.ts, where every
real-cluster test passes an explicit 20_000ms timeout. The pure-helper
tests keep the default.

Co-authored-by: Paperclip <noreply@paperclip.ing>
The supertest setup file mkdtemps a `paperclip-vitest-codex-home-*` dir
and sets CODEX_HOME once per vitest fork worker but registered no
teardown, so every worker leaked one dir on exit (hundreds accumulate on
a busy runner). Register a synchronous best-effort removal on process
exit and on SIGINT/SIGTERM/SIGHUP — vitest's fork pool terminates idle
workers with SIGTERM, which does not fire the "exit" event, so the signal
handlers are what actually reclaim the dir in the common case. Only the
dir this file created is removed; an environment-injected CODEX_HOME is
left untouched.

Add a regression test asserting the worker-scoped codex home and its
synthetic auth.json exist and that the exit/signal teardown handlers are
registered, so removing a handler reddens the suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
… edits bust the resumed session (#223)

The claude-local adapter busts a resumed session when the instruction prompt
bundle changes: it compares the stored `promptBundleKey` in
`runtime.sessionParams` against the freshly-computed bundle key and starts a
fresh session on mismatch. But `agent_runtime_state` — the home for the
system/heartbeat session (no per-issue task key) — had no column to hold those
params. Only per-issue sessions persisted them, in
`agent_task_sessions.session_params_json`. So for a heartbeat agent the stored
key was always empty, the guard's `length === 0` short-circuit always matched,
and a pinned session was never busted when its instructions/charter changed.

- Add nullable `session_params_json jsonb` to `agent_runtime_state` (schema +
  idempotent migration 0148 + journal + posture classification).
- Persist the system/heartbeat session's resume params on runtime-state save
  (system sessions only; per-issue sessions are untouched).
- Read those params back into `runtime.sessionParams` on the next system wake,
  honouring a requested session reset.
- Add an embedded-postgres integration test proving the persist + read-back
  wiring for a system session and no behaviour change for per-issue sessions.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…eardown

test(server): clean up per-worker vitest CODEX_HOME temp dirs
…key origin

A plugin worker's ctx.http.fetch previously had no destination check tied to
the plugin's own instance configuration: a plugin declaring a format:"uri"
config key (e.g. klipper's moonrakerBaseUrl, pointing at a company's own
Moonraker host) could still have ctx.http.fetch called against an arbitrary
attacker-controlled destination, with nothing to stop it besides the generic
protocol/DNS-based fetch guard.

This adds a second egress-allowlist axis, modeled directly on the existing
per-secret-binding allowlist mechanism: for any plugin that declares one or
more format:"uri" instance-config keys, ctx.http.fetch is gated (ahead of DNS
resolve) against an allowlist built from every enabled company's own declared
value for that key, plus any operator-added extra destinations. New instances
start log-only (every would-be-denied destination is recorded as a
suggestion, nothing is blocked) until an operator reviews the suggestions and
explicitly flips a company's row to enforced via the new operator-only HTTP
routes (GET/POST .../config-egress, mirroring the existing per-binding
allowlist review/set/enforce routes).

Because a plugin worker's ctx.http.fetch call carries no trustworthy per-call
company identity, the enforcement decision is necessarily plugin-wide (a
union across every company's declared config value, OR'd enforcement across
every company's row) rather than per-tenant — documented at the DB, service,
and operator-docs layers so this asymmetry isn't missed by an operator
enforcing what looks like a single company's row.

Two related transports are explicitly NOT covered by this mechanism and
should not be assumed egress-controlled: the vault plugin's outbound requests
(made via a direct fetch, not ctx.http.fetch), and klipper's Moonraker
WebSocket connection (a separate transport from ctx.http.fetch).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
… in openapi spec

Rebasing onto master surfaced two new fork guards:

- The security-posture total-classification guard (check:migrations /
  check-migration-safety.test.ts) requires every schema column to be either a
  registered posture control or an explicitly-reasoned rejection. Register the
  two config-egress enforcement columns (egress_allowlist_enforced,
  allowed_egress) as controls and reject the identity/metadata/observation
  columns of plugin_config_egress_allowlist and
  plugin_config_egress_would_deny_observations.

- The openapi route-coverage test requires every mounted route file to be
  registered and every route to appear in the generated spec. Register the
  three operator-only plugin config-key egress routes (review, set-allowlist,
  enforce) and add the route file to the coverage map.

Also fix a stale migration-number reference in the allowlist schema docblock.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…egress-allowlist

Gate ctx.http.fetch on a plugin's own declared config-key origin
…ss writes (#225)

The plugin config-key egress operator write routes (set-allowlist /
enforce-flip) gated only on assertBoard + assertCompanyAccess + a
config-key-declared check. Because the runtime deny decision unions/ORs
every enabled company's row plugin-wide (amendment A2), a board operator
of a company that does not run the plugin could still seed/flip the
shared allowlist and plugin-wide enforcement.

assertUriConfigKey now also requires an enabled=true plugin_company_settings
row for (companyId, pluginId) before either write — 403 otherwise, matching
the runtime union's notion of participation (listEnabledCompanySettings).
Review (read) stays ungated so posture is visible plugin-wide. Adds a
regression test: a board user of a company with no row / an enabled=false
row is rejected on both writes and no row is created.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…list (#226)

Routing every Vaultwarden request through `ctx.http.fetch` (the plugin no
longer calls `globalThis.fetch` directly) makes vault a full subject of the
plugin config-key egress mechanism: its `format:"uri"` `serverUrl` origin is
harvested into would-deny suggestions and can be flipped to enforce,
plugin-wide, exactly like klipper's `moonrakerBaseUrl`.

The prior "no effect on vault egress" bullet now understates coverage — the
mirror image of overclaiming, and the same operator-misunderstanding failure
mode we already guard against. Rewrite the Coverage note so vault is listed
as covered (plugin-wide, log-only until enforce), and leave the klipper
Moonraker WebSocket bullet as the one genuinely uncovered transport.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…le pin (#227)

A stored task session (its id lives in sessionParams) is expected to carry
a prompt-bundle pin. The old behaviour treated a missing pin as a match, so
a stored pinless session (e.g. one saved before the pin field existed, or
pinned outside the normal save path) resumed forever and the prompt-bundle
auto-bust never fired — agent instruction/charter edits silently ran against
a stale session.

Refuse to resume a stored session that has no prompt-bundle pin: start a
fresh session, which re-pins to the current bundle and converges on the next
heartbeat. Sessions supplied only via runtime.sessionId (e.g. in-run poison
recovery) are not stored task sessions and keep resuming as before.

Add an observability log line when a pinless stored session is detected so
the stale-session symptom is diagnosable from the run log, and add a unit
test covering the missing-pin case.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…ct changes (#228)

* feat(claude-local): bust pinned sessions when the host runtime contract changes

The prompt-bundle auto-bust only keys resume-eligibility on the injected
instruction/skill content. When a host-side runtime contract that governs a
resumed session (e.g. a /live CONTRACT.md, job script, or the active MCP
config) flips without any change to the agent's instructions, the bundle key
is unchanged and the pinned session keeps --resume-ing against stale precedent.

Fold a content fingerprint of a configurable set of host-side runtime-contract
inputs into resume-eligibility: a change to any configured input busts the
pinned session on the next trigger and a fresh session is started with
instructions injected. The fingerprint is persisted alongside the session
(same place as promptBundleKey) and compared on the next run.

- New runtime-contract.ts: buildRuntimeContractFingerprint(paths) -> sha256
  content hash of the configured files/dirs; "" when unconfigured.
- execute.ts: read config.runtimeContractFingerprintPaths, compute+compare the
  fingerprint, gate canResumeSession, log the bust, persist the fingerprint.
- index.ts sessionCodec: carry runtimeContractFingerprint through
  serialize/deserialize so it survives persistence.
- Unconfigured (empty fingerprint source) => no behavior change.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* test(claude-local): pin prompt-bundle key in the remote-resume fixture

The remote SSH resume fixture predates the missing-prompt-bundle-pin bust and
stored a session with no promptBundleKey, so that session is now correctly
refused for resume and the "--resume" assertion failed. Add the prompt-bundle
key for an empty adapterConfig so the fixture exercises the resume path again.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
* fix(hermes): default-deny env allowlist for child process

The hermes_local adapter built the child-process env by blanket-inheriting
the entire Paperclip server env (`...process.env`), then layering per-agent
values on top. The server process holds every company's/agent's secrets
(provider keys, PATs, bot tokens), so any agent whose model has a shell
toolset could `printenv` and exfiltrate them into third-party logs.

Replace the blanket spread with a default-deny allowlist of non-secret
runtime essentials (HOME, PATH, USER, LOGNAME, SHELL, PWD, TMPDIR, TZ, LANG,
LC_ALL/LC_*, TERM, XDG_*, NODE_ENV, SSL_CERT_FILE, SSL_CERT_DIR). Per-agent
credentials continue to flow through adapterConfig.env (userEnv) and the
explicit Paperclip injections (buildPaperclipEnv + PAPERCLIP_API_KEY), which
are layered on top and unchanged. Consequence: a logging provider can at most
see the agent's own least-privilege token and its own bound keys.

Add focused unit tests proving secret vars are dropped while PATH/HOME,
adapterConfig.env vars, and PAPERCLIP_API_KEY pass through.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(hermes): stop server-env inheritance at the child spawn boundary

The hermes adapter builds a least-privilege child env (allowlist + userEnv +
buildPaperclipEnv + explicit PAPERCLIP_* injections), but the shared
`runChildProcess` runner re-merged the full server `process.env` (minus
PAPERCLIP_* via the `sanitizeInheritedPaperclipEnv` denylist) UNDERNEATH that
env. Every non-PAPERCLIP_ server secret (other agents' keys, provider tokens,
etc.) therefore still reached the child. A hermes agent running on a
prompt-logging provider with a shell toolset could `printenv` and exfiltrate
them into a third-party-retained log.

- Extract the spawn-env merge into a pure, unit-testable `buildChildEnv`
  helper and add an opt-in `inheritServerEnv` param to `runChildProcess`
  (default `true`, so claude-local/gemini-local/cursor-local/acpx-local
  behavior is unchanged).
- When `inheritServerEnv === false`, the child env is built from `opts.env`
  alone (default-deny). The CLAUDE_CODE_* nesting-var strip and
  `ensurePathInEnv` still apply.
- The hermes adapter passes `inheritServerEnv: false`, so its child env is
  exactly the intended least-privilege set.

Tests: `build-child-env.test.ts` asserts the invariant at the merge boundary
(secret absent with the flag, present under the historical default);
`execute-env-wiring.test.ts` captures the real `runChildProcess` opts and
fails if the adapter stops passing the flag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
…rs (#230)

The hermes adapter unconditionally overwrote the child's PAPERCLIP_API_KEY
with the broad, run-scoped board key (ctx.authToken), clobbering any narrow
task_bridge key an operator bound via adapterConfig.env. For openrouter /
cloaked "stealth" models — whose upstream logs the full prompt and tool
output — that broad same-company key could leak into a third party's logs via
prompt injection, header reflection, or a naive env echo.

Now:
- An operator-bound task_bridge key (PAPERCLIP_API_KEY or
  PAPERCLIP_BRIDGE_API_KEY in adapterConfig.env) is never overwritten.
- External-logging providers with no scoped key bound fail closed (refuse to
  spawn) instead of silently injecting the broad key.
- Internal providers (e.g. claude_local) keep the run-scoped fallback, so
  existing behavior is unchanged.

Adds resolveSpawnApiKey/isExternalLoggingTarget helpers, regression tests
(synthetic key fixtures only), and adapter docs for the fail-closed contract.

Co-authored-by: Paperclip <noreply@paperclip.ing>
…owlist (#231)

* fix(hermes): default-deny run-key injection via internal provider allowlist

Invert the PAPERCLIP_API_KEY spawn guard from a provider denylist to a
fail-closed internal allowlist. The broad run-scoped board key is now
injected only for allowlisted internal providers (anthropic / claude_local);
every other provider — including provider=auto and any provider added to
Hermes in the future — must bind a task_bridge-scoped key or the spawn fails
closed.

A denylist missed two leak paths: (a) a future external-logging provider
nobody remembered to add to the set, and (b) provider=auto routing to an
external upstream under a benign (non-stealth) model name. Both now fail
closed by construction. isExternalLoggingTarget is retained as the inverse
of the new isInternalKeyTarget helper.

Adds regression tests for an unknown/new external provider and for
provider=auto with a non-stealth model (both leaked the broad key on the
pre-inversion code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(hermes): execute()-level regression for bound-key wiring

Follow-up to the hermes external-logging-key security review. The
resolveSpawnApiKey unit tests cover the helper in isolation; these cover the
call-site wiring in execute():

- boundBridgeKey is read from config.env (userEnv) ONLY, never from the
  merged env or a stray host process.env.PAPERCLIP_API_KEY. An external-
  logging target with a host key present but no bound key still fails closed.
- the `else { delete env.PAPERCLIP_API_KEY }` scrub fires: an internal
  provider with no bound key and no authToken spawns with no PAPERCLIP_API_KEY.

Also pins the pre-existing env-inheritance test to an internal provider
(anthropic); under the new default-deny key logic an unresolved provider
("auto") fails closed before reaching runChildProcess, which was the intended
scope of that test's assertions.

Synthetic fixtures only. Verified: both new tests pass on the fixed code;
test 1 fails when openrouter is (mis)added to the internal allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…giene

Add a "Never Paste Live Secret Values Into Board Text" section to the
engineering secrets guidance so QA and cross-company test runs discover
the rule at the point of use:

- Never paste a live agent/API key into a comment, doc, PR body, or
  attachment; soft-delete does not undo exposure (backups retain
  plaintext) — revocation neutralizes a leaked key, deletion does not.
- Preferred cross-company/QA key delivery order: mint-at-test-time and
  DELETE at run end; operator relay/interaction artifact; else reference
  by key id or <=6-8 char prefix, never the full value.
- On exposure: revoke first (ungated), soft-delete, sweep siblings/
  digests/attachments/backups, then file the incident.

Docs-only change; no secret values in the diff.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
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.

3 participants