obstat 0.2.0__tar.gz → 0.3.0__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.0}/.github/workflows/ci.yml +8 -1
- {obstat-0.2.0 → obstat-0.3.0}/PKG-INFO +47 -7
- {obstat-0.2.0 → obstat-0.3.0}/README.md +46 -6
- {obstat-0.2.0 → obstat-0.3.0}/docs/obstat-spec.md +207 -17
- {obstat-0.2.0 → obstat-0.3.0}/obstat/__init__.py +1 -1
- obstat-0.3.0/obstat/__main__.py +184 -0
- {obstat-0.2.0 → obstat-0.3.0}/obstat/approval.py +34 -6
- {obstat-0.2.0 → obstat-0.3.0}/obstat/guard.py +47 -13
- {obstat-0.2.0 → obstat-0.3.0}/obstat/policy.py +3 -2
- {obstat-0.2.0 → obstat-0.3.0}/obstat/record.py +12 -4
- {obstat-0.2.0 → obstat-0.3.0}/pyproject.toml +1 -1
- obstat-0.3.0/tests/test_cli.py +75 -0
- {obstat-0.2.0 → obstat-0.3.0}/tests/test_guard.py +121 -0
- {obstat-0.2.0 → obstat-0.3.0}/uv.lock +1 -1
- obstat-0.2.0/obstat/__main__.py +0 -98
- {obstat-0.2.0 → obstat-0.3.0}/.github/workflows/publish.yml +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/.gitignore +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/CLAUDE.md +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/LICENSE +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/examples/server.py +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/obstat/paths.py +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/obstat.toml +0 -0
- {obstat-0.2.0 → obstat-0.3.0}/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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: obstat
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -264,8 +320,8 @@ Policy said `approve` and no usable approval was supplied. obstat:
|
|
|
264
320
|
1. Derives the approval id: `sha256(tool | subject | resource | args_digest)[:12]`.
|
|
265
321
|
2. Writes a decision record with `effect: "approval_required"`, naming that id.
|
|
266
322
|
3. Inserts a `pending` row bound to `(tool, subject, resource, args_digest)` with
|
|
267
|
-
`expires = now +
|
|
268
|
-
`pending` or `approved` and unexpired.
|
|
323
|
+
`expires = now + OBSTAT_APPROVAL_TTL` (§7, 900s by default) — **or rejoins**
|
|
324
|
+
the row already there, if one is still `pending` or `approved` and unexpired.
|
|
269
325
|
4. Returns — **does not raise** — this value:
|
|
270
326
|
|
|
271
327
|
```python
|
|
@@ -284,6 +340,16 @@ what it is waiting for instead of treating a working control as a failure.
|
|
|
284
340
|
`waiting` is `True` when this rejoined an existing request, which is how an agent
|
|
285
341
|
distinguishes "asked" from "asked again".
|
|
286
342
|
|
|
343
|
+
The approvals table holds the argument *digest*, so `obstat pending` reads what
|
|
344
|
+
the call was for off the record named in step 2 — anything the tool recorded
|
|
345
|
+
under §6.1 is printed beneath the row:
|
|
346
|
+
|
|
347
|
+
```console
|
|
348
|
+
$ obstat pending
|
|
349
|
+
d41b88f29a43 send_email human:ana tool:send_email 871s left
|
|
350
|
+
to = 'board@example.com'
|
|
351
|
+
```
|
|
352
|
+
|
|
287
353
|
**The id is derived, not invented.** A random id would mean every retry opens
|
|
288
354
|
another approval, so an agent polling a slow human produces a queue of identical
|
|
289
355
|
requests for that human to sort out — and the pile-up is worst exactly when
|
|
@@ -379,7 +445,7 @@ fragment, so one failed write costs one record instead of two — `obstat verify
|
|
|
379
445
|
reports the fragment and everything after it still reads.
|
|
380
446
|
|
|
381
447
|
```json
|
|
382
|
-
{"schema":
|
|
448
|
+
{"schema": 4,
|
|
383
449
|
"id": "6430f1a38f8e470a898ca9a116dfb4cc",
|
|
384
450
|
"ts": 1785545412.241792,
|
|
385
451
|
"phase": "decision",
|
|
@@ -401,27 +467,61 @@ reports the fragment and everything after it still reads.
|
|
|
401
467
|
the policy file, so a record points at the line that decided it. `prev` and
|
|
402
468
|
`hash` are the chain (§6.3).
|
|
403
469
|
|
|
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
|
|
470
|
+
Schema 2 added `prev` and `hash`; schema 3 added `code`; schema 4 added
|
|
471
|
+
`args_recorded` (§6.1). Records written at schema 1 have no chain fields and
|
|
472
|
+
verify as *unverifiable* rather than as damaged — a log that predates the chain is
|
|
473
|
+
not evidence of tampering.
|
|
407
474
|
|
|
408
475
|
`via` (§1) is present on an `allow` record only when the subject carries a
|
|
409
476
|
delegation chain: `"via": ["human:ana"]`. It is omitted rather than written empty,
|
|
410
477
|
because `"via": []` on every record of every deployment that never delegates is
|
|
411
|
-
noise in the thing an examiner has to read.
|
|
478
|
+
noise in the thing an examiner has to read. `args_recorded` is omitted on the
|
|
479
|
+
same grounds.
|
|
412
480
|
|
|
413
|
-
### 6.1 Arguments are fingerprinted,
|
|
481
|
+
### 6.1 Arguments are fingerprinted, and named values are recorded
|
|
414
482
|
|
|
415
483
|
`args` is `sha256:` over the canonical JSON of the bound arguments. The values
|
|
416
|
-
are
|
|
484
|
+
are absent by default: tool arguments carry credentials, personal data and free
|
|
417
485
|
text, and a governance log that leaks them is a liability rather than a control.
|
|
418
486
|
The digest is enough to prove the call that executed is the call that was
|
|
419
487
|
approved, which is what the record is for.
|
|
420
488
|
|
|
489
|
+
A digest is not enough for the *other* reader. An approver deciding about
|
|
490
|
+
`sha256:ae32e6…` is deciding about nothing, and the resource id only sometimes
|
|
491
|
+
carries the answer — `doc:q3-report` does, `tool:send_email` does not. So a tool
|
|
492
|
+
may name parameters whose values are recorded:
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
@guard(record_args=("to", "issue_key"))
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
```json
|
|
499
|
+
"args_recorded": {"to": "ana@example.com", "issue_key": "ACME-14"}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Normative:
|
|
503
|
+
|
|
504
|
+
- The allowlist is **explicit and per tool**. There is no wildcard and no
|
|
505
|
+
"record everything" switch — the absent default is the safe one, and a
|
|
506
|
+
deployment that wants values names them.
|
|
507
|
+
- Naming a parameter the tool does not have is a `TypeError` **at decoration**.
|
|
508
|
+
A name that matched nothing would record nothing and say nothing about it,
|
|
509
|
+
which is the same quiet widening §2 refuses from a typo'd policy key.
|
|
510
|
+
- `args` still covers **every** argument. Recorded values are written beside the
|
|
511
|
+
digest, never instead of it, so the binding an approval rests on (§5.2) is
|
|
512
|
+
unchanged by what anyone chose to display.
|
|
513
|
+
- The field is omitted when nothing was named, on the §6 grounds `via` is.
|
|
514
|
+
- It appears on `allow`, `deny` and `approval_required` records alike: an
|
|
515
|
+
examiner asking what was refused deserves the same answer as an approver
|
|
516
|
+
asking what is waiting.
|
|
517
|
+
|
|
518
|
+
Name identifiers, not payloads. The point is that a human can tell which issue,
|
|
519
|
+
which document, which recipient — not that the log holds a copy of the message.
|
|
520
|
+
|
|
421
521
|
### 6.2 Outcome records
|
|
422
522
|
|
|
423
523
|
```json
|
|
424
|
-
{"schema":
|
|
524
|
+
{"schema": 4, "id": "<same id>", "ts": …, "phase": "outcome", "ok": true, "error": null,
|
|
425
525
|
"prev": "…", "hash": "…"}
|
|
426
526
|
```
|
|
427
527
|
|
|
@@ -523,6 +623,12 @@ is a library nobody can try.
|
|
|
523
623
|
| `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
|
|
524
624
|
| `OBSTAT_DB` | `.obstat/approvals.db` |
|
|
525
625
|
| `OBSTAT_HALT` | `.obstat/halt` |
|
|
626
|
+
| `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
|
|
627
|
+
|
|
628
|
+
A TTL that is not a positive number raises rather than falling back to the
|
|
629
|
+
default. An approval window of zero turns every `approve` rule into a call that
|
|
630
|
+
asks, expires and asks again, and a silent fallback is how that gets diagnosed as
|
|
631
|
+
obstat being broken instead of as a typo.
|
|
526
632
|
|
|
527
633
|
---
|
|
528
634
|
|
|
@@ -530,10 +636,23 @@ is a library nobody can try.
|
|
|
530
636
|
|
|
531
637
|
Ranked by how much they weaken the claim in the first paragraph.
|
|
532
638
|
|
|
639
|
+
**A resource id is caller-controlled text and nothing normalises it.** §3.3. A
|
|
640
|
+
policy written against `jira_issue:SEC-*` says nothing about `sec-1`, and the
|
|
641
|
+
cost of noticing is a denial that silently did not happen. The callable form of
|
|
642
|
+
`resource` is the entire answer today, which means the answer is applied only
|
|
643
|
+
where a tool author thought of the question. What is missing is not a fold — that
|
|
644
|
+
would be wrong for case-sensitive namespaces — but a way for a *policy* to say
|
|
645
|
+
which of its rules describe a namespace that is case-insensitive.
|
|
646
|
+
|
|
533
647
|
**No approver authority.** §5.4. Anything beyond a single operator needs the
|
|
534
648
|
approver to be authenticated and distinct from the requester.
|
|
535
649
|
|
|
536
|
-
**
|
|
650
|
+
**An approver sees only what the tool chose to show.** §6.1 gives a tool
|
|
651
|
+
`record_args`, and `obstat pending` prints it — but a tool that names nothing
|
|
652
|
+
still sends a human a digest and a resource id to decide on. Nothing warns an
|
|
653
|
+
operator that a rule with `effect = "approve"` sits in front of a tool that
|
|
654
|
+
records no values, which is the configuration where the approval is a rubber
|
|
655
|
+
stamp.
|
|
537
656
|
|
|
538
657
|
**The chain is unkeyed, and nothing anchors its head.** §6.3 makes an edit or a
|
|
539
658
|
deletion visible, which is where most of the value is — but anyone who can write
|
|
@@ -542,8 +661,19 @@ dangling. The answers are a head published where the log's owner cannot reach it
|
|
|
542
661
|
or append-only storage. Neither is here, so this is tamper-evidence and not proof
|
|
543
662
|
against the operator.
|
|
544
663
|
|
|
664
|
+
**The stop file is all-or-nothing.** §3.4.
|
|
665
|
+
|
|
545
666
|
**No retention or rotation on the log.** It grows without bound, and nothing says
|
|
546
|
-
how long a record must be kept.
|
|
667
|
+
how long a record must be kept. Rotation has to carry the chain across files as
|
|
668
|
+
well, or every new file begins at `prev: null` and a deletion at the seam is
|
|
669
|
+
indistinguishable from a rollover.
|
|
670
|
+
|
|
671
|
+
**The directory sync does not happen on Windows.** It opens a directory as a file
|
|
672
|
+
descriptor, which Windows refuses, so there it is skipped rather than performed
|
|
673
|
+
(§3.1) and the guarantee it exists for — that the log's own directory entry
|
|
674
|
+
survives a crash — is unavailable on that platform. The record's `fsync` still
|
|
675
|
+
happens, so this costs the *first* write of a new log file and nothing after it.
|
|
676
|
+
A platform that can open the directory and then fails to sync it still raises.
|
|
547
677
|
|
|
548
678
|
**No egress control.** Nothing asks where a result is allowed to go, which is the
|
|
549
679
|
control that matters for tools that send mail or post to channels.
|
|
@@ -556,10 +686,14 @@ adapters, not dependencies.
|
|
|
556
686
|
## 9. Required tests
|
|
557
687
|
|
|
558
688
|
These encode the claims above; `tests/test_guard.py` is the current
|
|
559
|
-
implementation of them.
|
|
689
|
+
implementation of them, with §2.3's two commands in `tests/test_cli.py`.
|
|
560
690
|
|
|
561
691
|
| Property | Test |
|
|
562
692
|
|---|---|
|
|
693
|
+
| a starter policy parses and permits nothing | `test_init_writes_a_policy_that_permits_nothing` |
|
|
694
|
+
| `init` does not overwrite a policy | `test_init_will_not_overwrite_a_policy` |
|
|
695
|
+
| `check` names the rule that decided | `test_check_names_the_rule_that_decided` |
|
|
696
|
+
| a broken policy is reported, not raised | `test_check_reports_a_broken_policy_instead_of_raising` |
|
|
563
697
|
| the record is durable before the body runs | reads the log from inside the body and asserts its own decision is already there |
|
|
564
698
|
| an outcome follows a decision, sharing its id | `test_outcome_is_recorded_after` |
|
|
565
699
|
| a failing body still leaves both records | `test_a_failing_body_still_leaves_both_records` |
|
|
@@ -568,14 +702,23 @@ implementation of them.
|
|
|
568
702
|
| a caller cannot name itself | `test_a_caller_cannot_supply_its_own_subject` |
|
|
569
703
|
| a colliding `subject` parameter is refused at decoration | `test_a_subject_that_would_collide_is_refused_at_decoration` |
|
|
570
704
|
| a delegation chain reaches the record | `test_a_delegation_chain_reaches_the_record` |
|
|
705
|
+
| the approval window is configurable, and a useless one raises | `test_the_approval_window_is_configurable` |
|
|
571
706
|
| an edited record is caught | `TestChain::test_an_edited_record_is_caught` |
|
|
572
707
|
| a removed record is caught | `TestChain::test_a_removed_record_is_caught` |
|
|
573
708
|
| recomputing one hash does not hide the edit | `TestChain::test_a_reused_hash_does_not_hide_an_edit` |
|
|
574
709
|
| a torn line does not swallow the next record | `TestChain::test_a_torn_line_does_not_swallow_the_next_record` |
|
|
575
710
|
| `subject` unadvertised, approval id advertised | `test_injected_subject_is_not_advertised…` |
|
|
576
711
|
| the resource comes from the arguments | `test_resource_comes_from_the_arguments` |
|
|
712
|
+
| a template matches only the spelling it was given | `test_a_template_matches_only_the_spelling_it_was_given` |
|
|
713
|
+
| a callable resource normalises what policy matches | `test_a_callable_resource_normalises_what_policy_matches` |
|
|
714
|
+
| an unknown effect is refused | `test_an_unknown_effect_is_refused` |
|
|
577
715
|
| an unresolvable resource denies | `test_an_unresolvable_resource_denies` |
|
|
578
716
|
| arguments are fingerprinted, not stored | `test_arguments_are_fingerprinted_not_stored` |
|
|
717
|
+
| only the named arguments are recorded | `test_only_the_named_arguments_are_recorded` |
|
|
718
|
+
| nothing is recorded unless it was named | `test_nothing_is_recorded_unless_it_was_named` |
|
|
719
|
+
| an argument that does not exist is refused at decoration | `test_recording_an_argument_that_does_not_exist_is_refused_at_decoration` |
|
|
720
|
+
| a denial records the named arguments | `test_a_denial_records_the_named_arguments` |
|
|
721
|
+
| an approver can see what they are approving | `test_pending_shows_what_is_being_approved` |
|
|
579
722
|
| the stop file denies, and lifting it restores | `test_the_stop_file_denies_everything` |
|
|
580
723
|
| approval is two-phase and does not run early | `TestApproval::test_first_call_asks_and_does_not_run` |
|
|
581
724
|
| approval is single-use | `TestApproval::test_retry_after_approval_runs_once` |
|
|
@@ -585,3 +728,50 @@ implementation of them.
|
|
|
585
728
|
| approval does not travel to another call | `TestApproval::test_an_approval_does_not_travel_to_another_call` |
|
|
586
729
|
| a denied or invented approval denies | `TestApproval::test_a_denied_approval_denies`, `…invented_approval_id…` |
|
|
587
730
|
| async tools take the same path | `test_async_tools_go_through_the_same_gate` |
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## 10. Direction
|
|
735
|
+
|
|
736
|
+
**Not normative. Nothing in this section is implemented**, and code that
|
|
737
|
+
disagrees with it is not a bug. It is here so a reader can tell a missing feature
|
|
738
|
+
from an undecided one: §8 is what is weak, this is what is next.
|
|
739
|
+
|
|
740
|
+
**Shadow mode, and a policy written from traffic.** Decisions recorded without
|
|
741
|
+
being enforced, so obstat can be installed in front of a live server for a week
|
|
742
|
+
before it denies anything — then `obstat suggest` reads the log back and emits
|
|
743
|
+
candidate rules. Policy authoring is the barrier to adoption, and an empty file
|
|
744
|
+
is not a starting point.
|
|
745
|
+
|
|
746
|
+
**Counterfactual replay.** `obstat check --policy new.toml --against
|
|
747
|
+
decisions.jsonl`: run decisions already recorded through a proposed policy and
|
|
748
|
+
print only what changes. Answers "I tightened a rule, what breaks" from real
|
|
749
|
+
traffic rather than from imagination, and needs nothing that §2 and §6 do not
|
|
750
|
+
already provide.
|
|
751
|
+
|
|
752
|
+
**An evidence pack.** One command producing what a controls walkthrough asks
|
|
753
|
+
for over a period: every call that required an approval, who decided it and
|
|
754
|
+
when, every denial, and the result of §6.3 across the same range. The log is
|
|
755
|
+
already the input; what is missing is the deliverable.
|
|
756
|
+
|
|
757
|
+
**An anchored head.** §8. Counter-signing the chain head somewhere the log's
|
|
758
|
+
owner cannot reach is the difference between tamper-evidence and
|
|
759
|
+
non-repudiation, and it is the only item here that changes what obstat can
|
|
760
|
+
*claim*.
|
|
761
|
+
|
|
762
|
+
**Result digests.** §6.1 fingerprints the arguments, which proves the call that
|
|
763
|
+
ran is the call that was approved. The same over the return value would say
|
|
764
|
+
something about what came back, and is the groundwork any egress rule needs.
|
|
765
|
+
|
|
766
|
+
**Break-glass with a retrospective.** The stop file's opposite: one call
|
|
767
|
+
permitted against policy, recorded as an override, and listed as outstanding
|
|
768
|
+
until somebody signs it off. That it is visible matters more than that it is
|
|
769
|
+
rare.
|
|
770
|
+
|
|
771
|
+
**The record as a format rather than a library.** §6 is language-agnostic. A
|
|
772
|
+
second implementation writing the same lines, verified by the same rules, would
|
|
773
|
+
make this document the product and the Python package one implementation of it.
|
|
774
|
+
|
|
775
|
+
Also wanted and unremarkable: approval channels beyond the CLI (§8), per-subject
|
|
776
|
+
call budgets as a rule key, and a trace id on the record so it joins traces a
|
|
777
|
+
host already collects.
|