Skip to content

Commit 48fc1d3

Browse files
committed
cli commands
2 parents c4788c5 + db4688e commit 48fc1d3

8 files changed

Lines changed: 522 additions & 2 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,37 @@ See runnable examples in:
123123
- `examples/mcp_tool_guard_example.py`
124124
- `examples/outbound_http_guard_example.py`
125125

126+
## Operations CLI (Phase 2)
127+
128+
`predicate-authority` provides an ops-focused CLI for sidecar/runtime workflows.
129+
130+
### Sidecar health and status
131+
132+
```bash
133+
predicate-authority sidecar health --host 127.0.0.1 --port 8787
134+
predicate-authority sidecar status --host 127.0.0.1 --port 8787
135+
```
136+
137+
### Policy validation and reload
138+
139+
```bash
140+
predicate-authority policy validate --file examples/authorityd/policy.json
141+
predicate-authority policy reload --host 127.0.0.1 --port 8787
142+
```
143+
144+
### Revocation controls
145+
146+
```bash
147+
predicate-authority revoke principal --host 127.0.0.1 --port 8787 --id agent:orders-01
148+
predicate-authority revoke intent --host 127.0.0.1 --port 8787 --hash <intent_hash>
149+
```
150+
151+
### Daemon startup
152+
153+
```bash
154+
predicate-authorityd --host 127.0.0.1 --port 8787 --mode local_only --policy-file examples/authorityd/policy.json
155+
```
156+
126157
## Security: Local Kill-Switch Path
127158

128159
The current Phase 1 runtime supports fail-closed checks and local proof emission. The sidecar model (`predicate-authorityd`) is planned to provide instant local revocation and managed token lifecycle for long-running production agents.

docs/pypi-release-guide.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# PyPI Release Guide
2+
3+
This repo publishes two Python packages in strict order:
4+
5+
1. `predicate-contracts`
6+
2. `predicate-authority` (depends on `predicate-contracts`)
7+
8+
## 1) One-time setup
9+
10+
### Reserve package names on PyPI
11+
12+
Ensure both package names exist under your organization:
13+
14+
- `predicate-contracts`
15+
- `predicate-authority`
16+
17+
### Add GitHub repository secrets
18+
19+
In GitHub repository settings -> Secrets and variables -> Actions, add:
20+
21+
- `PYPI_TOKEN_PREDICATE_CONTRACTS`
22+
- `PYPI_TOKEN_PREDICATE_AUTHORITY`
23+
24+
Use PyPI API tokens scoped to each package where possible.
25+
26+
## 2) Prepare a release
27+
28+
1. Update versions:
29+
- `predicate_contracts/pyproject.toml` -> `project.version`
30+
- `predicate_authority/pyproject.toml` -> `project.version`
31+
2. If `predicate-contracts` version changes, update dependency pin in:
32+
- `predicate_authority/pyproject.toml` (`predicate-contracts>=X,<Y`)
33+
3. Run local checks:
34+
35+
```bash
36+
make test
37+
make lint
38+
make verify-release-order
39+
python -m build predicate_contracts
40+
python -m build predicate_authority
41+
```
42+
43+
## 3) Publish via GitHub Actions (recommended)
44+
45+
1. Push your release commit to `main`.
46+
2. Open Actions -> `phase1-ci-and-release`.
47+
3. Click **Run workflow** with input `publish=true`.
48+
4. Workflow order is enforced:
49+
- `publish-predicate-contracts`
50+
- `publish-predicate-authority` (runs only after contracts publish succeeds)
51+
52+
## 4) Verify published artifacts
53+
54+
```bash
55+
python -m pip install --upgrade predicate-contracts predicate-authority
56+
python - <<'PY'
57+
import predicate_contracts
58+
import predicate_authority
59+
print("ok", predicate_contracts.__name__, predicate_authority.__name__)
60+
PY
61+
```
62+
63+
## 5) Optional: create git tags per package release
64+
65+
Tags are not required for publishing in this repo, but they are recommended for traceability.
66+
67+
Suggested tag format:
68+
69+
- `predicate-contracts-vX.Y.Z`
70+
- `predicate-authority-vX.Y.Z`
71+
72+
Example commands (after publish succeeds):
73+
74+
```bash
75+
git tag -a predicate-contracts-v0.1.0 -m "predicate-contracts v0.1.0"
76+
git tag -a predicate-authority-v0.1.0 -m "predicate-authority v0.1.0"
77+
git push origin predicate-contracts-v0.1.0
78+
git push origin predicate-authority-v0.1.0
79+
```
80+
81+
## 6) Manual fallback publish (if needed)
82+
83+
```bash
84+
python -m pip install --upgrade build twine
85+
python -m build predicate_contracts
86+
twine check predicate_contracts/dist/*
87+
TWINE_USERNAME=__token__ TWINE_PASSWORD="$PYPI_TOKEN_PREDICATE_CONTRACTS" twine upload predicate_contracts/dist/*
88+
89+
python -m build predicate_authority
90+
twine check predicate_authority/dist/*
91+
TWINE_USERNAME=__token__ TWINE_PASSWORD="$PYPI_TOKEN_PREDICATE_AUTHORITY" twine upload predicate_authority/dist/*
92+
```

