review: security fixes, dead-code + duplication cleanup, convention reframing - #34
Open
stevenn wants to merge 7 commits into
Open
review: security fixes, dead-code + duplication cleanup, convention reframing#34stevenn wants to merge 7 commits into
stevenn wants to merge 7 commits into
Conversation
draft_skill was open to every signed-in user but resolved upstreams with a bare getUpstreamBySlug, so it leaked the tool names, descriptions and input schemas of upstreams an admin never granted -- and of tools hidden by the per-tool ACL. It now resolves against listUpstreamsVisibleToUser and filters the catalogue with isToolAllowed, mirroring describe_upstream. buildDraftSkillText inlines the bundle into a prompt template that opens with imperative first-party guidance, but tool descriptions skipped the untrusted-text gate the proxy applies -- so an upstream could smuggle control characters and forge a first-party provenance segment, breaking the unforgeability invariant provenance.ts documents. sanitizeUntrustedText moves out of tools-proxy into provenance.ts (next to defangProvenance, so non-proxy consumers cannot silently skip it) and is applied when the bundle is built. The collab WebSocket upgrade verified only the cookie signature, so a suspended or deleted user kept read+write access over the socket for the cookie's remaining 30 days. It now re-reads the user row and requires status='active', matching resolveActiveUser on the REST path. PUT /api/upstreams/:id/credentials and GET /api/upstreams/:id/oauth/start used getUpstreamById with no visibility gate, unlike the sibling /:id/tools route. Beyond the existence oracle, the credentials path warms the shared catalogue -- letting an ungranted caller replace org-wide upstream_tools rows with what their own token can see. resolveUserPrincipals was built three different ways (tools-proxy, list_my_context, tools-directory); it now has one home in db/queries/ tool-access.ts so the ACL surfaces cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
…unused
Dead code, each verified unreferenced across apps/, packages/ and scripts/:
- getIncomingLinks / getOutgoingLinks (db/queries/doc-links.ts) -- only the
integration test called them, and their docstrings claimed production
roles ("move-rewrite driver", "for the editor panel") that
getIncomingLinkDocs / getOutgoingLinkTargets actually fill. The test now
drives those instead, so it exercises the paths production uses.
- listGitConnections (db/queries/git-connections.ts) -- no importers.
- DocLinkRow, FolderTreeResponse -- the latter is a leftover from a removed
endpoint; there is no GET /api/folders (folders.ts is PATCH + DELETE only)
and the SPA derives the tree client-side via computeFolderNodes.
- the ASSETS binding (wrangler.toml + env.ts) -- env.ASSETS is never called;
the SPA fallback is not_found_handling = "single-page-application".
- scripts/slug-prefix-migration.sql -- referenced by nothing.
- .info-strip CSS (superseded by MetaRow) and 10 never-read custom
properties, incl. --fg / --muted which duplicated --text / --text-muted.
USAGE_ERROR_CODES was documented as "exported for the SPA's label/colour maps"
but had no importers -- errors-table.tsx hand-wrote the same 7 keys as
Record<string, string>. Those maps are now keyed by the shared UsageErrorCode,
so a new server-side class is a compile error instead of a silent fall-through
to the raw slug with an undefined colour.
Duplication: one newId() in db/queries/util.ts replaces 16 copies of
crypto.randomUUID().replace(/-/g,'') (spelled newId / newUlid / newRevisionId
depending on the file); one errMessage() in util/errors.ts replaces 37 copies
of the `err instanceof Error` narrowing; errText + safeJson move to
mcp/tool-result.ts from three byte-identical copies.
personLabel moves to lib/person.ts. It was re-exported from the docs-list
ROUTE module, so the editor and an admin page imported a lazy-loaded route
chunk just to format a name.
tsconfig gains noUnusedLocals + noUnusedParameters. Biome's noUnusedVariables
does not report unused imports and nothing else in verify reported unused
anything, which is how this accumulated -- it is now self-policing. Turning it
on surfaced exactly one violation repo-wide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
bodyMessage was defined five times across the admin and skills routes --
four byte-identical, one (invites.tsx) a semantically equivalent short form.
It now lives in lib/explain.ts, which already owns the ApiError-to-copy
mapping it belongs beside.
lib/explain.ts imported ApiError from the './api' BARREL rather than
'./api/core'. Twenty-one files import explain, so every one of them was
pulling the whole REST client graph in through it.
routes/docs-editor/helpers.ts had formatAbsolute, byte-identical to
lib/time.ts absDateTime; the two call sites now use the shared one.
Left alone deliberately: docs-list's formatRelative differs from lib/time's
relativeTime in user-visible copy ("just now" vs "42s ago") and in its
cutoff (7d vs 30d). Merging them would change what the docs table renders --
a product decision, not a cleanup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
The reference deployment's identifiers were baked into source comments and
test fixtures: mcp.yukitools.dev, ctxlayer-yukitools-* queue names,
yuki-ia-mcp.dizzydata-bv.workers.dev, up-yuki-ia-nl, The-Yuki-Company/
yuki-public-api-specs, t_yuki, user@yuki.be. All replaced with the neutral
placeholder this repo already uses elsewhere (acme, and the reserved
.example TLD), preserving each fixture's shape so the tests still assert
what they did before -- the multi-hyphen kebab slug for the tool-name regex,
the mixed case for the CF-Access lowercasing test, a distinct workers.dev
host for the self-loop guard, and the org/repo split for the SAML-nudge
redaction test.
While in wrangler.toml + bootstrap, two real defects:
- ctxlayer-jobs is a producer AND consumer binding (WI-6 async
submit->poll) but was missing from bootstrap's QUEUES list, so a fresh
install never provisioned it.
- patch() silently returned false when its pattern missed, and its two
patterns required a trailing `# <TODO>` marker that wrangler.toml has
never carried -- while PLACEHOLDER_RE looks for an all-zeros id instead.
So bootstrapping from a zeroed id would create the resource, print the
new id, and then drop it: an orphan on the account and a wrangler.toml
still on the placeholder. The marker requirement is gone and patch()
now exits non-zero instead of no-opping.
The wrangler.toml binding comment documented the `<TODO>` convention that
never existed; it now describes the all-zeros one the code implements.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
The ~200 cap was stated in five places (CLAUDE.md, AGENTS.md, CONTRIBUTING.md, and three spots in docs/plan/E-dev-environment.md) and had drifted far enough from reality to stop carrying signal: 75 of 322 source files exceeded it, so "over the cap" no longer distinguished a genuinely overgrown module from an ordinary cohesive one. At ~300 the list is 29 files, and every one of them is a real split candidate rather than noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
The convention was stated as a hard cap ("split early", "hard cap modules"),
which pushes toward splitting cohesive units to satisfy a number -- the
failure mode being a standalone component sliced into fragments that
prop-drill through a boundary that was never there.
Concern separation is the actual rule; the line count only exists to prompt
"is this still one thing?". Recorded alongside it: the number is a weak proxy
for agent read-cost (dense JSX vs type declarations differ several-fold per
line), and in the recent review it predicted none of the findings that
mattered -- doc-room-do.ts at 375 LoC is a genuinely cohesive unit, while the
worst authorization gap sat in a ~180-line file.
E6 now names worked examples from this codebase in both directions so the
judgement call is anchored: keep doc-room-do.ts (one DO lifecycle) and
reindex-consumer.ts (one pipeline); split db/queries/docs.ts (three tables +
ACL predicates), tools-proxy.ts (registry + read-models + call runner), and
docs-editor/index.tsx (six independent hooks).
Also notes that nothing enforces the number in verify, deliberately -- a
line-count lint rule would mechanise exactly the reflex this warns against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
…le docs
An upstream reports failure the MCP-idiomatic way -- { content, isError: true }
-- rather than by throwing, so result text never reached the catch-path
sanitiser in runUpstreamCall. Only defangContent ran, which neutralises the
provenance marker but leaves C0/C1 bytes intact: results were the last path
that could carry raw control characters to the model.
defangContent becomes sanitizeUntrustedContent and applies the full
untrusted-text gate, matching what tool descriptions already get. The
forwarding of upstream error TEXT is unchanged and still deliberate (a test
asserts it) -- CLAUDE.md now records that gap explicitly instead of stating a
rule the code contradicts, and docs/review-2026-07.md carries the open question
of whether to credential-scrub result text too.
Doc corrections, all of which actively misled:
- .claude/commands/seed.md promised "a Notion HTTP upstream, a second
streamable_http upstream, and three demo docs". seed.mjs seeds three teams
and two products, and deliberately seeds no docs at all. CLAUDE.md repeated
the same wrong claim.
- seed.mjs still said "M4 will add upstream_servers fixtures" -- M1-M8 are
retired; the real reason docs aren't seeded (a fake created_by user would
bypass the IdP allowlist) is now the comment.
- db/queries/git-sources.ts listed git_source_visibility, replaced by
git_connection_visibility in migration 0030.
CLAUDE.md's security section also gains the two rules this branch's fixes
established: sanitise on EVERY path (the helpers live in provenance.ts, not the
proxy, so non-proxy consumers can't skip them), and agent-facing upstream reads
are gated twice -- listUpstreamsVisibleToUser then isToolAllowed.
docs/review-2026-07.md is a dated snapshot backlog, not a maintained plan: the
five open security items, the parked refactor, the packages/shared test gap,
and the skill_tags / documents.kind product decisions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmBPQvfZo5ZtDBuwtKqcZK
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.
Output of a full codebase review (architecture/SoC, security, duplication, dead code). 7 commits, 98 files, +696/−549.
bun run verifygreen throughout: typecheck ×3, lint (432 files), 523 unit, 390 integration.1. Security — five gaps closed (
dcde5c4)Each verified by reading the code, not taken on report. Three new tests lock them in.
draft_skillleaked ungranted upstreams. It is open to every signed-in user but resolved slugs with a baregetUpstreamBySlug— exposing tool names, descriptions and input schemas for upstreams an admin never granted, and for tools hidden by the per-tool ACL. Now resolves againstlistUpstreamsVisibleToUserand filters withisToolAllowed, mirroringdescribe_upstream. Slugs are trivially discoverable (list_skillspublishes them), so this was reachable.buildDraftSkillTextinlines upstream tool descriptions into a template that opens with imperative first-party guidance — without the untrusted-text gate. An upstream could smuggle control characters and forge a⟦ctxlayer⟧segment, breaking the unforgeability invariantprovenance.tsdocuments.sanitizeUntrustedTextwas file-local totools-proxy.ts, which is precisely why this path could skip it; it now lives inprovenance.ts.status='active', matchingresolveActiveUseron the REST path./:id/toolshas. Beyond the existence oracle,PUT /:id/credentialswarms the org-wide catalogue — an ungranted caller could replace sharedupstream_toolsrows with what their own token sees.resolveUserPrincipalswas built three different ways (proxy,list_my_context,/api/tools). One home indb/queries/tool-access.tsso the ACL surfaces can't drift.Plus (
8b96821): control-char strip on tool results. An upstream reporting failure as{ content, isError: true }never reaches thecatch-path sanitiser, so results were the last path that could carry raw C0/C1 bytes to the model.2. Dead code + duplication (
7f40733,3895505)Every removal grepped across
apps/,packages/,scripts/first.getIncomingLinks/getOutgoingLinks— test-only, with docstrings claiming production roles ("move-rewrite driver", "for the editor panel") thatgetIncomingLinkDocs/getOutgoingLinkTargetsactually fill. The test now drives the real readers.listGitConnections,DocLinkRow,FolderTreeResponse(leftover from a removed endpoint — there is noGET /api/folders), theASSETSbinding (env.ASSETSnever called),scripts/slug-prefix-migration.sql,.info-stripCSS, 10 never-read custom properties.USAGE_ERROR_CODESwas documented as "exported for the SPA's label/colour maps" but had zero importers —errors-table.tsxhand-wrote the same 7 keys. Now keyed by the sharedUsageErrorCode, so a new server-side class is a compile error instead of a silent fall-through.newId×16 → 1 (spellednewId/newUlid/newRevisionIdby folder),errMessage×37 → 1,errText/safeJson×3 → 1,bodyMessage×5 → 1,formatAbsolute→ existingabsDateTime.personLabelmoved tolib/person.ts— it was re-exported from the docs-list route module, so the editor and an admin page imported a lazy-loaded route chunk just to format a name.noUnusedLocals+noUnusedParameterson. Biome'snoUnusedVariablesdoesn't report unused imports and nothing else inverifyreported unused anything — which is how this accumulated. Exactly one violation repo-wide; it then caught six orphaned imports the cleanup itself created.3. Deployment-specific references removed (
3afd79d)All
yuki/yukitools/dizzydatastrings gone (4 source comments, 10 test files), replaced with this repo's existingacme+ reserved.exampleconvention. Each fixture keeps its shape so the tests still assert what they did — the multi-hyphen kebab slug for the tool-name regex, mixed case for the CF-Access lowercasing test, a distinctworkers.devhost for the self-loop guard.Two real bugs found there:
ctxlayer-jobswas never provisioned. It's a live producer and consumer binding (async submit→poll) but was missing from bootstrap'sQUEUES.patch()silently dropped provisioned ids. It returnedfalseon a miss, and both patterns required a# <TODO>markerwrangler.tomlhas never carried — whilePLACEHOLDER_RElooks for an all-zeros id. Bootstrapping from a zeroed id would create the resource, print the id, then throw it away: an orphan on the account and a config still on the placeholder. Now exits non-zero.4. Convention: one concern per file (
e4cfef5,31e1658)The
~200 LoCcap was stated in five places and had stopped carrying signal — 75 of 322 files exceeded it, holding 54% of all code. That's the norm, not an outlier set.Reframed: one file = one concern is the rule;
~300 LoCis a review trigger, not a limit — a prompt to ask "is this still one thing?". Cohesive units are exempt at any length; splitting a standalone component to hit a number buys prop-drilling across a boundary that isn't there.E6now names worked examples both ways (keepdoc-room-do.ts,reindex-consumer.ts; splitdb/queries/docs.ts,tools-proxy.ts,docs-editor/index.tsx) and records that the number is a weak proxy — in this review it predicted none of the findings that mattered.5. Docs corrected (
8b96821).claude/commands/seed.mdactively misled: it promised "a Notion HTTP upstream, a secondstreamable_httpupstream, and three demo docs".seed.mjsseeds three teams and two products and deliberately seeds no docs.CLAUDE.mdrepeated the claim.seed.mjsstill said "M4 will add…" (M1–M8 retired);git-sources.tslistedgit_source_visibility, replaced in migration 0030.CLAUDE.mdsecurity rules updated so code and doc stop contradicting each other, and gain the two rules this branch established: sanitise on every path, and gate agent-facing upstream reads twice.Known gap, deliberate
Upstream
isErrorresult text is still forwarded to the agent (and replayed bypoll_task). It's control-char stripped and provenance-defanged but not credential-scrubbed. This is tested behaviour and forwarding tool errors is functionally necessary —CLAUDE.mdnow records the gap instead of asserting a rule the code contradicts. Whether to scrub is an open decision, tracked indocs/review-2026-07.md.Not in this PR
docs/review-2026-07.md(a dated snapshot backlog, not a maintained plan) carries what's left: four more open security items (destructive GET without origin check; redirects carryingAuthorization; unboundedres.text()on git fetches; no consent step on/oauth/authorize+ open DCR), the parked structural refactor (~1000 LoC of duplication —makeRevisionQueries,adminCrudRouter, splittingtools-proxy.ts, SPAResourceListPage), thepackages/sharedzero-test gap across ~3000 LoC including theurl-trust.tssecurity boundary, and two product decisions (skill_tagsis a write-never table;documents.kindis dead end-to-end).Worth flagging for review: PKCE enforcement,
redirect_urimatching and auth-code single-use are delegated to@cloudflare/workers-oauth-provider, which isn't vendored — they could not be verified in-repo.