aegis-devops 0.1.7__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.1.7/src/aegis_devops.egg-info → aegis_devops-0.2.1}/PKG-INFO +5 -5
  2. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/README.md +4 -4
  3. {aegis_devops-0.1.7 → 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.1.7 → 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.1/src/aegis_core/_examples/repos.example.yaml +11 -0
  8. aegis_devops-0.2.1/src/aegis_core/_examples/repos.example.yaml.sig +2 -0
  9. aegis_devops-0.2.1/src/aegis_core/_examples/signers.example.yaml +13 -0
  10. aegis_devops-0.2.1/src/aegis_core/_examples/signers.example.yaml.sig +2 -0
  11. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +3 -2
  12. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +1 -0
  13. aegis_devops-0.2.1/src/aegis_core/_examples/sources/git-6009.json +15 -0
  14. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/cli.py +72 -4
  15. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/gitsource.py +145 -28
  16. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/interceptor.py +26 -10
  17. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parser.py +133 -20
  18. {aegis_devops-0.1.7 → aegis_devops-0.2.1/src/aegis_devops.egg-info}/PKG-INFO +5 -5
  19. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/SOURCES.txt +5 -0
  20. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_cli.py +9 -9
  21. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_default_rules.py +33 -0
  22. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_gitsource.py +202 -1
  23. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_interceptor.py +59 -0
  24. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_parser.py +98 -2
  25. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_parsers_sql.py +1 -1
  26. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_signing.py +3 -1
  27. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_store.py +2 -2
  28. aegis_devops-0.1.7/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  29. aegis_devops-0.1.7/src/aegis_core/_examples/constraints.example.yaml.sig +0 -2
  30. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/LICENSE +0 -0
  31. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/MANIFEST.in +0 -0
  32. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/pyproject.toml +0 -0
  33. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/setup.cfg +0 -0
  34. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/__init__.py +0 -0
  35. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml +0 -0
  36. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  37. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml +0 -0
  38. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  39. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/example-signing.key +0 -0
  40. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  41. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  42. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  43. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6001.json +0 -0
  44. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6002.json +0 -0
  45. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6003.json +0 -0
  46. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6004.json +0 -0
  47. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6005.json +0 -0
  48. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6006.json +0 -0
  49. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6007.json +0 -0
  50. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6008.json +0 -0
  51. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  52. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  53. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  54. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  55. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  56. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  57. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  58. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  59. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  60. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  61. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  62. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  63. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  64. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  65. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  66. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  67. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  68. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  69. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  70. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  71. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  72. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  73. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  74. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  75. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/adversarial.py +0 -0
  76. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/authority.py +0 -0
  77. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/__init__.py +0 -0
  78. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/base.py +0 -0
  79. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/external.py +0 -0
  80. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/llm.py +0 -0
  81. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/metrics.py +0 -0
  82. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/opa.py +0 -0
  83. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/config.py +0 -0
  84. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/environments.py +0 -0
  85. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/hook.py +0 -0
  86. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/integrations/opencode-plugin.js +0 -0
  87. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/intent.py +0 -0
  88. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/ledger.py +0 -0
  89. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parsers/__init__.py +0 -0
  90. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parsers/pulumi.py +0 -0
  91. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parsers/sql.py +0 -0
  92. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/plan.py +0 -0
  93. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/provenance.py +0 -0
  94. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/shell.py +0 -0
  95. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/signing.py +0 -0
  96. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/store.py +0 -0
  97. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  98. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  99. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/requires.txt +0 -0
  100. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/top_level.txt +0 -0
  101. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_adversarial.py +0 -0
  102. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_authority.py +0 -0
  103. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_baselines.py +0 -0
  104. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_benchmark.py +0 -0
  105. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_config.py +0 -0
  106. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_corpus.py +0 -0
  107. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_docs_consistency.py +0 -0
  108. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_environments.py +0 -0
  109. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_hook.py +0 -0
  110. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_intent.py +0 -0
  111. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_ledger.py +0 -0
  112. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_opencode_plugin.py +0 -0
  113. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_parsers_pulumi.py +0 -0
  114. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_plan.py +0 -0
  115. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_plugins.py +0 -0
  116. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_provenance.py +0 -0
  117. {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_shell.py +0 -0
  118. {aegis_devops-0.1.7 → 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.1.7
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,14 +252,14 @@ 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.1.3 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
 
259
259
  What is *not* done is the part the threat model leans on hardest. See `docs/dev/PLAN.md §8`, but in
260
260
  short: a rule may now cite a signed commit in a policy repository, whose verified signer
261
261
  becomes its principal ([Git sources](docs/configuration.md#git-sources-signed-commits), SSH
262
- signatures only so far), but file sources, the key-to-principal list and the other policy
262
+ or GPG signatures), but file sources, the key-to-principal list and the other policy
263
263
  files still rest on a shared signing secret rather than per-principal public keys, and there
264
264
  are no Slack/Jira connectors. Until those land, Aegis demonstrates that the *decision
265
265
  procedure* is sound; it proves the identities feeding it only for Git-sourced rules. Resource matching is also case-sensitive on names. It is not
@@ -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,14 +220,14 @@ 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.1.3 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
 
227
227
  What is *not* done is the part the threat model leans on hardest. See `docs/dev/PLAN.md §8`, but in
228
228
  short: a rule may now cite a signed commit in a policy repository, whose verified signer
229
229
  becomes its principal ([Git sources](docs/configuration.md#git-sources-signed-commits), SSH
230
- signatures only so far), but file sources, the key-to-principal list and the other policy
230
+ or GPG signatures), but file sources, the key-to-principal list and the other policy
231
231
  files still rest on a shared signing secret rather than per-principal public keys, and there
232
232
  are no Slack/Jira connectors. Until those land, Aegis demonstrates that the *decision
233
233
  procedure* is sound; it proves the identities feeding it only for Git-sourced rules. Resource matching is also case-sensitive on names. It is not
@@ -4,4 +4,4 @@ infrastructure actions of AI agents.
4
4
  See the README for what this does and, importantly, what it does not yet do.
5
5
  """
6
6
 
7
- __version__ = "0.1.7"
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
@@ -0,0 +1,11 @@
1
+ # Git sources (docs/configuration.md#git-sources-signed-commits).
2
+ # Copy to repos.yaml to enable: a constraint may then cite
3
+ # source_ref: "git:<repo-id>@<full commit sha>:<path>"
4
+ # and its principal becomes the verified signer of that commit.
5
+ # Aegis never fetches: keep each clone up to date with your own `git fetch`,
6
+ # and use full clones (shallow clones and grafts are refused).
7
+ repos:
8
+ platform-policy:
9
+ path: /srv/aegis/platform-policy
10
+ ref: refs/remotes/origin/main # the ref that defines "current"
11
+ rule_glob: rules/*.yaml # one rule per file
@@ -0,0 +1,2 @@
1
+ aegis-sig-v1
2
+ d3fdb081efd29c87dad98fd7ba59eb5522c31d6f86e8a4c672d902100c2339da
@@ -0,0 +1,13 @@
1
+ # Which signing key speaks for which principal (the names authority.yaml uses).
2
+ # Copy to signers.yaml alongside repos.yaml, then sign both with `aegis sign`.
3
+ # Never list GitHub's web-flow key (it signs merges made in the GitHub UI):
4
+ # it would make anyone who can press "Merge" any principal.
5
+ signers:
6
+ - principal: admin
7
+ keys:
8
+ - type: ssh
9
+ key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyReplaceWithARealPublicKey0000 alice@example.com"
10
+ - principal: sre_lead
11
+ keys:
12
+ - type: ssh
13
+ key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAnotherExampleKeyReplaceWithARealOne000 bob@example.com"
@@ -0,0 +1,2 @@
1
+ aegis-sig-v1
2
+ e7b0ef862e5c7837f13ffcfe9d53bc710b73aecac64d3d801c0e9507155435fa
@@ -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
+ }
@@ -96,6 +96,7 @@ from aegis_core.environments import (
96
96
  resolve_current_context,
97
97
  )
98
98
  from aegis_core.gitsource import (
99
+ GIT_PREFIX,
99
100
  REPOS_FILE,
100
101
  SIGNERS_FILE,
101
102
  DispatchingSourceFetcher,
@@ -480,6 +481,13 @@ def _build_parser() -> argparse.ArgumentParser:
480
481
  "--out", default="aegis-signing.key", help="path to write the key to"
481
482
  )
482
483
 
484
+ sources_parser = subparsers.add_parser(
485
+ "sources",
486
+ help="Report every constraint's source verification: which transport (git, file), "
487
+ "the verified principal, or why it is quarantined; exit 1 if any is",
488
+ )
489
+ _add_common_options(sources_parser)
490
+
483
491
  hook_parser = subparsers.add_parser(
484
492
  "hook",
485
493
  help="Run as a coding agent's pre-tool hook: read its JSON payload on stdin, answer "
@@ -878,11 +886,12 @@ def _open_ledger(
878
886
  return ledger
879
887
 
880
888
 
881
- def _evaluate(intents: list[InfrastructureIntent], args: argparse.Namespace) -> int:
889
+ def _load_policy(args: argparse.Namespace):
890
+ """Resolves the config, verifies and loads the authority map and the
891
+ constraint store (with file and git source verification). Returns
892
+ ``(store, authority_map, load, fetcher)``; shared by ``check`` and
893
+ ``sources``."""
882
894
  _resolve_config_paths(args)
883
- now = _parse_now(args.now)
884
- pretty = bool(args.pretty)
885
-
886
895
  if not os.path.exists(args.constraints):
887
896
  raise FileNotFoundError(2, "No such file or directory", args.constraints)
888
897
  key_warnings: list[str] = []
@@ -908,6 +917,13 @@ def _evaluate(intents: list[InfrastructureIntent], args: argparse.Namespace) ->
908
917
  "constraints",
909
918
  )
910
919
  store.warnings[:0] = key_warnings
920
+ return store, authority_map, load, fetcher
921
+
922
+
923
+ def _evaluate(intents: list[InfrastructureIntent], args: argparse.Namespace) -> int:
924
+ now = _parse_now(args.now)
925
+ pretty = bool(args.pretty)
926
+ store, authority_map, load, _fetcher = _load_policy(args)
911
927
  plan_store = None
912
928
  if args.plan_constraints:
913
929
  plan_store = _load_or_data_error(
@@ -1076,9 +1092,59 @@ def _run_init(args: argparse.Namespace) -> int:
1076
1092
  f" 3. Replace the *.example.yaml files in {args.dir} with your own constraints.yaml / "
1077
1093
  "authority.yaml / ... (aegis prefers a real file over the .example one when both exist)"
1078
1094
  )
1095
+ print(
1096
+ " 4. Optional, rules backed by signed Git commits: copy repos.example.yaml and "
1097
+ "signers.example.yaml to repos.yaml / signers.yaml (see docs/configuration.md), then "
1098
+ "check them with: aegis sources --pretty"
1099
+ )
1079
1100
  return 0
1080
1101
 
1081
1102
 
1103
+ def _transport(source_ref: str, fetcher) -> str:
1104
+ if source_ref.startswith(GIT_PREFIX):
1105
+ return "git"
1106
+ has_file = fetcher is not None and getattr(fetcher, "file_fetcher", fetcher) is not None
1107
+ return "file" if has_file else "unchecked"
1108
+
1109
+
1110
+ def _run_sources(args: argparse.Namespace) -> int:
1111
+ """``aegis sources``: one line per constraint -- loaded (with the
1112
+ principal its source verified) or quarantined (with the reason)."""
1113
+ store, _authority, _load, fetcher = _load_policy(args)
1114
+ rows = []
1115
+ for c in store.constraints.values():
1116
+ rows.append({"id": c.id, "status": "ok", "reason": None, "principal": c.principal,
1117
+ "transport": _transport(c.source_ref, fetcher),
1118
+ "source_ref": c.source_ref})
1119
+ # Walk the quarantine log entry by entry, in order. Entries that parsed
1120
+ # into a Constraint were appended to quarantined_constraints in the same
1121
+ # order; "invalid: ..." entries (bad shape, duplicate id) never parsed.
1122
+ # Matching by id instead would drop a duplicate-id entry whose id is
1123
+ # also a loaded rule, and confuse two quarantined entries sharing an id.
1124
+ parsed = iter(store.quarantined_constraints)
1125
+ for q in store.health.quarantined:
1126
+ c = None if q["reason"].startswith("invalid: ") else next(parsed, None)
1127
+ rows.append({"id": q["id"], "status": "quarantined", "reason": q["reason"],
1128
+ "principal": None,
1129
+ "transport": _transport(c.source_ref, fetcher) if c else None,
1130
+ "source_ref": c.source_ref if c else None})
1131
+ rows.sort(key=lambda r: (r["status"] != "quarantined", r["id"])) # stable
1132
+ if args.pretty:
1133
+ for r in rows:
1134
+ label = "OK" if r["status"] == "ok" else f"QUARANTINED ({r['reason']})"
1135
+ print(f"{label:<44} {r['id']:<40} {r['principal'] or '-':<12} "
1136
+ f"{r['transport'] or '-':<9} {r['source_ref'] or ''}")
1137
+ h = store.health
1138
+ print(f"STORE: loaded={h.loaded} quarantined={len(h.quarantined)} "
1139
+ f"principals={h.principals}")
1140
+ for w in h.warnings:
1141
+ print(f" warning: {w}")
1142
+ else:
1143
+ for r in rows:
1144
+ print(json.dumps(r))
1145
+ return 1 if store.health.quarantined else 0
1146
+
1147
+
1082
1148
  def _run_keygen(args: argparse.Namespace) -> int:
1083
1149
  path = config_module.generate_signing_key(args.out)
1084
1150
  print(f"aegis: wrote signing key to {path}")
@@ -1095,6 +1161,8 @@ def _run(argv: list[str]) -> int:
1095
1161
  return _run_init(args)
1096
1162
  if args.command == "keygen":
1097
1163
  return _run_keygen(args)
1164
+ if args.command == "sources":
1165
+ return _run_sources(args)
1098
1166
  if args.command == "hook":
1099
1167
  return hook_module.main_hook(
1100
1168
  args.agent,
@@ -40,7 +40,7 @@ import shutil
40
40
  import subprocess
41
41
  import tempfile
42
42
  import time
43
- from dataclasses import dataclass
43
+ from dataclasses import dataclass, field
44
44
  from pathlib import Path
45
45
  from typing import Any
46
46
 
@@ -195,16 +195,36 @@ def load_repos(path: str | Path, *, key: bytes | None = None, insecure: bool = F
195
195
  return out
196
196
 
197
197
 
198
+ _GPG_FPR_RE = re.compile(r"[0-9A-F]{40}")
199
+
200
+
201
+ @dataclass
202
+ class Signers:
203
+ """What ``signers.yaml`` trusts. ``ssh``: ``"<type> <base64>" ->
204
+ principal``; ``gpg``: primary-key fingerprint (40 hex, upper case) ->
205
+ principal, with ``gpg_keys`` the armored public keys to import."""
206
+
207
+ ssh: dict[str, str] = field(default_factory=dict)
208
+ gpg: dict[str, str] = field(default_factory=dict)
209
+ gpg_keys: list[str] = field(default_factory=list)
210
+
211
+ @property
212
+ def principals(self) -> set[str]:
213
+ return set(self.ssh.values()) | set(self.gpg.values())
214
+
215
+
198
216
  def load_signers(path: str | Path, *, key: bytes | None = None, insecure: bool = False,
199
- warnings: list[str] | None = None) -> dict[str, str]:
200
- """``signers.yaml`` -> ``{"<ssh key type> <base64>": principal}``. A key
201
- listed twice (under any principals) is a load error, not a guess."""
217
+ warnings: list[str] | None = None) -> Signers:
218
+ """``signers.yaml`` -> :class:`Signers`. A key listed twice (under any
219
+ principals) is a load error, not a guess. GPG keys carry both the
220
+ armored public key and its fingerprint; the fingerprint is checked
221
+ against the key when it is imported."""
202
222
  path = Path(path)
203
223
  raw = _load_yaml_mapping(path, key, insecure, warnings if warnings is not None else [])
204
224
  entries = raw.get("signers")
205
225
  if not isinstance(entries, list) or not entries:
206
226
  raise ValueError(f"{path}: 'signers' must be a non-empty list")
207
- out: dict[str, str] = {}
227
+ out = Signers()
208
228
  for entry in entries:
209
229
  if not isinstance(entry, dict):
210
230
  raise ValueError(f"{path}: every signer must be a mapping")
@@ -212,18 +232,34 @@ def load_signers(path: str | Path, *, key: bytes | None = None, insecure: bool =
212
232
  if not isinstance(principal, str) or not _PRINCIPAL_RE.fullmatch(principal):
213
233
  raise ValueError(f"{path}: invalid principal {principal!r}")
214
234
  for k in entry.get("keys") or []:
215
- if not isinstance(k, dict) or k.get("type") != "ssh":
216
- raise ValueError(
217
- f"{path}: {principal}: only 'type: ssh' keys are supported so far"
218
- )
219
- m = _SSH_KEY_RE.match(str(k.get("key", "")).strip())
220
- if not m:
221
- raise ValueError(f"{path}: {principal}: not an SSH public key: {k.get('key')!r}")
222
- canonical = m.group(0)
223
- if canonical in out:
224
- raise ValueError(f"{path}: key listed twice ({out[canonical]} and {principal})")
225
- out[canonical] = principal
226
- if not out:
235
+ kind = k.get("type") if isinstance(k, dict) else None
236
+ if kind == "ssh":
237
+ m = _SSH_KEY_RE.match(str(k.get("key", "")).strip())
238
+ if not m:
239
+ raise ValueError(
240
+ f"{path}: {principal}: not an SSH public key: {k.get('key')!r}")
241
+ canonical = m.group(0)
242
+ if canonical in out.ssh:
243
+ raise ValueError(
244
+ f"{path}: key listed twice ({out.ssh[canonical]} and {principal})")
245
+ out.ssh[canonical] = principal
246
+ elif kind == "gpg":
247
+ fpr = re.sub(r"\s+", "", str(k.get("fingerprint", ""))).upper()
248
+ armored = str(k.get("key", ""))
249
+ if not _GPG_FPR_RE.fullmatch(fpr):
250
+ raise ValueError(f"{path}: {principal}: a gpg key needs its full "
251
+ "40-hex primary fingerprint")
252
+ if "BEGIN PGP PUBLIC KEY BLOCK" not in armored:
253
+ raise ValueError(f"{path}: {principal}: a gpg key needs its armored "
254
+ "public key in 'key'")
255
+ if fpr in out.gpg:
256
+ raise ValueError(
257
+ f"{path}: key listed twice ({out.gpg[fpr]} and {principal})")
258
+ out.gpg[fpr] = principal
259
+ out.gpg_keys.append(armored)
260
+ else:
261
+ raise ValueError(f"{path}: {principal}: key type must be 'ssh' or 'gpg'")
262
+ if not out.ssh and not out.gpg:
227
263
  raise ValueError(f"{path}: no keys")
228
264
  return out
229
265
 
@@ -244,11 +280,15 @@ class _Git:
244
280
  from the calling process could otherwise redirect it) and overrides
245
281
  for every setting that decides how signatures are verified."""
246
282
 
247
- def __init__(self, repo: Path, allowed_signers: Path, empty_file: Path, home: Path):
283
+ def __init__(self, repo: Path, allowed_signers: Path, empty_file: Path, home: Path,
284
+ gpg: str | None = None, gnupghome: Path | None = None):
248
285
  self.repo = repo
249
286
  self.git = _find("git")
250
287
  ssh_keygen = _find("ssh-keygen")
251
288
  false = shutil.which("false") or "/usr/bin/false"
289
+ # OpenPGP signatures are only checkable when signers.yaml lists gpg
290
+ # keys; the keyring is a private one holding exactly those keys
291
+ gpg_program = gpg or false
252
292
  self.env = {
253
293
  "PATH": os.pathsep.join(sorted({str(Path(self.git).parent),
254
294
  str(Path(ssh_keygen).parent), "/usr/bin", "/bin"})),
@@ -262,6 +302,8 @@ class _Git:
262
302
  # paths after "--" are literal, never ":(glob)"-style pathspec magic
263
303
  "GIT_LITERAL_PATHSPECS": "1",
264
304
  }
305
+ if gnupghome is not None:
306
+ self.env["GNUPGHOME"] = str(gnupghome)
265
307
  self.overrides = [
266
308
  f"safe.directory={repo}",
267
309
  "core.hooksPath=" + os.devnull,
@@ -273,8 +315,8 @@ class _Git:
273
315
  f"gpg.ssh.program={ssh_keygen}",
274
316
  f"gpg.ssh.allowedSignersFile={allowed_signers}",
275
317
  f"gpg.ssh.revocationFile={empty_file}",
276
- f"gpg.program={false}",
277
- f"gpg.openpgp.program={false}",
318
+ f"gpg.program={gpg_program}",
319
+ f"gpg.openpgp.program={gpg_program}",
278
320
  f"gpg.x509.program={false}",
279
321
  "log.showSignature=false",
280
322
  ]
@@ -295,6 +337,43 @@ class _Git:
295
337
  # --- the fetcher ------------------------------------------------------------------
296
338
 
297
339
 
340
+ def _import_gpg_keys(gpg: str, home: Path, signers: Signers) -> None:
341
+ """Imports exactly the listed public keys into a private keyring and
342
+ marks them trusted (so git reports a good signature as ``G``). A key
343
+ block that holds any key other than the listed fingerprints is a load
344
+ error: the keyring must contain nothing else."""
345
+ env = {"GNUPGHOME": str(home), "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
346
+ "LC_ALL": "C"}
347
+ base = [gpg, "--batch", "--no-tty", "--no-autostart", "--homedir", str(home)]
348
+ for armored in signers.gpg_keys:
349
+ subprocess.run([*base, "--import"], input=armored, text=True, env=env,
350
+ capture_output=True, check=True, timeout=60)
351
+ listing = subprocess.run([*base, "--with-colons", "--fingerprint", "--list-keys"],
352
+ env=env, capture_output=True, text=True, check=True,
353
+ timeout=60).stdout
354
+ primaries, expect_fpr = set(), False
355
+ for line in listing.splitlines():
356
+ rec = line.split(":")
357
+ if rec[0] == "pub":
358
+ expect_fpr = True
359
+ elif rec[0] == "fpr" and expect_fpr:
360
+ primaries.add(rec[9].upper())
361
+ expect_fpr = False
362
+ elif rec[0] == "sub":
363
+ expect_fpr = False
364
+ if primaries != set(signers.gpg):
365
+ missing = set(signers.gpg) - primaries
366
+ extra = primaries - set(signers.gpg)
367
+ raise ValueError(
368
+ "signers.yaml: gpg keys do not match their fingerprints"
369
+ + (f"; missing {sorted(missing)}" if missing else "")
370
+ + (f"; unexpected {sorted(extra)}" if extra else "")
371
+ )
372
+ trust = "".join(f"{fpr}:6:\n" for fpr in signers.gpg)
373
+ subprocess.run([*base, "--import-ownertrust"], input=trust, text=True, env=env,
374
+ capture_output=True, check=True, timeout=60)
375
+
376
+
298
377
  class GitSourceFetcher:
299
378
  """``SourceFetcher`` for ``git:`` references (see the module docstring).
300
379
 
@@ -306,12 +385,14 @@ class GitSourceFetcher:
306
385
  def __init__(
307
386
  self,
308
387
  repos: dict[str, RepoConfig],
309
- signers: dict[str, str],
388
+ signers: Signers | dict[str, str],
310
389
  *,
311
390
  max_source_age: float | None = None,
312
391
  now: float | None = None,
313
392
  ):
314
393
  self.repos = repos
394
+ if isinstance(signers, dict): # SSH keys only
395
+ signers = Signers(ssh=dict(signers))
315
396
  self.signers = signers
316
397
  self.max_source_age = max_source_age
317
398
  self.warnings: list[str] = []
@@ -320,13 +401,20 @@ class GitSourceFetcher:
320
401
  allowed = tmp / "allowed_signers"
321
402
  # principals are validated to [A-Za-z0-9_.@-], so no quoting needed
322
403
  allowed.write_text("".join(
323
- f'{principal} namespaces="git" {k}\n' for k, principal in signers.items()
404
+ f'{principal} namespaces="git" {k}\n' for k, principal in signers.ssh.items()
324
405
  ))
325
406
  empty = tmp / "revoked"
326
407
  empty.write_text("")
327
408
  (tmp / "home").mkdir()
409
+ gpg, gnupghome = None, None
410
+ if signers.gpg:
411
+ gpg = _find("gpg")
412
+ gnupghome = tmp / "gnupg"
413
+ gnupghome.mkdir(mode=0o700)
414
+ _import_gpg_keys(gpg, gnupghome, signers)
328
415
  self._git = {
329
- rid: _Git(cfg.path, allowed, empty, tmp / "home") for rid, cfg in repos.items()
416
+ rid: _Git(cfg.path, allowed, empty, tmp / "home", gpg, gnupghome)
417
+ for rid, cfg in repos.items()
330
418
  }
331
419
  self._stale: set[str] = set()
332
420
  self._principals: dict[str, str] = {}
@@ -385,6 +473,21 @@ class GitSourceFetcher:
385
473
  raise SourceRejected("unknown-commit", f"{sha}: unreadable commit header")
386
474
  return tree, parents
387
475
 
476
+ @staticmethod
477
+ def _signature_type(git: _Git, sha: str) -> str | None:
478
+ """``"ssh"``, ``"gpg"``, ``"other"`` or ``None`` (unsigned), from the
479
+ ``gpgsig`` header of the raw commit object."""
480
+ for line in git.run("cat-file", "commit", sha).split("\n"):
481
+ if not line:
482
+ return None # end of headers, no signature
483
+ if line.startswith("gpgsig ") or line.startswith("gpgsig-sha256 "):
484
+ if "BEGIN SSH SIGNATURE" in line:
485
+ return "ssh"
486
+ if "BEGIN PGP SIGNATURE" in line:
487
+ return "gpg"
488
+ return "other"
489
+ return None
490
+
388
491
  @staticmethod
389
492
  def _is_ancestor(git: _Git, sha: str, ref: str) -> bool:
390
493
  try:
@@ -421,13 +524,27 @@ class GitSourceFetcher:
421
524
  if kind != "commit":
422
525
  raise SourceRejected("unknown-commit", ref.sha)
423
526
 
424
- # 1. the signature, verified against signers.yaml only
425
- status, _, signer = git.run("log", "-1", "--format=%G?%x00%GS", ref.sha).strip().partition(
426
- "\x00")
427
- if status == "N":
527
+ # 1. the signature, verified against signers.yaml only. Its type is
528
+ # read from the commit object; SSH signers are named by the
529
+ # allowed-signers file we wrote, OpenPGP ones by the primary-key
530
+ # fingerprint of a key in our private keyring.
531
+ sig_type = self._signature_type(git, ref.sha)
532
+ fields = git.run("log", "-1", "--format=%G?%x00%GS%x00%GP%x00%GF", ref.sha).strip()
533
+ status, signer, primary, subkey = (fields.split("\x00") + ["", "", ""])[:4]
534
+ if status == "N" or sig_type is None:
428
535
  raise SourceRejected("unsigned-source", ref.sha)
429
- if status != "G" or signer not in set(self.signers.values()):
536
+ if status != "G":
430
537
  raise SourceRejected("unknown-signer", f"{ref.sha} status {status}")
538
+ if sig_type == "ssh":
539
+ principal = signer if signer in set(self.signers.ssh.values()) else None
540
+ elif sig_type == "gpg":
541
+ principal = self.signers.gpg.get(primary.upper()) or self.signers.gpg.get(
542
+ subkey.upper())
543
+ else:
544
+ principal = None
545
+ if principal is None:
546
+ raise SourceRejected("unknown-signer", f"{ref.sha} ({sig_type})")
547
+ signer = principal
431
548
 
432
549
  # 2. the commit added or changed the rule's file (first-parent diff).
433
550
  # Tree and parents are read from the signed commit object itself,
@@ -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":