From 5add9bfc36df4384b8bc6df85b17fd75debc6f34 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:06:17 +0100 Subject: [PATCH] fix(ci): permit audited caller-only permission inheritance The stable-install smoke body is intentionally workflow_call-only so its read-only continuity caller and draft-capable release caller can supply different explicit token budgets. Treat that exact no-override shape as an auditable permission boundary while continuing to reject independent triggers and job permission overrides. Validation: 41 workflow-trust tests, repository workflow audit, focused install-smoke contract tests, Python compilation and diff checks pass. Change-source: pulse-maintainer --- scripts/check_workflow_trust.py | 42 ++++++++++++++++++++++++-- scripts/tests/test_workflow_trust.py | 45 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index c5873f1cb..97f2c03a5 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -833,6 +833,40 @@ def _has_trigger(lines: list[str], event: str) -> bool: return False +def _is_workflow_call_only(lines: list[str]) -> bool: + """Return whether a workflow can run only with its caller's token budget.""" + for index, line in enumerate(lines): + code = line.split("#", 1)[0] + match = re.match(rf"^{_yaml_key('on')}\s*:\s*(.*)$", code) + if not match: + continue + + inline = match.group(1).strip() + if inline: + return inline in {"workflow_call", "[workflow_call]"} + + direct_indent = _direct_mapping_indent(lines, index) + if direct_indent is None: + return False + trigger_re = re.compile( + rf"^(\s*)({_yaml_key('workflow_call')})\s*:" + ) + direct_triggers = [] + for trigger_line in lines[index + 1 :]: + trigger_code = trigger_line.split("#", 1)[0] + if not trigger_code.strip(): + continue + indent = _indent(trigger_code) + if indent == 0: + break + if indent != direct_indent: + continue + trigger_match = trigger_re.match(trigger_code) + direct_triggers.append(bool(trigger_match)) + return direct_triggers == [True] + return False + + def _is_hardened_closed_pr_cancellation(path: Path, lines: list[str]) -> bool: """Recognise the one metadata-only privileged PR automation we permit.""" if path.name != SAFE_PULL_REQUEST_TARGET_WORKFLOW: @@ -1404,12 +1438,16 @@ def audit_workflow(path: Path) -> list[Finding]: for index, match in permission_declarations if not match.group(1) ] - if len(top_level_permissions) != 1: + inherits_caller_permissions = ( + not permission_declarations and _is_workflow_call_only(lines) + ) + if len(top_level_permissions) != 1 and not inherits_caller_permissions: findings.append( Finding( path, 1, - "workflow must declare top-level permissions explicitly exactly once", + "workflow must declare top-level permissions explicitly exactly once " + "unless it is workflow_call-only and inherits its caller budget", ) ) for index, match in permission_declarations: diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index dfeed669d..3800198dc 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -645,6 +645,51 @@ steps: dynamic = self.audit("permissions: ${{ inputs.permissions }}\njobs: {}\n") self.assertTrue(any("scope mapping" in finding for finding in dynamic)) + def test_workflow_call_only_may_inherit_caller_permissions(self) -> None: + inherited = self.audit( + """on: + workflow_call: +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - run: echo inherited +""" + ) + self.assertEqual(inherited, []) + + independently_runnable = self.audit( + """on: + workflow_call: + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - run: echo independent +""" + ) + self.assertTrue( + any("top-level permissions" in finding for finding in independently_runnable) + ) + + job_override = self.audit( + """on: + workflow_call: +jobs: + unsafe: + permissions: + contents: write + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - run: echo unsafe +""" + ) + self.assertTrue(any("top-level permissions" in finding for finding in job_override)) + def test_rejects_broad_or_dynamic_job_permission_overrides(self) -> None: broad = self.audit( """permissions: