aegis-devops 0.1.6__tar.gz → 0.2.0__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 (116) hide show
  1. {aegis_devops-0.1.6/src/aegis_devops.egg-info → aegis_devops-0.2.0}/PKG-INFO +8 -7
  2. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/README.md +7 -6
  3. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/__init__.py +1 -1
  4. aegis_devops-0.2.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  5. aegis_devops-0.2.0/src/aegis_core/_examples/repos.example.yaml +11 -0
  6. aegis_devops-0.2.0/src/aegis_core/_examples/repos.example.yaml.sig +2 -0
  7. aegis_devops-0.2.0/src/aegis_core/_examples/signers.example.yaml +13 -0
  8. aegis_devops-0.2.0/src/aegis_core/_examples/signers.example.yaml.sig +2 -0
  9. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/cli.py +132 -4
  10. aegis_devops-0.2.0/src/aegis_core/gitsource.py +631 -0
  11. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/store.py +13 -0
  12. {aegis_devops-0.1.6 → aegis_devops-0.2.0/src/aegis_devops.egg-info}/PKG-INFO +8 -7
  13. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/SOURCES.txt +6 -0
  14. aegis_devops-0.2.0/tests/test_gitsource.py +729 -0
  15. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_signing.py +3 -1
  16. aegis_devops-0.1.6/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  17. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/LICENSE +0 -0
  18. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/MANIFEST.in +0 -0
  19. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/pyproject.toml +0 -0
  20. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/setup.cfg +0 -0
  21. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/__init__.py +0 -0
  22. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml +0 -0
  23. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  24. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml +0 -0
  25. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
  26. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml +0 -0
  27. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  28. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/example-signing.key +0 -0
  29. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  30. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  31. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
  32. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
  33. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  34. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6001.json +0 -0
  35. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6002.json +0 -0
  36. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6003.json +0 -0
  37. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6004.json +0 -0
  38. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6005.json +0 -0
  39. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6006.json +0 -0
  40. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6007.json +0 -0
  41. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6008.json +0 -0
  42. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  43. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  44. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  45. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  46. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  47. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  48. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  49. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  50. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  51. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  52. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  53. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  54. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  55. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  56. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  57. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  58. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  59. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  60. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  61. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  62. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  63. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  64. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  65. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  66. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/adversarial.py +0 -0
  67. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/authority.py +0 -0
  68. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/baselines/__init__.py +0 -0
  69. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/baselines/base.py +0 -0
  70. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/baselines/external.py +0 -0
  71. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/baselines/llm.py +0 -0
  72. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/baselines/metrics.py +0 -0
  73. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/baselines/opa.py +0 -0
  74. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/config.py +0 -0
  75. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/environments.py +0 -0
  76. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/hook.py +0 -0
  77. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/integrations/opencode-plugin.js +0 -0
  78. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/intent.py +0 -0
  79. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/interceptor.py +0 -0
  80. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/ledger.py +0 -0
  81. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/parser.py +0 -0
  82. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/parsers/__init__.py +0 -0
  83. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/parsers/pulumi.py +0 -0
  84. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/parsers/sql.py +0 -0
  85. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/plan.py +0 -0
  86. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/provenance.py +0 -0
  87. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/shell.py +0 -0
  88. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_core/signing.py +0 -0
  89. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  90. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  91. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/requires.txt +0 -0
  92. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/top_level.txt +0 -0
  93. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_adversarial.py +0 -0
  94. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_authority.py +0 -0
  95. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_baselines.py +0 -0
  96. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_benchmark.py +0 -0
  97. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_cli.py +0 -0
  98. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_config.py +0 -0
  99. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_corpus.py +0 -0
  100. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_default_rules.py +0 -0
  101. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_docs_consistency.py +0 -0
  102. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_environments.py +0 -0
  103. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_hook.py +0 -0
  104. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_intent.py +0 -0
  105. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_interceptor.py +0 -0
  106. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_ledger.py +0 -0
  107. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_opencode_plugin.py +0 -0
  108. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_parser.py +0 -0
  109. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_parsers_pulumi.py +0 -0
  110. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_parsers_sql.py +0 -0
  111. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_plan.py +0 -0
  112. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_plugins.py +0 -0
  113. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_provenance.py +0 -0
  114. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_shell.py +0 -0
  115. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/tests/test_store.py +0 -0
  116. {aegis_devops-0.1.6 → aegis_devops-0.2.0}/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.6
