aegis-devops 0.2.0__tar.gz → 0.2.1__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.2.0/src/aegis_devops.egg-info → aegis_devops-0.2.1}/PKG-INFO +4 -4
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/README.md +3 -3
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.2.1/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/constraints.example.yaml +15 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/constraints.example.yaml.sig +2 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +3 -2
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +1 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/sources/git-6009.json +15 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/interceptor.py +26 -10
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parser.py +133 -20
- {aegis_devops-0.2.0 → aegis_devops-0.2.1/src/aegis_devops.egg-info}/PKG-INFO +4 -4
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/SOURCES.txt +1 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_cli.py +9 -9
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_default_rules.py +33 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_interceptor.py +59 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_parser.py +98 -2
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_parsers_sql.py +1 -1
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_store.py +2 -2
- aegis_devops-0.2.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.2.0/src/aegis_core/_examples/constraints.example.yaml.sig +0 -2
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/LICENSE +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/MANIFEST.in +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/pyproject.toml +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/setup.cfg +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/repos.example.yaml +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/repos.example.yaml.sig +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/signers.example.yaml +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/signers.example.yaml.sig +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6002.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6003.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6004.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6005.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6006.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6007.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6008.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/external.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/llm.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/cli.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/gitsource.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/hook.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/integrations/opencode-plugin.js +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/store.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_authority.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_baselines.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_config.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_corpus.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_docs_consistency.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_environments.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_gitsource.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_hook.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_intent.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_ledger.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_opencode_plugin.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_plan.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_plugins.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_provenance.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_shell.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_signing.py +0 -0
- {aegis_devops-0.2.0 → aegis_devops-0.2.1}/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.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -33,7 +33,7 @@ Dynamic: license-file
|
|
|
33
33
|
# Aegis-DevOps
|
|
34
34
|
|
|
35
35
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
36
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
37
37
|
|
|
38
38
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
39
39
|
ticket told them to.**
|
|
@@ -104,7 +104,7 @@ BLOCK: kubernetes scale deployment/api-server
|
|
|
104
104
|
citations: no-scale-prod-peak
|
|
105
105
|
covered: True latency_ms: 0.20
|
|
106
106
|
PLAN BLOCK: 1 intent(s)
|
|
107
|
-
STORE: loaded=
|
|
107
|
+
STORE: loaded=30 quarantined=0 principals=3
|
|
108
108
|
warning: using example signing key
|
|
109
109
|
```
|
|
110
110
|
|
|
@@ -252,7 +252,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
252
252
|
|
|
253
253
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
254
254
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
255
|
-
v0.2.
|
|
255
|
+
v0.2.1 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
256
256
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
257
257
|
through the Codex CLI, and a local `mistral:latest`.
|
|
258
258
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Aegis-DevOps
|
|
2
2
|
|
|
3
3
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
4
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
4
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
5
5
|
|
|
6
6
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
7
7
|
ticket told them to.**
|
|
@@ -72,7 +72,7 @@ BLOCK: kubernetes scale deployment/api-server
|
|
|
72
72
|
citations: no-scale-prod-peak
|
|
73
73
|
covered: True latency_ms: 0.20
|
|
74
74
|
PLAN BLOCK: 1 intent(s)
|
|
75
|
-
STORE: loaded=
|
|
75
|
+
STORE: loaded=30 quarantined=0 principals=3
|
|
76
76
|
warning: using example signing key
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -220,7 +220,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
220
220
|
|
|
221
221
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
222
222
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
223
|
-
v0.2.
|
|
223
|
+
v0.2.1 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
224
224
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
225
225
|
through the Codex CLI, and a local `mistral:latest`.
|
|
226
226
|
|
|
@@ -451,3 +451,18 @@ constraints:
|
|
|
451
451
|
rule_text: Escalate deleting Argo CD applications (with cascade this deletes the
|
|
452
452
|
deployed resources) for human review.
|
|
453
453
|
provenance_hash: dc24eb69ac5c8b66279284b6df19095e3f0096303f3ef18da712a53e5e339aeb
|
|
454
|
+
- id: block-kubectl-impersonation
|
|
455
|
+
provider: kubernetes
|
|
456
|
+
resource_pattern: '*'
|
|
457
|
+
actions:
|
|
458
|
+
- impersonate
|
|
459
|
+
scope: {}
|
|
460
|
+
time_window: null
|
|
461
|
+
effect: BLOCK
|
|
462
|
+
constraint_class: configuration
|
|
463
|
+
principal: admin
|
|
464
|
+
source_ref: git-6009
|
|
465
|
+
source_timestamp: '2026-09-28T12:00:00+00:00'
|
|
466
|
+
rule_text: 'Block kubectl impersonation (--as, --as-group, --as-uid): the request
|
|
467
|
+
would run as another identity, outside the controls on the agent''s own.'
|
|
468
|
+
provenance_hash: 3c2382c07e085d7a10be92490d9b8905085e6996c5c4ef1aaf2ba43980a41fd1
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": {
|
|
3
|
-
"PRINCIPALS.yaml": "
|
|
3
|
+
"PRINCIPALS.yaml": "94a36ee80347de5ff022f103f9b1288a82d008351ce958d9a5764f1edda16ed5",
|
|
4
4
|
"git-4001.json": "8be2dd6c37ef643b3bc375b15d5d97889b00456285f594e21a3b4d52521e21de",
|
|
5
5
|
"git-6001.json": "6592ca4568f767d71a431490ff3cacdbc699ad92be966f57aed3b0a485844dc5",
|
|
6
6
|
"git-6002.json": "e6286c5fb8a0d2b3c34ca0c841ccede2a477a02f4781115caf4650f4f7c019b8",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"git-6006.json": "3f6fbfeb34afb3bcb8075f0864047b3641f0016b8817f67288db7f64afd36b58",
|
|
11
11
|
"git-6007.json": "eb84394208274b79792e285c5143dde320a2f4755d9b2922ffb55df07e7c5b9e",
|
|
12
12
|
"git-6008.json": "34d29df94b55a3fa29521246fa17e0966eda93f718a6c41082e6c648569aafa5",
|
|
13
|
+
"git-6009.json": "cadad3135121749b5dc1564e44dd2f97684782f2ebdb1cefbc6e08a319d88337",
|
|
13
14
|
"git-789abc.json": "48ac500e13d36240ec5c72bf90e96b433a5873d636e9e36f99a73468e7fb5757",
|
|
14
15
|
"git-abc123.json": "ee3751fdcd64cf1dd374bf9f7b73cda723a4aced5053f32501ba9416de89afc7",
|
|
15
16
|
"git-bcd234.json": "a0de7b32bde75735c91504aa7156e7b2ba2ef961d0f439218507e40d0bdfd89a",
|
|
@@ -36,5 +37,5 @@
|
|
|
36
37
|
"slack-perma-1.json": "2c8945a68e413c300ef58fc6a6d3daa8e4d0029a26a454dfad983e2091a188d1"
|
|
37
38
|
},
|
|
38
39
|
"header": "aegis-manifest-v1",
|
|
39
|
-
"sig": "
|
|
40
|
+
"sig": "5c28ed037b47840ab065c77c790d7a5807ac318a05ffa43393a3e6395d5f71e1"
|
|
40
41
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"provider": "kubernetes",
|
|
3
|
+
"resource_pattern": "*",
|
|
4
|
+
"actions": [
|
|
5
|
+
"impersonate"
|
|
6
|
+
],
|
|
7
|
+
"scope": {},
|
|
8
|
+
"time_window": null,
|
|
9
|
+
"effect": "BLOCK",
|
|
10
|
+
"constraint_class": "configuration",
|
|
11
|
+
"principal": "admin",
|
|
12
|
+
"source_ref": "git-6009",
|
|
13
|
+
"source_timestamp": "2026-09-28T12:00:00+00:00",
|
|
14
|
+
"rule_text": "Block kubectl impersonation (--as, --as-group, --as-uid): the request would run as another identity, outside the controls on the agent's own."
|
|
15
|
+
}
|
|
@@ -120,6 +120,16 @@ class AegisInterceptor:
|
|
|
120
120
|
self.fail_closed = fail_closed
|
|
121
121
|
self.on_untrusted_match = on_untrusted_match
|
|
122
122
|
|
|
123
|
+
def _untrusted_reason(self, c: Constraint) -> str | None:
|
|
124
|
+
"""``"tampered"`` / ``"unauthorized"`` if this constraint may not
|
|
125
|
+
vote, else ``None`` (re-checked per decision; see REVIEW-4 L4
|
|
126
|
+
below)."""
|
|
127
|
+
if not c.verify_integrity():
|
|
128
|
+
return "tampered"
|
|
129
|
+
if not self.store.is_authorized(c.principal, c.constraint_class):
|
|
130
|
+
return "unauthorized"
|
|
131
|
+
return None
|
|
132
|
+
|
|
123
133
|
def intercept(self, intent: InfrastructureIntent, now: datetime | None = None) -> Decision:
|
|
124
134
|
"""Evaluates an intent and returns a Decision.
|
|
125
135
|
|
|
@@ -167,12 +177,22 @@ class AegisInterceptor:
|
|
|
167
177
|
notes: list[str] = []
|
|
168
178
|
fail_closed = False
|
|
169
179
|
verified = []
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
# An unresolved condition only fails closed on behalf of a constraint
|
|
181
|
+
# that could vote: a tampered or unauthorized one is discarded here
|
|
182
|
+
# exactly as it is when it matches outright (below), so it cannot
|
|
183
|
+
# force ESCALATE just because the intent's environment or time zone
|
|
184
|
+
# could not be resolved.
|
|
185
|
+
for c, label in [*((c, "env-unresolved") for c in env_unresolved),
|
|
186
|
+
*((c, "time-window-unresolved") for c in time_window_unresolved)]:
|
|
187
|
+
reason = self._untrusted_reason(c)
|
|
188
|
+
if reason is not None:
|
|
189
|
+
discarded.append({"id": c.id, "reason": reason})
|
|
190
|
+
if c.effect in _ENFORCING_EFFECTS and self.on_untrusted_match == "escalate":
|
|
191
|
+
fail_closed = True
|
|
192
|
+
notes.append(f"fail-closed: {c.id} ({reason})")
|
|
193
|
+
continue
|
|
174
194
|
fail_closed = True
|
|
175
|
-
notes.append(f"
|
|
195
|
+
notes.append(f"{label}: {c.id}")
|
|
176
196
|
if unknown_target:
|
|
177
197
|
fail_closed = True
|
|
178
198
|
notes.append("unknown-target")
|
|
@@ -193,11 +213,7 @@ class AegisInterceptor:
|
|
|
193
213
|
# rule, not per loaded rule -- see get_matching_constraints'
|
|
194
214
|
# index), so we pay it on every decision instead of trusting a
|
|
195
215
|
# verification result that may be stale by the time it matters.
|
|
196
|
-
reason =
|
|
197
|
-
if not c.verify_integrity():
|
|
198
|
-
reason = "tampered"
|
|
199
|
-
elif not self.store.is_authorized(c.principal, c.constraint_class):
|
|
200
|
-
reason = "unauthorized"
|
|
216
|
+
reason = self._untrusted_reason(c)
|
|
201
217
|
if reason is not None:
|
|
202
218
|
discarded.append({"id": c.id, "reason": reason})
|
|
203
219
|
if c.effect in _ENFORCING_EFFECTS and self.on_untrusted_match == "escalate":
|
|
@@ -177,11 +177,46 @@ _RESOURCE_ALIASES = {
|
|
|
177
177
|
"jobs": "job",
|
|
178
178
|
"cronjobs": "cronjob",
|
|
179
179
|
"hpa": "horizontalpodautoscaler",
|
|
180
|
+
"horizontalpodautoscalers": "horizontalpodautoscaler",
|
|
181
|
+
"sc": "storageclass",
|
|
182
|
+
"storageclasses": "storageclass",
|
|
183
|
+
"ingressclasses": "ingressclass",
|
|
184
|
+
"pv": "persistentvolume",
|
|
185
|
+
"persistentvolumes": "persistentvolume",
|
|
186
|
+
"netpol": "networkpolicy",
|
|
187
|
+
"networkpolicies": "networkpolicy",
|
|
188
|
+
"pdb": "poddisruptionbudget",
|
|
189
|
+
"poddisruptionbudgets": "poddisruptionbudget",
|
|
190
|
+
"roles": "role",
|
|
191
|
+
"rolebindings": "rolebinding",
|
|
192
|
+
"clusterroles": "clusterrole",
|
|
193
|
+
"clusterrolebindings": "clusterrolebinding",
|
|
194
|
+
"crd": "customresourcedefinition",
|
|
195
|
+
"crds": "customresourcedefinition",
|
|
196
|
+
"customresourcedefinitions": "customresourcedefinition",
|
|
197
|
+
"validatingwebhookconfigurations": "validatingwebhookconfiguration",
|
|
198
|
+
"mutatingwebhookconfigurations": "mutatingwebhookconfiguration",
|
|
199
|
+
"priorityclasses": "priorityclass",
|
|
200
|
+
"pc": "priorityclass",
|
|
201
|
+
"limitranges": "limitrange",
|
|
202
|
+
"limits": "limitrange",
|
|
203
|
+
"resourcequotas": "resourcequota",
|
|
204
|
+
"quota": "resourcequota",
|
|
205
|
+
"certificatesigningrequests": "certificatesigningrequest",
|
|
206
|
+
"csr": "certificatesigningrequest",
|
|
207
|
+
"ep": "endpoints", # the kind is "Endpoints"; its singular name is "endpoints"
|
|
208
|
+
"leases": "lease",
|
|
209
|
+
"events": "event",
|
|
210
|
+
"ev": "event",
|
|
180
211
|
}
|
|
181
212
|
|
|
182
213
|
# kubectl flags that are booleans (no value token follows) when given
|
|
183
214
|
# without "=value".
|
|
184
|
-
_KNOWN_BOOL_FLAGS = {
|
|
215
|
+
_KNOWN_BOOL_FLAGS = {
|
|
216
|
+
"force", "cascade", "all", "wait", "now", "ignore-not-found",
|
|
217
|
+
# kubectl drain
|
|
218
|
+
"ignore-daemonsets", "delete-emptydir-data", "delete-local-data", "disable-eviction",
|
|
219
|
+
}
|
|
185
220
|
|
|
186
221
|
# Global boolean flags that are consumed but not recorded anywhere.
|
|
187
222
|
_DISCARD_BOOL_FLAGS = {
|
|
@@ -199,9 +234,6 @@ _DISCARD_VALUE_FLAGS = {
|
|
|
199
234
|
"s",
|
|
200
235
|
"server",
|
|
201
236
|
"v",
|
|
202
|
-
"as",
|
|
203
|
-
"as-uid",
|
|
204
|
-
"as-group",
|
|
205
237
|
"request-timeout",
|
|
206
238
|
"token",
|
|
207
239
|
"user",
|
|
@@ -217,6 +249,9 @@ _DISCARD_VALUE_FLAGS = {
|
|
|
217
249
|
"log-flush-frequency",
|
|
218
250
|
}
|
|
219
251
|
|
|
252
|
+
# kubectl impersonation flags, recorded in params["impersonate"].
|
|
253
|
+
_IMPERSONATION_FLAGS = {"as", "as-uid", "as-group"}
|
|
254
|
+
|
|
220
255
|
# Flags that point at a manifest (file or kustomize directory).
|
|
221
256
|
_MANIFEST_FLAGS = {"f", "filename", "k", "kustomize"}
|
|
222
257
|
|
|
@@ -227,15 +262,24 @@ _KUBECTL_IDENTITY_FLAGS = {"n", "namespace", "context", "cluster"}
|
|
|
227
262
|
_SELECTOR_FLAGS = {"l": "selector", "selector": "selector", "field-selector": "field_selector"}
|
|
228
263
|
|
|
229
264
|
# Every global option kubectl accepts in front of the verb, split by arity.
|
|
230
|
-
_KUBECTL_GLOBAL_VALUE_FLAGS = _KUBECTL_IDENTITY_FLAGS | _DISCARD_VALUE_FLAGS
|
|
265
|
+
_KUBECTL_GLOBAL_VALUE_FLAGS = _KUBECTL_IDENTITY_FLAGS | _DISCARD_VALUE_FLAGS | _IMPERSONATION_FLAGS
|
|
231
266
|
_KUBECTL_GLOBAL_BOOL_FLAGS = {"A", "all-namespaces"} | _DISCARD_BOOL_FLAGS
|
|
232
267
|
|
|
233
268
|
# Short flags that may be glued to their value: -nprod, -lapp=web, -fx.yaml.
|
|
234
269
|
_KUBECTL_GLUED_SHORT_FLAGS = {"n", "l", "f", "o", "k", "s", "v"}
|
|
235
270
|
|
|
236
|
-
# Verbs whose "resource" is just the literal first positional token (a
|
|
237
|
-
#
|
|
238
|
-
_LITERAL_RESOURCE_VERBS = {"
|
|
271
|
+
# Verbs whose "resource" is just the literal first positional token (a
|
|
272
|
+
# path), not a kind/name pair to be normalised.
|
|
273
|
+
_LITERAL_RESOURCE_VERBS = {"cp"}
|
|
274
|
+
|
|
275
|
+
# Verbs whose target is a pod by default: a bare name is a pod
|
|
276
|
+
# ("exec api-0" -> pod/api-0), and "deploy/web" / "svc/web" normalise like
|
|
277
|
+
# any other kind/name token.
|
|
278
|
+
_POD_TARGET_VERBS = {"exec", "logs", "port-forward", "attach"}
|
|
279
|
+
|
|
280
|
+
# Verbs that act on nodes: a bare name is a node ("drain node1" ->
|
|
281
|
+
# node/node1), not a kind to list.
|
|
282
|
+
_NODE_TARGET_VERBS = {"cordon", "uncordon", "drain"}
|
|
239
283
|
|
|
240
284
|
|
|
241
285
|
def _coerce(value: str) -> Any:
|
|
@@ -470,6 +514,18 @@ def _parse_flags(
|
|
|
470
514
|
i += 1
|
|
471
515
|
val = _value_at(tokens, i, tok)
|
|
472
516
|
manifest_value = val
|
|
517
|
+
elif key in _IMPERSONATION_FLAGS:
|
|
518
|
+
# kubectl --as / --as-group / --as-uid: the request runs as
|
|
519
|
+
# someone else. Recorded (never silently dropped), so a
|
|
520
|
+
# policy or a server-side check can see it.
|
|
521
|
+
if val is None:
|
|
522
|
+
i += 1
|
|
523
|
+
val = _value_at(tokens, i, tok)
|
|
524
|
+
imp = params.setdefault("impersonate", {})
|
|
525
|
+
if key == "as-group":
|
|
526
|
+
imp.setdefault("groups", []).append(val)
|
|
527
|
+
else:
|
|
528
|
+
imp["user" if key == "as" else "uid"] = val
|
|
473
529
|
elif key in _DISCARD_VALUE_FLAGS:
|
|
474
530
|
if val is None:
|
|
475
531
|
i += 1
|
|
@@ -514,7 +570,33 @@ def _make_intents(
|
|
|
514
570
|
def from_kubectl_multi(argv: list[str]) -> list[InfrastructureIntent]:
|
|
515
571
|
"""Parses a kubectl invocation into one InfrastructureIntent per target
|
|
516
572
|
resource (kubectl commands may name more than one, e.g.
|
|
517
|
-
``kubectl delete pod/a pod/b``).
|
|
573
|
+
``kubectl delete pod/a pod/b``).
|
|
574
|
+
|
|
575
|
+
With ``--as`` / ``--as-group`` / ``--as-uid`` the request runs as
|
|
576
|
+
someone else, which moves it out of an agent identity's scope on the
|
|
577
|
+
server side. So besides the action itself, each impersonated identity
|
|
578
|
+
becomes its own ``impersonate`` intent (``user/<name>``,
|
|
579
|
+
``group/<name>``, ``uid/<id>``) that a policy can block or escalate."""
|
|
580
|
+
intents = _from_kubectl_multi(argv)
|
|
581
|
+
seen: set[str] = set()
|
|
582
|
+
extra: list[InfrastructureIntent] = []
|
|
583
|
+
for intent in intents:
|
|
584
|
+
imp = intent.params.get("impersonate") or {}
|
|
585
|
+
targets = ([f"user/{imp['user']}"] if "user" in imp else []) + [
|
|
586
|
+
f"group/{g}" for g in imp.get("groups", [])
|
|
587
|
+
] + ([f"uid/{imp['uid']}"] if "uid" in imp else [])
|
|
588
|
+
for target in targets:
|
|
589
|
+
if target in seen:
|
|
590
|
+
continue
|
|
591
|
+
seen.add(target)
|
|
592
|
+
extra.append(InfrastructureIntent(
|
|
593
|
+
resource=target, action="impersonate", provider="kubernetes",
|
|
594
|
+
params={"impersonate": imp}, metadata=dict(intent.metadata),
|
|
595
|
+
))
|
|
596
|
+
return intents + extra
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _from_kubectl_multi(argv: list[str]) -> list[InfrastructureIntent]:
|
|
518
600
|
if len(argv) < 2 or not (argv[0] == "kubectl" or argv[0].endswith("/kubectl")):
|
|
519
601
|
raise ValueError(f"not a recognizable kubectl invocation: {argv!r}")
|
|
520
602
|
|
|
@@ -526,19 +608,34 @@ def from_kubectl_multi(argv: list[str]) -> list[InfrastructureIntent]:
|
|
|
526
608
|
tool="kubectl",
|
|
527
609
|
)
|
|
528
610
|
verb = rest[0]
|
|
611
|
+
after_verb = rest[1:]
|
|
612
|
+
|
|
613
|
+
# A subcommand ("rollout restart", "set image") is the token right after
|
|
614
|
+
# the verb, so it is taken from the post-verb tokens *before* the leading
|
|
615
|
+
# global options are merged back in -- otherwise "kubectl -n prod rollout
|
|
616
|
+
# restart x" would read "-n" as the subcommand.
|
|
617
|
+
# kubectl also accepts global options *between* the verb and its
|
|
618
|
+
# subcommand ("kubectl rollout --as admin restart x"), so those are
|
|
619
|
+
# consumed the same way as leading ones and kept for the flag walker.
|
|
620
|
+
subverb = None
|
|
621
|
+
if verb in ("rollout", "set"):
|
|
622
|
+
if not after_verb:
|
|
623
|
+
raise ValueError(f"kubectl {verb} requires a subcommand: {argv!r}")
|
|
624
|
+
between, sub_rest = _split_leading_globals(
|
|
625
|
+
after_verb,
|
|
626
|
+
value_flags=_KUBECTL_GLOBAL_VALUE_FLAGS,
|
|
627
|
+
bool_flags=_KUBECTL_GLOBAL_BOOL_FLAGS,
|
|
628
|
+
tool="kubectl",
|
|
629
|
+
)
|
|
630
|
+
subverb, after_verb = sub_rest[0], between + sub_rest[1:]
|
|
631
|
+
if verb == "set":
|
|
632
|
+
if subverb != "image":
|
|
633
|
+
raise ValueError(f"unsupported 'kubectl set' invocation: {argv!r}")
|
|
634
|
+
subverb = None
|
|
635
|
+
|
|
529
636
|
# Global options are parsed by the same flag walker as post-verb flags,
|
|
530
637
|
# so "-n prod delete x" and "delete x -n prod" produce identical intents.
|
|
531
|
-
tokens = leading +
|
|
532
|
-
|
|
533
|
-
subverb = None
|
|
534
|
-
if verb == "rollout":
|
|
535
|
-
if not tokens:
|
|
536
|
-
raise ValueError(f"kubectl rollout requires a subcommand: {argv!r}")
|
|
537
|
-
subverb, *tokens = tokens
|
|
538
|
-
elif verb == "set":
|
|
539
|
-
if not tokens or tokens[0] != "image":
|
|
540
|
-
raise ValueError(f"unsupported 'kubectl set' invocation: {argv!r}")
|
|
541
|
-
tokens = tokens[1:]
|
|
638
|
+
tokens = leading + after_verb
|
|
542
639
|
|
|
543
640
|
metadata, params, positional, manifest_value, command = _parse_flags(tokens)
|
|
544
641
|
if command is not None:
|
|
@@ -560,6 +657,22 @@ def from_kubectl_multi(argv: list[str]) -> list[InfrastructureIntent]:
|
|
|
560
657
|
raise ValueError(f"could not find a target resource in: {argv!r}")
|
|
561
658
|
return _make_intents([positional[0]], verb, params, metadata)
|
|
562
659
|
|
|
660
|
+
if verb in _POD_TARGET_VERBS:
|
|
661
|
+
if not positional:
|
|
662
|
+
raise ValueError(f"could not find a target resource in: {argv!r}")
|
|
663
|
+
kind, name = _split_resource_token(positional[0])
|
|
664
|
+
target = f"{kind}/{name}" if name is not None else f"pod/{positional[0]}"
|
|
665
|
+
return _make_intents([target], verb, params, metadata)
|
|
666
|
+
|
|
667
|
+
if verb in _NODE_TARGET_VERBS:
|
|
668
|
+
if not positional:
|
|
669
|
+
raise ValueError(f"could not find a target resource in: {argv!r}")
|
|
670
|
+
targets = []
|
|
671
|
+
for token in positional:
|
|
672
|
+
kind, name = _split_resource_token(token)
|
|
673
|
+
targets.append(f"{kind}/{name}" if name is not None else f"node/{token}")
|
|
674
|
+
return _make_intents(targets, verb, params, metadata)
|
|
675
|
+
|
|
563
676
|
if verb in ("label", "annotate"):
|
|
564
677
|
resource_tokens = [t for t in positional if "=" not in t]
|
|
565
678
|
kv_tokens = [t for t in positional if "=" in t]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aegis-devops
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -33,7 +33,7 @@ Dynamic: license-file
|
|
|
33
33
|
# Aegis-DevOps
|
|
34
34
|
|
|
35
35
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
36
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
37
37
|
|
|
38
38
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
39
39
|
ticket told them to.**
|
|
@@ -104,7 +104,7 @@ BLOCK: kubernetes scale deployment/api-server
|
|
|
104
104
|
citations: no-scale-prod-peak
|
|
105
105
|
covered: True latency_ms: 0.20
|
|
106
106
|
PLAN BLOCK: 1 intent(s)
|
|
107
|
-
STORE: loaded=
|
|
107
|
+
STORE: loaded=30 quarantined=0 principals=3
|
|
108
108
|
warning: using example signing key
|
|
109
109
|
```
|
|
110
110
|
|
|
@@ -252,7 +252,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
252
252
|
|
|
253
253
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
254
254
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
255
|
-
v0.2.
|
|
255
|
+
v0.2.1 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
256
256
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
257
257
|
through the Codex CLI, and a local `mistral:latest`.
|
|
258
258
|
|
|
@@ -45,6 +45,7 @@ src/aegis_core/_examples/sources/git-6005.json
|
|
|
45
45
|
src/aegis_core/_examples/sources/git-6006.json
|
|
46
46
|
src/aegis_core/_examples/sources/git-6007.json
|
|
47
47
|
src/aegis_core/_examples/sources/git-6008.json
|
|
48
|
+
src/aegis_core/_examples/sources/git-6009.json
|
|
48
49
|
src/aegis_core/_examples/sources/git-789abc.json
|
|
49
50
|
src/aegis_core/_examples/sources/git-abc123.json
|
|
50
51
|
src/aegis_core/_examples/sources/git-bcd234.json
|
|
@@ -460,7 +460,7 @@ def test_example_constraints_load_with_zero_quarantined():
|
|
|
460
460
|
|
|
461
461
|
store = ConstraintStore.load(CONSTRAINTS, authority_map=load_authority_map(AUTHORITY))
|
|
462
462
|
assert store.quarantined == []
|
|
463
|
-
assert len(store.constraints) ==
|
|
463
|
+
assert len(store.constraints) == 30
|
|
464
464
|
|
|
465
465
|
|
|
466
466
|
def test_tofu_plan_is_gated_by_the_same_terraform_rule(capsys, tmp_path):
|
|
@@ -680,7 +680,7 @@ def test_sources_flag_quarantines_forged_constraints_with_no_vote_by_default(cap
|
|
|
680
680
|
)
|
|
681
681
|
assert {"id": "no-scale-prod-peak", "reason": "forged"} in plan["quarantined_at_load"]
|
|
682
682
|
assert {"id": "no-scale-prod-peak", "reason": "forged"} in plan["store_health"]["quarantined"]
|
|
683
|
-
assert lines[0]["store_health"]["loaded"] ==
|
|
683
|
+
assert lines[0]["store_health"]["loaded"] == 29
|
|
684
684
|
|
|
685
685
|
|
|
686
686
|
def test_sources_flag_forged_constraint_escalates_under_on_untrusted_match_escalate(
|
|
@@ -708,7 +708,7 @@ def test_sources_flag_forged_constraint_escalates_under_on_untrusted_match_escal
|
|
|
708
708
|
assert "fail-closed: no-scale-prod-peak (forged)" in lines[0]["decision"]["notes"]
|
|
709
709
|
assert {"id": "no-scale-prod-peak", "reason": "forged"} in plan["quarantined_at_load"]
|
|
710
710
|
assert {"id": "no-scale-prod-peak", "reason": "forged"} in plan["store_health"]["quarantined"]
|
|
711
|
-
assert lines[0]["store_health"]["loaded"] ==
|
|
711
|
+
assert lines[0]["store_health"]["loaded"] == 29
|
|
712
712
|
|
|
713
713
|
|
|
714
714
|
def test_ledger_flag_records_allowed_actions(capsys, tmp_path):
|
|
@@ -874,7 +874,7 @@ def test_store_health_is_on_every_per_intent_line_and_the_plan_summary(capsys, m
|
|
|
874
874
|
)
|
|
875
875
|
assert code == 0
|
|
876
876
|
health = lines[0]["store_health"]
|
|
877
|
-
assert health["loaded"] ==
|
|
877
|
+
assert health["loaded"] == 30
|
|
878
878
|
assert health["quarantined"] == []
|
|
879
879
|
assert health["principals"] == 3
|
|
880
880
|
assert len(health["constraints_sha256"]) == 64
|
|
@@ -890,7 +890,7 @@ def test_pretty_output_ends_with_store_line(capsys):
|
|
|
890
890
|
)
|
|
891
891
|
out = capsys.readouterr().out
|
|
892
892
|
assert code == 0
|
|
893
|
-
assert out.strip().splitlines()[-1] == "STORE: loaded=
|
|
893
|
+
assert out.strip().splitlines()[-1] == "STORE: loaded=30 quarantined=0 principals=3"
|
|
894
894
|
|
|
895
895
|
|
|
896
896
|
def test_single_bit_flip_allows_by_default_but_reports_quarantine(capsys, tmp_path):
|
|
@@ -919,7 +919,7 @@ def test_single_bit_flip_allows_by_default_but_reports_quarantine(capsys, tmp_pa
|
|
|
919
919
|
assert decision["notes"] == []
|
|
920
920
|
health = lines[0]["store_health"]
|
|
921
921
|
assert health["quarantined"] == [{"id": "no-delete-nodes", "reason": "tampered"}]
|
|
922
|
-
assert health["loaded"] ==
|
|
922
|
+
assert health["loaded"] == 29
|
|
923
923
|
assert "aegis: WARNING Quarantined constraint no-delete-nodes" in err
|
|
924
924
|
|
|
925
925
|
|
|
@@ -939,7 +939,7 @@ def test_single_bit_flip_escalates_under_on_untrusted_match_escalate(capsys, tmp
|
|
|
939
939
|
assert "fail-closed: no-delete-nodes (tampered)" in decision["notes"]
|
|
940
940
|
health = lines[0]["store_health"]
|
|
941
941
|
assert health["quarantined"] == [{"id": "no-delete-nodes", "reason": "tampered"}]
|
|
942
|
-
assert health["loaded"] ==
|
|
942
|
+
assert health["loaded"] == 29
|
|
943
943
|
assert "aegis: WARNING Quarantined constraint no-delete-nodes" in err
|
|
944
944
|
|
|
945
945
|
|
|
@@ -952,7 +952,7 @@ def test_single_bit_flip_pretty_lists_quarantined_ids_but_allows_by_default(caps
|
|
|
952
952
|
)
|
|
953
953
|
out = capsys.readouterr().out
|
|
954
954
|
assert code == 0
|
|
955
|
-
assert "STORE: loaded=
|
|
955
|
+
assert "STORE: loaded=29 quarantined=1 principals=3" in out
|
|
956
956
|
assert " quarantined: no-delete-nodes (tampered)" in out
|
|
957
957
|
assert "note: fail-closed:" not in out
|
|
958
958
|
|
|
@@ -968,7 +968,7 @@ def test_single_bit_flip_pretty_lists_quarantined_ids_and_escalates_under_escala
|
|
|
968
968
|
)
|
|
969
969
|
out = capsys.readouterr().out
|
|
970
970
|
assert code == 2
|
|
971
|
-
assert "STORE: loaded=
|
|
971
|
+
assert "STORE: loaded=29 quarantined=1 principals=3" in out
|
|
972
972
|
assert " quarantined: no-delete-nodes (tampered)" in out
|
|
973
973
|
assert "note: fail-closed: no-delete-nodes (tampered)" in out
|
|
974
974
|
|
|
@@ -124,3 +124,36 @@ def test_forged_sources_differ_from_sources_in_one_file_only():
|
|
|
124
124
|
or (real / n).read_bytes() != (forged / n).read_bytes()
|
|
125
125
|
)
|
|
126
126
|
assert differing == ["jira-1001.json"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.parametrize("command, target", [
|
|
130
|
+
("kubectl --as admin get pods", "user/admin"),
|
|
131
|
+
("kubectl get pods --as-group=system:masters", "group/system:masters"),
|
|
132
|
+
("kubectl delete pod/x --as-uid=0", "uid/0"),
|
|
133
|
+
])
|
|
134
|
+
def test_kubectl_impersonation_is_blocked(command, target, capsys):
|
|
135
|
+
code, verdicts = _verdicts(command, capsys)
|
|
136
|
+
assert ("BLOCK", ["block-kubectl-impersonation"]) in verdicts
|
|
137
|
+
assert code == 3
|
|
138
|
+
assert ("impersonate", target) in [
|
|
139
|
+
(i.action, i.resource) for i in intents_from_command(command)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_kubectl_without_impersonation_is_unaffected(capsys):
|
|
143
|
+
code, verdicts = _verdicts("kubectl get pods", capsys)
|
|
144
|
+
assert code == 0
|
|
145
|
+
assert all(v == "ALLOW" for v, _ in verdicts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize("command", [
|
|
149
|
+
"kubectl --as admin rollout restart deploy/web",
|
|
150
|
+
"kubectl --as=admin rollout restart deploy/web",
|
|
151
|
+
"kubectl --as=admin set image deploy/web app=x:2",
|
|
152
|
+
"kubectl rollout --as admin restart deploy/web",
|
|
153
|
+
"kubectl rollout --as=admin restart deploy/web",
|
|
154
|
+
"kubectl set --as=admin image deploy/web app=x:2",
|
|
155
|
+
])
|
|
156
|
+
def test_impersonation_before_a_subcommand_verb_is_blocked(command, capsys):
|
|
157
|
+
code, verdicts = _verdicts(command, capsys)
|
|
158
|
+
assert ("BLOCK", ["block-kubectl-impersonation"]) in verdicts
|
|
159
|
+
assert code == 3
|
|
@@ -779,3 +779,62 @@ def test_resource_pattern_matches_module_stripped_terraform_alias():
|
|
|
779
779
|
bare = InfrastructureIntent(resource="module.app.aws_db_instance.main", action="delete",
|
|
780
780
|
provider="terraform")
|
|
781
781
|
assert interceptor.intercept(bare, now=NOW).verdict == "ALLOW"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# --- v0.2.1: unresolved conditions only fail closed for rules that may vote -----
|
|
785
|
+
# (council review of the v0.3 design, gpt-6-astra P0-2: an unauthorized or
|
|
786
|
+
# tampered rule could force ESCALATE through the env-/time-window-unresolved
|
|
787
|
+
# paths, which ran before any integrity or authority check.)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _env_rule(**overrides):
|
|
791
|
+
fields = dict(id="prod-only", actions={"delete"}, resource_pattern="*",
|
|
792
|
+
scope={"env": "prod"}, constraint_class="deletion", principal="admin")
|
|
793
|
+
fields.update(overrides)
|
|
794
|
+
return make_constraint(**fields)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
_UNRESOLVED_ENV_INTENT = InfrastructureIntent(
|
|
798
|
+
resource="pod/x", action="delete", provider="kubernetes", metadata={"namespace": "prod"})
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def test_unauthorized_rule_cannot_force_escalate_through_env_unresolved():
|
|
802
|
+
rule = _env_rule(principal="developer") # developer may not assert deletion
|
|
803
|
+
decision = AegisInterceptor(_store_with(rule)).intercept(_UNRESOLVED_ENV_INTENT, now=NOW)
|
|
804
|
+
assert decision.verdict == "ALLOW"
|
|
805
|
+
assert decision.notes == []
|
|
806
|
+
assert decision.discarded == [{"id": "prod-only", "reason": "unauthorized"}]
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def test_tampered_rule_cannot_force_escalate_through_env_unresolved():
|
|
810
|
+
rule = _env_rule()
|
|
811
|
+
rule.effect = "ESCALATE" # mutated after its hash was computed
|
|
812
|
+
decision = AegisInterceptor(_store_with(rule)).intercept(_UNRESOLVED_ENV_INTENT, now=NOW)
|
|
813
|
+
assert decision.verdict == "ALLOW"
|
|
814
|
+
assert decision.discarded == [{"id": "prod-only", "reason": "tampered"}]
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def test_unauthorized_env_rule_escalates_only_under_escalate_mode():
|
|
818
|
+
rule = _env_rule(principal="developer")
|
|
819
|
+
decision = AegisInterceptor(_store_with(rule), on_untrusted_match="escalate").intercept(
|
|
820
|
+
_UNRESOLVED_ENV_INTENT, now=NOW)
|
|
821
|
+
assert decision.verdict == "ESCALATE"
|
|
822
|
+
assert decision.notes == ["fail-closed: prod-only (unauthorized)"]
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def test_authorized_env_rule_still_escalates_when_env_is_unresolved():
|
|
826
|
+
decision = AegisInterceptor(_store_with(_env_rule())).intercept(
|
|
827
|
+
_UNRESOLVED_ENV_INTENT, now=NOW)
|
|
828
|
+
assert (decision.verdict, decision.notes) == ("ESCALATE", ["env-unresolved: prod-only"])
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def test_unauthorized_rule_cannot_force_escalate_through_time_window_unresolved():
|
|
832
|
+
rule = make_constraint(
|
|
833
|
+
id="peak-hours", actions={"scale"}, principal="developer", constraint_class="scaling",
|
|
834
|
+
time_window={"days": ["Mon"], "start": "09:00", "end": "17:00", "tz": "America/New_York"},
|
|
835
|
+
)
|
|
836
|
+
store = _store_with(rule)
|
|
837
|
+
store.tzdata_available = False
|
|
838
|
+
decision = AegisInterceptor(store).intercept(SCALE_INTENT, now=NOW)
|
|
839
|
+
assert decision.verdict == "ALLOW"
|
|
840
|
+
assert decision.discarded == [{"id": "peak-hours", "reason": "unauthorized"}]
|
|
@@ -254,14 +254,14 @@ def test_from_kubectl_rollout_status():
|
|
|
254
254
|
def test_from_kubectl_exec_with_command():
|
|
255
255
|
intent = from_kubectl(["kubectl", "exec", "mypod", "--", "ls", "-la"])
|
|
256
256
|
assert intent.action == "exec"
|
|
257
|
-
assert intent.resource == "mypod"
|
|
257
|
+
assert intent.resource == "pod/mypod" # a bare name is a pod
|
|
258
258
|
assert intent.params["command"] == ["ls", "-la"]
|
|
259
259
|
|
|
260
260
|
|
|
261
261
|
def test_from_kubectl_logs_resource():
|
|
262
262
|
intent = from_kubectl(["kubectl", "logs", "mypod"])
|
|
263
263
|
assert intent.action == "logs"
|
|
264
|
-
assert intent.resource == "mypod"
|
|
264
|
+
assert intent.resource == "pod/mypod"
|
|
265
265
|
|
|
266
266
|
|
|
267
267
|
def test_from_kubectl_set_image():
|
|
@@ -2487,3 +2487,99 @@ def test_helm_set_replica_count_zero_expressible_as_a_scope_rule():
|
|
|
2487
2487
|
["helm", "upgrade", "api", "./chart", "--set", "replicaCount=3"]
|
|
2488
2488
|
)
|
|
2489
2489
|
assert not scope_matches(scope, not_zero.metadata, not_zero.params)
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
# --- v0.2.1: kubectl normal forms (council review of the v0.3 design) --------
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
@pytest.mark.parametrize("argv, expected", [
|
|
2496
|
+
("kubectl drain node1", [("drain", "node/node1")]),
|
|
2497
|
+
("kubectl drain node/node1 --ignore-daemonsets", [("drain", "node/node1")]),
|
|
2498
|
+
("kubectl cordon node1 node2", [("cordon", "node/node1"), ("cordon", "node/node2")]),
|
|
2499
|
+
("kubectl uncordon node1", [("uncordon", "node/node1")]),
|
|
2500
|
+
("kubectl exec api-0 -- sh", [("exec", "pod/api-0")]),
|
|
2501
|
+
("kubectl exec deploy/web -- sh", [("exec", "deployment/web")]),
|
|
2502
|
+
("kubectl attach api-0", [("attach", "pod/api-0")]),
|
|
2503
|
+
("kubectl port-forward svc/web 8080:80", [("port-forward", "service/web")]),
|
|
2504
|
+
("kubectl port-forward api-0 8080:80", [("port-forward", "pod/api-0")]),
|
|
2505
|
+
("kubectl logs api-0", [("logs", "pod/api-0")]),
|
|
2506
|
+
("kubectl delete storageclasses fast", [("delete", "storageclass/fast")]),
|
|
2507
|
+
("kubectl delete sc fast", [("delete", "storageclass/fast")]),
|
|
2508
|
+
("kubectl delete ingressclasses nginx", [("delete", "ingressclass/nginx")]),
|
|
2509
|
+
("kubectl delete clusterrolebindings admin", [("delete", "clusterrolebinding/admin")]),
|
|
2510
|
+
("kubectl delete validatingwebhookconfigurations aegis",
|
|
2511
|
+
[("delete", "validatingwebhookconfiguration/aegis")]),
|
|
2512
|
+
("kubectl delete ep web", [("delete", "endpoints/web")]),
|
|
2513
|
+
])
|
|
2514
|
+
def test_kubectl_normal_forms(argv, expected):
|
|
2515
|
+
from aegis_core.parser import from_argv
|
|
2516
|
+
|
|
2517
|
+
assert [(i.action, i.resource) for i in from_argv(argv.split())] == expected
|
|
2518
|
+
|
|
2519
|
+
|
|
2520
|
+
def test_kubectl_impersonation_is_recorded_not_dropped():
|
|
2521
|
+
from aegis_core.parser import from_argv
|
|
2522
|
+
|
|
2523
|
+
intents = from_argv("kubectl delete node w1 --as=admin --as-group=system:masters "
|
|
2524
|
+
"--as-group=ops --as-uid=42".split())
|
|
2525
|
+
assert intents[0].params["impersonate"] == {
|
|
2526
|
+
"user": "admin", "groups": ["system:masters", "ops"], "uid": "42"}
|
|
2527
|
+
# each impersonated identity is also its own intent, so policy can block it
|
|
2528
|
+
assert [(i.action, i.resource) for i in intents] == [
|
|
2529
|
+
("delete", "node/w1"),
|
|
2530
|
+
("impersonate", "user/admin"),
|
|
2531
|
+
("impersonate", "group/system:masters"),
|
|
2532
|
+
("impersonate", "group/ops"),
|
|
2533
|
+
("impersonate", "uid/42"),
|
|
2534
|
+
]
|
|
2535
|
+
leading = from_argv("kubectl --as admin delete node w1".split())
|
|
2536
|
+
assert [(i.action, i.resource) for i in leading] == [
|
|
2537
|
+
("delete", "node/w1"), ("impersonate", "user/admin")]
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
@pytest.mark.parametrize("leading, trailing", [
|
|
2541
|
+
("kubectl -n prod rollout restart deploy/web", "kubectl rollout restart deploy/web -n prod"),
|
|
2542
|
+
("kubectl --as admin rollout restart deploy/web",
|
|
2543
|
+
"kubectl rollout restart deploy/web --as admin"),
|
|
2544
|
+
("kubectl --as=admin rollout undo deploy/web", "kubectl rollout undo deploy/web --as=admin"),
|
|
2545
|
+
("kubectl -n prod set image deploy/web app=x:2",
|
|
2546
|
+
"kubectl set image deploy/web app=x:2 -n prod"),
|
|
2547
|
+
("kubectl --as=admin set image deploy/web app=x:2",
|
|
2548
|
+
"kubectl set image deploy/web app=x:2 --as=admin"),
|
|
2549
|
+
# re-review of #13: globals between the verb and its subcommand
|
|
2550
|
+
("kubectl rollout --as admin restart deploy/web",
|
|
2551
|
+
"kubectl rollout restart deploy/web --as admin"),
|
|
2552
|
+
("kubectl rollout --as=admin restart deploy/web",
|
|
2553
|
+
"kubectl rollout restart deploy/web --as=admin"),
|
|
2554
|
+
("kubectl rollout -n prod --as-group=ops restart deploy/web",
|
|
2555
|
+
"kubectl rollout restart deploy/web -n prod --as-group=ops"),
|
|
2556
|
+
("kubectl set --as=admin image deploy/web app=x:2",
|
|
2557
|
+
"kubectl set image deploy/web app=x:2 --as=admin"),
|
|
2558
|
+
("kubectl -n prod set --as admin image deploy/web app=x:2",
|
|
2559
|
+
"kubectl set image deploy/web app=x:2 -n prod --as admin"),
|
|
2560
|
+
])
|
|
2561
|
+
def test_leading_globals_before_a_subcommand_verb(leading, trailing):
|
|
2562
|
+
"""Review of #13: for rollout/set the subcommand was taken from the
|
|
2563
|
+
merged token list, so a leading global ("-n", "--as") became the
|
|
2564
|
+
subcommand and the impersonation / namespace was lost."""
|
|
2565
|
+
from aegis_core.parser import from_argv
|
|
2566
|
+
|
|
2567
|
+
def shape(argv):
|
|
2568
|
+
return [(i.action, i.resource, i.metadata, i.params.get("impersonate"))
|
|
2569
|
+
for i in from_argv(argv.split())]
|
|
2570
|
+
|
|
2571
|
+
assert shape(leading) == shape(trailing)
|
|
2572
|
+
assert not any(a.startswith("rollout--") for a, *_ in shape(leading))
|
|
2573
|
+
|
|
2574
|
+
|
|
2575
|
+
@pytest.mark.parametrize("argv", [
|
|
2576
|
+
"kubectl rollout --bogus restart deploy/web", # unknown option before the subcommand
|
|
2577
|
+
"kubectl rollout --as", # option without its value
|
|
2578
|
+
"kubectl rollout --as admin", # no subcommand after the options
|
|
2579
|
+
"kubectl set --as admin rollout deploy/web", # not "set image"
|
|
2580
|
+
])
|
|
2581
|
+
def test_unparseable_options_before_a_subcommand_fail_closed(argv):
|
|
2582
|
+
from aegis_core.parser import from_argv
|
|
2583
|
+
|
|
2584
|
+
with pytest.raises(ValueError):
|
|
2585
|
+
from_argv(argv.split())
|
|
@@ -500,7 +500,7 @@ def _load_example_store():
|
|
|
500
500
|
def test_example_store_loads_with_zero_quarantined():
|
|
501
501
|
store = _load_example_store()
|
|
502
502
|
assert store.quarantined == []
|
|
503
|
-
assert len(store.constraints) ==
|
|
503
|
+
assert len(store.constraints) == 30
|
|
504
504
|
|
|
505
505
|
|
|
506
506
|
def test_unbounded_delete_is_blocked_end_to_end():
|
|
@@ -299,7 +299,7 @@ def test_store_health_of_example_store_is_clean():
|
|
|
299
299
|
"data/constraints.example.yaml", authority_map=load_authority_map(AUTHORITY_PATH)
|
|
300
300
|
)
|
|
301
301
|
health = store.health
|
|
302
|
-
assert health.loaded ==
|
|
302
|
+
assert health.loaded == 30
|
|
303
303
|
assert health.quarantined == []
|
|
304
304
|
assert health.principals == 3
|
|
305
305
|
assert len(health.constraints_sha256) == 64
|
|
@@ -479,7 +479,7 @@ def test_shipped_example_store_loads_signed_with_sources_and_no_warnings():
|
|
|
479
479
|
source_fetcher=FileSourceFetcher("data/sources", key=key),
|
|
480
480
|
key=key,
|
|
481
481
|
)
|
|
482
|
-
assert store.health.loaded ==
|
|
482
|
+
assert store.health.loaded == 30
|
|
483
483
|
assert store.health.quarantined == []
|
|
484
484
|
assert store.health.warnings == []
|
|
485
485
|
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.2.0 → aegis_devops-0.2.1}/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
|