SysKnife handles privileged local system operations, so security bugs matter.
Please do not open a public issue for security-sensitive problems.
Use GitHub's private vulnerability reporting flow for this repository if it is available. If a private report is not available in your environment, contact the maintainers privately before publishing details.
- a short summary of the issue
- affected action family or component
- exact reproduction steps
- expected vs actual behavior
- logs or traces, with secrets removed
- impact assessment
We will:
- acknowledge the report
- triage the issue
- work on a fix
- coordinate disclosure timing with the reporter
- Never paste secrets into issues or pull requests.
- Never disclose a zero-day publicly before coordination.
- Treat approval, authorization, and transaction data as sensitive.
SysKnife uses a layered enforcement model. Every layer is independent; a bypass of one does not bypass the others.
Every intent string is checked before it is forwarded to the LLM provider:
- Length cap (
INTENT_MAX_BYTES = 2048): intents whose byte length exceeds 2048 are rejected withPlanningError::IntentTooLong. Oversized payloads are almost always copy-paste accidents or injection attempts. - Secret scan: the same pattern list used to guard the preferences
file (
SENSITIVE_PATTERNS+SENSITIVE_PREFIXESincrates/sysknife-brain/src/prefs.rs) is applied to the raw intent. Intents containing API key prefixes (sk-,ghp_,xoxb-, …), the wordspassword,token,api_key, and similar are rejected withPlanningError::IntentContainsSensitiveDatabefore any network call is made. - Rate limit (
RateLimiterincrates/sysknife-brain/src/rate_limit.rs,DEFAULT_MAX_RPM = 20inplanner.rs): a sliding 60-second window caps planning requests per session. When the window is full,plan_intentandsummarizereturnPlanningError::RateLimitExceeded { retry_after_secs }before any network call is made. The default limit is 20 requests per minute, applied automatically byLlmPlanner::from_config; override withSYSKNIFE_MAX_RPM(must be ≥ 1). Call timestamps are persisted to$XDG_DATA_HOME/sysknife/rate-limit.logso the limit survives process restarts.
The ActionName newtype in crates/sysknife-brain/src/action_name.rs
validates every action name proposed by the LLM against KNOWN_ACTIONS
at the type boundary. An action name not in that list (e.g.
"RunShellCommand") is rejected with UnknownActionName and the
planning loop returns an error. The LLM cannot invent actions.
The daemon resolves the caller's Linux group membership via
SO_PEERCRED on the Unix socket and maps it to a CallerRole:
| Group | Role | Can call |
|---|---|---|
sysknife-observer |
Observer | Read-only actions |
sysknife-dev |
Dev | Read + medium-risk mutations |
sysknife-admin or wheel |
Admin | All including rpm-ostree, reboot |
sysknife-boot |
Boot | Everything (reserved for boot-time automation) |
The per-action minimum role is a compile-time exhaustive match in
crates/sysknife-daemon/src/policy.rs. Unknown actions return None and
are denied unconditionally. The caller's role is never supplied by the
client — it is always derived server-side from kernel credentials.
Every mutating action requires a preview→approve→execute round-trip:
- The client requests a preview; the daemon records the action + canonical params and returns a transaction ID.
- The user approves. MCP users run
sysknife approve <transaction-id>in a real terminal, which reloads the daemon-authoritative preview before asking for confirmation and requires the exact action name for high-risk work. - The daemon derives a domain-separated Ed25519 receipt from the transaction ID and request hash, then stores only its SHA-256 commitment. That commitment is part of the immutable signed transaction row.
- Execute must present the transaction ID, exact action and params, and the receipt. The daemon atomically consumes the receipt before running anything.
A receipt cannot be replayed and expires with its queued preview after 15 minutes. MCP exposes no tool that can mint a receipt, so an agent cannot turn its own plan into an executable request without the separate terminal step.
This boundary protects against an untrusted MCP agent, not against arbitrary malware already running as the same Linux user. A same-user process that can connect directly to the daemon IPC endpoint can invoke the approval request; Unix permissions, role groups, and host security remain part of the trust model.
Concurrent execute requests for the same transaction are blocked by an
database transaction that verifies the receipt digest, changes the queued
transaction to running, and marks the receipt consumed. Only the first request
wins; concurrent or replayed requests get stale_approval.
Receipt digests are 256-bit commitments to 512-bit Ed25519 signatures. The atomic claim compares the digest inside the database transaction; SQL engines do not promise constant-time string comparison, but the high-entropy, single-use, 15-minute bearer value makes timing recovery impractical. The daemon uses constant-time comparison when validating the signed commitment before issuing a receipt.
The daemon socket lives at /run/sysknife/daemon.sock in a directory owned
sysknife:sysknife 0750. A user needs two group memberships to use SysKnife:
sysknifegroup — grants access to the socket directory. Without this the connection is refused before any authentication happens.- A role group — determines what the user can do once connected.
Omitting this falls back to
Observer(read-only queries only).
# Grant a user read-only access:
sudo usermod -aG sysknife,sysknife-observer alice
# Grant a user medium-risk access (services, containers, SSH keys, flatpaks):
sudo usermod -aG sysknife,sysknife-dev alice
# Grant a user full access (rpm-ostree, reboot, kernel arguments):
sudo usermod -aG sysknife,sysknife-admin aliceGroup changes take effect on next login. To apply without logging out:
exec newgrp sysknifeMembers of the wheel group are automatically treated as sysknife-admin
by the daemon — no explicit sysknife-admin membership is needed for
existing sudo users. They still need the sysknife group to reach the
socket.
The four role groups (sysknife-observer, sysknife-dev, sysknife-admin,
sysknife-boot) are created at install time by systemd-sysusers via
packaging/sysknife-sysusers.conf.
Every plan rejected by the brain's safety fence (unknown action name, bad risk level, etc.) is appended as a JSON line to:
$XDG_DATA_HOME/sysknife/safety-audit.jsonl
~/.local/share/sysknife/safety-audit.jsonl (fallback)
Each entry contains timestamp, event, intent, reason, and
raw_plan.
Every daemon execution — previewed, approved, running, succeeded, failed, or
rolled back — is recorded in the configured transaction database. SQLite is
the default at SYSKNIFE_DATABASE_PATH (packaged default:
/var/lib/sysknife/daemon.sqlite); PostgreSQL is available for centralized,
off-host durability. Both backends store the same signed hash-chain fields.
The transaction database is authoritative. Query it with sysknife history
and verify its chain with sysknife audit verify. See
docs/storage-cloud.md for backup, restore, and
PostgreSQL migration operations.
On systemd hosts, every safety fence rejection is also forwarded to the systemd journal as a structured log entry with these fields:
SYSKNIFE_EVENT=safety_fence_rejection
SYSKNIFE_INTENT=<the user's original intent>
SYSKNIFE_REASON=<why the fence triggered>
SYSKNIFE_TIMESTAMP=<RFC 3339 UTC timestamp matching the JSONL entry>
PRIORITY=4 (LOG_WARNING)
SYSLOG_IDENTIFIER=sysknife-brain
Query live:
journalctl -f SYSKNIFE_EVENT=safety_fence_rejection
journalctl SYSLOG_IDENTIFIER=sysknife-brain --since todayThe daemon also emits an audit-chain watermark to journald after transaction writes and can forward transaction events as RFC 5424 syslog over UDP. These paths are best effort: UDP may lose, reorder, or duplicate events, and neither path replaces the transaction database or its backups.
systemd's Forward Secure Sealing (FSS) signs each journal entry with a key that rotates forward — retrospective forgery is computationally infeasible, and modification of any entry is detectable offline.
Enable FSS once at deployment time:
sudo journalctl --setup-keysVerify log integrity at any time:
sudo journalctl --verifyWithout FSS enabled, journald entries are still useful for querying but are not tamper-evident. The JSONL file on disk is never cryptographically protected regardless of FSS status.
These are acknowledged gaps tracked as open issues. They do not represent exploitable vulnerabilities in normal use — the downstream enforcement layers cap their blast radius — but they are relevant for security certification work.
| Gap | Issue | Notes |
|---|---|---|
| Tool output injection | #98 | query_* results re-enter the LLM context unsanitized. A crafted service description or package name could attempt prompt injection. Impact is bounded by Layer 2–5. |
| Action param validation | — | Action params are typed per-handler but not validated at a shared schema boundary. A compromised LLM could propose valid action + malicious params (e.g. AddAuthorizedKey with an attacker-controlled key). |
| UDP audit forwarding | — | External RFC 5424 forwarding is best effort and provides no delivery acknowledgement. Use the transaction database and tested backups as the durable record. |