feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(crit): least-privilege thread-scoped endpoints + WS gap closure - #24

Merged
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration
Jun 7, 2026
Merged

feat(crit): least-privilege thread-scoped endpoints + WS gap closure#24
Ecko95 merged 11 commits into
gitsfrom
feat/crit-pr-review-integration

Conversation

@Ecko95

Copy link
Copy Markdown
Owner

Follow-up hardening on top of the merged PR #22 (crit PR-review integration v1). Lands the deferred least-privilege work plus the WS escalation-gap closure it surfaced. Flag-gated OFF (critReviewEnabled, default false); no behaviour change to existing flows.

What changed

  • Dedicated thread-scoped crit endpointsPOST /api/crit/turn + GET /api/crit/turn-status, authorized by session.subject === threadId (not owner role). The wrapper CLI no longer calls the broad owner-gated /api/orchestration/{dispatch,snapshot}; baseline/snapshot race logic moved server-side into a pure compute_turn_status.
  • Dedicated thread-scoped session role — the sidecar token drops from role:"owner"role:"thread-scoped" + subject:threadId (2h TTL + revoke-on-teardown kept).
  • WS escalation gap closedthread-scoped is denied (403) at POST /api/auth/ws-token, the /ws upgrade, and GET /api/attachments/* via one centralized denyThreadScopedRealtime predicate. Owner + ordinary client sessions (paired "Shared Devices") are unaffected and still use /ws.

Verification

  • crit 37/37, auth 37/37, contracts 167/167 green; typecheck clean for touched files.
  • Load-bearing security tests: cross-thread 403, owner-endpoint unreachable, thread-scoped→403 at ws-token/attachments, and a regression proving role:"client" still gets a ws-token.
  • Manual security-reviewer pass: no High/Medium. Two LOW follow-ups tracked (project-favicon/OTLP reachable by any authenticated session; buildTurnStartCommand UUID PlatformError not mapped).

Not in scope (still deferred)

Real crit CLI flags, agent_cmd stdin format, per-platform binary bundling, live E2E — needs a buildable crit (Go unavailable). Design: docs/superpowers/specs/2026-06-07-crit-dedicated-endpoint-design.md.

🤖 Generated with Claude Code

Ecko95and others added 11 commits June 7, 2026 18:30
Least-privilege follow-up: replace the wrapper's owner-gated orchestration
calls with POST /api/crit/turn + GET /api/crit/turn-status authorized by
session subject === threadId, so the sidecar token drops from role:owner to
role:client + subject:threadId. Documents the async-turnId constraint and the
server-side baseline (Option A) correlation design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Add CritTurnRequest, CritTurnResponse, and CritTurnStatusResponse Effect
schemas (plus inferred types and tests) used by the new thread-scoped
crit server endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add narrow, subject-bound crit endpoints the sidecar wrapper uses instead
of the broad owner-gated orchestration routes. Authorization requires
session.subject === threadId (the subject binding is the capability; role
is deliberately not checked). POST /api/crit/turn captures the baseline
priorTurnId, builds a thread.turn.start command server-side, normalizes and
dispatches it. GET /api/crit/turn-status resolves the started turn via the
pure compute_turn_status race logic. Register both layers in makeRoutesLayer.
Tests cover every compute_turn_status branch plus load-bearing security
properties: a subject-bound token reaches only its own thread (200); a token
for thread A is rejected (403) on both endpoints for thread B; missing/invalid
token -> 401; and the client-role token stays refused by the owner-gated
/api/orchestration/dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change the per-sidecar bearer token from role:"owner" to
role:"client" bound to subject:threadId. The token is authorized
only by the dedicated /api/crit/{turn,turn-status} endpoints
(session.subject === threadId), so a leaked token can act on nothing
but its own thread, while owner-gated /api/orchestration/* endpoints
reject the client role and stay unreachable. Keep the 2h TTL and the
revoke-on-teardown scope finalizer unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints
Remove snapshot/baseline/stale-turn logic from the crit agent wrapper and
drive the new thread-scoped server endpoints instead: POST /api/crit/turn to
start the turn (returns the opaque priorTurnId) and poll
GET /api/crit/turn-status until completed/error/terminal or timeout. Server now
owns turn correlation, so the wrapper only formats crit stdin into the
<review_comment> block text and relays status. Update tests to mock the two new
endpoints and cover completed, pending->completed, and timeout paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /api/crit/turn-status: decode the query threadId through the Effect
error channel (Schema.decodeUnknownEffect) instead of the throwing
ThreadId.make(...). A whitespace-only threadId (e.g. ?threadId=%20) trims
to "" and previously threw a schema defect that Effect.catchTags does not
catch, surfacing as a pre-auth 500. Now mapped to a clean 400.
- Correct the least-privilege overclaim: the NOTE in crit-sidecar-manager.ts
and the design doc asserted the sidecar token "can act on nothing but its
own thread". That is false — the WebSocket RPC surface
(POST /api/auth/ws-token + GET /ws) has no role/subject gate and still
reaches orchestrationEngine.dispatch. Reworded both to state the HTTP
orchestration endpoints are scoped while the WS path remains broadly
reachable, flagged as a tracked follow-up.
- Encode the residual WS reachability as an asserted-limitation test and add
400 coverage for whitespace-only / missing threadId in critHttp.test.ts.
The load-bearing cross-thread (403) and owner-endpoint-unreachable tests
remain and pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "thread-scoped" to both SessionRole definitions so the crit sidecar
session (role:"thread-scoped", subject:threadId) can be minted and the web
can decode session state including it. Denied at broad surfaces separately;
this only widens the union. bySessionPriority still orders owner first with
thread-scoped/client as peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chments
Add a centralized denyThreadScopedRealtime helper near the auth service and
apply it at the three broad surfaces so a dedicated crit sidecar role cannot
mint a realtime connection or read arbitrary attachments:
- issueWebSocketToken (POST /api/auth/ws-token) -> 403
- authenticateWebSocketUpgrade (/ws) -> 403 (defense-in-depth for pre-minted tokens)
- attachmentsRouteLayer (GET /api/attachments/*) -> 403
Widen the session role schemas (JWT claims, persistence record/row/create) to
admit the thread-scoped literal so the role can actually be minted. Owner and
ordinary client sessions are unaffected; both still mint ws-tokens and pass the
upgrade, proven by regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the crit sidecar session from role:"client" to the dedicated
least-privilege role:"thread-scoped" now that the broad surfaces deny it.
The token is bound to subject:threadId and can reach ONLY the
/api/crit/{turn,turn-status} endpoints for its own thread; it is denied at
POST /api/auth/ws-token, the /ws upgrade, GET /api/attachments/*, and the
owner-gated /api/orchestration/* endpoints. Keep the 2h TTL and the
revoke-on-teardown finalizer unchanged. Rewrite the NOTE/CAVEAT: the
confinement guarantee now HOLDS and is no longer a residual gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar token is role:"thread-scoped"+subject:threadId. Update
critHttp.test.ts so the sidecar-equivalent tokens (cross-thread 403,
owner-endpoint refusal) are issued as "thread-scoped", and remove the
stale KNOWN-LIMITATION ws-token test that expected a 200 — the
thread-scoped->403-at-ws-token property is now asserted positively by
"POST /api/auth/ws-token rejects a thread-scoped session with 403".
Replace the spec's "Residual gap" WS caveat with an accurate
authorization model: accepted only by /api/crit/turn{,-status}
(subject===threadId), denied at /api/auth/ws-token, /ws and
/api/attachments/*; owner endpoints unreachable; owner/client sessions
unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AuthControlPlane.createPairingLink: narrow role to owner|client (pairing
links are never thread-scoped) so it satisfies BootstrapCredentialRole.
- critHttp.test.ts: brand the mock instanceId via ProviderInstanceId.make, and
type with_app's run/token callbacks concretely (AuthControlPlane | HttpClient)
instead of 'any' in the requirements channel.
These were missed earlier because the per-package ./node_modules/.bin/tsgo path
does not exist and silently exits 0; the real checker is 'bun run typecheck'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@Ecko95