Skip to content

Signals scouts: per-scout additive PostHog write scopes on SignalScoutConfig #77925

Description

@andrewm4894

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_runpending_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:

  1. oauth postureposthog/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).
  2. config surface + runnerSignalScoutConfig 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions