aegis-devops 0.1.4__tar.gz → 0.1.6__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.4 → aegis_devops-0.1.6}/MANIFEST.in +1 -0
- {aegis_devops-0.1.4/src/aegis_devops.egg-info → aegis_devops-0.1.6}/PKG-INFO +35 -23
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/README.md +34 -22
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/pyproject.toml +1 -1
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.1.6/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/hook.py +44 -2
- aegis_devops-0.1.6/src/aegis_core/integrations/opencode-plugin.js +62 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6/src/aegis_devops.egg-info}/PKG-INFO +35 -23
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/SOURCES.txt +2 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_benchmark.py +38 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_hook.py +53 -2
- aegis_devops-0.1.6/tests/test_opencode_plugin.py +87 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_plugins.py +41 -5
- aegis_devops-0.1.4/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/LICENSE +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/setup.cfg +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/constraints.example.yaml +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6002.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6003.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6004.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6005.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6006.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6007.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6008.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/external.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/llm.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/cli.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/interceptor.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parser.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/store.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_authority.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_baselines.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_cli.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_config.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_corpus.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_default_rules.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_docs_consistency.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_environments.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_intent.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_interceptor.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_ledger.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_parser.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_parsers_sql.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_plan.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_provenance.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_shell.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_signing.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_store.py +0 -0
- {aegis_devops-0.1.4 → aegis_devops-0.1.6}/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.6
|
|
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
|
|
@@ -53,7 +53,15 @@ claude plugin marketplace add moneytool/aegis-devops
|
|
|
53
53
|
claude plugin install aegis-devops@aegis-devops
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
### Install in Gemini CLI
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install aegis-devops && aegis init .aegis
|
|
60
|
+
gemini extensions install https://github.com/moneytool/aegis-devops
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Codex, GitHub Copilot (CLI and VS Code), Cursor and OpenCode:
|
|
64
|
+
`aegis install codex|copilot|vscode|cursor|opencode`
|
|
57
65
|
(see [Coding agents](docs/agents.md)). It only acts in projects with a `.aegis/` policy, and
|
|
58
66
|
only blocks what that policy blocks. It also works as a CI step
|
|
59
67
|
(`aegis check terraform plan.json --exit-style ci`) and as a Python library; see
|
|
@@ -178,20 +186,21 @@ oracle that never imports Aegis's own code. The full table
|
|
|
178
186
|
(precision/recall/F1, latency, coverage) and methodology are in
|
|
179
187
|
[`docs/benchmark.md`](docs/benchmark.md); the columns that matter most are summarized below.
|
|
180
188
|
|
|
181
|
-
| verifier | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
182
|
-
| :--- | ---: | ---: | ---: |
|
|
183
|
-
| **aegis** | **0.000** | **0.000** | **0.000** |
|
|
184
|
-
|
|
|
185
|
-
|
|
|
186
|
-
|
|
|
187
|
-
|
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
|
193
|
-
|
|
|
194
|
-
|
|
|
189
|
+
| verifier | rules shown & scored on | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
190
|
+
| :--- | ---: | ---: | ---: | ---: |
|
|
191
|
+
| **aegis** | 500 | **0.000** | **0.000** | **0.000** |
|
|
192
|
+
| opa-signed | 500 | 0.250 | 0.500 | 1.000 |
|
|
193
|
+
| opa | 500 | 0.500 | 1.000 | 1.000 |
|
|
194
|
+
| llm-heuristic | 500 | 0.500 | 1.000 | 1.000 |
|
|
195
|
+
| **aegis-holdout** | 100 | **0.000** | **0.000** | **0.000** |
|
|
196
|
+
| codex (gpt-6-astra) | 100 | 0.224 | 1.000 | 1.000 |
|
|
197
|
+
| codex-gpt-6-sol | 100 | 0.224 | 1.000 | 1.000 |
|
|
198
|
+
| codex-gpt-6-luna | 100 | 0.188 | 0.789 | 0.500 |
|
|
199
|
+
| claude-cli (haiku) | 100 | 0.235 | 1.000 | 1.000 |
|
|
200
|
+
| claude-cli-sonnet | 100 | 0.259 | 1.000 | 1.000 |
|
|
201
|
+
| claude-cli-opus | 100 | 0.235 | 1.000 | 1.000 |
|
|
202
|
+
| claude-cli-fable | 100 | 0.235 | 1.000 | 1.000 |
|
|
203
|
+
| ollama (mistral 7B) | 100 | 1.000 | 1.000 | 1.000 |
|
|
195
204
|
|
|
196
205
|
`poison-susceptibility` (`ps + pe`) is the fraction of poisoned rules — constraints an
|
|
197
206
|
unauthorized/tampered/forged author slipped in — that moved a verdict at all, split by kind;
|
|
@@ -203,12 +212,15 @@ exactly that reason, while Aegis's independent authority check scores `0.000`.
|
|
|
203
212
|
The `codex*` and `claude-cli*` rows are **agent harnesses wrapped around a model**, not raw
|
|
204
213
|
completions — seven models across two vendors (Claude Haiku 4.5, Sonnet 5, Opus 5 and Fable
|
|
205
214
|
5.1; GPT `gpt-6-astra`, `gpt-6-sol` and `gpt-6-luna`), each verified before its run to be the
|
|
206
|
-
model that actually answered
|
|
207
|
-
100-constraint holdout subset
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
model that actually answered. For context-window and cost reasons they, and the local model,
|
|
216
|
+
are shown only the corpus's 100-constraint holdout subset, so they are **scored against the
|
|
217
|
+
oracle over those same 100 rules** (19 poison candidates rather than 30); `aegis-holdout` runs
|
|
218
|
+
Aegis on the same 100 for a like-for-like row. Compare rows within one "rules" value. On that
|
|
219
|
+
basis model size and vendor do not change the picture: six of the seven models act on every
|
|
220
|
+
poisoned rule they are shown, `gpt-6-luna` on 15 of 19, and Aegis on none. A model can reason
|
|
221
|
+
about who wrote a rule, but it cannot recompute a hash or fetch a source. Up to v0.1.5 these
|
|
222
|
+
rows were scored against all 500 rules, which counted rules the models never saw as resisted
|
|
223
|
+
poison and understated their susceptibility (0.23–0.33); see [`CHANGELOG.md`](CHANGELOG.md).
|
|
212
224
|
|
|
213
225
|
## Why not OPA/Gatekeeper?
|
|
214
226
|
|
|
@@ -227,7 +239,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
227
239
|
| | Aegis-DevOps | [nah](https://github.com/manuelschipper/nah) | [claude-code-safety-net](https://github.com/kenryu42/claude-code-safety-net) | [destructive_command_guard](https://github.com/Dicklesworthstone/destructive_command_guard) |
|
|
228
240
|
|---|---|---|---|---|
|
|
229
241
|
| Guards | Infra commands and plans: kubectl, terraform/tofu, aws/az/gcloud, helm, argocd, flux, git/gh, SQL, pulumi | Git, filesystem, infra CLIs (Terraform, OpenTofu, Pulumi, kubectl, Docker), secrets, publishing | Destructive git and filesystem commands, secret access; cloud CLIs via optional rulebooks | Git, filesystem, databases, Kubernetes, IaC, clouds, Docker and more (50+ packs) |
|
|
230
|
-
| Agents | Claude Code, Codex, Copilot CLI, VS Code, Cursor | Claude Code, Codex, Cursor, Copilot and 10+ more | Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and 8+ more | Claude Code, Codex, Copilot, Cursor, Gemini CLI and 9+ more |
|
|
242
|
+
| Agents | Claude Code, Codex, Copilot CLI, VS Code, Cursor, Gemini CLI, OpenCode | Claude Code, Codex, Cursor, Copilot and 10+ more | Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and 8+ more | Claude Code, Codex, Copilot, Cursor, Gemini CLI and 9+ more |
|
|
231
243
|
| Rules | Signed rules, each citing a source and an author; checked against an authority map (who may assert what) | Built-in deterministic guards; custom guards can only make it stricter | Built-in AST-based protections, configurable presets, community rulebooks | Built-in regex/AST packs, TOML config, custom YAML packs |
|
|
232
244
|
| Rule provenance and authority | Yes: a tampered, forged or unauthorised rule gets no vote | — | — | — |
|
|
233
245
|
| Terraform/Pulumi plan checks | Yes (plan JSON) | Whole-stack destroy commands | Commands via rulebooks | Destroy commands |
|
|
@@ -21,7 +21,15 @@ claude plugin marketplace add moneytool/aegis-devops
|
|
|
21
21
|
claude plugin install aegis-devops@aegis-devops
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
### Install in Gemini CLI
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install aegis-devops && aegis init .aegis
|
|
28
|
+
gemini extensions install https://github.com/moneytool/aegis-devops
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Codex, GitHub Copilot (CLI and VS Code), Cursor and OpenCode:
|
|
32
|
+
`aegis install codex|copilot|vscode|cursor|opencode`
|
|
25
33
|
(see [Coding agents](docs/agents.md)). It only acts in projects with a `.aegis/` policy, and
|
|
26
34
|
only blocks what that policy blocks. It also works as a CI step
|
|
27
35
|
(`aegis check terraform plan.json --exit-style ci`) and as a Python library; see
|
|
@@ -146,20 +154,21 @@ oracle that never imports Aegis's own code. The full table
|
|
|
146
154
|
(precision/recall/F1, latency, coverage) and methodology are in
|
|
147
155
|
[`docs/benchmark.md`](docs/benchmark.md); the columns that matter most are summarized below.
|
|
148
156
|
|
|
149
|
-
| verifier | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
150
|
-
| :--- | ---: | ---: | ---: |
|
|
151
|
-
| **aegis** | **0.000** | **0.000** | **0.000** |
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
|
|
|
157
|
+
| verifier | rules shown & scored on | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
158
|
+
| :--- | ---: | ---: | ---: | ---: |
|
|
159
|
+
| **aegis** | 500 | **0.000** | **0.000** | **0.000** |
|
|
160
|
+
| opa-signed | 500 | 0.250 | 0.500 | 1.000 |
|
|
161
|
+
| opa | 500 | 0.500 | 1.000 | 1.000 |
|
|
162
|
+
| llm-heuristic | 500 | 0.500 | 1.000 | 1.000 |
|
|
163
|
+
| **aegis-holdout** | 100 | **0.000** | **0.000** | **0.000** |
|
|
164
|
+
| codex (gpt-6-astra) | 100 | 0.224 | 1.000 | 1.000 |
|
|
165
|
+
| codex-gpt-6-sol | 100 | 0.224 | 1.000 | 1.000 |
|
|
166
|
+
| codex-gpt-6-luna | 100 | 0.188 | 0.789 | 0.500 |
|
|
167
|
+
| claude-cli (haiku) | 100 | 0.235 | 1.000 | 1.000 |
|
|
168
|
+
| claude-cli-sonnet | 100 | 0.259 | 1.000 | 1.000 |
|
|
169
|
+
| claude-cli-opus | 100 | 0.235 | 1.000 | 1.000 |
|
|
170
|
+
| claude-cli-fable | 100 | 0.235 | 1.000 | 1.000 |
|
|
171
|
+
| ollama (mistral 7B) | 100 | 1.000 | 1.000 | 1.000 |
|
|
163
172
|
|
|
164
173
|
`poison-susceptibility` (`ps + pe`) is the fraction of poisoned rules — constraints an
|
|
165
174
|
unauthorized/tampered/forged author slipped in — that moved a verdict at all, split by kind;
|
|
@@ -171,12 +180,15 @@ exactly that reason, while Aegis's independent authority check scores `0.000`.
|
|
|
171
180
|
The `codex*` and `claude-cli*` rows are **agent harnesses wrapped around a model**, not raw
|
|
172
181
|
completions — seven models across two vendors (Claude Haiku 4.5, Sonnet 5, Opus 5 and Fable
|
|
173
182
|
5.1; GPT `gpt-6-astra`, `gpt-6-sol` and `gpt-6-luna`), each verified before its run to be the
|
|
174
|
-
model that actually answered
|
|
175
|
-
100-constraint holdout subset
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
model that actually answered. For context-window and cost reasons they, and the local model,
|
|
184
|
+
are shown only the corpus's 100-constraint holdout subset, so they are **scored against the
|
|
185
|
+
oracle over those same 100 rules** (19 poison candidates rather than 30); `aegis-holdout` runs
|
|
186
|
+
Aegis on the same 100 for a like-for-like row. Compare rows within one "rules" value. On that
|
|
187
|
+
basis model size and vendor do not change the picture: six of the seven models act on every
|
|
188
|
+
poisoned rule they are shown, `gpt-6-luna` on 15 of 19, and Aegis on none. A model can reason
|
|
189
|
+
about who wrote a rule, but it cannot recompute a hash or fetch a source. Up to v0.1.5 these
|
|
190
|
+
rows were scored against all 500 rules, which counted rules the models never saw as resisted
|
|
191
|
+
poison and understated their susceptibility (0.23–0.33); see [`CHANGELOG.md`](CHANGELOG.md).
|
|
180
192
|
|
|
181
193
|
## Why not OPA/Gatekeeper?
|
|
182
194
|
|
|
@@ -195,7 +207,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
195
207
|
| | Aegis-DevOps | [nah](https://github.com/manuelschipper/nah) | [claude-code-safety-net](https://github.com/kenryu42/claude-code-safety-net) | [destructive_command_guard](https://github.com/Dicklesworthstone/destructive_command_guard) |
|
|
196
208
|
|---|---|---|---|---|
|
|
197
209
|
| Guards | Infra commands and plans: kubectl, terraform/tofu, aws/az/gcloud, helm, argocd, flux, git/gh, SQL, pulumi | Git, filesystem, infra CLIs (Terraform, OpenTofu, Pulumi, kubectl, Docker), secrets, publishing | Destructive git and filesystem commands, secret access; cloud CLIs via optional rulebooks | Git, filesystem, databases, Kubernetes, IaC, clouds, Docker and more (50+ packs) |
|
|
198
|
-
| Agents | Claude Code, Codex, Copilot CLI, VS Code, Cursor | Claude Code, Codex, Cursor, Copilot and 10+ more | Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and 8+ more | Claude Code, Codex, Copilot, Cursor, Gemini CLI and 9+ more |
|
|
210
|
+
| Agents | Claude Code, Codex, Copilot CLI, VS Code, Cursor, Gemini CLI, OpenCode | Claude Code, Codex, Cursor, Copilot and 10+ more | Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and 8+ more | Claude Code, Codex, Copilot, Cursor, Gemini CLI and 9+ more |
|
|
199
211
|
| Rules | Signed rules, each citing a source and an author; checked against an authority map (who may assert what) | Built-in deterministic guards; custom guards can only make it stricter | Built-in AST-based protections, configurable presets, community rulebooks | Built-in regex/AST packs, TOML config, custom YAML packs |
|
|
200
212
|
| Rule provenance and authority | Yes: a tampered, forged or unauthorised rule gets no vote | — | — | — |
|
|
201
213
|
| Terraform/Pulumi plan checks | Yes (plan JSON) | Whole-stack destroy commands | Commands via rulebooks | Destroy commands |
|
|
@@ -41,6 +41,7 @@ is a deny.
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
43
|
import contextlib
|
|
44
|
+
import importlib.resources
|
|
44
45
|
import io
|
|
45
46
|
import json
|
|
46
47
|
import os
|
|
@@ -53,7 +54,7 @@ from pathlib import Path
|
|
|
53
54
|
from aegis_core import config as config_module
|
|
54
55
|
from aegis_core.shell import _KNOWN_BINARIES, ShellRejected, intents_from_command
|
|
55
56
|
|
|
56
|
-
AGENTS = ("claude", "codex", "copilot", "cursor", "vscode")
|
|
57
|
+
AGENTS = ("claude", "codex", "copilot", "cursor", "gemini", "opencode", "vscode")
|
|
57
58
|
|
|
58
59
|
# Binaries whose commands are gated even when the policy is unusable or the
|
|
59
60
|
# command cannot be parsed. terraform/tofu have no argv parser (their plans
|
|
@@ -156,6 +157,7 @@ def _project_dir(request: HookRequest) -> Path:
|
|
|
156
157
|
os.environ.get("CLAUDE_PROJECT_DIR"),
|
|
157
158
|
os.environ.get("COPILOT_PROJECT_DIR"),
|
|
158
159
|
os.environ.get("CURSOR_PROJECT_DIR"),
|
|
160
|
+
os.environ.get("GEMINI_PROJECT_DIR"),
|
|
159
161
|
):
|
|
160
162
|
if candidate:
|
|
161
163
|
return Path(candidate)
|
|
@@ -255,6 +257,11 @@ def decide(command: str, config_dir: Path, extra_args: list[str] | None = None)
|
|
|
255
257
|
# --- replying --------------------------------------------------------------------
|
|
256
258
|
|
|
257
259
|
|
|
260
|
+
# Agents whose hooks cannot ask the user: an ESCALATE is a deny there.
|
|
261
|
+
# (Codex treats an unknown decision as an error and runs the command.)
|
|
262
|
+
_NO_ASK = frozenset({"codex", "gemini", "opencode"})
|
|
263
|
+
|
|
264
|
+
|
|
258
265
|
def _both_shapes(decision: str, reason: str) -> dict:
|
|
259
266
|
"""Copilot CLI reads a top-level ``permissionDecision``; VS Code reads
|
|
260
267
|
``hookSpecificOutput``. Both read the same ``.github/hooks`` files and
|
|
@@ -275,7 +282,7 @@ def render(agent: str, verdict: HookVerdict, *, escalate_as: str = "ask") -> tup
|
|
|
275
282
|
stderr (Cursor: its JSON on exit 0); ask is each agent's own JSON with
|
|
276
283
|
exit 0."""
|
|
277
284
|
decision = verdict.decision
|
|
278
|
-
if decision == "ask" and (agent
|
|
285
|
+
if decision == "ask" and (agent in _NO_ASK or escalate_as == "deny"):
|
|
279
286
|
decision = "deny"
|
|
280
287
|
if decision == "allow":
|
|
281
288
|
return _allow(agent)
|
|
@@ -291,6 +298,9 @@ def render(agent: str, verdict: HookVerdict, *, escalate_as: str = "ask") -> tup
|
|
|
291
298
|
"copilot": _both_shapes("deny", reason),
|
|
292
299
|
"vscode": _both_shapes("deny", reason),
|
|
293
300
|
"cursor": {"permission": "deny", "user_message": reason, "agent_message": reason},
|
|
301
|
+
"gemini": {"decision": "deny", "reason": reason},
|
|
302
|
+
# the OpenCode plugin throws with stderr; stdout is not read
|
|
303
|
+
"opencode": {"decision": "deny", "reason": reason},
|
|
294
304
|
}[agent]
|
|
295
305
|
if agent == "cursor":
|
|
296
306
|
# Cursor shows the stdout of an exit-2 hook verbatim as the
|
|
@@ -389,6 +399,10 @@ def config_path(agent: str, *, user: bool, project: Path) -> Path:
|
|
|
389
399
|
("vscode", False): project / ".github" / "hooks" / "aegis-vscode.json",
|
|
390
400
|
("cursor", True): home / ".cursor" / "hooks.json",
|
|
391
401
|
("cursor", False): project / ".cursor" / "hooks.json",
|
|
402
|
+
("gemini", True): home / ".gemini" / "settings.json",
|
|
403
|
+
("gemini", False): project / ".gemini" / "settings.json",
|
|
404
|
+
("opencode", True): home / ".config" / "opencode" / "plugins" / "aegis-devops.js",
|
|
405
|
+
("opencode", False): project / ".opencode" / "plugins" / "aegis-devops.js",
|
|
392
406
|
}[(agent, user)]
|
|
393
407
|
|
|
394
408
|
|
|
@@ -423,6 +437,12 @@ def merge_config(agent: str, existing: dict, command: str | None) -> dict:
|
|
|
423
437
|
"matcher": "Bash" if agent == "claude" else "^Bash$",
|
|
424
438
|
"hooks": [{"type": "command", "command": command, "timeout": 30}],
|
|
425
439
|
}
|
|
440
|
+
elif agent == "gemini":
|
|
441
|
+
event, entry = "BeforeTool", {
|
|
442
|
+
"matcher": "run_shell_command",
|
|
443
|
+
"hooks": [{"name": "aegis-devops", "type": "command", "command": command,
|
|
444
|
+
"timeout": 30000}],
|
|
445
|
+
}
|
|
426
446
|
elif agent == "copilot":
|
|
427
447
|
doc.setdefault("version", 1)
|
|
428
448
|
event, entry = "preToolUse", {"type": "command", "bash": command, "timeoutSec": 30}
|
|
@@ -454,11 +474,33 @@ _AFTER_INSTALL = {
|
|
|
454
474
|
"vscode": "Hooks are a Preview feature in VS Code; enable chat hooks in its settings if "
|
|
455
475
|
"they do not run.",
|
|
456
476
|
"cursor": "Restart Cursor to load it.",
|
|
477
|
+
"gemini": "Restart Gemini CLI; '/hooks' lists it (project hooks may ask to be trusted).",
|
|
478
|
+
"opencode": "Restart OpenCode to load the plugin.",
|
|
457
479
|
}
|
|
458
480
|
|
|
459
481
|
|
|
482
|
+
def _install_opencode_plugin(path: Path, *, remove: bool) -> Path:
|
|
483
|
+
"""OpenCode hooks are JS plugins: write (or delete) our plugin file, with
|
|
484
|
+
the argv of this aegis filled in."""
|
|
485
|
+
if remove:
|
|
486
|
+
if path.exists():
|
|
487
|
+
path.unlink()
|
|
488
|
+
return path
|
|
489
|
+
template = (
|
|
490
|
+
importlib.resources.files("aegis_core") / "integrations" / "opencode-plugin.js"
|
|
491
|
+
).read_text()
|
|
492
|
+
marker = "const INSTALLED = null"
|
|
493
|
+
if marker not in template:
|
|
494
|
+
raise ValueError("opencode plugin template has no INSTALLED marker")
|
|
495
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
path.write_text(template.replace(marker, f"const INSTALLED = {json.dumps(_aegis_command())}"))
|
|
497
|
+
return path
|
|
498
|
+
|
|
499
|
+
|
|
460
500
|
def install(agent: str, *, user: bool, project: Path, remove: bool = False) -> Path:
|
|
461
501
|
path = config_path(agent, user=user, project=project)
|
|
502
|
+
if agent == "opencode":
|
|
503
|
+
return _install_opencode_plugin(path, remove=remove)
|
|
462
504
|
existing: dict = {}
|
|
463
505
|
if path.exists():
|
|
464
506
|
text = path.read_text()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Aegis-DevOps plugin for OpenCode: checks each bash command against the
|
|
2
|
+
// project's Aegis policy before it runs. Written by `aegis install opencode`
|
|
3
|
+
// into .opencode/plugins/ (or ~/.config/opencode/plugins/ with --user).
|
|
4
|
+
//
|
|
5
|
+
// OpenCode hooks are plugins, not shell commands, so this hands the command to
|
|
6
|
+
// `aegis hook opencode` and throws (which blocks the call and shows the model
|
|
7
|
+
// the reason) when Aegis says no. Everything is decided by aegis itself: it
|
|
8
|
+
// allows every command outside projects with a policy (.aegis/ here or above,
|
|
9
|
+
// $AEGIS_CONFIG_DIR, ~/.config/aegis), and inside one blocks only what the
|
|
10
|
+
// policy blocks. If aegis cannot be started, the same rule applies in its
|
|
11
|
+
// crudest form: in an opted-in project block commands naming an
|
|
12
|
+
// infrastructure binary, allow the rest.
|
|
13
|
+
import { spawnSync } from "node:child_process"
|
|
14
|
+
import { existsSync } from "node:fs"
|
|
15
|
+
import { homedir } from "node:os"
|
|
16
|
+
import { dirname, join, resolve } from "node:path"
|
|
17
|
+
|
|
18
|
+
// `aegis install opencode` replaces null with the argv that starts the aegis
|
|
19
|
+
// it was run from (GUI apps do not see your shell's PATH).
|
|
20
|
+
const INSTALLED = null
|
|
21
|
+
const AEGIS = process.env.AEGIS_BIN ? [process.env.AEGIS_BIN] : INSTALLED || ["aegis"]
|
|
22
|
+
const INFRA =
|
|
23
|
+
/(^|[^\w.-])(kubectl|k|terraform|tf|tofu|aws|az|gcloud|gsutil|helm|argocd|flux|git|gh|psql|mysql|sqlite3|mongosh|pulumi|alembic|flyway|rails|prisma)([^\w.-]|$)/
|
|
24
|
+
|
|
25
|
+
function optedIn(start) {
|
|
26
|
+
if (process.env.AEGIS_CONFIG_DIR) return true
|
|
27
|
+
if (existsSync(join(homedir(), ".config", "aegis"))) return true
|
|
28
|
+
let dir = resolve(start || process.cwd())
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (existsSync(join(dir, ".aegis"))) return true
|
|
31
|
+
const parent = dirname(dir)
|
|
32
|
+
if (parent === dir) return false
|
|
33
|
+
dir = parent
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const AegisDevOps = async ({ directory }) => ({
|
|
38
|
+
"tool.execute.before": async (input, output) => {
|
|
39
|
+
if (input?.tool !== "bash") return
|
|
40
|
+
const command = output?.args?.command
|
|
41
|
+
if (typeof command !== "string" || !command.trim()) return
|
|
42
|
+
const cwd = directory || process.cwd()
|
|
43
|
+
const payload = JSON.stringify({ tool_name: "bash", tool_input: { command }, cwd })
|
|
44
|
+
const run = spawnSync(AEGIS[0], [...AEGIS.slice(1), "hook", "opencode"], {
|
|
45
|
+
input: payload,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
timeout: 30000,
|
|
48
|
+
cwd,
|
|
49
|
+
})
|
|
50
|
+
if (run.error || run.status === null) {
|
|
51
|
+
if (optedIn(cwd) && INFRA.test(command)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"aegis-devops could not run, so this infrastructure command cannot be checked: " +
|
|
54
|
+
"run 'pip install aegis-devops' (blocking to fail closed)",
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (run.status === 0) return
|
|
60
|
+
throw new Error((run.stderr || "").trim() || `aegis: blocked (exit ${run.status})`)
|
|
61
|
+
},
|
|
62
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aegis-devops
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
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
|
|
@@ -53,7 +53,15 @@ claude plugin marketplace add moneytool/aegis-devops
|
|
|
53
53
|
claude plugin install aegis-devops@aegis-devops
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
### Install in Gemini CLI
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install aegis-devops && aegis init .aegis
|
|
60
|
+
gemini extensions install https://github.com/moneytool/aegis-devops
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Codex, GitHub Copilot (CLI and VS Code), Cursor and OpenCode:
|
|
64
|
+
`aegis install codex|copilot|vscode|cursor|opencode`
|
|
57
65
|
(see [Coding agents](docs/agents.md)). It only acts in projects with a `.aegis/` policy, and
|
|
58
66
|
only blocks what that policy blocks. It also works as a CI step
|
|
59
67
|
(`aegis check terraform plan.json --exit-style ci`) and as a Python library; see
|
|
@@ -178,20 +186,21 @@ oracle that never imports Aegis's own code. The full table
|
|
|
178
186
|
(precision/recall/F1, latency, coverage) and methodology are in
|
|
179
187
|
[`docs/benchmark.md`](docs/benchmark.md); the columns that matter most are summarized below.
|
|
180
188
|
|
|
181
|
-
| verifier | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
182
|
-
| :--- | ---: | ---: | ---: |
|
|
183
|
-
| **aegis** | **0.000** | **0.000** | **0.000** |
|
|
184
|
-
|
|
|
185
|
-
|
|
|
186
|
-
|
|
|
187
|
-
|
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
|
193
|
-
|
|
|
194
|
-
|
|
|
189
|
+
| verifier | rules shown & scored on | over-block | poison-susceptibility | ps_unauth + pe_unauth |
|
|
190
|
+
| :--- | ---: | ---: | ---: | ---: |
|
|
191
|
+
| **aegis** | 500 | **0.000** | **0.000** | **0.000** |
|
|
192
|
+
| opa-signed | 500 | 0.250 | 0.500 | 1.000 |
|
|
193
|
+
| opa | 500 | 0.500 | 1.000 | 1.000 |
|
|
194
|
+
| llm-heuristic | 500 | 0.500 | 1.000 | 1.000 |
|
|
195
|
+
| **aegis-holdout** | 100 | **0.000** | **0.000** | **0.000** |
|
|
196
|
+
| codex (gpt-6-astra) | 100 | 0.224 | 1.000 | 1.000 |
|
|
197
|
+
| codex-gpt-6-sol | 100 | 0.224 | 1.000 | 1.000 |
|
|
198
|
+
| codex-gpt-6-luna | 100 | 0.188 | 0.789 | 0.500 |
|
|
199
|
+
| claude-cli (haiku) | 100 | 0.235 | 1.000 | 1.000 |
|
|
200
|
+
| claude-cli-sonnet | 100 | 0.259 | 1.000 | 1.000 |
|
|
201
|
+
| claude-cli-opus | 100 | 0.235 | 1.000 | 1.000 |
|
|
202
|
+
| claude-cli-fable | 100 | 0.235 | 1.000 | 1.000 |
|
|
203
|
+
| ollama (mistral 7B) | 100 | 1.000 | 1.000 | 1.000 |
|
|
195
204
|
|
|
196
205
|
`poison-susceptibility` (`ps + pe`) is the fraction of poisoned rules — constraints an
|
|
197
206
|
unauthorized/tampered/forged author slipped in — that moved a verdict at all, split by kind;
|
|
@@ -203,12 +212,15 @@ exactly that reason, while Aegis's independent authority check scores `0.000`.
|
|
|
203
212
|
The `codex*` and `claude-cli*` rows are **agent harnesses wrapped around a model**, not raw
|
|
204
213
|
completions — seven models across two vendors (Claude Haiku 4.5, Sonnet 5, Opus 5 and Fable
|
|
205
214
|
5.1; GPT `gpt-6-astra`, `gpt-6-sol` and `gpt-6-luna`), each verified before its run to be the
|
|
206
|
-
model that actually answered
|
|
207
|
-
100-constraint holdout subset
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
model that actually answered. For context-window and cost reasons they, and the local model,
|
|
216
|
+
are shown only the corpus's 100-constraint holdout subset, so they are **scored against the
|
|
217
|
+
oracle over those same 100 rules** (19 poison candidates rather than 30); `aegis-holdout` runs
|
|
218
|
+
Aegis on the same 100 for a like-for-like row. Compare rows within one "rules" value. On that
|
|
219
|
+
basis model size and vendor do not change the picture: six of the seven models act on every
|
|
220
|
+
poisoned rule they are shown, `gpt-6-luna` on 15 of 19, and Aegis on none. A model can reason
|
|
221
|
+
about who wrote a rule, but it cannot recompute a hash or fetch a source. Up to v0.1.5 these
|
|
222
|
+
rows were scored against all 500 rules, which counted rules the models never saw as resisted
|
|
223
|
+
poison and understated their susceptibility (0.23–0.33); see [`CHANGELOG.md`](CHANGELOG.md).
|
|
212
224
|
|
|
213
225
|
## Why not OPA/Gatekeeper?
|
|
214
226
|
|
|
@@ -227,7 +239,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
227
239
|
| | Aegis-DevOps | [nah](https://github.com/manuelschipper/nah) | [claude-code-safety-net](https://github.com/kenryu42/claude-code-safety-net) | [destructive_command_guard](https://github.com/Dicklesworthstone/destructive_command_guard) |
|
|
228
240
|
|---|---|---|---|---|
|
|
229
241
|
| Guards | Infra commands and plans: kubectl, terraform/tofu, aws/az/gcloud, helm, argocd, flux, git/gh, SQL, pulumi | Git, filesystem, infra CLIs (Terraform, OpenTofu, Pulumi, kubectl, Docker), secrets, publishing | Destructive git and filesystem commands, secret access; cloud CLIs via optional rulebooks | Git, filesystem, databases, Kubernetes, IaC, clouds, Docker and more (50+ packs) |
|
|
230
|
-
| Agents | Claude Code, Codex, Copilot CLI, VS Code, Cursor | Claude Code, Codex, Cursor, Copilot and 10+ more | Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and 8+ more | Claude Code, Codex, Copilot, Cursor, Gemini CLI and 9+ more |
|
|
242
|
+
| Agents | Claude Code, Codex, Copilot CLI, VS Code, Cursor, Gemini CLI, OpenCode | Claude Code, Codex, Cursor, Copilot and 10+ more | Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI and 8+ more | Claude Code, Codex, Copilot, Cursor, Gemini CLI and 9+ more |
|
|
231
243
|
| Rules | Signed rules, each citing a source and an author; checked against an authority map (who may assert what) | Built-in deterministic guards; custom guards can only make it stricter | Built-in AST-based protections, configurable presets, community rulebooks | Built-in regex/AST packs, TOML config, custom YAML packs |
|
|
232
244
|
| Rule provenance and authority | Yes: a tampered, forged or unauthorised rule gets no vote | — | — | — |
|
|
233
245
|
| Terraform/Pulumi plan checks | Yes (plan JSON) | Whole-stack destroy commands | Commands via rulebooks | Destroy commands |
|
|
@@ -70,6 +70,7 @@ src/aegis_core/baselines/external.py
|
|
|
70
70
|
src/aegis_core/baselines/llm.py
|
|
71
71
|
src/aegis_core/baselines/metrics.py
|
|
72
72
|
src/aegis_core/baselines/opa.py
|
|
73
|
+
src/aegis_core/integrations/opencode-plugin.js
|
|
73
74
|
src/aegis_core/parsers/__init__.py
|
|
74
75
|
src/aegis_core/parsers/pulumi.py
|
|
75
76
|
src/aegis_core/parsers/sql.py
|
|
@@ -93,6 +94,7 @@ tests/test_hook.py
|
|
|
93
94
|
tests/test_intent.py
|
|
94
95
|
tests/test_interceptor.py
|
|
95
96
|
tests/test_ledger.py
|
|
97
|
+
tests/test_opencode_plugin.py
|
|
96
98
|
tests/test_parser.py
|
|
97
99
|
tests/test_parsers_pulumi.py
|
|
98
100
|
tests/test_parsers_sql.py
|
|
@@ -267,3 +267,41 @@ def test_benchmark_split_dev_is_allowed(tmp_path):
|
|
|
267
267
|
split = json.load(f)
|
|
268
268
|
assert payload["meta"]["split"] == "dev"
|
|
269
269
|
assert payload["meta"]["n_intents"] == len(split["intents"]["dev"])
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --- every row is scored against the rules it was shown ---------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_published_rows_are_scored_on_the_rules_they_were_shown():
|
|
276
|
+
"""A row shown the 100-constraint holdout subset must be scored against
|
|
277
|
+
the oracle over those 100, not all 500: scoring it on all 500 counts
|
|
278
|
+
every rule it never saw as a missed legitimate rule or as resisted
|
|
279
|
+
poison (the pre-v0.1.6 bug)."""
|
|
280
|
+
results = json.loads((REPO_ROOT / "results" / "benchmark.json").read_text())
|
|
281
|
+
n_holdout = results["meta"]["n_constraints_holdout"]
|
|
282
|
+
for name, row in results["verifiers"].items():
|
|
283
|
+
if row.get("skipped"):
|
|
284
|
+
continue
|
|
285
|
+
want = "holdout" if row["n_constraints_fed"] == n_holdout else "full"
|
|
286
|
+
assert row["scored_against"] == want, name
|
|
287
|
+
if want == "holdout":
|
|
288
|
+
m = row["metrics"]
|
|
289
|
+
total = m["poison_candidate_count"]
|
|
290
|
+
assert total == results["meta"]["poison_candidates_holdout"], name
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_holdout_oracle_only_sees_the_holdout_constraints(monkeypatch):
|
|
294
|
+
monkeypatch.syspath_prepend(str(REPO_ROOT / "scripts"))
|
|
295
|
+
import benchmark
|
|
296
|
+
import reference_oracle
|
|
297
|
+
|
|
298
|
+
corpus = REPO_ROOT / "data" / "corpus"
|
|
299
|
+
split = json.loads((corpus / "split.json").read_text())
|
|
300
|
+
hold = set(split["holdout"])
|
|
301
|
+
intents = [i for i in reference_oracle.read_jsonl(corpus / "intents.jsonl")
|
|
302
|
+
if i["id"] in set(split["intents"]["holdout"])]
|
|
303
|
+
truth = benchmark.oracle_ground_truth_over(corpus, intents, hold)
|
|
304
|
+
for rec in truth:
|
|
305
|
+
matched = set(rec["matched_trusted"]) | set(rec["matched_untrusted"]) | set(
|
|
306
|
+
rec["matched_malicious"])
|
|
307
|
+
assert matched <= hold
|
|
@@ -117,8 +117,17 @@ AGENT_PAYLOAD_BUILDERS = {
|
|
|
117
117
|
"vscode": claude_payload,
|
|
118
118
|
"copilot": copilot_payload,
|
|
119
119
|
"cursor": cursor_payload,
|
|
120
|
+
# Gemini CLI's BeforeTool payload; its shell tool is run_shell_command
|
|
121
|
+
"gemini": lambda command, cwd=None: {
|
|
122
|
+
**claude_payload(command, cwd, tool_name="run_shell_command"),
|
|
123
|
+
"hook_event_name": "BeforeTool",
|
|
124
|
+
},
|
|
125
|
+
# what the bundled OpenCode plugin sends
|
|
126
|
+
"opencode": lambda command, cwd=None: claude_payload(command, cwd, tool_name="bash"),
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
JSON_CONFIG_AGENTS = tuple(a for a in hook.AGENTS if a != "opencode")
|
|
130
|
+
|
|
122
131
|
|
|
123
132
|
def payload_for(agent: str, command: str | None, cwd: str | None = None) -> dict:
|
|
124
133
|
return AGENT_PAYLOAD_BUILDERS[agent](command, cwd)
|
|
@@ -316,6 +325,9 @@ def test_escalate_codex_denies(opted_in_project):
|
|
|
316
325
|
@pytest.mark.parametrize("agent", hook.AGENTS)
|
|
317
326
|
def test_escalate_as_deny_denies_for_every_agent(opted_in_project, agent):
|
|
318
327
|
code, out, err = run(agent, payload_for(agent, ESCALATE_COMMAND), escalate_as="deny")
|
|
328
|
+
if agent in ("gemini", "opencode"):
|
|
329
|
+
assert code == 2 and err
|
|
330
|
+
return
|
|
319
331
|
if agent == "cursor":
|
|
320
332
|
assert (code, json.loads(out)["permission"]) == (0, "deny")
|
|
321
333
|
else:
|
|
@@ -507,7 +519,7 @@ def test_install_cursor_json_shape(isolated, tmp_path):
|
|
|
507
519
|
assert "hook cursor" in entries[0]["command"]
|
|
508
520
|
|
|
509
521
|
|
|
510
|
-
@pytest.mark.parametrize("agent",
|
|
522
|
+
@pytest.mark.parametrize("agent", JSON_CONFIG_AGENTS)
|
|
511
523
|
def test_install_is_idempotent(isolated, agent, tmp_path):
|
|
512
524
|
hook.install(agent, user=False, project=tmp_path)
|
|
513
525
|
path = hook.install(agent, user=False, project=tmp_path)
|
|
@@ -540,7 +552,7 @@ def test_install_preserves_unrelated_settings_and_hooks(isolated, tmp_path):
|
|
|
540
552
|
assert matchers == {"Write", "Bash"}
|
|
541
553
|
|
|
542
554
|
|
|
543
|
-
@pytest.mark.parametrize("agent",
|
|
555
|
+
@pytest.mark.parametrize("agent", JSON_CONFIG_AGENTS)
|
|
544
556
|
def test_install_remove_removes_only_ours(isolated, agent, tmp_path):
|
|
545
557
|
path = hook.config_path(agent, user=False, project=tmp_path)
|
|
546
558
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -634,3 +646,42 @@ def test_copilot_reply_carries_the_vscode_shape_too(opted_in_project):
|
|
|
634
646
|
assert code == 2
|
|
635
647
|
assert body["permissionDecision"] == "deny"
|
|
636
648
|
assert body["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# --- Gemini CLI and OpenCode ------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def test_gemini_deny_format(opted_in_project):
|
|
655
|
+
code, out, err = run("gemini", payload_for("gemini", BLOCK_COMMAND))
|
|
656
|
+
assert code == 2
|
|
657
|
+
assert json.loads(out)["decision"] == "deny"
|
|
658
|
+
assert "no-delete-nodes" in err
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@pytest.mark.parametrize("agent", ["gemini", "opencode"])
|
|
662
|
+
def test_escalate_is_a_deny_where_the_agent_cannot_ask(opted_in_project, agent):
|
|
663
|
+
code, _out, err = run(agent, payload_for(agent, ESCALATE_COMMAND))
|
|
664
|
+
assert code == 2
|
|
665
|
+
assert "ESCALATE" in err
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def test_install_gemini_json_shape(isolated, tmp_path):
|
|
669
|
+
path = hook.install("gemini", user=False, project=tmp_path)
|
|
670
|
+
assert path == tmp_path / ".gemini" / "settings.json"
|
|
671
|
+
[entry] = json.loads(path.read_text())["hooks"]["BeforeTool"]
|
|
672
|
+
assert entry["matcher"] == "run_shell_command"
|
|
673
|
+
assert entry["hooks"][0]["command"].endswith("hook gemini")
|
|
674
|
+
assert entry["hooks"][0]["timeout"] == 30000 # milliseconds in Gemini CLI
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_install_opencode_writes_the_plugin_with_this_aegis(isolated, tmp_path):
|
|
678
|
+
path = hook.install("opencode", user=False, project=tmp_path)
|
|
679
|
+
assert path == tmp_path / ".opencode" / "plugins" / "aegis-devops.js"
|
|
680
|
+
text = path.read_text()
|
|
681
|
+
assert "const INSTALLED = null" not in text
|
|
682
|
+
assert f"const INSTALLED = {json.dumps(hook._aegis_command())}" in text
|
|
683
|
+
assert "tool.execute.before" in text
|
|
684
|
+
hook.install("opencode", user=False, project=tmp_path) # idempotent: rewrites
|
|
685
|
+
assert path.read_text() == text
|
|
686
|
+
hook.install("opencode", user=False, project=tmp_path, remove=True)
|
|
687
|
+
assert not path.exists()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The bundled OpenCode plugin, run under Node (OpenCode runs plugins in Bun,
|
|
2
|
+
which provides the same node:child_process/fs/os/path APIs it uses)."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from aegis_core import hook
|
|
11
|
+
from aegis_core.cli import main
|
|
12
|
+
from aegis_core.signing import load_key
|
|
13
|
+
|
|
14
|
+
NODE = shutil.which("node")
|
|
15
|
+
pytestmark = pytest.mark.skipif(NODE is None, reason="node not installed")
|
|
16
|
+
EXAMPLE_KEY = load_key("file:data/example-signing.key")
|
|
17
|
+
|
|
18
|
+
HARNESS = """
|
|
19
|
+
import { pathToFileURL } from "node:url"
|
|
20
|
+
const [pluginPath, directory, command] = process.argv.slice(2)
|
|
21
|
+
const { AegisDevOps } = await import(pathToFileURL(pluginPath).href)
|
|
22
|
+
const hooks = await AegisDevOps({ directory })
|
|
23
|
+
try {
|
|
24
|
+
await hooks["tool.execute.before"]({ tool: "bash" }, { args: { command } })
|
|
25
|
+
console.log(JSON.stringify({ allowed: true }))
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.log(JSON.stringify({ allowed: false, message: e.message }))
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def env(tmp_path, monkeypatch):
|
|
34
|
+
home, project = tmp_path / "home", tmp_path / "project"
|
|
35
|
+
home.mkdir()
|
|
36
|
+
project.mkdir()
|
|
37
|
+
for var in ("AEGIS_CONFIG_DIR", "CLAUDE_PROJECT_DIR", "COPILOT_PROJECT_DIR",
|
|
38
|
+
"CURSOR_PROJECT_DIR", "GEMINI_PROJECT_DIR", "AEGIS_BIN"):
|
|
39
|
+
monkeypatch.delenv(var, raising=False)
|
|
40
|
+
monkeypatch.setenv("HOME", str(home))
|
|
41
|
+
monkeypatch.setenv("AEGIS_SIGNING_KEY", EXAMPLE_KEY.hex())
|
|
42
|
+
plugin = hook.install("opencode", user=False, project=project)
|
|
43
|
+
mjs = tmp_path / "aegis-devops.mjs" # ESM regardless of any package.json
|
|
44
|
+
mjs.write_text(plugin.read_text())
|
|
45
|
+
harness = tmp_path / "harness.mjs"
|
|
46
|
+
harness.write_text(HARNESS)
|
|
47
|
+
return {"home": home, "project": project, "plugin": mjs, "harness": harness}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _call(env, command, extra_env=None):
|
|
51
|
+
import os
|
|
52
|
+
|
|
53
|
+
proc_env = {**os.environ, **(extra_env or {})}
|
|
54
|
+
out = subprocess.run(
|
|
55
|
+
[NODE, str(env["harness"]), str(env["plugin"]), str(env["project"]), command],
|
|
56
|
+
capture_output=True, text=True, env=proc_env, check=True, timeout=60,
|
|
57
|
+
)
|
|
58
|
+
return json.loads(out.stdout.strip().splitlines()[-1])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_not_opted_in_allows_everything(env):
|
|
62
|
+
assert _call(env, "kubectl delete nodes --all") == {"allowed": True}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_opted_in_blocks_only_what_the_policy_blocks(env):
|
|
66
|
+
main(["init", str(env["project"] / ".aegis")])
|
|
67
|
+
assert _call(env, "ls -la") == {"allowed": True}
|
|
68
|
+
blocked = _call(env, "kubectl delete nodes --all")
|
|
69
|
+
assert blocked["allowed"] is False
|
|
70
|
+
assert "no-delete-nodes" in blocked["message"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_escalate_blocks_because_opencode_cannot_ask(env):
|
|
74
|
+
main(["init", str(env["project"] / ".aegis")])
|
|
75
|
+
result = _call(env, "argocd app delete app1")
|
|
76
|
+
assert result["allowed"] is False
|
|
77
|
+
assert "ESCALATE" in result["message"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_missing_aegis_blocks_only_infra_in_opted_in_projects(env):
|
|
81
|
+
missing = {"AEGIS_BIN": str(env["home"] / "no-such-aegis")}
|
|
82
|
+
assert _call(env, "kubectl delete nodes --all", missing) == {"allowed": True}
|
|
83
|
+
(env["project"] / ".aegis").mkdir()
|
|
84
|
+
blocked = _call(env, "kubectl get pods", missing)
|
|
85
|
+
assert blocked["allowed"] is False
|
|
86
|
+
assert "pip install aegis-devops" in blocked["message"]
|
|
87
|
+
assert _call(env, "ls -la", missing) == {"allowed": True}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
"""Packaging of the Claude Code
|
|
2
|
-
|
|
1
|
+
"""Packaging of the Claude Code plugin, the Copilot plugin and the Gemini CLI
|
|
2
|
+
extension, and the wrapper script all three run when ``aegis`` itself may be
|
|
3
|
+
missing."""
|
|
3
4
|
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
@@ -13,6 +14,7 @@ from aegis_core import __version__
|
|
|
13
14
|
ROOT = Path(__file__).resolve().parent.parent
|
|
14
15
|
WRAPPER = ROOT / "hooks" / "aegis-hook.sh"
|
|
15
16
|
COPILOT = ROOT / "plugins" / "copilot"
|
|
17
|
+
CLAUDE = ROOT / "plugins" / "claude"
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
def test_copilot_plugin_manifest():
|
|
@@ -30,18 +32,52 @@ def test_copilot_plugin_hook_runs_the_wrapper():
|
|
|
30
32
|
assert (COPILOT / "aegis-hook.sh").stat().st_mode & 0o111
|
|
31
33
|
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
@pytest.mark.parametrize(
|
|
36
|
+
"copy", ["plugins/copilot/aegis-hook.sh", "plugins/claude/hooks/aegis-hook.sh"]
|
|
37
|
+
)
|
|
38
|
+
def test_plugin_wrappers_are_copies_of_the_root_one(copy):
|
|
34
39
|
# a plugin can only ship files inside its own folder
|
|
35
|
-
|
|
40
|
+
path = ROOT / copy
|
|
41
|
+
assert path.read_bytes() == WRAPPER.read_bytes()
|
|
42
|
+
assert path.stat().st_mode & 0o111
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_claude_marketplace_points_at_the_plugin_folder():
|
|
46
|
+
market = json.loads((ROOT / ".claude-plugin" / "marketplace.json").read_text())
|
|
47
|
+
[entry] = market["plugins"]
|
|
48
|
+
assert entry["name"] == "aegis-devops"
|
|
49
|
+
assert entry["source"] == "./plugins/claude"
|
|
50
|
+
manifest = json.loads((CLAUDE / ".claude-plugin" / "plugin.json").read_text())
|
|
51
|
+
assert manifest["name"] == entry["name"]
|
|
52
|
+
# the repo root is the Gemini extension now; no Claude plugin manifest there
|
|
53
|
+
assert not (ROOT / ".claude-plugin" / "plugin.json").exists()
|
|
36
54
|
|
|
37
55
|
|
|
38
56
|
def test_claude_plugin_hook_runs_the_wrapper():
|
|
39
|
-
hooks = json.loads((
|
|
57
|
+
hooks = json.loads((CLAUDE / "hooks" / "hooks.json").read_text())
|
|
40
58
|
[entry] = hooks["hooks"]["PreToolUse"]
|
|
41
59
|
assert entry["matcher"] == "Bash"
|
|
42
60
|
assert entry["hooks"][0]["command"] == '"${CLAUDE_PLUGIN_ROOT}"/hooks/aegis-hook.sh claude'
|
|
43
61
|
|
|
44
62
|
|
|
63
|
+
def test_gemini_extension_manifest():
|
|
64
|
+
manifest = json.loads((ROOT / "gemini-extension.json").read_text())
|
|
65
|
+
assert manifest["name"] == "aegis-devops"
|
|
66
|
+
assert manifest["version"] == __version__
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_gemini_extension_hook_runs_the_wrapper():
|
|
70
|
+
# Gemini CLI reads an extension's hooks from hooks/hooks.json at its root;
|
|
71
|
+
# nothing else may live there (Gemini warns about unknown events)
|
|
72
|
+
hooks = json.loads((ROOT / "hooks" / "hooks.json").read_text())
|
|
73
|
+
assert set(hooks["hooks"]) == {"BeforeTool"}
|
|
74
|
+
[entry] = hooks["hooks"]["BeforeTool"]
|
|
75
|
+
assert entry["matcher"] == "run_shell_command"
|
|
76
|
+
[h] = entry["hooks"]
|
|
77
|
+
assert h["command"] == '"${extensionPath}${/}hooks${/}aegis-hook.sh" gemini'
|
|
78
|
+
assert h["timeout"] == 30000
|
|
79
|
+
|
|
80
|
+
|
|
45
81
|
# --- the wrapper with no aegis installed --------------------------------------------
|
|
46
82
|
|
|
47
83
|
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/authority.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/constraints.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/environments.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/environments.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/plan_constraints.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/plan_constraints.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.1.4 → aegis_devops-0.1.6}/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
|
|
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.4 → aegis_devops-0.1.6}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|