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.
Files changed (118) hide show
  1. {aegis_devops-0.2.0/src/aegis_devops.egg-info → aegis_devops-0.2.1}/PKG-INFO +4 -4
  2. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/README.md +3 -3
  3. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/__init__.py +1 -1
  4. aegis_devops-0.2.1/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  5. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/constraints.example.yaml +15 -0
  6. aegis_devops-0.2.1/src/aegis_core/_examples/constraints.example.yaml.sig +2 -0
  7. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +3 -2
  8. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +1 -0
  9. aegis_devops-0.2.1/src/aegis_core/_examples/sources/git-6009.json +15 -0
  10. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/interceptor.py +26 -10
  11. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parser.py +133 -20
  12. {aegis_devops-0.2.0 → aegis_devops-0.2.1/src/aegis_devops.egg-info}/PKG-INFO +4 -4
  13. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/SOURCES.txt +1 -0
  14. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_cli.py +9 -9
  15. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_default_rules.py +33 -0
  16. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_interceptor.py +59 -0
  17. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_parser.py +98 -2
  18. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_parsers_sql.py +1 -1
  19. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_store.py +2 -2
  20. aegis_devops-0.2.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  21. aegis_devops-0.2.0/src/aegis_core/_examples/constraints.example.yaml.sig +0 -2
  22. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/LICENSE +0 -0
  23. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/MANIFEST.in +0 -0
  24. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/pyproject.toml +0 -0
  25. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/setup.cfg +0 -0
  26. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/__init__.py +0 -0
  27. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml +0 -0
  28. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  29. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml +0 -0
  30. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  31. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/example-signing.key +0 -0
  32. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  33. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  34. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/repos.example.yaml +0 -0
  35. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/repos.example.yaml.sig +0 -0
  36. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/signers.example.yaml +0 -0
  37. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/signers.example.yaml.sig +0 -0
  38. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  39. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6001.json +0 -0
  40. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6002.json +0 -0
  41. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6003.json +0 -0
  42. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6004.json +0 -0
  43. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6005.json +0 -0
  44. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6006.json +0 -0
  45. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6007.json +0 -0
  46. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6008.json +0 -0
  47. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  48. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  49. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  50. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  51. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  52. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  53. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  54. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  55. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  56. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  57. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  58. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  59. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  60. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  61. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  62. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  63. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  64. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  65. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  66. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  67. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  68. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  69. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  70. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  71. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/adversarial.py +0 -0
  72. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/authority.py +0 -0
  73. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/__init__.py +0 -0
  74. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/base.py +0 -0
  75. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/external.py +0 -0
  76. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/llm.py +0 -0
  77. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/metrics.py +0 -0
  78. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/baselines/opa.py +0 -0
  79. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/cli.py +0 -0
  80. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/config.py +0 -0
  81. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/environments.py +0 -0
  82. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/gitsource.py +0 -0
  83. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/hook.py +0 -0
  84. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/integrations/opencode-plugin.js +0 -0
  85. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/intent.py +0 -0
  86. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/ledger.py +0 -0
  87. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parsers/__init__.py +0 -0
  88. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parsers/pulumi.py +0 -0
  89. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/parsers/sql.py +0 -0
  90. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/plan.py +0 -0
  91. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/provenance.py +0 -0
  92. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/shell.py +0 -0
  93. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/signing.py +0 -0
  94. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_core/store.py +0 -0
  95. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  96. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  97. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/requires.txt +0 -0
  98. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/top_level.txt +0 -0
  99. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_adversarial.py +0 -0
  100. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_authority.py +0 -0
  101. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_baselines.py +0 -0
  102. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_benchmark.py +0 -0
  103. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_config.py +0 -0
  104. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_corpus.py +0 -0
  105. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_docs_consistency.py +0 -0
  106. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_environments.py +0 -0
  107. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_gitsource.py +0 -0
  108. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_hook.py +0 -0
  109. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_intent.py +0 -0
  110. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_ledger.py +0 -0
  111. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_opencode_plugin.py +0 -0
  112. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_parsers_pulumi.py +0 -0
  113. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_plan.py +0 -0
  114. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_plugins.py +0 -0
  115. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_provenance.py +0 -0
  116. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_shell.py +0 -0
  117. {aegis_devops-0.2.0 → aegis_devops-0.2.1}/tests/test_signing.py +0 -0
  118. {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.0
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
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22950337.svg)](https://doi.org/10.5281/zenodo.22950337)
36
- [![PyPI](https://img.shields.io/pypi/v/aegis-devops)](https://pypi.org/project/aegis-devops/)
36
+ [![PyPI](https://img.shields.io/pypi/v/aegis-devops?label=pypi)](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=29 quarantined=0 principals=3
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.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
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
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22950337.svg)](https://doi.org/10.5281/zenodo.22950337)
4
- [![PyPI](https://img.shields.io/pypi/v/aegis-devops)](https://pypi.org/project/aegis-devops/)
4
+ [![PyPI](https://img.shields.io/pypi/v/aegis-devops?label=pypi)](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=29 quarantined=0 principals=3
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.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
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
 
@@ -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.2.0"
7
+ __version__ = "0.2.1"
@@ -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
@@ -0,0 +1,2 @@
1
+ aegis-sig-v1
2
+ 1b135ef7b7ff50e078b96d0944ce3f771fb541c208a55d41f93f5f0bb65c01ab
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "files": {
3
- "PRINCIPALS.yaml": "e2e7025a2092240acede6a88c7f1a4f22d3cdbf5821772fba9e0f91fbfcbcc8f",
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": "034812f78b7f502ebcbb9183bfcf88023324ffcd83e82e5d698aee252cd3b824"
40
+ "sig": "5c28ed037b47840ab065c77c790d7a5807ac318a05ffa43393a3e6395d5f71e1"
40
41
  }
@@ -37,3 +37,4 @@ principals:
37
37
  git-6006: admin
38
38
  git-6007: admin
39
39
  git-6008: admin
40
+ git-6009: admin
@@ -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
- for c in env_unresolved:
171
- fail_closed = True
172
- notes.append(f"env-unresolved: {c.id}")
173
- for c in time_window_unresolved:
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"time-window-unresolved: {c.id}")
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 = None
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 = {"force", "cascade", "all", "wait", "now", "ignore-not-found"}
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 pod
237
- # name, a path, ...), not a kind/name pair to be normalised.
238
- _LITERAL_RESOURCE_VERBS = {"exec", "logs", "port-forward", "cp"}
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 + rest[1:]
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.0
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
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22950337.svg)](https://doi.org/10.5281/zenodo.22950337)
36
- [![PyPI](https://img.shields.io/pypi/v/aegis-devops)](https://pypi.org/project/aegis-devops/)
36
+ [![PyPI](https://img.shields.io/pypi/v/aegis-devops?label=pypi)](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=29 quarantined=0 principals=3
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.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
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) == 29
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"] == 28
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"] == 28
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"] == 29
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=29 quarantined=0 principals=3"
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"] == 28
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"] == 28
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=28 quarantined=1 principals=3" in out
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=28 quarantined=1 principals=3" in out
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) == 29
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 == 29
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 == 29
482
+ assert store.health.loaded == 30
483
483
  assert store.health.quarantined == []
484
484
  assert store.health.warnings == []
485
485
 
@@ -1,2 +0,0 @@
1
- aegis-sig-v1
2
- 245908109e5e1ecf20ba80c41aa725c57dfbd5b2f185da1bc6708b3cbd3ce42d
File without changes
File without changes
File without changes