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.
Files changed (110) hide show
  1. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/MANIFEST.in +1 -0
  2. {aegis_devops-0.1.4/src/aegis_devops.egg-info → aegis_devops-0.1.6}/PKG-INFO +35 -23
  3. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/README.md +34 -22
  4. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/pyproject.toml +1 -1
  5. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/__init__.py +1 -1
  6. aegis_devops-0.1.6/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  7. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/hook.py +44 -2
  8. aegis_devops-0.1.6/src/aegis_core/integrations/opencode-plugin.js +62 -0
  9. {aegis_devops-0.1.4 → aegis_devops-0.1.6/src/aegis_devops.egg-info}/PKG-INFO +35 -23
  10. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/SOURCES.txt +2 -0
  11. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_benchmark.py +38 -0
  12. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_hook.py +53 -2
  13. aegis_devops-0.1.6/tests/test_opencode_plugin.py +87 -0
  14. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_plugins.py +41 -5
  15. aegis_devops-0.1.4/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  16. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/LICENSE +0 -0
  17. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/setup.cfg +0 -0
  18. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/__init__.py +0 -0
  19. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/authority.example.yaml +0 -0
  20. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  21. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/constraints.example.yaml +0 -0
  22. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
  23. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/environments.example.yaml +0 -0
  24. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  25. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/example-signing.key +0 -0
  26. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  27. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  28. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
  29. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
  30. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  31. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6001.json +0 -0
  32. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6002.json +0 -0
  33. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6003.json +0 -0
  34. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6004.json +0 -0
  35. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6005.json +0 -0
  36. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6006.json +0 -0
  37. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6007.json +0 -0
  38. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-6008.json +0 -0
  39. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  40. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  41. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  42. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  43. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  44. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  45. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  46. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  47. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  48. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  49. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  50. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  51. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  52. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  53. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  54. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  55. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  56. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  57. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  58. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  59. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  60. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  61. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  62. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  63. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/adversarial.py +0 -0
  64. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/authority.py +0 -0
  65. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/__init__.py +0 -0
  66. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/base.py +0 -0
  67. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/external.py +0 -0
  68. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/llm.py +0 -0
  69. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/metrics.py +0 -0
  70. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/baselines/opa.py +0 -0
  71. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/cli.py +0 -0
  72. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/config.py +0 -0
  73. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/environments.py +0 -0
  74. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/intent.py +0 -0
  75. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/interceptor.py +0 -0
  76. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/ledger.py +0 -0
  77. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parser.py +0 -0
  78. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parsers/__init__.py +0 -0
  79. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parsers/pulumi.py +0 -0
  80. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/parsers/sql.py +0 -0
  81. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/plan.py +0 -0
  82. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/provenance.py +0 -0
  83. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/shell.py +0 -0
  84. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/signing.py +0 -0
  85. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_core/store.py +0 -0
  86. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  87. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  88. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/requires.txt +0 -0
  89. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/src/aegis_devops.egg-info/top_level.txt +0 -0
  90. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_adversarial.py +0 -0
  91. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_authority.py +0 -0
  92. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_baselines.py +0 -0
  93. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_cli.py +0 -0
  94. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_config.py +0 -0
  95. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_corpus.py +0 -0
  96. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_default_rules.py +0 -0
  97. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_docs_consistency.py +0 -0
  98. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_environments.py +0 -0
  99. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_intent.py +0 -0
  100. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_interceptor.py +0 -0
  101. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_ledger.py +0 -0
  102. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_parser.py +0 -0
  103. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_parsers_pulumi.py +0 -0
  104. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_parsers_sql.py +0 -0
  105. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_plan.py +0 -0
  106. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_provenance.py +0 -0
  107. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_shell.py +0 -0
  108. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_signing.py +0 -0
  109. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_store.py +0 -0
  110. {aegis_devops-0.1.4 → aegis_devops-0.1.6}/tests/test_store_match.py +0 -0
@@ -1,3 +1,4 @@
1
1
  include README.md
2
2
  include LICENSE
3
3
  recursive-include src/aegis_core/_examples *
