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.
Files changed (99) hide show
  1. aegis_devops-0.1.2/PKG-INFO +248 -0
  2. aegis_devops-0.1.2/README.md +216 -0
  3. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/__init__.py +1 -1
  4. aegis_devops-0.1.2/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  5. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/external.py +51 -1
  6. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/llm.py +18 -1
  7. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/store.py +20 -8
  8. aegis_devops-0.1.2/src/aegis_devops.egg-info/PKG-INFO +248 -0
  9. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/SOURCES.txt +1 -0
  10. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_baselines.py +159 -0
  11. aegis_devops-0.1.2/tests/test_docs_consistency.py +140 -0
  12. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_store.py +97 -18
  13. aegis_devops-0.1.0/PKG-INFO +0 -933
  14. aegis_devops-0.1.0/README.md +0 -901
  15. aegis_devops-0.1.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  16. aegis_devops-0.1.0/src/aegis_devops.egg-info/PKG-INFO +0 -933
  17. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/LICENSE +0 -0
  18. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/MANIFEST.in +0 -0
  19. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/pyproject.toml +0 -0
  20. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/setup.cfg +0 -0
  21. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/__init__.py +0 -0
  22. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml +0 -0
  23. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  24. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml +0 -0
  25. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
  26. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml +0 -0
  27. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  28. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/example-signing.key +0 -0
  29. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  30. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  31. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
  32. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
  33. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  34. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  35. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  36. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  37. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  38. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  39. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  40. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  41. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  42. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  43. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  44. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  45. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  46. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  47. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  48. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  49. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  50. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  51. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  52. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  53. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  54. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  55. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  56. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  57. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  58. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/adversarial.py +0 -0
  59. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/authority.py +0 -0
  60. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/__init__.py +0 -0
  61. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/base.py +0 -0
  62. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/metrics.py +0 -0
  63. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/baselines/opa.py +0 -0
  64. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/cli.py +0 -0
  65. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/config.py +0 -0
  66. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/environments.py +0 -0
  67. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/intent.py +0 -0
  68. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/interceptor.py +0 -0
  69. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/ledger.py +0 -0
  70. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parser.py +0 -0
  71. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parsers/__init__.py +0 -0
  72. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parsers/pulumi.py +0 -0
  73. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/parsers/sql.py +0 -0
  74. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/plan.py +0 -0
  75. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/provenance.py +0 -0
  76. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/shell.py +0 -0
  77. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_core/signing.py +0 -0
  78. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  79. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  80. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/requires.txt +0 -0
  81. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/top_level.txt +0 -0
  82. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_adversarial.py +0 -0
  83. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_authority.py +0 -0
  84. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_benchmark.py +0 -0
  85. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_cli.py +0 -0
  86. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_config.py +0 -0
  87. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_corpus.py +0 -0
  88. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_environments.py +0 -0
  89. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_intent.py +0 -0
  90. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_interceptor.py +0 -0
  91. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_ledger.py +0 -0
  92. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_parser.py +0 -0
  93. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_parsers_pulumi.py +0 -0
  94. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_parsers_sql.py +0 -0
  95. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_plan.py +0 -0
  96. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_provenance.py +0 -0
  97. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_shell.py +0 -0
  98. {aegis_devops-0.1.0 → aegis_devops-0.1.2}/tests/test_signing.py +0 -0
  99. {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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22950337.svg)](https://doi.org/10.5281/zenodo.22950337)
36
+ [![PyPI](https://img.shields.io/pypi/v/aegis-devops)](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
+ ![Aegis demo](docs/demo.gif)
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
+ ![Aegis architecture](docs/architecture.svg)
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
+ ![Decision pipeline](docs/decision-pipeline.svg)
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
+ ![Benchmark results](docs/benchmark.svg)
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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22950337.svg)](https://doi.org/10.5281/zenodo.22950337)
4
+ [![PyPI](https://img.shields.io/pypi/v/aegis-devops)](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
+ ![Aegis demo](docs/demo.gif)
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
+ ![Aegis architecture](docs/architecture.svg)
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
+ ![Decision pipeline](docs/decision-pipeline.svg)
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
+ ![Benchmark results](docs/benchmark.svg)
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).
@@ -4,4 +4,4 @@ infrastructure actions of AI agents.
4
4
  See the README for what this does and, importantly, what it does not yet do.
5
5
  """
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.1.2"
@@ -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=_cache_key(system, user, self.model), response=response)
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 candidates
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.constraints.values()
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.constraints.values()
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