read-only-prisma.ts only blocks $executeRaw[Unsafe], not $transaction, $queryRaw[Unsafe], $runCommandRaw. Tools in helpers.ts pass raw ctx.db to createScopedCallerContext → tRPC callers bypass the proxy and can mutate. Separately, several tool executors call assertPermission AFTER input parsing/DB lookups, allowing enumeration before auth.
Evidence
packages/api/src/lib/read-only-prisma.ts:47-52 — only $executeRaw[Unsafe] blocked
packages/api/src/router/assistant-tools/helpers.ts:1676-1680 — raw ctx.db passed to scoped caller
packages/api/src/router/assistant-tools/advanced-timeline.ts:617 — assertPermission late
Impact
LLM-invoked 'read-only' tool can trigger writes via tRPC-caller indirection, violating the read-only guarantee. Late assertPermission leaks info via lookup errors before auth fires.
Proposed Fix
(1) Extend proxy to block $transaction, $queryRaw*, $runCommandRaw. (2) createScopedCallerContext must propagate isReadOnly and swap in the proxy so tRPC callers see it. (3) Move assertPermission(ctx, PermissionKey.XXX) to the FIRST line of every tool executor, before argument parsing. (4) Unit test: each read-tool invoked with mutation-intent payload → no writes observed.
## Problem
`read-only-prisma.ts` only blocks `$executeRaw[Unsafe]`, not `$transaction`, `$queryRaw[Unsafe]`, `$runCommandRaw`. Tools in helpers.ts pass raw `ctx.db` to `createScopedCallerContext` → tRPC callers bypass the proxy and can mutate. Separately, several tool executors call `assertPermission` AFTER input parsing/DB lookups, allowing enumeration before auth.
## Evidence
- `packages/api/src/lib/read-only-prisma.ts:47-52 — only $executeRaw[Unsafe] blocked`
- `packages/api/src/router/assistant-tools/helpers.ts:1676-1680 — raw ctx.db passed to scoped caller`
- `packages/api/src/router/assistant-tools/advanced-timeline.ts:617 — assertPermission late`
## Impact
LLM-invoked 'read-only' tool can trigger writes via tRPC-caller indirection, violating the read-only guarantee. Late `assertPermission` leaks info via lookup errors before auth fires.
## Proposed Fix
(1) Extend proxy to block `$transaction`, `$queryRaw*`, `$runCommandRaw`. (2) `createScopedCallerContext` must propagate `isReadOnly` and swap in the proxy so tRPC callers see it. (3) Move `assertPermission(ctx, PermissionKey.XXX)` to the FIRST line of every tool executor, before argument parsing. (4) Unit test: each read-tool invoked with mutation-intent payload → no writes observed.
## Acceptance Criteria
- [ ] Proxy blocks $transaction/$queryRaw*/$runCommandRaw
- [ ] Scoped-caller tests: read-only intent → raw client unreachable
- [ ] Every tool executor calls assertPermission first line
- [ ] Regression test suite for all read-tools
---
Parent Epic: #1
Source: Full-Codebase Security Audit 2026-04-16 (C-3, C-4)
Part 1 — Proxy blocks all escape hatches (commit 1ff5c33, verified at packages/api/src/lib/read-only-prisma.ts:26-32):
$executeRaw
$executeRawUnsafe
$transaction
$queryRawUnsafe (template-tagged $queryRaw intentionally allowed — read-only by API contract)
$runCommandRaw
Part 2 — Scoped-caller forwarding (commit 1ff5c33, verified at packages/api/src/router/assistant-tools/helpers.ts:1670-1687 and packages/api/src/router/assistant-tools.ts:740):
executeTool wraps ctx.db with createReadOnlyProxy(ctx.db) for every non-mutation tool BEFORE the executor runs.
createScopedCallerContext forwards ctx.db to the tRPC caller verbatim — since ctx.db is already the proxy, the caller inherits it transparently.
Regression test: packages/api/src/__tests__/read-only-scoped-caller.test.ts (3 tests, commit b9040cb) proves writes, raw SQL, and $transaction all throw through the forwarded db.
Part 3 — Early access check (verified at packages/api/src/router/assistant-tools.ts:728-736 and packages/api/src/router/assistant-tools/access-control.ts):
getAssistantToolAccessFailure(toolDefinition, ctx) runs at the top of executeTool, BEFORE JSON.parse(args) and BEFORE the executor is invoked. No DB lookup happens before auth fires.
advanced-timeline.ts:617 already has assertPermission as the first two lines of quick_assign_timeline_resource (before the DB resolver calls at line 620).
Per-executor assertPermission calls remain as defense-in-depth.
Part 4 — Regression test suite:
packages/api/src/__tests__/read-only-prisma.test.ts (7 tests) — covers the proxy itself
Resolved.
**Part 1 — Proxy blocks all escape hatches** (commit 1ff5c33, verified at `packages/api/src/lib/read-only-prisma.ts:26-32`):
- `$executeRaw`
- `$executeRawUnsafe`
- `$transaction`
- `$queryRawUnsafe` (template-tagged `$queryRaw` intentionally allowed — read-only by API contract)
- `$runCommandRaw`
**Part 2 — Scoped-caller forwarding** (commit 1ff5c33, verified at `packages/api/src/router/assistant-tools/helpers.ts:1670-1687` and `packages/api/src/router/assistant-tools.ts:740`):
- `executeTool` wraps `ctx.db` with `createReadOnlyProxy(ctx.db)` for every non-mutation tool BEFORE the executor runs.
- `createScopedCallerContext` forwards `ctx.db` to the tRPC caller verbatim — since `ctx.db` is already the proxy, the caller inherits it transparently.
- Regression test: `packages/api/src/__tests__/read-only-scoped-caller.test.ts` (3 tests, commit b9040cb) proves writes, raw SQL, and `$transaction` all throw through the forwarded db.
**Part 3 — Early access check** (verified at `packages/api/src/router/assistant-tools.ts:728-736` and `packages/api/src/router/assistant-tools/access-control.ts`):
- `getAssistantToolAccessFailure(toolDefinition, ctx)` runs at the top of `executeTool`, BEFORE `JSON.parse(args)` and BEFORE the executor is invoked. No DB lookup happens before auth fires.
- `advanced-timeline.ts:617` already has `assertPermission` as the first two lines of `quick_assign_timeline_resource` (before the DB resolver calls at line 620).
- Per-executor `assertPermission` calls remain as defense-in-depth.
**Part 4 — Regression test suite**:
- `packages/api/src/__tests__/read-only-prisma.test.ts` (7 tests) — covers the proxy itself
- `packages/api/src/__tests__/read-only-scoped-caller.test.ts` (3 tests) — covers scoped-caller forwarding
All acceptance criteria met — closing.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
read-only-prisma.tsonly blocks$executeRaw[Unsafe], not$transaction,$queryRaw[Unsafe],$runCommandRaw. Tools in helpers.ts pass rawctx.dbtocreateScopedCallerContext→ tRPC callers bypass the proxy and can mutate. Separately, several tool executors callassertPermissionAFTER input parsing/DB lookups, allowing enumeration before auth.Evidence
packages/api/src/lib/read-only-prisma.ts:47-52 — only $executeRaw[Unsafe] blockedpackages/api/src/router/assistant-tools/helpers.ts:1676-1680 — raw ctx.db passed to scoped callerpackages/api/src/router/assistant-tools/advanced-timeline.ts:617 — assertPermission lateImpact
LLM-invoked 'read-only' tool can trigger writes via tRPC-caller indirection, violating the read-only guarantee. Late
assertPermissionleaks info via lookup errors before auth fires.Proposed Fix
(1) Extend proxy to block
$transaction,$queryRaw*,$runCommandRaw. (2)createScopedCallerContextmust propagateisReadOnlyand swap in the proxy so tRPC callers see it. (3) MoveassertPermission(ctx, PermissionKey.XXX)to the FIRST line of every tool executor, before argument parsing. (4) Unit test: each read-tool invoked with mutation-intent payload → no writes observed.Acceptance Criteria
Parent Epic: #1
Source: Full-Codebase Security Audit 2026-04-16 (C-3, C-4)
Resolved.
Part 1 — Proxy blocks all escape hatches (commit
1ff5c33, verified atpackages/api/src/lib/read-only-prisma.ts:26-32):$executeRaw$executeRawUnsafe$transaction$queryRawUnsafe(template-tagged$queryRawintentionally allowed — read-only by API contract)$runCommandRawPart 2 — Scoped-caller forwarding (commit
1ff5c33, verified atpackages/api/src/router/assistant-tools/helpers.ts:1670-1687andpackages/api/src/router/assistant-tools.ts:740):executeToolwrapsctx.dbwithcreateReadOnlyProxy(ctx.db)for every non-mutation tool BEFORE the executor runs.createScopedCallerContextforwardsctx.dbto the tRPC caller verbatim — sincectx.dbis already the proxy, the caller inherits it transparently.packages/api/src/__tests__/read-only-scoped-caller.test.ts(3 tests, commitb9040cb) proves writes, raw SQL, and$transactionall throw through the forwarded db.Part 3 — Early access check (verified at
packages/api/src/router/assistant-tools.ts:728-736andpackages/api/src/router/assistant-tools/access-control.ts):getAssistantToolAccessFailure(toolDefinition, ctx)runs at the top ofexecuteTool, BEFOREJSON.parse(args)and BEFORE the executor is invoked. No DB lookup happens before auth fires.advanced-timeline.ts:617already hasassertPermissionas the first two lines ofquick_assign_timeline_resource(before the DB resolver calls at line 620).assertPermissioncalls remain as defense-in-depth.Part 4 — Regression test suite:
packages/api/src/__tests__/read-only-prisma.test.ts(7 tests) — covers the proxy itselfpackages/api/src/__tests__/read-only-scoped-caller.test.ts(3 tests) — covers scoped-caller forwardingAll acceptance criteria met — closing.