Skip to content

fix: review follow-ups for 0.6.5 (#168) - #173

Merged
Madraka merged 2 commits into
mainfrom
claude/suspicious-satoshi-971430
Nov 8, 2026
Merged

Madraka merged 2 commits into
mainfrom
claude/suspicious-satoshi-971430

Conversation

@Madraka

@Madraka Madraka commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

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_scope denial now carries missing, the required scopes (proposeScopes for a propose call) that neither the caller's scopes nor an own variant satisfy. The new stepUpScopes(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. stepUpChallenge asks for exactly that set.
Verified: packages/agent-runtime/test/step-up.test.ts (8 tests after round 2) covers these cases:

  • Author → content:write:own / site:publish:own.
  • Contributor → content:draft:own, and nothing for publish.
  • A propose edit_body → content:read content:propose.
  • An installation Editor who is a Contributor on one site and an Editor on another gets different answers per site.
  • An unknown site and a person-only scope get no challenge. Neither does a call with all its scopes that plan mode refuses (round 2 adds the plan-mode-token and skill cases).

The shared HTTP suite (packages/api/src/testing-mcp-oauth.ts, sqlite + D1) runs the same cases through /mcp with 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 deny row in deps.mcp.audit, the log the handler's own denials go to. The row has the actor, site, tool, mode, outcome error, and reason insufficient_scope: this connection needs …. It has no input: this layer does not have the tool's auditArgs redaction, 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 createApi registers 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 are createApi routes (mounted with mcp.oauth), so the guard should refuse them, as the rows always claimed. isBlockedInPlayground now judges /.well-known/oauth-protected-resource[/…] like /api/*. Other /.well-known/* paths still belong to the site, not the API.
Verified: test/playground.test.ts has table cases (GET, HEAD, an unclassified sub-path, and /.well-known/security.txt left alone). It also has a new createApi({ playground, mcp: { oauth } }) case that gets 403 {error:"playground", block:"mcp"} for both documents and served them before this change.

4. RFC-0044 text and the resource assertion

  • The Compatibility section now says no runtime sets mcp.oauth until phase 2, and that existing deployments get the challenge once a runtime sets it.
  • §1 records that the protected-resource documents are served without CORS, consistent with /mcp refusing cross-origin requests.
  • §1 describes the step-up as the policy engine's answer (own variants, proposeScopes, site role) and says the 403 is audited.
  • createApi now throws unless the path of mcp.oauth.resource ends in /mcp, with no query (assertMcpOauth). A proxy prefix such as /studio/mcp is allowed.
  • Phase 2's migration is renumbered 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) with cancel-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_request branch 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.2 in pnpm-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 new rate_limits table. It is one atomic upsert, no transaction.
  • Migration packages/storage/migrations/0030_rate_limits.ts is plain SQL, applied by migrate() as one atomic batch(). It also deletes the old cloudhal: rows from rateLimit.
  • Better Auth's rateLimit now 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 faked Date:

  1. A first burst of 4 reset requests sends 3 mails.
  2. 61 s later, a sign-in from the same address triggers Better Auth's prune. A sentinel row proves the prune ran.
  3. A second burst of 3 still sends nothing.
  4. An hour later the address is mailed again.

With the old hit() the suite fails with 6 mails instead of 3. packages/storage/test/migrate.test.ts (better-sqlite3, D1) and do-sqlite.test.ts (which applies every migration) run 0030.

9. ReDoS trims

  • New trimChar(text, ch) in @cloudhal/core-ts/names is a linear trim.
  • It replaces /^-+|-+$/g (and the trailing /-+$/ after a cut) in names.ts (slugify and foldName, which the grep also found) and media/types.ts (slugifyFilename).
  • The grep found the same pattern in Studio (lib/field-form.ts, lib/media.ts, lib/workflow.ts) and astro-renderer (markdown.ts headingSlug). Those now use trimChar too.
  • Analytics' datasetName used /^_+|_+$/g and 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). datasetName was really quadratic: its replace keeps existing _ runs, and the old trim took about 4 s on a 100k run.
Verified:

  • core-ts names.test.ts and media.test.ts: edge cases, a cut that lands on a dash, 100k dash runs, and an equivalence check against the old regex slugify.
  • astro-renderer kit.test.ts.
  • analytics 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 slugify from the loop by time: its first replace collapsed every run before the trim. So I dropped the timing claim for slugify. Its cases are now a behaviour test, with a comment saying why. ${"a".repeat(63)} b does 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 on describeSetup, 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 --check
  • turbo run test --concurrency=1 --continue for 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 with EADDRNOTAVAIL connecting to Miniflare (macOS ephemeral ports used up, about 10k sockets in TIME_WAIT); rerun alone, it passed 437/437
  • cli: test/studio-selfhost.test.ts 28/28. Also Studio media-lib, fields-studio, workflow, design-rules (600 tests) and cloudflare playground*.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 b0ebd954 version of 0030_rate_limits (no expires_at) must be recreated. The migration is unreleased, so it was edited in place, and _migrations will not re-run it.

R1. rate_limits is pruned (packages/api/src/rate-limits.ts, 0030_rate_limits.ts edited in place since it is unreleased)

  • Each row now carries expires_at (epoch ms), indexed as rate_limits_expires. Windows differ per counter: the agent-key window is configurable (agentKeyRateLimit), so a fixed 1 h cutoff would be wrong.
  • About one hit in 32 (randomPrune) runs DELETE … WHERE rowid IN (SELECT rowid … WHERE expires_at < ? LIMIT 500) through the index.
  • It is random, not "once per instance per minute", because the admin Worker builds createAuth, and so a StaffStore, per request.
  • Writes are bounded: one prune can delete 500 rows, which is far more than the one row a hit can add.

Deviation from the brief: the prune is its own statement, not part of the write's batch().

  • SqlDriver.batch() returns RunResult[] with no rows, so a batch cannot carry the RETURNING count that R2 asks for.
  • Ordering does not matter. The prune only deletes rows whose window has ended, and the next hit on such a key resets it to 1 anyway.

Verified: new suite case "prunes expired counters, which nothing else deletes" (sqlite + D1), using a pinned prune decision:

  • a stale row survives a non-pruning hit and is removed by a pruning one;
  • a live hourly counter keeps its count;
  • a pruned key restarts at 1;
  • one prune deletes at most PRUNE_LIMIT rows;
  • the index exists.

R2. Atomic count
hit() is now one statement, INSERT … ON CONFLICT … DO UPDATE … RETURNING count, read with get. In SQLite every column reference in SET sees the pre-update row, so the three CASEs 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
stepUpScopes simulates the principal after consent: token scopes plus the asked ones, narrowed by the installation role, then forSite/forInstallation again. It returns undefined when that evaluation is any deny: scope, mode, skill or anything else.
Verified: step-up.test.ts covers:

  • a grant with only scopesForMode("plan") calling update_entry gets no challenge;
  • the same grant's propose call is still challenged;
  • a declared skill that does not cover content:write gets 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 and principal.ts narrow by the installation role, and forSite then narrows by the site role.
Verified:

  • An installation Contributor who is an Editor on a site is now asked for content:draft:own, not content:write.
  • After simulated consent the call is allowed with a draft limit, and nothing is asked again: the loop scenario, in both the unit test and the HTTP suite (editorOn and consented).

R5. More ReDoS sites

  • surface-interop.ts Ids.get used an unanchored /-+$/ after a replace that keeps - runs. The old code took about 16 s on 200k. It now uses trimChar, tested with a 200k-dash json-render key through fromJsonRender.
  • I also grepped every unanchored trailing-run trim (X+$, X*$, \s+$, \/+$, [^\S\n]+$, =+$, .*$) in packages/*/src, apps/*/src, starters and scripts, and fixed the ones whose input can come from outside:
Site Input Before Fix
api/src/playground.ts isBlockedInPlayground request path, every playground request ~150 ms per 16k / trimEndChar (new, @cloudhal/core-ts/names)
api/src/scopes.ts matchesRoute request path same trimEndChar
core-ts/src/directives.ts (parseDirective, directive source) agent/MCP markdown ~6.5 s per 100k spaces String#trimEnd() (same character set as \s)
comments/src/normalize.ts reader-submitted comment lines quadratic per line line.trimEnd()
astro-renderer/src/locales.ts, feeds.ts hrefs in rendered HTML, redirect targets quadratic on / runs trimEndChar
analytics/src/paths.ts beacon path (≤ 1024) bounded, fixed anyway inline loop
apps/docs/src/worker/paths.ts docs Worker request path quadratic on / runs inline loop

Checked 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 ^\/[!-~]*$.
  • Studio's redirects.ts, agents.ts and seo.ts (seo.ts works on a cut of at most about 160 characters). They act only on the editor's own input in their own browser.
  • CLI config readers working on local files.

Verified: timing cases with a 1.5 s bound and 200k-character inputs:

  • isBlockedInPlayground, trimEndChar, parseDirective, normalizeText, fromJsonRender;
  • plus test/route-scopes.test.ts, astro-renderer's locale, feed and redirect tests, and the docs Worker test.

The size baseline for surface-interop.ts dropped 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 docs worker test.

  • Then turbo run test --concurrency=1 for storage (6 files), analytics (12 + 1 skipped), agent-runtime (10), comments (4), astro-renderer (49) and api (39): all green.
  • core-ts was run on its own: 60 files, 1090 tests. The first attempt failed only with 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:lint and turbo run typecheck (20/20) are green.

🤖 Generated with Claude Code

Madraka and others added 2 commits November 8, 2026 20:21
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant