Summary
Allow a scout's PostHog API scope posture to be tuned per scout via its SignalScoutConfig, by granting additional user-facing write scopes on top of the fleet-wide baseline. Example use case: a custom scout whose job is to maintain a dashboard needs dashboard:write + insight:write; today no scout can hold those.
This is the "Option A / additive writes" design: reads stay fleet-global (all public read scopes, as today), internal harness scopes stay untouchable, and only a code-reviewed allowlist of write scopes becomes grantable per scout.
Current state
The scope posture is hardcoded per channel, not per scout:
scout_harness/runner.py passes posthog_mcp_scopes="signals_scout" (or "signals_scout_reports" when the skill opted into emit_report/edit_report via allowed_tools) into CustomPromptSandboxContext.
posthog/temporal/oauth.py:resolve_scopes() expands that preset into the token's scope list: all public MCP read scopes + internal scopes (task:write, internal_run:read, signal_scout_internal:write, optionally signal_scout_report:write) + SCOUT_USER_WRITE_SCOPES, which is deliberately tiny and fleet-global (today just notebook:write).
- The MCP server filters its tool catalog by token scope, so scope = tool availability; no MCP-side changes are needed for new scopes to expose their tools.
Useful existing plumbing:
PosthogMcpScopes is already preset | list[str] and survives the whole dispatch path (Task.create_and_run → pending_dispatch JSON → Temporal → token mint). Caveat: the list[str] branch of resolve_scopes() does not add the scout-internal/report scopes — those ride only the preset branch — so the harness must compose them explicitly (or the type gets extended, see below).
SignalScoutConfig.network_access is the direct precedent for a per-scout security knob, including stamping the effective value into the run row's metadata at run creation so later config edits don't falsify history.
Proposed design
Config surface
New field on SignalScoutConfig (migration + serializer):
additional_write_scopes: list[str] # default []
Validated against a new code-level allowlist in posthog/temporal/oauth.py:
SCOUT_GRANTABLE_WRITE_SCOPES: frozenset[str] = frozenset({
"dashboard:write",
"insight:write",
"notebook:write",
"annotation:write",
# deliberate exclusions below
})
Exposed through the existing config endpoints/MCP tools (scout-config-update, scout-config-create, and the nested config object on scout-create-prepare), then hogli build:openapi.
Scope allowlist policy (the real decision)
The token runs unattended in a sandbox that reads untrusted data (event properties, ticket text, survey responses — prompt-injection surfaces), so every granted write scope is blast radius for an injected run. Starting policy:
- Grantable: recoverable, artifact-shaped writes —
dashboard:write, insight:write, notebook:write, annotation:write. (cohort:write is a candidate; discuss.)
- Categorically excluded:
hog_function:write / batch-export scopes — a webhook destination is arbitrary egress (exfiltration channel);
feature_flag:write, experiment:write, survey:write — production-behavior-changing;
- anything org/user/member/role-shaped.
Note scopes are object-level, not tool-level: dashboard:write includes update and soft-delete of existing dashboards, not just ones the scout created. Same accepted-risk framing as the existing notebook:write note in oauth.py — recoverable soft-deletes, single-team token, monitored — and it belongs in the field's help text.
Token composition
Extend the scout posture so extras can ride along while internal scopes stay preset-owned. Cleanest shape: a small structured value that stays JSON-round-trippable through pending_dispatch and Temporal payloads, e.g.
{"preset": "signals_scout_reports", "extra_write_scopes": ["dashboard:write", "insight:write"]}
handled in resolve_scopes() / has_write_scopes() next to the existing presets, with extras intersected against SCOUT_GRANTABLE_WRITE_SCOPES at mint time (defense in depth — config validation is the first gate, the mint is the second).
The runner reads the config field and composes the posture (~10 lines, following the network_access pattern).
Authorization on the grant path
scout-config-update requires only signal_scout:write today. Widening a scout's token is more than steering — the token is minted under an acting user who may be more privileged than the config editor — so writes that set/change additional_write_scopes should demand the elevated bar the scout-notes path already uses (llm_skill:write key scope + the llm_skill RBAC editor check). Edits that don't touch the field keep the current requirement.
Auditability
- Stamp the effective extra scopes into
SignalScoutRun.metadata at run creation (config-edit-proof), and carry them on the signals_scout_run_started/_finished lifecycle events via _attach_run_shape_props.
- Config activity logging should capture grants/revocations like any other config change.
Prompt (optional, recommended)
Render a short section in the run prompt when extras are present ("you additionally hold write access to X; use it for Y") so a scout actually maintains its dashboard rather than describing what it would do. Follows the same per-run composition pattern as the existing channel/origin forks in scout_harness/prompt.py.
Implementation sketch
Likely two PRs:
- oauth posture —
posthog/temporal/oauth.py: SCOUT_GRANTABLE_WRITE_SCOPES, structured posture handling in resolve_scopes()/has_write_scopes(), mint-time intersection, tests (posthog/temporal/tests/test_oauth.py).
- config surface + runner —
SignalScoutConfig migration, serializer validation + elevated authorization gate, runner composition + metadata stamping, prompt section, MCP tool schema regeneration, docs updates (authoring-scouts / exploring-scouts skill references that document config fields, scout_harness/AGENTS.md), tests.
Explicit non-goals (this issue)
- Narrowing read scopes per scout (true least-privilege). Deferred; the additive design doesn't preclude a later
scope_mode: custom.
- Free-form scope lists outside the allowlist, or touching the internal scope set (
signal_scout_internal:write, signal_scout_report:write stay preset-owned; the report channel remains gated by the skill's allowed_tools exactly as today).
- Any change to the report-tool fail-closed gates in
scout_harness/views.py.
Open questions
- Final allowlist membership (
cohort:write? action:write?) — each entry is real unattended write access, so default to leaving it out until a scout needs it.
- Whether revoking a scope should also prune scratchpad guidance/prompt sections that referenced it (probably not — prompt composes per run from current config).
- Whether the elevated grant gate should additionally require the acting user (token identity) to hold the granted scopes, or the allowlist + editor bar is enough.
Summary
Allow a scout's PostHog API scope posture to be tuned per scout via its
SignalScoutConfig, by granting additional user-facing write scopes on top of the fleet-wide baseline. Example use case: a custom scout whose job is to maintain a dashboard needsdashboard:write+insight:write; today no scout can hold those.This is the "Option A / additive writes" design: reads stay fleet-global (all public read scopes, as today), internal harness scopes stay untouchable, and only a code-reviewed allowlist of write scopes becomes grantable per scout.
Current state
The scope posture is hardcoded per channel, not per scout:
scout_harness/runner.pypassesposthog_mcp_scopes="signals_scout"(or"signals_scout_reports"when the skill opted intoemit_report/edit_reportviaallowed_tools) intoCustomPromptSandboxContext.posthog/temporal/oauth.py:resolve_scopes()expands that preset into the token's scope list: all public MCP read scopes + internal scopes (task:write,internal_run:read,signal_scout_internal:write, optionallysignal_scout_report:write) +SCOUT_USER_WRITE_SCOPES, which is deliberately tiny and fleet-global (today justnotebook:write).Useful existing plumbing:
PosthogMcpScopesis alreadypreset | list[str]and survives the whole dispatch path (Task.create_and_run→pending_dispatchJSON → Temporal → token mint). Caveat: thelist[str]branch ofresolve_scopes()does not add the scout-internal/report scopes — those ride only the preset branch — so the harness must compose them explicitly (or the type gets extended, see below).SignalScoutConfig.network_accessis the direct precedent for a per-scout security knob, including stamping the effective value into the run row'smetadataat run creation so later config edits don't falsify history.Proposed design
Config surface
New field on
SignalScoutConfig(migration + serializer):Validated against a new code-level allowlist in
posthog/temporal/oauth.py:Exposed through the existing config endpoints/MCP tools (
scout-config-update,scout-config-create, and the nestedconfigobject onscout-create-prepare), thenhogli build:openapi.Scope allowlist policy (the real decision)
The token runs unattended in a sandbox that reads untrusted data (event properties, ticket text, survey responses — prompt-injection surfaces), so every granted write scope is blast radius for an injected run. Starting policy:
dashboard:write,insight:write,notebook:write,annotation:write. (cohort:writeis a candidate; discuss.)hog_function:write/ batch-export scopes — a webhook destination is arbitrary egress (exfiltration channel);feature_flag:write,experiment:write,survey:write— production-behavior-changing;Note scopes are object-level, not tool-level:
dashboard:writeincludes update and soft-delete of existing dashboards, not just ones the scout created. Same accepted-risk framing as the existingnotebook:writenote inoauth.py— recoverable soft-deletes, single-team token, monitored — and it belongs in the field's help text.Token composition
Extend the scout posture so extras can ride along while internal scopes stay preset-owned. Cleanest shape: a small structured value that stays JSON-round-trippable through
pending_dispatchand Temporal payloads, e.g.{"preset": "signals_scout_reports", "extra_write_scopes": ["dashboard:write", "insight:write"]}handled in
resolve_scopes()/has_write_scopes()next to the existing presets, with extras intersected againstSCOUT_GRANTABLE_WRITE_SCOPESat mint time (defense in depth — config validation is the first gate, the mint is the second).The runner reads the config field and composes the posture (~10 lines, following the
network_accesspattern).Authorization on the grant path
scout-config-updaterequires onlysignal_scout:writetoday. Widening a scout's token is more than steering — the token is minted under an acting user who may be more privileged than the config editor — so writes that set/changeadditional_write_scopesshould demand the elevated bar the scout-notes path already uses (llm_skill:writekey scope + thellm_skillRBAC editor check). Edits that don't touch the field keep the current requirement.Auditability
SignalScoutRun.metadataat run creation (config-edit-proof), and carry them on thesignals_scout_run_started/_finishedlifecycle events via_attach_run_shape_props.Prompt (optional, recommended)
Render a short section in the run prompt when extras are present ("you additionally hold write access to X; use it for Y") so a scout actually maintains its dashboard rather than describing what it would do. Follows the same per-run composition pattern as the existing channel/origin forks in
scout_harness/prompt.py.Implementation sketch
Likely two PRs:
posthog/temporal/oauth.py:SCOUT_GRANTABLE_WRITE_SCOPES, structured posture handling inresolve_scopes()/has_write_scopes(), mint-time intersection, tests (posthog/temporal/tests/test_oauth.py).SignalScoutConfigmigration, serializer validation + elevated authorization gate, runner composition + metadata stamping, prompt section, MCP tool schema regeneration, docs updates (authoring-scouts/exploring-scoutsskill references that document config fields,scout_harness/AGENTS.md), tests.Explicit non-goals (this issue)
scope_mode: custom.signal_scout_internal:write,signal_scout_report:writestay preset-owned; the report channel remains gated by the skill'sallowed_toolsexactly as today).scout_harness/views.py.Open questions
cohort:write?action:write?) — each entry is real unattended write access, so default to leaving it out until a scout needs it.