Repository navigation
fix: review follow-ups for 0.6.5 (#168) - #173
Merged
Merged
Conversation
- agent-runtime: missing_scope denials carry `missing`; `stepUpScopes` answers the scopes a token should add, with the handler's rules (own variants, proposeScopes, the member's role on the call's site). - api: the MCP step-up 403 asks for exactly that set and is audited; createApi refuses an mcp.oauth.resource that is not /mcp; the playground guard enforces its OAuth well-known rows. - storage/api: Cloudhal's own counters move to `rate_limits` (migration 0030), out of reach of Better Auth's 60 s rateLimit prune that reset the hourly reset-mail cap. - core-ts, analytics, astro-renderer, Studio: linear trims instead of /^-+|-+$/g (`trimChar`); datasetName was quadratic on `_` runs. - cli: the slugify test no longer claims a timing difference; only describeSetup keeps the timing assertion. - ci/docs: CodeQL cancels only pull-request runs; ADR-0012 records the undici override from #159; RFC-0044 phase 1 wording, CORS note, step-up rules, phase 2 migration renumbered 0031_oauth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- rate_limits: expires_at with an index; one upsert RETURNING count per hit; about one hit in 32 prunes up to 500 expired counters. Migration 0030 edited in place (unreleased). Storage conformance proves get returns RETURNING rows on better-sqlite3, D1 and do-sqlite. - stepUpScopes: asks only for scopes both the site role and the installation role give (consent caps by the installation role), and never when the call would still be denied after consent (plan mode, an uncovering skill). - Linear trims for surface-interop ids and for input-reachable trailing trims: request paths (playground guard, route matching, analytics, renderer links, docs Worker), directive sources, comment lines; new trimEndChar in @cloudhal/core-ts/names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
This was referenced Nov 8, 2026
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.
Fixes every item of #168, the three added in its comments included. It is one PR. The OAuth/API part stayed small enough that splitting it off was not worth it.
Items
1. Step-up scopes come from the policy engine (
packages/agent-runtime/src/step-up.ts,policy.ts,packages/api/src/mcp-oauth.ts)A
missing_scopedenial now carriesmissing, the required scopes (proposeScopesfor a propose call) that neither the caller's scopes nor an own variant satisfy. The newstepUpScopes(policy, principal, tool, args)works on the principal as the handler sees it (forSite/forInstallation, RFC-0034 A2). It maps each missing scope to the strongest scope, or own variant, that the member's role on the call's site gives a token. Before answering, it re-evaluates with those scopes added, so a challenge never sends the client back for nothing.stepUpChallengeasks for exactly that set.Verified:
packages/agent-runtime/test/step-up.test.ts(8 tests after round 2) covers these cases:content:write:own/site:publish:own.content:draft:own, and nothing for publish.edit_body→content:read content:propose.The shared HTTP suite (
packages/api/src/testing-mcp-oauth.ts, sqlite + D1) runs the same cases through/mcpwith real tools (create_entry,publish,edit_body). The Author, Contributor, propose and site-role cases fail on the old code.2. The step-up 403 is audited (
mcp-mount.ts)The 403 records a
denyrow indeps.mcp.audit, the log the handler's own denials go to. The row has the actor, site, tool, mode, outcomeerror, and reasoninsufficient_scope: this connection needs …. It has noinput: this layer does not have the tool'sauditArgsredaction, so it stores no arguments rather than raw ones.Verified: new suite case "records the step-up 403 in the audit log" (sqlite + D1).
3. Playground well-known rows are now enforced (
playground.ts)I enforced the rows instead of removing them. RFC-0036 §5 says every route
createApiregisters must be blocked or allowed, so that a new route is never silently open in a playground. RFC-0044 decision 10 says a playground never speaks OAuth discovery. The two protected-resource documents arecreateApiroutes (mounted withmcp.oauth), so the guard should refuse them, as the rows always claimed.isBlockedInPlaygroundnow judges/.well-known/oauth-protected-resource[/…]like/api/*. Other/.well-known/*paths still belong to the site, not the API.Verified:
test/playground.test.tshas table cases (GET, HEAD, an unclassified sub-path, and/.well-known/security.txtleft alone). It also has a newcreateApi({ playground, mcp: { oauth } })case that gets403 {error:"playground", block:"mcp"}for both documents and served them before this change.4. RFC-0044 text and the resource assertion
mcp.oauthuntil phase 2, and that existing deployments get the challenge once a runtime sets it./mcprefusing cross-origin requests.proposeScopes, site role) and says the 403 is audited.createApinow throws unless the path ofmcp.oauth.resourceends in/mcp, with no query (assertMcpOauth). A proxy prefix such as/studio/mcpis allowed.0031_oauth, because item 8 takes 0030.Verified: new suite case "refuses at createApi time an oauth.resource that is not the /mcp endpoint" (sqlite + D1).
5. CodeQL concurrency (
.github/workflows/codeql.yml)Pull requests still cancel their own running analysis. A push to main and the weekly run each get a group of their own (
github.run_id) withcancel-in-progress: false. A shared group would also cancel a pending run when another queues, so changing only the flag would not have been enough.Verified: by reading only. This PR's own CodeQL run exercises the
pull_requestbranch of the expression.6. ADR-0012
The 2026-10-25 addendum's undici bullet now says the override shipped in #159 (
"undici@<7.30.1": 7.30.2inpnpm-workspace.yaml).Verified: matches
pnpm-workspace.yaml.7. #161's Badge fix
Noted as "Also in 0.6.3 …" in
.changeset/linear-trims-168.md(@cloudhal/cli, which bundles Studio). Old changelogs are untouched.8. Rate-limit counters out of Better Auth's table
StaffStore.hit(the hourly per-address reset-mail cap, the public token buckets, each agent key's window) writes the newrate_limitstable. It is one atomic upsert, no transaction.packages/storage/migrations/0030_rate_limits.tsis plain SQL, applied bymigrate()as one atomicbatch(). It also deletes the oldcloudhal:rows fromrateLimit.rateLimitnow holds only Better Auth's rows. The invite cap counts invite rows, so it was never affected.Verified: new shared suite
packages/api/src/testing-rate-limits.ts(test/rate-limits.test.ts, sqlite + D1). It runs the real API and Better Auth with database rate limits and a fakedDate:With the old
hit()the suite fails with 6 mails instead of 3.packages/storage/test/migrate.test.ts(better-sqlite3, D1) anddo-sqlite.test.ts(which applies every migration) run 0030.9. ReDoS trims
trimChar(text, ch)in@cloudhal/core-ts/namesis a linear trim./^-+|-+$/g(and the trailing/-+$/after a cut) innames.ts(slugifyandfoldName, which the grep also found) andmedia/types.ts(slugifyFilename).lib/field-form.ts,lib/media.ts,lib/workflow.ts) and astro-renderer (markdown.tsheadingSlug). Those now usetrimChartoo.datasetNameused/^_+|_+$/gand gets an inline loop, since analytics does not depend on core-ts.All the dash sites collapse runs to one
-first, so their regexes were linear in practice (measured: 0.06 ms on 100k).datasetNamewas really quadratic: its replace keeps existing_runs, and the old trim took about 4 s on a 100k run.Verified:
names.test.tsandmedia.test.ts: edge cases, a cut that lands on a dash, 100k dash runs, and an equivalence check against the old regexslugify.kit.test.ts.wae-reader.test.ts: a 200k_run under 1.5 s, which the old code fails at about 16 s.10. CLI slugify test
No input tells the old two-pass regex
slugifyfrom the loop by time: its first replace collapsed every run before the trim. So I dropped the timing claim forslugify. Its cases are now a behaviour test, with a comment saying why.${"a".repeat(63)} bdoes tell them apart on correctness: the old code returned a trailing-. The comment says that case is the regression guard. The timing assertion stays only ondescribeSetup, the one case that tells old from new, in its own test.Verified:
packages/cli/test/studio-selfhost.test.ts(both tests).Checks run locally (serially, per the iMac constraint)
pnpm lint(0 errors),pnpm size:check,pnpm design:lint,turbo run typecheck(20/20),mcp-tools-doc --check,demo-archive --checkturbo run test --concurrency=1 --continuefor agent-runtime, storage, core-ts, analytics, astro-renderer, api, mcp-server: all 7 green (storage 6 files, agent-runtime 10, analytics 12, core-ts 60, astro-renderer 49, mcp-server 24, api 39). On the first pass core-ts's D1 store suite failed withEADDRNOTAVAILconnecting to Miniflare (macOS ephemeral ports used up, about 10k sockets in TIME_WAIT); rerun alone, it passed 437/437test/studio-selfhost.test.ts28/28. Also Studiomedia-lib,fields-studio,workflow,design-rules(600 tests) and cloudflareplayground*.test.ts(14 tests)Changesets:
mcp-step-up-168.md(agent-runtime, api),rate-limits-168.md(storage, api),linear-trims-168.md(core-ts, analytics, astro-renderer, cli), all patch (fixed group).Review round 2 (second commit)
Note for local databases: a database that applied the
b0ebd954version of0030_rate_limits(noexpires_at) must be recreated. The migration is unreleased, so it was edited in place, and_migrationswill not re-run it.R1.
rate_limitsis pruned (packages/api/src/rate-limits.ts,0030_rate_limits.tsedited in place since it is unreleased)expires_at(epoch ms), indexed asrate_limits_expires. Windows differ per counter: the agent-key window is configurable (agentKeyRateLimit), so a fixed 1 h cutoff would be wrong.randomPrune) runsDELETE … WHERE rowid IN (SELECT rowid … WHERE expires_at < ? LIMIT 500)through the index.createAuth, and so aStaffStore, per request.Deviation from the brief: the prune is its own statement, not part of the write's
batch().SqlDriver.batch()returnsRunResult[]with no rows, so a batch cannot carry theRETURNINGcount that R2 asks for.Verified: new suite case "prunes expired counters, which nothing else deletes" (sqlite + D1), using a pinned prune decision:
PRUNE_LIMITrows;R2. Atomic count
hit()is now one statement,INSERT … ON CONFLICT … DO UPDATE … RETURNING count, read withget. In SQLite every column reference inSETsees the pre-update row, so the threeCASEs agree on whether the window has ended.Verified: a new storage conformance case, "get returns the row of a write's RETURNING clause", runs on better-sqlite3, D1 (Miniflare) and do-sqlite.
R3. No challenge when the call would still be denied
stepUpScopessimulates the principal after consent: token scopes plus the asked ones, narrowed by the installation role, thenforSite/forInstallationagain. It returnsundefinedwhen that evaluation is anydeny: scope, mode, skill or anything else.Verified:
step-up.test.tscovers:scopesForMode("plan")callingupdate_entrygets no challenge;content:writegets no challenge, and one that covers it does.The HTTP suite has a plan-mode case too.
R4. Site role capped by the installation role
The grantable set is
narrow(roleScopes(siteRole), roleScopes(installationRole)). That matches how tokens are capped: key creation andprincipal.tsnarrow by the installation role, andforSitethen narrows by the site role.Verified:
content:draft:own, notcontent:write.editorOnandconsented).R5. More ReDoS sites
surface-interop.tsIds.getused an unanchored/-+$/after a replace that keeps-runs. The old code took about 16 s on 200k. It now usestrimChar, tested with a 200k-dash json-render key throughfromJsonRender.X+$,X*$,\s+$,\/+$,[^\S\n]+$,=+$,.*$) inpackages/*/src,apps/*/src,startersandscripts, and fixed the ones whose input can come from outside:api/src/playground.tsisBlockedInPlayground/trimEndChar(new,@cloudhal/core-ts/names)api/src/scopes.tsmatchesRoutetrimEndCharcore-ts/src/directives.ts(parseDirective, directive source)String#trimEnd()(same character set as\s)comments/src/normalize.tsline.trimEnd()astro-renderer/src/locales.ts,feeds.ts/runstrimEndCharanalytics/src/paths.tsapps/docs/src/worker/paths.ts/runsChecked and left alone:
=+$on base64 output (tokens.ts,uploads.ts,entry-list.ts,deploy/state.ts, comments and playground crypto,assets.ts). At most two=, all at the end.[?#].*$,[/?#].*$,in JSON at position.*$. These match at the first occurrence and then run to the end, which is linear.^-anchored validators such as^Bearer\s+(\S+)\s*$, the email patterns and^\/[!-~]*$.redirects.ts,agents.tsandseo.ts(seo.tsworks on a cut of at most about 160 characters). They act only on the editor's own input in their own browser.Verified: timing cases with a 1.5 s bound and 200k-character inputs:
isBlockedInPlayground,trimEndChar,parseDirective,normalizeText,fromJsonRender;test/route-scopes.test.ts, astro-renderer's locale, feed and redirect tests, and the docs Worker test.The size baseline for
surface-interop.tsdropped by 2 lines (size-check --update).Checks for this round: - Single files first:
step-up,mcp-oauth,rate-limits,playground,route-scopes,names,directives,interop,media,text, and the docsworkertest.turbo run test --concurrency=1for storage (6 files), analytics (12 + 1 skipped), agent-runtime (10), comments (4), astro-renderer (49) and api (39): all green.EADDRNOTAVAIL, macOS running out of local ports after the long serial run. It passed after TIME_WAIT sockets drained.pnpm lint(0 errors),pnpm size:check,pnpm design:lintandturbo run typecheck(20/20) are green.🤖 Generated with Claude Code