Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
# hash-pin an editable install in the same invocation.
- run: python -m pip install --require-hashes -r requirements/test.txt
- run: python -m pip install --no-deps -e .
- run: python tools/check_workflow_oidc.py
- run: python tools/check_versions.py
- run: python tools/check_schemas.py
- run: python tools/check_otel_compatibility.py
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Changed

- CI checks the existing four job-level OIDC grants across all workflow files,
refusing source permission drift without changing any publishing grant.

- The project name is spelled AgenTrust (one shared t) in docs, docstrings,
the compatibility matrix notes and the TypeScript package description, and the
LICENSE and NOTICE copyright holder reads "AgenTrust Contributors". Package
Expand Down
17 changes: 17 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,20 @@ Note that npm sets `latest` on a package's very first published version whatever
`--tag` says. That is expected on a bootstrap publish and corrects itself when
the first stable version ships. PyPI and npm create registry provenance
through trusted publishing; GitHub also attests the downloadable release assets.

## OIDC source drift

CI runs `python tools/check_workflow_oidc.py` across every `.yml` and `.yaml`
workflow. The proposed existing-source baseline preserves `release.yml` jobs
`publish-pypi`, `publish-npm` and `release-assets`, and `scorecard.yml/analysis`.
It refuses workflow-wide OIDC grants even when current jobs override them,
`write-all` at either scope, and additions, removals or renames of these grants.
An intentional change needs review of the baseline and workflow delta together.
Unittest controls start from passing copies of the real workflows before each
mutation; a direct Python `-O` CLI check still refuses a broad grant.

