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.
- {aegis_devops-0.1.7/src/aegis_devops.egg-info → aegis_devops-0.2.0}/PKG-INFO +3 -3
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/README.md +2 -2
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.2.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.2.0/src/aegis_core/_examples/repos.example.yaml +11 -0
- aegis_devops-0.2.0/src/aegis_core/_examples/repos.example.yaml.sig +2 -0
- aegis_devops-0.2.0/src/aegis_core/_examples/signers.example.yaml +13 -0
- aegis_devops-0.2.0/src/aegis_core/_examples/signers.example.yaml.sig +2 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/cli.py +72 -4
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/gitsource.py +145 -28
- {aegis_devops-0.1.7 → aegis_devops-0.2.0/src/aegis_devops.egg-info}/PKG-INFO +3 -3
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/SOURCES.txt +4 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_gitsource.py +202 -1
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_signing.py +3 -1
- aegis_devops-0.1.7/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/LICENSE +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/MANIFEST.in +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/pyproject.toml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/setup.cfg +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6005.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6006.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6007.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-6008.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/external.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/llm.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/hook.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/integrations/opencode-plugin.js +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/interceptor.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parser.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/store.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_authority.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_baselines.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_cli.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_config.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_corpus.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_default_rules.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_docs_consistency.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_environments.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_hook.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_intent.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_interceptor.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_ledger.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_opencode_plugin.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_parser.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_parsers_sql.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_plan.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_plugins.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_provenance.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_shell.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.0}/tests/test_store.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
@@ -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,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"
|
|
@@ -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
|
|
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) ->
|
|
200
|
-
"""``signers.yaml`` ->
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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={
|
|
277
|
-
f"gpg.openpgp.program={
|
|
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"
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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="
|
|
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"))) <=
|
|
193
|
+
assert len(list(Path("data").rglob("*.sig"))) <= 11
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/authority.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/constraints.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/environments.example.yaml.sig
RENAMED
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml
RENAMED
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/plan_constraints.example.yaml.sig
RENAMED
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aegis_devops-0.1.7 → aegis_devops-0.2.0}/src/aegis_core/_examples/sources/slack-perma-1.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|