predicate_authority/cli.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import http.client
5+
import json
6+
import sys
7+
from pathlib import Path
8+
from urllib.parse import urlsplit
9+
10+
from predicate_authority.policy_source import PolicyFileSource
11+
12+
13+
def _request_json(
14+
method: str, url: str, payload: dict[str, str] | None = None
15+
) -> tuple[int, dict[str, object]]:
16+
parsed = urlsplit(url)
17+
if parsed.scheme not in {"http", "https"}:
18+
raise RuntimeError(f"Unsupported URL scheme: {parsed.scheme}")
19+
if parsed.netloc == "":
20+
raise RuntimeError("URL must include host:port.")
21+
path = parsed.path or "/"
22+
if parsed.query:
23+
path = f"{path}?{parsed.query}"
24+
body = None
25+
headers: dict[str, str] = {}
26+
if payload is not None:
27+
body = json.dumps(payload)
28+
headers["Content-Type"] = "application/json"
29+
connection_cls = (
30+
http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
31+
)
32+
connection = connection_cls(parsed.netloc, timeout=5.0)
33+
try:
34+
connection.request(method.upper(), path, body=body, headers=headers)
35+
response = connection.getresponse()
36+
raw = response.read().decode("utf-8")
37+
finally:
38+
connection.close()
39+
if raw.strip() == "":
40+
return int(response.status), {}
41+
loaded = json.loads(raw)
42+
if not isinstance(loaded, dict):
43+
raise RuntimeError("Expected JSON object response.")
44+
return int(response.status), loaded
45+
46+
47+
def _base_url(host: str, port: int) -> str:
48+
return f"http://{host}:{port}"
49+
50+
51+
def _print_json(payload: dict[str, object]) -> None:
52+
print(json.dumps(payload, indent=2, sort_keys=True))
53+
54+
55+
def _cmd_sidecar_health(args: argparse.Namespace) -> int:
56+
status, payload = _request_json("GET", f"{_base_url(args.host, args.port)}/health")
57+
_print_json(payload)
58+
return 0 if status < 400 else 1
59+
60+
61+
def _cmd_sidecar_status(args: argparse.Namespace) -> int:
62+
status, payload = _request_json("GET", f"{_base_url(args.host, args.port)}/status")
63+
_print_json(payload)
64+
return 0 if status < 400 else 1
65+
66+
67+
def _cmd_policy_validate(args: argparse.Namespace) -> int:
68+
path = Path(args.file)
69+
if not path.exists():
70+
print(f"Policy file not found: {path}", file=sys.stderr)
71+
return 1
72+
try:
73+
rules = PolicyFileSource(str(path)).load_rules()
74+
except Exception as exc: # noqa: BLE001
75+
print(f"Policy validation failed: {exc}", file=sys.stderr)
76+
return 1
77+
_print_json({"valid": True, "rule_count": len(rules), "file": str(path)})
78+
return 0
79+
80+
81+
def _cmd_policy_reload(args: argparse.Namespace) -> int:
82+
status, payload = _request_json("POST", f"{_base_url(args.host, args.port)}/policy/reload")
83+
_print_json(payload)
84+
return 0 if status < 400 else 1
85+
86+
87+
def _cmd_revoke_principal(args: argparse.Namespace) -> int:
88+
status, payload = _request_json(
89+
"POST",
90+
f"{_base_url(args.host, args.port)}/revoke/principal",
91+
payload={"principal_id": args.id},
92+
)
93+
_print_json(payload)
94+
return 0 if status < 400 else 1
95+
96+
97+
def _cmd_revoke_intent(args: argparse.Namespace) -> int:
98+
status, payload = _request_json(
99+
"POST",
100+
f"{_base_url(args.host, args.port)}/revoke/intent",
101+
payload={"intent_hash": args.hash},
102+
)
103+
_print_json(payload)
104+
return 0 if status < 400 else 1
105+
106+
107+
def _add_host_port_args(parser: argparse.ArgumentParser) -> None:
108+
parser.add_argument("--host", default="127.0.0.1")
109+
parser.add_argument("--port", type=int, default=8787)
110+
111+
112+
def build_parser() -> argparse.ArgumentParser:
113+
parser = argparse.ArgumentParser(description="predicate-authority operational CLI")
114+
subparsers = parser.add_subparsers(dest="command", required=True)
115+
116+
sidecar_parser = subparsers.add_parser("sidecar", help="Sidecar inspection commands")
117+
sidecar_sub = sidecar_parser.add_subparsers(dest="sidecar_command", required=True)
118+
119+
sidecar_health = sidecar_sub.add_parser("health", help="Query sidecar /health")
120+
_add_host_port_args(sidecar_health)
121+
sidecar_health.set_defaults(func=_cmd_sidecar_health)
122+
123+
sidecar_status = sidecar_sub.add_parser("status", help="Query sidecar /status")
124+
_add_host_port_args(sidecar_status)
125+
sidecar_status.set_defaults(func=_cmd_sidecar_status)
126+
127+
policy_parser = subparsers.add_parser("policy", help="Policy utility commands")
128+
policy_sub = policy_parser.add_subparsers(dest="policy_command", required=True)
129+
130+
policy_validate = policy_sub.add_parser("validate", help="Validate policy file")
131+
policy_validate.add_argument("--file", required=True)
132+
policy_validate.set_defaults(func=_cmd_policy_validate)
133+
134+
policy_reload = policy_sub.add_parser("reload", help="Request sidecar policy reload")
135+
_add_host_port_args(policy_reload)
136+
policy_reload.set_defaults(func=_cmd_policy_reload)
137+
138+
revoke_parser = subparsers.add_parser("revoke", help="Revocation commands")
139+
revoke_sub = revoke_parser.add_subparsers(dest="revoke_command", required=True)
140+
141+
revoke_principal = revoke_sub.add_parser("principal", help="Revoke by principal_id")
142+
_add_host_port_args(revoke_principal)
143+
revoke_principal.add_argument("--id", required=True)
144+
revoke_principal.set_defaults(func=_cmd_revoke_principal)
145+
146+
revoke_intent = revoke_sub.add_parser("intent", help="Revoke by intent hash")
147+
_add_host_port_args(revoke_intent)
148+
revoke_intent.add_argument("--hash", required=True)
149+
revoke_intent.set_defaults(func=_cmd_revoke_intent)
150+
151+
return parser
152+
153+
154+
def main() -> None:
155+
parser = build_parser()
156+
args = parser.parse_args()
157+
exit_code = args.func(args)
158+
raise SystemExit(exit_code)
159+
160+
161+
if __name__ == "__main__":
162+
main()

