Sealed execution environment for GitHub Actions. Stop supply chain attacks dead in their tracks.
See the codeRunseal was built to solve the problem of software supply chain attacks that are often triggered from GitHub Actions-based exploits.
We built runseal in response to the rise of supply chain attacks targeting GitHub Actions, where attackers often gain access to repository secrets and use them to exfiltrate data or deploy malicious code. By using nono's kernel-enforced sandboxing, runseal can protect sensitive files, secrets/tokens, and filter network access from untrusted or malicious code, while still allowing necessary software engineering operations through a flexible policy system.
From the same folks who brought you sigstore and nono.
[!WARNING] Early Alpha Software This project is in early alpha and under active development. Expect bugs, breaking changes, and incomplete features and a series of follow-up security audits. Therefore consider this an early preview until this note is updated.
name: Publish
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm publish
policy: |
fs:
read: ["."]
write: []
network:
mode: filtered
access:
npm:
secret: NPM_TOKEN
url: https://registry.npmjs.org
allow:
- PUT /**
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
In this example, npm publish can read the repository, cannot write to paths
not listed in fs.write, network access is limited to 'registry.npmjs.org' with no access to other hosts, and 'NPM_TOKEN' is retrieved from GitHub Secrets and a phantom credential is injected in its place. If the workflow is compromised and an attacker tries to exfiltrate the token or publish a malicious package, the attack would be blocked by the sandbox and the real token would remain safe and outside of the 'npm publish 'workflow.
Runseal policy is YAML passed through the policy input.
fs:
read:
- "."
- "$HOME/.cache/my-tool"
write:
- "./dist"
network:
mode: filtered
allow:
- api.github.com
access:
deploy:
secret: DEPLOY_TOKEN
url: https://api.example.com
allow:
- POST /v1/deployments
- GET /v1/deployments/*
fs.read lists paths the command can read. fs.write lists paths the command
can write.
Keep these narrow. For example, a deploy step often only needs to read ./dist
and a config file, and may not need write access at all.
fs:
read: ["./dist", "./fly.toml"]
write: []
Runseal expects network.mode: blocked or network.mode: filtered.
Add network.allow only for unauthenticated hosts the command must reach. Hosts
used by access grants are added to the generated nono profile automatically.
A network.allow list implies mode: filtered when mode is omitted;
combining it with an explicit mode: blocked is a configuration error.
network:
mode: filtered
allow:
- api.github.com
Each key under access is a named grant. secret is the environment variable
containing the real secret, url is the service base URL, and allow lists the
HTTP routes where the secret may be injected. Runseal masks the secret in logs,
writes it to a private file, removes it from the child environment, and
configures nono to inject it through the local proxy. Runseal's own copy of
the plaintext is zeroed out of memory once the credential file is written, and
on every error path in between.
access:
fly:
secret: FLY_API_TOKEN
url: https://api.machines.dev
allow:
- POST /v1/apps/*/machines
The sandboxed command receives a phantom credential for SDK compatibility. The real secret remains outside the sandbox and is only inserted by the proxy when the host and endpoint policy match.
inject.mode selects how the proxy injects the secret:
| Mode | Status | Behavior |
|---|---|---|
header | Default | Injected as an Authorization: Bearer header. |
basic_auth | Supported | Injected as HTTP basic auth credentials. |
url_path | Reserved | Rejected; runseal does not emit the required path_pattern yet. |
query_param | Reserved | Rejected; runseal does not emit the required query_param_name yet. |
access:
fly:
secret: FLY_API_TOKEN
url: https://api.machines.dev
inject:
mode: header
allow:
- POST /v1/apps/*/machines
allow restricts access use by HTTP method and path. Matching is allow-list
based. The method may be any HTTP method token (GET, DELETE, PROPFIND,
...) or the wildcard *; methods are uppercased before matching.
allow:
- POST /v1/apps/*/releases
- GET /v1/apps/*/status
Runseal relies on nono TLS interception for this. The nono proxy creates an
ephemeral trust bundle and injects standard CA environment variables into the
sandboxed process, so common HTTPS clients can connect through the proxy while
still allowing L7 policy enforcement.
Instead of writing policy in the workflow, a repository can commit a nono profile and point runseal at it:
- run: mkdir -p node_modules
- uses: nolabs-ai/runseal@main
with:
runseal-version: source
run: npm ci
profile: runseal.json
{
"meta": { "name": "my-repo" },
"filesystem": {
"read": ["./src", "./package.json"],
"write": ["./node_modules"]
},
"network": {
"allow_domain": ["registry.npmjs.org"]
}
}
runseal layers this profile underneath the profile it generates. Runseal validates it, rewrites relative paths to absolute ones, and writes out its own copy.
The profile input is mutually exclusive with policy, fs-read, fs-write,
and network. Setting both is an error rather than a precedence rule.
| Key | Meaning |
|---|---|
$schema | Schema hint for editors. |
meta.name, meta.description | Documentation for the profile. |
filesystem.read, filesystem.write | Directories the command may read or write. |
filesystem.read_file, filesystem.write_file | Single files the command may read or write. |
filesystem.deny | Paths to deny, on top of everything else. |
network.allow_domain | Hosts the command may reach. |
network.block | Only true. Network is already blocked by default. |
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm test
policy: |
fs:
read: [".", "./node_modules"]
write: ["./coverage"]
network:
mode: blocked
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm ci
policy: |
fs:
read: ["."]
write: ["./node_modules"]
network:
mode: filtered
allow:
- registry.npmjs.org
- uses: nolabs-ai/runseal@v0.3.4
with:
run: ./scripts/deploy.sh
policy: |
fs:
read: ["./dist", "./deploy.yaml"]
write: []
network:
mode: filtered
access:
deploy:
secret: DEPLOY_TOKEN
url: https://deploy.example.com
allow:
- POST /v1/releases
- GET /v1/releases/*
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
| Input | Required | Default | Description |
|---|---|---|---|
run | Yes | none | Command to execute inside the sandbox. |
policy | No | empty | Runseal policy YAML. Prefer this for new workflows. |
profile | No | empty | Path to a nono profile in the repository, relative to the workspace. Mutually exclusive with policy, fs-read, fs-write, and network. See Repo Profile. |
fs-read | No | empty | Comma-separated read paths when policy is not set. |
fs-write | No | empty | Comma-separated write paths when policy is not set. |
network | No | empty (blocked) | Network policy when policy is not set: blocked or comma-separated domains. An empty value means blocked. filtered is only valid as network.mode inside policy and is rejected here. |
runseal-version | No | 0.3.4 | Runseal release version to install. Accepts v0.1.0 or 0.1.0. |
nono-version | No | pinned | nono release version to install. Defaults to the Dependabot-managed pin in .github/nono-version/Cargo.toml. Accepts v0.1.0 or 0.1.0. |
verify-attestations | No | true | Verify GitHub artifact attestations for downloaded release assets. |
audit | No | false | Set to artifact or true to upload nono audit evidence as a GitHub Actions artifact. |
Runseal can export the nono audit session for a sandboxed command:
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm rebuild
audit: artifact
policy: |
fs:
read: [".", "./node_modules"]
write: ["./node_modules"]
network:
mode: blocked
When enabled, Runseal captures the new nono audit session after the command
finishes and uploads a runseal-audit artifact containing:
summary.mdAudit export runs before Runseal returns the sandboxed command's exit status, so failed or denied commands can still produce audit evidence.
gh CLI available on the runner for attestation verificationnonoRelease assets are expected to use this naming scheme:
runseal-v<version>-x86_64-unknown-linux-gnu.tar.gznono-v<version>-x86_64-unknown-linux-gnu.tar.gzSHA256SUMSmake ci
make ci runs make lint and make test — the same Rust checks as the CI workflow.
make lint # clippy + fmt check
make test # unit tests only
make fmt # format code
make audit # cargo audit (run make audit-install first)
Rust
79.9%
Shell
18.9%
Makefile
1.2%
Sealed execution environment for GitHub Actions. Stop supply chain attacks dead in their tracks.
See the codeRunseal was built to solve the problem of software supply chain attacks that are often triggered from GitHub Actions-based exploits.
We built runseal in response to the rise of supply chain attacks targeting GitHub Actions, where attackers often gain access to repository secrets and use them to exfiltrate data or deploy malicious code. By using nono's kernel-enforced sandboxing, runseal can protect sensitive files, secrets/tokens, and filter network access from untrusted or malicious code, while still allowing necessary software engineering operations through a flexible policy system.
From the same folks who brought you sigstore and nono.
[!WARNING] Early Alpha Software This project is in early alpha and under active development. Expect bugs, breaking changes, and incomplete features and a series of follow-up security audits. Therefore consider this an early preview until this note is updated.
name: Publish
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm publish
policy: |
fs:
read: ["."]
write: []
network:
mode: filtered
access:
npm:
secret: NPM_TOKEN
url: https://registry.npmjs.org
allow:
- PUT /**
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
In this example, npm publish can read the repository, cannot write to paths
not listed in fs.write, network access is limited to 'registry.npmjs.org' with no access to other hosts, and 'NPM_TOKEN' is retrieved from GitHub Secrets and a phantom credential is injected in its place. If the workflow is compromised and an attacker tries to exfiltrate the token or publish a malicious package, the attack would be blocked by the sandbox and the real token would remain safe and outside of the 'npm publish 'workflow.
Runseal policy is YAML passed through the policy input.
fs:
read:
- "."
- "$HOME/.cache/my-tool"
write:
- "./dist"
network:
mode: filtered
allow:
- api.github.com
access:
deploy:
secret: DEPLOY_TOKEN
url: https://api.example.com
allow:
- POST /v1/deployments
- GET /v1/deployments/*
fs.read lists paths the command can read. fs.write lists paths the command
can write.
Keep these narrow. For example, a deploy step often only needs to read ./dist
and a config file, and may not need write access at all.
fs:
read: ["./dist", "./fly.toml"]
write: []
Runseal expects network.mode: blocked or network.mode: filtered.
Add network.allow only for unauthenticated hosts the command must reach. Hosts
used by access grants are added to the generated nono profile automatically.
A network.allow list implies mode: filtered when mode is omitted;
combining it with an explicit mode: blocked is a configuration error.
network:
mode: filtered
allow:
- api.github.com
Each key under access is a named grant. secret is the environment variable
containing the real secret, url is the service base URL, and allow lists the
HTTP routes where the secret may be injected. Runseal masks the secret in logs,
writes it to a private file, removes it from the child environment, and
configures nono to inject it through the local proxy. Runseal's own copy of
the plaintext is zeroed out of memory once the credential file is written, and
on every error path in between.
access:
fly:
secret: FLY_API_TOKEN
url: https://api.machines.dev
allow:
- POST /v1/apps/*/machines
The sandboxed command receives a phantom credential for SDK compatibility. The real secret remains outside the sandbox and is only inserted by the proxy when the host and endpoint policy match.
inject.mode selects how the proxy injects the secret:
| Mode | Status | Behavior |
|---|---|---|
header | Default | Injected as an Authorization: Bearer header. |
basic_auth | Supported | Injected as HTTP basic auth credentials. |
url_path | Reserved | Rejected; runseal does not emit the required path_pattern yet. |
query_param | Reserved | Rejected; runseal does not emit the required query_param_name yet. |
access:
fly:
secret: FLY_API_TOKEN
url: https://api.machines.dev
inject:
mode: header
allow:
- POST /v1/apps/*/machines
allow restricts access use by HTTP method and path. Matching is allow-list
based. The method may be any HTTP method token (GET, DELETE, PROPFIND,
...) or the wildcard *; methods are uppercased before matching.
allow:
- POST /v1/apps/*/releases
- GET /v1/apps/*/status
Runseal relies on nono TLS interception for this. The nono proxy creates an
ephemeral trust bundle and injects standard CA environment variables into the
sandboxed process, so common HTTPS clients can connect through the proxy while
still allowing L7 policy enforcement.
Instead of writing policy in the workflow, a repository can commit a nono profile and point runseal at it:
- run: mkdir -p node_modules
- uses: nolabs-ai/runseal@main
with:
runseal-version: source
run: npm ci
profile: runseal.json
{
"meta": { "name": "my-repo" },
"filesystem": {
"read": ["./src", "./package.json"],
"write": ["./node_modules"]
},
"network": {
"allow_domain": ["registry.npmjs.org"]
}
}
runseal layers this profile underneath the profile it generates. Runseal validates it, rewrites relative paths to absolute ones, and writes out its own copy.
The profile input is mutually exclusive with policy, fs-read, fs-write,
and network. Setting both is an error rather than a precedence rule.
| Key | Meaning |
|---|---|
$schema | Schema hint for editors. |
meta.name, meta.description | Documentation for the profile. |
filesystem.read, filesystem.write | Directories the command may read or write. |
filesystem.read_file, filesystem.write_file | Single files the command may read or write. |
filesystem.deny | Paths to deny, on top of everything else. |
network.allow_domain | Hosts the command may reach. |
network.block | Only true. Network is already blocked by default. |
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm test
policy: |
fs:
read: [".", "./node_modules"]
write: ["./coverage"]
network:
mode: blocked
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm ci
policy: |
fs:
read: ["."]
write: ["./node_modules"]
network:
mode: filtered
allow:
- registry.npmjs.org
- uses: nolabs-ai/runseal@v0.3.4
with:
run: ./scripts/deploy.sh
policy: |
fs:
read: ["./dist", "./deploy.yaml"]
write: []
network:
mode: filtered
access:
deploy:
secret: DEPLOY_TOKEN
url: https://deploy.example.com
allow:
- POST /v1/releases
- GET /v1/releases/*
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
| Input | Required | Default | Description |
|---|---|---|---|
run | Yes | none | Command to execute inside the sandbox. |
policy | No | empty | Runseal policy YAML. Prefer this for new workflows. |
profile | No | empty | Path to a nono profile in the repository, relative to the workspace. Mutually exclusive with policy, fs-read, fs-write, and network. See Repo Profile. |
fs-read | No | empty | Comma-separated read paths when policy is not set. |
fs-write | No | empty | Comma-separated write paths when policy is not set. |
network | No | empty (blocked) | Network policy when policy is not set: blocked or comma-separated domains. An empty value means blocked. filtered is only valid as network.mode inside policy and is rejected here. |
runseal-version | No | 0.3.4 | Runseal release version to install. Accepts v0.1.0 or 0.1.0. |
nono-version | No | pinned | nono release version to install. Defaults to the Dependabot-managed pin in .github/nono-version/Cargo.toml. Accepts v0.1.0 or 0.1.0. |
verify-attestations | No | true | Verify GitHub artifact attestations for downloaded release assets. |
audit | No | false | Set to artifact or true to upload nono audit evidence as a GitHub Actions artifact. |
Runseal can export the nono audit session for a sandboxed command:
- uses: nolabs-ai/runseal@v0.3.4
with:
run: npm rebuild
audit: artifact
policy: |
fs:
read: [".", "./node_modules"]
write: ["./node_modules"]
network:
mode: blocked
When enabled, Runseal captures the new nono audit session after the command
finishes and uploads a runseal-audit artifact containing:
summary.mdAudit export runs before Runseal returns the sandboxed command's exit status, so failed or denied commands can still produce audit evidence.
gh CLI available on the runner for attestation verificationnonoRelease assets are expected to use this naming scheme:
runseal-v<version>-x86_64-unknown-linux-gnu.tar.gznono-v<version>-x86_64-unknown-linux-gnu.tar.gzSHA256SUMSmake ci
make ci runs make lint and make test — the same Rust checks as the CI workflow.
make lint # clippy + fmt check
make test # unit tests only
make fmt # format code
make audit # cargo audit (run make audit-install first)
Rust
79.9%
Shell
18.9%
Makefile
1.2%