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.
@@ -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