Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions e2e/web-ui-sweep/web-ui-sweep.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,26 +149,22 @@ test('Activity and Usage report the same 24h numbers as the API', async ({ page,
return { summary: s.data ?? {}, usage: u.data ?? {} }
}

const { summary, usage } = await read()
const total = Number(summary.total_count ?? 0)
const calls = Number(summary.call_count ?? 0)

if (total === 0) {
test.skip(true, 'no activity in the last 24h on this instance')
}

// The API-level halves of the invariant. `usage` is served from a cached
// snapshot while `summary` counts live, so let them converge rather than
// demanding they agree on the first read.
// snapshot while `summary` counts live, so keep the exact summary/usage pair
// from the successful convergence poll. A pre-poll summary can be stale: on
// a fresh instance, the startup activity may be recorded after the first API
// read but before Activity renders, making the UI look one event ahead.
let converged: Awaited<ReturnType<typeof read>> | undefined
await expect
.poll(
async () => {
const now = await read()
return (
const matches =
Number(now.usage.total_calls ?? -1) === Number(now.summary.call_count ?? -2) &&
Number(now.usage.total_errors ?? -1) ===
Number(now.summary.error_count ?? 0) + Number(now.summary.blocked_count ?? 0)
)
if (matches) converged = now
return matches
},
{
timeout: 20_000,
Expand All @@ -179,6 +175,16 @@ test('Activity and Usage report the same 24h numbers as the API', async ({ page,
)
.toBe(true)

// Use the summary that actually passed the API consistency check above, not
// the earlier startup snapshot.
const { summary } = converged!
const total = Number(summary.total_count ?? 0)
const calls = Number(summary.call_count ?? 0)

if (total === 0) {
test.skip(true, 'no activity in the last 24h on this instance')
}

// Activity paints the summary's own fields, and labels the total for what it
// is: "events" when some rows are not calls, "calls" when every row is one.
const errors = watchPageErrors(page)
Expand Down
30 changes: 30 additions & 0 deletions internal/auth/scoped_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package auth

// ScopedView returns the request's existing authorization identity unless it
// is an anonymous MCP caller confined by a named profile. Such callers retain
// the historical admin-shaped AnonymousContext everywhere else; management
// handlers use this non-admin view so their operation gates match a client
// credential without granting secret-reveal or administrator behavior.
func ScopedView(ac *AuthContext, confinedAnonymous bool) *AuthContext {
if !confinedAnonymous {
return ac
}
var view AuthContext
if ac != nil {
view = *ac
}
view.Type = AuthTypeAgent
view.Anonymous = true
view.CredentialKind = CredentialKindAnonymous
view.AllowedServers = []string{"*"}
view.Permissions = []string{PermRead, PermWrite, PermDestructive}
return &view
}

// IsAdminOrAbsent keeps the historical admin-shaped management view for an
// unconfined anonymous MCP request while allowing ScopedView to narrow a
// confined anonymous request first.
func IsAdminOrAbsent(ac *AuthContext) bool { return ac == nil || ac.IsAdmin() }

// IsNonAdmin is the nil-safe counterpart used by handlers after ScopedView.
func IsNonAdmin(ac *AuthContext) bool { return ac != nil && !ac.IsAdmin() }
23 changes: 23 additions & 0 deletions internal/auth/scoped_view_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package auth

import "testing"

func TestScopedView_ConfinedAnonymousBecomesNonAdminForManagement(t *testing.T) {
admin := AnonymousContext()
view := ScopedView(admin, true)
if view == admin || view.IsAdmin() || !view.Anonymous {
t.Fatalf("confined anonymous view must be a non-admin copy: %#v", view)
}
if !view.CanAccessServer("any-server") || !view.HasPermission(PermRead) || !view.HasPermission(PermDestructive) {
t.Fatalf("the view must defer capability limits to the effective profile: %#v", view)
}
if got := ScopedView(admin, false); got != admin {
t.Fatal("unconfined anonymous callers must keep the historical context")
}
if got := ScopedView(nil, false); got != nil {
t.Fatal("unconfined in-process calls keep the nil context")
}
if got := ScopedView(nil, true); got == nil || got.IsAdmin() {
t.Fatal("a nil confined anonymous caller must still receive a non-admin view")
}
}
42 changes: 6 additions & 36 deletions internal/config/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package config
import (
"fmt"
"regexp"
"sync/atomic"
"testing"
"unicode/utf8"
)

Expand Down Expand Up @@ -125,22 +123,11 @@ const (
ProfileUnannotatedAsRead = "as_read"
)

// policyEnforcementReadyBase is the FR-009a rollout gate's compile-time
// value: false from PR 108-a and flips to true in PR 108-d, in the same
// commit that lands the last execution gate — so a release built from any
// commit between 108-a and 108-d rejects every v3 policy field at load time
// and behaves exactly like a pre-108 build for profiles (discovery can never
// hide a tool that execution would still run). It is an unexported const —
// never a package variable — so no import can open the gate by assigning to
// it; the only way to open it outside 108-d is EnablePolicyForTest below.
const policyEnforcementReadyBase = false

// policyEnforcementTestOverride is the FR-009a test-only override's flag
// (zcode review: "the override cannot ship" — it must be unreachable from
// any non-test call site). It is flipped only by EnablePolicyForTest, whose
// own testing.Testing() guard is what keeps it out of production, not the
// atomic type.
var policyEnforcementTestOverride atomic.Bool
// policyEnforcementReadyBase is the FR-009a rollout gate. Spec 108-d wires
// enforcement into every MCP and REST execution/discovery path, so this
// compile-time constant now admits Profiles v3 policy fields and anonymous
// confinement in every build.
const policyEnforcementReadyBase = true

// PolicyEnforcementReady reports whether the FR-009a rollout gate is open:
// no build may admit a v3 policy field it cannot yet enforce on every
Expand All @@ -154,24 +141,7 @@ var policyEnforcementTestOverride atomic.Bool
// a reference the other way round would cycle. internal/profile and every
// other consumer calls config.PolicyEnforcementReady() directly.
func PolicyEnforcementReady() bool {
return policyEnforcementReadyBase || (policyEnforcementTestOverride.Load() && testing.Testing())
}

// EnablePolicyForTest opens the FR-009a gate for the duration of the
// caller's test only (the 108-a/108-b "test-only override" the spec and
// tasks.md call for). It panics when called outside a test binary
// (testing.Testing() false) — no env var, config field, flag or build tag
// can open the gate — and registers a tb.Cleanup that closes it again when
// the test ends, so callers need no defer/restore bookkeeping of their own.
// It takes testing.TB (not *testing.T) so any package's tests — not only
// internal/config's — can use it.
func EnablePolicyForTest(tb testing.TB) {
if !testing.Testing() {
panic("config: EnablePolicyForTest called outside a test binary")
}
tb.Helper()
policyEnforcementTestOverride.Store(true)
tb.Cleanup(func() { policyEnforcementTestOverride.Store(false) })
return policyEnforcementReadyBase
}

// profileSlugPattern is the allowed profile-name form (FR-007): lowercase
Expand Down
Loading
Loading