predicate_authority/daemon.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,53 @@ def do_GET(self) -> None: # noqa: N802
6464
return
6565
self._send_json(404, {"error": "not_found"})
6666

67+
def do_POST(self) -> None: # noqa: N802
68+
parsed = urlparse(self.path)
69+
if parsed.path == "/policy/reload":
70+
reloaded = self.server.daemon_ref.reload_policy_now() # type: ignore[attr-defined]
71+
self._send_json(200, {"reloaded": reloaded})
72+
return
73+
if parsed.path == "/revoke/principal":
74+
payload = self._read_json_body()
75+
principal_id = payload.get("principal_id")
76+
if not isinstance(principal_id, str) or principal_id.strip() == "":
77+
self._send_json(400, {"error": "principal_id is required"})
78+
return
79+
self.server.daemon_ref.revoke_principal(principal_id.strip()) # type: ignore[attr-defined]
80+
self._send_json(200, {"ok": True, "principal_id": principal_id.strip()})
81+
return
82+
if parsed.path == "/revoke/intent":
83+
payload = self._read_json_body()
84+
intent_hash = payload.get("intent_hash")
85+
if not isinstance(intent_hash, str) or intent_hash.strip() == "":
86+
self._send_json(400, {"error": "intent_hash is required"})
87+
return
88+
self.server.daemon_ref.revoke_intent(intent_hash.strip()) # type: ignore[attr-defined]
89+
self._send_json(200, {"ok": True, "intent_hash": intent_hash.strip()})
90+
return
91+
self._send_json(404, {"error": "not_found"})
92+
6793
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
6894
# Keep daemon output deterministic and quiet by default.
6995
return
7096

