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.
- {aegis_devops-0.1.1/src/aegis_devops.egg-info → aegis_devops-0.1.2}/PKG-INFO +18 -5
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/README.md +17 -4
- {aegis_devops-0.1.1 → 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.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/external.py +51 -1
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/llm.py +18 -1
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/store.py +20 -8
- {aegis_devops-0.1.1 → aegis_devops-0.1.2/src/aegis_devops.egg-info}/PKG-INFO +18 -5
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_baselines.py +159 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_docs_consistency.py +15 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_store.py +97 -18
- aegis_devops-0.1.1/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/LICENSE +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/MANIFEST.in +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/pyproject.toml +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/setup.cfg +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/cli.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/interceptor.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parser.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/SOURCES.txt +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_authority.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_cli.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_config.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_corpus.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_environments.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_intent.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_interceptor.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_ledger.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_parser.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_parsers_sql.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_plan.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_provenance.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_shell.py +0 -0
- {aegis_devops-0.1.1 → aegis_devops-0.1.2}/tests/test_signing.py +0 -0
- {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.
|
|
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
|
+
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
+
[](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
|
|
179
|
-
completions
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
[](https://doi.org/10.5281/zenodo.22950337)
|
|
4
|
+
[](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
|
|
147
|
-
completions
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
|
@@ -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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aegis-devops
|
|
3
|
-
Version: 0.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
|
+
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
+
[](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
|
|
179
|
-
completions
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
1003
|
-
|
|
1004
|
-
|
|
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=
|
|
1022
|
+
provider=providers[i % len(providers)],
|
|
1020
1023
|
resource_pattern=f"kind{i % 50}/*",
|
|
1021
|
-
actions={
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/authority.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/constraints.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/environments.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/plan_constraints.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.1 → aegis_devops-0.1.2}/src/aegis_core/_examples/sources/slack-perma-1.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|