aegis-devops 0.1.7__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.7/src/aegis_devops.egg-info → aegis_devops-0.2.0}/PKG-INFO +3 -3
  2. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/README.md +2 -2
  3. {aegis_devops-0.1.7 → 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.7 → aegis_devops-0.2.0}/src/aegis_core/cli.py +72 -4
  10. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/gitsource.py +145 -28
  11. {aegis_devops-0.1.7 → aegis_devops-0.2.0/src/aegis_devops.egg-info}/PKG-INFO +3 -3
  12. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/SOURCES.txt +4 -0
  13. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_gitsource.py +202 -1
  14. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_signing.py +3 -1
  15. aegis_devops-0.1.7/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
  16. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/LICENSE +0 -0
  17. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/MANIFEST.in +0 -0
  18. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/pyproject.toml +0 -0
  19. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/setup.cfg +0 -0
  20. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/__init__.py +0 -0
  21. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml +0 -0
  22. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
  23. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml +0 -0
  24. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
  25. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml +0 -0
  26. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
  27. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/example-signing.key +0 -0
  28. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
  29. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
  30. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
  31. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
  32. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-4001.json +0 -0
  33. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6001.json +0 -0
  34. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6002.json +0 -0
  35. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6003.json +0 -0
  36. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6004.json +0 -0
  37. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6005.json +0 -0
  38. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6006.json +0 -0
  39. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6007.json +0 -0
  40. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6008.json +0 -0
  41. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
  42. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
  43. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
  44. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-def456.json +0 -0
  45. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
  46. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
  47. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
  48. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
  49. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
  50. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
  51. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
  52. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
  53. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
  54. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
  55. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
  56. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
  57. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
  58. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
  59. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
  60. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
  61. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
  62. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
  63. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
  64. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
  65. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/adversarial.py +0 -0
  66. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/authority.py +0 -0
  67. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/__init__.py +0 -0
  68. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/base.py +0 -0
  69. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/external.py +0 -0
  70. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/llm.py +0 -0
  71. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/metrics.py +0 -0
  72. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/opa.py +0 -0
  73. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/config.py +0 -0
  74. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/environments.py +0 -0
  75. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/hook.py +0 -0
  76. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/integrations/opencode-plugin.js +0 -0
  77. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/intent.py +0 -0
  78. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/interceptor.py +0 -0
  79. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/ledger.py +0 -0
  80. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parser.py +0 -0
  81. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parsers/__init__.py +0 -0
  82. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parsers/pulumi.py +0 -0
  83. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parsers/sql.py +0 -0
  84. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/plan.py +0 -0
  85. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/provenance.py +0 -0
  86. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/shell.py +0 -0
  87. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/signing.py +0 -0
  88. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/store.py +0 -0
  89. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
  90. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/entry_points.txt +0 -0
  91. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/requires.txt +0 -0
  92. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/top_level.txt +0 -0
  93. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_adversarial.py +0 -0
  94. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_authority.py +0 -0
  95. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_baselines.py +0 -0
  96. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_benchmark.py +0 -0
  97. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_cli.py +0 -0
  98. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_config.py +0 -0
  99. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_corpus.py +0 -0
  100. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_default_rules.py +0 -0
  101. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_docs_consistency.py +0 -0
  102. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_environments.py +0 -0
  103. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_hook.py +0 -0
  104. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_intent.py +0 -0
  105. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_interceptor.py +0 -0
  106. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_ledger.py +0 -0
  107. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_opencode_plugin.py +0 -0
  108. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_parser.py +0 -0
  109. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_parsers_pulumi.py +0 -0
  110. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_parsers_sql.py +0 -0
  111. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_plan.py +0 -0
  112. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_plugins.py +0 -0
  113. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_provenance.py +0 -0
  114. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_shell.py +0 -0
  115. {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_store.py +0 -0
  116. {aegis_devops-0.1.7 → 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.7
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,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.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
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
@@ -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.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
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.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
@@ -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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aegis-devops
3
- Version: 0.1.7
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,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.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
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
@@ -29,6 +29,10 @@ src/aegis_core/_examples/environments.example.yaml.sig
29
29
  src/aegis_core/_examples/example-signing.key
30
30
  src/aegis_core/_examples/plan_constraints.example.yaml
31
31
  src/aegis_core/_examples/plan_constraints.example.yaml.sig
32
+ src/aegis_core/_examples/repos.example.yaml
33
+ src/aegis_core/_examples/repos.example.yaml.sig
34
+ src/aegis_core/_examples/signers.example.yaml
35
+ src/aegis_core/_examples/signers.example.yaml.sig
32
36
  src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc
33
37
  src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig
34
38
  src/aegis_core/_examples/sources/PRINCIPALS.yaml
@@ -327,8 +327,13 @@ def test_configuration_errors_fail_the_load(repo, tmp_path):
327
327
  gpg = tmp_path / "gpg.yaml"
328
328
  gpg.write_text(yaml.safe_dump({"signers": [
329
329
  {"principal": "admin", "keys": [{"type": "gpg", "fingerprint": "ABCD"}]}]}))
330
- with pytest.raises(ValueError, match="only 'type: ssh'"):
330
+ with pytest.raises(ValueError, match="40-hex"):
331
331
  load_signers(gpg, insecure=True)
332
+ other = tmp_path / "x509.yaml"
333
+ other.write_text(yaml.safe_dump({"signers": [
334
+ {"principal": "admin", "keys": [{"type": "x509", "key": "..."}]}]}))
335
+ with pytest.raises(ValueError, match="'ssh' or 'gpg'"):
336
+ load_signers(other, insecure=True)
332
337
  repos = tmp_path / "repos.yaml"
333
338
  repos.write_text(yaml.safe_dump({"repos": {"policy": {"path": str(repo.path),
334
339
  "ref": "main"}}}))
@@ -526,3 +531,199 @@ def test_git_citation_with_only_file_sources_is_unknown_repo(repo, tmp_path, cap
526
531
 
527
532
  line = json.loads(capsys.readouterr().out.splitlines()[0])
528
533
  assert {"id": "r1", "reason": "unknown-repo"} in line["decision"]["discarded"]
534
+
535
+
536
+ # --- aegis sources and aegis init ---------------------------------------------------
537
+
538
+
539
+ def test_sources_report_lists_principals_and_quarantine_reasons(repo, tmp_path, capsys):
540
+ import json
541
+
542
+ from aegis_core.cli import main
543
+
544
+ conf = _cli_setup(repo, tmp_path, "admin")
545
+ assert main(["sources", "--config-dir", str(conf), "--insecure", "--sources", ""]) == 0
546
+ rows = {r["id"]: r for r in map(json.loads, capsys.readouterr().out.splitlines())}
547
+ assert rows["r1"]["status"] == "ok"
548
+ assert (rows["r1"]["principal"], rows["r1"]["transport"]) == ("admin", "git")
549
+ assert rows["f1"]["transport"] == "unchecked"
550
+
551
+
552
+ def test_sources_report_exits_1_and_names_the_reason(tmp_path, capsys):
553
+ from aegis_core.cli import main
554
+
555
+ other = tmp_path / "other"
556
+ other.mkdir()
557
+ r = Repo(other)
558
+ r.write_rule("seed", {**RULE, "rule_text": "seed"})
559
+ r.commit("seed", "admin")
560
+ conf = _cli_setup(r, other, "mallory")
561
+ assert main(["sources", "--config-dir", str(conf), "--insecure", "--sources", "",
562
+ "--pretty"]) == 1
563
+ out = capsys.readouterr().out
564
+ assert "QUARANTINED (unknown-signer)" in out
565
+ assert "STORE: loaded=1 quarantined=1" in out
566
+
567
+
568
+ def test_init_writes_inert_git_examples(tmp_path, capsys):
569
+ from aegis_core.cli import main
570
+
571
+ target = tmp_path / ".aegis"
572
+ main(["init", str(target)])
573
+ assert (target / "repos.example.yaml").exists()
574
+ assert (target / "signers.example.yaml").exists()
575
+ assert not (target / "repos.yaml").exists() # examples are never loaded
576
+ assert "aegis sources" in capsys.readouterr().out
577
+
578
+
579
+ # --- OpenPGP signatures -------------------------------------------------------------
580
+
581
+ GPG = shutil.which("gpg")
582
+ needs_gpg = pytest.mark.skipif(GPG is None, reason="needs gpg")
583
+
584
+
585
+ class GpgKey:
586
+ """A throwaway OpenPGP key in its own GNUPGHOME. The home is a short
587
+ path under /tmp: gpg-agent's socket path must fit in 104 bytes on
588
+ macOS, which pytest's tmp_path does not."""
589
+
590
+ homes: list[Path] = []
591
+
592
+ def __init__(self, root: Path, name: str, *, expire: str = "0"):
593
+ import tempfile
594
+
595
+ self.home = Path(tempfile.mkdtemp(prefix="agpg-", dir="/tmp"))
596
+ self.home.chmod(0o700)
597
+ GpgKey.homes.append(self.home)
598
+ self.env = {**os.environ, "GNUPGHOME": str(self.home)}
599
+ gen = subprocess.run([GPG, "--batch", "--passphrase", "", "--quick-gen-key",
600
+ f"{name} <{name}@example.com>", "ed25519", "sign", expire],
601
+ env=self.env, check=False, capture_output=True, text=True)
602
+ if gen.returncode != 0: # say why, not just "exit status 2"
603
+ raise RuntimeError(f"gpg key generation failed (home {self.home}):\n{gen.stderr}")
604
+ colons = subprocess.run([GPG, "--with-colons", "--fingerprint", "--list-keys"],
605
+ env=self.env, check=True, capture_output=True,
606
+ text=True).stdout
607
+ self.fpr = next(line.split(":")[9] for line in colons.splitlines()
608
+ if line.startswith("fpr:"))
609
+ self.armored = subprocess.run([GPG, "--armor", "--export", self.fpr], env=self.env,
610
+ check=True, capture_output=True, text=True).stdout
611
+
612
+ def commit(self, repo: Repo, message: str) -> str:
613
+ env = {**repo.env, "GNUPGHOME": str(self.home)}
614
+ subprocess.run(["git", "-c", "user.name=g", "-c", "user.email=g@example.com",
615
+ "-c", "gpg.format=openpgp", "-c", f"user.signingkey={self.fpr}",
616
+ "commit", "-q", "-S", "-m", message], cwd=repo.path, env=env,
617
+ check=True, capture_output=True)
618
+ return repo.git("rev-parse", "HEAD")
619
+
620
+
621
+ @pytest.fixture(autouse=True)
622
+ def _cleanup_gpg_homes():
623
+ yield
624
+ while GpgKey.homes:
625
+ home = GpgKey.homes.pop()
626
+ if GPG:
627
+ subprocess.run(["gpgconf", "--homedir", str(home), "--kill", "all"],
628
+ capture_output=True, check=False)
629
+ shutil.rmtree(home, ignore_errors=True)
630
+
631
+
632
+ def gpg_signers(*pairs):
633
+ from aegis_core.gitsource import Signers
634
+
635
+ out = Signers()
636
+ for key, principal in pairs:
637
+ out.gpg[key.fpr.upper()] = principal
638
+ out.gpg_keys.append(key.armored)
639
+ return out
640
+
641
+
642
+ @needs_gpg
643
+ def test_gpg_signed_commit_backs_the_rule(repo, tmp_path):
644
+ alice = GpgKey(tmp_path, "alice")
645
+ path = repo.write_rule("r1")
646
+ sha = alice.commit(repo, "alice adds r1")
647
+ f = fetcher(repo, gpg_signers((alice, "admin")))
648
+ store = load(tmp_path, [constraint_for(ref(sha, path), "admin")], f)
649
+ assert list(store.constraints) == ["r1"]
650
+
651
+
652
+ @needs_gpg
653
+ def test_gpg_key_not_in_signers_is_unknown(repo, tmp_path):
654
+ alice, mallory = GpgKey(tmp_path, "alice"), GpgKey(tmp_path, "mallory")
655
+ path = repo.write_rule("r1")
656
+ sha = mallory.commit(repo, "mallory adds r1")
657
+ store = load(tmp_path, [constraint_for(ref(sha, path), "admin")],
658
+ fetcher(repo, gpg_signers((alice, "admin"))))
659
+ assert reasons(store) == {"r1": "unknown-signer"}
660
+
661
+
662
+ @needs_gpg
663
+ def test_gpg_signer_mapped_to_another_principal(repo, tmp_path):
664
+ dev = GpgKey(tmp_path, "dev")
665
+ path = repo.write_rule("r1")
666
+ sha = dev.commit(repo, "dev adds r1")
667
+ store = load(tmp_path, [constraint_for(ref(sha, path), "admin")],
668
+ fetcher(repo, gpg_signers((dev, "developer"))))
669
+ assert reasons(store) == {"r1": "principal-mismatch"}
670
+
671
+
672
+ @needs_gpg
673
+ def test_gpg_key_block_must_match_its_fingerprint(repo, tmp_path):
674
+ from aegis_core.gitsource import Signers
675
+
676
+ alice, mallory = GpgKey(tmp_path, "alice"), GpgKey(tmp_path, "mallory")
677
+ # declares alice's fingerprint but ships mallory's key
678
+ bad = Signers(gpg={alice.fpr.upper(): "admin"}, gpg_keys=[mallory.armored])
679
+ with pytest.raises(ValueError, match="do not match"):
680
+ fetcher(repo, bad)
681
+
682
+
683
+ @needs_gpg
684
+ def test_ssh_and_gpg_signers_side_by_side(repo, tmp_path):
685
+ alice = GpgKey(tmp_path, "alice")
686
+ sig = gpg_signers((alice, "admin"))
687
+ sig.ssh.update(signers(repo, bob="sre_lead"))
688
+ p1 = repo.write_rule("r1")
689
+ s1 = alice.commit(repo, "alice, gpg")
690
+ p2 = repo.write_rule("r2", {**RULE, "rule_text": "second", "constraint_class": "scaling"})
691
+ s2 = repo.commit("bob, ssh", "bob")
692
+ store = load(tmp_path, [
693
+ constraint_for(ref(s1, p1), "admin"),
694
+ constraint_for(ref(s2, p2), "sre_lead", cid="r2", rule_text="second",
695
+ constraint_class="scaling"),
696
+ ], fetcher(repo, sig))
697
+ assert sorted(store.constraints) == ["r1", "r2"]
698
+
699
+
700
+ @needs_gpg
701
+ def test_repository_config_cannot_swap_the_gpg_program(repo, tmp_path):
702
+ mallory = GpgKey(tmp_path, "mallory")
703
+ alice = GpgKey(tmp_path, "alice")
704
+ path = repo.write_rule("r1")
705
+ sha = mallory.commit(repo, "mallory")
706
+ repo.git("config", "gpg.program", "/usr/bin/true")
707
+ repo.git("config", "gpg.openpgp.program", "/usr/bin/true")
708
+ store = load(tmp_path, [constraint_for(ref(sha, path), "admin")],
709
+ fetcher(repo, gpg_signers((alice, "admin"))))
710
+ assert reasons(store) == {"r1": "unknown-signer"}
711
+
712
+
713
+ def test_sources_report_keeps_a_duplicate_id_quarantine(repo, tmp_path, capsys):
714
+ """Review of #12: a duplicate of a loaded rule's id was dropped from the
715
+ report because rows were matched by id."""
716
+ import json
717
+
718
+ from aegis_core.cli import main
719
+
720
+ conf = _cli_setup(repo, tmp_path, "admin")
721
+ doc = yaml.safe_load((conf / "constraints.yaml").read_text())
722
+ doc["constraints"].append(dict(doc["constraints"][0])) # same id "r1" again
723
+ (conf / "constraints.yaml").write_text(yaml.safe_dump(doc, sort_keys=False))
724
+ assert main(["sources", "--config-dir", str(conf), "--insecure", "--sources", ""]) == 1
725
+ rows = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
726
+ r1 = [r for r in rows if r["id"] == "r1"]
727
+ assert sorted(r["status"] for r in r1) == ["ok", "quarantined"]
728
+ assert [r["reason"] for r in r1 if r["status"] == "quarantined"] == [
729
+ "invalid: duplicate id"]
@@ -175,6 +175,8 @@ def test_shipped_example_and_corpus_files_verify_under_the_example_key():
175
175
  "data/authority.example.yaml",
176
176
  "data/environments.example.yaml",
177
177
  "data/plan_constraints.example.yaml",
178
+ "data/repos.example.yaml",
179
+ "data/signers.example.yaml",
178
180
  "data/sources/PRINCIPALS.yaml",
179
181
  "data/sources/jira-1001.json",
180
182
  "data/corpus/constraints.yaml",
@@ -188,4 +190,4 @@ def test_shipped_example_and_corpus_files_verify_under_the_example_key():
188
190
  ):
189
191
  assert verify_file(path, key), path
190
192
  assert not any(p.name.endswith(".json.sig") for p in Path("data").rglob("*.sig"))
191
- assert len(list(Path("data").rglob("*.sig"))) <= 9
193
+ assert len(list(Path("data").rglob("*.sig"))) <= 11
File without changes
File without changes
File without changes