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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,5 +1035,5 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 94/113 (83%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 7/153 (5%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 23/153 (15%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 2/179 (1%) |
49 changes: 35 additions & 14 deletions internal/cache/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ type Authorization struct {
// or narrowing a profile must revoke cached access, and a stale pin keeps
// its name while resolving to deny-all.
ProfileServers []string `json:"profile_servers,omitempty"`
// PolicyFingerprint is the effective profile's Spec 108 tool-policy
// fingerprint (profile.CompiledPolicy.Fingerprint, hex-encoded), set only
// when ProfileScoped and the profile has a compiled policy. Editing a
// profile's policy (tier cap, allow/deny, classify, unannotated handling,
// code execution, management tools, switchable_to) changes this without
// necessarily changing ProfileServers, so a page cached under the old
// policy is refused after the edit even though the server-scope dimension
// is unchanged (FR-027).
PolicyFingerprint string `json:"policy_fingerprint,omitempty"`
// ToolTierGeneration is the Supervisor-wide counter (runtime/supervisor.
// Supervisor.ToolTierGeneration) bumped whenever any server's discovered
// tool set is republished — and therefore whenever any tool's effective
// annotations may have changed, or a tool appeared/disappeared — set only
// when ProfileScoped. A page cached while a tool was classified read and
// later re-listed with a destructive hint is refused by the next
// read_cache once this has moved, with no config edit at all (FR-027).
ToolTierGeneration uint64 `json:"tool_tier_generation,omitempty"`
}

// IsAdministrator reports whether the caller kind is an administrator kind:
Expand Down Expand Up @@ -214,13 +231,15 @@ func permissionBits(perms []string) uint8 {
// the identity the gate requires, and for an agent it makes "digest-equal"
// mean the same credential.
type canonicalAuthorization struct {
CallerKind string `json:"k"`
Principal string `json:"p,omitempty"`
AllowedServers []string `json:"s,omitempty"`
Permissions []string `json:"t,omitempty"`
ProfilePin string `json:"pin,omitempty"`
ProfileScoped bool `json:"ps,omitempty"`
ProfileServers []string `json:"pss,omitempty"`
CallerKind string `json:"k"`
Principal string `json:"p,omitempty"`
AllowedServers []string `json:"s,omitempty"`
Permissions []string `json:"t,omitempty"`
ProfilePin string `json:"pin,omitempty"`
ProfileScoped bool `json:"ps,omitempty"`
ProfileServers []string `json:"pss,omitempty"`
PolicyFingerprint string `json:"pf,omitempty"`
ToolTierGeneration uint64 `json:"ttg,omitempty"`
}

func sortedSet(in []string) []string {
Expand All @@ -238,13 +257,15 @@ func sortedSet(in []string) []string {
// is one 32-byte comparison whatever the snapshot names (research D16).
func (a Authorization) digest() [sha256.Size]byte {
data, err := json.Marshal(canonicalAuthorization{
CallerKind: a.CallerKind,
Principal: a.Principal,
AllowedServers: sortedSet(a.AllowedServers),
Permissions: sortedSet(a.Permissions),
ProfilePin: a.ProfilePin,
ProfileScoped: a.ProfileScoped,
ProfileServers: sortedSet(a.ProfileServers),
CallerKind: a.CallerKind,
Principal: a.Principal,
AllowedServers: sortedSet(a.AllowedServers),
Permissions: sortedSet(a.Permissions),
ProfilePin: a.ProfilePin,
ProfileScoped: a.ProfileScoped,
ProfileServers: sortedSet(a.ProfileServers),
PolicyFingerprint: a.PolicyFingerprint,
ToolTierGeneration: a.ToolTierGeneration,
})
if err != nil {
// Strings, string slices and a bool: json.Marshal cannot fail.
Expand Down
108 changes: 108 additions & 0 deletions internal/index/bleve.go
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,114 @@ func (b *BleveIndex) SearchToolsScoped(queryStr string, limit int, inScope func(
return results, nil
}

// Hit is the canonical registration identity SearchToolsAdmitted hands to its
// predicate (Spec 108 FR-011, data-model.md §2): the exact (server, raw tool)
// pair a hit resolves to, NEVER a stored annotation — the index carries no
// annotation field and gains none (ToolDocument/readToolMetadata are
// unchanged; research.md "Annotation source for SearchToolsAdmitted"). A
// caller that needs the tool's effective annotations resolves them itself,
// through the same identity seam every dispatch path already uses
// (profile.EffectiveAnnotations = resolveExactToolIdentity), keeping
// discovery and execution classifying from one source.
type Hit struct {
Server string
Tool string
}

// Admission is admit's per-hit verdict for SearchToolsAdmitted.
type Admission int

const (
// Admit means the hit is visible to this caller and counts toward limit.
Admit Admission = iota
// RejectScope means the hit's server is outside the caller's effective
// scope (agent-token allowed_servers, profile server set). Never counted
// in hiddenByPolicy — an out-of-scope tool must stay invisible, not merely
// "hidden by profile" (FR-011).
RejectScope
// RejectPolicy means the hit's server was in scope but the tool policy
// (Spec 108 CompiledPolicy.Decide) excluded it. Counted in hiddenByPolicy.
RejectPolicy
)

// SearchToolsAdmitted is SearchToolsScoped's hit-level counterpart (Spec 108
// FR-011, data-model.md §2): the pre-limit predicate sees the hit's canonical
// (server, tool) identity — never a stored annotation, the index carries
// none — and returns one of three verdicts instead of a bool, so the caller
// can tell an out-of-scope rejection (never counted, stays invisible) from a
// policy-only one (counted in hiddenByPolicy, the FR-011 `hidden_by_profile`
// figure) over the SAME exhaustive pre-limit scan SearchToolsScoped already
// runs. limit is applied to ADMITTED hits only — a rejected top hit, whatever
// its reason, never shortens the page — and hiddenByPolicy accumulates over
// the full match set, not merely the hits collected before the cut, so a
// caller whose page filled up before scanning every match still gets an
// accurate count.
//
// Identical to SearchToolsScoped in query construction (incl. the
// underscore-segment enhancement) and score-then-id sort. It differs in ONE
// respect, precisely because of the counting requirement above:
// SearchToolsScoped returns as soon as its page fills, while this method
// keeps paging to the end of the exhaustive match set regardless (zcode
// review round 1 — an early return there silently undercounted
// hiddenByPolicy for any RejectPolicy hit ranked below the cut). A predicate
// that only ever returns Admit/RejectScope (never RejectPolicy) still makes
// this method's RESULT SET equal SearchToolsScoped(query, limit, func(s
// string) bool { admit still sees the server only })'s exactly (T016a) —
// the extra scanning costs work, never a different admitted page.
func (b *BleveIndex) SearchToolsAdmitted(queryStr string, limit int, admit func(Hit) Admission) (results []*config.SearchResult, hiddenByPolicy int, err error) {
if queryStr == "" {
return nil, 0, fmt.Errorf("search query cannot be empty")
}
if admit == nil || limit <= 0 {
return []*config.SearchResult{}, 0, nil
}

q, err := b.augmentedToolSearchQuery(queryStr, limit)
if err != nil {
return nil, 0, err
}
pageSize := limit
if pageSize < scopedSearchMinPage {
pageSize = scopedSearchMinPage
}

b.logger.Debug("Searching tools with admitted query", zap.String("query", queryStr), zap.Int("limit", limit))

results = make([]*config.SearchResult, 0, limit)
for from := 0; ; from += pageSize {
searchResult, err := b.index.Search(newToolSearchRequest(q, from, pageSize))
if err != nil {
return nil, 0, fmt.Errorf("search failed: %w", err)
}
for _, hit := range searchResult.Hits {
tool := readToolMetadata(hit.ID, hit.Fields)
switch admit(Hit{Server: tool.ServerName, Tool: config.RawToolName(tool)}) {
case Admit:
// The page itself stops growing once it holds `limit`
// admitted hits, but the SCAN does not stop here: a
// RejectPolicy hit ranked below the cut must still be
// counted (FR-011 "over the full match set" — codex/zcode
// review round 1). Returning as soon as the page filled
// silently undercounted hiddenByPolicy for every match
// ranked after the limit-th admitted one.
if len(results) < limit {
results = append(results, &config.SearchResult{Tool: tool, Score: hit.Score})
}
case RejectPolicy:
hiddenByPolicy++
case RejectScope:
// Invisible: never counted, never collected.
}
}
if len(searchResult.Hits) == 0 || uint64(from+pageSize) >= searchResult.Total {
break
}
}

b.logger.Debug("Found admitted tools matching query", zap.Int("count", len(results)), zap.Int("hidden_by_policy", hiddenByPolicy), zap.String("query", queryStr))
return results, hiddenByPolicy, nil
}

func fieldsContainExactToolName(fields map[string]interface{}, queryStr string) bool {
for _, field := range []string{"tool_name", "full_tool_name"} {
if value, ok := fields[field].(string); ok && value == queryStr {
Expand Down
15 changes: 15 additions & 0 deletions internal/index/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ func (m *Manager) SearchToolsScoped(query string, limit int, inScope func(server
return m.bleveIndex.SearchToolsScoped(query, limit, inScope)
}

// SearchToolsAdmitted is SearchToolsScoped's hit-level counterpart (Spec 108
// FR-011): the predicate resolves per-hit Admission (Admit/RejectScope/
// RejectPolicy) over the canonical (server, tool) identity. See
// BleveIndex.SearchToolsAdmitted.
func (m *Manager) SearchToolsAdmitted(query string, limit int, admit func(Hit) Admission) ([]*config.SearchResult, int, error) {
m.mu.RLock()
defer m.mu.RUnlock()

if limit <= 0 {
limit = 20 // default limit, as SearchTools
}

return m.bleveIndex.SearchToolsAdmitted(query, limit, admit)
}

// Search searches for tools matching the query (alias for SearchTools)
func (m *Manager) Search(query string, limit int) ([]*config.SearchResult, error) {
return m.SearchTools(query, limit)
Expand Down
Loading
Loading