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
28 changes: 25 additions & 3 deletions .github/workflows/publish-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# Prerequisites:
# - PyPI trusted publisher configured for this repo + workflow
# - GitHub environment "release-python" created in repo settings
# - "release-windows-signing" environment restricted to release tags
# - Validated Public Trust profile + profile-scoped OIDC signer permission

name: Publish Python

Expand Down Expand Up @@ -117,7 +119,8 @@ jobs:

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: pypi_files-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'manylinux' }}
# Unsigned Windows wheels must never match the publisher's glob.
name: ${{ matrix.os == 'windows' && 'pypi_unsigned' || 'pypi_files' }}-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'manylinux' }}
path: crates/bashkit-python/dist

# Reduced-feature Pyodide/Emscripten wheel (browser / JupyterLite).
Expand Down Expand Up @@ -217,6 +220,12 @@ jobs:
merge-multiple: true
path: dist

- name: Download unsigned Windows wheel for inspection
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: pypi_unsigned-windows-x86_64-manylinux
path: dist

- name: List dist files
run: |
ls -lhR dist/
Expand All @@ -226,14 +235,27 @@ jobs:
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
- run: uvx twine check dist/*

# Only this shared signer can turn unsigned build outputs into publishable
# Windows wheels. It also checks the immutable tag before accessing Azure.
sign-windows:
name: Sign Windows wheel
needs: [inspect]
uses: ./.github/workflows/sign-windows-wheel.yml
permissions:
contents: read
id-token: write
with:
unsigned-artifact-name: pypi_unsigned-windows-x86_64-manylinux
signed-artifact-name: pypi_files-windows-x86_64-manylinux

# Smoke-test the single cp39-abi3 wheel on every targeted Python version.
# With abi3 there is one wheel per platform, so this is the check that it
# actually installs and runs across the full 3.9–3.14 range it claims — not
# just the build interpreter. os × python-version cross-product (18 jobs);
# the include entries only map each os to its runner.
test-builds:
name: Test wheel on ${{ matrix.os }} (Python ${{ matrix.python-version }})
needs: [build]
needs: [build, sign-windows]
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -270,7 +292,7 @@ jobs:
# Publish to PyPI using trusted publishing (OIDC)
publish:
name: Publish to PyPI
needs: [inspect, test-builds]
needs: [inspect, sign-windows, test-builds]
if: success()
runs-on: ubuntu-latest

Expand Down
118 changes: 118 additions & 0 deletions .github/workflows/sign-windows-wheel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Shared production signing path. No compiler or native import runs with OIDC.
# THREAT[TM-INF-035]: every caller must use an immutable main-reachable tag.
name: Sign Windows wheel

on:
workflow_call:
inputs:
unsigned-artifact-name:
type: string
required: true
signed-artifact-name:
type: string
required: true

permissions:
contents: read

jobs:
sign:
name: Sign Windows wheel
runs-on: windows-latest
timeout-minutes: 10
environment: release-windows-signing
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Verify immutable tag source
shell: bash
run: |
if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-signing-smoke-[0-9a-f]{12})?$ ]]; then
echo "Signing requires a stable release or signing-smoke tag"
exit 1
fi
git fetch --no-tags origin main:refs/remotes/origin/main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"

- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: ${{ inputs.unsigned-artifact-name }}
path: unsigned-dist

- name: Install wheel tooling
run: >-
python -m pip install --only-binary=:all: wheel==0.48.0

- name: Unpack and retain unsigned file hashes
id: unpack
run: python scripts/windows_wheel.py prepare --source unsigned-dist --work wheel-work

- name: Read public signing configuration
id: config
shell: pwsh
run: |
$config = Get-Content scripts/windows-signing.json -Raw | ConvertFrom-Json
foreach ($key in @("tenant-id", "subscription-id", "client-id", "endpoint", "signing-account-name", "certificate-profile-name")) {
$value = $config.PSObject.Properties[$key].Value
if ([string]::IsNullOrWhiteSpace($value) -or $value -match "[\r\n]") {
throw "Invalid signing configuration: $key"
}
$name = $key.Replace("-", "_")
"$name=$value" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
}

- name: Authenticate release signer with OIDC
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3
with:
client-id: ${{ steps.config.outputs.client_id }}
tenant-id: ${{ steps.config.outputs.tenant_id }}
subscription-id: ${{ steps.config.outputs.subscription_id }}

- name: Sign native files and timestamp
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
with:
endpoint: ${{ steps.config.outputs.endpoint }}
signing-account-name: ${{ steps.config.outputs.signing_account_name }}
certificate-profile-name: ${{ steps.config.outputs.certificate_profile_name }}
files-folder: ${{ steps.unpack.outputs.root }}
files-folder-filter: pyd,dll,exe
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256

- name: Verify every native signature
shell: pwsh
env:
WHEEL_ROOT: ${{ steps.unpack.outputs.root }}
run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT

- name: Repack with regenerated RECORD and verify archive hashes
run: python scripts/windows_wheel.py repack --work wheel-work --output signed-dist

- name: Extract the final archive for signature verification
id: final
run: python scripts/windows_wheel.py prepare --source signed-dist --work final-check

- name: Verify signatures from the final wheel
shell: pwsh
env:
WHEEL_ROOT: ${{ steps.final.outputs.root }}
run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: ${{ inputs.signed-artifact-name }}
path: signed-dist

88 changes: 88 additions & 0 deletions .github/workflows/windows-signing-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Exercise the real signing identity without publishing or rebuilding a registry
# version. The input is the exact reported PyPI wheel, verified by SHA-256.
name: Windows signing smoke

on:
workflow_dispatch:

permissions:
contents: read

jobs:
prepare:
name: Prepare known published wheel
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Require a signing-smoke tag
shell: bash
run: |
[[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+-signing-smoke-[0-9a-f]{12}$ ]]
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Download and verify the original Windows artifact
run: |
python -m pip download --index-url https://pypi.org/simple --platform win_amd64 --only-binary=:all: --no-deps --no-cache-dir --dest unsigned-dist bashkit==0.20.1
echo "948f3f73a3e1289caeba7581330732fd145d5b26e5c018d97345c1bd92ce9099 unsigned-dist/bashkit-0.20.1-cp39-abi3-win_amd64.whl" | sha256sum --check --strict
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: pypi_unsigned-signing-smoke
path: unsigned-dist

sign:
needs: [prepare]
uses: ./.github/workflows/sign-windows-wheel.yml
permissions:
contents: read
id-token: write
with:
unsigned-artifact-name: pypi_unsigned-signing-smoke
signed-artifact-name: windows-signing-validation

verify:
name: Install signed wheel and test pytest loading
needs: [sign]
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.11.9"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: windows-signing-validation
path: dist
- name: Install verification tooling
run: >-
python -m pip install --only-binary=:all: wheel==0.48.0 pytest==9.1.1
- name: Unpack the exact final wheel
id: unpack
run: python scripts/windows_wheel.py prepare --source dist --work final-wheel
- name: Verify the production signatures and timestamps
shell: pwsh
env:
WHEEL_ROOT: ${{ steps.unpack.outputs.root }}
run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT
- name: Install the signed wheel
run: python -m pip install --no-index --find-links dist --no-deps bashkit==0.20.1
- name: Test native import during pytest plugin loading and execution
shell: pwsh
working-directory: ${{ runner.temp }}
run: |
'from bashkit import BashTool' | Set-Content bashkit_validation_plugin.py
@'
from bashkit import BashTool

def test_signed_native_execution():
tool = BashTool()
assert tool.execute_sync('export SIGNING_TEST=verified').exit_code == 0
result = tool.execute_sync('echo $SIGNING_TEST')
assert result.exit_code == 0
assert result.stdout.strip() == 'verified'
'@ | Set-Content test_signed_wheel.py
python -m pytest -p bashkit_validation_plugin test_signed_wheel.py -q
88 changes: 88 additions & 0 deletions .github/workflows/windows-signing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Test packaging and real WinVerifyTrust without production signing credentials.
# This hosted Windows Server job is not a SAC enforcement test.
name: Windows signing

on:
push:
branches: [main]
paths:
- ".github/workflows/windows-signing.yml"
- ".github/workflows/publish-python.yml"
- ".github/workflows/sign-windows-wheel.yml"
- ".github/workflows/windows-signing-smoke.yml"
- "scripts/windows_wheel.py"
- "scripts/windows-signing.json"
- "scripts/verify_windows_signatures.ps1"
- "scripts/tests/test_windows_signing.py"
pull_request:
branches: [main]
paths:
- ".github/workflows/windows-signing.yml"
- ".github/workflows/publish-python.yml"
- ".github/workflows/sign-windows-wheel.yml"
- ".github/workflows/windows-signing-smoke.yml"
- "scripts/windows_wheel.py"
- "scripts/windows-signing.json"
- "scripts/verify_windows_signatures.ps1"
- "scripts/tests/test_windows_signing.py"
workflow_dispatch:

permissions:
contents: read

jobs:
published-baseline:
name: Published wheel ${{ matrix.version }} (Windows baseline)
runs-on: windows-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- version: "0.20.0"
sha256: 4bcee1e7bf4bed6b55d80c04694c95f1116685d847fbd9bfce42f76740e7915c
- version: "0.20.1"
sha256: 948f3f73a3e1289caeba7581330732fd145d5b26e5c018d97345c1bd92ce9099
steps:
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.11.9"
- name: Verify published bytes, record signature, and test native execution
shell: pwsh
env:
VERSION: ${{ matrix.version }}
EXPECTED_SHA256: ${{ matrix.sha256 }}
run: |
# No production identity or signing permission belongs in this job.
Get-ComputerInfo | Select-Object WindowsProductName, OsVersion, OsBuildNumber
python --version
python -m pip download --index-url https://pypi.org/simple --only-binary=:all: --no-deps --no-cache-dir --dest published "bashkit==$env:VERSION"
if ($LASTEXITCODE -ne 0) { throw "Wheel download failed" }
$wheels = @(Get-ChildItem published -Filter *.whl)
if ($wheels.Count -ne 1) { throw "Expected one published wheel" }
$digest = (Get-FileHash $wheels[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant()
if ($digest -ne $env:EXPECTED_SHA256) { throw "Published wheel hash differs from the inspected artifact" }
python -m pip install --no-index --find-links published --no-deps "bashkit==$env:VERSION"
if ($LASTEXITCODE -ne 0) { throw "Wheel installation failed" }
$site = python -c "import sysconfig; print(sysconfig.get_path('platlib'))"
Get-FileHash "$site\bashkit\_bashkit.pyd" -Algorithm SHA256
Get-AuthenticodeSignature "$site\bashkit\_bashkit.pyd" | Format-List Path, Status, StatusMessage, SignerCertificate
python -c "from bashkit import BashTool; r = BashTool().execute_sync('echo hello'); assert r.exit_code == 0; assert r.stdout.strip() == 'hello'; print(r.stdout)"
if ($LASTEXITCODE -ne 0) { throw "Native import/execution failed" }

test:
name: Windows wheel integrity and Authenticode
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Install test tooling
run: >-
python -m pip install --only-binary=:all: wheel==0.48.0 PyYAML==6.0.3
- name: Test RECORD repair and Windows signature validation
run: python -m unittest discover -s scripts/tests -p test_windows_signing.py -v
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ Do not mix criterion `.md` files into `crates/bashkit-bench/results/`.
- Tests: `pytest crates/bashkit-python/tests/ -v` (requires `maturin develop` first)
- CI: `.github/workflows/python.yml` (lint, test on 3.9/3.12/3.13, build wheel)

Windows wheel signing checks: `.github/workflows/windows-signing.yml` runs
`python -m unittest discover -s scripts/tests -p test_windows_signing.py -v`
with pinned wheel tooling on Windows. Packaging tests also run on other hosts;
WinVerifyTrust tests require Windows. Production signing prerequisites and the
unsigned/signed artifact boundary live in `knowledge/runtimes/python-package.md`.
The manual `windows-signing-smoke.yml` workflow tests the actual Azure signer
through the same reusable job as publication, from a main-reachable test tag,
and emits a validation wheel without publishing. See the release knowledge.

### Pre-PR Checklist

1. `just pre-pr` (runs 2-4 automatically)
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ Scripts may attempt to leak sensitive information.
| Host env side-channel via clap `Arg::env` (TM-INF-024) | `ls` resolved `TABSIZE`/`TIME_STYLE` from host env | Codegen strips `.env(...)`; builtins read `ctx.env` only | MITIGATED |
| Untrusted generated Rust in drift CI (TM-INF-025) | Malicious upstream `uu_app()` runs with a write token | Generator validates the emitted shape; drift workflow splits read/write privilege | FIXED |
| Checkout token readable by untrusted CI code (TM-INF-034) | `actions/checkout` leaves `GITHUB_TOKEN` in `.git/config`, where a dependency build script or test in the same job can read it | `persist-credentials: false` on every checkout that does not back an authenticated `git`/`gh` call, and on every job of a pull-request-triggered workflow; every workflow declares a read-only default `permissions` block | FIXED |
| Windows signing authority (TM-INF-035) | Compiler dependencies or native imports gain production code-signing access, or unsigned build outputs reach PyPI | A separate source-validated OIDC job signs native files without compiling or importing them; deployment requires a release-tag-restricted environment and profile-scoped signer role. Embedded signatures and timestamps are verified after RECORD repair; unsigned artifact names never match the publisher's glob. External profile/environment provisioning must complete before enabling the release gate | MITIGATED |
| Publish from unverified release refs (TM-INF-027) | Dispatch publish from a branch or unprotected tag | Dispatch requires `refs/heads/main`; publish gated on real `vX.Y.Z` tags | FIXED |
| JS `onOutput` errors expose host stack traces (TM-INF-028) | Callback throws, leaking `error.stack` | Propagate `error.message` only; strip absolute/`file://` paths | FIXED |
| Raw callback errors leak host internals (TM-INF-030) | Tool callback throws API keys/connection strings/stack traces | `sanitize_errors` defaults on for `ScriptedTool`/`ToolImpl`/`ToolRegistry` across shell/Python/TypeScript | MITIGATED |
Expand Down
Loading
Loading