obstat 0.2.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.2.0 → obstat-0.3.1}/.github/workflows/ci.yml +8 -1
- {obstat-0.2.0 → obstat-0.3.1}/CLAUDE.md +14 -3
- {obstat-0.2.0 → obstat-0.3.1}/PKG-INFO +47 -7
- {obstat-0.2.0 → obstat-0.3.1}/README.md +46 -6
- {obstat-0.2.0 → obstat-0.3.1}/docs/obstat-spec.md +228 -22
- {obstat-0.2.0 → obstat-0.3.1}/obstat/__init__.py +1 -1
- obstat-0.3.1/obstat/__main__.py +184 -0
- {obstat-0.2.0 → obstat-0.3.1}/obstat/approval.py +34 -6
- {obstat-0.2.0 → obstat-0.3.1}/obstat/guard.py +74 -16
- {obstat-0.2.0 → obstat-0.3.1}/obstat/policy.py +3 -2
- {obstat-0.2.0 → obstat-0.3.1}/obstat/record.py +12 -4
- {obstat-0.2.0 → obstat-0.3.1}/pyproject.toml +1 -1
- obstat-0.3.1/tests/test_cli.py +75 -0
- {obstat-0.2.0 → obstat-0.3.1}/tests/test_guard.py +283 -0
- {obstat-0.2.0 → obstat-0.3.1}/uv.lock +1 -1
- obstat-0.2.0/obstat/__main__.py +0 -98
- {obstat-0.2.0 → obstat-0.3.1}/.github/workflows/publish.yml +0 -0
- {obstat-0.2.0 → obstat-0.3.1}/.gitignore +0 -0
- {obstat-0.2.0 → obstat-0.3.1}/LICENSE +0 -0
- {obstat-0.2.0 → obstat-0.3.1}/examples/server.py +0 -0
- {obstat-0.2.0 → obstat-0.3.1}/obstat/paths.py +0 -0
- {obstat-0.2.0 → obstat-0.3.1}/obstat.toml +0 -0
- {obstat-0.2.0 → obstat-0.3.1}/tests/conftest.py +0 -0
|
@@ -7,10 +7,17 @@ on:
|
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
test:
|
|
10
|
-
runs-on:
|
|
10
|
+
runs-on: ${{ matrix.os }}
|
|
11
11
|
strategy:
|
|
12
12
|
matrix:
|
|
13
|
+
os: [ubuntu-latest]
|
|
13
14
|
python: ["3.11", "3.12", "3.13"]
|
|
15
|
+
# Windows cannot fsync a directory (§8), which the first record write
|
|
16
|
+
# depends on. One leg, so the platform where that is skipped is a
|
|
17
|
+
# platform the tests actually run on.
|
|
18
|
+
include:
|
|
19
|
+
- os: windows-latest
|
|
20
|
+
python: "3.12"
|
|
14
21
|
steps:
|
|
15
22
|
- uses: actions/checkout@v4
|
|
16
23
|
- uses: astral-sh/setup-uv@v5
|
|
@@ -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
|
+
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
|
|
@@ -47,6 +47,11 @@ is still weak.
|
|
|
47
47
|
|
|
48
48
|
## 60 seconds
|
|
49
49
|
|
|
50
|
+
```console
|
|
51
|
+
$ obstat init
|
|
52
|
+
wrote obstat.toml — everything is denied until you uncomment a rule
|
|
53
|
+
```
|
|
54
|
+
|
|
50
55
|
`obstat.toml`:
|
|
51
56
|
|
|
52
57
|
```toml
|
|
@@ -173,6 +178,18 @@ permission, and a missing policy file is an error rather than an implicit allow.
|
|
|
173
178
|
Patterns are globs. The file is re-read when it changes, so editing policy does
|
|
174
179
|
not need a restart.
|
|
175
180
|
|
|
181
|
+
They are matched **case-sensitively**, against an id built from arguments the
|
|
182
|
+
caller sent. If your namespace is not case-sensitive — Jira keys, most
|
|
183
|
+
filesystems — a rule written for `jira_issue:SEC-*` will not cover `sec-1`, and
|
|
184
|
+
nothing will tell you. Normalise the id where you build it:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
@guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
That is what the callable form is for; [§3.3](docs/obstat-spec.md#33-resource-resolution)
|
|
191
|
+
has the whole story, including why obstat does not fold case for you.
|
|
192
|
+
|
|
176
193
|
## The order
|
|
177
194
|
|
|
178
195
|
```
|
|
@@ -197,6 +214,8 @@ reads "authorised, outcome unknown", which is the honest state; paying for a sec
|
|
|
197
214
|
## Operator commands
|
|
198
215
|
|
|
199
216
|
```console
|
|
217
|
+
obstat init # a starter policy; refuses to overwrite one
|
|
218
|
+
obstat check <tool> [res] # what the policy would decide, without a call
|
|
200
219
|
obstat pending # approvals waiting on a human
|
|
201
220
|
obstat approve <id> [--by] # decide
|
|
202
221
|
obstat deny <id> [--by]
|
|
@@ -209,14 +228,34 @@ obstat resume
|
|
|
209
228
|
`obstat stop` is checked before policy, so stopping never depends on the policy
|
|
210
229
|
file still being parseable.
|
|
211
230
|
|
|
212
|
-
|
|
231
|
+
`obstat check` exits 0 for an allow and 1 for anything else, so a policy can be
|
|
232
|
+
tested in CI — and a policy that does not parse is reported there rather than by
|
|
233
|
+
the next real call.
|
|
234
|
+
|
|
235
|
+
## Arguments
|
|
213
236
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
Fingerprinted by default, never stored: tool arguments carry credentials and
|
|
238
|
+
personal data, and a governance log that leaks them is a liability rather than a
|
|
239
|
+
control. Name the ones a human needs and those values are recorded too —
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
@guard(resource="tool:send_email", record_args=("to",))
|
|
243
|
+
def send_email(to: str, body: str) -> str: ...
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```console
|
|
247
|
+
$ obstat pending
|
|
248
|
+
d41b88f29a43 send_email human:ana tool:send_email 871s left
|
|
249
|
+
to = 'board@example.com'
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
— because an approver deciding about `sha256:ae32e6…` is deciding about nothing.
|
|
253
|
+
The digest still covers every argument; `body` is in it and nowhere else. Name
|
|
254
|
+
identifiers, not payloads.
|
|
255
|
+
|
|
256
|
+
## What is not here yet
|
|
218
257
|
|
|
219
|
-
|
|
258
|
+
Deliberately absent: retention and rotation of the log, Slack and webhook
|
|
220
259
|
approval channels, a policy for where a result may be *sent*, and anything that
|
|
221
260
|
talks to a cloud. Those belong at the edges, and the edges should be adapters
|
|
222
261
|
rather than dependencies.
|
|
@@ -229,6 +268,7 @@ rather than dependencies.
|
|
|
229
268
|
| `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
|
|
230
269
|
| `OBSTAT_DB` | `.obstat/approvals.db` |
|
|
231
270
|
| `OBSTAT_HALT` | `.obstat/halt` |
|
|
271
|
+
| `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
|
|
232
272
|
|
|
233
273
|
Read at call time, never at import. A library that raises on import is a library
|
|
234
274
|
you cannot try.
|
|
@@ -26,6 +26,11 @@ is still weak.
|
|
|
26
26
|
|
|
27
27
|
## 60 seconds
|
|
28
28
|
|
|
29
|
+
```console
|
|
30
|
+
$ obstat init
|
|
31
|
+
wrote obstat.toml — everything is denied until you uncomment a rule
|
|
32
|
+
```
|
|
33
|
+
|
|
29
34
|
`obstat.toml`:
|
|
30
35
|
|
|
31
36
|
```toml
|
|
@@ -152,6 +157,18 @@ permission, and a missing policy file is an error rather than an implicit allow.
|
|
|
152
157
|
Patterns are globs. The file is re-read when it changes, so editing policy does
|
|
153
158
|
not need a restart.
|
|
154
159
|
|
|
160
|
+
They are matched **case-sensitively**, against an id built from arguments the
|
|
161
|
+
caller sent. If your namespace is not case-sensitive — Jira keys, most
|
|
162
|
+
filesystems — a rule written for `jira_issue:SEC-*` will not cover `sec-1`, and
|
|
163
|
+
nothing will tell you. Normalise the id where you build it:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
@guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
That is what the callable form is for; [§3.3](docs/obstat-spec.md#33-resource-resolution)
|
|
170
|
+
has the whole story, including why obstat does not fold case for you.
|
|
171
|
+
|
|
155
172
|
## The order
|
|
156
173
|
|
|
157
174
|
```
|
|
@@ -176,6 +193,8 @@ reads "authorised, outcome unknown", which is the honest state; paying for a sec
|
|
|
176
193
|
## Operator commands
|
|
177
194
|
|
|
178
195
|
```console
|
|
196
|
+
obstat init # a starter policy; refuses to overwrite one
|
|
197
|
+
obstat check <tool> [res] # what the policy would decide, without a call
|
|
179
198
|
obstat pending # approvals waiting on a human
|
|
180
199
|
obstat approve <id> [--by] # decide
|
|
181
200
|
obstat deny <id> [--by]
|
|
@@ -188,14 +207,34 @@ obstat resume
|
|
|
188
207
|
`obstat stop` is checked before policy, so stopping never depends on the policy
|
|
189
208
|
file still being parseable.
|
|
190
209
|
|
|
191
|
-
|
|
210
|
+
`obstat check` exits 0 for an allow and 1 for anything else, so a policy can be
|
|
211
|
+
tested in CI — and a policy that does not parse is reported there rather than by
|
|
212
|
+
the next real call.
|
|
213
|
+
|
|
214
|
+
## Arguments
|
|
192
215
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
216
|
+
Fingerprinted by default, never stored: tool arguments carry credentials and
|
|
217
|
+
personal data, and a governance log that leaks them is a liability rather than a
|
|
218
|
+
control. Name the ones a human needs and those values are recorded too —
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
@guard(resource="tool:send_email", record_args=("to",))
|
|
222
|
+
def send_email(to: str, body: str) -> str: ...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```console
|
|
226
|
+
$ obstat pending
|
|
227
|
+
d41b88f29a43 send_email human:ana tool:send_email 871s left
|
|
228
|
+
to = 'board@example.com'
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
— because an approver deciding about `sha256:ae32e6…` is deciding about nothing.
|
|
232
|
+
The digest still covers every argument; `body` is in it and nowhere else. Name
|
|
233
|
+
identifiers, not payloads.
|
|
234
|
+
|
|
235
|
+
## What is not here yet
|
|
197
236
|
|
|
198
|
-
|
|
237
|
+
Deliberately absent: retention and rotation of the log, Slack and webhook
|
|
199
238
|
approval channels, a policy for where a result may be *sent*, and anything that
|
|
200
239
|
talks to a cloud. Those belong at the edges, and the edges should be adapters
|
|
201
240
|
rather than dependencies.
|
|
@@ -208,6 +247,7 @@ rather than dependencies.
|
|
|
208
247
|
| `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
|
|
209
248
|
| `OBSTAT_DB` | `.obstat/approvals.db` |
|
|
210
249
|
| `OBSTAT_HALT` | `.obstat/halt` |
|
|
250
|
+
| `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
|
|
211
251
|
|
|
212
252
|
Read at call time, never at import. A library that raises on import is a library
|
|
213
253
|
you cannot try.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# obstat — specification
|
|
2
2
|
|
|
3
|
-
Normative. Where this document and the code disagree, one of them
|
|
4
|
-
which in an issue.
|
|
3
|
+
Normative, §10 excepted. Where this document and the code disagree, one of them
|
|
4
|
+
is a bug; say which in an issue.
|
|
5
5
|
|
|
6
6
|
The claim obstat makes is narrow: **a tool call leaves a written decision before
|
|
7
7
|
it runs.** Everything below exists to make that claim true without an identity
|
|
@@ -109,6 +109,28 @@ effect on the next call rather than the next restart. The cost is one `stat()`
|
|
|
109
109
|
per call, which is cheaper than the class of incident where someone tightened a
|
|
110
110
|
rule, saw no change, and concluded the tightening was wrong.
|
|
111
111
|
|
|
112
|
+
### 2.3 Writing one, and asking what it would do
|
|
113
|
+
|
|
114
|
+
```console
|
|
115
|
+
obstat init # a starter obstat.toml
|
|
116
|
+
obstat check delete_document doc:q3-report # deny (rule 0)
|
|
117
|
+
obstat check read_doc --subject human:ana # allow (rule 1)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`init` writes every rule commented out and one live `deny`, so a policy nobody
|
|
121
|
+
finished editing permits nothing. It refuses to overwrite an existing file: that
|
|
122
|
+
file is the only thing standing between an agent and every guarded tool.
|
|
123
|
+
|
|
124
|
+
`check` evaluates §2 against the file on disk and prints the effect with the rule
|
|
125
|
+
that produced it. The resource argument is optional and defaults to
|
|
126
|
+
`tool:<name>`, the same default §3.3 applies. Exit status is 0 for `allow` and 1
|
|
127
|
+
for anything else, including a policy that does not parse — which is otherwise
|
|
128
|
+
discovered by the next real call, in front of a real agent.
|
|
129
|
+
|
|
130
|
+
It writes no record, resolves no resource and touches no approval. It answers
|
|
131
|
+
what the policy *would* say, which is the question §2.2's reload leaves a reader
|
|
132
|
+
unable to ask.
|
|
133
|
+
|
|
112
134
|
---
|
|
113
135
|
|
|
114
136
|
## 3. `@guard`
|
|
@@ -118,6 +140,7 @@ def guard(
|
|
|
118
140
|
*,
|
|
119
141
|
resource: str | Callable[[dict[str, Any]], str] | None = None,
|
|
120
142
|
tool: str | None = None,
|
|
143
|
+
record_args: tuple[str, ...] = (),
|
|
121
144
|
) -> Callable[[F], F]: ...
|
|
122
145
|
```
|
|
123
146
|
|
|
@@ -149,8 +172,10 @@ Normative. Each step's failure is terminal.
|
|
|
149
172
|
**Step 6 before step 7 is the whole point.** `record.decision()` returns only
|
|
150
173
|
after `write` and `fsync` — and, on the write that creates the log file, an
|
|
151
174
|
`fsync` of the containing directory too, since a synced file whose directory
|
|
152
|
-
entry is not synced can survive a crash with nothing pointing at it.
|
|
153
|
-
|
|
175
|
+
entry is not synced can survive a crash with nothing pointing at it. That
|
|
176
|
+
directory sync opens a directory as a file descriptor, which POSIX allows and
|
|
177
|
+
Windows does not; where it cannot be opened it is skipped, and §8 says what that
|
|
178
|
+
costs. If the process dies between 6 and 7, the
|
|
154
179
|
record says `allow` and no outcome follows, which reads as "authorised, did not
|
|
155
180
|
complete" — a state an examiner can act on. The reverse ordering produces
|
|
156
181
|
"executed, no idea whether it was allowed", which is the state that costs an
|
|
@@ -194,6 +219,37 @@ policy needs to distinguish. A template that raises (`KeyError`, `IndexError`,
|
|
|
194
219
|
`AttributeError`, `TypeError`) denies with the resource recorded as `unresolved`.
|
|
195
220
|
It never falls back to a wildcard.
|
|
196
221
|
|
|
222
|
+
#### The id is caller-controlled text, matched byte for byte
|
|
223
|
+
|
|
224
|
+
A template interpolates arguments the caller supplied, and §2 matches the result
|
|
225
|
+
with a case-sensitive glob. Where the system behind the tool treats two spellings
|
|
226
|
+
as one object, the policy does not:
|
|
227
|
+
|
|
228
|
+
```toml
|
|
229
|
+
[[rule]]
|
|
230
|
+
resource = "jira_issue:SEC-*"
|
|
231
|
+
effect = "deny"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`transition_issue("SEC-1", …)` is denied. `transition_issue("sec-1", …)` is
|
|
235
|
+
allowed, and Jira resolves both to the same issue.
|
|
236
|
+
|
|
237
|
+
obstat does not fix this centrally, and folding case for everyone would be worse
|
|
238
|
+
than the problem: whether `ACME-1` and `acme-1` name one thing is a fact about
|
|
239
|
+
the caller's namespace, and case-folding one that is genuinely case-sensitive
|
|
240
|
+
would silently widen every `allow` rule to cover objects it was never written
|
|
241
|
+
for. **Normalising is the tool author's job.** It is what the callable form
|
|
242
|
+
exists for:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
@guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Normalising the id does not loosen the approval binding. §5.2 binds an approval
|
|
249
|
+
to `args_digest`, which is taken over the raw arguments, so an approval granted
|
|
250
|
+
for `SEC-1` is refused when it is retried as `sec-1` — `approval_mismatch` —
|
|
251
|
+
even though both produce the same resource.
|
|
252
|
+
|
|
197
253
|
### 3.4 The stop file
|
|
198
254
|
|
|
199
255
|
Every other control answers "may this call proceed". This one answers "is
|
|
@@ -245,10 +301,20 @@ The wrapper's `__signature__` is the wrapped function's, minus `subject`, plus:
|
|
|
245
301
|
obstat_approval_id: str | None = None # keyword-only
|
|
246
302
|
```
|
|
247
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
|
+
|
|
248
312
|
MCP servers generate their tool schema from that signature, so a client sees the
|
|
249
313
|
approval argument — it needs to, for §5.2 — and cannot see `subject`. Verified
|
|
250
|
-
against the MCP SDK
|
|
251
|
-
`['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.
|
|
252
318
|
|
|
253
319
|
---
|
|
254
320
|
|
|
@@ -264,8 +330,8 @@ Policy said `approve` and no usable approval was supplied. obstat:
|
|
|
264
330
|
1. Derives the approval id: `sha256(tool | subject | resource | args_digest)[:12]`.
|
|
265
331
|
2. Writes a decision record with `effect: "approval_required"`, naming that id.
|
|
266
332
|
3. Inserts a `pending` row bound to `(tool, subject, resource, args_digest)` with
|
|
267
|
-
`expires = now +
|
|
268
|
-
`pending` or `approved` and unexpired.
|
|
333
|
+
`expires = now + OBSTAT_APPROVAL_TTL` (§7, 900s by default) — **or rejoins**
|
|
334
|
+
the row already there, if one is still `pending` or `approved` and unexpired.
|
|
269
335
|
4. Returns — **does not raise** — this value:
|
|
270
336
|
|
|
271
337
|
```python
|
|
@@ -280,9 +346,21 @@ Policy said `approve` and no usable approval was supplied. obstat:
|
|
|
280
346
|
```
|
|
281
347
|
|
|
282
348
|
A return rather than an error, so the agent can reason about it and tell the user
|
|
283
|
-
what it is waiting for instead of treating a working control as a failure.
|
|
284
|
-
|
|
285
|
-
|
|
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".
|
|
354
|
+
|
|
355
|
+
The approvals table holds the argument *digest*, so `obstat pending` reads what
|
|
356
|
+
the call was for off the record named in step 2 — anything the tool recorded
|
|
357
|
+
under §6.1 is printed beneath the row:
|
|
358
|
+
|
|
359
|
+
```console
|
|
360
|
+
$ obstat pending
|
|
361
|
+
d41b88f29a43 send_email human:ana tool:send_email 871s left
|
|
362
|
+
to = 'board@example.com'
|
|
363
|
+
```
|
|
286
364
|
|
|
287
365
|
**The id is derived, not invented.** A random id would mean every retry opens
|
|
288
366
|
another approval, so an agent polling a slow human produces a queue of identical
|
|
@@ -379,7 +457,7 @@ fragment, so one failed write costs one record instead of two — `obstat verify
|
|
|
379
457
|
reports the fragment and everything after it still reads.
|
|
380
458
|
|
|
381
459
|
```json
|
|
382
|
-
{"schema":
|
|
460
|
+
{"schema": 4,
|
|
383
461
|
"id": "6430f1a38f8e470a898ca9a116dfb4cc",
|
|
384
462
|
"ts": 1785545412.241792,
|
|
385
463
|
"phase": "decision",
|
|
@@ -401,27 +479,61 @@ reports the fragment and everything after it still reads.
|
|
|
401
479
|
the policy file, so a record points at the line that decided it. `prev` and
|
|
402
480
|
`hash` are the chain (§6.3).
|
|
403
481
|
|
|
404
|
-
Schema 2 added `prev` and `hash`; schema 3 added `code
|
|
405
|
-
schema 1 have no chain fields and
|
|
406
|
-
— a log that predates the chain is
|
|
482
|
+
Schema 2 added `prev` and `hash`; schema 3 added `code`; schema 4 added
|
|
483
|
+
`args_recorded` (§6.1). Records written at schema 1 have no chain fields and
|
|
484
|
+
verify as *unverifiable* rather than as damaged — a log that predates the chain is
|
|
485
|
+
not evidence of tampering.
|
|
407
486
|
|
|
408
487
|
`via` (§1) is present on an `allow` record only when the subject carries a
|
|
409
488
|
delegation chain: `"via": ["human:ana"]`. It is omitted rather than written empty,
|
|
410
489
|
because `"via": []` on every record of every deployment that never delegates is
|
|
411
|
-
noise in the thing an examiner has to read.
|
|
490
|
+
noise in the thing an examiner has to read. `args_recorded` is omitted on the
|
|
491
|
+
same grounds.
|
|
412
492
|
|
|
413
|
-
### 6.1 Arguments are fingerprinted,
|
|
493
|
+
### 6.1 Arguments are fingerprinted, and named values are recorded
|
|
414
494
|
|
|
415
495
|
`args` is `sha256:` over the canonical JSON of the bound arguments. The values
|
|
416
|
-
are
|
|
496
|
+
are absent by default: tool arguments carry credentials, personal data and free
|
|
417
497
|
text, and a governance log that leaks them is a liability rather than a control.
|
|
418
498
|
The digest is enough to prove the call that executed is the call that was
|
|
419
499
|
approved, which is what the record is for.
|
|
420
500
|
|
|
501
|
+
A digest is not enough for the *other* reader. An approver deciding about
|
|
502
|
+
`sha256:ae32e6…` is deciding about nothing, and the resource id only sometimes
|
|
503
|
+
carries the answer — `doc:q3-report` does, `tool:send_email` does not. So a tool
|
|
504
|
+
may name parameters whose values are recorded:
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
@guard(record_args=("to", "issue_key"))
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
```json
|
|
511
|
+
"args_recorded": {"to": "ana@example.com", "issue_key": "ACME-14"}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Normative:
|
|
515
|
+
|
|
516
|
+
- The allowlist is **explicit and per tool**. There is no wildcard and no
|
|
517
|
+
"record everything" switch — the absent default is the safe one, and a
|
|
518
|
+
deployment that wants values names them.
|
|
519
|
+
- Naming a parameter the tool does not have is a `TypeError` **at decoration**.
|
|
520
|
+
A name that matched nothing would record nothing and say nothing about it,
|
|
521
|
+
which is the same quiet widening §2 refuses from a typo'd policy key.
|
|
522
|
+
- `args` still covers **every** argument. Recorded values are written beside the
|
|
523
|
+
digest, never instead of it, so the binding an approval rests on (§5.2) is
|
|
524
|
+
unchanged by what anyone chose to display.
|
|
525
|
+
- The field is omitted when nothing was named, on the §6 grounds `via` is.
|
|
526
|
+
- It appears on `allow`, `deny` and `approval_required` records alike: an
|
|
527
|
+
examiner asking what was refused deserves the same answer as an approver
|
|
528
|
+
asking what is waiting.
|
|
529
|
+
|
|
530
|
+
Name identifiers, not payloads. The point is that a human can tell which issue,
|
|
531
|
+
which document, which recipient — not that the log holds a copy of the message.
|
|
532
|
+
|
|
421
533
|
### 6.2 Outcome records
|
|
422
534
|
|
|
423
535
|
```json
|
|
424
|
-
{"schema":
|
|
536
|
+
{"schema": 4, "id": "<same id>", "ts": …, "phase": "outcome", "ok": true, "error": null,
|
|
425
537
|
"prev": "…", "hash": "…"}
|
|
426
538
|
```
|
|
427
539
|
|
|
@@ -523,6 +635,12 @@ is a library nobody can try.
|
|
|
523
635
|
| `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
|
|
524
636
|
| `OBSTAT_DB` | `.obstat/approvals.db` |
|
|
525
637
|
| `OBSTAT_HALT` | `.obstat/halt` |
|
|
638
|
+
| `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
|
|
639
|
+
|
|
640
|
+
A TTL that is not a positive number raises rather than falling back to the
|
|
641
|
+
default. An approval window of zero turns every `approve` rule into a call that
|
|
642
|
+
asks, expires and asks again, and a silent fallback is how that gets diagnosed as
|
|
643
|
+
obstat being broken instead of as a typo.
|
|
526
644
|
|
|
527
645
|
---
|
|
528
646
|
|
|
@@ -530,10 +648,23 @@ is a library nobody can try.
|
|
|
530
648
|
|
|
531
649
|
Ranked by how much they weaken the claim in the first paragraph.
|
|
532
650
|
|
|
651
|
+
**A resource id is caller-controlled text and nothing normalises it.** §3.3. A
|
|
652
|
+
policy written against `jira_issue:SEC-*` says nothing about `sec-1`, and the
|
|
653
|
+
cost of noticing is a denial that silently did not happen. The callable form of
|
|
654
|
+
`resource` is the entire answer today, which means the answer is applied only
|
|
655
|
+
where a tool author thought of the question. What is missing is not a fold — that
|
|
656
|
+
would be wrong for case-sensitive namespaces — but a way for a *policy* to say
|
|
657
|
+
which of its rules describe a namespace that is case-insensitive.
|
|
658
|
+
|
|
533
659
|
**No approver authority.** §5.4. Anything beyond a single operator needs the
|
|
534
660
|
approver to be authenticated and distinct from the requester.
|
|
535
661
|
|
|
536
|
-
**
|
|
662
|
+
**An approver sees only what the tool chose to show.** §6.1 gives a tool
|
|
663
|
+
`record_args`, and `obstat pending` prints it — but a tool that names nothing
|
|
664
|
+
still sends a human a digest and a resource id to decide on. Nothing warns an
|
|
665
|
+
operator that a rule with `effect = "approve"` sits in front of a tool that
|
|
666
|
+
records no values, which is the configuration where the approval is a rubber
|
|
667
|
+
stamp.
|
|
537
668
|
|
|
538
669
|
**The chain is unkeyed, and nothing anchors its head.** §6.3 makes an edit or a
|
|
539
670
|
deletion visible, which is where most of the value is — but anyone who can write
|
|
@@ -542,8 +673,19 @@ dangling. The answers are a head published where the log's owner cannot reach it
|
|
|
542
673
|
or append-only storage. Neither is here, so this is tamper-evidence and not proof
|
|
543
674
|
against the operator.
|
|
544
675
|
|
|
676
|
+
**The stop file is all-or-nothing.** §3.4.
|
|
677
|
+
|
|
545
678
|
**No retention or rotation on the log.** It grows without bound, and nothing says
|
|
546
|
-
how long a record must be kept.
|
|
679
|
+
how long a record must be kept. Rotation has to carry the chain across files as
|
|
680
|
+
well, or every new file begins at `prev: null` and a deletion at the seam is
|
|
681
|
+
indistinguishable from a rollover.
|
|
682
|
+
|
|
683
|
+
**The directory sync does not happen on Windows.** It opens a directory as a file
|
|
684
|
+
descriptor, which Windows refuses, so there it is skipped rather than performed
|
|
685
|
+
(§3.1) and the guarantee it exists for — that the log's own directory entry
|
|
686
|
+
survives a crash — is unavailable on that platform. The record's `fsync` still
|
|
687
|
+
happens, so this costs the *first* write of a new log file and nothing after it.
|
|
688
|
+
A platform that can open the directory and then fails to sync it still raises.
|
|
547
689
|
|
|
548
690
|
**No egress control.** Nothing asks where a result is allowed to go, which is the
|
|
549
691
|
control that matters for tools that send mail or post to channels.
|
|
@@ -556,10 +698,14 @@ adapters, not dependencies.
|
|
|
556
698
|
## 9. Required tests
|
|
557
699
|
|
|
558
700
|
These encode the claims above; `tests/test_guard.py` is the current
|
|
559
|
-
implementation of them.
|
|
701
|
+
implementation of them, with §2.3's two commands in `tests/test_cli.py`.
|
|
560
702
|
|
|
561
703
|
| Property | Test |
|
|
562
704
|
|---|---|
|
|
705
|
+
| a starter policy parses and permits nothing | `test_init_writes_a_policy_that_permits_nothing` |
|
|
706
|
+
| `init` does not overwrite a policy | `test_init_will_not_overwrite_a_policy` |
|
|
707
|
+
| `check` names the rule that decided | `test_check_names_the_rule_that_decided` |
|
|
708
|
+
| a broken policy is reported, not raised | `test_check_reports_a_broken_policy_instead_of_raising` |
|
|
563
709
|
| the record is durable before the body runs | reads the log from inside the body and asserts its own decision is already there |
|
|
564
710
|
| an outcome follows a decision, sharing its id | `test_outcome_is_recorded_after` |
|
|
565
711
|
| a failing body still leaves both records | `test_a_failing_body_still_leaves_both_records` |
|
|
@@ -568,14 +714,27 @@ implementation of them.
|
|
|
568
714
|
| a caller cannot name itself | `test_a_caller_cannot_supply_its_own_subject` |
|
|
569
715
|
| a colliding `subject` parameter is refused at decoration | `test_a_subject_that_would_collide_is_refused_at_decoration` |
|
|
570
716
|
| a delegation chain reaches the record | `test_a_delegation_chain_reaches_the_record` |
|
|
717
|
+
| the approval window is configurable, and a useless one raises | `test_the_approval_window_is_configurable` |
|
|
571
718
|
| an edited record is caught | `TestChain::test_an_edited_record_is_caught` |
|
|
572
719
|
| a removed record is caught | `TestChain::test_a_removed_record_is_caught` |
|
|
573
720
|
| recomputing one hash does not hide the edit | `TestChain::test_a_reused_hash_does_not_hide_an_edit` |
|
|
574
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` |
|
|
575
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` |
|
|
576
727
|
| the resource comes from the arguments | `test_resource_comes_from_the_arguments` |
|
|
728
|
+
| a template matches only the spelling it was given | `test_a_template_matches_only_the_spelling_it_was_given` |
|
|
729
|
+
| a callable resource normalises what policy matches | `test_a_callable_resource_normalises_what_policy_matches` |
|
|
730
|
+
| an unknown effect is refused | `test_an_unknown_effect_is_refused` |
|
|
577
731
|
| an unresolvable resource denies | `test_an_unresolvable_resource_denies` |
|
|
578
732
|
| arguments are fingerprinted, not stored | `test_arguments_are_fingerprinted_not_stored` |
|
|
733
|
+
| only the named arguments are recorded | `test_only_the_named_arguments_are_recorded` |
|
|
734
|
+
| nothing is recorded unless it was named | `test_nothing_is_recorded_unless_it_was_named` |
|
|
735
|
+
| an argument that does not exist is refused at decoration | `test_recording_an_argument_that_does_not_exist_is_refused_at_decoration` |
|
|
736
|
+
| a denial records the named arguments | `test_a_denial_records_the_named_arguments` |
|
|
737
|
+
| an approver can see what they are approving | `test_pending_shows_what_is_being_approved` |
|
|
579
738
|
| the stop file denies, and lifting it restores | `test_the_stop_file_denies_everything` |
|
|
580
739
|
| approval is two-phase and does not run early | `TestApproval::test_first_call_asks_and_does_not_run` |
|
|
581
740
|
| approval is single-use | `TestApproval::test_retry_after_approval_runs_once` |
|
|
@@ -585,3 +744,50 @@ implementation of them.
|
|
|
585
744
|
| approval does not travel to another call | `TestApproval::test_an_approval_does_not_travel_to_another_call` |
|
|
586
745
|
| a denied or invented approval denies | `TestApproval::test_a_denied_approval_denies`, `…invented_approval_id…` |
|
|
587
746
|
| async tools take the same path | `test_async_tools_go_through_the_same_gate` |
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## 10. Direction
|
|
751
|
+
|
|
752
|
+
**Not normative. Nothing in this section is implemented**, and code that
|
|
753
|
+
disagrees with it is not a bug. It is here so a reader can tell a missing feature
|
|
754
|
+
from an undecided one: §8 is what is weak, this is what is next.
|
|
755
|
+
|
|
756
|
+
**Shadow mode, and a policy written from traffic.** Decisions recorded without
|
|
757
|
+
being enforced, so obstat can be installed in front of a live server for a week
|
|
758
|
+
before it denies anything — then `obstat suggest` reads the log back and emits
|
|
759
|
+
candidate rules. Policy authoring is the barrier to adoption, and an empty file
|
|
760
|
+
is not a starting point.
|
|
761
|
+
|
|
762
|
+
**Counterfactual replay.** `obstat check --policy new.toml --against
|
|
763
|
+
decisions.jsonl`: run decisions already recorded through a proposed policy and
|
|
764
|
+
print only what changes. Answers "I tightened a rule, what breaks" from real
|
|
765
|
+
traffic rather than from imagination, and needs nothing that §2 and §6 do not
|
|
766
|
+
already provide.
|
|
767
|
+
|
|
768
|
+
**An evidence pack.** One command producing what a controls walkthrough asks
|
|
769
|
+
for over a period: every call that required an approval, who decided it and
|
|
770
|
+
when, every denial, and the result of §6.3 across the same range. The log is
|
|
771
|
+
already the input; what is missing is the deliverable.
|
|
772
|
+
|
|
773
|
+
**An anchored head.** §8. Counter-signing the chain head somewhere the log's
|
|
774
|
+
owner cannot reach is the difference between tamper-evidence and
|
|
775
|
+
non-repudiation, and it is the only item here that changes what obstat can
|
|
776
|
+
*claim*.
|
|
777
|
+
|
|
778
|
+
**Result digests.** §6.1 fingerprints the arguments, which proves the call that
|
|
779
|
+
ran is the call that was approved. The same over the return value would say
|
|
780
|
+
something about what came back, and is the groundwork any egress rule needs.
|
|
781
|
+
|
|
782
|
+
**Break-glass with a retrospective.** The stop file's opposite: one call
|
|
783
|
+
permitted against policy, recorded as an override, and listed as outstanding
|
|
784
|
+
until somebody signs it off. That it is visible matters more than that it is
|
|
785
|
+
rare.
|
|
786
|
+
|
|
787
|
+
**The record as a format rather than a library.** §6 is language-agnostic. A
|
|
788
|
+
second implementation writing the same lines, verified by the same rules, would
|
|
789
|
+
make this document the product and the Python package one implementation of it.
|
|
790
|
+
|
|
791
|
+
Also wanted and unremarkable: approval channels beyond the CLI (§8), per-subject
|
|
792
|
+
call budgets as a rule key, and a trace id on the record so it joins traces a
|
|
793
|
+
host already collects.
|