Compare commits

...
2 changed files with 30 additions and 50 deletions
+6
View File
@@ -65,6 +65,12 @@ def test_what_im_writing_targets_the_visible_document():
language="richtext", language="richtext",
) )
assert _turn_targets_active_document(
{"domains": set()},
"can you fact check what Im writing",
active_document,
)
def test_unrelated_fact_check_does_not_target_a_stale_visible_document(): def test_unrelated_fact_check_does_not_target_a_stale_visible_document():
active_document = MagicMock( active_document = MagicMock(
+24 -50
View File
@@ -1,24 +1,8 @@
"""Independent validation test for the claim that """Skills operations must preserve ownership across same-slug skills.
`SkillsManager.update_skill` mutates the first skill on disk matching
`name` regardless of the caller's owner, and that `owner` is in its
`scalar_keys` whitelist allowing cross-user ownership reassignment.
This test sets up two user-owned skills on disk with the SAME slug An unscoped update may match only ownerless skills. With Alice's and Bob's
(`login-flow`) — Alice's and Bob's — and then calls `update_skill` with skills sharing a slug, it must return False and leave both files unchanged.
NO `owner` argument. If the bug is real, exactly one of the two files Unexpected implementation errors must fail the regression test.
will be mutated (whichever `_iter_skill_files` yields first) and the
caller will have effectively re-stamped the file as owned by the value
in `updates["owner"]` ("attacker"). If the manager method is safe (or
the slug uniqueness invariant makes the bug moot), the call should
either:
* raise (it requires an `owner` argument), OR
* be a no-op (no other side effect on Bob's file), OR
* the file that gets modified should still belong to its original
owner (no ownership reassignment).
We assert the safer behaviors; the test FAILS only when update_skill
silently mutates a file owned by a different user AND overwrites the
`owner` field with an attacker's value.
""" """
import os import os
@@ -27,9 +11,6 @@ import textwrap
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
# ── module-load stubbing (matches other tests in this repo) ────────── # ── module-load stubbing (matches other tests in this repo) ──────────
# Stub heavy deps so importing the skills manager doesn't pull DB / FastAPI. # Stub heavy deps so importing the skills manager doesn't pull DB / FastAPI.
for _mod in ("sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative"): for _mod in ("sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative"):
@@ -100,37 +81,34 @@ def test_update_skill_does_not_mutate_foreign_owned_skill(tmp_path):
sm = SkillsManager(str(tmp_path)) sm = SkillsManager(str(tmp_path))
# Snapshot before. # Snapshot before.
before_alice = alice_path.read_text(encoding="utf-8") before_alice = alice_path.read_bytes()
before_bob = bob_path.read_text(encoding="utf-8") before_bob = bob_path.read_bytes()
# Try to reassign + mutate. The caller does NOT supply an owner # Try to reassign + mutate. The caller does NOT supply an owner
# arg, mirroring the in-process callers in tool_implementations.py # arg, mirroring the in-process callers in tool_implementations.py
# (lines 716, 740, 753) which call sm.update_skill(name, updates). # (lines 716, 740, 753) which call sm.update_skill(name, updates).
try: result = sm.update_skill(
result = sm.update_skill( "login-flow",
"login-flow", {"owner": "attacker", "description": "pwned"},
{"owner": "attacker", "description": "pwned"}, )
)
except TypeError as e:
# If the method were fixed to require an owner arg, this is
# the desired (safe) behavior — the call refused.
pytest.skip(
f"update_skill raised TypeError (refused unsafe call): {e}"
)
return
# After: read what each file now contains. # After: read what each file now contains.
after_alice = alice_path.read_text(encoding="utf-8") after_alice = alice_path.read_bytes()
after_bob = bob_path.read_text(encoding="utf-8") after_bob = bob_path.read_bytes()
assert after_alice == before_alice
assert after_bob == before_bob
assert b"owner: alice" in after_alice
assert b"owner: bob" in after_bob
# Invariant 1: a file that was owned by `alice` (resp. `bob`) MUST # Invariant 1: a file that was owned by `alice` (resp. `bob`) MUST
# NOT end up owned by `attacker` after the call. If it does, that's # NOT end up owned by `attacker` after the call. If it does, that's
# the cross-user ownership reassignment bug. # the cross-user ownership reassignment bug.
assert "owner: attacker" not in after_alice, ( assert b"owner: attacker" not in after_alice, (
"BUG: Alice's file was silently re-owned as 'attacker' by " "BUG: Alice's file was silently re-owned as 'attacker' by "
"update_skill (cross-user ownership reassignment)." "update_skill (cross-user ownership reassignment)."
) )
assert "owner: attacker" not in after_bob, ( assert b"owner: attacker" not in after_bob, (
"BUG: Bob's file was silently re-owned as 'attacker' by " "BUG: Bob's file was silently re-owned as 'attacker' by "
"update_skill (cross-user ownership reassignment)." "update_skill (cross-user ownership reassignment)."
) )
@@ -138,23 +116,19 @@ def test_update_skill_does_not_mutate_foreign_owned_skill(tmp_path):
# Invariant 2: a file that was owned by `alice` and contained # Invariant 2: a file that was owned by `alice` and contained
# description "alice original" must not be silently mutated into # description "alice original" must not be silently mutated into
# "pwned" by a caller that did not supply an owner. # "pwned" by a caller that did not supply an owner.
if "alice original" in before_alice: if b"alice original" in before_alice:
assert "alice original" in after_alice, ( assert b"alice original" in after_alice, (
"BUG: Alice's skill description was overwritten by a call " "BUG: Alice's skill description was overwritten by a call "
"to update_skill that did not scope to her owner." "to update_skill that did not scope to her owner."
) )
if "bob original" in before_bob: if b"bob original" in before_bob:
assert "bob original" in after_bob, ( assert b"bob original" in after_bob, (
"BUG: Bob's skill description was overwritten by a call " "BUG: Bob's skill description was overwritten by a call "
"to update_skill that did not scope to his owner." "to update_skill that did not scope to his owner."
) )
# The return value should not lie about success — if the manager assert result is False, "an unscoped update must not report a foreign-owner mutation"
# touched nothing because both files were foreign-owned, the safer
# behavior is to return False, not True. (A return of True is the
# buggy path; we don't assert False, we just don't assert True.)
_ = result # not asserted; documented behavior is not the point.
def test_update_skill_scalar_keys_exclude_owner(): def test_update_skill_scalar_keys_exclude_owner():