3
+ Version: 0.2.0
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
@@ -252,16 +252,17 @@ 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.0 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
- short: sources are verified against files on disk rather than real Git/Slack/Jira connectors,
261
- signing uses a shared secret rather than per-principal public keys, and a `principal` is a
262
- signed name rather than an identity bound to a commit signature or an SSO group. Until those
263
- land, Aegis demonstrates that the *decision procedure* is sound; it does not yet prove the
264
- identities feeding it are. Resource matching is also case-sensitive on names. It is not
260
+ short: a rule may now cite a signed commit in a policy repository, whose verified signer
261
+ becomes its principal ([Git sources](docs/configuration.md#git-sources-signed-commits), SSH
262
+ or GPG signatures), but file sources, the key-to-principal list and the other policy
263
+ files still rest on a shared signing secret rather than per-principal public keys, and there
264
+ are no Slack/Jira connectors. Until those land, Aegis demonstrates that the *decision
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
265
266
  production-ready: read `docs/dev/PLAN.md §8` for the full list of open gaps before putting it in front
266
267
  of anything you care about.
267
268
 
@@ -220,16 +220,17 @@ 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.0 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
- short: sources are verified against files on disk rather than real Git/Slack/Jira connectors,
229
- signing uses a shared secret rather than per-principal public keys, and a `principal` is a
230
- signed name rather than an identity bound to a commit signature or an SSO group. Until those
231
- land, Aegis demonstrates that the *decision procedure* is sound; it does not yet prove the
232
- identities feeding it are. Resource matching is also case-sensitive on names. It is not
228
+ short: a rule may now cite a signed commit in a policy repository, whose verified signer
229
+ becomes its principal ([Git sources](docs/configuration.md#git-sources-signed-commits), SSH
230
+ or GPG signatures), but file sources, the key-to-principal list and the other policy
231
+ files still rest on a shared signing secret rather than per-principal public keys, and there
232
+ are no Slack/Jira connectors. Until those land, Aegis demonstrates that the *decision
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
233
234
  production-ready: read `docs/dev/PLAN.md §8` for the full list of open gaps before putting it in front
234
235
  of anything you care about.
235
236
 
@@ -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.6"
7
+ __version__ = "0.2.0"
@@ -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
@@ -95,6 +95,15 @@ from aegis_core.environments import (
95
95
  load_environment_map,
96
96
  resolve_current_context,
97
97
  )
98
+ from aegis_core.gitsource import (
99
+ GIT_PREFIX,
100
+ REPOS_FILE,
101
+ SIGNERS_FILE,
102
+ DispatchingSourceFetcher,
103
+ GitSourceFetcher,
104
+ load_repos,
105
+ load_signers,
106
+ )
98
107
  from aegis_core.intent import InfrastructureIntent
99
108
  from aegis_core.interceptor import AegisInterceptor, Decision
100
109
  from aegis_core.ledger import DecisionLedger, JsonlLedger, SqliteLedger, parse_window
@@ -284,6 +293,28 @@ def _add_common_options(subparser: argparse.ArgumentParser) -> None:
284
293
  "when it exists; pass '' to disable); constraints whose cited source does not back "
285
294
  "them are quarantined as forged at load time",
286
295
  )
296
+ subparser.add_argument(
297
+ "--repos",
298
+ default=None,
299
+ help="repos.yaml configuring the local clones of policy repositories that "
300
+ "constraints may cite as git:<repo>@<sha>:<path> (default: <dir of --constraints>/"
301
+ "repos.yaml when it exists); needs --signers",
302
+ )
303
+ subparser.add_argument(
304
+ "--signers",
305
+ default=None,
306
+ help="signers.yaml mapping SSH signing keys to principals; a git-sourced "
307
+ "constraint's principal is the verified signer of the commit it cites (default: "
308
+ "<dir of --constraints>/signers.yaml)",
309
+ )
310
+ subparser.add_argument(
311
+ "--max-source-age",
312
+ type=float,
313
+ default=None,
314
+ metavar="HOURS",
315
+ help="quarantine git-sourced constraints from a repository whose tracked ref has no "
316
+ "commit newer than this (default: only warn after 24h)",
317
+ )
287
318
  _add_key_options(subparser)
288
319
  subparser.add_argument(
289
320
  "--insecure",
@@ -450,6 +481,13 @@ def _build_parser() -> argparse.ArgumentParser:
450
481
  "--out", default="aegis-signing.key", help="path to write the key to"
451
482
  )
452
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
+
453
491
  hook_parser = subparsers.add_parser(
454
492
  "hook",
455
493
  help="Run as a coding agent's pre-tool hook: read its JSON payload on stdin, answer "
@@ -591,6 +629,30 @@ def _resolve_config_paths(args: argparse.Namespace) -> None:
591
629
  )
592
630
 
593
631
 
632
+ def _git_source_fetcher(args: argparse.Namespace, load: dict) -> GitSourceFetcher | None:
633
+ """A :class:`GitSourceFetcher` when a ``repos.yaml`` is given or sits
634
+ next to the constraints file; ``None`` otherwise. ``repos.yaml`` without
635
+ a ``signers.yaml`` is a data error: git sources cannot verify anyone."""
636
+ base = os.path.dirname(os.path.abspath(args.constraints))
637
+ repos = args.repos or os.path.join(base, REPOS_FILE)
638
+ if not os.path.exists(repos):
639
+ if args.repos:
640
+ raise FileNotFoundError(2, "No such file or directory", repos)
641
+ return None
642
+ signers = args.signers or os.path.join(base, SIGNERS_FILE)
643
+ if not os.path.exists(signers):
644
+ raise DataError(f"{repos} is configured but there is no {signers}: git sources need "
645
+ "a signers file to verify commit signatures")
646
+ warnings: list[str] = []
647
+ fetcher = GitSourceFetcher(
648
+ load_repos(repos, warnings=warnings, **load),
649
+ load_signers(signers, warnings=warnings, **load),
650
+ max_source_age=args.max_source_age * 3600 if args.max_source_age is not None else None,
651
+ )
652
+ fetcher.warnings[:0] = warnings
653
+ return fetcher
654
+
655
+
594
656
  def _default_sources(args: argparse.Namespace) -> str | None:
595
657
  """``--sources`` as given; ``''`` disables; ``None`` means the
596
658
  ``sources`` directory next to the constraints file, when it exists."""
@@ -824,11 +886,12 @@ def _open_ledger(
824
886
  return ledger
825
887
 
826
888
 
827
- 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``."""
828
894
  _resolve_config_paths(args)
829
- now = _parse_now(args.now)
830
- pretty = bool(args.pretty)
831
-
832
895
  if not os.path.exists(args.constraints):
833
896
  raise FileNotFoundError(2, "No such file or directory", args.constraints)
834
897
  key_warnings: list[str] = []
@@ -840,6 +903,12 @@ def _evaluate(intents: list[InfrastructureIntent], args: argparse.Namespace) ->
840
903
  )
841
904
  sources = _default_sources(args)
842
905
  fetcher = FileSourceFetcher(sources, **load) if sources else None
906
+ git_fetcher = _git_source_fetcher(args, load)
907
+ if fetcher is not None or git_fetcher is not None:
908
+ # always dispatch, so a git: citation is never handed to the file
909
+ # fetcher and is rejected as unknown-repo when git is not configured
910
+ # (with no fetcher at all, ConstraintStore.load rejects it itself)
911
+ fetcher = DispatchingSourceFetcher(fetcher, git_fetcher)
843
912
  store = _load_or_data_error(
844
913
  lambda: ConstraintStore.load(
845
914
  args.constraints, authority_map=authority_map, source_fetcher=fetcher, **load
@@ -848,6 +917,13 @@ def _evaluate(intents: list[InfrastructureIntent], args: argparse.Namespace) ->
848
917
  "constraints",
849
918
  )
850
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)
851
927
  plan_store = None
852
928
  if args.plan_constraints:
853
929
  plan_store = _load_or_data_error(
@@ -1016,9 +1092,59 @@ def _run_init(args: argparse.Namespace) -> int:
1016
1092
  f" 3. Replace the *.example.yaml files in {args.dir} with your own constraints.yaml / "
1017
1093
  "authority.yaml / ... (aegis prefers a real file over the .example one when both exist)"
1018
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
+ )
1019
1100
  return 0
1020
1101
 
1021
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
+
1022
1148
  def _run_keygen(args: argparse.Namespace) -> int:
1023
1149
  path = config_module.generate_signing_key(args.out)
1024
1150
  print(f"aegis: wrote signing key to {path}")
@@ -1035,6 +1161,8 @@ def _run(argv: list[str]) -> int:
1035
1161
  return _run_init(args)
1036
1162
  if args.command == "keygen":
1037
1163
  return _run_keygen(args)
1164
+ if args.command == "sources":
1165
+ return _run_sources(args)
1038
1166
  if args.command == "hook":
1039
1167
  return hook_module.main_hook(
1040
1168
  args.agent,