docs(secrets): never paste live agent keys into board text + QA key delivery hygiene - #232
Open
claudegoogl-sudo wants to merge 272 commits into
Open
docs(secrets): never paste live agent keys into board text + QA key delivery hygiene#232claudegoogl-sudo wants to merge 272 commits into
claudegoogl-sudo wants to merge 272 commits into
Conversation
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.
…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>
claudegoogl-sudo
force-pushed
the
master
branch
from
August 24, 2026 07:46
ee93ee0 to
458e462
Compare
14 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
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
docs/deploy/secrets.md, immediately after "Custody Boundaries".DELETEat run end, (2) operator relay/interaction artifact, (3) reference by key id or<=6-8char prefix — never the full value.Verification
git diffscanned for secret shapes and internal ids — clean (0 matches forpcp_/xox/sk-/AKIA/Bearer/internal ticket ids).<and{in the added lines (<=6-8,{id}) are inside backticks, so Mintlify/MDX parses them as inline code, not JSX.deploy/secretsis already registered indocs/docs.jsonnavigation, 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
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template — path (B)#NNN/github.com/paperclipai/paperclipURLs)docs/secrets-no-live-keys-in-comments) and contains no internal Paperclip ticket id or instance-derived detailsFollows
CONTRIBUTING.mdPR template — sections present: Thinking Path, Linked Issues/Issue Description, What Changed, Verification, Risks, Model Used, Checklist.