4
+ recursive-include src/aegis_core/integrations *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aegis-devops
3
- Version: 0.1.4
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
- Codex, GitHub Copilot (CLI and VS Code) and Cursor: `aegis install codex|copilot|vscode|cursor`
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
- | codex (gpt-6-astra) | 0.150 | 0.300 | 0.200 |
185
- | codex-gpt-6-sol | 0.150 | 0.300 | 0.200 |
186
- | codex-gpt-6-luna | 0.117 | 0.233 | 0.100 |
187
- | claude-cli (haiku) | 0.150 | 0.300 | 0.200 |
188
- | claude-cli-sonnet | 0.167 | 0.333 | 0.200 |
189
- | claude-cli-opus | 0.150 | 0.300 | 0.200 |
190
- | claude-cli-fable | 0.150 | 0.300 | 0.200 |
191
- | opa-signed | 0.250 | 0.500 | 1.000 |
192
- | opa | 0.500 | 1.000 | 1.000 |
193
- | llm-heuristic | 0.500 | 1.000 | 1.000 |
194
- | ollama (mistral 7B) | 1.000 | 1.000 | 1.000 |
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 — and — unlike the other rows — are scored against a
207
- 100-constraint holdout subset rather than the full 500-constraint store, for context-window and
208
- cost reasons; see [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything
209
- else in the table. Model size and vendor do not change the picture: all seven land between
210
- 0.23 and 0.33 poison-susceptibility, and every one acts on 5 of 8 forged rules — a model can
211
- reason about who wrote a rule, but not recompute a hash or fetch a source.
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
- Codex, GitHub Copilot (CLI and VS Code) and Cursor: `aegis install codex|copilot|vscode|cursor`
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
- | codex (gpt-6-astra) | 0.150 | 0.300 | 0.200 |
153
- | codex-gpt-6-sol | 0.150 | 0.300 | 0.200 |
154
- | codex-gpt-6-luna | 0.117 | 0.233 | 0.100 |
155
- | claude-cli (haiku) | 0.150 | 0.300 | 0.200 |
156
- | claude-cli-sonnet | 0.167 | 0.333 | 0.200 |
157
- | claude-cli-opus | 0.150 | 0.300 | 0.200 |
158
- | claude-cli-fable | 0.150 | 0.300 | 0.200 |
159
- | opa-signed | 0.250 | 0.500 | 1.000 |
160
- | opa | 0.500 | 1.000 | 1.000 |
161
- | llm-heuristic | 0.500 | 1.000 | 1.000 |
162
- | ollama (mistral 7B) | 1.000 | 1.000 | 1.000 |
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 — and — unlike the other rows — are scored against a
175
- 100-constraint holdout subset rather than the full 500-constraint store, for context-window and
176
- cost reasons; see [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything
177
- else in the table. Model size and vendor do not change the picture: all seven land between
178
- 0.23 and 0.33 poison-susceptibility, and every one acts on 5 of 8 forged rules — a model can
179
- reason about who wrote a rule, but not recompute a hash or fetch a source.
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 |
@@ -62,7 +62,7 @@ version = {attr = "aegis_core.__version__"}
62
62
  where = ["src"]
63
63
 
64
64
  [tool.setuptools.package-data]
65
- aegis_core = ["_examples/**/*"]
65
+ aegis_core = ["_examples/**/*", "integrations/*"]
66
66
 
67
67
  [tool.ruff]
68
68
  line-length = 100
@@ -4,4 +4,4 @@ infrastructure actions of AI agents.
4
4
  See the README for what this does and, importantly, what it does not yet do.
5
5
  """
6
6
 
7
- __version__ = "0.1.4"
7
+ __version__ = "0.1.6"
@@ -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 == "codex" or escalate_as == "deny"):
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.4
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
- Codex, GitHub Copilot (CLI and VS Code) and Cursor: `aegis install codex|copilot|vscode|cursor`
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
- | codex (gpt-6-astra) | 0.150 | 0.300 | 0.200 |
185
- | codex-gpt-6-sol | 0.150 | 0.300 | 0.200 |
186
- | codex-gpt-6-luna | 0.117 | 0.233 | 0.100 |
187
- | claude-cli (haiku) | 0.150 | 0.300 | 0.200 |
188
- | claude-cli-sonnet | 0.167 | 0.333 | 0.200 |
189
- | claude-cli-opus | 0.150 | 0.300 | 0.200 |
190
- | claude-cli-fable | 0.150 | 0.300 | 0.200 |
191
- | opa-signed | 0.250 | 0.500 | 1.000 |
192
- | opa | 0.500 | 1.000 | 1.000 |
193
- | llm-heuristic | 0.500 | 1.000 | 1.000 |
194
- | ollama (mistral 7B) | 1.000 | 1.000 | 1.000 |
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 — and — unlike the other rows — are scored against a
207
- 100-constraint holdout subset rather than the full 500-constraint store, for context-window and
208
- cost reasons; see [`docs/benchmark.md`](docs/benchmark.md) before comparing them with anything
209
- else in the table. Model size and vendor do not change the picture: all seven land between
210
- 0.23 and 0.33 poison-susceptibility, and every one acts on 5 of 8 forged rules — a model can
211
- reason about who wrote a rule, but not recompute a hash or fetch a source.
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", hook.AGENTS)
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", hook.AGENTS)
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 and Copilot plugins, and the wrapper script
2
- they both run when ``aegis`` itself may be missing."""
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
- def test_copilot_wrapper_is_a_copy_of_the_claude_one():
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
- assert (COPILOT / "aegis-hook.sh").read_bytes() == WRAPPER.read_bytes()
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((ROOT / "hooks" / "hooks.json").read_text())
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
 
File without changes
File without changes