97+
def _read_json_body(self) -> dict[str, Any]:
98+
raw_length = self.headers.get("Content-Length", "0")
99+
try:
100+
content_length = int(raw_length)
101+
except ValueError:
102+
return {}
103+
if content_length <= 0:
104+
return {}
105+
payload = self.rfile.read(content_length).decode("utf-8")
106+
try:
107+
loaded = json.loads(payload)
108+
except json.JSONDecodeError:
109+
return {}
110+
if isinstance(loaded, dict):
111+
return loaded
112+
return {}
113+
71114
def _send_json(self, code: int, payload: dict[str, Any]) -> None:
72115
encoded = json.dumps(payload).encode("utf-8")
73116
self.send_response(code)
@@ -143,6 +186,19 @@ def status_payload(self) -> dict[str, Any]:
143186
)
144187
return payload
145188

189+
def reload_policy_now(self) -> bool:
190+
changed = self._sidecar.hot_reload_policy()
191+
if changed:
192+
self._runtime.policy_reload_count += 1
193+
self._runtime.last_policy_reload_epoch_s = time.time()
194+
return changed
195+
196+
def revoke_principal(self, principal_id: str) -> None:
197+
self._sidecar.revoke_by_invariant(principal_id)
198+
199+
def revoke_intent(self, intent_hash: str) -> None:
200+
self._sidecar.revoke_intent_hash(intent_hash)
201+
146202
def _policy_poll_loop(self) -> None:
147203
while not self._stop_event.is_set():
148204
try:

predicate_authority/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
]
2121

2222
[project.scripts]
23+
predicate-authority = "predicate_authority.cli:main"
2324
predicate-authorityd = "predicate_authority.daemon:main"
2425

2526
[project.optional-dependencies]

predicate_authority/sidecar.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ def exchange_access_token(
131131
def revoke_by_invariant(self, principal_id: str) -> None:
132132
self._revocation_cache.revoke_principal(principal_id)
133133

134+
def revoke_intent_hash(self, intent_hash: str) -> None:
135+
self._revocation_cache.revoke_intent_hash(intent_hash)
136+
134137
def hot_reload_policy(self) -> bool:
135138
if self._policy_source is None:
136139
return False

0 commit comments

Comments
 (0)