aegis-devops 0.1.7__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aegis_devops-0.1.7/src/aegis_devops.egg-info → aegis_devops-0.2.1}/PKG-INFO +5 -5
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/README.md +4 -4
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.2.1/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/constraints.example.yaml +15 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/constraints.example.yaml.sig +2 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/repos.example.yaml +11 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/repos.example.yaml.sig +2 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/signers.example.yaml +13 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/signers.example.yaml.sig +2 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +3 -2
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +1 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/sources/git-6009.json +15 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/cli.py +72 -4
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/gitsource.py +145 -28
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/interceptor.py +26 -10
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parser.py +133 -20
- {aegis_devops-0.1.7 → aegis_devops-0.2.1/src/aegis_devops.egg-info}/PKG-INFO +5 -5
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/SOURCES.txt +5 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_cli.py +9 -9
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_default_rules.py +33 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_gitsource.py +202 -1
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_interceptor.py +59 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_parser.py +98 -2
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_parsers_sql.py +1 -1
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_signing.py +3 -1
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_store.py +2 -2
- aegis_devops-0.1.7/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.1.7/src/aegis_core/_examples/constraints.example.yaml.sig +0 -2
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/LICENSE +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/MANIFEST.in +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/pyproject.toml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/setup.cfg +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6005.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6006.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6007.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-6008.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/external.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/llm.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/hook.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/integrations/opencode-plugin.js +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/store.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_authority.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_baselines.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_config.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_corpus.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_docs_consistency.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_environments.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_hook.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_intent.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_ledger.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_opencode_plugin.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_plan.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_plugins.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_provenance.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_shell.py +0 -0
- {aegis_devops-0.1.7 → aegis_devops-0.2.1}/tests/test_store_match.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aegis-devops
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Provenance-backed, authority-aware policy verifier for agentic DevOps actions.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/moneytool/aegis-devops
|
|
@@ -33,7 +33,7 @@ Dynamic: license-file
|
|
|
33
33
|
# Aegis-DevOps
|
|
34
34
|
|
|
35
35
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
36
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
37
37
|
|
|
38
38
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
39
39
|
ticket told them to.**
|
|
@@ -104,7 +104,7 @@ BLOCK: kubernetes scale deployment/api-server
|
|
|
104
104
|
citations: no-scale-prod-peak
|
|
105
105
|
covered: True latency_ms: 0.20
|
|
106
106
|
PLAN BLOCK: 1 intent(s)
|
|
107
|
-
STORE: loaded=
|
|
107
|
+
STORE: loaded=30 quarantined=0 principals=3
|
|
108
108
|
warning: using example signing key
|
|
109
109
|
```
|
|
110
110
|
|
|
@@ -252,14 +252,14 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
252
252
|
|
|
253
253
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
254
254
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
255
|
-
v0.1
|
|
255
|
+
v0.2.1 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
256
256
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
257
257
|
through the Codex CLI, and a local `mistral:latest`.
|
|
258
258
|
|
|
259
259
|
What is *not* done is the part the threat model leans on hardest. See `docs/dev/PLAN.md §8`, but in
|
|
260
260
|
short: a rule may now cite a signed commit in a policy repository, whose verified signer
|
|
261
261
|
becomes its principal ([Git sources](docs/configuration.md#git-sources-signed-commits), SSH
|
|
262
|
-
|
|
262
|
+
or GPG signatures), but file sources, the key-to-principal list and the other policy
|
|
263
263
|
files still rest on a shared signing secret rather than per-principal public keys, and there
|
|
264
264
|
are no Slack/Jira connectors. Until those land, Aegis demonstrates that the *decision
|
|
265
265
|
procedure* is sound; it proves the identities feeding it only for Git-sourced rules. Resource matching is also case-sensitive on names. It is not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Aegis-DevOps
|
|
2
2
|
|
|
3
3
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
4
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
4
|
+
[](https://pypi.org/project/aegis-devops/)
|
|
5
5
|
|
|
6
6
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
7
7
|
ticket told them to.**
|
|
@@ -72,7 +72,7 @@ BLOCK: kubernetes scale deployment/api-server
|
|
|
72
72
|
citations: no-scale-prod-peak
|
|
73
73
|
covered: True latency_ms: 0.20
|
|
74
74
|
PLAN BLOCK: 1 intent(s)
|
|
75
|
-
STORE: loaded=
|
|
75
|
+
STORE: loaded=30 quarantined=0 principals=3
|
|
76
76
|
warning: using example signing key
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -220,14 +220,14 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
220
220
|
|
|
221
221
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
222
222
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
223
|
-
v0.1
|
|
223
|
+
v0.2.1 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
224
224
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
225
225
|
through the Codex CLI, and a local `mistral:latest`.
|
|
226
226
|
|
|
227
227
|
What is *not* done is the part the threat model leans on hardest. See `docs/dev/PLAN.md §8`, but in
|
|
228
228
|
short: a rule may now cite a signed commit in a policy repository, whose verified signer
|
|
229
229
|
becomes its principal ([Git sources](docs/configuration.md#git-sources-signed-commits), SSH
|
|
230
|
-
|
|
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
|
|
@@ -451,3 +451,18 @@ constraints:
|
|
|
451
451
|
rule_text: Escalate deleting Argo CD applications (with cascade this deletes the
|
|
452
452
|
deployed resources) for human review.
|
|
453
453
|
provenance_hash: dc24eb69ac5c8b66279284b6df19095e3f0096303f3ef18da712a53e5e339aeb
|
|
454
|
+
- id: block-kubectl-impersonation
|
|
455
|
+
provider: kubernetes
|
|
456
|
+
resource_pattern: '*'
|
|
457
|
+
actions:
|
|
458
|
+
- impersonate
|
|
459
|
+
scope: {}
|
|
460
|
+
time_window: null
|
|
461
|
+
effect: BLOCK
|
|
462
|
+
constraint_class: configuration
|
|
463
|
+
principal: admin
|
|
464
|
+
source_ref: git-6009
|
|
465
|
+
source_timestamp: '2026-09-28T12:00:00+00:00'
|
|
466
|
+
rule_text: 'Block kubectl impersonation (--as, --as-group, --as-uid): the request
|
|
467
|
+
would run as another identity, outside the controls on the agent''s own.'
|
|
468
|
+
provenance_hash: 3c2382c07e085d7a10be92490d9b8905085e6996c5c4ef1aaf2ba43980a41fd1
|
|
@@ -0,0 +1,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"
|
{aegis_devops-0.1.7 → aegis_devops-0.2.1}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": {
|
|
3
|
-
"PRINCIPALS.yaml": "
|
|
3
|
+
"PRINCIPALS.yaml": "94a36ee80347de5ff022f103f9b1288a82d008351ce958d9a5764f1edda16ed5",
|
|
4
4
|
"git-4001.json": "8be2dd6c37ef643b3bc375b15d5d97889b00456285f594e21a3b4d52521e21de",
|
|
5
5
|
"git-6001.json": "6592ca4568f767d71a431490ff3cacdbc699ad92be966f57aed3b0a485844dc5",
|
|
6
6
|
"git-6002.json": "e6286c5fb8a0d2b3c34ca0c841ccede2a477a02f4781115caf4650f4f7c019b8",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"git-6006.json": "3f6fbfeb34afb3bcb8075f0864047b3641f0016b8817f67288db7f64afd36b58",
|
|
11
11
|
"git-6007.json": "eb84394208274b79792e285c5143dde320a2f4755d9b2922ffb55df07e7c5b9e",
|
|
12
12
|
"git-6008.json": "34d29df94b55a3fa29521246fa17e0966eda93f718a6c41082e6c648569aafa5",
|
|
13
|
+
"git-6009.json": "cadad3135121749b5dc1564e44dd2f97684782f2ebdb1cefbc6e08a319d88337",
|
|
13
14
|
"git-789abc.json": "48ac500e13d36240ec5c72bf90e96b433a5873d636e9e36f99a73468e7fb5757",
|
|
14
15
|
"git-abc123.json": "ee3751fdcd64cf1dd374bf9f7b73cda723a4aced5053f32501ba9416de89afc7",
|
|
15
16
|
"git-bcd234.json": "a0de7b32bde75735c91504aa7156e7b2ba2ef961d0f439218507e40d0bdfd89a",
|
|
@@ -36,5 +37,5 @@
|
|
|
36
37
|
"slack-perma-1.json": "2c8945a68e413c300ef58fc6a6d3daa8e4d0029a26a454dfad983e2091a188d1"
|
|
37
38
|
},
|
|
38
39
|
"header": "aegis-manifest-v1",
|
|
39
|
-
"sig": "
|
|
40
|
+
"sig": "5c28ed037b47840ab065c77c790d7a5807ac318a05ffa43393a3e6395d5f71e1"
|
|
40
41
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"provider": "kubernetes",
|
|
3
|
+
"resource_pattern": "*",
|
|
4
|
+
"actions": [
|
|
5
|
+
"impersonate"
|
|
6
|
+
],
|
|
7
|
+
"scope": {},
|
|
8
|
+
"time_window": null,
|
|
9
|
+
"effect": "BLOCK",
|
|
10
|
+
"constraint_class": "configuration",
|
|
11
|
+
"principal": "admin",
|
|
12
|
+
"source_ref": "git-6009",
|
|
13
|
+
"source_timestamp": "2026-09-28T12:00:00+00:00",
|
|
14
|
+
"rule_text": "Block kubectl impersonation (--as, --as-group, --as-uid): the request would run as another identity, outside the controls on the agent's own."
|
|
15
|
+
}
|
|
@@ -96,6 +96,7 @@ from aegis_core.environments import (
|
|
|
96
96
|
resolve_current_context,
|
|
97
97
|
)
|
|
98
98
|
from aegis_core.gitsource import (
|
|
99
|
+
GIT_PREFIX,
|
|
99
100
|
REPOS_FILE,
|
|
100
101
|
SIGNERS_FILE,
|
|
101
102
|
DispatchingSourceFetcher,
|
|
@@ -480,6 +481,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
480
481
|
"--out", default="aegis-signing.key", help="path to write the key to"
|
|
481
482
|
)
|
|
482
483
|
|
|
484
|
+
sources_parser = subparsers.add_parser(
|
|
485
|
+
"sources",
|
|
486
|
+
help="Report every constraint's source verification: which transport (git, file), "
|
|
487
|
+
"the verified principal, or why it is quarantined; exit 1 if any is",
|
|
488
|
+
)
|
|
489
|
+
_add_common_options(sources_parser)
|
|
490
|
+
|
|
483
491
|
hook_parser = subparsers.add_parser(
|
|
484
492
|
"hook",
|
|
485
493
|
help="Run as a coding agent's pre-tool hook: read its JSON payload on stdin, answer "
|
|
@@ -878,11 +886,12 @@ def _open_ledger(
|
|
|
878
886
|
return ledger
|
|
879
887
|
|
|
880
888
|
|
|
881
|
-
def
|
|
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,
|
|
@@ -120,6 +120,16 @@ class AegisInterceptor:
|
|
|
120
120
|
self.fail_closed = fail_closed
|
|
121
121
|
self.on_untrusted_match = on_untrusted_match
|
|
122
122
|
|
|
123
|
+
def _untrusted_reason(self, c: Constraint) -> str | None:
|
|
124
|
+
"""``"tampered"`` / ``"unauthorized"`` if this constraint may not
|
|
125
|
+
vote, else ``None`` (re-checked per decision; see REVIEW-4 L4
|
|
126
|
+
below)."""
|
|
127
|
+
if not c.verify_integrity():
|
|
128
|
+
return "tampered"
|
|
129
|
+
if not self.store.is_authorized(c.principal, c.constraint_class):
|
|
130
|
+
return "unauthorized"
|
|
131
|
+
return None
|
|
132
|
+
|
|
123
133
|
def intercept(self, intent: InfrastructureIntent, now: datetime | None = None) -> Decision:
|
|
124
134
|
"""Evaluates an intent and returns a Decision.
|
|
125
135
|
|
|
@@ -167,12 +177,22 @@ class AegisInterceptor:
|
|
|
167
177
|
notes: list[str] = []
|
|
168
178
|
fail_closed = False
|
|
169
179
|
verified = []
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
# An unresolved condition only fails closed on behalf of a constraint
|
|
181
|
+
# that could vote: a tampered or unauthorized one is discarded here
|
|
182
|
+
# exactly as it is when it matches outright (below), so it cannot
|
|
183
|
+
# force ESCALATE just because the intent's environment or time zone
|
|
184
|
+
# could not be resolved.
|
|
185
|
+
for c, label in [*((c, "env-unresolved") for c in env_unresolved),
|
|
186
|
+
*((c, "time-window-unresolved") for c in time_window_unresolved)]:
|
|
187
|
+
reason = self._untrusted_reason(c)
|
|
188
|
+
if reason is not None:
|
|
189
|
+
discarded.append({"id": c.id, "reason": reason})
|
|
190
|
+
if c.effect in _ENFORCING_EFFECTS and self.on_untrusted_match == "escalate":
|
|
191
|
+
fail_closed = True
|
|
192
|
+
notes.append(f"fail-closed: {c.id} ({reason})")
|
|
193
|
+
continue
|
|
174
194
|
fail_closed = True
|
|
175
|
-
notes.append(f"
|
|
195
|
+
notes.append(f"{label}: {c.id}")
|
|
176
196
|
if unknown_target:
|
|
177
197
|
fail_closed = True
|
|
178
198
|
notes.append("unknown-target")
|
|
@@ -193,11 +213,7 @@ class AegisInterceptor:
|
|
|
193
213
|
# rule, not per loaded rule -- see get_matching_constraints'
|
|
194
214
|
# index), so we pay it on every decision instead of trusting a
|
|
195
215
|
# verification result that may be stale by the time it matters.
|
|
196
|
-
reason =
|
|
197
|
-
if not c.verify_integrity():
|
|
198
|
-
reason = "tampered"
|
|
199
|
-
elif not self.store.is_authorized(c.principal, c.constraint_class):
|
|
200
|
-
reason = "unauthorized"
|
|
216
|
+
reason = self._untrusted_reason(c)
|
|
201
217
|
if reason is not None:
|
|
202
218
|
discarded.append({"id": c.id, "reason": reason})
|
|
203
219
|
if c.effect in _ENFORCING_EFFECTS and self.on_untrusted_match == "escalate":
|