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
27 changes: 27 additions & 0 deletions products/signals/backend/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def ready(self) -> None:
receivers.connect_task_run_assignment_sync()
self._register_signal_emission_gate()
self._register_scout_skill_archive_guard()
self._register_scout_skill_rename_hook()

def _register_signal_emission_gate(self) -> None:
"""Let the data-import pipeline ask whether to emit signals for a source without
Expand Down Expand Up @@ -60,3 +61,29 @@ def _guard(team: "Team", skill_name: str, user: "User", authenticator: object) -
raise SkillArchiveRefused(str(err)) from err

register_skill_archive_guard(_guard)

def _register_scout_skill_rename_hook(self) -> None:
"""Let the skills product move or refuse a scout's config on rename without importing this one."""
from products.skills.backend.api.archive_guards import (
SkillRenameConflict,
SkillRenameRefused,
register_skill_rename_hook,
)

def _hook(team: "Team", skill_name: str, new_name: str, user: "User | None", authenticator: object) -> None:
from products.signals.backend.scout_harness.lifecycle_lock import ( # noqa: PLC0415
ScoutConfigNameTaken,
ScoutLifecycleLocked,
rename_scout_with_skill,
)

try:
rename_scout_with_skill(
team=team, skill_name=skill_name, new_name=new_name, user=user, authenticator=authenticator
)
except ScoutLifecycleLocked as err:
raise SkillRenameRefused(str(err)) from err
except ScoutConfigNameTaken as err:
raise SkillRenameConflict(str(err)) from err

register_skill_rename_hook(_hook)
112 changes: 101 additions & 11 deletions products/signals/backend/scout_harness/lifecycle_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

from collections.abc import Mapping

from django.db.models import Subquery

import structlog
from prometheus_client import Counter

Expand All @@ -25,7 +27,8 @@
from posthog.user_permissions import UserPermissions

from products.signals.backend.models import SignalScoutConfig
from products.signals.backend.scout_harness.skill_loader import resolve_scout_acting_user_id
from products.signals.backend.scout_harness.skill_loader import earliest_member_author_id, resolve_scout_acting_user_id
from products.skills.backend.models.skills import LLMSkill

logger = structlog.get_logger(__name__)

Expand All @@ -48,6 +51,10 @@ class ScoutLifecycleLocked(Exception):
"""


class ScoutConfigNameTaken(Exception):
"""A skill rename would move a scout's config onto a name another config already holds."""


def resolve_auth_kind(authenticator: object) -> str:
"""How a caller authenticated, as a bounded metric label.

Expand Down Expand Up @@ -87,6 +94,25 @@ def lock_protected_changes(requested: object, *, config: SignalScoutConfig | Non
return changed if config.lifecycle_locked or "lifecycle_locked" in changed else []


def resolve_scout_lifecycle_owner_id(team: Team, skill_name: str, config: SignalScoutConfig) -> int | None:
"""The person the lock protects: the acting user, also after the skill is archived.

The delete flow archives the skill before it deletes the config. The acting-user lookup reads
live versions only, so without this fallback the skill author loses the claim between the two
steps and gets a refusal for their own scout. Only the most recent archive counts: an archive
stamps one `updated_at` on every version it tombstones, and an older skill that once used the
name must not give its author the claim.
"""
if LLMSkill.objects.filter(team=team, name=skill_name, deleted=False).exists():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Any member can take over a locked scout's config by reusing its archived skill name

The lifecycle lock can be defeated for any locked config whose skill was archived (the orphan state the config-destroy endpoint exists to clean up). Once a live skill exists under the config's skill_name, resolve_scout_lifecycle_owner_id takes the live path and names that skill's earliest author as the claim holder — so whoever re-creates the name becomes the owner, and the config's granted write scopes then run the attacker's prompt on schedule.

How:

  1. The scout's owner archives its skill (allowed); the locked SignalScoutConfig stays keyed on the old skill name.
  2. A plain member creates a new skill under that now-free name — create_skill only checks live rows (skill_services.py:651), and nothing in the skills product consults scout configs — or renames a non-scout skill onto it: rename_scout_with_skill returns at if config is None (lifecycle_lock.py:233-234) before the skill_name=new_name conflict check at line 246.
  3. With a live skill under the name, line 106-107 resolves the claim to the earliest live author — the attacker — beating the archived-author fallback this PR added.
  4. The attacker now passes every lock gate (user_holds_scout_lifecycle_claim, line 132): pauses, unlocks, or deletes the victim's locked config. (The scout-create route also skips the gate entirely when the skill was created fresh in the request, views.py:2813, applying enabled: false straight to the adopted orphan config.)

Fix: Resolve the claim for an orphaned locked config from the config's own provenance (archived-author path) rather than whoever currently holds the name, and refuse a skill create/rename onto a name a scout config holds unless the caller holds that config's claim.


React with 👍 if useful or 👎 if not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. This path exists on master, so this PR does not add it:

  • On master, user_holds_scout_lifecycle_claim calls resolve_scout_acting_user_id, which picks the earliest live author (master permalink). Re-creating an archived scout's name already gives the new author the claim.
  • This PR only adds the archived-author fallback for the case with no live version. When a live version exists, the result stays the same as on master.
  • A full fix has to stop create_skill and every rename from taking a name that an orphan scout config holds, or bind the config to the skill identity instead of the name. That is a separate change in the skills product, outside this PR.

No code change in this PR for this thread. The gap stays open until a separate follow-up lands.

return resolve_scout_acting_user_id(team, skill_name, config)
archived = LLMSkill.objects.filter(team=team, name=skill_name, deleted=True)
last_archive = archived.filter(updated_at=Subquery(archived.order_by("-updated_at").values("updated_at")[:1]))
archived_author_id = earliest_member_author_id(team, last_archive)
Comment on lines +108 to +110

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 · Use immutable archive metadata to resolve the lifecycle owner

updated_at does not identify an archive operation. normalize_skill_file_paths.Command._apply updates this field on individual skill versions, including archived versions. If it updates a version written by an editor, last_archive excludes the creator's versions and assigns the lifecycle claim to that editor. The editor can then unlock or delete the creator's locked config. Updating an older archive can also restore a former author's claim. Preserve the resolved lifecycle owner when archiving, or record an immutable archive identifier that groups the archived versions. Do not derive authorization from updated_at. Add regression coverage that changes one archived version's updated_at and verifies that the lifecycle owner stays unchanged.

if archived_author_id is not None:
return archived_author_id
return resolve_scout_acting_user_id(team, skill_name, config)


def user_holds_scout_lifecycle_claim(
*,
team: Team,
Expand All @@ -103,7 +129,7 @@ def user_holds_scout_lifecycle_claim(
level = UserPermissions(user=user, team=team).current_team.effective_membership_level
if level is not None and level >= OrganizationMembership.Level.ADMIN:
return True
return resolve_scout_acting_user_id(team, skill_name, config) == user.pk
return resolve_scout_lifecycle_owner_id(team, skill_name, config) == user.pk


def record_lifecycle_refusal(
Expand All @@ -128,6 +154,33 @@ def record_lifecycle_refusal(
)


def _refuse_without_claim(
*,
team: Team,
skill_name: str,
config: SignalScoutConfig,
user: User,
authenticator: object,
action: str,
verb: str,
) -> None:
"""Raise `ScoutLifecycleLocked` for a locked scout unless the caller holds its claim."""
if not config.lifecycle_locked:
return
if user_holds_scout_lifecycle_claim(team=team, skill_name=skill_name, config=config, user=user):
return
record_lifecycle_refusal(
team=team,
skill_name=skill_name,
action=action,
auth_kind=resolve_auth_kind(authenticator),
user_id=user.pk,
)
raise ScoutLifecycleLocked(
f"This scout is locked, so only the person its runs act as or a project admin can {verb} it."
)


def assert_can_archive_scout_skill(
*,
team: Team,
Expand All @@ -145,17 +198,54 @@ def assert_can_archive_scout_skill(
"""
scoped_team = canonical_team(team)
config = SignalScoutConfig.objects.for_team(scoped_team.id).filter(skill_name=skill_name).first()
if config is None or not config.lifecycle_locked:
return
if user_holds_scout_lifecycle_claim(team=scoped_team, skill_name=skill_name, config=config, user=user):
if config is None:
return
record_lifecycle_refusal(
_refuse_without_claim(
team=scoped_team,
skill_name=skill_name,
config=config,
user=user,
authenticator=authenticator,
action="archive_skill",
auth_kind=resolve_auth_kind(authenticator),
user_id=user.pk,
)
raise ScoutLifecycleLocked(
"This scout is locked, so only the person its runs act as or a project admin can delete it."
verb="delete",
)


def rename_scout_with_skill(
*,
team: Team,
skill_name: str,
new_name: str,
user: User | None,
authenticator: object = None,
) -> None:
"""Move a scout's config to its skill's new name, behind the same gate as archiving.

The config is keyed on the skill name. A rename that left it behind would orphan the config
and stop the scout, so a rename removes a scout as surely as an archive does. Call it inside
the rename transaction, before the skill rows move: the owner lookup reads the old name.

A no-op for a skill that is not a scout. A caller with no acting user is a system path, so it
is not gated.
"""
scoped_team = canonical_team(team)
configs = SignalScoutConfig.objects.for_team(scoped_team.id)
config = configs.select_for_update().filter(skill_name=skill_name).first()
Comment on lines +214 to +216

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Keep child-environment renames from moving parent scout configs

rename_skill() updates skill rows for the exact team, but this hook selects configs from the parent team. If the parent and child both contain custom-watch, renaming the child's skill moves the parent's config. The parent's skill keeps its original name, so its scout stops running. The skills endpoint does not apply ScoutCanonicalTeamAccessPermission. A child-scoped credential can therefore trigger this parent write when the config is unlocked. Return without moving parent configs when team.id differs from scoped_team.id. Keep the config and skill lookups bound to the same owning team. Add a regression case with matching parent and child skill names and a child-scoped API key. Assert that renaming the child skill leaves the parent skill and config unchanged.

if config is None:
return
if user is not None:
_refuse_without_claim(
team=scoped_team,
skill_name=skill_name,
config=config,
user=user,
authenticator=authenticator,
action="rename_skill",
verb="rename",
)
if configs.filter(skill_name=new_name).exists():
raise ScoutConfigNameTaken(
"Another scout config already uses this name. Delete that config or pick another name."
)
config.skill_name = new_name
config.save(update_fields=["skill_name", "updated_at"])
Comment on lines +234 to +239

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 · Validate reserved scout names before moving the config

LLMSkillRenameSerializer accepts runs, findings, and scratchpad because these names are valid for ordinary skills. The new hook also accepts them and moves the scout config. Inbox routing explicitly ignores these names when selecting a scout, so the renamed scout's detail page cannot open. Scout creation and registration already reject them through reserved_scout_name_error(). Call reserved_scout_name_error(new_name) when the source has a scout config, before moving any rows. Propagate a validation error through the hook so the endpoint returns 400. Keep ordinary skill renames valid. Extend rename coverage with runs, findings, and scratchpad.

25 changes: 12 additions & 13 deletions products/signals/backend/scout_harness/skill_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, Literal

from django.db.models import Max, Min
from django.db.models import Max, Min, QuerySet

from posthog.models.team.team import Team

Expand Down Expand Up @@ -176,6 +176,16 @@ def resolve_skill_owner_user_uuids(team: Team, skill_name: str) -> list[str]:
]


