custodian-codex-guard 0.1.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.
- custodian_codex_guard-0.1.0/LICENSE +21 -0
- custodian_codex_guard-0.1.0/PKG-INFO +118 -0
- custodian_codex_guard-0.1.0/README.md +92 -0
- custodian_codex_guard-0.1.0/custodian/codex_guard/__init__.py +9 -0
- custodian_codex_guard-0.1.0/custodian/codex_guard/approvals.py +273 -0
- custodian_codex_guard-0.1.0/custodian/codex_guard/cli.py +474 -0
- custodian_codex_guard-0.1.0/custodian/codex_guard/guard.py +285 -0
- custodian_codex_guard-0.1.0/custodian/codex_guard/mcp_server.py +258 -0
- custodian_codex_guard-0.1.0/custodian/codex_guard/receipts.py +211 -0
- custodian_codex_guard-0.1.0/custodian_codex_guard.egg-info/PKG-INFO +118 -0
- custodian_codex_guard-0.1.0/custodian_codex_guard.egg-info/SOURCES.txt +18 -0
- custodian_codex_guard-0.1.0/custodian_codex_guard.egg-info/dependency_links.txt +1 -0
- custodian_codex_guard-0.1.0/custodian_codex_guard.egg-info/entry_points.txt +3 -0
- custodian_codex_guard-0.1.0/custodian_codex_guard.egg-info/requires.txt +7 -0
- custodian_codex_guard-0.1.0/custodian_codex_guard.egg-info/top_level.txt +1 -0
- custodian_codex_guard-0.1.0/pyproject.toml +56 -0
- custodian_codex_guard-0.1.0/setup.cfg +4 -0
- custodian_codex_guard-0.1.0/tests/test_codex_guard.py +666 -0
- custodian_codex_guard-0.1.0/tests/test_codex_guard_cli.py +480 -0
- custodian_codex_guard-0.1.0/tests/test_codex_policy_wiring.py +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InovinLabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: custodian-codex-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Custodian Guard for OpenAI Codex — typed action risk classification, fail-closed enforcement, and hash-chained receipts for Codex tool calls.
|
|
5
|
+
Author-email: InovinLabs <hello@inovinlabs.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://getcustodian.xyz
|
|
8
|
+
Project-URL: Repository, https://github.com/KeyArgo/custodian-codex-guard
|
|
9
|
+
Project-URL: Custodian kernel, https://github.com/KeyArgo/custodian-kernel
|
|
10
|
+
Keywords: ai,agent,codex,openai,security,guardrails,mcp
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: custodian-kernel<0.5,>=0.4.0
|
|
21
|
+
Provides-Extra: paladin
|
|
22
|
+
Requires-Dist: custodian-kernel[paladin]<0.5,>=0.4.0; extra == "paladin"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Custodian Guard for Codex
|
|
28
|
+
|
|
29
|
+
*(OpenAI Build Week, July 2026)*
|
|
30
|
+
|
|
31
|
+
A capability firewall for coding agents. Codex can inspect, test, and edit
|
|
32
|
+
inside an approved workspace; credential use, network operations, destructive
|
|
33
|
+
commands, production changes, money movement, and governance changes stop at a
|
|
34
|
+
human-approval boundary. Every decision produces a value-free HMAC hash-chained
|
|
35
|
+
receipt. Classification is deterministic — typed action-kind rules over the
|
|
36
|
+
tool name and arguments, not a model call — so a mislabeled or adversarial
|
|
37
|
+
proposal can't talk its way past the boundary by re-describing itself.
|
|
38
|
+
|
|
39
|
+
This is the Build Week contribution specifically: the Codex-facing MCP server,
|
|
40
|
+
the policy bridge, the receipts CLI, and the governance skill. It depends on
|
|
41
|
+
[`custodian-kernel`](https://github.com/KeyArgo/custodian-kernel) — the
|
|
42
|
+
policy engine, adapter pipeline, and approval/filesystem/ledger-access
|
|
43
|
+
policies — which is agent-agnostic and predates this Build Week.
|
|
44
|
+
|
|
45
|
+
This plugin is generic. It does not know about any particular website, IDE,
|
|
46
|
+
or operator. A site or IDE is a client of the MCP boundary, never part of the
|
|
47
|
+
kernel.
|
|
48
|
+
|
|
49
|
+
## Install for judging
|
|
50
|
+
|
|
51
|
+
Python 3.11 or later:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Linux/macOS
|
|
55
|
+
python3 -m venv .venv
|
|
56
|
+
. .venv/bin/activate
|
|
57
|
+
# Windows PowerShell
|
|
58
|
+
python -m venv .venv
|
|
59
|
+
.venv\Scripts\Activate.ps1
|
|
60
|
+
|
|
61
|
+
python -m pip install -e .
|
|
62
|
+
custodian-codex setup
|
|
63
|
+
custodian-codex doctor
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`pip install -e .` pulls in `custodian-kernel` (pinned to the exact commit
|
|
67
|
+
this was built and verified against — see `pyproject.toml`; 0.4.0 isn't on
|
|
68
|
+
PyPI yet) automatically, nothing else to install first. Start a new Codex
|
|
69
|
+
thread after `setup` so it loads the plugin. The plugin manifest is at
|
|
70
|
+
`plugins/custodian-codex-guard/.codex-plugin/plugin.json`; its governance
|
|
71
|
+
skill is at `plugins/custodian-codex-guard/skills/govern-codex/SKILL.md`.
|
|
72
|
+
|
|
73
|
+
If the integration itself is broken, the operator — not the model — can run
|
|
74
|
+
`custodian-codex disable`. This removes the Codex plugin while deliberately
|
|
75
|
+
preserving receipts and approvals for diagnosis; `custodian-codex setup`
|
|
76
|
+
restores it. Start a new Codex thread after either change.
|
|
77
|
+
|
|
78
|
+
## Sixty-second proof
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python scripts/codex-guard-demo.py
|
|
82
|
+
pytest -q tests/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The demo performs no network calls and changes no external state. It shows a
|
|
86
|
+
safe test and workspace edit passing, `.env` access being denied, deliberately
|
|
87
|
+
misclassified delete/deploy commands being independently upgraded to human
|
|
88
|
+
escalation, a valid receipt chain, and rejection after receipt tampering.
|
|
89
|
+
106 tests cover the full threat model.
|
|
90
|
+
|
|
91
|
+
## Enforcement contract
|
|
92
|
+
|
|
93
|
+
`guard_action` returns `autonomous`, `escalation_required`, `approved`, or
|
|
94
|
+
`denied`. An escalation is never permission. The model can create a pending
|
|
95
|
+
request but cannot approve it; the operator runs the returned
|
|
96
|
+
`custodian-codex approve ID --digest DIGEST` outside the model tool boundary.
|
|
97
|
+
Approval binds the exact tool, effective risk class, arguments, resolved
|
|
98
|
+
workspace, requester, and policy version — any change requires a fresh
|
|
99
|
+
request, never a reused approval ID.
|
|
100
|
+
|
|
101
|
+
No harness — including Codex itself — can read the receipt ledger by
|
|
102
|
+
default, not even its own history. Visibility is only ever an explicit
|
|
103
|
+
operator grant. The agent being governed is exactly the party a denial log
|
|
104
|
+
exists to constrain; letting it read its own denial history would turn the
|
|
105
|
+
ledger into an oracle it could probe to learn the enforcement boundary and
|
|
106
|
+
route around it.
|
|
107
|
+
|
|
108
|
+
## What's in this repo vs. the kernel
|
|
109
|
+
|
|
110
|
+
- **Here:** `custodian/codex_guard/` (MCP server, risk classification,
|
|
111
|
+
receipts, approvals, CLI), `plugins/custodian-codex-guard/` (Codex plugin
|
|
112
|
+
manifest + governance skill), tests, judge demo script.
|
|
113
|
+
- **In `custodian-kernel`:** the adapter pipeline (workspace/secret/prompt-
|
|
114
|
+
injection/egress guards), `ApprovalPolicy`, `FilesystemPolicy`,
|
|
115
|
+
`LedgerAccessPolicy` — the policy engine every action is actually checked
|
|
116
|
+
against.
|
|
117
|
+
|
|
118
|
+
See [`docs/CODEX_GUARD.md`](docs/CODEX_GUARD.md) for the full judge guide.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Custodian Guard for Codex
|
|
2
|
+
|
|
3
|
+
*(OpenAI Build Week, July 2026)*
|
|
4
|
+
|
|
5
|
+
A capability firewall for coding agents. Codex can inspect, test, and edit
|
|
6
|
+
inside an approved workspace; credential use, network operations, destructive
|
|
7
|
+
commands, production changes, money movement, and governance changes stop at a
|
|
8
|
+
human-approval boundary. Every decision produces a value-free HMAC hash-chained
|
|
9
|
+
receipt. Classification is deterministic — typed action-kind rules over the
|
|
10
|
+
tool name and arguments, not a model call — so a mislabeled or adversarial
|
|
11
|
+
proposal can't talk its way past the boundary by re-describing itself.
|
|
12
|
+
|
|
13
|
+
This is the Build Week contribution specifically: the Codex-facing MCP server,
|
|
14
|
+
the policy bridge, the receipts CLI, and the governance skill. It depends on
|
|
15
|
+
[`custodian-kernel`](https://github.com/KeyArgo/custodian-kernel) — the
|
|
16
|
+
policy engine, adapter pipeline, and approval/filesystem/ledger-access
|
|
17
|
+
policies — which is agent-agnostic and predates this Build Week.
|
|
18
|
+
|
|
19
|
+
This plugin is generic. It does not know about any particular website, IDE,
|
|
20
|
+
or operator. A site or IDE is a client of the MCP boundary, never part of the
|
|
21
|
+
kernel.
|
|
22
|
+
|
|
23
|
+
## Install for judging
|
|
24
|
+
|
|
25
|
+
Python 3.11 or later:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Linux/macOS
|
|
29
|
+
python3 -m venv .venv
|
|
30
|
+
. .venv/bin/activate
|
|
31
|
+
# Windows PowerShell
|
|
32
|
+
python -m venv .venv
|
|
33
|
+
.venv\Scripts\Activate.ps1
|
|
34
|
+
|
|
35
|
+
python -m pip install -e .
|
|
36
|
+
custodian-codex setup
|
|
37
|
+
custodian-codex doctor
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`pip install -e .` pulls in `custodian-kernel` (pinned to the exact commit
|
|
41
|
+
this was built and verified against — see `pyproject.toml`; 0.4.0 isn't on
|
|
42
|
+
PyPI yet) automatically, nothing else to install first. Start a new Codex
|
|
43
|
+
thread after `setup` so it loads the plugin. The plugin manifest is at
|
|
44
|
+
`plugins/custodian-codex-guard/.codex-plugin/plugin.json`; its governance
|
|
45
|
+
skill is at `plugins/custodian-codex-guard/skills/govern-codex/SKILL.md`.
|
|
46
|
+
|
|
47
|
+
If the integration itself is broken, the operator — not the model — can run
|
|
48
|
+
`custodian-codex disable`. This removes the Codex plugin while deliberately
|
|
49
|
+
preserving receipts and approvals for diagnosis; `custodian-codex setup`
|
|
50
|
+
restores it. Start a new Codex thread after either change.
|
|
51
|
+
|
|
52
|
+
## Sixty-second proof
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python scripts/codex-guard-demo.py
|
|
56
|
+
pytest -q tests/
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The demo performs no network calls and changes no external state. It shows a
|
|
60
|
+
safe test and workspace edit passing, `.env` access being denied, deliberately
|
|
61
|
+
misclassified delete/deploy commands being independently upgraded to human
|
|
62
|
+
escalation, a valid receipt chain, and rejection after receipt tampering.
|
|
63
|
+
106 tests cover the full threat model.
|
|
64
|
+
|
|
65
|
+
## Enforcement contract
|
|
66
|
+
|
|
67
|
+
`guard_action` returns `autonomous`, `escalation_required`, `approved`, or
|
|
68
|
+
`denied`. An escalation is never permission. The model can create a pending
|
|
69
|
+
request but cannot approve it; the operator runs the returned
|
|
70
|
+
`custodian-codex approve ID --digest DIGEST` outside the model tool boundary.
|
|
71
|
+
Approval binds the exact tool, effective risk class, arguments, resolved
|
|
72
|
+
workspace, requester, and policy version — any change requires a fresh
|
|
73
|
+
request, never a reused approval ID.
|
|
74
|
+
|
|
75
|
+
No harness — including Codex itself — can read the receipt ledger by
|
|
76
|
+
default, not even its own history. Visibility is only ever an explicit
|
|
77
|
+
operator grant. The agent being governed is exactly the party a denial log
|
|
78
|
+
exists to constrain; letting it read its own denial history would turn the
|
|
79
|
+
ledger into an oracle it could probe to learn the enforcement boundary and
|
|
80
|
+
route around it.
|
|
81
|
+
|
|
82
|
+
## What's in this repo vs. the kernel
|
|
83
|
+
|
|
84
|
+
- **Here:** `custodian/codex_guard/` (MCP server, risk classification,
|
|
85
|
+
receipts, approvals, CLI), `plugins/custodian-codex-guard/` (Codex plugin
|
|
86
|
+
manifest + governance skill), tests, judge demo script.
|
|
87
|
+
- **In `custodian-kernel`:** the adapter pipeline (workspace/secret/prompt-
|
|
88
|
+
injection/egress guards), `ApprovalPolicy`, `FilesystemPolicy`,
|
|
89
|
+
`LedgerAccessPolicy` — the policy engine every action is actually checked
|
|
90
|
+
against.
|
|
91
|
+
|
|
92
|
+
See [`docs/CODEX_GUARD.md`](docs/CODEX_GUARD.md) for the full judge guide.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Custodian Guard for Codex: policy decisions for coding-agent actions."""
|
|
2
|
+
|
|
3
|
+
from .guard import ActionKind, GuardDecision, evaluate_action
|
|
4
|
+
from .approvals import ApprovalError, ApprovalRecord, ApprovalStore, action_digest
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ActionKind", "GuardDecision", "evaluate_action",
|
|
8
|
+
"ApprovalError", "ApprovalRecord", "ApprovalStore", "action_digest",
|
|
9
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Action-bound, expiring, single-use approvals for Codex Guard.
|
|
2
|
+
|
|
3
|
+
Approval records persist only a digest and bounded metadata. Proposed commands,
|
|
4
|
+
arguments, prompts, file contents, and secret references are never written.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import stat
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApprovalError(ValueError):
|
|
21
|
+
"""An approval is missing, invalid, expired, changed, or already used."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _private_dir(path: Path) -> None:
|
|
25
|
+
"""Create a private state directory and reject symlink redirection."""
|
|
26
|
+
try:
|
|
27
|
+
mode = path.lstat().st_mode
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
path.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
30
|
+
mode = path.lstat().st_mode
|
|
31
|
+
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
|
32
|
+
raise ApprovalError("approval state path must be a real directory")
|
|
33
|
+
if os.name != "nt":
|
|
34
|
+
path.chmod(0o700)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def action_digest(
|
|
38
|
+
*,
|
|
39
|
+
tool: str,
|
|
40
|
+
action_kind: str,
|
|
41
|
+
arguments: dict[str, Any],
|
|
42
|
+
workspace: str,
|
|
43
|
+
requester: str,
|
|
44
|
+
policy_version: str = "default",
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Return a stable digest binding every execution-relevant field."""
|
|
47
|
+
if not requester or len(requester) > 128:
|
|
48
|
+
raise ApprovalError("requester must contain 1 to 128 characters")
|
|
49
|
+
if not tool or len(tool) > 128:
|
|
50
|
+
raise ApprovalError("tool must contain 1 to 128 characters")
|
|
51
|
+
if not policy_version or len(policy_version) > 128:
|
|
52
|
+
raise ApprovalError("policy version must contain 1 to 128 characters")
|
|
53
|
+
body = {
|
|
54
|
+
"action_kind": action_kind,
|
|
55
|
+
"arguments": arguments,
|
|
56
|
+
"policy_version": policy_version,
|
|
57
|
+
"requester": requester,
|
|
58
|
+
"tool": tool,
|
|
59
|
+
"workspace": str(Path(workspace).expanduser().resolve()),
|
|
60
|
+
}
|
|
61
|
+
try:
|
|
62
|
+
encoded = json.dumps(
|
|
63
|
+
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
64
|
+
allow_nan=False,
|
|
65
|
+
).encode("utf-8")
|
|
66
|
+
except (TypeError, ValueError) as exc:
|
|
67
|
+
raise ApprovalError("action arguments are not canonically serializable") from exc
|
|
68
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ApprovalRecord:
|
|
73
|
+
approval_id: str
|
|
74
|
+
action_digest: str
|
|
75
|
+
requester: str
|
|
76
|
+
created_at: float
|
|
77
|
+
expires_at: float
|
|
78
|
+
status: str = "pending"
|
|
79
|
+
approved_by: str = ""
|
|
80
|
+
approved_at: float | None = None
|
|
81
|
+
consumed_at: float | None = None
|
|
82
|
+
mac: str = ""
|
|
83
|
+
# Stamped server-side by the caller from a trusted adapter identity (see
|
|
84
|
+
# mcp_server.py's evaluate_guard_action) -- never accepted from a model-
|
|
85
|
+
# supplied argument. "unknown" only for records written before this field
|
|
86
|
+
# existed. Lets ledger_access_policy grant or deny cross-adapter
|
|
87
|
+
# visibility based on a value nothing but trusted adapter code could set.
|
|
88
|
+
harness: str = "unknown"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ApprovalStore:
|
|
92
|
+
"""Filesystem-backed approval store with atomic single-use consumption."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, state_dir: Path, *, now=time.time) -> None:
|
|
95
|
+
self.state_dir = state_dir
|
|
96
|
+
self.approvals_dir = state_dir / "codex-approvals"
|
|
97
|
+
self.key_path = state_dir / "codex-approval.key"
|
|
98
|
+
self._now = now
|
|
99
|
+
|
|
100
|
+
def _key(self) -> bytes:
|
|
101
|
+
_private_dir(self.state_dir)
|
|
102
|
+
if not self.key_path.exists():
|
|
103
|
+
try:
|
|
104
|
+
fd = os.open(self.key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
105
|
+
except FileExistsError:
|
|
106
|
+
pass
|
|
107
|
+
else:
|
|
108
|
+
with os.fdopen(fd, "wb") as stream:
|
|
109
|
+
stream.write(os.urandom(32))
|
|
110
|
+
key = self.key_path.read_bytes()
|
|
111
|
+
if len(key) != 32:
|
|
112
|
+
raise ApprovalError("approval key is invalid")
|
|
113
|
+
return key
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _canonical(record: dict[str, Any]) -> bytes:
|
|
117
|
+
body = {k: v for k, v in record.items() if k != "mac"}
|
|
118
|
+
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
|
119
|
+
|
|
120
|
+
def _seal(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
record = dict(record)
|
|
122
|
+
record["mac"] = hmac.new(
|
|
123
|
+
self._key(), self._canonical(record), hashlib.sha256,
|
|
124
|
+
).hexdigest()
|
|
125
|
+
return record
|
|
126
|
+
|
|
127
|
+
def _verify(self, record: dict[str, Any]) -> None:
|
|
128
|
+
expected = hmac.new(
|
|
129
|
+
self._key(), self._canonical(record), hashlib.sha256,
|
|
130
|
+
).hexdigest()
|
|
131
|
+
if not hmac.compare_digest(expected, str(record.get("mac", ""))):
|
|
132
|
+
raise ApprovalError("approval record authentication failed")
|
|
133
|
+
|
|
134
|
+
def _path(self, approval_id: str) -> Path:
|
|
135
|
+
if not approval_id or any(c not in "0123456789abcdef-" for c in approval_id):
|
|
136
|
+
raise ApprovalError("invalid approval id")
|
|
137
|
+
return self.approvals_dir / f"{approval_id}.json"
|
|
138
|
+
|
|
139
|
+
def _write(self, path: Path, record: dict[str, Any]) -> None:
|
|
140
|
+
_private_dir(self.state_dir)
|
|
141
|
+
_private_dir(self.approvals_dir)
|
|
142
|
+
tmp = path.with_suffix(f".{uuid4().hex}.tmp")
|
|
143
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
144
|
+
try:
|
|
145
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
146
|
+
json.dump(self._seal(record), stream, sort_keys=True, separators=(",", ":"))
|
|
147
|
+
stream.flush()
|
|
148
|
+
os.fsync(stream.fileno())
|
|
149
|
+
os.replace(tmp, path)
|
|
150
|
+
finally:
|
|
151
|
+
if tmp.exists():
|
|
152
|
+
tmp.unlink()
|
|
153
|
+
|
|
154
|
+
def _read(self, approval_id: str) -> dict[str, Any]:
|
|
155
|
+
path = self._path(approval_id)
|
|
156
|
+
try:
|
|
157
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
158
|
+
except FileNotFoundError as exc:
|
|
159
|
+
raise ApprovalError("approval not found") from exc
|
|
160
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
161
|
+
raise ApprovalError("approval record is unreadable") from exc
|
|
162
|
+
self._verify(record)
|
|
163
|
+
return record
|
|
164
|
+
|
|
165
|
+
def get(self, approval_id: str) -> ApprovalRecord:
|
|
166
|
+
"""Read one record only after authenticating it."""
|
|
167
|
+
return ApprovalRecord(**self._read(approval_id))
|
|
168
|
+
|
|
169
|
+
def list_records(self) -> list[ApprovalRecord]:
|
|
170
|
+
_private_dir(self.state_dir)
|
|
171
|
+
_private_dir(self.approvals_dir)
|
|
172
|
+
records = []
|
|
173
|
+
for path in self.approvals_dir.glob("*.json"):
|
|
174
|
+
try:
|
|
175
|
+
records.append(self.get(path.stem))
|
|
176
|
+
except ApprovalError:
|
|
177
|
+
continue
|
|
178
|
+
return sorted(records, key=lambda record: record.created_at, reverse=True)
|
|
179
|
+
|
|
180
|
+
def list_visible(self, policy, *, harness: str, model: str) -> list[ApprovalRecord]:
|
|
181
|
+
"""Same visibility scoping as ReceiptChain.list_visible -- always
|
|
182
|
+
includes `harness`'s own records, plus anything explicitly granted."""
|
|
183
|
+
visible = policy.visible_harnesses(harness=harness, model=model)
|
|
184
|
+
records = self.list_records()
|
|
185
|
+
if visible == "*":
|
|
186
|
+
return records
|
|
187
|
+
return [r for r in records if r.harness in visible]
|
|
188
|
+
|
|
189
|
+
def deny(self, approval_id: str, *, denied_by: str) -> ApprovalRecord:
|
|
190
|
+
if not denied_by.strip():
|
|
191
|
+
raise ApprovalError("operator identity is required")
|
|
192
|
+
path = self._path(approval_id)
|
|
193
|
+
record = self._read(approval_id)
|
|
194
|
+
if record["status"] != "pending":
|
|
195
|
+
raise ApprovalError("approval is not pending")
|
|
196
|
+
record.update(status="denied", approved_by=denied_by.strip()[:128],
|
|
197
|
+
approved_at=self._now())
|
|
198
|
+
self._write(path, record)
|
|
199
|
+
return self.get(approval_id)
|
|
200
|
+
|
|
201
|
+
def request(self, *, digest: str, requester: str, ttl_seconds: int = 300,
|
|
202
|
+
harness: str = "unknown") -> ApprovalRecord:
|
|
203
|
+
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
|
204
|
+
raise ApprovalError("invalid action digest")
|
|
205
|
+
if not requester or len(requester) > 128 or ttl_seconds < 1 or ttl_seconds > 3600:
|
|
206
|
+
raise ApprovalError("requester and a TTL from 1 to 3600 seconds are required")
|
|
207
|
+
now = self._now()
|
|
208
|
+
record = ApprovalRecord(
|
|
209
|
+
approval_id=str(uuid4()),
|
|
210
|
+
action_digest=digest,
|
|
211
|
+
requester=requester,
|
|
212
|
+
created_at=now,
|
|
213
|
+
expires_at=now + ttl_seconds,
|
|
214
|
+
harness=harness[:64],
|
|
215
|
+
)
|
|
216
|
+
path = self._path(record.approval_id)
|
|
217
|
+
self._write(path, asdict(record))
|
|
218
|
+
return ApprovalRecord(**self._read(record.approval_id))
|
|
219
|
+
|
|
220
|
+
def approve(
|
|
221
|
+
self, approval_id: str, *, approved_by: str, expected_digest: str | None = None,
|
|
222
|
+
) -> ApprovalRecord:
|
|
223
|
+
if not approved_by.strip():
|
|
224
|
+
raise ApprovalError("operator identity is required")
|
|
225
|
+
path = self._path(approval_id)
|
|
226
|
+
record = self._read(approval_id)
|
|
227
|
+
now = self._now()
|
|
228
|
+
if record["status"] != "pending":
|
|
229
|
+
raise ApprovalError("approval is not pending")
|
|
230
|
+
if now > record["expires_at"]:
|
|
231
|
+
raise ApprovalError("approval expired")
|
|
232
|
+
if expected_digest is not None and not hmac.compare_digest(
|
|
233
|
+
record["action_digest"], expected_digest,
|
|
234
|
+
):
|
|
235
|
+
raise ApprovalError("approval digest does not match the displayed action")
|
|
236
|
+
record.update(status="approved", approved_by=approved_by.strip()[:128], approved_at=now)
|
|
237
|
+
self._write(path, record)
|
|
238
|
+
return ApprovalRecord(**self._read(approval_id))
|
|
239
|
+
|
|
240
|
+
def consume(self, approval_id: str, *, digest: str, requester: str) -> ApprovalRecord:
|
|
241
|
+
path = self._path(approval_id)
|
|
242
|
+
claim = path.with_suffix(".claim")
|
|
243
|
+
_private_dir(self.state_dir)
|
|
244
|
+
_private_dir(self.approvals_dir)
|
|
245
|
+
try:
|
|
246
|
+
claim_fd = os.open(claim, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
247
|
+
except FileExistsError as exc:
|
|
248
|
+
raise ApprovalError("approval is already being consumed or was used") from exc
|
|
249
|
+
os.close(claim_fd)
|
|
250
|
+
try:
|
|
251
|
+
record = self._read(approval_id)
|
|
252
|
+
now = self._now()
|
|
253
|
+
if record["status"] != "approved":
|
|
254
|
+
raise ApprovalError("approval has not been approved")
|
|
255
|
+
if now > record["expires_at"]:
|
|
256
|
+
raise ApprovalError("approval expired")
|
|
257
|
+
if not hmac.compare_digest(record["action_digest"], digest):
|
|
258
|
+
raise ApprovalError("action changed after approval")
|
|
259
|
+
if not hmac.compare_digest(record["requester"], requester):
|
|
260
|
+
raise ApprovalError("approval belongs to a different requester")
|
|
261
|
+
record.update(status="consumed", consumed_at=now)
|
|
262
|
+
self._write(path, record)
|
|
263
|
+
return ApprovalRecord(**self._read(approval_id))
|
|
264
|
+
except Exception:
|
|
265
|
+
# A validation failure may be corrected before expiry. Successful
|
|
266
|
+
# consumption leaves the claim marker as durable replay protection.
|
|
267
|
+
try:
|
|
268
|
+
consumed = self._read(approval_id).get("status") == "consumed"
|
|
269
|
+
except ApprovalError:
|
|
270
|
+
consumed = False
|
|
271
|
+
if not consumed:
|
|
272
|
+
claim.unlink(missing_ok=True)
|
|
273
|
+
raise
|