Signals scouts: per-scout additive PostHog write scopes on SignalScoutConfig
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 39.9k
- Forks
- 3.4k
- Avg merge
- 6h 51m
- Merged PRs (30d)
- 232
Description
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.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).- 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:
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):
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:writeis 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.metadataat run creation (config-edit-proof), and carry them on thesignals_scout_run_started/_finishedlifecycle 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 inresolve_scopes()/has_write_scopes(), mint-time intersection, tests (posthog/temporal/tests/test_oauth.py). - config surface + runner —
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)
- 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:writestay preset-owned; the report channel remains gated by the skill'sallowed_toolsexactly 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with posthog/temporal/oauth.py and posthog/temporal/tests/test_oauth.py to understand current preset resolution and write-scope checks. Then trace SignalScoutConfig, scout_harness/runner.py, run metadata creation, and the config endpoints described in the issue. Done means the approved per-scout scope flow, validation and authorization, audit metadata, regenerated schemas, documentation, and tests are complete.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, authorization, backend, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100