def earliest_member_author_id(team: Team, versions: QuerySet[LLMSkill]) -> int | None:
"""The author of the earliest version in `versions` who is still an active member of `team`."""
return (
versions.filter(created_by__isnull=False, created_by__in=team.all_users_with_access())
.order_by("created_at", "id")
.values_list("created_by_id", flat=True)
.first()
)


def resolve_scout_acting_user_id(team: Team, skill_name: str, config: SignalScoutConfig | None) -> int | None:
"""User id a scout run acts as, which is also where its AI spend is attributed.

Expand All @@ -196,18 +206,7 @@ def resolve_scout_acting_user_id(team: Team, skill_name: str, config: SignalScou
mints a sandbox token as this user. Returns None when no path resolves a member; the runner
then falls back to the team-level default (`resolve_acting_user_id_for_team`).
"""
creator_id = (
LLMSkill.objects.filter(
team=team,
name=skill_name,
deleted=False,
created_by__isnull=False,
created_by__in=team.all_users_with_access(),
)
.order_by("created_at", "id")
.values_list("created_by_id", flat=True)
.first()
)
creator_id = earliest_member_author_id(team, LLMSkill.objects.filter(team=team, name=skill_name, deleted=False))
if creator_id is not None:
return creator_id
if config is None:
Expand Down
13 changes: 9 additions & 4 deletions products/signals/backend/scout_harness/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3170,12 +3170,17 @@ def guard_scout_lifecycle_fields(
requested: object,
action: str,
) -> None:
"""Apply the lock gate to a write body. A no-op unless the body changes a protected field."""
fields = lock_protected_changes(requested, config=config)
if config is None or not fields:
"""Apply the lock gate to a write body. A no-op unless the body changes a protected field.

With no config yet, the gate checks the row the write would create. So a lock set at
registration asks for the same claim as a lock set afterwards.
"""
target = config or SignalScoutConfig(team_id=team.id, skill_name=skill_name, created_by=cast(User, request.user))
fields = lock_protected_changes(requested, config=target)
if not fields:
return
assert_can_change_scout_lifecycle(
request=request, team=team, skill_name=skill_name, config=config, action=action, fields=fields
request=request, team=team, skill_name=skill_name, config=target, action=action, fields=fields
)


