Ctrl K
ComunidadGuías…

docs/src/security.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Security Architecture

Moltis is designed with a defense-in-depth security model. This document
explains the key security features and provides guidance for production
deployments.

## Overview

Moltis runs AI agents that can execute code and interact with external systems.
This power requires multiple layers of protection:

1. **Human-in-the-loop approval** for dangerous commands
2. **Sandbox isolation** for command execution
3. **Channel authorization** for external integrations
4. **Rate limiting** to prevent resource abuse
5. **Scope-based access control** for API authorization

For marketplace-style skill/plugin hardening (trust gating, provenance pinning,
drift re-trust, dependency install guards, kill switch, audit log), see
[Third-Party Skills Security](skills-security.md).

## Certificate trust updates

Certificate-verification fixes and bundled root-store updates require new
binaries. Keep both the Moltis daemon and Moltis Desktop current; they have
separate dependency lockfiles. Also apply operating-system trust-store updates
and refresh container images, since some integrations use native TLS libraries.

For the October 2026 CA distrust incident (tracked by Go as CVE-2026-111943),
the lockfiles include `rustls-webpki` 0.103.16, which rejects chains containing
eight distrusted public keys, and `webpki-roots` / `webpki-root-certs` 1.0.10,
which remove the emSign ECC Root CA – G3 trust anchor. Reissuing a certificate
under a different issuer does not bypass the patched verifier's key blocklist.
See the [verifier release](https://github.com/rustls/webpki/releases/tag/v/0.103.16)
and [root-store release](https://github.com/rustls/webpki-roots/releases/tag/v/1.0.10).

This does not establish uniform protection across every TLS backend. The older
Rustls verifier used by Discord and the APNs courier has no equivalent key
blocklist backport; its `webpki-roots` 0.26 compatibility crate does use the
updated 1.0.10 root list. OS-native verification and OpenSSL-backed integrations
depend on their own trust and revocation policies. Explicit desktop certificate
pins remain a separate trust mechanism. These paths need separate verification.

## Command Execution Approval

By default, Moltis requires explicit user approval before executing potentially
dangerous commands. This "human-in-the-loop" design ensures the AI cannot take
destructive actions without consent.

### How It Works

When the agent wants to run a command:

1. The command is analyzed against approval policies
2. If approval is required, the user sees a prompt in the UI. Channel-backed
   sessions also receive a notification in the originating channel so the run
   does not stall silently.
3. The user can approve, deny, or modify the command
4. Only approved commands execute

For channel-backed sessions, operators can also use `/approvals` to list the
pending requests for the current session, then `/approve N` or `/deny N`
directly from Telegram or WhatsApp.

### Approval Policies

Configure approval behavior in `moltis.toml`:

```toml
[tools.exec]
approval_mode = "always"  # always require approval
# approval_mode = "smart" # auto-approve safe commands (default)
# approval_mode = "never" # dangerous: never require approval
```

**Recommendation**: Keep `approval_mode = "smart"` (the default) for most use
cases. Only use `"never"` in fully automated, sandboxed environments.

### Built-in Dangerous Command Blocklist

Even with `approval_mode = "never"` or `security_level = "full"`, Moltis
maintains a safety floor: a hardcoded set of regex patterns for the most
critical destructive commands (e.g. `rm -rf /`, `git reset --hard`,
`DROP TABLE`, `mkfs`, `terraform destroy`). Matching commands always require
approval regardless of configuration.

Users can override specific patterns by adding matching entries to their
`allowlist` in `moltis.toml`. The blocklist only applies to host execution;
sandboxed commands are already isolated.

### Destructive Command Guard (dcg)

For broader coverage beyond the built-in blocklist, install the
[Destructive Command Guard](https://github.com/Dicklesworthstone/destructive_command_guard)
(dcg) as a hook. dcg adds 49+ pattern categories including heredoc/inline-script
scanning, database, cloud, and infrastructure patterns.

See [Hooks: Destructive Command Guard](hooks.md#recommended-destructive-command-guard-dcg)
for setup instructions.

dcg complements (does not replace) sandbox isolation and the approval system.

## Sandbox Isolation

Commands execute inside isolated containers (Docker or Apple Container) by
default. This protects your host system from:

- Accidental file deletion or modification
- Malicious code execution
- Resource exhaustion (memory, CPU, disk)

See [sandbox.md](sandbox.md) for backend configuration.

### Resource Limits

```toml
[tools.exec.sandbox.resource_limits]
memory_limit = "512M"
cpu_quota = 1.0
pids_max = 256
```

### Network Isolation

Sandbox containers have no network access by default (`no_network = true`).

For tasks that need internet access, [trusted network mode](trusted-network.md)
provides a proxy-filtered allowlist — only connections to explicitly approved
domains are permitted. All requests (allowed and denied) are recorded in the
network audit log for review.

### Managed Files privacy boundary

[Managed Files](managed-files.md) are one shared tree at `<data_dir>/files`,
not a per-session store. The deliberate product policy is that **all agent
sessions, including channel-originated sessions, can read that tree whenever
their available tools and sandbox mount permit it**. Channel sessions remain
subject to channel authorization and tool policy, but there is no additional
Files-level partition after access is permitted.

This is especially important for health information and other private data. Do
not upload such files unless every agent session and channel operator with tool
access may read them. To prevent local container sandboxes from seeing the
tree, set:

```toml
[tools.exec.sandbox]
managed_files_mount = "none"
```

The default is `ro`, which prevents sandbox writes but still permits reads.
`rw` also permits agent mutations. Docker, Podman, and Apple Container support
these mount modes initially; WASM and remote backends do not yet mount Managed
Files. Host-side tools need separate restriction through tool/channel policy
and, for the native filesystem tools, `[tools.fs]` path rules.

The Settings UI and `/api/files/*` remain behind normal gateway
authentication. Read operations require an API identity with `operator.read`
(or admin), while uploads, renames, moves, and deletes require
`operator.write` (or admin). The service rejects path traversal, symbolic
links, and special files, caps each upload at 1 GiB, and forces browser
downloads to use attachment disposition.

## Channel Authorization

Channels (Telegram, Slack, etc.) allow external parties to interact with your
Moltis agent. This requires careful access control.

### Sender Allowlisting

When a new sender contacts the agent through a channel, they are placed in a
pending queue. You must explicitly approve or deny each sender before they can
interact with the agent.

```
UI: Settings > Channels > Pending Senders
```

### Operators vs. Allowed Senders

Being allowed to message the bot is **not** permission to run commands on the
host. Shell access is gated separately by each account's `operators` list:

| | Guest | Operator in proven DM | Operator in shared/unknown chat |
|---|---|---|---|
| Chat with the agent | yes | yes | yes |
| Room-local slash commands | yes | yes | yes |
| Privileged slash commands | no | yes | no |
| `/sh`, shell command mode | no | yes | no |
| Agent tools and external agents | no | yes | no |
| Owner memory, profile, project context | no | yes | no |

Denial is enforced before command dispatch, before shell command-mode rewrite,
and with a deny-all request policy. Host-owned tool audience metadata provides
an additional ceiling, and name-based configuration cannot widen the deny-all
channel policy. Untrusted turns also omit private prompt and memory context.

With no `operators` list, **no one** has privileged channel access. Configure it under
**Settings → Channels → Edit → Operators**, or see
[Channels → Operators](./channels.md#operators-privileged-senders).

```admonish danger title="Public channels"
On a public Discord guild or any shared group chat, every member may clear the
access gate. Every turn therefore runs without tools or owner-private context,
even when an operator sends it. `/sh` and privileged commands are also denied;
move privileged work to an operator DM or the authenticated web UI. Adapters
that cannot prove a chat is direct treat it as shared.
```

Microsoft Teams and Matrix currently fall into that conservative category
even for actual DMs. Their normal chat remains available, but tools,
private context, `/sh`, location updates, and privileged commands are denied.

Telephony is in that category permanently: a call's only identifier is the
caller number, and caller ID is spoofable, so it can never authenticate an
operator. Adding a phone number to `operators` grants nothing.

Sessions bound to a chat — including a working session an operator moved there
with `/attach` — are untrusted for *every* non-gateway caller, so cron jobs,
webhooks, and `sessions_send` also run tool-free against them. Release the
binding to restore full access; see
[Channels → Channel-bound sessions](./channels.md#channel-bound-sessions).

### Channel Isolation

Channels run in isolated sessions by default. A malicious message from one
channel cannot affect another channel's session or the main UI session.

## Cron Job Security

Scheduled tasks (cron jobs) can run agent turns automatically. Security
considerations:

### Rate Limiting

To prevent prompt injection attacks from rapidly creating many cron jobs:

```toml
[cron]
rate_limit_max = 10           # max jobs per window
rate_limit_window_secs = 60   # window duration (1 minute)
```

This limits job creation to 10 per minute by default. System jobs (like
heartbeat) bypass this limit.

### Job Notifications

When cron jobs are created, updated, or removed, Moltis broadcasts events:

- `cron.job.created` - A new job was created
- `cron.job.updated` - An existing job was modified
- `cron.job.removed` - A job was deleted

Monitor these events to detect suspicious automated job creation.

### Sandbox for Cron Jobs

Cron job execution uses sandbox isolation by default:

```toml
# Per-job configuration
[cron.job.sandbox]
enabled = true              # run in sandbox (default)
# image = "custom:latest"   # optional custom image
```

## Identity Protection

The agent's identity fields (name, emoji, creature, vibe) are stored in `IDENTITY.md`
YAML frontmatter at the workspace root (`data_dir`).
User profile fields are stored in `USER.md` YAML frontmatter at the same location.
The personality text is stored separately in `SOUL.md` at the workspace root (`data_dir`).
Tool guidance is stored in `TOOLS.md` at the workspace root (`data_dir`) and is injected
as workspace context in the system prompt.
Modifying identity requires the `operator.write` scope, not just `operator.read`.

This prevents prompt injection attacks from subtly modifying the agent's
personality to make it more compliant with malicious requests.

## API Authorization

The gateway API uses role-based access control with scopes:

| Scope | Permissions |
|-------|-------------|
| `operator.read` | View status, list jobs, read history |
| `operator.write` | Send messages, create jobs, modify configuration |
| `operator.admin` | All permissions (includes all other scopes) |
| `operator.approvals` | Handle command approval requests |
| `operator.pairing` | Manage device/node pairing |

### API Keys

API keys authenticate external tools and scripts connecting to Moltis. Keys
**must specify at least one scope** — keys without scopes are denied access
(least-privilege by default).

#### Creating API Keys

**Web UI**: Settings > Security > API Keys

1. Enter a label describing the key's purpose
2. Select the required scopes
3. Click "Generate key"
4. **Copy the key immediately** — it's only shown once

**CLI**:

```bash
# Scoped key (comma-separated scopes)
moltis auth create-api-key --label "Monitor" --scopes "operator.read"
moltis auth create-api-key --label "Automation" --scopes "operator.read,operator.write"
moltis auth create-api-key --label "CI pipeline" --scopes "operator.admin"
```

#### Using API Keys

Pass the key in the `connect` handshake over WebSocket:

```json
{
  "method": "connect",
  "params": {
    "client": { "id": "my-tool", "version": "1.0.0" },
    "auth": { "api_key": "mk_abc123..." }
  }
}
```

Or use Bearer authentication for REST API calls:

```
Authorization: Bearer mk_abc123...
```

#### Scope Recommendations

| Use Case | Recommended Scopes |
|----------|-------------------|
| Read-only monitoring | `operator.read` |
| Automated workflows | `operator.read`, `operator.write` |
| Approval handling | `operator.read`, `operator.approvals` |
| Full automation | `operator.admin` |

**Best practice**: Use the minimum necessary scopes. If a key only needs to
read status and logs, don't grant `operator.write`.

#### Backward Compatibility

Existing API keys created without scopes will be **denied access** until
scopes are added. Re-create keys with explicit scopes to restore access.

## Encryption at Rest

Sensitive data in the SQLite database (environment variables containing
API keys, tokens, etc.) is encrypted at rest using XChaCha20-Poly1305.
The encryption key is derived from the user's password via Argon2id.

The vault initializes when a first password is set (during setup or later
in Settings > Authentication), unseals automatically on login, and
re-seals on server restart. A recovery key is provided at initialization
for emergency access.

When the vault is sealed, a middleware layer blocks vault-protected API
requests with `423 Locked`. Session history and bootstrap endpoints remain
available because those payloads are not yet encrypted at rest.

For full details on the key hierarchy, vault states, API endpoints, and
cryptographic parameters, see [Encryption at Rest (Vault)](vault.md).

## Network Security

### TLS Encryption

HTTPS is enabled by default with auto-generated certificates:

```toml
[tls]
enabled = true
auto_generate = true
```

For production, use certificates from a trusted CA or configure custom
certificates. The auto-generated certificate is intended for localhost and
private-network access; importing the generated CA does not make the certificate
valid for public IP addresses or domains that are not listed in its SANs. IP
address URLs require a certificate with that address as an IP SAN; set
`tls.public_ip` to include a direct-access VPS IP in Moltis' auto-generated
certificate. Regular public TLS deployments should use a domain name. For VPS and other
internet-facing deployments, terminate TLS at a reverse proxy or configure
`tls.cert_path` and `tls.key_path` with a certificate issued for your public
hostname.

### Origin Validation

WebSocket connections validate the `Origin` header to prevent cross-site
WebSocket hijacking (CSWSH). Connections from untrusted origins are rejected.

### SSRF Protection

The `web_fetch` tool resolves DNS and blocks requests to private IP ranges
(loopback, RFC 1918, link-local, CGNAT). This prevents server-side request
forgery attacks.

To allow access to trusted private networks (e.g. Docker sibling containers),
add their CIDR ranges to `ssrf_allowlist`:

```toml
[tools.web.fetch]
ssrf_allowlist = ["172.22.0.0/16"]
```

**Warning:** Only add networks you trust. The allowlist bypasses SSRF protection
for the listed ranges. Never add cloud metadata ranges (`169.254.169.254/32`)
unless you understand the risk.

## Authentication

Moltis uses a unified auth gate that applies a single `check_auth()`
function to every request. This prevents split-brain bugs where different
code paths disagree on auth status.

For full details — including the decision matrix, credential types, API
key scopes, session management endpoints, and WebSocket auth — see the
dedicated [Authentication](authentication.md) page.

### Three-Tier Model (summary)

| Tier | Condition | Behaviour |
|------|-----------|-----------|
| **1** | Password/passkey is configured | Auth **always** required (any IP) |
| **2** | No credentials + direct local connection | Full access (dev convenience) |
| **3** | No credentials + remote/proxied connection | Onboarding only (setup code required) |

### Node Identity (Ed25519 TOFU)

Remote nodes authenticate using Ed25519 challenge-response, following the same
Trust On First Use (TOFU) model as SSH:

1. **First connection**: The operator opens the pairing window from the Nodes UI
   or with `moltis node pairing enable`. The node generates an Ed25519 keypair
   and presents its public key to the gateway. The operator verifies the
   fingerprint and approves the pairing (via the web UI or `moltis node approve`).
2. **Subsequent connections**: The gateway sends a random 32-byte nonce. The node
   signs it with its private key. The gateway verifies the signature against the
   stored public key. No shared secret crosses the wire.
3. **Key pinning**: Once approved, the public key is pinned to the device ID. If
   the same device reconnects with a different key, the connection is rejected
   and a `node.security.key-mismatch` alert is broadcast to operators.
4. **Revocation**: Revoked keys are kept in the database so they cannot be
   re-paired without explicit operator action.

The private key (`~/.moltis/node_key`) is stored with mode 0600 and never
leaves the node. The gateway stores only public keys.

Pairing is disabled by default. Keep it disabled except while onboarding a new
node, then close the window with `moltis node pairing disable`.

## HTTP Endpoint Throttling

Moltis includes built-in per-IP endpoint throttling to reduce brute force
attempts and traffic spikes, but only when auth is required for the current
request.

Throttling is bypassed when a request is already authenticated, when auth is
explicitly disabled, or when setup is incomplete and local Tier-2 access is
allowed.

### Default Limits

| Scope | Default |
|------|---------|
| `POST /api/auth/login` | 5 requests per 60 seconds |
| Other `/api/auth/*` | 120 requests per 60 seconds |
| Other `/api/*` | 180 requests per 60 seconds |
| `/ws/chat` upgrade | 30 requests per 60 seconds |
| `/ws` upgrade | 30 requests per 60 seconds |

### When Limits Are Hit

- API endpoints return `429 Too Many Requests`
- Responses include `Retry-After`
- JSON responses include `retry_after_seconds`

### Reverse Proxy Behavior

When `MOLTIS_BEHIND_PROXY=true`, throttling is keyed by forwarded client IP
headers (`X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`) instead of the
direct socket address.

### Production Guidance

Built-in throttling is the first layer. For internet-facing deployments, add
edge rate limits at your reverse proxy or WAF as a second layer (IP reputation,
burst controls, geo rules, bot filtering).

## Reverse Proxy Deployments

Running Moltis behind a reverse proxy (Caddy, nginx, Traefik, etc.)
requires understanding how authentication interacts with loopback
connections.

### The problem

When Moltis binds to `127.0.0.1` and a proxy on the same machine
forwards traffic to it, **every** incoming TCP connection appears to
originate from `127.0.0.1` — including requests from the public
internet.  A naive "trust all loopback connections" check would bypass
authentication for all proxied traffic.

This is the same class of vulnerability as
[CVE-2026-25253](https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq),
which allowed one-click remote code execution on OpenClaw through
authentication token exfiltration and cross-site WebSocket hijacking.

### How Moltis handles it

Moltis uses the per-request `is_local_connection()` check described
above.  Most reverse proxies add forwarding headers or change the
`Host` header, which automatically triggers the "remote" classification.

For proxies that **strip all signals** (e.g. a bare nginx `proxy_pass`
that rewrites `Host` to the upstream address and adds no `X-Forwarded-For`),
use the `MOLTIS_BEHIND_PROXY` environment variable as a hard override:

```bash
MOLTIS_BEHIND_PROXY=true moltis
```

When this variable is set, **all** connections are treated as remote —
no loopback bypass, no exceptions.

### Deploying behind a proxy

1. **Set `MOLTIS_BEHIND_PROXY=true`** if your proxy does not add
   forwarding headers (safest option — eliminates any ambiguity).

2. **Set a password or register a passkey** during initial setup.
   Once a password is configured (Tier 1), authentication is required
   for all traffic regardless of `is_local_connection()`.

3. **WebSocket proxying** must preserve browser origin host info
   (`Host`, or `X-Forwarded-Host` if `Host` is rewritten). Moltis
   validates same-origin on WebSocket upgrades to prevent cross-site
   WebSocket hijacking (CSWSH).

4. **TLS termination** should happen at the proxy. Run Moltis with
   `--no-tls` (or `MOLTIS_NO_TLS=true`) in this mode.

   If your browser is being redirected to `https://<domain>:13131`,
   Moltis TLS is still enabled while your proxy upstream is plain HTTP.

5. **Advanced TLS upstream mode** (optional): if your proxy connects to
   Moltis using HTTPS upstream (or TCP TLS passthrough), you may keep
   Moltis TLS enabled. Set `MOLTIS_ALLOW_TLS_BEHIND_PROXY=true` to
   acknowledge this non-default setup.

### Nginx (direct config example)

If HTTP works but WebSockets fail, make sure your location block includes
`proxy_http_version 1.1;` and upgrade headers.

```nginx
location / {
    proxy_pass http://172.17.0.1:13131;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP $remote_addr;

    # WebSocket upgrade support
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
}
```

If you use `$connection_upgrade`, define it once in the `http {}` block:

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
```

### Nginx Proxy Manager (known-good headers)

If WebSockets fail behind NPM while HTTP works, ensure:

- Moltis runs with `MOLTIS_BEHIND_PROXY=true`
- For standard edge TLS termination, Moltis runs with `--no-tls`
- NPM preserves browser host/origin context

Use this in NPM's **Advanced** field:

```nginx
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```

**Why `$http_host` instead of `$host`?** Nginx's `$host` strips the port,
while `$http_host` preserves it. Moltis validates that the WebSocket `Origin`
header matches the `Host` header (including port). On non-standard ports
(e.g., `:444` instead of `:443`), using `$host` causes a mismatch and
WebSocket connections are rejected with "cross-origin WebSocket upgrade".

Upstream scheme guidance:

- **Edge TLS termination (most setups)**: proxy to `http://<moltis-host>:13131`
  with Moltis started using `--no-tls`
- **HTTPS upstream / TLS passthrough**: proxy to `https://<moltis-host>:13131`
  and set `MOLTIS_ALLOW_TLS_BEHIND_PROXY=true`

### Passkeys Behind Proxies (Host Changes)

WebAuthn passkeys are bound to an RP ID (domain identity), not just the
server process. In practice:

- If users move from one hostname to another, old passkeys for the old host
  will not authenticate on the new host.
- If a proxy rewrites `Host` and does not preserve browser host context,
  passkey routes can fail with "no passkey config for this hostname".

For stable proxy deployments, set your public URL in `moltis.toml`:

```toml
[server]
external_url = "https://chat.example.com"
```

Moltis derives the WebAuthn RP ID (domain) and origin from this URL
automatically. The `MOLTIS_EXTERNAL_URL` environment variable takes
precedence over the config field, which is useful for container
deployments:

```bash
MOLTIS_BEHIND_PROXY=true
MOLTIS_NO_TLS=true
MOLTIS_EXTERNAL_URL=https://chat.example.com
```

For fine-grained control (e.g. when the RP ID differs from the URL host),
the existing env vars still work and take precedence after
`external_url`:

```bash
MOLTIS_WEBAUTHN_RP_ID=chat.example.com
MOLTIS_WEBAUTHN_ORIGIN=https://chat.example.com
```

Migration guidance when changing host/domain:

1. Keep password login enabled during migration.
2. Deploy with the new `server.external_url` (or env var equivalent).
3. Ask users to register a new passkey on the new host.
4. Remove old passkeys after new-host login is confirmed.

## Production Recommendations

### 1. Enable Authentication

By default, Moltis requires a password when accessed from non-localhost:

```toml
[auth]
disabled = false  # keep this false in production
```

### 2. Use Sandbox Isolation

Always run with sandbox enabled in production:

```toml
[tools.exec.sandbox]
enabled = true
backend = "auto"  # uses strongest available
```

### 3. Limit Rate Limits

Tighten rate limits for untrusted environments:

```toml
[cron]
rate_limit_max = 5
rate_limit_window_secs = 300  # 5 per 5 minutes
```

### 4. Review Channel Senders

Regularly audit approved senders and revoke access for unknown parties.

### 5. Monitor Events

Watch for these suspicious patterns:

- Rapid cron job creation
- Identity modification attempts
- Unusual command patterns in approval requests
- New channel senders from unexpected sources

### 6. Network Segmentation

Run Moltis on a private network or behind a reverse proxy with:

- IP allowlisting
- Rate limiting
- Web Application Firewall (WAF) rules

### 7. Keep Software Updated

Subscribe to security advisories and update promptly when vulnerabilities are
disclosed.

## Release Signing and Verification

All release artifacts are signed with three independent methods:

1. **[GitHub artifact attestations](https://github.com/moltis-org/moltis/attestations)**
   (automated in CI) — cryptographic provenance records tied to the repository,
   workflow, and commit SHA; provides SLSA v1.0 Build Level 2 guarantees;
   verifiable with `gh attestation verify`
2. **Sigstore keyless signing** (automated in CI) — proves the artifact was
   built by the `moltis-org/moltis` GitHub Actions pipeline; recorded in
   Sigstore's Rekor transparency log
3. **GPG signing** (maintainer's YubiKey hardware key) — proves a specific
   maintainer authorized the release

Checksums (SHA-256 and SHA-512) are generated for every artifact.

See [Release Verification](release-verification.md) for detailed verification
instructions, artifact file extensions, and maintainer signing workflow.

## Reporting Security Issues

Report security vulnerabilities privately to the maintainers. Do not open
public issues for security bugs.

See the repository's SECURITY.md for contact information.