This preserves current permissions, including the release-asset attestation and
Scorecard grants; it does not establish that each grant remains necessary.
The guard checks source, not live environment reviewers, administrator bypass,
deployment restrictions, trusted-publisher bindings or backup-operator coverage.
Those remain part of [the release-safeguard sweep](https://github.com/agentrust-io/.github/issues/54).
3 changes: 3 additions & 0 deletions requirements/test.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ opentelemetry-api>=1.30,<2
agentrust-trace>=0.9,<1; python_version >= '3.11'
tomli>=2.5.0,<3; python_version < '3.11'
build

# Source-only workflow OIDC regression guard. Not a runtime dependency.
PyYAML==6.0.3
75 changes: 75 additions & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,81 @@ python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via arrow
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via -r requirements/test.in
referencing==0.37.0 \
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
--hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
Expand Down
134 changes: 134 additions & 0 deletions tests/test_workflow_oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Paired source-permission controls; no workflow dispatch or live setting access."""

import importlib.util
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest

import yaml


ROOT = Path(__file__).resolve().parents[1]
TOOL = ROOT / "tools/check_workflow_oidc.py"
spec = importlib.util.spec_from_file_location("check_workflow_oidc", TOOL)
assert spec and spec.loader
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)


class WorkflowOidcTests(unittest.TestCase):
def copy_workflows(self, directory):
root = Path(directory)
for path in [*gate.WORKFLOWS.glob("*.yml"), *gate.WORKFLOWS.glob("*.yaml")]:
shutil.copyfile(path, root / path.name)
gate.check_oidc_permissions(root)
return root

def test_real_workflow_baseline(self):
gate.check_oidc_permissions(gate.WORKFLOWS)

def test_each_permission_drift_is_refused_from_a_passing_copy(self):
scenarios = [
"new-yml", "new-yaml", "extra-job", "inherited-default", "shadowed-default",
"default-write-all", "shadowed-write-all", "job-write-all", "allowed-write-all",
"remove-pypi", "remove-npm", "remove-assets", "remove-scorecard",
"rename-job", "rename-file",
]
for scenario in scenarios:
with self.subTest(scenario=scenario), tempfile.TemporaryDirectory() as directory:
root = self.copy_workflows(directory)
path = root / "release.yml"
workflow = yaml.safe_load(path.read_text(encoding="utf-8"))
if scenario.startswith("new-"):
path = root / ("unexpected." + scenario.removeprefix("new-"))
workflow = {"permissions": {}, "jobs": {"unexpected": {
"runs-on": "ubuntu-latest", "permissions": {"id-token": "write"},
"steps": [{"run": "echo test"}],
}}}
elif scenario == "extra-job":
workflow["jobs"]["unexpected"] = {"permissions": {"id-token": "write"}}
elif scenario in {"inherited-default", "shadowed-default"}:
workflow["permissions"] = {"id-token": "write"}
if scenario == "shadowed-default":
for job in workflow["jobs"].values():
job.setdefault("permissions", {})
elif scenario in {"default-write-all", "shadowed-write-all"}:
workflow["permissions"] = "write-all"
if scenario == "shadowed-write-all":
for job in workflow["jobs"].values():
job.setdefault("permissions", {})
elif scenario in {"job-write-all", "allowed-write-all"}:
job = "build" if scenario == "job-write-all" else "publish-pypi"
workflow["jobs"][job]["permissions"] = "write-all"
elif scenario.startswith("remove-"):
filename, job = {
"remove-pypi": ("release.yml", "publish-pypi"),
"remove-npm": ("release.yml", "publish-npm"),
"remove-assets": ("release.yml", "release-assets"),
"remove-scorecard": ("scorecard.yml", "analysis"),
}[scenario]
path = root / filename
workflow = yaml.safe_load(path.read_text(encoding="utf-8"))
workflow["jobs"][job]["permissions"].pop("id-token")
elif scenario == "rename-job":
workflow["jobs"]["renamed"] = workflow["jobs"].pop("publish-pypi")
elif scenario == "rename-file":
path.unlink()
path = root / "release.yaml"
path.write_text(yaml.safe_dump(workflow), encoding="utf-8", newline="\n")
with self.assertRaisesRegex(ValueError, "OIDC"):
gate.check_oidc_permissions(root)

def test_read_only_inheritance_and_narrowing_are_valid(self):
with tempfile.TemporaryDirectory() as directory:
root = self.copy_workflows(directory)
(root / "read-only.yaml").write_text(
"permissions: read-all\njobs:\n inherited: {}\n"
" narrow:\n permissions:\n contents: read\n", encoding="utf-8",
)
gate.check_oidc_permissions(root)

def test_malformed_permissions_refuse(self):
for value in [None, [], 1, "unsupported", {"id-token": True}]:
with self.subTest(value=value), self.assertRaises(ValueError):
gate._oidc_write(value)

def test_empty_and_malformed_workflow_inventories_refuse(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with self.assertRaisesRegex(ValueError, "no workflows"):
gate.check_oidc_permissions(root)
for text in ["null", "[]", "jobs: null", "jobs: {test: null}"]:
with self.subTest(text=text):
(root / "bad.yml").write_text(text, encoding="utf-8")
with self.assertRaisesRegex(ValueError, "mapping"):
gate.check_oidc_permissions(root)

def test_optimized_cli_has_a_positive_control_and_detects_permission_drift(self):
with tempfile.TemporaryDirectory() as directory:
root = self.copy_workflows(directory)
command = [sys.executable, "-O", str(TOOL), str(root)]
positive = subprocess.run(command, capture_output=True, text=True, check=False)
self.assertEqual(positive.returncode, 0, positive.stderr)
(root / "new.yaml").write_text(
"permissions: write-all\njobs: {}\n", encoding="utf-8",
)
refused = subprocess.run(command, capture_output=True, text=True, check=False)
self.assertEqual(refused.returncode, 1)
self.assertIn("workflow-wide OIDC grant", refused.stderr)

def test_ci_runs_the_guard_without_an_optional_parser_skip(self):
ci = yaml.safe_load((gate.WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
commands = [step.get("run", "") for step in ci["jobs"]["test"]["steps"]]
self.assertIn("python tools/check_workflow_oidc.py", commands)
inputs = (ROOT / "requirements/test.in").read_text(encoding="utf-8")
self.assertIn("PyYAML==6.0.3", inputs.splitlines())
lock = (ROOT / "requirements/test.txt").read_text(encoding="utf-8")
self.assertIn("pyyaml==6.0.3", lock)


if __name__ == "__main__":
unittest.main()
76 changes: 76 additions & 0 deletions tools/check_workflow_oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Source-only OIDC grant baseline for the release-safeguard sweep (.github#54)."""

from __future__ import annotations

from pathlib import Path
import sys

import yaml


WORKFLOWS = Path(__file__).resolve().parents[1] / ".github/workflows"
# Proposed existing-source baseline, not approval of live environment settings.
OIDC_JOBS = {
("release.yml", "publish-pypi"),
("release.yml", "publish-npm"),
("release.yml", "release-assets"),
("scorecard.yml", "analysis"),
}


def _oidc_write(permissions):
if isinstance(permissions, str):
if permissions not in {"read-all", "write-all"}:
raise ValueError("unsupported permission shorthand")
return permissions == "write-all"
if not isinstance(permissions, dict):
raise ValueError("permissions must be a mapping or supported shorthand")
if any(not isinstance(key, str) or value not in ("read", "write", "none")
for key, value in permissions.items()):
raise ValueError("permission entries must name read, write or none")
return permissions.get("id-token") == "write"


def check_oidc_permissions(workflows):
"""Refuse source drift even when narrower jobs shadow a broad default.

Explicit raises are intentional: a direct python -O invocation still refuses.
actionlint separately validates workflow syntax, expressions and duplicates.
"""
paths = sorted([*workflows.glob("*.yml"), *workflows.glob("*.yaml")])
if not paths:
raise ValueError("no workflows found")
actual = set()
for path in paths:
workflow = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(workflow, dict) or not isinstance(workflow.get("jobs"), dict):
raise ValueError(f"workflow and jobs must be mappings: {path.name}")
defaults = workflow.get("permissions", {})
if _oidc_write(defaults):
raise ValueError(f"workflow-wide OIDC grant: {path.name}")
for name, job in workflow["jobs"].items():
if not isinstance(job, dict):
raise ValueError(f"job must be a mapping: {path.name}/{name}")
permissions = job.get("permissions", defaults)
if permissions == "write-all":
raise ValueError(f"OIDC write-all job grant: {path.name}/{name}")
if _oidc_write(permissions):
actual.add((path.name, name))
if actual != OIDC_JOBS:
raise ValueError(f"OIDC job allowance drift: {sorted(actual ^ OIDC_JOBS)}")



def main():
workflows = Path(sys.argv[1]) if len(sys.argv) > 1 else WORKFLOWS
try:
check_oidc_permissions(workflows)
except (ValueError, yaml.YAMLError) as error:
print(f"FAIL {error}", file=sys.stderr)
return 1
print("PASS workflow OIDC grants match the proposed source baseline")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading