aegis-devops 0.1.0__tar.gz → 0.1.2__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.
- aegis_devops-0.1.2/PKG-INFO +248 -0
- aegis_devops-0.1.2/README.md +216 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.1.2/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/external.py +51 -1
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/llm.py +18 -1
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/store.py +20 -8
- aegis_devops-0.1.2/src/aegis_devops.egg-info/PKG-INFO +248 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/SOURCES.txt +1 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_baselines.py +159 -0
- aegis_devops-0.1.2/tests/test_docs_consistency.py +140 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_store.py +97 -18
- aegis_devops-0.1.0/PKG-INFO +0 -933
- aegis_devops-0.1.0/README.md +0 -901
- aegis_devops-0.1.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.1.0/src/aegis_devops.egg-info/PKG-INFO +0 -933
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/LICENSE +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/MANIFEST.in +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/pyproject.toml +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/setup.cfg +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/cli.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/interceptor.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parser.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_authority.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_cli.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_config.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_corpus.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_environments.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_intent.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_interceptor.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_ledger.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_parser.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_parsers_sql.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_plan.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_provenance.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_shell.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_signing.py +0 -0
- {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_store_match.py +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aegis-devops
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Provenance-backed, authority-aware policy verifier for agentic DevOps actions.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/moneytool/aegis-devops
|
|
7
|
+
Project-URL: Repository, https://github.com/moneytool/aegis-devops
|
|
8
|
+
Keywords: agentops,agent-security,policy-as-code,provenance,kubernetes,terraform,guardrails,prompt-injection,context-poisoning
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: pyyaml
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-benchmark; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff; extra == "dev"
|
|
29
|
+
Provides-Extra: llm
|
|
30
|
+
Requires-Dist: anthropic; extra == "llm"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# Aegis-DevOps — Policy Verifier for AI DevOps Agents
|
|
34
|
+
|
|
35
|
+
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
37
|
+
|
|
38
|
+
**Provenance-backed, authority-aware guardrails that stop context poisoning and agentic drift
|
|
39
|
+
before an AI agent's `kubectl` or `terraform` action reaches your infrastructure.**
|
|
40
|
+
|
|
41
|
+
**Keywords:** AI agent security · AgentOps · prompt injection · context poisoning · policy
|
|
42
|
+
enforcement · policy-as-code · Kubernetes · Terraform · OPA · SRE · LLM guardrails ·
|
|
43
|
+
provenance · infrastructure-as-code
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
## What it does
|
|
48
|
+
|
|
49
|
+
Aegis intercepts a proposed agent action — a `kubectl`/`terraform`/`aws`/... invocation or a
|
|
50
|
+
plan file — and checks it against a **Constraint Store** before it runs, returning `ALLOW`,
|
|
51
|
+
`BLOCK`, or `ESCALATE` with citations. Unlike a naive policy engine, every constraint in the
|
|
52
|
+
store must independently pass an **integrity check** (has it been tampered with since
|
|
53
|
+
ingestion?) and an **authority check** (was its source ever allowed to assert this kind of
|
|
54
|
+
policy?), so a poisoned Jira ticket or a forged Slack message can't quietly become law. It
|
|
55
|
+
covers 20 CLI/plan targets today — Kubernetes, Terraform/OpenTofu, the three major clouds,
|
|
56
|
+
Helm/ArgoCD/Flux, Git/GitHub, SQL/migrations, and Pulumi — through one shared intent schema.
|
|
57
|
+
|
|
58
|
+

|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
Requires Python 3.11+ (tested on 3.11, 3.13 and 3.14 on Linux and macOS).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install aegis-devops
|
|
66
|
+
aegis init ./.aegis
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`aegis init` writes the example policy files (constraints, authority map, environment map,
|
|
70
|
+
plan constraints, signed sources) into a directory. Putting them in `./.aegis` means the CLI
|
|
71
|
+
finds them with no flags and no environment variable — see [Configuration](docs/configuration.md)
|
|
72
|
+
for the full search order. Then check a command:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
aegis check kubectl --now 2026-03-16T10:00:00-05:00 --pretty -- \
|
|
76
|
+
kubectl scale deployment/api-server --replicas=5 -n prod
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
BLOCK: kubernetes scale deployment/api-server
|
|
81
|
+
citations: no-scale-prod-peak
|
|
82
|
+
covered: True latency_ms: 0.20
|
|
83
|
+
PLAN BLOCK: 1 intent(s)
|
|
84
|
+
STORE: loaded=21 quarantined=0 principals=3
|
|
85
|
+
warning: using example signing key
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The `warning` is real and deliberate: the shipped policy files are signed with a public demo
|
|
89
|
+
key that ships beside them, so the CLI verifies them out of the box while telling you it used
|
|
90
|
+
a key everyone has. `aegis init` prints the two commands that replace it with your own — see
|
|
91
|
+
[Signing](docs/configuration.md#signing). The example rules are a demo, not a starting policy;
|
|
92
|
+
replace `constraints.example.yaml` with your own `constraints.yaml` (a real file wins over the
|
|
93
|
+
`.example` one when both exist).
|
|
94
|
+
|
|
95
|
+
### From a clone
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python -m venv venv
|
|
99
|
+
venv/bin/python -m pip install -e ".[dev]"
|
|
100
|
+
venv/bin/python examples/demo.py
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`examples/demo.py` runs 15 intents across every supported tool through the interceptor and
|
|
104
|
+
prints each decision, starting with the store's health. A clone already has `data/`, so the CLI
|
|
105
|
+
finds its policy files without `aegis init`.
|
|
106
|
+
|
|
107
|
+
Exit codes, store health, the Claude Code hook, argv parsing, compound commands, dry runs and
|
|
108
|
+
library usage are all in [`docs/cli.md`](docs/cli.md).
|
|
109
|
+
|
|
110
|
+
## How a decision is made
|
|
111
|
+
|
|
112
|
+

|
|
113
|
+
|
|
114
|
+
At **load time**, `ConstraintStore.load` parses the constraint YAML, verifies each
|
|
115
|
+
`provenance_hash`, and (with `--sources`) re-fetches and checks the original source —
|
|
116
|
+
anything that fails either check is quarantined, not silently dropped.
|
|
117
|
+
|
|
118
|
+
At **decision time**, `AegisInterceptor.intercept` matches each intent against the surviving
|
|
119
|
+
constraints on `(provider, resource_pattern, action, scope, time_window)`, re-checks integrity
|
|
120
|
+
and authority (authority can be revoked after ingestion), applies any rate limit against the
|
|
121
|
+
decision ledger, downgrades a dry run to `ALLOW`, and takes the highest-precedence effect
|
|
122
|
+
(`BLOCK` > `ESCALATE` > `ALLOW`) among what's left. A `PlanConstraint` then runs once more over
|
|
123
|
+
the whole batch of intents from one plan/chart/invocation.
|
|
124
|
+
|
|
125
|
+
## Supported tools
|
|
126
|
+
|
|
127
|
+
Every target below is checked with `aegis check <target> [flags] -- <argv...>`, except
|
|
128
|
+
`terraform`/`tofu`/`pulumi-preview`, which take a JSON document path instead of `--`.
|
|
129
|
+
|
|
130
|
+
| target | example |
|
|
131
|
+
| :--- | :--- |
|
|
132
|
+
| `kubectl` | `aegis check kubectl -- kubectl scale deployment/api-server --replicas=5 -n prod` |
|
|
133
|
+
| `terraform` | `aegis check terraform plan.json` (from `terraform show -json tfplan > plan.json`) |
|
|
134
|
+
| `tofu` | `aegis check tofu plan.json` (identical plan schema; `provider` stays `terraform`) |
|
|
135
|
+
| `pulumi-preview` | `aegis check pulumi-preview preview.json` (from `pulumi preview --json`) |
|
|
136
|
+
| `pulumi` | `aegis check pulumi -- pulumi destroy --stack prod` |
|
|
137
|
+
| `aws` | `aegis check aws -- aws ec2 terminate-instances --instance-ids i-0abc --region us-east-1` |
|
|
138
|
+
| `az` | `aegis check az -- az aks scale --resource-group rg1 --name aks1 --node-count 5` |
|
|
139
|
+
| `gcloud` | `aegis check gcloud -- gcloud sql instances delete prod-db` |
|
|
140
|
+
| `helm` | `aegis check helm -- helm uninstall api -n prod` |
|
|
141
|
+
| `argocd` | `aegis check argocd -- argocd app sync prod-web --prune` |
|
|
142
|
+
| `flux` | `aegis check flux -- flux reconcile kustomization podinfo -n flux-system` |
|
|
143
|
+
| `git` | `aegis check git -- git push --force origin main` |
|
|
144
|
+
| `gh` | `aegis check gh -- gh workflow run deploy-prod.yml -r main` |
|
|
145
|
+
| `psql` | `aegis check psql -- psql -c "DROP TABLE users;"` |
|
|
146
|
+
| `mysql` | `aegis check mysql -- mysql -e "DROP TABLE users;"` |
|
|
147
|
+
| `sqlite3` | `aegis check sqlite3 -- sqlite3 app.db "DELETE FROM users;"` |
|
|
148
|
+
| `mongosh` | `aegis check mongosh -- mongosh --eval "db.users.drop()"` |
|
|
149
|
+
| `migrate` | `aegis check migrate -- alembic downgrade base` |
|
|
150
|
+
| `sql` | `aegis check sql -- "DROP TABLE users;"` |
|
|
151
|
+
| `argv` | `aegis check argv -- gcloud sql instances delete prod-db` (dispatches by binary name) |
|
|
152
|
+
| `command` | `aegis check command -- "kubectl get pods; sudo kubectl delete node/w1"` (a shell string; see [Compound commands](docs/cli.md#compound-commands)) |
|
|
153
|
+
|
|
154
|
+
## Results
|
|
155
|
+
|
|
156
|
+

|
|
157
|
+
|
|
158
|
+
`scripts/benchmark.py` runs Aegis and several baselines over the labeled 500-constraint corpus
|
|
159
|
+
in `data/corpus/` (323 distinct rule structures), on 120 held-out intents, scored against an
|
|
160
|
+
oracle that never imports Aegis's own code. The full table
|
|
161
|
+
(precision/recall/F1, latency, coverage) and methodology are in
|
|
162
|
+
[`docs/benchmark.md`](docs/benchmark.md); the columns that matter most are summarized below.
|
|
163
|
+
|
|
164
|
+
| verifier | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
165
|
+
| :--- | ---: | ---: | ---: |
|
|
166
|
+
| **aegis** | **0.000** | **0.000** | **0.000** |
|
|
167
|
+
| codex (gpt-6-astra) | 0.150 | 0.300 | 0.200 |
|
|
168
|
+
| codex-gpt-6-sol | 0.150 | 0.300 | 0.200 |
|
|
169
|
+
| codex-gpt-6-luna | 0.117 | 0.233 | 0.100 |
|
|
170
|
+
| claude-cli (haiku) | 0.150 | 0.300 | 0.200 |
|
|
171
|
+
| claude-cli-sonnet | 0.167 | 0.333 | 0.200 |
|
|
172
|
+
| claude-cli-opus | 0.150 | 0.300 | 0.200 |
|
|
173
|
+
| claude-cli-fable | 0.150 | 0.300 | 0.200 |
|
|
174
|
+
| opa-signed | 0.250 | 0.500 | 1.000 |
|
|
175
|
+
| opa | 0.500 | 1.000 | 1.000 |
|
|
176
|
+
| llm-heuristic | 0.500 | 1.000 | 1.000 |
|
|
177
|
+
| ollama (mistral 7B) | 1.000 | 1.000 | 1.000 |
|
|
178
|
+
|
|
179
|
+
`poison-susceptibility` (`ps + pe`) is the fraction of poisoned rules — constraints an
|
|
180
|
+
unauthorized/tampered/forged author slipped in — that moved a verdict at all, split by kind;
|
|
181
|
+
`ps_unauth + pe_unauth` isolates the realistic pre-ingest attacker (an unauthorized principal).
|
|
182
|
+
It's the headline column because **signing a policy bundle proves it wasn't altered in transit,
|
|
183
|
+
not that its author was ever allowed to write the rule** — `opa-signed` scores `1.000` on it for
|
|
184
|
+
exactly that reason, while Aegis's independent authority check scores `0.000`.
|
|
185
|
+
|
|
186
|
+
The `codex*` and `claude-cli*` rows are **agent harnesses wrapped around a model**, not raw
|
|
187
|
+
completions — seven models across two vendors (Claude Haiku 4.5, Sonnet 5, Opus 5 and Fable
|
|
188
|
+
5.1; GPT `gpt-6-astra`, `gpt-6-sol` and `gpt-6-luna`), each verified before its run to be the
|
|
189
|
+
model that actually answered — and — unlike the other rows — are scored against a
|
|
190
|
+
100-constraint holdout subset rather than the full 500-constraint store, for context-window and
|
|
191
|
+
cost reasons; see [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything
|
|
192
|
+
else in the table. Model size and vendor do not change the picture: all seven land between
|
|
193
|
+
0.23 and 0.33 poison-susceptibility, and every one acts on 5 of 8 forged rules — a model can
|
|
194
|
+
reason about who wrote a rule, but not recompute a hash or fetch a source.
|
|
195
|
+
|
|
196
|
+
## Why not OPA/Gatekeeper?
|
|
197
|
+
|
|
198
|
+
OPA/Gatekeeper evaluates **structured API objects** against **hand-authored rules**. Aegis
|
|
199
|
+
derives **unstructured human constraints** (from Slack, Jira, Git) and applies
|
|
200
|
+
**authority-driven validation** to the agent's intent *before* it reaches the infrastructure.
|
|
201
|
+
|
|
202
|
+
## Project status / roadmap
|
|
203
|
+
|
|
204
|
+
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
205
|
+
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
206
|
+
v0.1.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
207
|
+
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
208
|
+
through the Codex CLI, and a local `mistral:latest`.
|
|
209
|
+
|
|
210
|
+
What is *not* done is the part the threat model leans on hardest. See `PLAN.md §8`, but in
|
|
211
|
+
short: sources are verified against files on disk rather than real Git/Slack/Jira connectors,
|
|
212
|
+
signing uses a shared secret rather than per-principal public keys, and a `principal` is a
|
|
213
|
+
signed name rather than an identity bound to a commit signature or an SSO group. Until those
|
|
214
|
+
land, Aegis demonstrates that the *decision procedure* is sound; it does not yet prove the
|
|
215
|
+
identities feeding it are. Resource matching is also case-sensitive on names. It is not
|
|
216
|
+
production-ready: read `PLAN.md §8` for the full list of open gaps before putting it in front
|
|
217
|
+
of anything you care about.
|
|
218
|
+
|
|
219
|
+
## Documentation
|
|
220
|
+
|
|
221
|
+
| Page | Covers |
|
|
222
|
+
| :--- | :--- |
|
|
223
|
+
| [`docs/cli.md`](docs/cli.md) | Exit codes, store health, the Claude Code hook, argv forms, compound commands, dry runs, library usage |
|
|
224
|
+
| [`docs/constraints.md`](docs/constraints.md) | Writing constraints, metadata vocabulary, authority policy, environment mapping |
|
|
225
|
+
| [`docs/configuration.md`](docs/configuration.md) | Configuration/config-dir discovery, signing, source verification, rate limits & ledger |
|
|
226
|
+
| [`docs/benchmark.md`](docs/benchmark.md) | Benchmark methodology, full results table, real LLM baselines, agent-harness baselines, corpus, adversarial suite |
|
|
227
|
+
| [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) | Adding a new parser or tool |
|
|
228
|
+
| [`PLAN.md`](PLAN.md) | Design plan and open gaps |
|
|
229
|
+
| [`SECURITY.md`](SECURITY.md) | Reporting a bypass |
|
|
230
|
+
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
|
231
|
+
| [`REVIEW-4.md`](REVIEW-4.md) | Corpus/oracle rewrite (independent oracle, intent-level holdout) |
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
venv/bin/python -m pytest -q # 1257 passed (last full green run; CI runs this on 3.11/3.13/3.14)
|
|
237
|
+
venv/bin/python -m ruff check src tests scripts examples
|
|
238
|
+
vhs docs/demo.tape # regenerate docs/demo.gif
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Adding a new parser or tool: see [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md).
|
|
242
|
+
Reporting a bypass: see [`SECURITY.md`](SECURITY.md) — parser evasion is the largest
|
|
243
|
+
attack surface and the most useful thing to report. Release history is in
|
|
244
|
+
[`CHANGELOG.md`](CHANGELOG.md).
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
Apache-2.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Aegis-DevOps — Policy Verifier for AI DevOps Agents
|
|
2
|
+
|
|
3
|
+
[](https://doi.org/10.5281/zenodo.22950337)
|
|
4
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
5
|
+
|
|
6
|
+
**Provenance-backed, authority-aware guardrails that stop context poisoning and agentic drift
|
|
7
|
+
before an AI agent's `kubectl` or `terraform` action reaches your infrastructure.**
|
|
8
|
+
|
|
9
|
+
**Keywords:** AI agent security · AgentOps · prompt injection · context poisoning · policy
|
|
10
|
+
enforcement · policy-as-code · Kubernetes · Terraform · OPA · SRE · LLM guardrails ·
|
|
11
|
+
provenance · infrastructure-as-code
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
Aegis intercepts a proposed agent action — a `kubectl`/`terraform`/`aws`/... invocation or a
|
|
18
|
+
plan file — and checks it against a **Constraint Store** before it runs, returning `ALLOW`,
|
|
19
|
+
`BLOCK`, or `ESCALATE` with citations. Unlike a naive policy engine, every constraint in the
|
|
20
|
+
store must independently pass an **integrity check** (has it been tampered with since
|
|
21
|
+
ingestion?) and an **authority check** (was its source ever allowed to assert this kind of
|
|
22
|
+
policy?), so a poisoned Jira ticket or a forged Slack message can't quietly become law. It
|
|
23
|
+
covers 20 CLI/plan targets today — Kubernetes, Terraform/OpenTofu, the three major clouds,
|
|
24
|
+
Helm/ArgoCD/Flux, Git/GitHub, SQL/migrations, and Pulumi — through one shared intent schema.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
Requires Python 3.11+ (tested on 3.11, 3.13 and 3.14 on Linux and macOS).
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install aegis-devops
|
|
34
|
+
aegis init ./.aegis
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`aegis init` writes the example policy files (constraints, authority map, environment map,
|
|
38
|
+
plan constraints, signed sources) into a directory. Putting them in `./.aegis` means the CLI
|
|
39
|
+
finds them with no flags and no environment variable — see [Configuration](docs/configuration.md)
|
|
40
|
+
for the full search order. Then check a command:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
aegis check kubectl --now 2026-03-16T10:00:00-05:00 --pretty -- \
|
|
44
|
+
kubectl scale deployment/api-server --replicas=5 -n prod
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
BLOCK: kubernetes scale deployment/api-server
|
|
49
|
+
citations: no-scale-prod-peak
|
|
50
|
+
covered: True latency_ms: 0.20
|
|
51
|
+
PLAN BLOCK: 1 intent(s)
|
|
52
|
+
STORE: loaded=21 quarantined=0 principals=3
|
|
53
|
+
warning: using example signing key
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The `warning` is real and deliberate: the shipped policy files are signed with a public demo
|
|
57
|
+
key that ships beside them, so the CLI verifies them out of the box while telling you it used
|
|
58
|
+
a key everyone has. `aegis init` prints the two commands that replace it with your own — see
|
|
59
|
+
[Signing](docs/configuration.md#signing). The example rules are a demo, not a starting policy;
|
|
60
|
+
replace `constraints.example.yaml` with your own `constraints.yaml` (a real file wins over the
|
|
61
|
+
`.example` one when both exist).
|
|
62
|
+
|
|
63
|
+
### From a clone
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m venv venv
|
|
67
|
+
venv/bin/python -m pip install -e ".[dev]"
|
|
68
|
+
venv/bin/python examples/demo.py
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`examples/demo.py` runs 15 intents across every supported tool through the interceptor and
|
|
72
|
+
prints each decision, starting with the store's health. A clone already has `data/`, so the CLI
|
|
73
|
+
finds its policy files without `aegis init`.
|
|
74
|
+
|
|
75
|
+
Exit codes, store health, the Claude Code hook, argv parsing, compound commands, dry runs and
|
|
76
|
+
library usage are all in [`docs/cli.md`](docs/cli.md).
|
|
77
|
+
|
|
78
|
+
## How a decision is made
|
|
79
|
+
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
At **load time**, `ConstraintStore.load` parses the constraint YAML, verifies each
|
|
83
|
+
`provenance_hash`, and (with `--sources`) re-fetches and checks the original source —
|
|
84
|
+
anything that fails either check is quarantined, not silently dropped.
|
|
85
|
+
|
|
86
|
+
At **decision time**, `AegisInterceptor.intercept` matches each intent against the surviving
|
|
87
|
+
constraints on `(provider, resource_pattern, action, scope, time_window)`, re-checks integrity
|
|
88
|
+
and authority (authority can be revoked after ingestion), applies any rate limit against the
|
|
89
|
+
decision ledger, downgrades a dry run to `ALLOW`, and takes the highest-precedence effect
|
|
90
|
+
(`BLOCK` > `ESCALATE` > `ALLOW`) among what's left. A `PlanConstraint` then runs once more over
|
|
91
|
+
the whole batch of intents from one plan/chart/invocation.
|
|
92
|
+
|
|
93
|
+
## Supported tools
|
|
94
|
+
|
|
95
|
+
Every target below is checked with `aegis check <target> [flags] -- <argv...>`, except
|
|
96
|
+
`terraform`/`tofu`/`pulumi-preview`, which take a JSON document path instead of `--`.
|
|
97
|
+
|
|
98
|
+
| target | example |
|
|
99
|
+
| :--- | :--- |
|
|
100
|
+
| `kubectl` | `aegis check kubectl -- kubectl scale deployment/api-server --replicas=5 -n prod` |
|
|
101
|
+
| `terraform` | `aegis check terraform plan.json` (from `terraform show -json tfplan > plan.json`) |
|
|
102
|
+
| `tofu` | `aegis check tofu plan.json` (identical plan schema; `provider` stays `terraform`) |
|
|
103
|
+
| `pulumi-preview` | `aegis check pulumi-preview preview.json` (from `pulumi preview --json`) |
|
|
104
|
+
| `pulumi` | `aegis check pulumi -- pulumi destroy --stack prod` |
|
|
105
|
+
| `aws` | `aegis check aws -- aws ec2 terminate-instances --instance-ids i-0abc --region us-east-1` |
|
|
106
|
+
| `az` | `aegis check az -- az aks scale --resource-group rg1 --name aks1 --node-count 5` |
|
|
107
|
+
| `gcloud` | `aegis check gcloud -- gcloud sql instances delete prod-db` |
|
|
108
|
+
| `helm` | `aegis check helm -- helm uninstall api -n prod` |
|
|
109
|
+
| `argocd` | `aegis check argocd -- argocd app sync prod-web --prune` |
|
|
110
|
+
| `flux` | `aegis check flux -- flux reconcile kustomization podinfo -n flux-system` |
|
|
111
|
+
| `git` | `aegis check git -- git push --force origin main` |
|
|
112
|
+
| `gh` | `aegis check gh -- gh workflow run deploy-prod.yml -r main` |
|
|
113
|
+
| `psql` | `aegis check psql -- psql -c "DROP TABLE users;"` |
|
|
114
|
+
| `mysql` | `aegis check mysql -- mysql -e "DROP TABLE users;"` |
|
|
115
|
+
| `sqlite3` | `aegis check sqlite3 -- sqlite3 app.db "DELETE FROM users;"` |
|
|
116
|
+
| `mongosh` | `aegis check mongosh -- mongosh --eval "db.users.drop()"` |
|
|
117
|
+
| `migrate` | `aegis check migrate -- alembic downgrade base` |
|
|
118
|
+
| `sql` | `aegis check sql -- "DROP TABLE users;"` |
|
|
119
|
+
| `argv` | `aegis check argv -- gcloud sql instances delete prod-db` (dispatches by binary name) |
|
|
120
|
+
| `command` | `aegis check command -- "kubectl get pods; sudo kubectl delete node/w1"` (a shell string; see [Compound commands](docs/cli.md#compound-commands)) |
|
|
121
|
+
|
|
122
|
+
## Results
|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
`scripts/benchmark.py` runs Aegis and several baselines over the labeled 500-constraint corpus
|
|
127
|
+
in `data/corpus/` (323 distinct rule structures), on 120 held-out intents, scored against an
|
|
128
|
+
oracle that never imports Aegis's own code. The full table
|
|
129
|
+
(precision/recall/F1, latency, coverage) and methodology are in
|
|
130
|
+
[`docs/benchmark.md`](docs/benchmark.md); the columns that matter most are summarized below.
|
|
131
|
+
|
|
132
|
+
| verifier | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
133
|
+
| :--- | ---: | ---: | ---: |
|
|
134
|
+
| **aegis** | **0.000** | **0.000** | **0.000** |
|
|
135
|
+
| codex (gpt-6-astra) | 0.150 | 0.300 | 0.200 |
|
|
136
|
+
| codex-gpt-6-sol | 0.150 | 0.300 | 0.200 |
|
|
137
|
+
| codex-gpt-6-luna | 0.117 | 0.233 | 0.100 |
|
|
138
|
+
| claude-cli (haiku) | 0.150 | 0.300 | 0.200 |
|
|
139
|
+
| claude-cli-sonnet | 0.167 | 0.333 | 0.200 |
|
|
140
|
+
| claude-cli-opus | 0.150 | 0.300 | 0.200 |
|
|
141
|
+
| claude-cli-fable | 0.150 | 0.300 | 0.200 |
|
|
142
|
+
| opa-signed | 0.250 | 0.500 | 1.000 |
|
|
143
|
+
| opa | 0.500 | 1.000 | 1.000 |
|
|
144
|
+
| llm-heuristic | 0.500 | 1.000 | 1.000 |
|
|
145
|
+
| ollama (mistral 7B) | 1.000 | 1.000 | 1.000 |
|
|
146
|
+
|
|
147
|
+
`poison-susceptibility` (`ps + pe`) is the fraction of poisoned rules — constraints an
|
|
148
|
+
unauthorized/tampered/forged author slipped in — that moved a verdict at all, split by kind;
|
|
149
|
+
`ps_unauth + pe_unauth` isolates the realistic pre-ingest attacker (an unauthorized principal).
|
|
150
|
+
It's the headline column because **signing a policy bundle proves it wasn't altered in transit,
|
|
151
|
+
not that its author was ever allowed to write the rule** — `opa-signed` scores `1.000` on it for
|
|
152
|
+
exactly that reason, while Aegis's independent authority check scores `0.000`.
|
|
153
|
+
|
|
154
|
+
The `codex*` and `claude-cli*` rows are **agent harnesses wrapped around a model**, not raw
|
|
155
|
+
completions — seven models across two vendors (Claude Haiku 4.5, Sonnet 5, Opus 5 and Fable
|
|
156
|
+
5.1; GPT `gpt-6-astra`, `gpt-6-sol` and `gpt-6-luna`), each verified before its run to be the
|
|
157
|
+
model that actually answered — and — unlike the other rows — are scored against a
|
|
158
|
+
100-constraint holdout subset rather than the full 500-constraint store, for context-window and
|
|
159
|
+
cost reasons; see [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything
|
|
160
|
+
else in the table. Model size and vendor do not change the picture: all seven land between
|
|
161
|
+
0.23 and 0.33 poison-susceptibility, and every one acts on 5 of 8 forged rules — a model can
|
|
162
|
+
reason about who wrote a rule, but not recompute a hash or fetch a source.
|
|
163
|
+
|
|
164
|
+
## Why not OPA/Gatekeeper?
|
|
165
|
+
|
|
166
|
+
OPA/Gatekeeper evaluates **structured API objects** against **hand-authored rules**. Aegis
|
|
167
|
+
derives **unstructured human constraints** (from Slack, Jira, Git) and applies
|
|
168
|
+
**authority-driven validation** to the agent's intent *before* it reaches the infrastructure.
|
|
169
|
+
|
|
170
|
+
## Project status / roadmap
|
|
171
|
+
|
|
172
|
+
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
173
|
+
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
174
|
+
v0.1.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
175
|
+
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
176
|
+
through the Codex CLI, and a local `mistral:latest`.
|
|
177
|
+
|
|
178
|
+
What is *not* done is the part the threat model leans on hardest. See `PLAN.md §8`, but in
|
|
179
|
+
short: sources are verified against files on disk rather than real Git/Slack/Jira connectors,
|
|
180
|
+
signing uses a shared secret rather than per-principal public keys, and a `principal` is a
|
|
181
|
+
signed name rather than an identity bound to a commit signature or an SSO group. Until those
|
|
182
|
+
land, Aegis demonstrates that the *decision procedure* is sound; it does not yet prove the
|
|
183
|
+
identities feeding it are. Resource matching is also case-sensitive on names. It is not
|
|
184
|
+
production-ready: read `PLAN.md §8` for the full list of open gaps before putting it in front
|
|
185
|
+
of anything you care about.
|
|
186
|
+
|
|
187
|
+
## Documentation
|
|
188
|
+
|
|
189
|
+
| Page | Covers |
|
|
190
|
+
| :--- | :--- |
|
|
191
|
+
| [`docs/cli.md`](docs/cli.md) | Exit codes, store health, the Claude Code hook, argv forms, compound commands, dry runs, library usage |
|
|
192
|
+
| [`docs/constraints.md`](docs/constraints.md) | Writing constraints, metadata vocabulary, authority policy, environment mapping |
|
|
193
|
+
| [`docs/configuration.md`](docs/configuration.md) | Configuration/config-dir discovery, signing, source verification, rate limits & ledger |
|
|
194
|
+
| [`docs/benchmark.md`](docs/benchmark.md) | Benchmark methodology, full results table, real LLM baselines, agent-harness baselines, corpus, adversarial suite |
|
|
195
|
+
| [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) | Adding a new parser or tool |
|
|
196
|
+
| [`PLAN.md`](PLAN.md) | Design plan and open gaps |
|
|
197
|
+
| [`SECURITY.md`](SECURITY.md) | Reporting a bypass |
|
|
198
|
+
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
|
199
|
+
| [`REVIEW-4.md`](REVIEW-4.md) | Corpus/oracle rewrite (independent oracle, intent-level holdout) |
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
venv/bin/python -m pytest -q # 1257 passed (last full green run; CI runs this on 3.11/3.13/3.14)
|
|
205
|
+
venv/bin/python -m ruff check src tests scripts examples
|
|
206
|
+
vhs docs/demo.tape # regenerate docs/demo.gif
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Adding a new parser or tool: see [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md).
|
|
210
|
+
Reporting a bypass: see [`SECURITY.md`](SECURITY.md) — parser evasion is the largest
|
|
211
|
+
attack surface and the most useful thing to report. Release history is in
|
|
212
|
+
[`CHANGELOG.md`](CHANGELOG.md).
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
Apache-2.0 — see [LICENSE](LICENSE).
|
|
@@ -78,6 +78,9 @@ _CODEX_BASE_ARGS = [
|
|
|
78
78
|
|
|
79
79
|
_VERDICT_LINE_RE = re.compile(r"^\s*(ALLOW|BLOCK|ESCALATE)\b.*$", re.IGNORECASE | re.MULTILINE)
|
|
80
80
|
_MODEL_BANNER_RE = re.compile(r"^model:\s*(\S+)\s*$", re.MULTILINE)
|
|
81
|
+
# Claude Code's usage-limit reply, e.g. "You've hit your session limit · resets 2:10am".
|
|
82
|
+
_CLAUDE_LIMIT_RE = re.compile(r"(session|usage|rate|weekly) limit|limit reached", re.I)
|
|
83
|
+
_CLAUDE_RESET_RE = re.compile(r"resets?\s+(?:at\s+)?([0-9]{1,2}(?::[0-9]{2})?\s?(?:am|pm)?)", re.I)
|
|
81
84
|
_TOKENS_USED_RE = re.compile(r"tokens used\s*\n\s*([\d,]+)", re.IGNORECASE)
|
|
82
85
|
|
|
83
86
|
|
|
@@ -154,10 +157,20 @@ class CodexCliClient:
|
|
|
154
157
|
# <time>") land on stderr, not stdout -- combine both so a
|
|
155
158
|
# quota error doesn't silently look like an empty response.
|
|
156
159
|
combined = stdout + "\n" + stderr if stderr else stdout
|
|
160
|
+
if re.search(r"usage limit", combined, re.I):
|
|
161
|
+
when = re.search(r"try again at ([^.\n]+)", combined, re.I)
|
|
162
|
+
raise QuotaExceededError(
|
|
163
|
+
"codex usage limit reached", when.group(1).strip() if when else None
|
|
164
|
+
)
|
|
157
165
|
|
|
158
166
|
model_match = _MODEL_BANNER_RE.search(combined)
|
|
159
167
|
if model_match:
|
|
160
168
|
self.resolved_model = model_match.group(1)
|
|
169
|
+
if self.model and self.resolved_model != self.model:
|
|
170
|
+
raise ModelMismatchError(
|
|
171
|
+
f"requested codex model {self.model!r} but the CLI ran "
|
|
172
|
+
f"{self.resolved_model!r}"
|
|
173
|
+
)
|
|
161
174
|
tokens_match = _TOKENS_USED_RE.search(combined)
|
|
162
175
|
if tokens_match:
|
|
163
176
|
self.last_tokens_used = int(tokens_match.group(1).replace(",", ""))
|
|
@@ -300,6 +313,27 @@ class OllamaClient:
|
|
|
300
313
|
_AUTH_FAILURE_RE = re.compile(r"\b(401|authenticate|oauth)\b", re.IGNORECASE)
|
|
301
314
|
|
|
302
315
|
|
|
316
|
+
class QuotaExceededError(RuntimeError):
|
|
317
|
+
"""The CLI refused the call because the account hit a usage limit.
|
|
318
|
+
|
|
319
|
+
``resets_at`` is the reset time as the CLI printed it ("10:44 PM",
|
|
320
|
+
"2:10am"), or None. Never retried and never cached, so a runner can wait
|
|
321
|
+
for the reset and resume from the recorded answers."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, message: str, resets_at: str | None = None):
|
|
324
|
+
super().__init__(message)
|
|
325
|
+
self.resets_at = resets_at
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class ModelMismatchError(RuntimeError):
|
|
329
|
+
"""The CLI answered with a different model than the one requested.
|
|
330
|
+
|
|
331
|
+
Agent CLIs can fall back to an account default without failing (a ChatGPT
|
|
332
|
+
account's Codex did exactly that), which would put one model's numbers under
|
|
333
|
+
another model's name. Never retried: a mismatch is a configuration problem,
|
|
334
|
+
not a transient one."""
|
|
335
|
+
|
|
336
|
+
|
|
303
337
|
class ClaudeCliAuthError(RuntimeError):
|
|
304
338
|
"""Raised when ``claude -p`` reports ``is_error: true`` with a result
|
|
305
339
|
that looks like an expired/missing OAuth session (see
|
|
@@ -421,8 +455,24 @@ class ClaudeCliClient:
|
|
|
421
455
|
is_error = bool(envelope.get("is_error"))
|
|
422
456
|
if is_error and _AUTH_FAILURE_RE.search(result_text):
|
|
423
457
|
raise ClaudeCliAuthError("claude CLI is not authenticated: run 'claude login'")
|
|
458
|
+
if is_error and _CLAUDE_LIMIT_RE.search(result_text):
|
|
459
|
+
when = _CLAUDE_RESET_RE.search(result_text)
|
|
460
|
+
raise QuotaExceededError(
|
|
461
|
+
f"claude usage limit reached: {result_text[:120]}",
|
|
462
|
+
when.group(1).strip() if when else None,
|
|
463
|
+
)
|
|
424
464
|
|
|
425
465
|
self.last_model_usage = envelope.get("modelUsage")
|
|
466
|
+
family = self.model.lower() if self.model else ""
|
|
467
|
+
known_family = family in ("haiku", "sonnet", "opus", "fable")
|
|
468
|
+
if known_family and isinstance(self.last_model_usage, dict):
|
|
469
|
+
used = list(self.last_model_usage)
|
|
470
|
+
# Claude Code may also call a small model for side tasks, so require the
|
|
471
|
+
# requested family to be present rather than to be the only one.
|
|
472
|
+
if used and not any(f"claude-{family}-" in m for m in used):
|
|
473
|
+
raise ModelMismatchError(
|
|
474
|
+
f"requested claude {family!r} but the CLI reported {used}"
|
|
475
|
+
)
|
|
426
476
|
self.last_tokens_used = _sum_token_usage(self.last_model_usage)
|
|
427
477
|
self.last_session_id = envelope.get("session_id")
|
|
428
478
|
self.last_num_turns = envelope.get("num_turns")
|
|
@@ -474,7 +524,7 @@ class RetryingClient:
|
|
|
474
524
|
for _ in range(attempts):
|
|
475
525
|
try:
|
|
476
526
|
return self.inner.complete(system, user)
|
|
477
|
-
except ClaudeCliAuthError:
|
|
527
|
+
except (ClaudeCliAuthError, ModelMismatchError, QuotaExceededError):
|
|
478
528
|
raise
|
|
479
529
|
except RuntimeError as exc:
|
|
480
530
|
last_exc = exc
|
|
@@ -280,12 +280,29 @@ class RecordingClient:
|
|
|
280
280
|
self.cache_path = Path(cache_path)
|
|
281
281
|
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
282
|
self.model = model
|
|
283
|
+
# Resume: answers already on disk are reused, so a run interrupted by a
|
|
284
|
+
# usage limit continues where it stopped instead of re-asking (and
|
|
285
|
+
# re-paying for) every prompt. Identical prompts are asked once.
|
|
286
|
+
self._known: dict[str, str] = {}
|
|
287
|
+
if self.cache_path.exists():
|
|
288
|
+
for line in self.cache_path.read_text().splitlines():
|
|
289
|
+
if line.strip():
|
|
290
|
+
row = json.loads(line)
|
|
291
|
+
if (row.get("response") or "").strip():
|
|
292
|
+
self._known[row["key"]] = row["response"]
|
|
293
|
+
self.reused = 0
|
|
283
294
|
|
|
284
295
|
def complete(self, system: str, user: str) -> str:
|
|
296
|
+
key = _cache_key(system, user, self.model)
|
|
297
|
+
if key in self._known:
|
|
298
|
+
self.reused += 1
|
|
299
|
+
return self._known[key]
|
|
300
|
+
# A usage-limit error raises out of inner.complete, so it is never cached.
|
|
285
301
|
response = self.inner.complete(system, user)
|
|
286
|
-
entry = _CacheEntry(key=
|
|
302
|
+
entry = _CacheEntry(key=key, response=response)
|
|
287
303
|
with open(self.cache_path, "a") as f:
|
|
288
304
|
f.write(json.dumps({"key": entry.key, "response": entry.response}) + "\n")
|
|
305
|
+
self._known[key] = response
|
|
289
306
|
return response
|
|
290
307
|
|
|
291
308
|
|
|
@@ -838,6 +838,23 @@ class ConstraintStore:
|
|
|
838
838
|
self.constraints[constraint.id] = constraint
|
|
839
839
|
return constraint
|
|
840
840
|
|
|
841
|
+
def _candidates(self, intent: InfrastructureIntent) -> list[Constraint]:
|
|
842
|
+
"""Every loaded constraint that could possibly apply to ``intent``: the
|
|
843
|
+
``(provider, action)`` and ``(provider, "*")`` buckets of the index.
|
|
844
|
+
|
|
845
|
+
Each matcher below rejects a constraint unless its provider equals the
|
|
846
|
+
intent's and its actions contain the intent's action or ``"*"``, which is
|
|
847
|
+
exactly the index key -- so these buckets are a superset of anything any
|
|
848
|
+
of them can return. All three draw from here so they cannot drift apart,
|
|
849
|
+
and a decision costs O(k) in the plausible constraints, not O(n) in the
|
|
850
|
+
store (REVIEW-4 T2.3)."""
|
|
851
|
+
candidates = self._index.get((intent.provider, intent.action), [])
|
|
852
|
+
wildcard = self._index.get((intent.provider, ANY_ACTION), [])
|
|
853
|
+
if wildcard:
|
|
854
|
+
seen = {id(c) for c in candidates}
|
|
855
|
+
candidates = candidates + [c for c in wildcard if id(c) not in seen]
|
|
856
|
+
return candidates
|
|
857
|
+
|
|
841
858
|
def get_matching_constraints(
|
|
842
859
|
self, intent: InfrastructureIntent, now: datetime
|
|
843
860
|
) -> list[Constraint]:
|
|
@@ -848,14 +865,9 @@ class ConstraintStore:
|
|
|
848
865
|
constraints that could plausibly match, not O(n) in the whole
|
|
849
866
|
store -- rather than every loaded constraint."""
|
|
850
867
|
_require_tz_aware(now)
|
|
851
|
-
candidates = self._index.get((intent.provider, intent.action), [])
|
|
852
|
-
wildcard = self._index.get((intent.provider, ANY_ACTION), [])
|
|
853
|
-
if wildcard:
|
|
854
|
-
seen = {id(c) for c in candidates}
|
|
855
|
-
candidates = candidates + [c for c in wildcard if id(c) not in seen]
|
|
856
868
|
return [
|
|
857
869
|
c
|
|
858
|
-
for c in
|
|
870
|
+
for c in self._candidates(intent)
|
|
859
871
|
if _constraint_matches(c, intent, now, self.default_tz, self.tzdata_available)
|
|
860
872
|
]
|
|
861
873
|
|
|
@@ -867,7 +879,7 @@ class ConstraintStore:
|
|
|
867
879
|
_require_tz_aware(now)
|
|
868
880
|
return [
|
|
869
881
|
c
|
|
870
|
-
for c in self.
|
|
882
|
+
for c in self._candidates(intent)
|
|
871
883
|
if _matches_except_scope(c, intent, now, self.default_tz, self.tzdata_available)
|
|
872
884
|
and env_unresolved(c.scope, intent)
|
|
873
885
|
]
|
|
@@ -884,7 +896,7 @@ class ConstraintStore:
|
|
|
884
896
|
_require_tz_aware(now)
|
|
885
897
|
return [
|
|
886
898
|
c
|
|
887
|
-
for c in self.
|
|
899
|
+
for c in self._candidates(intent)
|
|
888
900
|
if time_window_unresolved(c, intent, now, self.default_tz, self.tzdata_available)
|
|
889
901
|
]
|
|
890
902
|
|