fix: scope monitor runner and workflow targets to the caller's fleet
GET /api/monitors and GET /api/monitors/:id returned Monitor.Runner unfiltered; for an agent-pushed monitor that field is literally a server ID, so a restricted token learned which out-of-scope server a monitor runs on directly, not merely that one exists. services.RedactMonitorRunner replaces Runner with models.RunnerRestricted when it names a server outside the caller's scope, resolved once via the new services.VisibleServerIDs rather than per monitor. The monitor itself is still returned — a restricted operator may legitimately need to see that it exists and is up or down — only the runner field goes neutral; omitting the monitor entirely was considered and rejected as more surprising than one field changing. Runner "server" (control-plane-run) is never touched. The MCP list_monitors/get_monitor_status projections never had a Runner field to begin with, so REST and the tool surface already agreed; a comment now records why. GET /api/workflows and GET /api/workflows/:id returned Workflow.TargetServerIDs unfiltered — directly naming out-of-scope servers, worse than a count. services.FilterVisibleServerIDs narrows the list to what VisibleServerIDs admits and reports hidden (no count) when at least one target was dropped; WorkflowResponse wraps *models.Workflow with a scoped TargetServerIDs and a TargetsRestricted flag. TargetTags is left untouched — the tag vocabulary is already ruled acceptable to expose. The MCP list_workflows/get_workflow tools get the identical treatment: list_workflows' target count is now based on the filtered ID list, and get_workflow's workflowDetail carries the same TargetsRestricted flag, so a model that sees a filtered target list and then has run_workflow refuse the same workflow for out-of-scope targets is not left concluding the refusal invented a problem the list never mentioned. All four routes recorded in serverScopedRoutes as true; none is boot-enforced, for the same substring-filter reason as the key routes added in the previous round.
This commit is contained in:
@@ -12,6 +12,14 @@ import (
|
||||
// monitorSummary carries state and identity. A model asking "what is broken"
|
||||
// needs the state and the name; the target URL, expected status, keyword,
|
||||
// runner and channel list are configuration it did not ask for.
|
||||
//
|
||||
// Runner in particular is not merely omitted as noise: for an agent-pushed
|
||||
// monitor it is literally a server ID, and REST's listMonitors/getMonitor
|
||||
// redact it to models.RunnerRestricted when that server is outside the
|
||||
// caller's scope. This projection never had a runner field to redact — the
|
||||
// same outcome, reached by never including it rather than by filtering it
|
||||
// out, so this tool and get_monitor_status cannot disagree with the REST
|
||||
// surface about what a restricted token learns.
|
||||
type monitorSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -37,6 +37,12 @@ type workflowDetail struct {
|
||||
Targets []string `json:"target_server_ids,omitempty"`
|
||||
Tags map[string]string `json:"target_tags,omitempty"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
// TargetsRestricted is set, with no count, when Targets omits at least
|
||||
// one server ID outside this token's scope — mirroring
|
||||
// WorkflowResponse's REST field, so a model reading this alongside a
|
||||
// run_workflow refusal for the same workflow is not left to conclude the
|
||||
// refusal invented a problem this tool never mentioned.
|
||||
TargetsRestricted bool `json:"targets_restricted,omitempty"`
|
||||
}
|
||||
|
||||
// ---- runs ----
|
||||
@@ -159,13 +165,24 @@ func init() {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list workflows: %w", err)
|
||||
}
|
||||
|
||||
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
|
||||
}
|
||||
|
||||
limit := pageLimit(args)
|
||||
out := make([]workflowSummary, 0, limit)
|
||||
for _, w := range workflows {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
targets := len(w.TargetServerIDs)
|
||||
// The tag count is exposed as-is (the tag vocabulary is not
|
||||
// restricted); the ID count is narrowed to what this token
|
||||
// can see so it cannot itself disclose that out-of-scope
|
||||
// targets exist, the same leak the REST list closes.
|
||||
ids, _ := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
|
||||
targets := len(ids)
|
||||
if len(w.TargetTags) > 0 {
|
||||
targets = len(w.TargetTags)
|
||||
}
|
||||
@@ -207,13 +224,21 @@ func init() {
|
||||
if w.Schedule != nil && w.Schedule.Enabled {
|
||||
schedule = w.Schedule.Cron
|
||||
}
|
||||
|
||||
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
|
||||
}
|
||||
targets, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
|
||||
|
||||
return workflowDetail{
|
||||
ID: w.WorkflowID,
|
||||
Name: w.Name,
|
||||
Steps: steps,
|
||||
Targets: w.TargetServerIDs,
|
||||
Tags: w.TargetTags,
|
||||
Schedule: schedule,
|
||||
ID: w.WorkflowID,
|
||||
Name: w.Name,
|
||||
Steps: steps,
|
||||
Targets: targets,
|
||||
Tags: w.TargetTags,
|
||||
Schedule: schedule,
|
||||
TargetsRestricted: hidden,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user