obstat 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {obstat-0.3.0 → obstat-0.3.1}/CLAUDE.md +14 -3
- {obstat-0.3.0 → obstat-0.3.1}/PKG-INFO +1 -1
- {obstat-0.3.0 → obstat-0.3.1}/docs/obstat-spec.md +21 -5
- {obstat-0.3.0 → obstat-0.3.1}/obstat/__init__.py +1 -1
- {obstat-0.3.0 → obstat-0.3.1}/obstat/guard.py +27 -3
- {obstat-0.3.0 → obstat-0.3.1}/pyproject.toml +1 -1
- {obstat-0.3.0 → obstat-0.3.1}/tests/test_guard.py +162 -0
- {obstat-0.3.0 → obstat-0.3.1}/uv.lock +1 -1
- {obstat-0.3.0 → obstat-0.3.1}/.github/workflows/ci.yml +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/.github/workflows/publish.yml +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/.gitignore +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/LICENSE +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/README.md +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/examples/server.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/obstat/__main__.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/obstat/approval.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/obstat/paths.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/obstat/policy.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/obstat/record.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/obstat.toml +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/tests/conftest.py +0 -0
- {obstat-0.3.0 → obstat-0.3.1}/tests/test_cli.py +0 -0
|
@@ -7,7 +7,7 @@ that weakens that ordering is a bug, not an optimisation.
|
|
|
7
7
|
## Commands
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
uv run pytest -q #
|
|
10
|
+
uv run pytest -q # 42 tests, ~0.9s (TestConcurrency spawns two children)
|
|
11
11
|
uv run ruff check .
|
|
12
12
|
uv run ruff format .
|
|
13
13
|
```
|
|
@@ -44,9 +44,20 @@ when the spec is renumbered.
|
|
|
44
44
|
uploads via PyPI trusted publishing (OIDC — there is no token anywhere). **A PyPI
|
|
45
45
|
version is immutable**: a bad build can be superseded, never withdrawn.
|
|
46
46
|
|
|
47
|
-
##
|
|
47
|
+
## Gotchas
|
|
48
48
|
|
|
49
49
|
`uv run --with /path/to/obstat` serves a **cached wheel** and will happily run
|
|
50
50
|
code you edited minutes ago as if you hadn't. `--reinstall-package` does not
|
|
51
51
|
dislodge it; `--refresh` does. Two verification runs were wasted on this — if a
|
|
52
|
-
local install seems to ignore your change, that is why.
|
|
52
|
+
local install seems to ignore your change, that is why. To exercise uncommitted
|
|
53
|
+
work, `--with-editable` sidesteps the question entirely.
|
|
54
|
+
|
|
55
|
+
**PyPI lags its own publish.** Minutes after `publish.yml` goes green,
|
|
56
|
+
`uv pip install obstat==X` can still fail with *"there is no version of
|
|
57
|
+
obstat==X"* while `https://pypi.org/pypi/obstat/json` reports X as latest — or
|
|
58
|
+
the reverse; 0.2.0 and 0.3.0 each showed one. It cleared inside a minute both
|
|
59
|
+
times. The symptom impersonates a failed upload, and every tempting response
|
|
60
|
+
(re-tag, re-run, `force`) is wrong, one of them irreversibly. **The publish job
|
|
61
|
+
log is authoritative**: it prints the `https://pypi.org/project/obstat/X/` that
|
|
62
|
+
upload.pythonhosted.org returned, which means the file was accepted. Poll the
|
|
63
|
+
install until it succeeds rather than diagnosing it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: obstat
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: An auditable decision record for agent tool calls. The clearance is written down before the call runs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/marcinmarzeta/obstat
|
|
6
6
|
Project-URL: Issues, https://github.com/marcinmarzeta/obstat/issues
|
|
@@ -301,10 +301,20 @@ The wrapper's `__signature__` is the wrapped function's, minus `subject`, plus:
|
|
|
301
301
|
obstat_approval_id: str | None = None # keyword-only
|
|
302
302
|
```
|
|
303
303
|
|
|
304
|
+
The **return annotation is widened** to `R | dict[str, Any]`, where `R` is what
|
|
305
|
+
the body declared. A tool declaring `-> str` is telling the truth about its own
|
|
306
|
+
result and not about its wrapper's: policy may send the call to a human and
|
|
307
|
+
obstat returns §5.1's payload instead. A server validates a tool's result against
|
|
308
|
+
this annotation, so without the widening an approval reaches the client as a
|
|
309
|
+
protocol error — the exact outcome §5.1 returns rather than raises to avoid. An
|
|
310
|
+
undeclared return is left undeclared; there is nothing to validate against.
|
|
311
|
+
|
|
304
312
|
MCP servers generate their tool schema from that signature, so a client sees the
|
|
305
313
|
approval argument — it needs to, for §5.2 — and cannot see `subject`. Verified
|
|
306
|
-
against the MCP SDK
|
|
307
|
-
`['doc_id', 'obstat_approval_id']
|
|
314
|
+
against the MCP SDK by calling through it, not by reading the signature:
|
|
315
|
+
`delete_document` advertises `['doc_id', 'obstat_approval_id']`, an allowed call
|
|
316
|
+
returns its result, and an approval-required call returns the §5.1 payload with
|
|
317
|
+
`is_error` false.
|
|
308
318
|
|
|
309
319
|
---
|
|
310
320
|
|
|
@@ -336,9 +346,11 @@ Policy said `approve` and no usable approval was supplied. obstat:
|
|
|
336
346
|
```
|
|
337
347
|
|
|
338
348
|
A return rather than an error, so the agent can reason about it and tell the user
|
|
339
|
-
what it is waiting for instead of treating a working control as a failure.
|
|
340
|
-
|
|
341
|
-
|
|
349
|
+
what it is waiting for instead of treating a working control as a failure. This
|
|
350
|
+
is why §4.2 widens the advertised return: a server that validates results against
|
|
351
|
+
the declared type would otherwise turn this payload back into the error it exists
|
|
352
|
+
not to be. `waiting` is `True` when this rejoined an existing request, which is
|
|
353
|
+
how an agent distinguishes "asked" from "asked again".
|
|
342
354
|
|
|
343
355
|
The approvals table holds the argument *digest*, so `obstat pending` reads what
|
|
344
356
|
the call was for off the record named in step 2 — anything the tool recorded
|
|
@@ -707,7 +719,11 @@ implementation of them, with §2.3's two commands in `tests/test_cli.py`.
|
|
|
707
719
|
| a removed record is caught | `TestChain::test_a_removed_record_is_caught` |
|
|
708
720
|
| recomputing one hash does not hide the edit | `TestChain::test_a_reused_hash_does_not_hide_an_edit` |
|
|
709
721
|
| a torn line does not swallow the next record | `TestChain::test_a_torn_line_does_not_swallow_the_next_record` |
|
|
722
|
+
| concurrent processes interleave records without splitting one | `TestConcurrency::test_two_processes_write_one_log` |
|
|
723
|
+
| within one process the chain stays a line | `TestConcurrency::test_threads_do_not_split_a_record` |
|
|
724
|
+
| a forked chain verifies | `TestConcurrency::test_a_forked_chain_still_verifies` |
|
|
710
725
|
| `subject` unadvertised, approval id advertised | `test_injected_subject_is_not_advertised…` |
|
|
726
|
+
| an approval is a return value through a real MCP server | `test_an_approval_survives_a_real_mcp_server` |
|
|
711
727
|
| the resource comes from the arguments | `test_resource_comes_from_the_arguments` |
|
|
712
728
|
| a template matches only the spelling it was given | `test_a_template_matches_only_the_spelling_it_was_given` |
|
|
713
729
|
| a callable resource normalises what policy matches | `test_a_callable_resource_normalises_what_policy_matches` |
|
|
@@ -126,12 +126,33 @@ class _Checked:
|
|
|
126
126
|
approval_id: str | None
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def _widened_return(returns: Any) -> Any:
|
|
130
|
+
"""The advertised return: what the body promises, or the §5.1 payload.
|
|
131
|
+
|
|
132
|
+
A server validates a tool's result against this. A tool annotated `-> str`
|
|
133
|
+
therefore fails when policy sends the call to a human and obstat returns the
|
|
134
|
+
approval payload instead — the control reaches the client as a protocol
|
|
135
|
+
error, which is the opposite of what §5.1 returns rather than raises for.
|
|
136
|
+
"""
|
|
137
|
+
if returns is inspect.Signature.empty:
|
|
138
|
+
return returns # nothing declared, so nothing to validate against
|
|
139
|
+
try:
|
|
140
|
+
return returns | dict[str, Any]
|
|
141
|
+
except TypeError:
|
|
142
|
+
# `from __future__ import annotations` leaves the annotation a string,
|
|
143
|
+
# and a string has no `|`. Servers resolve either form the same way.
|
|
144
|
+
return f"{returns} | dict[str, Any]"
|
|
145
|
+
|
|
146
|
+
|
|
129
147
|
def _public_signature(original: inspect.Signature) -> inspect.Signature:
|
|
130
|
-
"""The signature callers see: `subject` removed, `obstat_approval_id` added
|
|
148
|
+
"""The signature callers see: `subject` removed, `obstat_approval_id` added,
|
|
149
|
+
and the return widened to admit the approval payload.
|
|
131
150
|
|
|
132
151
|
`subject` is injected by obstat, so leaving it in the advertised schema would
|
|
133
152
|
invite a client to supply its own. The approval id is the opposite — the
|
|
134
|
-
retry protocol needs the caller to send it, so it has to be advertised.
|
|
153
|
+
retry protocol needs the caller to send it, so it has to be advertised. The
|
|
154
|
+
return is widened for the same reason both of those are true: this signature
|
|
155
|
+
describes what the wrapper does, not what the body does.
|
|
135
156
|
"""
|
|
136
157
|
kept = [p for name, p in original.parameters.items() if name != "subject"]
|
|
137
158
|
var_keyword = [p for p in kept if p.kind is inspect.Parameter.VAR_KEYWORD]
|
|
@@ -142,7 +163,10 @@ def _public_signature(original: inspect.Signature) -> inspect.Signature:
|
|
|
142
163
|
default=None,
|
|
143
164
|
annotation="str | None",
|
|
144
165
|
)
|
|
145
|
-
return original.replace(
|
|
166
|
+
return original.replace(
|
|
167
|
+
parameters=[*positional, approval_param, *var_keyword],
|
|
168
|
+
return_annotation=_widened_return(original.return_annotation),
|
|
169
|
+
)
|
|
146
170
|
|
|
147
171
|
|
|
148
172
|
def _resource_for(
|
|
@@ -10,6 +10,10 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import inspect
|
|
12
12
|
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
13
17
|
|
|
14
18
|
import pytest
|
|
15
19
|
|
|
@@ -540,6 +544,164 @@ class TestChain:
|
|
|
540
544
|
assert record.verify(log) == ["line 5: not a record"]
|
|
541
545
|
|
|
542
546
|
|
|
547
|
+
CHILD = """
|
|
548
|
+
import pathlib, sys, time
|
|
549
|
+
from obstat import guard
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@guard()
|
|
553
|
+
def touch(n: int) -> int:
|
|
554
|
+
return n
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
pathlib.Path(sys.argv[1]).write_text("ready")
|
|
558
|
+
go = pathlib.Path(sys.argv[2])
|
|
559
|
+
while not go.exists():
|
|
560
|
+
time.sleep(0.005)
|
|
561
|
+
for i in range({calls}):
|
|
562
|
+
touch(i)
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class TestConcurrency:
|
|
567
|
+
"""Two writers, one log. §6 says records interleave but never split; §6.3
|
|
568
|
+
says the chain forks across processes and verification tolerates it.
|
|
569
|
+
|
|
570
|
+
Both are claims about what happens when nothing is coordinating the writers,
|
|
571
|
+
which is the state a served tool is in and the state no other test puts it
|
|
572
|
+
in.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
CALLS = 15
|
|
576
|
+
|
|
577
|
+
def test_threads_do_not_split_a_record(self, workspace):
|
|
578
|
+
"""One process, one lock: every record whole, and the chain still a line."""
|
|
579
|
+
workspace(ALLOW_ALL)
|
|
580
|
+
|
|
581
|
+
@guard()
|
|
582
|
+
def touch(n: int) -> int:
|
|
583
|
+
return n
|
|
584
|
+
|
|
585
|
+
threads = [
|
|
586
|
+
threading.Thread(target=lambda: [touch(i) for i in range(self.CALLS)]) for _ in range(4)
|
|
587
|
+
]
|
|
588
|
+
for thread in threads:
|
|
589
|
+
thread.start()
|
|
590
|
+
for thread in threads:
|
|
591
|
+
thread.join()
|
|
592
|
+
|
|
593
|
+
# read() parses every line, so a split record fails here before anything
|
|
594
|
+
# is asserted about it.
|
|
595
|
+
entries = record.read()
|
|
596
|
+
assert len(entries) == 4 * self.CALLS * 2 # a decision and an outcome each
|
|
597
|
+
|
|
598
|
+
# `_chain_lock` covers hashing and appending together, so within one
|
|
599
|
+
# process the result is a line: one root, and no record followed twice.
|
|
600
|
+
prevs = [entry["prev"] for entry in entries]
|
|
601
|
+
assert prevs.count(None) == 1
|
|
602
|
+
assert len(prevs) == len(set(prevs))
|
|
603
|
+
assert record.verify() == []
|
|
604
|
+
|
|
605
|
+
def test_two_processes_write_one_log(self, workspace, tmp_path):
|
|
606
|
+
"""No inter-process lock, so this is O_APPEND and one write() syscall
|
|
607
|
+
doing the work (§6). Released together, because two writers that happen
|
|
608
|
+
to take turns prove nothing."""
|
|
609
|
+
workspace(ALLOW_ALL)
|
|
610
|
+
script = tmp_path / "child.py"
|
|
611
|
+
script.write_text(CHILD.format(calls=self.CALLS), encoding="utf-8")
|
|
612
|
+
|
|
613
|
+
go = tmp_path / "go"
|
|
614
|
+
children = []
|
|
615
|
+
for name in ("a", "b"):
|
|
616
|
+
ready = tmp_path / f"ready-{name}"
|
|
617
|
+
children.append(
|
|
618
|
+
(
|
|
619
|
+
ready,
|
|
620
|
+
subprocess.Popen([sys.executable, str(script), str(ready), str(go)]),
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
deadline = time.time() + 30
|
|
624
|
+
while not all(ready.exists() for ready, _ in children):
|
|
625
|
+
assert time.time() < deadline, "a child never started"
|
|
626
|
+
time.sleep(0.01)
|
|
627
|
+
go.write_text("go")
|
|
628
|
+
|
|
629
|
+
for _, child in children:
|
|
630
|
+
assert child.wait(timeout=60) == 0
|
|
631
|
+
|
|
632
|
+
entries = record.read()
|
|
633
|
+
assert len(entries) == 2 * self.CALLS * 2
|
|
634
|
+
assert record.verify() == []
|
|
635
|
+
|
|
636
|
+
def test_a_forked_chain_still_verifies(self, workspace):
|
|
637
|
+
"""What a second process does, done deliberately: carry on from the
|
|
638
|
+
record you last saw, not from the one that is now on the end.
|
|
639
|
+
|
|
640
|
+
Reproduced in-process because two real processes might take turns and
|
|
641
|
+
never fork at all, and a test that only sometimes tests the thing is
|
|
642
|
+
worse than no test.
|
|
643
|
+
"""
|
|
644
|
+
workspace(ALLOW_ALL)
|
|
645
|
+
|
|
646
|
+
@guard()
|
|
647
|
+
def touch(what: str) -> str:
|
|
648
|
+
return what
|
|
649
|
+
|
|
650
|
+
touch("first")
|
|
651
|
+
branch = record._chain # what another process would still be holding
|
|
652
|
+
touch("second")
|
|
653
|
+
record._chain = branch
|
|
654
|
+
touch("third")
|
|
655
|
+
|
|
656
|
+
prevs = [entry["prev"] for entry in record.read()]
|
|
657
|
+
assert len(prevs) != len(set(prevs)), "nothing forked, so nothing was tested"
|
|
658
|
+
# Every `prev` still names a record earlier in the file, which is all
|
|
659
|
+
# verification asks of it — a fork is not damage.
|
|
660
|
+
assert record.verify() == []
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
async def test_an_approval_survives_a_real_mcp_server(workspace):
|
|
664
|
+
"""Through the SDK, not past it (§4.2).
|
|
665
|
+
|
|
666
|
+
Every other test calls the decorated function directly, which is exactly how
|
|
667
|
+
a server validating the tool's result against its return annotation went
|
|
668
|
+
unnoticed: a tool declaring `-> str` raised at the client the moment policy
|
|
669
|
+
sent a call to a human, turning the control §5.1 designed as a return value
|
|
670
|
+
into a protocol error.
|
|
671
|
+
"""
|
|
672
|
+
server = pytest.importorskip("mcp.server", reason="mcp is an optional extra")
|
|
673
|
+
workspace('[[rule]]\ntool = "read_*"\neffect = "allow"\n[[rule]]\neffect = "approve"\n')
|
|
674
|
+
|
|
675
|
+
mcp = server.MCPServer("test")
|
|
676
|
+
|
|
677
|
+
@mcp.tool()
|
|
678
|
+
@guard()
|
|
679
|
+
def read_thing(doc_id: str) -> str:
|
|
680
|
+
return f"contents of {doc_id}"
|
|
681
|
+
|
|
682
|
+
@mcp.tool()
|
|
683
|
+
@guard()
|
|
684
|
+
def delete_thing(doc_id: str) -> str: # pragma: no cover - waits for a human
|
|
685
|
+
return f"{doc_id} deleted"
|
|
686
|
+
|
|
687
|
+
advertised = await mcp.list_tools()
|
|
688
|
+
assert {tool.name for tool in advertised} == {"read_thing", "delete_thing"}
|
|
689
|
+
|
|
690
|
+
allowed = await mcp.call_tool("read_thing", {"doc_id": "q3"})
|
|
691
|
+
assert allowed.is_error is False
|
|
692
|
+
assert allowed.content[0].text == "contents of q3"
|
|
693
|
+
|
|
694
|
+
# The one that used to raise.
|
|
695
|
+
asked = await mcp.call_tool("delete_thing", {"doc_id": "q3"})
|
|
696
|
+
assert asked.is_error is False
|
|
697
|
+
assert json.loads(asked.content[0].text)["obstat"] == "approval_required"
|
|
698
|
+
|
|
699
|
+
assert [entry["effect"] for entry in record.read() if entry["phase"] == "decision"] == [
|
|
700
|
+
"allow",
|
|
701
|
+
"approval_required",
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
|
|
543
705
|
async def test_async_tools_go_through_the_same_gate(workspace):
|
|
544
706
|
workspace('[[rule]]\ntool = "fetch"\neffect = "allow"\n[[rule]]\neffect = "deny"\n')
|
|
545
707
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|