aegis-devops 0.1.1__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 (96) hide show
  1. {aegis_devops-0.1.1/src/aegis_devops.egg-info → aegis_devops-0.1.2}/PKG-INFO +18 -5
  2. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/README.md +17 -4
  3. {aegis_devops-0.1.1 → 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.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/external.py +51 -1
  6. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/llm.py +18 -1
  7. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/store.py +20 -8
  8. {aegis_devops-0.1.1 → aegis_devops-0.1.2/src/aegis_devops.egg-info}/PKG-INFO +18 -5
  9. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_baselines.py +159 -0
  10. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_docs_consistency.py +15 -0
  11. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_store.py +97 -18
  12. aegis_devops-0.1.1/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  13. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/LICENSE +0 -0
  14. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/MANIFEST.in +0 -0
  15. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/pyproject.toml +0 -0
  16. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/setup.cfg +0 -0
  17. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/__init__.py +0 -0
  18. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml +0 -0
  19. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  20. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml +0 -0
  21. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
  22. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml +0 -0
  23. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  24. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/example-signing.key +0 -0
  25. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  26. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  27. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
  28. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
  29. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  30. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  31. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  32. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  33. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  34. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  35. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  36. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  37. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  38. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  39. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  40. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  41. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  42. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  43. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  44. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  45. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  46. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  47. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  48. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  49. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  50. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  51. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  52. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  53. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  54. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/adversarial.py +0 -0
  55. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/authority.py +0 -0
  56. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/__init__.py +0 -0
  57. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/base.py +0 -0
  58. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/metrics.py +0 -0
  59. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/opa.py +0 -0
  60. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/cli.py +0 -0
  61. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/config.py +0 -0
  62. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/environments.py +0 -0
  63. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/intent.py +0 -0
  64. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/interceptor.py +0 -0
  65. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/ledger.py +0 -0
  66. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parser.py +0 -0
  67. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parsers/__init__.py +0 -0
  68. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parsers/pulumi.py +0 -0
  69. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parsers/sql.py +0 -0
  70. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/plan.py +0 -0
  71. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/provenance.py +0 -0
  72. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/shell.py +0 -0
  73. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/signing.py +0 -0
  74. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/SOURCES.txt +0 -0
  75. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  76. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  77. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/requires.txt +0 -0
  78. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/top_level.txt +0 -0
  79. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_adversarial.py +0 -0
  80. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_authority.py +0 -0
  81. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_benchmark.py +0 -0
  82. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_cli.py +0 -0
  83. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_config.py +0 -0
  84. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_corpus.py +0 -0
  85. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_environments.py +0 -0
  86. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_intent.py +0 -0
  87. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_interceptor.py +0 -0
  88. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_ledger.py +0 -0
  89. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_parser.py +0 -0
  90. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_parsers_pulumi.py +0 -0
  91. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_parsers_sql.py +0 -0
  92. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_plan.py +0 -0
  93. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_provenance.py +0 -0
  94. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_shell.py +0 -0
  95. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_signing.py +0 -0
  96. {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_store_match.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aegis-devops
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Provenance-backed, authority-aware policy verifier for agentic DevOps actions.
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/moneytool/aegis-devops
@@ -32,6 +32,9 @@ Dynamic: license-file
32
32
 
33
33
  # Aegis-DevOps — Policy Verifier for AI DevOps Agents
34
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
+
35
38
  **Provenance-backed, authority-aware guardrails that stop context poisoning and agentic drift
36
39
  before an AI agent's `kubectl` or `terraform` action reaches your infrastructure.**
37
40
 
@@ -162,7 +165,12 @@ oracle that never imports Aegis's own code. The full table
162
165
  | :--- | ---: | ---: | ---: |
163
166
  | **aegis** | **0.000** | **0.000** | **0.000** |
164
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 |
165
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 |
166
174
  | opa-signed | 0.250 | 0.500 | 1.000 |
167
175
  | opa | 0.500 | 1.000 | 1.000 |
168
176
  | llm-heuristic | 0.500 | 1.000 | 1.000 |
@@ -175,10 +183,15 @@ It's the headline column because **signing a policy bundle proves it wasn't alte
175
183
  not that its author was ever allowed to write the rule** — `opa-signed` scores `1.000` on it for
176
184
  exactly that reason, while Aegis's independent authority check scores `0.000`.
177
185
 
178
- The `codex` and `claude-cli` rows are **agent harnesses wrapped around a model**, not raw
179
- completions, and — unlike the other rows — are scored against a 100-constraint holdout subset
180
- rather than the full 500-constraint store, for context-window and cost reasons; see
181
- [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything else in the table.
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.
182
195
 
183
196
  ## Why not OPA/Gatekeeper?
184
197
 
@@ -1,5 +1,8 @@
1
1
  # Aegis-DevOps — Policy Verifier for AI DevOps Agents
2
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
+
3
6
  **Provenance-backed, authority-aware guardrails that stop context poisoning and agentic drift
4
7
  before an AI agent's `kubectl` or `terraform` action reaches your infrastructure.**
5
8
 
@@ -130,7 +133,12 @@ oracle that never imports Aegis's own code. The full table
130
133
  | :--- | ---: | ---: | ---: |
131
134
  | **aegis** | **0.000** | **0.000** | **0.000** |
132
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 |
133
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 |
134
142
  | opa-signed | 0.250 | 0.500 | 1.000 |
135
143
  | opa | 0.500 | 1.000 | 1.000 |
136
144
  | llm-heuristic | 0.500 | 1.000 | 1.000 |
@@ -143,10 +151,15 @@ It's the headline column because **signing a policy bundle proves it wasn't alte
143
151
  not that its author was ever allowed to write the rule** — `opa-signed` scores `1.000` on it for
144
152
  exactly that reason, while Aegis's independent authority check scores `0.000`.
145
153
 
146
- The `codex` and `claude-cli` rows are **agent harnesses wrapped around a model**, not raw
147
- completions, and — unlike the other rows — are scored against a 100-constraint holdout subset
148
- rather than the full 500-constraint store, for context-window and cost reasons; see
149
- [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything else in the table.
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.
150
163
 
151
164
  ## Why not OPA/Gatekeeper?
152
165
 
@@ -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.1"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aegis-devops
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Provenance-backed, authority-aware policy verifier for agentic DevOps actions.
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/moneytool/aegis-devops
@@ -32,6 +32,9 @@ Dynamic: license-file
32
32
 
33
33
  # Aegis-DevOps — Policy Verifier for AI DevOps Agents
34
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
+
35
38
  **Provenance-backed, authority-aware guardrails that stop context poisoning and agentic drift
36
39
  before an AI agent's `kubectl` or `terraform` action reaches your infrastructure.**
37
40
 
@@ -162,7 +165,12 @@ oracle that never imports Aegis's own code. The full table
162
165
  | :--- | ---: | ---: | ---: |
163
166
  | **aegis** | **0.000** | **0.000** | **0.000** |
164
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 |
165
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 |
166
174
  | opa-signed | 0.250 | 0.500 | 1.000 |
167
175
  | opa | 0.500 | 1.000 | 1.000 |
168
176
  | llm-heuristic | 0.500 | 1.000 | 1.000 |
@@ -175,10 +183,15 @@ It's the headline column because **signing a policy bundle proves it wasn't alte
175
183
  not that its author was ever allowed to write the rule** — `opa-signed` scores `1.000` on it for
176
184
  exactly that reason, while Aegis's independent authority check scores `0.000`.
177
185
 
178
- The `codex` and `claude-cli` rows are **agent harnesses wrapped around a model**, not raw
179
- completions, and — unlike the other rows — are scored against a 100-constraint holdout subset
180
- rather than the full 500-constraint store, for context-window and cost reasons; see
181
- [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything else in the table.
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.
182
195
 
183
196
  ## Why not OPA/Gatekeeper?
184
197
 
@@ -831,3 +831,162 @@ def test_cache_key_includes_model_name_and_avoids_collisions(tmp_path):
831
831
  replay_untagged = ReplayClient(cache_path)
832
832
  with pytest.raises(KeyError):
833
833
  replay_untagged.complete("same system prompt", "same user turn")
834
+
835
+
836
+ # --- ModelMismatchError: a CLI must not silently answer with another model ----------
837
+
838
+ def test_codex_banner_naming_another_model_raises_mismatch(monkeypatch, tmp_path):
839
+ import subprocess as sp
840
+
841
+ from aegis_core.baselines import external
842
+
843
+ def fake_run(cmd, **kwargs):
844
+ return sp.CompletedProcess(cmd, 0, stdout=b"model: gpt-6-astra\ncodex\nALLOW\n", stderr=b"")
845
+
846
+ monkeypatch.setattr(external.shutil, "which", lambda _: "/usr/bin/codex")
847
+ monkeypatch.setattr(external.subprocess, "run", fake_run)
848
+ client = external.CodexCliClient(model="gpt-6-sol")
849
+ with pytest.raises(external.ModelMismatchError, match="gpt-6-sol"):
850
+ client.complete("sys", "user")
851
+
852
+
853
+ def test_claude_cli_model_usage_without_requested_family_raises_mismatch(monkeypatch):
854
+ import json as _json
855
+ import subprocess as sp
856
+
857
+ from aegis_core.baselines import external
858
+
859
+ envelope = {"result": "ALLOW", "is_error": False,
860
+ "modelUsage": {"claude-haiku-4-5-20251001": {"inputTokens": 1}}}
861
+
862
+ def fake_run(cmd, **kwargs):
863
+ return sp.CompletedProcess(cmd, 0, stdout=_json.dumps(envelope).encode(), stderr=b"")
864
+
865
+ monkeypatch.setattr(external.shutil, "which", lambda _: "/usr/bin/claude")
866
+ monkeypatch.setattr(external.subprocess, "run", fake_run)
867
+ with pytest.raises(external.ModelMismatchError, match="opus"):
868
+ external.ClaudeCliClient(model="opus").complete("sys", "user")
869
+
870
+
871
+ def test_claude_cli_side_model_alongside_requested_family_is_accepted(monkeypatch):
872
+ import json as _json
873
+ import subprocess as sp
874
+
875
+ from aegis_core.baselines import external
876
+
877
+ envelope = {"result": "BLOCK", "is_error": False,
878
+ "modelUsage": {"claude-opus-5": {"inputTokens": 1},
879
+ "claude-haiku-4-5-20251001": {"inputTokens": 1}}}
880
+
881
+ def fake_run(cmd, **kwargs):
882
+ return sp.CompletedProcess(cmd, 0, stdout=_json.dumps(envelope).encode(), stderr=b"")
883
+
884
+ monkeypatch.setattr(external.shutil, "which", lambda _: "/usr/bin/claude")
885
+ monkeypatch.setattr(external.subprocess, "run", fake_run)
886
+ assert external.ClaudeCliClient(model="opus").complete("sys", "user") == "BLOCK"
887
+
888
+
889
+ def test_retrying_client_does_not_retry_a_model_mismatch():
890
+ from aegis_core.baselines import external
891
+
892
+ calls = []
893
+
894
+ class Mismatching:
895
+ def complete(self, system, user):
896
+ calls.append(1)
897
+ raise external.ModelMismatchError("wrong model")
898
+
899
+ with pytest.raises(external.ModelMismatchError):
900
+ external.RetryingClient(Mismatching()).complete("s", "u")
901
+ assert len(calls) == 1
902
+
903
+
904
+ # --- resume + usage limits: what a long benchmark run depends on ---------------------
905
+
906
+ def test_recording_client_resumes_from_its_own_cache(tmp_path):
907
+ from aegis_core.baselines.llm import RecordingClient
908
+
909
+ calls = []
910
+
911
+ class Model:
912
+ def complete(self, system, user):
913
+ calls.append(user)
914
+ return f"ALLOW {user}"
915
+
916
+ cache = tmp_path / "c.jsonl"
917
+ first = RecordingClient(Model(), cache, model="m")
918
+ first.complete("s", "a")
919
+ first.complete("s", "b")
920
+ # a new run over the same cache must not re-ask a or b
921
+ second = RecordingClient(Model(), cache, model="m")
922
+ assert second.complete("s", "a") == "ALLOW a"
923
+ second.complete("s", "c")
924
+ assert calls == ["a", "b", "c"] and second.reused == 1
925
+
926
+
927
+ def test_recording_client_asks_an_identical_prompt_once(tmp_path):
928
+ from aegis_core.baselines.llm import RecordingClient
929
+
930
+ calls = []
931
+
932
+ class Model:
933
+ def complete(self, system, user):
934
+ calls.append(user)
935
+ return "BLOCK"
936
+
937
+ rc = RecordingClient(Model(), tmp_path / "c.jsonl")
938
+ rc.complete("s", "same")
939
+ rc.complete("s", "same")
940
+ assert calls == ["same"]
941
+
942
+
943
+ def test_quota_error_is_neither_retried_nor_cached(tmp_path):
944
+ from aegis_core.baselines.external import QuotaExceededError, RetryingClient
945
+ from aegis_core.baselines.llm import RecordingClient
946
+
947
+ calls = []
948
+
949
+ class Limited:
950
+ def complete(self, system, user):
951
+ calls.append(1)
952
+ raise QuotaExceededError("limit", "10:44 PM")
953
+
954
+ cache = tmp_path / "c.jsonl"
955
+ with pytest.raises(QuotaExceededError) as exc:
956
+ RecordingClient(RetryingClient(Limited()), cache).complete("s", "u")
957
+ assert exc.value.resets_at == "10:44 PM"
958
+ assert len(calls) == 1
959
+ assert not cache.exists() or cache.read_text() == ""
960
+
961
+
962
+ def test_codex_usage_limit_raises_quota_with_reset_time(monkeypatch):
963
+ import subprocess as sp
964
+
965
+ from aegis_core.baselines import external
966
+
967
+ def fake_run(cmd, **kwargs):
968
+ err = b"ERROR: You've hit your usage limit. try again at 10:44 PM."
969
+ return sp.CompletedProcess(cmd, 1, stdout=b"", stderr=err)
970
+
971
+ monkeypatch.setattr(external.shutil, "which", lambda _: "/usr/bin/codex")
972
+ monkeypatch.setattr(external.subprocess, "run", fake_run)
973
+ with pytest.raises(external.QuotaExceededError) as exc:
974
+ external.CodexCliClient(model="gpt-6-luna").complete("s", "u")
975
+ assert exc.value.resets_at == "10:44 PM"
976
+
977
+
978
+ def test_claude_session_limit_raises_quota_with_reset_time(monkeypatch):
979
+ import json as _json
980
+ import subprocess as sp
981
+
982
+ from aegis_core.baselines import external
983
+
984
+ env = {"is_error": True, "modelUsage": {},
985
+ "result": "You've hit your session limit · resets 2:10am"}
986
+ out = _json.dumps(env).encode()
987
+ monkeypatch.setattr(external.shutil, "which", lambda _: "/usr/bin/claude")
988
+ monkeypatch.setattr(external.subprocess, "run",
989
+ lambda cmd, **k: sp.CompletedProcess(cmd, 0, stdout=out, stderr=b""))
990
+ with pytest.raises(external.QuotaExceededError) as exc:
991
+ external.ClaudeCliClient(model="fable").complete("s", "u")
992
+ assert exc.value.resets_at == "2:10am"
@@ -123,3 +123,18 @@ def test_docs_do_not_describe_escalate_as_the_default(doc):
123
123
 
124
124
  def test_changelog_has_an_entry_for_the_package_version():
125
125
  assert f"## [{__version__}]" in (ROOT / "CHANGELOG.md").read_text()
126
+
127
+
128
+ def test_latency_svg_is_regenerated_from_current_results(tmp_path):
129
+ """docs/latency.svg used to be hand-drawn; it is generated now and must match."""
130
+ committed = (ROOT / "docs" / "latency.svg").read_text()
131
+ for d in ("results", "docs", "scripts"):
132
+ (tmp_path / d).mkdir()
133
+ latency = (ROOT / "results" / "latency.json").read_text()
134
+ (tmp_path / "results" / "latency.json").write_text(latency)
135
+ script = tmp_path / "scripts" / "make_latency_svg.py"
136
+ script.write_text((ROOT / "scripts" / "make_latency_svg.py").read_text())
137
+ subprocess.run([sys.executable, str(script)], check=True, capture_output=True)
138
+ assert (tmp_path / "docs" / "latency.svg").read_text() == committed, (
139
+ "docs/latency.svg is stale: run venv/bin/python scripts/make_latency_svg.py"
140
+ )
@@ -997,28 +997,31 @@ def test_get_matching_constraints_finds_direct_dict_assignments_and_overwrites()
997
997
  assert store.get_matching_constraints(intent, now) == []
998
998
 
999
999
 
1000
- def test_matching_10k_constraints_against_500_intents_is_fast(tmp_path):
1000
+ def test_matching_10k_constraints_examines_only_the_indexed_bucket(tmp_path, monkeypatch):
1001
1001
  """REVIEW-4 T2.3: matching should be O(k) in the constraints that could
1002
- plausibly apply, not O(n) in the whole store -- generous bound, the
1003
- point is the scaling, not a tight latency number."""
1004
- import time as _time
1002
+ plausibly apply, not O(n) in the whole store.
1003
+
1004
+ This used to assert a wall-clock bound (500 intents in < 0.5 s). That was
1005
+ flaky on shared CI runners -- it failed once at 0.513 s on a change that
1006
+ touched no code -- and it only tested the scaling indirectly. Counting how
1007
+ many constraints the matcher actually examines tests it directly and is
1008
+ deterministic. Real latency is measured by scripts/latency_sweep.py."""
1005
1009
  from datetime import UTC, datetime
1006
1010
 
1007
1011
  import yaml
1008
1012
 
1013
+ from aegis_core import store as store_module
1009
1014
  from aegis_core.intent import InfrastructureIntent
1010
1015
 
1011
1016
  providers = ["kubernetes", "terraform", "helm", "aws", "gcp"]
1012
1017
  actions = [f"action-{j}" for j in range(20)]
1013
1018
  entries = []
1014
1019
  for i in range(10_000):
1015
- provider = providers[i % len(providers)]
1016
- action = actions[i % len(actions)]
1017
1020
  c = make_constraint(
1018
1021
  id=f"c-{i}",
1019
- provider=provider,
1022
+ provider=providers[i % len(providers)],
1020
1023
  resource_pattern=f"kind{i % 50}/*",
1021
- actions={action},
1024
+ actions={actions[i % len(actions)]},
1022
1025
  time_window=None,
1023
1026
  )
1024
1027
  entries.append(_constraint_entry(c))
@@ -1028,20 +1031,34 @@ def test_matching_10k_constraints_against_500_intents_is_fast(tmp_path):
1028
1031
  assert store.quarantined == []
1029
1032
  assert len(store.constraints) == 10_000
1030
1033
 
1034
+ examined = 0
1035
+ real_match = store_module._constraint_matches
1036
+
1037
+ def counting_match(*args, **kwargs):
1038
+ nonlocal examined
1039
+ examined += 1
1040
+ return real_match(*args, **kwargs)
1041
+
1042
+ monkeypatch.setattr(store_module, "_constraint_matches", counting_match)
1043
+
1031
1044
  now = datetime(2026, 1, 5, 12, 0, tzinfo=UTC)
1032
- intents = [
1033
- InfrastructureIntent(
1034
- resource=f"kind{i % 50}/x", action=actions[i % len(actions)],
1045
+ matched = 0
1046
+ for i in range(500):
1047
+ intent = InfrastructureIntent(
1048
+ resource=f"kind{i % 50}/x",
1049
+ action=actions[i % len(actions)],
1035
1050
  provider=providers[i % len(providers)],
1036
1051
  )
1037
- for i in range(500)
1038
- ]
1039
- start = _time.perf_counter()
1040
- for intent in intents:
1041
- store.get_matching_constraints(intent, now)
1042
- elapsed = _time.perf_counter() - start
1043
- assert elapsed < 0.5, f"500 intents against 10k constraints took {elapsed:.3f}s"
1052
+ bucket = len(store._index.get((intent.provider, intent.action), []))
1053
+ before = examined
1054
+ matched += len(store.get_matching_constraints(intent, now))
1055
+ # exactly the (provider, action) bucket is scanned, nothing else
1056
+ assert examined - before == bucket
1057
+ assert bucket < len(store.constraints) / 10
1044
1058
 
1059
+ assert matched > 0, "the fixture must actually produce matches"
1060
+ # 500 intents over 10k constraints: a full scan would examine 5,000,000
1061
+ assert examined < 500 * len(store.constraints) / 10
1045
1062
 
1046
1063
  def _constraint_entry(c: Constraint) -> dict:
1047
1064
  from aegis_core.store import _constraint_to_dict
@@ -1070,3 +1087,65 @@ def test_load_cached_returns_the_same_object_until_the_file_changes(tmp_path):
1070
1087
  third = CS.load_cached(path, insecure=True)
1071
1088
  assert third is not first
1072
1089
  assert "ok2" in third.constraints
1090
+
1091
+
1092
+ def test_full_intercept_scans_only_the_indexed_bucket(tmp_path, monkeypatch):
1093
+ """A decision, not just matching, must be O(k) in the plausible constraints.
1094
+
1095
+ get_matching_constraints used the index, but get_env_unresolved and
1096
+ get_time_window_unresolved -- which the interceptor calls on every intent --
1097
+ scanned the whole store, so intercept() stayed O(n) even though matching was
1098
+ O(k). All three now draw from ConstraintStore._candidates. This counts every
1099
+ per-constraint predicate a full intercept() evaluates and requires it to stay
1100
+ within a small multiple of the bucket, never the store."""
1101
+ from datetime import UTC, datetime
1102
+
1103
+ import yaml
1104
+
1105
+ from aegis_core import store as store_module
1106
+ from aegis_core.intent import InfrastructureIntent
1107
+ from aegis_core.interceptor import AegisInterceptor
1108
+
1109
+ providers = ["kubernetes", "terraform", "helm", "aws", "gcp"]
1110
+ actions = [f"action-{j}" for j in range(20)]
1111
+ entries = [
1112
+ _constraint_entry(make_constraint(
1113
+ id=f"c-{i}",
1114
+ provider=providers[i % len(providers)],
1115
+ resource_pattern=f"kind{i % 50}/*",
1116
+ actions={actions[i % len(actions)]},
1117
+ time_window=None,
1118
+ ))
1119
+ for i in range(10_000)
1120
+ ]
1121
+ path = tmp_path / "constraints.yaml"
1122
+ path.write_text(yaml.safe_dump({"constraints": entries}, sort_keys=False))
1123
+ store = ConstraintStore.load(path, authority_map=AUTHORITY, insecure=True)
1124
+ interceptor = AegisInterceptor(store)
1125
+
1126
+ calls = 0
1127
+
1128
+ def counted(fn):
1129
+ def wrapper(*args, **kwargs):
1130
+ nonlocal calls
1131
+ calls += 1
1132
+ return fn(*args, **kwargs)
1133
+ return wrapper
1134
+
1135
+ for name in ("_constraint_matches", "_matches_except_scope", "time_window_unresolved"):
1136
+ monkeypatch.setattr(store_module, name, counted(getattr(store_module, name)))
1137
+
1138
+ now = datetime(2026, 1, 5, 12, 0, tzinfo=UTC)
1139
+ for i in range(200):
1140
+ intent = InfrastructureIntent(
1141
+ resource=f"kind{i % 50}/x",
1142
+ action=actions[i % len(actions)],
1143
+ provider=providers[i % len(providers)],
1144
+ )
1145
+ bucket = len(store._candidates(intent))
1146
+ before = calls
1147
+ interceptor.intercept(intent, now=now)
1148
+ # matching (2 predicate calls per candidate), env and time-window checks
1149
+ # (1 each): a bounded multiple of the bucket, never of the store
1150
+ assert calls - before <= 4 * bucket, (calls - before, bucket)
1151
+ assert bucket < len(store.constraints) / 10
File without changes
File without changes
File without changes