Expand Down
58 changes: 58 additions & 0 deletions products/signals/backend/test/test_scout_harness_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,64 @@ def test_archiving_an_ordinary_skill_is_untouched_by_the_guard(self) -> None:
assert response.status_code == status.HTTP_204_NO_CONTENT, response.content
assert not LLMSkill.objects.filter(name="not-a-scout", deleted=False).exists()

@parameterized.expand(
[
("scout_author", True, status.HTTP_201_CREATED),
("plain_member", False, status.HTTP_403_FORBIDDEN),
]
)
def test_registering_a_locked_config_needs_the_acting_user_or_an_admin(
self, _name: str, is_author: bool, expected: int
) -> None:
self._authored_by(self.user if is_author else self._other_member())

response = self.client.post(
self._list_url(),
data={"skill_name": "signals-scout-hygiene", "enabled": False, "lifecycle_locked": True},
format="json",
)

assert response.status_code == expected, response.json()
assert SignalScoutConfig.objects.filter(skill_name="signals-scout-hygiene").exists() is (
expected == status.HTTP_201_CREATED
)

@parameterized.expand(
[
("scout_author", True, status.HTTP_200_OK),
("plain_member", False, status.HTTP_403_FORBIDDEN),
]
)
def test_renaming_a_locked_scouts_skill_needs_the_acting_user_and_moves_the_config(
self, _name: str, is_author: bool, expected: int
) -> None:
author = self.user if is_author else self._other_member()
LLMSkill.objects.create(team=self.team, name="custom-watch", description="", body="", created_by=author)
config = SignalScoutConfig.objects.create(team=self.team, skill_name="custom-watch", lifecycle_locked=True)

response = self.client.post(
f"/api/projects/{self.team.id}/llm_skills/name/custom-watch/rename",
data={"new_name": "custom-watch-renamed"},
format="json",
)

assert response.status_code == expected, response.content
config.refresh_from_db()
renamed = expected == status.HTTP_200_OK
assert config.skill_name == ("custom-watch-renamed" if renamed else "custom-watch")
assert LLMSkill.objects.filter(name="custom-watch-renamed", deleted=False).exists() is renamed

def test_the_owner_deletes_a_locked_scout_after_archiving_its_skill(self) -> None:
config = self._config(lifecycle_locked=True, created_by=self._other_member())
self._authored_by(self.user)

archive = self.client.post(self._archive_url())
destroy = self.client.delete(self._detail_url(str(config.id)))

assert archive.status_code == status.HTTP_204_NO_CONTENT, archive.content
assert destroy.status_code == status.HTTP_204_NO_CONTENT, destroy.content
assert not SignalScoutConfig.objects.filter(id=config.id).exists()

def test_the_owner_locks_the_scout_and_the_read_surfaces_it(self) -> None:
# Wiring guard: the flag has to round-trip through the update serializer and the read
# shape, or the settings UI cannot show or set the lock at all.
Expand Down
38 changes: 37 additions & 1 deletion products/skills/backend/api/archive_guards.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Hook inversion for skill archival, so another product can refuse one.
"""Hook inversion for skill archival and rename, so another product can refuse one.

Archiving is permanent — there is no unarchive endpoint — and a skill can be the definition
another product runs. That product knows when removing it is not the caller's call to make, but
Expand All @@ -7,6 +7,10 @@

Signals registers the only guard today: a scout that locked its lifecycle refuses to be archived
by anyone but the person its runs act as, or a project admin.

A rename gets the same treatment, because another product can key its rows on the skill name.
A rename hook runs inside the rename transaction, before the skill rows move. It can refuse the
rename, or move its own rows to the new name in the same transaction.
"""

from collections.abc import Callable
Expand Down Expand Up @@ -47,3 +51,35 @@ def assert_skill_archive_allowed(
return
for guard in _archive_guards:
guard(team, skill_name, user, authenticator)


class SkillRenameRefused(Exception):
"""A registered rename hook refused this rename. Carries the message to show the caller."""


class SkillRenameConflict(Exception):
"""A registered rename hook found its own rows already under the new name."""


# (team, skill_name, new_name, user, authenticator) -> None, raising SkillRenameRefused or
# SkillRenameConflict to stop the rename. `user` is None for a system path.
SkillRenameHook = Callable[[Team, str, str, User | None, object], None]

_rename_hooks: list[SkillRenameHook] = []


def register_skill_rename_hook(fn: SkillRenameHook) -> None:
_rename_hooks.append(fn)


def run_skill_rename_hooks(
*,
team: Team,
skill_name: str,
new_name: str,
user: User | None,
authenticator: object = None,
) -> None:
"""Run every registered rename hook. Call it inside the rename transaction."""
for hook in _rename_hooks:
hook(team, skill_name, new_name, user, authenticator)
Loading