Your Linux sysadmin co-pilot. Plain language in. Typed plan out. You approve. Daemon executes.

What SysKnife does
You describe a task in plain language. SysKnife asks an LLM to turn it into a typed plan — a list of named actions with formal risk levels. You review the plan, approve it, and the daemon executes it step by step with live output and automatic rollback on failure.
$ sysknife "install neovim and make sure it starts on login"
Plan (2 steps)
──────────────────────────────────────────────────────────
1 AddLayeredPackage neovim risk: high
2 SetServiceEnabled neovim.service risk: medium
──────────────────────────────────────────────────────────
Approve? [y/N]
The AI cannot run a shell command. It can only propose typed actions. The daemon is the only process that touches your system — and only after you say yes.
Why not just paste the shell command the AI gives you?
Because you find out what it did after. There is no record, no rollback, and no way to verify the AI proposed the minimal change rather than a sledgehammer.
SysKnife gives you:
- Typed actions — not shell strings.
AddLayeredPackage neovimnotrpm-ostree install neovim && ... - Risk levels — Low (read-only), Medium (reversible), High (irreversible, access-control, or reboot-required)
- Preview before execution — see the exact commands before they run
- Automatic rollback — if a high-risk action fails, the daemon reverses what it can
- Immutable audit trail — every execution is Ed25519-signed and hash-chained
How it works
┌─────────────────┐ plan ┌──────────────────┐ approve ┌─────────────────┐
│ sysknife-brain │ ────────► │ sysknife-shell │ ────────► │ sysknife-daemon │
│ (unprivileged) │ │ (approval gate) │ │ (root, locked) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
LLM + tools you review here executes + audits
| Component | Privilege | Job |
|---|---|---|
| brain | none | Talks to the LLM, proposes a typed plan |
| shell | user | Shows you the plan, collects your approval |
| daemon | root | Executes approved actions, writes the audit log |
The brain proposes but cannot touch the system. The daemon executes but cannot be reached without an approved plan.
Fastest path: use via your AI coding tool (MCP)
MCP is how most people use SysKnife in 2026 — it's the dominant way AI tools talk to real systems, and Claude Code, Cursor, and Codex CLI all support it. One command wires it up:
npx sysknife-setup
The demo above shows the whole loop: plan in chat, approve in a terminal, execute with a one-time receipt. See the MCP Server guide for full setup and the approval-gate hook.
Prefer the terminal? The CLI is a first-class path
No IDE and no MCP client — the same planner, approval gate, and Ed25519 audit chain, driven straight from your shell. This is a fully supported way to run SysKnife, not an afterthought.

ℹ️ Distro support
Ubuntu 24.04 is validated with the full 65-story VM suite. Ubuntu 22.04 and 26.04 are smoke-tested. Fedora Atomic is supported by the rpm-ostree action family, but a current Silverblue 44 VM run is a release gate. Plain Fedora remains experimental until the
dnfaction family ships. See Distro Support for the full matrix.
git clone https://github.com/lacs-project/sysknife
cd sysknife && make build && sudo make install
sudo systemctl enable --now sysknife-daemon
sysknife "show disk usage"
No API key needed if you have Ollama running locally — SysKnife auto-detects it. See Quick Start.
Also: a desktop GUI (a distant third option)
There is an experimental desktop GUI (sysknife-shell, built with Tauri) that
wraps the same plan → approve → execute loop in a window. It is the least
frequently maintained surface — a distant third behind the MCP integration and
the CLI — so reach for it only if you specifically want a graphical approval
flow.
Status
189 typed actions · 1,405 Rust tests + 72 frontend tests · MIT
SysKnife is the reference implementation of the LACS specification — a CC0 public-domain protocol for AI agents that operate at the Linux system level.
Quick Start
💡 Prefer your AI coding tool?
If you use Claude Code, Cursor, or Codex CLI — run
npx sysknife-setupand follow the wizard. See MCP Server for the full guide. You can skip this page entirely.
Step 1 — Install
Prerequisites: Rust stable (rustup update stable) and an LLM provider
(see Step 2).
git clone https://github.com/lacs-project/sysknife
cd sysknife
make build
sudo make install
sudo systemctl enable --now sysknife-daemon
ℹ️ Fedora / Silverblue
Ubuntu 24.04 is validated with 65/65 stories on a live VM. Ubuntu 22.04 and 26.04 have smoke-test coverage. Fedora Atomic uses the rpm-ostree action family and requires a current Silverblue 44 validation run for each release. Plain Fedora remains experimental. See distro support.
Step 2 — Choose an LLM
Pick one. No account needed for Ollama.
Ollama — local, fully offline, recommended for homelabs:
ollama pull qwen3:8b # runs well on 16 GB RAM
# SysKnife auto-detects Ollama when no cloud key is set
Anthropic:
export ANTHROPIC_API_KEY=sk-ant-...
OpenAI / Gemini / others — see Configuration for the full list of supported providers.
Optional config file (~/.config/sysknife/config.toml):
[llm]
provider = "ollama"
model = "qwen3:8b"
Step 3 — Run
# Safe first run — plan only, nothing executes
sysknife --dry-run "show disk usage"
# Full run with the daemon
sysknife "what packages do I have installed as layers?"
⚠️ Daemon required for execution
--dry-runworks anywhere and is a great way to test the planner without installing the daemon. Full execution requiressysknife-daemonrunning as root (enabled in Step 1).
That's it. The planner proposes a typed plan, you approve, the daemon executes.
Try without installing anything
On any Linux machine with an API key:
export ANTHROPIC_API_KEY=sk-ant-...
cargo run --bin sysknife -- --dry-run "show disk usage"
Plans the intent and prints the result. No daemon, no root, no installation. Useful for evaluating the planner or running in CI.
What to read next
- CLI Reference — all flags, subcommands, and output formats
- MCP Server — use SysKnife from Claude Code, Cursor, Codex CLI
- Configuration — full provider and storage options
- Distro Support — what works on which distributions
SysKnife MCP Server
The sysknife mcp-server subcommand exposes five MCP tools that let any
MCP-capable AI assistant (Claude Code, Cursor, Codex CLI, …) plan and execute
Linux system administration tasks through SysKnife's approval-gated,
audit-logged path.

💡 One command to configure everything
Run
npx sysknife-setup— it detects which AI clients you have installed and writes the correct config files for each one automatically.
Tools
sysknife_plan
Turn a natural-language intent into a risk-labelled plan. No action is executed.
Input
| Field | Type | Description |
|---|---|---|
intent | string | Natural-language intent, e.g. "check disk usage" |
Output — PlanOutput
| Field | Type | Description |
|---|---|---|
intent | string | The original intent |
summary | string | One-line plan summary |
explanation | string | Why this plan was chosen |
steps | PlanStep[] | Ordered steps to execute |
Each PlanStep:
| Field | Type | Description |
|---|---|---|
action_name | string | Canonical action name, e.g. GetDiskUsage |
summary | string | What this step does |
risk_level | string | "low", "medium", or "high" |
params | object | Action-specific parameters |
command | string | Daemon-resolved shell command, e.g. "timedatectl" |
transaction_id | string | Daemon identity for this immutable preview |
sysknife_execute
Execute a plan produced by sysknife_plan. Each step must include the
one-time receipt printed by sysknife approve <transaction-id>.
Input
| Field | Type | Description |
|---|---|---|
steps | StepToExecute[] | Approved steps from sysknife_plan |
Each StepToExecute contains the original transaction_id, action_name, and
params, plus an approval_receipt. Execution halts on the first failure.
Output — ExecuteOutput
| Field | Type | Description |
|---|---|---|
steps | StepResult[] | Per-step results |
needs_reboot | bool | True if any step requires a reboot |
Each StepResult:
| Field | Type | Description |
|---|---|---|
action_name | string | Action that was executed |
status | string | "succeeded", "failed", etc. |
summary | string | Human-readable outcome |
output | string[] | Progress lines (ANSI stripped) |
warnings | string[] | Daemon warnings |
needs_reboot | bool | Whether this step needs a reboot |
transaction_id | string | Daemon audit transaction ID |
sysknife_history
List audit-log entries with optional status, action, since, and limit
filters. This tool is read-only and does not require a plan or receipt.
sysknife_doctor
Check daemon connectivity, active brain configuration, audit storage, and a quick audit-chain status. This tool is read-only.
sysknife_audit_verify
Verify the Ed25519-signed audit chain and report intact, broken, or
cannot_verify, including the first offending row when verification fails.
This tool is read-only.
The Approval Workflow
The assistant must always follow this order — no exceptions:
1. sysknife_plan { intent }
↓
Present the plan (steps + risk levels) to the user
↓
2. STOP and wait for the user
↓
3. User runs: sysknife approve <transaction-id>
Repeat for each accepted step and return the printed receipt
↓
4. sysknife_execute { steps with approval_receipt }
↓
Report results
Never call sysknife_execute without a receipt minted by the separate CLI
command. A chat response such as "yes" is not an approval receipt. Receipts
are bound to one immutable preview, expire after 15 minutes, and are consumed
atomically on first use.
The daemon enforces this protocol. Prompt hooks provide guidance but are not the security boundary.
Setup
1. Run the setup wizard
npx sysknife-setup
The wizard detects your installed sysknife binary, asks for the daemon
socket and LLM provider, and then asks which integration to configure.
No manual file editing needed.
If you already know the target, skip the picker:
npx sysknife-setup --claude
npx sysknife-setup --cursor
npx sysknife-setup --codex
.mcp.json is gitignored — it contains secrets and local paths.
2. Connect to a daemon in a VM
If the daemon runs inside a VM, two transports are available:
SSH socket tunnel (works with any hypervisor):
ssh -fN -L /tmp/sysknife-vm.sock:/run/sysknife/daemon.sock \
<user>@<vm-host>
Set SYSKNIFE_SOCKET=/tmp/sysknife-vm.sock when the setup wizard asks for
the socket path.
virtio-vsock (KVM/QEMU only, no SSH required):
# Find the guest CID from the host
virsh dumpxml <vm-name> | grep cid
Set SYSKNIFE_SOCKET=vsock://<CID>:9734 and SYSKNIFE_TOKEN=<hex> when
the wizard asks. The wizard detects the vsock:// prefix and prompts for
the token automatically.
See VM + Daemon Setup for the complete walkthrough including token generation, libvirt XML, and troubleshooting.
3. Manual configuration
If you prefer to edit .mcp.json by hand:
{
"mcpServers": {
"sysknife": {
"command": "/path/to/sysknife",
"args": ["mcp-server"],
"env": {
"SYSKNIFE_SOCKET": "/run/sysknife/daemon.sock",
"SYSKNIFE_LLM_PROVIDER": "openai",
"OPENAI_API_KEY": "<your-api-key>",
"SYSKNIFE_LLM_MODEL": "gpt-4.1"
}
}
}
}
For vsock add SYSKNIFE_TOKEN alongside SYSKNIFE_SOCKET.
4. Build the binary
cargo build -p sysknife-cli --release
# binary at target/release/sysknife
5. Reload the MCP server in your client
In Claude Code: run /reload-plugins.
Example Session
User: check disk usage on the VM
Claude: [calls sysknife_plan { intent: "check disk usage" }]
Plan: Check disk usage on all filesystems
Steps:
● low GetDiskUsage — Retrieve current disk usage
Execute?
User: [runs sysknife approve 018f2c9d-... in a terminal]
Approval receipt: 5f493f80-...
Claude: [calls sysknife_execute with transaction_id and approval_receipt]
GetDiskUsage ✓
Filesystem Size Used Avail Use% Mounted on
/dev/vda3 38G 18G 19G 49% /var
...
Risk Levels
| Level | Meaning | Approval |
|---|---|---|
low | Read-only or fully reversible | One-time receipt |
medium | Modifies state but reversible (e.g. set timezone) | One-time receipt |
high | Destructive or hard to reverse (e.g. rpm-ostree) | One-time receipt |
Risk changes how prominently the plan should be reviewed; it never replaces the receipt requirement. The standalone CLI additionally uses stronger confirmation prompts for high-risk actions.
sysknife CLI Reference
sysknife is the command-line interface to the SysKnife daemon. It turns a
natural-language intent into a risk-labelled plan, asks for approval where
needed, and streams execution output in real time.
If you want SysKnife inside Claude Code / Cursor / Codex CLI instead, see
the main README and run npx sysknife-setup. Both paths
share the daemon, the audit chain, and the typed-action set.

Quick start
# Check that the daemon is reachable
sysknife doctor
# Plan + execute a single intent
sysknife "check disk usage"
# Preview the plan without executing
sysknife --dry-run "list running containers"
# Open the interactive REPL
sysknife
Synopsis
sysknife [GLOBAL FLAGS] [SUBCOMMAND | INTENT WORDS...]
When no subcommand is given and no intent words are provided, sysknife starts
an interactive REPL.
Subcommands
sysknife <intent>
Plan and (optionally) execute a natural-language intent.
sysknife "check disk usage"
sysknife check disk usage # words are joined — same result
sysknife "list running containers"
sysknife "is firewalld active?"
sysknife "layer vim via rpm-ostree"
What happens:
- A spinner appears while the LLM plans (
Thinking…→Querying …→Proposing plan…). - The coloured plan is printed — each step shows a risk badge
(
● low/● medium/● HIGH), the action name, and a summary. - If any step requires approval, you are prompted. HIGH-risk steps always
require confirmation regardless of
--yes. - Execution streams output line by line with a
›prefix; a✓/✗result icon is printed after each step.
sysknife doctor
Check daemon connectivity and print the resolved configuration.
sysknife doctor
sysknife --json doctor # machine-readable
Exit code 0 on success, non-zero if the daemon is unreachable.
Sample output:
✓ daemon ok
socket /run/sysknife/daemon.sock
host my-silverblue
provider anthropic
model claude-sonnet-4-6
sysknife history
Query past SysKnife execution history.
sysknife history
sysknife history --limit 50
sysknife history --status failed
sysknife history --action InstallPackages
sysknife history --since 2026-04-01T00:00:00Z
sysknife history --status succeeded --limit 5 --since 2026-04-10T00:00:00Z
Flags:
| Flag | Default | Description |
|---|---|---|
--limit N | 20 | Maximum entries to return |
--status STATUS | — | Filter by job status (succeeded, failed, canceled, …) |
--action ACTION | — | Filter by action name (e.g. InstallPackages) |
--since DATETIME | — | Only entries after this UTC RFC 3339 timestamp |
sysknife approve
Issue a one-time receipt for a transaction returned by the MCP
sysknife_plan tool. This command requires an interactive terminal. It first
loads and displays the daemon-authoritative action, risk, summary, and proposed
change so an agent cannot substitute an opaque transaction ID. It mints the
receipt only after confirmation; high-risk approvals require typing the exact
action name.
sysknife approve 018f2c9d-...
sysknife --json approve 018f2c9d-...
Give the printed approval_receipt to the MCP client for that exact step. The
receipt expires after 15 minutes, is bound to the preview's action and params,
and is consumed on first execution. A chat message saying "approved" is not a
receipt.
sysknife audit
Inspect and anchor the tamper-evident, Ed25519-signed audit chain the daemon writes for every executed action.
sysknife audit verify
Verify the audit chain. Exits 0 if intact, 1 if any row is broken
(tampered), 2 if the chain cannot be verified (missing key, unreadable
database).
sysknife audit verify
sysknife audit verify --json
sysknife audit verify --pubkey /etc/sysknife/audit-key.pub
| Flag | Description |
|---|---|
--json | Machine-readable JSON report instead of human text |
--pubkey FILE | Verify with only the exported public key (<audit-key>.pub), no private key: the third-party / auditor path. Works with SQLite and PostgreSQL and proves the chain without signing access. |
sysknife audit checkpoint
Sign the current chain tip as a checkpoint and anchor it to an external append-only database, then verify all anchored checkpoints against the local chain. Anchoring the tip off-box is what makes tail-truncation and rewrite of the local chain detectable.
# credentials via env (preferred; keeps them off the command line)
SYSKNIFE_CHECKPOINT_DB=postgres://user@host/db sysknife audit checkpoint
# or explicitly
sysknife audit checkpoint --db postgres://user@host/db
| Flag | Description |
|---|---|
--db URL | Postgres URL of the append-only checkpoint database. Prefer SYSKNIFE_CHECKPOINT_DB so credentials are not exposed via ps / shell history. |
Each row is signed with Ed25519; verification uses the public key, so an auditor can verify without the ability to forge. See configuration for the key and checkpoint-DB env vars.
sysknife completions <shell>
Print a shell completion script to stdout.
sysknife completions bash >> ~/.bashrc
sysknife completions zsh >> ~/.zshrc
sysknife completions fish >> ~/.config/fish/completions/sysknife.fish
Supported shells: bash, zsh, fish, elvish, powershell.
sysknife mcp-server
Start an MCP (Model Context Protocol) server over stdio, so Claude Code, Claude Desktop, Cursor, Codex, and any other MCP-capable agent can drive SysKnife.
sysknife mcp-server
It exposes five tools backed by the SysKnife daemon: sysknife_plan,
sysknife_execute, sysknife_history, sysknife_doctor, and
sysknife_audit_verify. Planning and execution stay behind the same approval
interlock as the CLI — sysknife_plan returns typed steps with daemon-issued
transaction IDs, and each step still requires an explicit
sysknife approve <transaction-id> in a real terminal before it can run.
Register it with your agent by pointing it at the binary, e.g. in
claude_desktop_config.json:
{ "mcpServers": { "sysknife": { "command": "sysknife", "args": ["mcp-server"] } } }
npx sysknife-setup writes this configuration for the common clients
automatically.
REPL (no arguments)
sysknife
Starts an interactive session. Each line is treated as a natural-language intent and planned + executed in sequence.
Key bindings:
| Key | Action |
|---|---|
| ↑ / ↓ | Navigate command history |
| Ctrl+R | Reverse incremental history search |
| Ctrl+A / Ctrl+E | Jump to line start / end |
| Ctrl+W | Delete word before cursor |
| Ctrl+C | Cancel current line (does not exit) |
| Ctrl+D | Exit the REPL |
exit / quit | Exit the REPL |
History is persisted to ~/.local/share/sysknife/history between sessions.
Global flags
All flags apply to every subcommand and to free-form intents.
| Flag | Description |
|---|---|
--yes | Auto-approve LOW-risk steps. With --max-risk medium, also approves MEDIUM. HIGH always requires human confirmation. |
--max-risk LEVEL | Abort if the plan contains any step above this ceiling. Values: low, medium, high. |
--non-interactive | Fail immediately (exit 3) if any step would require interactive approval. Use in scripts and CI. |
--dry-run | Print the plan and exit without executing anything. |
--step-by-step | Prompt for approval before each individual step instead of once for the whole plan. |
--json | Emit NDJSON to stdout — one JSON object per event (plan, preview, result). All colour and spinner output is suppressed. Safe to pipe. |
--timeout SECS | Hard wall-clock timeout in seconds. Aborts the whole operation if exceeded. |
--log-to FILE | Tee all stdout output to FILE in addition to the terminal. Appends if the file exists. |
Exit codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Plan or step refused — you rejected it, it exceeded the configured risk ceiling, or approval was required but the session is non-interactive |
2 | Execution failed — the action ran but returned an error (also returned when --timeout expires) |
3 | Planning failed — LLM error, provider unreachable, or the intent could not be turned into a plan |
4 | Configuration or daemon error — invalid configuration, or the daemon could not be reached |
Subcommands with their own semantics (for example sysknife audit verify) pass
through their own exit code.
Environment variables
LLM provider
sysknife auto-detects between Anthropic and local Ollama from the presence of
ANTHROPIC_API_KEY. Every other provider must be selected explicitly with
SYSKNIFE_LLM_PROVIDER (and its matching API key set).
| Variable | Description |
|---|---|
SYSKNIFE_LLM_PROVIDER | Force a provider: anthropic, openai, gemini, ollama, groq, deepseek, mistral, xai |
SYSKNIFE_LLM_MODEL | Override the model name for the selected provider |
ANTHROPIC_API_KEY | Use the Anthropic provider (default model: claude-sonnet-4-6) |
OPENAI_API_KEY | Use the OpenAI provider (default model: gpt-4.1) |
GEMINI_API_KEY | Use the Gemini provider (default model: gemini-2.0-flash) |
GROQ_API_KEY | Use the Groq provider (default model: llama-3.3-70b-versatile) |
DEEPSEEK_API_KEY | Use the DeepSeek provider (default model: deepseek-chat) |
MISTRAL_API_KEY | Use the Mistral provider (default model: mistral-large-latest) |
XAI_API_KEY | Use the xAI provider (default model: grok-3) |
SYSKNIFE_ANTHROPIC_URL | Override the Anthropic base URL (default: https://api.anthropic.com) |
SYSKNIFE_OLLAMA_URL | Override the Ollama base URL (default: http://localhost:11434) |
SYSKNIFE_BRAIN_MAX_TURNS | Planning loop turn limit — integer ≥ 1 (default: 10) |
SYSKNIFE_OLLAMA_THINK | Set true/false to override thinking-mode detection for Ollama models |
Auto-detection (when SYSKNIFE_LLM_PROVIDER is not set):
ANTHROPIC_API_KEYpresent and non-empty →anthropic- Otherwise →
ollama(must be running locally)
The other providers (openai, gemini, groq, deepseek, mistral, xai)
are not auto-detected from their API-key variables. To use one, set
SYSKNIFE_LLM_PROVIDER to its name and provide the matching key from the table
above.
Daemon socket
| Variable | Description |
|---|---|
SYSKNIFE_SOCKET | Daemon socket the CLI dials (unix://, vsock://, or a bare path). Falls back to the same resolution as SYSKNIFE_LISTEN_URI: $XDG_RUNTIME_DIR/sysknife/daemon.sock, then /tmp/sysknife-$UID.sock as a last resort. Production deployments set this via the systemd unit to /run/sysknife/daemon.sock. |
Scripting and CI
For non-interactive use (scripts, CI pipelines), combine --json,
--non-interactive, and --max-risk:
# Plan only — parse the JSON to inspect before executing
PLAN=$(sysknife --dry-run --json "check disk usage")
echo "$PLAN" | jq '.plan.steps[].action'
# Execute automatically up to medium risk; fail if anything higher appears
sysknife --yes --max-risk medium --non-interactive "list layered packages"
# Full pipeline with a timeout and log
sysknife --yes --max-risk low --non-interactive --timeout 60 \
--log-to /var/log/sysknife/run.log \
"check disk usage"
The --json output schema:
// Planning output
{ "plan": { "intent": "…", "summary": "…", "steps": [
{ "action": "GetDiskUsage", "summary": "…", "risk": "low", "params": {} }
] } }
// Per-step preview (before execution)
{ "summary": "…", "risk_level": "low", "reboot_required": false,
"warnings": [], "request_hash": "…", … }
// Per-step result (after execution)
{ "status": "succeeded", "summary": "…", "job_id": "…",
"needs_reboot": false, "warnings": [], … }
Examples
# Check if any services are failing
sysknife "which systemd services are failed?"
# See recent SysKnife activity
sysknife history --limit 10
# Dry-run a destructive action to inspect the plan
sysknife --dry-run "layer vim via rpm-ostree"
# Execute step-by-step with manual approval of each action
sysknife --step-by-step "update system"
# Non-interactive: fail fast if the plan needs a human
sysknife --non-interactive --max-risk low "check memory pressure"
# Get JSON output and parse with jq
sysknife --dry-run --json "list containers" | jq '.plan.steps[].action'
# Override the LLM for a single run
SYSKNIFE_LLM_PROVIDER=openai OPENAI_API_KEY=sk-... sysknife "check disk usage"
# Use a local Ollama model
SYSKNIFE_LLM_PROVIDER=ollama SYSKNIFE_LLM_MODEL=llama3.2:3b sysknife "list services"
Shell completion setup
Run once per shell:
# bash (add to ~/.bashrc)
eval "$(sysknife completions bash)"
# zsh (add to ~/.zshrc)
eval "$(sysknife completions zsh)"
# fish
sysknife completions fish | source
Related
- Architecture overview — trust boundary between CLI, shell, and daemon
- Developer guide — building and testing locally
- User stories — end-to-end scenario descriptions
Configuration reference
SysKnife reads configuration from three places, in lowest-to-highest priority:
- Built-in defaults — compiled into
sysknife-brainandsysknife-core ~/.config/sysknife/config.toml— optional, user-owned- Environment variables — always win
Set whatever's stable for your install in config.toml; override with env
vars when you need to (CI runs, ad-hoc experiments, distro packagers).
config.toml reference
Path: $XDG_CONFIG_HOME/sysknife/config.toml, falling back to
~/.config/sysknife/config.toml. The daemon and CLI read this on every
startup; the GUI reloads it after the wizard finishes.
# ─── [daemon] ────────────────────────────────────────────────────────
[daemon]
# Unix socket path the daemon listens on. CLI / shell connect here.
socket = "/run/sysknife/daemon.sock"
# SQLite database path for the local audit log.
# Production deployments should switch to [storage] backend = "postgres"
# (see below).
database = "/var/lib/sysknife/daemon.sqlite"
# ─── [llm] ───────────────────────────────────────────────────────────
[llm]
provider = "ollama" # ollama | anthropic | openai | gemini |
# groq | deepseek | mistral | xai
model = "qwen3:8b" # provider-specific model identifier
ollama_url = "http://localhost:11434"
anthropic_url = "https://api.anthropic.com"
max_turns = 10 # planning loop turn limit (>= 1)
# Optional: override the auto-detected thinking mode for Ollama.
# Default: auto-detect from the model name (qwen3 / qwq / deepseek-r → true).
# Set to `false` on CPU-only hosts running thinking models — thinking
# traces exceed Ollama's internal request timeout on 4 vCPUs.
# ollama_think = false
# ─── [storage] ───────────────────────────────────────────────────────
# Audit-log backend. Default (absent or backend = "sqlite") uses the
# local rusqlite store at [daemon].database. Production deployments
# should set backend = "postgres" — the SQLite path dies with the host.
[storage]
backend = "postgres"
url = "postgres://sysknife:${PG_PASSWORD}@db.example.com:5432/audit?sslmode=verify-full"
[storage.pool]
max_connections = 8
acquire_timeout_secs = 10
statement_cache_capacity = 100 # set to 0 for transaction-mode poolers
# ─── [policy] ────────────────────────────────────────────────────────
# Per-action risk-level overrides. Map from action name → risk level
# ("Low" | "Medium" | "High"). Validated at startup; unknown action
# names or attempted downgrades are fatal — overrides may only RAISE
# the minimum role required, never lower it.
[policy.risk_overrides]
InstallFlatpak = "High" # require Admin in this org (default: Medium/Dev)
# ─── [audit.forward] ─────────────────────────────────────────────────
# Optional SIEM forwarding. Best-effort — never blocks daemon execution.
# Phase 1 ships RFC 5424 syslog over UDP; CEF and NDJSON-over-TCP
# arrive in follow-up PRs.
[audit.forward.syslog]
host = "siem.internal:514"
facility = 1 # 1 = user-level (default)
The transaction database is the durable audit record. Safety-fence JSONL,
journald watermarks, and UDP syslog forwarding are operational signals and may
be unavailable or lossy. PostgreSQL migrations run automatically at daemon
startup; backup and restore requirements are documented in
storage-cloud.md.
Environment variables
Env vars always win over config.toml. Useful for CI runs, distro
packagers, and ad-hoc experiments. Variable names mirror the config
file's section.field path.
| Variable | Default | What it sets |
|---|---|---|
SYSKNIFE_LISTEN_URI | unix://$XDG_RUNTIME_DIR/sysknife/daemon.sock (falls back to unix:///tmp/sysknife-$UID.sock if XDG_RUNTIME_DIR is unset; production systemd unit sets unix:///run/sysknife/daemon.sock) | Daemon socket / vsock URI |
SYSKNIFE_DATABASE_PATH | $XDG_STATE_HOME/sysknife/daemon.sqlite (falls back to ~/.local/state/sysknife/daemon.sqlite; production systemd unit sets /var/lib/sysknife/daemon.sqlite) | SQLite audit log path |
SYSKNIFE_LLM_PROVIDER | auto-detect | LLM provider name (8 supported) |
SYSKNIFE_LLM_MODEL | provider default | Model identifier |
SYSKNIFE_OLLAMA_URL | http://localhost:11434 | Ollama base URL |
SYSKNIFE_OLLAMA_THINK | auto-detect | true / false thinking-mode override |
SYSKNIFE_ANTHROPIC_URL | https://api.anthropic.com | Anthropic base URL |
SYSKNIFE_BRAIN_MAX_TURNS | 10 | Planning loop turn limit |
SYSKNIFE_MAX_RPM | 20 | Rate limit (requests / 60s sliding window) |
SYSKNIFE_AUDIT_KEY_PATH | <db_dir>/audit-key | Ed25519 signing key path for the audit chain |
SYSKNIFE_CHECKPOINT_DB | — | Postgres URL for audit checkpoint external anchoring (keeps DB credentials off the command line) |
SYSKNIFE_SOCKET | falls back to the same default as SYSKNIFE_LISTEN_URI | CLI / MCP daemon address |
SYSKNIFE_TOKEN | — | Vsock auth token (when daemon runs in a VM) |
XDG_CONFIG_HOME | ~/.config | Base path for sysknife/config.toml |
Provider API keys
Required when the corresponding provider is selected:
OPENAI_API_KEY— OpenAIANTHROPIC_API_KEY— AnthropicGEMINI_API_KEY— GeminiGROQ_API_KEY— GroqDEEPSEEK_API_KEY— DeepSeekMISTRAL_API_KEY— MistralXAI_API_KEY— xAI- none — Ollama (local, no key)
Daemon-only configuration
These environment variables are read only by sysknife-daemon, not by
the CLI / shell:
| Variable | Purpose |
|---|---|
SYSKNIFE_AUDIT_KEY_PATH | Ed25519 audit signing key path (default: alongside the database) |
Validating your config
sysknife doctor
Reports the resolved configuration (socket, host, provider, model, audit
backend) plus a quick chain-integrity check. A failing doctor is the
fastest way to catch a typo'd env var or a bad path.
Where each setting lives in the source
For maintainers — the canonical types are:
crates/sysknife-core/src/config.rs—LacsConfig,DaemonSection,LlmSection,PolicySection,AuditSection,StorageSection,StoragePoolSectioncrates/sysknife-brain/src/config.rs—BrainConfig,ProviderConfigcrates/sysknife-core/src/lib.rs—default_listen_uri,default_database_path,prefs_path
Adding a new config knob: add the field to LacsConfig, surface the env
var in apply_defaults_to_env, and update this document. A test in
crates/sysknife-core/src/config.rs should cover the new field.
Distro support matrix
SysKnife reports operating-system support by evidence, not by family name
alone. Recognition in /etc/os-release means the planner can select the right
action vocabulary; it does not prove that every action has passed on that
release.
Status definitions
| Tier | Meaning |
|---|---|
| Validated | The documented full story suite passed on a real VM. |
| Smoke-tested | Bootstrap and basic daemon/tooling checks passed; full action parity was not exercised. |
| Current validation required | An action backend exists, but the current distro release still needs its launch-gate VM run. |
| Experimental | Detection or partial code exists, but production support is not claimed. |
| Planned | No complete action backend exists. |
Launch matrix
| Distro | Action backend | Evidence | Launch tier |
|---|---|---|---|
| Ubuntu 24.04 LTS | apt, ufw, netplan, snap, AppArmor, systemd, containers | 65/65 stories on a live VM with gpt-4.1 | Validated |
| Ubuntu 22.04 LTS | Ubuntu/apt family | VM bootstrap and smoke tests | Smoke-tested |
| Ubuntu 26.04 LTS | Ubuntu/apt family | VM bootstrap, smoke tests, and sudo-rs sudoers verification (26.04 ships sudo-rs 0.2.x; visudo -cf parses the SysKnife sudoers and every grant — including the trailing-* wildcard grants — is honoured) | Smoke-tested |
| Fedora Silverblue 44 | rpm-ostree, Flatpak, toolbox, firewalld, systemd, containers | Harness and fixture coverage; current live-VM run must be recorded before release | Current validation required |
| Other Fedora Atomic 41+ variants | rpm-ostree family | Detection and shared action tests | Experimental until variant-specific VM evidence exists |
| Fedora Workstation / Server | dnf family incomplete | Detection tests only | Experimental |
The deterministic workspace baseline is 1,405 Rust tests plus 72 frontend tests. Those tests verify action construction, policy, approval, storage, and UI behavior, but they do not replace a real distribution VM run.
Important scope differences
- Atomic rollback applies to rpm-ostree deployment changes. Ubuntu package operations are mutable and cannot offer equivalent deployment rollback.
- Ubuntu Server may use netplan with
systemd-networkd; Ubuntu Desktop often uses NetworkManager. SysKnife detects and routes those mechanisms. aptcan contend with unattended upgrades andneedrestart; the Ubuntu actions use non-interactive execution and bounded lock handling.- Fedora Workstation and Server require a dedicated
dnfaction family. Falling through to rpm-ostree commands would be incorrect, so they are not reported as supported.
The complete Ubuntu action catalogue is in the Ubuntu action reference.
Distro detection
SysKnife parses /etc/os-release without evaluating it as shell code:
IDselects Fedora, Ubuntu, Debian, or another exact distribution.ID_LIKEsupplies a family fallback for planning.VERSION_IDdetermines the release.VARIANT_IDdistinguishes Fedora Atomic variants and Ubuntu Core.
Ubuntu Core is detected separately and is not supported. Unknown Debian- or Fedora-family systems receive a warning rather than a false support claim.
Planned systems
| Distro | State |
|---|---|
| Debian stable/testing | Planned after Ubuntu hardening |
| Arch / EndeavourOS | Planned; requires a pacman action family |
| openSUSE Leap / Tumbleweed | Planned; requires zypper and transactional-update design |
| NixOS | Out of scope; configuration evaluation does not fit per-action mutation |
| macOS, Windows, WSL | Out of scope; SysKnife is a native Linux system daemon |
Verify a host
sysknife doctor
doctor reports detected distribution, daemon reachability, provider, and
audit-chain status. For release evidence, follow the current VM procedures in
Testing and record the exact image, architecture,
model, commit, and story results in the release checklist.
Adding support
- Document the action mapping and unsupported semantics.
- Add real
/etc/os-releasefixtures and detection tests. - Implement typed actions without raw shell strings.
- Add policy, preview, and executor consistency tests.
- Add a reproducible VM harness and record a full run before using the Validated label.
Architecture
SysKnife is a local Linux control plane built around a strict boundary between planning, presentation, and execution.
Crate and App Layout
| Location | Package | Role |
|---|---|---|
crates/sysknife-brain/ | sysknife-brain | Unprivileged LLM planner |
crates/sysknife-types/ | sysknife-types | Shared domain types |
crates/sysknife-core/ | sysknife-core | Config file loading, constants |
crates/sysknife-daemon/ | sysknife-daemon | Privileged executor |
crates/sysknife-proto/ | sysknife-proto | Protobuf definitions (future use) |
apps/sysknife-shell/ | sysknife-shell | Tauri + React GUI |
apps/sysknife-cli/ | sysknife | Production CLI — also used headlessly by E2E stories (--dry-run --json) |
sysknife-brain
Unprivileged. Reads the user's intent, queries the daemon for system state, then calls the LLM in a tool-use loop until a typed plan is produced. Provides:
LlmPlanner— the planning loop entry pointBrainConfig— provider and model selection- Provider adapters: Anthropic, OpenAI, Gemini, Ollama, Groq, DeepSeek, Mistral, xAI
- Planning tools:
get_system_state,query_*,propose_plan,remember,forget - Safety fence: validates every action name and risk level before a plan leaves the brain
Prompt construction — per-distro dispatch
build_system_prompt in crates/sysknife-brain/src/prompt.rs dispatches to one
of three pure render functions based on distro_hint.family:
| Distro family | Render function | Actions included |
|---|---|---|
Fedora (fedora) | render_fedora_prompt | rpm-ostree, flatpak, toolbox, firewalld, … |
Debian (debian) | render_debian_prompt | apt, snap, distrobox, ufw, netplan, … |
| Unknown | render_generic_prompt | Cross-distro actions only |
Each render function concatenates shared const blocks (role, rule, examples)
with per-distro const blocks (action catalogue, worked examples, parameter
reference). Fedora prompts never contain Debian action names and vice versa —
the isolation is structural. This prevents the model from proposing
AptInstall on Fedora or AddLayeredPackage on Ubuntu regardless of what the
user types.
The prompt is rebuilt on every plan_intent() call so that injected user
preferences (~/.config/sysknife/prefs.md) are always current.
See ADR 0004 for the full rationale.
sysknife-types
Shared domain types used by every crate. Contains:
CallerRole—Observer|Dev|Admin|BootRiskLevel—Low|Medium|HighJobState—Queued|Running|Succeeded|Failed|Canceled|RolledBack|NeedsReboot- Request and result envelopes for IPC messages
sysknife-core
Shared constants and ~/.config/sysknife/config.toml loading via
LacsConfig. Config file values become env-var defaults so every
component uses the same resolution order.
sysknife-daemon
Privileged. The only component that touches the system. Provides:
- 189 typed actions (rpm-ostree, systemd, firewall, users, containers, flatpak, toolbox, SSH, kernel args, …)
- Role-based authorization (
Observer→Dev→Admin) - Policy enforcement: stale-approval detection, request hash validation
- Preview generation: risk level, side effects, reboot flag, rollback metadata, content hash
- IPC dispatcher over a Unix domain socket
- Live stdout streaming as
JobProgressframes - Automatic rollback for supported high-risk actions that fail
- SQLite transaction audit log
sysknife-shell
The user-facing surface. A Tauri app (Rust backend + React frontend) that provides:
- Intent entry pane
- Plan review with risk badges and previews
- Approval gate (explicit checkbox for Medium; typed action name for High)
- Live job timeline with streaming output
- Setup wizard for first-run LLM configuration
sysknife-cli
The production CLI (apps/sysknife-cli/). Accepts a natural-language intent,
calls the LLM planner, and prints or executes the resulting plan.
--dry-run --json mode emits the plan as JSON on stdout without executing —
this is the mode used by every E2E story script.
Trust Boundary
The daemon is trusted. The brain and shell are not trusted with raw privileged execution.
sysknife-brain ──plan──► sysknife-shell ──approval──► sysknife-daemon
(planner) (approval) (executor)
The daemon owns:
- authorization (role-based: Observer → Dev → Admin)
- policy (stale-approval detection, request validation)
- previews (risk level, side effects, rollback metadata)
- jobs (execution, live output streaming)
- transaction records (SQLite audit log)
- rollback (automatic on failure for supported actions)
Neither the brain nor the shell can execute a privileged action directly. The daemon verifies and atomically consumes a one-time approval receipt before running anything.
Request Flow
- A user enters intent in the shell.
- The brain proposes a typed plan.
- The shell sends each mutating step to the daemon for preview.
- The daemon persists an immutable preview and returns its transaction ID, risk level, side effects, reboot requirement, and rollback availability.
- The shell shows the preview and captures approval. High-risk steps require the user to type the action name explicitly.
- The daemon issues a deterministic, domain-separated Ed25519 receipt and stores its SHA-256 commitment inside the signed transaction row.
- The client sends the exact transaction, action, params, and receipt.
- The daemon verifies the preview is fresh and atomically consumes the receipt, then runs the action.
- During execution, the daemon streams live stdout output line-by-line
as
JobProgressframes. - The shell displays each line as it arrives.
- On failure, if
rollback_availableis true, the daemon runs the rollback action automatically and reports the result. - The transaction is persisted to the configured SQLite or PostgreSQL store with the final job state.
IPC Protocol
The shell and daemon communicate over a Unix domain socket
($XDG_RUNTIME_DIR/sysknife/daemon.sock by default, overridable via SYSKNIFE_LISTEN_URI).
The framing is a 4-byte little-endian u32 length prefix followed by
a UTF-8 JSON body. Each message carries a "type" discriminant so
the dispatcher can route without a full decode.
Maximum message size is 4 MiB. The daemon limits concurrent connections to 16 via a tokio semaphore; excess connections are dropped immediately rather than queued.
The protocol is human-readable. You can inspect live traffic with:
socat - UNIX-CONNECT:"$XDG_RUNTIME_DIR/sysknife/daemon.sock"
See ADR 0003 for the rationale behind length-prefixed JSON over gRPC or binary protobuf.
Design Principles
- typed instead of free-form
- local instead of remote
- auditable instead of opaque
- rollback-aware instead of irreversible
- explicit approval instead of hidden mutation
Typed Actions
Every AI-driven system administration tool has to answer one question: what, exactly, is the model allowed to make the machine do? Most answer it with an allowlist or a regex over a shell command string. SysKnife answers it differently — the model is never given a channel to produce a shell string in the first place.
Why string allowlists fail
The independent security research known as GuardFall tested AI coding agents against shell-command guardrails — allowlists and regex filters applied to the command string the model was about to run — and found that 10 of 11 agents could be talked into bypassing them. That is not a bug in any one guard; it is a structural property of the approach. A string allowlist has to anticipate every syntactic trick (quoting, encoding, command substitution, argument smuggling, alias/function shadowing) a sufficiently motivated or merely creative model can produce, in a language — shell — that was designed to make exactly those tricks easy. Filtering a dangerous language after the fact is an arms race you don't get to stop fighting.
SysKnife's answer is architectural, not defensive: don't give the model the dangerous language at all.
The model: typed actions, not shell strings
The brain (sysknife-brain) runs the LLM in a tool-use loop against a single
terminal tool, propose_plan. That tool's schema is built from
KNOWN_ACTIONS in crates/sysknife-brain/src/planning_tools/propose_plan.rs
— a fixed, named list of actions such as InstallPackages, StartService, or
AptUpdate, each with a one-line description of its purpose and parameters.
The model can only emit one of these names plus a small parameter object; it
has no field, anywhere in the schema, into which a shell string fits.
There is no run_command, no exec, no free-text field that reaches a
shell. The typed catalogue is the model's entire vocabulary for changing
the system. If an action isn't in the catalogue, the model cannot request it
— it can only fall back to a different, already-typed action, or tell the
user it can't do that.
Every proposed action name is checked against ActionName::parse before a
plan leaves the brain — this is the safety fence referenced throughout the
codebase. On the daemon side (sysknife-daemon), each action is backed by an
ActionSpec (crates/sysknife-daemon/src/actions/mod.rs) that pins down:
| Field | What it fixes |
|---|---|
mechanism | Exactly one privileged operation: a Command with a fixed program and a typed argument list, or a scoped FileWrite / FilePatch / FileDelete / FileScan |
risk_level | Low, Medium, or High — drives approval UX and policy |
reboot_required | Whether the daemon should warn the caller before proceeding |
rollback_available | Whether a failure triggers automatic rollback |
As of this writing the catalogue defines 189 actions across families such
as Deployment, Services, Package Layering, Flatpak, Containers, Toolbox,
Network, Identity, SSH Keys, Package Repositories, apt/snap/ufw/netplan/grub
(Debian-family), and rpm-ostree/AppArmor/cloud-init/Pro (Fedora-family). Each
family is also fenced by distro: crates/sysknife-core/src/action_family.rs
is the single source of truth for which actions are Debian-only versus
Fedora-only, and that list is shared — not duplicated — across the brain's
prompt construction, the CLI's routing guard, and the daemon's execution
fence, specifically so the three can never silently drift apart.
The daemon is the sole executor and the sole authority. It does not trust the
brain's judgment about parameters — every ActionSpec's mechanism is
re-validated against the actual request before anything runs. A typed action
name plus a parameter object cannot be turned into an arbitrary command line,
because the daemon, not the model, decides which program and argv shape that
action maps to.
A concrete example
Say a user asks something that sounds routine but is actually destructive:
"Clean up old kernels, I'm low on disk space."
An allowlist-guarded shell agent has to decide, in the moment, whether whatever command string it's about to construct is safe — and a determined or confused model can dress up something worse as "cleanup." SysKnife never gets to that fork. The brain can only propose the one typed action that matches this intent:
{
"action_name": "CleanupDeployments",
"params": {}
}
The daemon looks up CleanupDeployments in its catalogue and finds:
risk_level: High, reboot_required: false, rollback_available: false —
because this action permanently deletes the rollback and pending deployments
(sudo rpm-ostree cleanup --rollback --pending), it destroys the very safety
net that would undo it. That fact isn't something the model asserts and the
daemon has to believe; it's a property of the fixed ActionSpec the daemon
already owns. The risk level and the missing rollback both surface in the
approval prompt before anything executes.
Composing with approval and rollback
A typed action alone is not the whole safety story — it's the piece that determines what can be requested at all. What happens to a request after it's typed is covered elsewhere:
- Every preview the daemon issues must be approved before it can be executed, and every mutating action — including its risk level and the approval that authorized it — is recorded in a forward, Ed25519-signed hash chain. See The Audit Chain.
- When an action whose
ActionSpeccarriesrollback_available: truefails, the daemon executes its rollback automatically, in the same job, before the caller ever sees a terminal result. See Automatic Rollback.
Typed actions are what make both of those guarantees possible to state
precisely: the audit chain can name an exact action_name and risk_level
because those are fixed catalogue facts, not free text extracted from a
shell command after the fact; automatic rollback can look up "is there a
rollback for this" because the mapping from action to rollback action is a
lookup table, not a heuristic.
The tradeoff, honestly
Typed actions are a closed catalogue. If an operation isn't in it, the model cannot perform it — full stop, no escape hatch. That is the entire point, but it has a real cost:
- Adoption cost. Every new operation SysKnife should support requires an
engineer to add an
ActionSpec, wire its mechanism, decide its risk level, and — if applicable — its rollback. This is slower than "the model can run any command it comes up with." - Coverage gaps. A user's request that doesn't map to any action in the
catalogue gets a "I can't do that yet" rather than a best-effort shell
command.
docs/ubuntu-action-gap-analysis.mdis a historical (2026-04-25) snapshot of this tradeoff playing out in the Debian/Ubuntu action set — almost all of the gaps it identified have since been closed.
SysKnife accepts this tradeoff deliberately: a finite, auditable vocabulary that the daemon fully understands is worth more than an open-ended one the daemon can only try to filter.
Action reference
This file is generated. Do not edit by hand.
Regenerate with UPDATE_ACTION_REFERENCE=1 cargo test -p sysknife-daemon --test action_reference_doc; a plain cargo test fails if it drifts from the catalogue.
Every row is derived from the live code: the command from each action's ActionSpec mechanism, the risk from its risk_level, the distro from sysknife-core::action_family, and the description from the brain's KNOWN_ACTIONS list. Distro is All (cross-distro), Ubuntu (Debian-family only), or Fedora (atomic-host only). Rb = requires reboot; Ro = automatic rollback available.
Deployment (atomic host)
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetSystemState | rpm-ostree status --json | Low | All | – | – | full rpm-ostree deployment snapshot: layered packages, pinned/staged deployments, booted/pending OSTree refs |
CollectDiagnostics | journalctl -b -n 500 --no-pager | Low | All | – | – | recent system journal log (last 500 lines) for error diagnosis and troubleshooting |
GetDeploymentHistory | rpm-ostree status --json | Low | Fedora | – | – | rpm-ostree deployment history: past and current OSTree commits with timestamps |
ListDeployments | rpm-ostree status --json | Low | Fedora | – | – | list all currently staged, pending, and booted deployments |
UpdateSystem | sudo rpm-ostree upgrade | High | All | ✓ | ✓ | download and stage the latest OSTree update (does not reboot) |
PinDeployment | sudo ostree admin pin 0 | High | Fedora | – | – | pin a deployment so it is not GC'd — param: index (u32, deployment index from ListDeployments) |
UnpinDeployment | sudo ostree admin pin --unpin 0 | High | Fedora | – | – | unpin a previously pinned deployment — param: index (u32) |
RebaseSystem | sudo rpm-ostree rebase fedora/41/x86_64/silverblue | High | Fedora | ✓ | ✓ | switch to a different OSTree ref/remote — param: target_ref (string, e.g. fedora/40/x86_64/silverblue) |
CleanupDeployments | sudo rpm-ostree cleanup --rollback --pending | High | Fedora | – | – | remove old staged deployments to free disk space |
RebootSystem | sudo systemctl reboot | High | All | ✓ | – | reboot the machine into the current or staged deployment |
RollbackDeployment | sudo rpm-ostree rollback | High | Fedora | ✓ | – | roll back to the previous booted deployment |
GetKernelArguments | rpm-ostree kargs | Low | Fedora | – | – | list current kernel command-line arguments (kargs) |
SetKernelArguments | sudo rpm-ostree kargs | High | Fedora | ✓ | ✓ | add/remove kernel command-line args — params: add (string[]), remove (string[]) — either may be [] |
Package layering (rpm-ostree)
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
InstallPackages | sudo rpm-ostree install --idempotent podman | High | All | ✓ | ✓ | layer multiple RPM packages — param: packages* (string[]) |
RemovePackages | sudo rpm-ostree uninstall podman | High | All | ✓ | ✓ | remove layered RPM packages — param: packages* (string[]) |
GetLayeredPackages | rpm-ostree status --json | Low | Fedora | – | – | list RPM packages layered on top of the base OS image — no params |
AddLayeredPackage | sudo rpm-ostree install --idempotent podman | High | Fedora | ✓ | ✓ | layer a single RPM package (requires reboot) — param: package* (string) |
RemoveLayeredPackage | sudo rpm-ostree uninstall podman | High | Fedora | ✓ | ✓ | remove a single layered RPM package (requires reboot) — param: package* (string) |
ReplaceLayeredPackage | sudo rpm-ostree install neovim --uninstall vim | High | Fedora | ✓ | ✓ | replace one layered package with another — params: old* (string), new* (string) |
ResetLayeredPackageOverride | sudo rpm-ostree override reset --all | High | Fedora | ✓ | ✓ | reset all rpm-ostree override changes — no params |
RemoveBasePackage | sudo rpm-ostree override remove gedit | High | Fedora | ✓ | ✓ | exclude a base OS package from the deployment — param: package* (string) |
GetPendingUpdates | rpm-ostree upgrade --check | Low | All | – | – | check for a staged update and show its diff — no params |
Filesystem
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetDiskUsage | df -h --output=source,fstype,size,used,avail,pcent,target --exclude-type=composefs --exclude-type=tmpfs --exclude-type=devtmpfs --exclude-type=efivarfs | Low | All | – | – | show disk space usage for all mounted filesystems (df -h) — no params |
Flatpak
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
InstallFlatpak | sudo runuser -u testuser -- flatpak install --user -y flathub app-id | Medium | All | – | – | install a Flatpak app — params: username* (Linux user), app_id* (e.g. org.mozilla.firefox), remote* (e.g. flathub) |
RemoveFlatpak | sudo runuser -u testuser -- flatpak uninstall --user -y app-id | Medium | All | – | – | uninstall a Flatpak app — params: username*, app_id* |
SearchFlatpakApps | flatpak search search-term | Low | All | – | – | search Flatpak remotes for apps — param: term* (query string) — no username needed |
ListFlatpakRemotes | sudo runuser -u testuser -- flatpak remotes --user --columns=name,url | Low | All | – | – | list configured Flatpak remotes — param: username* |
ListInstalledFlatpaks | sudo runuser -u testuser -- flatpak list --user --app --columns=application,name,version,origin | Low | All | – | – | list installed Flatpak apps for a user — param: username* |
AddFlatpakRemote | sudo runuser -u testuser -- flatpak remote-add --user --if-not-exists remote https://example.invalid | Medium | All | – | – | add a Flatpak remote — params: username*, remote* (name), url* |
RemoveFlatpakRemote | sudo runuser -u testuser -- flatpak remote-delete --user remote | Medium | All | – | – | remove a Flatpak remote — params: username*, remote* (name) |
GetFlatpakAppInfo | sudo runuser -u testuser -- flatpak info --user app-id | Low | All | – | – | show metadata for an installed Flatpak — params: username*, app_id* |
UpdateFlatpak | sudo runuser -u testuser -- flatpak update --user -y com.example.App | Medium | All | – | – | update Flatpak apps — params: username* (required); app_id (optional — omit to update all) |
UbuntuInstallFlatpak | sudo runuser -u testuser -- flatpak install --user -y flathub app-id | Medium | Ubuntu | – | – | install a Flatpak app on Ubuntu — params: username*, app_id*, remote* (e.g. flathub); Ubuntu only; Medium risk |
UbuntuRemoveFlatpak | sudo runuser -u testuser -- flatpak uninstall --user -y app-id | Medium | Ubuntu | – | – | remove a Flatpak app on Ubuntu — params: username*, app_id*; Ubuntu only; Medium risk |
UbuntuUpdateFlatpak | sudo runuser -u testuser -- flatpak update --user -y com.example.App | Medium | Ubuntu | – | – | update Flatpak app(s) on Ubuntu — param: username*; optional: app_id (omit for all); Ubuntu only; Medium risk |
UbuntuListFlatpaks | sudo runuser -u testuser -- flatpak list --user --app --columns=application,name,version,origin | Low | Ubuntu | – | – | list installed Flatpak apps on Ubuntu — param: username*; Ubuntu only; read-only |
Toolbox
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ListToolboxes | sudo runuser -l testuser -c XDG_RUNTIME_DIR=/run/user/$(id -u) toolbox list | Low | All | – | – | list toolbox containers for a user — param: username* |
CreateToolbox | sudo runuser -l testuser -c XDG_RUNTIME_DIR=/run/user/$(id -u) toolbox create --container 'sysknife-dev' --release '41' | Medium | All | – | – | create a toolbox container — params: username*, name*; optional: image, release |
RemoveToolbox | sudo runuser -l testuser -c XDG_RUNTIME_DIR=/run/user/$(id -u) toolbox rm 'sysknife-dev' | Medium | All | – | – | remove a toolbox container — params: username*, name* |
Services
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ListServices | systemctl list-units --type=service --all --no-legend --no-pager | Low | All | – | – | list all systemd units and their active/enabled state — no params |
StartService | sudo systemctl start NetworkManager.service | Medium | All | – | – | start a systemd service — param: unit* (e.g. sshd.service) |
StopService | sudo systemctl stop NetworkManager.service | Medium | All | – | – | stop a systemd service — param: unit* |
RestartService | sudo systemctl restart NetworkManager.service | Medium | All | – | – | restart a systemd service — param: unit* |
SetServiceEnabled | sudo systemctl enable sshd.service | Medium | All | – | – | enable or disable a service at boot — params: unit*, enabled* (bool) |
MaskService | sudo systemctl mask cups.service | High | All | – | – | mask a unit so it cannot start by any means — param: unit* |
UnmaskService | sudo systemctl unmask cups.service | Medium | All | – | – | unmask a previously masked unit — param: unit* |
GetServiceLogs | journalctl -u NetworkManager.service -n 200 --no-pager | Low | All | – | – | fetch recent journald log lines for a service — param: unit* |
GetServiceStatus | systemctl status nginx.service --no-pager | Low | All | – | – | show detailed status of a service — param: unit* |
ReloadService | sudo systemctl reload nginx.service | Medium | All | – | – | reload a service config without restart (SIGHUP) — param: unit* |
ListTimers | systemctl list-timers --all --no-legend --no-pager | Low | All | – | – | list all systemd timer units with next trigger time — no params |
ReloadDaemon | sudo systemctl daemon-reload | Medium | All | – | – | run systemctl daemon-reload to pick up changed unit files — no params |
CreateScheduledJob | sudo /usr/lib/sysknife/scheduled-job-edit --name sysknife-example --command /usr/bin/true --schedule *-*-* 02:00:00 | High | All | – | – | schedule a recurring command as a systemd timer — params: name* (unit-safe id), command* (executable line), schedule* (systemd OnCalendar, e.g. "*-*-* 02:00:00" or "daily") |
GetServiceResourceLimits | systemctl show nginx.service --property=MemoryMax,MemoryHigh,CPUQuotaPerSecUSec,TasksMax | Low | All | – | – | show a service's cgroup limits (MemoryMax/CPUQuota/TasksMax) via systemctl show — param: unit*; read-only |
SetServiceResourceLimits | sudo systemctl set-property nginx.service MemoryMax=500M CPUQuota=50% | Medium | All | – | – | cap a service's resources via systemctl set-property (applies live + persists) — params: unit*, plus at least one of memory_max (e.g. '500M' or 'infinity'), memory_high, cpu_quota (e.g. '50%'), tasks_max (integer or 'infinity'); Medium risk; undo with systemctl revert |
Processes
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ListProcesses | ps aux --sort=-%mem | Low | All | – | – | list running processes with CPU and memory usage — no params |
SignalProcess | sudo kill -s TERM 1234 | High | All | – | – | send a signal to a process to stop it — params: pid* (integer > 1); signal (TERM|KILL|HUP|INT, default TERM) |
Journald
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetJournalLog | journalctl --output=json --no-pager --lines=100 --unit=ssh.service --priority=err --boot | Low | All | – | – | read filtered systemd journal entries as JSON (journalctl) — all params optional: unit (e.g. 'ssh.service'), priority (0-7 or name like 'err', or a range '0..3'), boot (bool, current boot only), kernel (bool, kernel messages only), since/until (e.g. '2026-07-22 10:00:00', 'yesterday', '-1h'), grep (regex on MESSAGE), lines (default 100, max 10000); read-only |
VacuumJournal | journalctl --vacuum-size=500M | High | All | – | – | reclaim journal disk space — supply exactly one of size_mb (cap total journal size) or retain_days (delete entries older than N days) |
Storage — LVM
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetLvmReport | lvs --reportformat json --units b -o lv_name,vg_name,lv_size,lv_attr,origin,data_percent | Low | All | – | – | list logical volumes with VG, size, attributes, and usage as JSON (lvs) — no params; read-only |
ExtendLogicalVolume | sudo lvextend -L +10G -r ubuntu-vg/ubuntu-lv | High | All | – | – | grow a logical volume AND its filesystem in one step (lvextend -r) — params: vg*, lv*, size* (e.g. '+10G' to add, or '50G' absolute); High risk |
CreateLogicalVolume | sudo lvcreate -L 20G -n data ubuntu-vg | Medium | All | – | – | create a new logical volume in a volume group (lvcreate) — params: vg*, name*, size* (e.g. '20G'); Medium risk |
CreateLvSnapshot | sudo lvcreate -s -L 5G -n ubuntu-lv-snap ubuntu-vg/ubuntu-lv | Medium | All | – | – | snapshot a logical volume before risky changes (lvcreate -s) — params: vg*, origin* (LV to snapshot), snapshot* (new name), size* (copy-on-write reserve, e.g. '5G'); Medium risk |
Kernel parameters — sysctl
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetSysctl | sysctl -- net.ipv4.ip_forward | Low | All | – | – | read a kernel parameter (sysctl) — param: key (optional, dotted e.g. 'net.ipv4.ip_forward'; omit to dump all); read-only |
SetSysctl | sudo /usr/lib/sysknife/sysctl-edit --key net.ipv4.ip_forward --value 1 | High | All | – | – | set AND persist a kernel parameter (runtime + /etc/sysctl.d drop-in) — params: key* (dotted, e.g. 'vm.swappiness'), value* (number or space-separated list); High risk |
Mounts & swap
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetMounts | findmnt --json | Low | All | – | – | list mounted filesystems as JSON (findmnt) — no params; read-only |
AddMount | sudo /usr/lib/sysknife/mount-edit --op mount --device /dev/sdb1 --mountpoint /mnt/data --fstype ext4 --options defaults | High | All | – | – | mount a device and persist it to /etc/fstab with nofail — params: device* (/dev/.., UUID=.., LABEL=.., //host/share, or host:/export), mountpoint* (absolute; not a system dir), fstype* (ext4/xfs/btrfs/vfat/nfs/cifs/…), options (csv, optional); High risk |
RemoveMount | sudo /usr/lib/sysknife/mount-edit --op unmount --mountpoint /mnt/data | High | All | – | – | unmount and drop the /etc/fstab entry for a mountpoint — param: mountpoint*; High risk |
AddSwap | sudo /usr/lib/sysknife/mount-edit --op addswap --file /swapfile --size-mb 2048 | High | All | – | – | create a swap file, enable it, and persist to /etc/fstab — params: file* (absolute path), size_mb* (integer MB); High risk |
RemoveSwap | sudo /usr/lib/sysknife/mount-edit --op rmswap --file /swapfile | High | All | – | – | disable a swap file, remove it, and drop its /etc/fstab entry — param: file*; High risk |
Log management
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetLogrotateStatus | logrotate -d /etc/logrotate.conf | Low | All | – | – | dry-run logrotate to show what would rotate (logrotate -d) — param: config (optional path); read-only |
ConfigureLogRotation | sudo /usr/lib/sysknife/log-edit --op logrotate --name nginx --path /var/log/nginx/*.log --frequency daily --rotate 14 --compress | Medium | All | – | – | write a logrotate drop-in (validated with logrotate -d) — params: name*, path* (log file/glob under /var/log), frequency* (daily|weekly|monthly), rotate* (count 0-1000), compress (bool); Medium risk |
RemoveLogRotation | sudo /usr/lib/sysknife/log-edit --op rm-logrotate --name nginx | Medium | All | – | – | remove a SysKnife-managed logrotate drop-in — param: name*; Medium risk |
ConfigureRemoteSyslog | sudo /usr/lib/sysknife/log-edit --op rsyslog-forward --host logs.example.com --port 514 --protocol tcp | High | All | – | – | forward all logs to a remote collector via rsyslog (validated with rsyslogd -N1) — params: host*, port* (1-65535), protocol* (tcp|udp); High risk — logs leave the host |
RemoveRemoteSyslog | sudo /usr/lib/sysknife/log-edit --op rm-forward | High | All | – | – | stop remote syslog forwarding (remove the rsyslog drop-in) — no params; High risk |
PAM password policy
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetPasswordAging | chage -l alice | Low | All | – | – | show a user's password-aging fields (chage -l) — param: user*; read-only |
SetPasswordAging | sudo chage -M 90 alice | High | All | – | – | set a user's password aging (chage) — params: user*, plus at least one of max_days/min_days/warn_days (0-99999); High risk |
SetPasswordPolicy | sudo /usr/lib/sysknife/pam-edit --op pwquality --minlen 12 | High | All | – | – | set password-quality rules via pwquality — params: at least one of minlen (1-128), dcredit/ucredit/lcredit/ocredit (-64..64); High risk — needs libpam-pwquality enabled in the PAM stack to take effect |
SetAccountLockout | sudo /usr/lib/sysknife/pam-edit --op faillock --deny 5 | High | All | – | – | configure account lockout via faillock — params: at least one of deny (1-1000), unlock_time/fail_interval (seconds, 0-604800); High risk — needs pam_faillock enabled in the PAM stack to take effect |
auditd
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetAuditRules | auditctl -l | Low | All | – | – | list loaded audit rules (auditctl -l) — no params; read-only; needs auditd installed |
AddAuditRule | sudo /usr/lib/sysknife/audit-edit --op add --path /etc/passwd --perms wa --key passwd-watch | Medium | All | – | – | add a persistent audit file-watch rule — params: path* (absolute file/dir), perms* (subset of r/w/x/a), key* (label); Medium risk; needs auditd installed |
RemoveAuditRule | sudo /usr/lib/sysknife/audit-edit --op remove --key passwd-watch | High | All | – | – | remove a SysKnife-managed audit rule by key — param: key*; High risk |
certbot / ACME
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetCertificates | certbot certificates | Low | All | – | – | list certbot-managed certificates — no params; read-only; needs certbot installed |
ObtainCertificate | sudo certbot certonly --non-interactive --agree-tos --standalone -m admin@example.com -d example.com | High | All | – | – | obtain a TLS certificate non-interactively (certbot certonly) — params: domains* (array) or domain* (string), email*, challenge (standalone|nginx|apache, default standalone); High risk; needs certbot + network |
RenewCertificates | sudo certbot renew | Medium | All | – | – | renew due certbot certificates (certbot renew) — no params; Medium risk; needs certbot + network |
Scoped sudoers.d
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetSudoGrants | /usr/lib/sysknife/sudoers-edit --op list | Low | All | – | – | list SysKnife-managed sudoers.d drop-ins — no params; read-only |
GrantSudoAccess | sudo /usr/lib/sysknife/sudoers-edit --op grant --name deploy-restart --user deploy --commands /usr/bin/systemctl --nopasswd | High | All | – | – | grant a scoped sudo rule (validated with visudo before install) — params: name* (^[a-z0-9][a-z0-9_-]*$), user*, commands* ('ALL' or comma-separated ABSOLUTE paths), runas (default root, or 'ALL'), nopasswd (bool); High risk — this configures privilege escalation |
RevokeSudoAccess | sudo /usr/lib/sysknife/sudoers-edit --op revoke --name deploy-restart | High | All | – | – | remove a SysKnife-managed sudoers.d drop-in — param: name*; High risk |
Network
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ConfigureWifi | sudo nmcli device wifi connect CafeHotspot | High | All | – | – | connect to a Wi-Fi network — params: ssid*, password (optional for open networks) |
SetDnsServers | sudo resolvectl dns wlp1s0 1.1.1.1 8.8.8.8 | High | All | – | – | set DNS servers for an interface — params: interface* (e.g. wlp1s0), servers* (string[]) |
ConfigureFirewall | sudo sh -c firewall-cmd --permanent --zone='public' --add-service='ssh' && firewall-cmd --reload | High | All | – | – | add/remove a service in a firewalld zone — params: zone*, service*, enabled* (bool) |
GetFirewallState | firewall-cmd --list-all | Low | All | – | – | show current firewalld zones, open services, and port rules — no params |
GetNetworkStatus | ip -brief addr | Low | All | – | – | show network interfaces, IP addresses, and connection state — no params |
GetListeningPorts | ss -tulpnH | Low | All | – | – | show listening TCP/UDP sockets and the process bound to each (ss -tulpn) — no params; read-only; use for "what is listening on port X?" |
resolvectl
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ResolvectlStatus | resolvectl status | Low | All | – | – | show DNS resolution status for all network interfaces (resolvectl status) — no params; cross-distro (any systemd-resolved host); read-only |
ResolvectlSetDns | sudo resolvectl dns eth0 1.1.1.1 8.8.8.8 | High | All | – | – | set DNS servers for a network interface — params: interface* (e.g. eth0), servers* (string[]); cross-distro; High risk |
Identity / time / locale
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetDateTime | timedatectl | Low | All | – | – | current date, time, timezone, and NTP status (timedatectl) — no params |
SetHostname | sudo hostnamectl set-hostname sysknife-lab | Medium | All | – | – | change the system hostname — param: hostname* (string) |
SetTimezone | sudo timedatectl set-timezone America/Mexico_City | Medium | All | – | – | change the system timezone — param: timezone* (e.g. America/Chicago) |
SetLocale | sudo localectl set-locale en_US.UTF-8 | Medium | All | – | – | change the system locale — param: locale* (e.g. en_US.UTF-8) |
SetNtp | sudo timedatectl set-ntp true | Medium | All | – | – | enable or disable NTP sync — param: enabled* (bool) |
Users & groups
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ListUsers | getent passwd | Low | All | – | – | list all local user accounts — no params |
ListGroups | getent group | Low | All | – | – | list all local groups — no params |
CreateUser | sudo useradd --create-home --home-dir /home/alice --shell /bin/bash alice | High | All | – | – | create a local user account — param: username*; optional: shell, home |
DeleteUser | sudo userdel alice | High | All | – | – | delete a local user account — param: username* |
AddUserToGroup | sudo sh -c grep -q '^wheel:' /etc/group || getent group 'wheel' >> /etc/group; usermod --append --groups 'wheel' 'alice' | High | All | – | – | add a user to a group — params: username*, group* |
RemoveUserFromGroup | sudo sh -c grep -q '^wheel:' /etc/group || getent group 'wheel' >> /etc/group; gpasswd --delete 'alice' 'wheel' | High | All | – | – | remove a user from a group — params: username*, group* |
CreateGroup | sudo groupadd developers | Medium | All | – | – | create a local group — param: group*; optional: system (bool → system GID range) |
DeleteGroup | sudo groupdel developers | High | All | – | – | delete a local group — param: group*; irreversible |
LockUserAccount | sudo usermod --lock alice | High | All | – | – | disable password login for a user without deleting it — param: username* |
UnlockUserAccount | sudo usermod --unlock alice | High | All | – | – | re-enable password login for a locked user — param: username* |
SSH keys
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetAuthorizedKeys | cat /home/alice/.ssh/authorized_keys | Low | All | – | – | list SSH authorized_keys for a user — param: username* |
AddAuthorizedKey | sudo sh -c grep -Fxq 'ssh-ed25519 AAAA...' '/home/alice/.ssh/authorized_keys' 2>/dev/null || echo 'ssh-ed25519 AAAA...' >> '/home/alice/.ssh/authorized_keys' | High | All | – | – | append an SSH public key to a user's authorized_keys — params: username*, public_key* (full key string) |
RemoveAuthorizedKey | sudo sh -c sed -i '\\|^ssh-ed25519 AAAA...$|d' '/home/alice/.ssh/authorized_keys' | High | All | – | – | remove an SSH public key from a user's authorized_keys — params: username*, public_key* (full key string) |
SetSshdOption | sudo /usr/lib/sysknife/sshd-option-edit --option PermitRootLogin --value prohibit-password | High | All | – | – | harden sshd by setting an allowlisted option via a validated drop-in — params: option* (one of PermitRootLogin, PasswordAuthentication, PubkeyAuthentication, X11Forwarding, PermitEmptyPasswords), value* (per-option: yes/no, or prohibit-password/forced-commands-only for PermitRootLogin) |
Package repositories
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ListPackageRepositories | scan /etc/yum.repos.d | Low | All | – | – | list configured DNF/rpm-ostree repos and their enabled state — no params |
AddPackageRepository | write /etc/yum.repos.d/repo-id.repo | High | All | – | – | add a DNF repo — params: repo_id*, repo_url* |
RemovePackageRepository | delete /etc/yum.repos.d/repo-id.repo | Medium | All | – | – | remove a DNF repo — param: repo_id* |
EnablePackageRepository | patch /etc/yum.repos.d/repo-id.repo | Medium | All | – | – | enable a disabled DNF repo — param: repo_id* |
DisablePackageRepository | patch /etc/yum.repos.d/repo-id.repo | Medium | All | – | – | disable a DNF repo without removing it — param: repo_id* |
System info
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetMemoryInfo | free -h | Low | All | – | – | show RAM and swap usage (free -h) — no params |
Containers
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ListContainers | sudo runuser -l testuser -c podman ps --all --format json | Low | All | – | – | list Podman containers for a user — param: username* |
CreateContainer | sudo runuser -l testuser -c podman create --name 'sysknife-dev' 'registry.fedoraproject.org/fedora-toolbox:41' | Medium | All | – | – | create a Podman container — params: username*, name*, image* (e.g. ubuntu:22.04) |
StartContainer | sudo runuser -l testuser -c podman start 'sysknife-dev' | Medium | All | – | – | start a Podman container — params: username*, name* |
StopContainer | sudo runuser -l testuser -c podman stop 'sysknife-dev' | Medium | All | – | – | stop a Podman container — params: username*, name* |
RemoveContainer | sudo runuser -l testuser -c podman rm 'sysknife-dev' | Medium | All | – | – | remove a stopped Podman container — params: username*, name* |
GetContainerInfo | sudo runuser -l testuser -c podman inspect 'sysknife-dev' | Low | All | – | – | inspect a Podman container — params: username*, name* |
Reboot
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
CheckPendingReboot | bash -c if test -f /var/run/reboot-required; then cat /var/run/reboot-required; cat /var/run/reboot-required-pkgs 2>/dev/null; else echo 'No reboot required.'; fi | Low | Ubuntu | – | – | check whether a reboot is pending (/var/run/reboot-required) — no params; Ubuntu/Debian only; read-only |
AppArmor
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
AppArmorStatus | sudo aa-status | Low | Ubuntu | – | – | show status of all loaded AppArmor profiles (aa-status) — no params; Ubuntu only; read-only |
AppArmorEnforce | sudo aa-enforce /etc/apparmor.d/usr.bin.firefox | High | Ubuntu | – | – | put an AppArmor profile into enforce mode (aa-enforce) — param: profile_path* (e.g. /etc/apparmor.d/usr.bin.firefox); Ubuntu only; High risk |
AppArmorComplain | sudo aa-complain /etc/apparmor.d/usr.bin.firefox | High | Ubuntu | – | – | put an AppArmor profile into complain/learning mode (aa-complain) — param: profile_path*; Ubuntu only; High risk (disables MAC enforcement for the profile) |
cloud-init
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
CloudInitStatus | cloud-init status --long | Low | Ubuntu | – | – | show cloud-init provisioning status (cloud-init status --long) — no params; Ubuntu only; read-only |
fail2ban
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
Fail2banStatus | sudo fail2ban-client status | Low | Ubuntu | – | – | show fail2ban jail status — optional param: jail (omit for all jails); Ubuntu only; read-only |
Fail2banBanIp | sudo fail2ban-client set sshd banip 192.0.2.1 | High | Ubuntu | – | – | ban an IP address in a fail2ban jail — params: jail* (string), ip* (IPv4 or IPv6); Ubuntu only; High risk |
Fail2banUnbanIp | sudo fail2ban-client set sshd unbanip 192.0.2.1 | Medium | Ubuntu | – | – | unban an IP address from a fail2ban jail — params: jail*, ip*; Ubuntu only; Medium risk |
ConfigureFail2banJail | sudo /usr/lib/sysknife/fail2ban-jail-edit --name sshd --maxretry 3 | High | Ubuntu | – | – | write a fail2ban jail override (/etc/fail2ban/jail.d/) — params: name*, plus at least one of enabled (bool), maxretry (1-100), bantime/findtime (seconds 0-2592000); Ubuntu only; High risk; needs fail2ban installed |
apt
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
AptUpdate | sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update | Low | Ubuntu | – | – | refresh apt package index (apt-get update) — no params; Ubuntu only |
AptUpgrade | sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get dist-upgrade -y | High | Ubuntu | – | – | upgrade all installed packages via dist-upgrade — no params; Ubuntu only; High risk |
AptInstall | sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y curl | Medium | Ubuntu | – | – | install a package — param: package* (string, e.g. nginx); Ubuntu only |
AptRemove | sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get remove -y curl | Medium | Ubuntu | – | – | remove a package, keep config files — param: package*; Ubuntu only |
AptPurge | sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get purge -y curl | Medium | Ubuntu | – | – | remove a package AND its config files — param: package*; Ubuntu only |
AptAutoremove | sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get autoremove -y | Medium | Ubuntu | – | – | remove automatically-installed packages no longer needed — no params; Ubuntu only |
AptHold | sudo apt-mark hold curl | Medium | Ubuntu | – | – | pin a package at its current version (apt-mark hold) — param: package*; Ubuntu only |
AptUnhold | sudo apt-mark unhold curl | Medium | Ubuntu | – | – | unpin a package to allow upgrades (apt-mark unhold) — param: package*; Ubuntu only |
AptSearch | apt-cache search curl | Low | Ubuntu | – | – | search apt repos for packages — param: term*; Ubuntu only; read-only |
AptListInstalled | dpkg -l | Low | Ubuntu | – | – | list all installed packages (dpkg -l) — no params; Ubuntu only; read-only |
AptShow | apt-cache show curl | Low | Ubuntu | – | – | show package details (version, deps, description) — param: package*; Ubuntu only; read-only |
AptListUpgradable | bash -c apt list --upgradable 2>/dev/null | Low | Ubuntu | – | – | list packages with available upgrades — no params; Ubuntu only; read-only. Use for 'are there pending updates?' or 'what updates are available?' |
AptHistoryList | bash -c grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80 | Low | Ubuntu | – | – | show recent apt transaction history — no params; Ubuntu only; read-only |
ConfigureUnattendedUpgrades | sudo /usr/lib/sysknife/unattended-upgrades-edit --enable | High | Ubuntu | – | – | enable or disable automatic security updates (unattended-upgrades) — param: enabled* (bool); Ubuntu only; High risk |
apt preferences / pinning
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GetAptPins | apt-cache policy | Low | Ubuntu | – | – | show apt pin priorities (apt-cache policy) — param: package (optional); Ubuntu only; read-only |
SetAptPin | sudo /usr/lib/sysknife/apt-pin-edit --op set --name hold-nginx --package nginx --pin version 1.24.* --priority 990 | Medium | Ubuntu | – | – | pin a package to a version/release via /etc/apt/preferences.d — params: name*, package* (glob), pin* (e.g. 'version 1.24.*' or 'release a=noble-security'), priority* (int -1..1000); Ubuntu only; Medium risk |
RemoveAptPin | sudo /usr/lib/sysknife/apt-pin-edit --op remove --name hold-nginx | Medium | Ubuntu | – | – | remove a SysKnife-managed apt pin — param: name*; Ubuntu only; Medium risk |
PPA
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
AddPpa | sudo add-apt-repository -y ppa:deadsnakes/ppa | High | Ubuntu | – | – | add a Launchpad PPA — param: name* in <user>/<ppa> format (e.g. 'deadsnakes/ppa'); Ubuntu only; requires software-properties-common |
RemovePpa | sudo add-apt-repository -y --remove ppa:deadsnakes/ppa | Medium | Ubuntu | – | – | remove a Launchpad PPA — param: name* in <user>/<ppa> format; Ubuntu only |
snap
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
SnapInstall | sudo sh -c snap install --channel=stable firefox && snap refresh --hold firefox | Medium | Ubuntu | – | – | install a snap (auto-holds to prevent auto-refresh) — params: name*; optional: channel (default stable), auto_update (bool, default false); Ubuntu only |
SnapRemove | sudo snap remove firefox | Medium | Ubuntu | – | – | remove a snap — param: name*; Ubuntu only |
SnapRefresh | sudo snap refresh firefox | Medium | Ubuntu | – | – | update a snap or all snaps — param: name (optional, omit for all); Ubuntu only |
SnapHold | sudo snap refresh --hold firefox | Medium | Ubuntu | – | – | pin a snap at its current version (snap refresh --hold) — param: name*; Ubuntu only |
SnapUnhold | sudo snap refresh --unhold firefox | Medium | Ubuntu | – | – | allow a held snap to auto-refresh again — param: name*; Ubuntu only |
SnapList | snap list | Low | Ubuntu | – | – | list installed snaps — no params; Ubuntu only; read-only |
SnapInfo | snap info firefox | Low | Ubuntu | – | – | show snap details (version, channel, description) — param: name*; Ubuntu only; read-only |
SnapRevert | sudo snap revert firefox | Medium | Ubuntu | – | – | revert a snap to its previous revision — param: name*; Ubuntu only |
SnapClassicInstall | sudo snap install --classic code | Medium | Ubuntu | – | – | install a snap with classic confinement (full system access) — param: name*; Ubuntu only |
ufw
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
UfwEnable | sudo ufw --force enable | High | Ubuntu | – | – | enable the ufw firewall — no params; Ubuntu only; High risk |
UfwDisable | sudo ufw disable | High | Ubuntu | – | – | disable the ufw firewall — no params; Ubuntu only; High risk |
UfwAllow | sudo ufw allow 22 | High | Ubuntu | – | – | allow inbound traffic on a port or service — param: port_or_service* (e.g. 22, 22/tcp, OpenSSH); Ubuntu only; High risk |
UfwDeny | sudo ufw deny 23 | High | Ubuntu | – | – | deny inbound traffic on a port or service — param: port_or_service*; Ubuntu only; High risk |
UfwReset | sudo ufw --force reset | High | Ubuntu | – | – | reset ufw to defaults, removing all rules — no params; Ubuntu only; High risk; irreversible |
UfwStatus | sudo ufw status verbose | Low | Ubuntu | – | – | show current ufw status and rules — no params; Ubuntu only; read-only |
UfwDeleteRule | sudo ufw --force delete 1 | High | Ubuntu | – | – | delete a ufw rule by number — param: rule_number* (positive integer from 'ufw status numbered'); Ubuntu only; High risk |
UfwLimit | sudo ufw limit 22 | High | Ubuntu | – | – | add rate-limiting rule on a port/service (>6 connections/30s blocked) — param: target* (e.g. '22' or 'ssh'); Ubuntu only; High risk; use for SSH brute-force mitigation |
netplan
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
NetplanGetConfig | find /etc/netplan -maxdepth 1 -name *.yaml -print -exec cat {} + | Low | Ubuntu | – | – | read current netplan YAML config from /etc/netplan/ — no params; Ubuntu only; read-only |
NetplanApply | sudo netplan apply | High | Ubuntu | – | – | apply netplan network configuration immediately — no params; Ubuntu only; High risk; can disconnect SSH |
NetplanSet | sudo netplan set ethernets.eth0.dhcp4=true | High | Ubuntu | – | – | set a single netplan key to a value — params: key* (e.g. 'ethernets.eth0.dhcp4'), value*; Ubuntu only; High risk; run NetplanApply to activate |
NetplanGenerate | sudo netplan generate | Medium | Ubuntu | – | – | regenerate netplan backend config without applying — no params; Ubuntu only; Medium risk; dry-run before NetplanApply |
distrobox
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
DistroboxList | distrobox list | Low | Ubuntu | – | – | list distrobox containers — no params; Ubuntu only; read-only |
DistroboxCreate | distrobox create --yes --name dev --image ubuntu:24.04 | Medium | Ubuntu | – | – | create a distrobox container — params: name*, image* (e.g. ubuntu:24.04, fedora:41); Ubuntu only |
DistroboxRemove | distrobox rm --force dev | Medium | Ubuntu | – | – | remove a distrobox container — param: name*; Ubuntu only |
GRUB
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
GrubGetKargs | grep -E ^GRUB_CMDLINE_LINUX /etc/default/grub | Low | Ubuntu | – | – | read current GRUB_CMDLINE_LINUX from /etc/default/grub — no params; Ubuntu only; read-only |
GrubSetKargs | sudo /usr/lib/sysknife/grub-kargs-edit --append quiet --delete splash | High | Ubuntu | ✓ | – | modify GRUB kernel arguments and run update-grub — params: append (list), delete (list); Ubuntu only; High risk; requires reboot |
Ubuntu release upgrade
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
UbuntuReleaseUpgrade | sudo do-release-upgrade -f DistUpgradeViewNonInteractive | High | Ubuntu | ✓ | – | upgrade to the next Ubuntu release (do-release-upgrade) — no params; Ubuntu only; High risk; takes 20–45 min; requires reboot; only for explicit distribution upgrade requests |
Ubuntu Pro
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
ProStatus | pro status --all | Low | Ubuntu | – | – | show Ubuntu Pro subscription status — no params; Ubuntu only; read-only |
ProAttach | sudo pro attach <REDACTED> | High | Ubuntu | – | – | attach machine to an Ubuntu Pro subscription — param: token* (credential, never log); Ubuntu only; High risk |
ProDetach | sudo pro detach --assume-yes | High | Ubuntu | – | – | detach from Ubuntu Pro subscription — no params; Ubuntu only; High risk |
EnableProService | sudo pro enable esm-apps --assume-yes | High | Ubuntu | – | – | enable one Ubuntu Pro service (pro enable <service>) — param: service* (one of esm-apps, esm-infra, livepatch, usg, fips, fips-updates, cis, ros, ros-updates, cc-eal, realtime-kernel, landscape, anbox-cloud); Ubuntu only; High risk; needs an attached subscription |
DisableProService | sudo pro disable esm-apps --assume-yes | High | Ubuntu | – | – | disable one Ubuntu Pro service (pro disable <service>) — param: service* (same allowlist as EnableProService); Ubuntu only; High risk |
Livepatch
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
LivepatchStatus | sudo canonical-livepatch status --verbose | Low | Ubuntu | – | – | show Canonical Livepatch kernel-patch status — no params; Ubuntu only; read-only; requires canonical-livepatch installed and Ubuntu Pro |
Multipass
| Action | Command | Risk | Distro | Rb | Ro | Description |
|---|---|---|---|---|---|---|
MultipassList | multipass list | Low | Ubuntu | – | – | list Multipass VMs and their state — no params; Ubuntu only; read-only |
188 actions have an ActionSpec and are tabled above. The full catalogue (KNOWN_ACTION_NAMES) also includes ListJobHistory, which the dispatcher handles before the executor, for 189 total.
The Audit Chain
Every mutating action SysKnife's daemon runs is recorded in a forward, Ed25519-signed hash chain. This page covers what is recorded, how the chain proves tamper and reorder, why the signature scheme is asymmetric rather than a shared-secret MAC, how signed checkpoints close the one gap a hash chain cannot close on its own, and how to verify all of it yourself — independently of the daemon that wrote it. If you are evaluating SysKnife for a security-sensitive deployment, this is the page to try to break.
What gets recorded
Each row in the transaction table (sysknife history) captures the decision
the daemon made about one action, at the moment it made it:
| Field | What it commits to |
|---|---|
seq | Monotonic position in the chain |
key_id | Which signing key generation wrote this row |
transaction_id, request_id | Identifiers for the preview/approve/execute round-trip |
request_hash | Commitment to the exact request that was previewed |
action_name, risk_level | The action and its policy-assigned risk tier |
summary | Human-readable description of the planned action |
approval_id | Which signed approval receipt authorized execution, if any |
warnings_json | Warnings surfaced to the user before approval |
created_at | When the row was written |
These eleven fields are serialized into a stable, self-describing byte
string (tag + value pairs, with a prefix-free escape scheme so no field's
content can be crafted to alias another field's boundary), then signed. The
resulting signature is the row's chain_hash — there is no separate hash
step, because Ed25519 already commits to the message.
The mutable status column (queued → running → succeeded/failed/rolled
back) is not part of the signed content. The chain protects the
authorization decision captured at insert time, not the live execution
state — a scope decision, not an oversight (see Limits).
The hash chain: each entry links to the one before it
Every row also stores prev_chain_hash, and the signed message is:
chain_hash = Ed25519-Sign(signing_key, ROW_DOMAIN || canonical(row_fields) || prev_chain_hash)
Because prev_chain_hash is inside the signed message, a row's signature is
only valid if it was produced with the exact preceding row's hash. This
gives verification two independent failure modes to catch tampering:
- Content tamper — edit any field of a row after the fact (e.g. change
summaryorrisk_level) and its own signature no longer verifies. - Reorder or delete — remove or reorder a row and the next row's
prev_chain_hashno longer matches the actual predecessor, breaking the chain at that point even if every individual signature is otherwise valid.
sysknife audit verify walks the chain in seq order and reports the first
row where either check fails.
Ed25519, not HMAC — why asymmetric matters
The signing key is a 32-byte Ed25519 seed, generated on first daemon start
and stored at <db_dir>/audit-key (mode 0600, refused if group/world
readable), with $SYSKNIFE_AUDIT_KEY_PATH as an override for systemd
deployments. The corresponding public key is written alongside as
<audit-key>.pub.
This is a deliberate replacement for an earlier HMAC-SHA256 design. The distinction matters more than it might look:
- With a symmetric MAC (HMAC), the same secret both produces and checks the tag. Anyone able to verify the chain is, by construction, also able to forge it. A "verified" HMAC chain is a claim the verifier is making about itself — it convinces no one who doesn't already trust the verifier's custody of the secret.
- With Ed25519, the daemon signs with a private key that never leaves its host, and verification uses only the corresponding public key. Publishing the public key gives an auditor, a central log aggregator, or a customer's security team the ability to prove the chain is intact and unforged — without ever being able to forge an entry themselves. This is non-repudiation: a signature that verifies under the daemon's public key could only have been produced by whoever holds the private key.
Two more properties fall out of the implementation:
- Domain separation. Row signatures, checkpoint signatures, and approval
receipts each sign under a distinct, prefix-free domain tag
(
sysknife-audit-row-v1,sysknife-checkpoint-v1,sysknife-approval-receipt-v1). A signature valid in one context can never be replayed as valid in another, even though the underlying key is shared. - Determinism. Ed25519 (RFC 8032) is deterministic — identical inputs always produce the identical signature. This makes chain verification reproducible without any randomness or nonce bookkeeping on the verifier's side.
Signed checkpoints: closing the truncation gap
A hash chain alone cannot detect one specific attack: tail truncation.
If an attacker with write access to the audit database deletes the most
recent K rows, the remaining chain is still perfectly self-consistent —
every row's signature is valid and every prev_chain_hash still points to
its (now-final) predecessor. The chain walk reports Intact, because
nothing in the remaining rows says how long the chain used to be.
Signed checkpoints close this gap using the same idiom Certificate Transparency uses for its logs: periodically, the daemon signs a commitment to the current chain tip —
checkpoint_signature = Ed25519-Sign(signing_key, CHECKPOINT_DOMAIN || seq || chain_tip || created_at)
— and anchors (seq, chain_tip, created_at, signature) into an independent,
append-only sink (a separate PostgreSQL database via sysknife audit checkpoint). Because the checkpoint lives outside the chain it commits to,
an attacker who can edit or truncate the local chain cannot also reach back
and edit the anchored checkpoint. Verifying anchored checkpoints against the
current chain distinguishes three outcomes:
- Consistent — the checkpoint's
seqis still present in the chain and itschain_hashat thatseqmatches the anchored tip. - Truncated — the checkpoint's
seqis no longer present at all (the chain is now shorter than a previously anchored tip proves it once was). - Tip mismatch (rewrite) — the
seqis present, but itschain_hashno longer matches what was anchored (the row was rewritten in place).
sysknife audit checkpoint refuses to anchor a chain that does not already
verify — it never launders a tampered chain into a signed checkpoint.
The anti-truncation guarantee only holds if the checkpoint sink is actually
external to the host being audited. Anchoring checkpoints into the same
database the chain lives in gives no protection: an attacker with write
access to that database can delete the checkpoint row along with the
truncated chain rows. See Audit Storage and Recovery
for how to configure SYSKNIFE_CHECKPOINT_DB.
Verifying it yourself
Verification only ever needs the public key. You do not need daemon
access, the private signing key, or trust in the machine that produced the
log — only the exported <audit-key>.pub file (or its hex contents) and
read access to the transaction database.
Full chain, third-party path:
sysknife audit verify --pubkey audit-key.pub
Machine-readable output for CI or a SIEM pipeline:
sysknife audit verify --pubkey audit-key.pub --json
Anchor and check signed checkpoints against an external database:
SYSKNIFE_CHECKPOINT_DB="postgres://user@host/checkpoints" sysknife audit checkpoint
A clean run looks like:
OK: 4128 row(s) verified in sqlite
A detected tamper looks like:
BROKEN: chain intact for first 891 row(s); row seq=892 (transaction 3f9c1a2e)
does not chain.
expected: valid ed25519 signature
actual: 9b1f...c02a
or, for a reordered/deleted row, a prev_chain_hash mismatch instead of a
signature mismatch — same report shape, different expected/actual
pair. Either failure is reported at the first broken row; rows before it
are still proven intact.
Exit codes matter for automation: 0 intact, 1 broken (a real tamper was
detected), 2 cannot verify (missing key file, unreadable database, wrong
key generation loaded). The 1-vs-2 split is deliberate — a CI job that only
checks for a nonzero exit code must not silently treat "I couldn't check"
the same as "I checked and it's fine."
Limits and honest scope
The chain is strong evidence, not a magic guarantee. Be precise about what it does and does not prove:
- Key custody is the trust root. Anyone who reads the private key file can forge future entries indistinguishably from real ones. Signed checkpoints bound the damage to "after the compromise," since prior anchored tips remain unreproducible from a rewritten chain.
statusis out of scope by design. The chain protects the decision recorded at insert time (what was previewed, at what risk level, with what warnings), not later execution-status transitions. "Chain verifies" does not mean "the action's final status is trustworthy."- Truncation needs an external sink to be detectable at all. Without a
checkpoint anchored off-host, deleting the tail of the chain is invisible
to
sysknife audit verifyby construction. - Key rotation is manual today. Every row carries a
key_id(currently always"v1"); rotation means regenerating the chain from scratch until a planned epoch-aware rotation flow lands. - This is a detection control, not a prevention control. It proves,
after the fact, that something was altered — it does not stop the daemon
from executing an authorized-looking but malicious action. That is the
job of the layered authorization model in
SECURITY.md.
See also: Audit Storage and Recovery for backend
choice, backup procedure, and restore verification; CLI
Reference for the full sysknife audit command surface.
Automatic Rollback
Automatic rollback is one of SysKnife's two hard differentiators (the other is the approval-gated, hash-chained audit log). This page describes exactly what it does, the mechanism behind it, and — just as importantly — where it does not apply yet.
What "automatic rollback" means
When the daemon executes a mutating action and that action's process exits non-zero (or fails to launch), the dispatcher checks two things before returning a result to the caller:
- Did the job land in
JobState::Failed? - Does the executed
ActionSpeccarryrollback_available: true?
Both conditions are evaluated in attempt_rollback_if_needed in
crates/sysknife-daemon/src/dispatcher.rs. If both hold, the daemon looks up
a rollback ActionSpec for the failed action name via
executor::rollback_spec_for and executes it immediately, in the same job,
before the client ever sees a terminal result. There is no separate "approve
the rollback" step — a failed rollback-eligible action rolls back
automatically as part of finishing that job.
If the rollback command itself succeeds, the job transitions to
JobState::RolledBack and the JobResult carries a rollback_ref describing
what was reverted. If the rollback command also fails, the job stays
Failed and the response tells the caller the system may need manual
intervention — SysKnife does not retry indefinitely or attempt a different
recovery path.
Rollback only fires for actions the daemon has positively marked
rollback-eligible. Read-only queries, additive actions like AddUserToGroup,
and RollbackDeployment itself (to avoid recursion) are excluded by
construction — rollback_spec_for returns None for them.
The flag is an equivalence, not a hint, and it's enforced by a test.
ActionSpec.rollback_available means exactly one thing: the daemon will
automatically revert this action if it fails. It does not mean "there
happens to be another action that manually undoes this one" — an action can
be perfectly reversible by hand (e.g. RemovePpa undoes AddPpa) while still
reporting rollback_available: false, because undoing it requires the
operator or agent to explicitly issue that second call; the daemon does not
do it for them on failure. rollback_available_matches_rollback_spec_for_all_actions
in crates/sysknife-daemon/src/executor.rs asserts, for every action in
actions::all_specs() — not a hand-picked subset — that
spec.rollback_available == rollback_spec_for(action_name).is_some(). An
action can't claim automatic rollback it doesn't have, or fail to claim
rollback it does have, without breaking that test.
The mechanism: rpm-ostree rollback
On rpm-ostree-based systems (Fedora Atomic / Silverblue), every mutation to the base OS is an atomic deployment: the tree that boots next is a new, separate deployment, and the previously active deployment is retained until explicitly cleaned up. This gives SysKnife a rollback target it doesn't have to construct itself.
rollback_spec_for in crates/sysknife-daemon/src/executor.rs maps every
rollback-eligible action to the same recovery command,
rpm-ostree rollback, which swaps the pending deployment back to the one
that was active before the failed action ran:
UpdateSystemInstallPackages,RemovePackagesRebaseSystemSetKernelArgumentsAddLayeredPackage,RemoveLayeredPackage,ReplaceLayeredPackage,ResetLayeredPackageOverride,RemoveBasePackage
All of these are rpm-ostree deployment mutations — they change what will boot
next, not the currently running root. That's what makes reverting them a
single, well-defined operation instead of an attempt to undo an arbitrary set
of file writes. No pre-change snapshot has to be staged by SysKnife itself:
rpm-ostree's deployment history already holds the "before" state, and
rollback is rpm-ostree's own primitive for restoring it.
Some rollback-eligible actions (RebaseSystem, SetKernelArguments, layered
package changes) also require a reboot to take effect either way — rollback
restores the deployment that will be booted, not a running-process state.
Honest scope: Ubuntu/apt has no automatic rollback yet
On Ubuntu and other Debian-family systems, SysKnife does not perform
automatic rollback. apt operations mutate the live filesystem directly —
there is no equivalent to an rpm-ostree deployment to revert to. SysKnife
still enforces the approval gate and still writes a hash-chained audit
record for every apt action, including failures, but a failed AptInstall
or AptUpgrade is left as-is. Nothing in rollback_spec_for maps a
Debian-family action name to a rollback command — the function returns
None for all of them, and DEBIAN_ONLY_ACTIONS in
crates/sysknife-core/src/action_family.rs contains no rollback actions at
all. Every Debian-family/Ubuntu-only ActionSpec accordingly reports
rollback_available: false, including ones with an obvious manual inverse —
AddPpa/RemovePpa, NetplanSet, GrubSetKargs, and ProAttach/
ProDetach can all be undone by hand (running the paired action, or another
NetplanSet/GrubSetKargs call), but none of that is automatic, so none of
them set the flag.
This matches docs/distro-support.md, which states plainly: "Atomic
rollback applies to rpm-ostree deployment changes. Ubuntu package operations
are mutable and cannot offer equivalent deployment rollback." Do not
describe SysKnife as rolling back Ubuntu package state in any other doc —
today it gates and audits apt, it does not revert it.
Interaction with the audit chain
Rollback outcomes are not a side channel — they go through the same paths as every other job result:
- The job's terminal status is written via
update_terminal_status, so a successful rollback lands in thetransactionstable asJobState::RolledBack, notFailed. A query over the audit log shows the full story: the action was attempted, it failed, and it was reverted. - The daemon forwards a status-change event to the configured SIEM sink
(
forward_status_change_event) soRolledBackis visible to external monitoring, not just the local database. - The
JobResultreturned to the client includesrollback_ref(currently the literal string"rpm-ostree rollback") so the caller — CLI, MCP tool, or GUI — can show what was reverted.
One nuance worth knowing: the Ed25519 hash chain in
crates/sysknife-daemon/src/audit_chain.rs signs the immutable fields
captured when a transaction row is first inserted (the authorization
decision — who approved what, at what risk level). status is a mutable
column and is explicitly excluded from the signed payload — see the
"Status mutations are not in the chain" note in audit_chain.rs. That means
a completed rollback is durably recorded and forwarded, but the chain's
tamper-evidence guarantees cover the fact that the risky action was approved,
not the specific fact that it was later rolled back. If your threat model
needs the status transition itself to be tamper-evident, that's tracked as
future work (an append-only audit_events table), not something SysKnife
claims today.
See also
- Distro Support — full support matrix and per-family caveats.
- MCP Server — how
sysknife_executesurfacesrollback_refto an AI assistant. - Architecture — the planning/presentation/execution boundary that rollback lives inside.
VM + Daemon Setup
This guide walks through running the SysKnife daemon inside a virtual machine and connecting Claude Code (or any MCP client) to it from the host.
Two transport options are covered:
| Transport | Best for | Requirement |
|---|---|---|
| SSH socket tunnel | Any VM, any hypervisor | SSH access to the guest |
| virtio-vsock | KVM/QEMU guests on the same host | vhost_vsock kernel module |
Both transports produce the same .mcp.json — only the SYSKNIFE_SOCKET value
and the optional SYSKNIFE_TOKEN differ.
1. Install and start the daemon in the VM
SSH into the guest and run:
# Clone the repo (or copy a pre-built binary)
git clone https://github.com/lacs-project/sysknife
cd sysknife
# Build and install (requires Rust stable)
make build
sudo make install
# Enable and start the daemon
sudo systemctl enable --now sysknife-daemon
# Confirm it is running
systemctl status sysknife-daemon
The daemon listens on /run/sysknife/daemon.sock by default when managed by
systemd. You can verify the socket exists:
ls -la /run/sysknife/daemon.sock
Silverblue / Kinoite (rpm-ostree hosts):
make installuses the correct rpm-ostree override flags automatically. No extra steps needed.
2a. SSH socket tunnel (simplest)
This approach works on any Linux VM regardless of hypervisor. It forwards the daemon's Unix socket to a local path on your host.
On the host — open the tunnel
# Replace <user>, <vm-host-or-ip>, and <port> for your setup.
# QEMU user-mode networking typically maps port 22 → 2222 on localhost.
ssh -fN \
-L /tmp/sysknife-vm.sock:/run/sysknife/daemon.sock \
-p 2222 <user>@localhost
For a network-accessible VM (libvirt bridge, cloud VM):
ssh -fN \
-L /tmp/sysknife-vm.sock:/run/sysknife/daemon.sock \
<user>@<vm-ip>
-fNforks the SSH process and keeps the tunnel open without running a remote command. The local socket/tmp/sysknife-vm.sockis created on the host and forwards transparently to the daemon inside the guest.
Configure and connect (SSH tunnel)
Run the setup wizard on the host and choose the integration you want:
npx sysknife-setup
Use --claude, --cursor, --codex, or --all if you want a direct
path without the picker.
When prompted for the daemon socket, enter /tmp/sysknife-vm.sock.
Or set SYSKNIFE_SOCKET manually in .mcp.json:
{
"mcpServers": {
"sysknife": {
"command": "/path/to/sysknife",
"args": ["mcp-server"],
"env": {
"SYSKNIFE_SOCKET": "/tmp/sysknife-vm.sock",
"SYSKNIFE_LLM_PROVIDER": "anthropic",
"ANTHROPIC_API_KEY": "<your-api-key>"
}
}
}
}
Test the SSH tunnel connection
sysknife --dry-run "check disk usage"
2b. virtio-vsock (faster, no SSH required)
virtio-vsock is a kernel-level channel between the host and KVM/QEMU guests. It requires no network stack and survives network changes inside the guest.
Prerequisites
# On the host — load the vhost_vsock module
sudo modprobe vhost_vsock
lsmod | grep vhost_vsock # confirm it loaded
# Persist across reboots
echo vhost_vsock | sudo tee /etc/modules-load.d/vhost_vsock.conf
Your VM must be started with a vsock device. With libvirt add this to the domain XML:
<devices>
<vsock model='virtio'>
<cid auto='yes'/>
</vsock>
</devices>
With QEMU directly:
qemu-system-x86_64 \
... \
-device vhost-vsock-pci,guest-cid=10
Find the guest CID
The Context ID (CID) is the vsock address of the guest. It is assigned by the hypervisor and visible in two ways:
From the host (libvirt):
virsh dominfo <vm-name> | grep -i cid
# or
virsh dumpxml <vm-name> | grep cid
From inside the guest:
cat /sys/class/vsock/local_cid # e.g. prints "10"
Generate and distribute the pre-shared token
vsock connections require a pre-shared token to authenticate the host to the daemon. This prevents other processes on the host from connecting.
The token file holds only the trimmed token itself — no role: prefix.
The role granted to a token-authenticated connection comes from a separate
env var, SYSKNIFE_TOKEN_ROLE (default Dev if unset), read by the daemon
process — not from the file contents.
The default token path is ~/.config/sysknife/token (the daemon's XDG config
directory, next to prefs.md; respects $XDG_CONFIG_HOME if set), not
/etc/sysknife/token.
# On the host — generate a token
openssl rand -hex 32
# e.g.: a3f8c2d1e4b7a0f9...
# On the guest — write the token to the daemon's token file
mkdir -p ~/.config/sysknife
echo "a3f8c2d1e4b7a0f9..." > ~/.config/sysknife/token
chmod 600 ~/.config/sysknife/token
# Grant Admin to token-authenticated vsock connections (default is Dev)
sudo systemctl edit sysknife-daemon
# Add under [Service]:
# Environment=SYSKNIFE_TOKEN_ROLE=admin
# Restart the daemon to pick up the token and role
sudo systemctl restart sysknife-daemon
The token file contains just the raw token, with no role prefix:
# ~/.config/sysknife/token
<paste-token-here>
Configure and connect (vsock)
Run the setup wizard on the host and choose the integration you want:
npx sysknife-setup
Use --claude, --cursor, --codex, or --all if you want a direct
path without the picker.
When prompted for the daemon socket, enter vsock://<CID>:9734 — for example
vsock://10:9734. The wizard detects the vsock:// prefix and prompts for
the token.
Or configure .mcp.json manually:
{
"mcpServers": {
"sysknife": {
"command": "/path/to/sysknife",
"args": ["mcp-server"],
"env": {
"SYSKNIFE_SOCKET": "vsock://10:9734",
"SYSKNIFE_TOKEN": "<your-hex-token>",
"SYSKNIFE_LLM_PROVIDER": "anthropic",
"ANTHROPIC_API_KEY": "<your-api-key>"
}
}
}
}
Test the vsock connection
sysknife --dry-run "check disk usage"
3. Reload the MCP client
In Claude Code, run /reload-plugins to pick up the new .mcp.json. The
sysknife_plan and sysknife_execute tools should appear in the tool list.
Troubleshooting
"connection refused" or "no such file or directory"
- SSH tunnel: confirm the tunnel is running:
ls -la /tmp/sysknife-vm.sock - vsock: confirm
vhost_vsockis loaded (lsmod | grep vhost_vsock) and the CID is correct (cat /sys/class/vsock/local_cidinside the guest) - Confirm the daemon is running:
systemctl status sysknife-daemoninside the guest
"SYSKNIFE_TOKEN is not set; vsock connections require a pre-shared token"
Set SYSKNIFE_TOKEN in the env block of .mcp.json (see above).
"authentication failed" on vsock
The token on the host (SYSKNIFE_TOKEN in .mcp.json) does not match the
contents of ~/.config/sysknife/token on the guest. Verify both match
exactly (the file should hold just the raw token, no role: prefix).
SSH tunnel drops when the terminal closes
Use ssh -fN (forks, no command) or run it via a systemd user unit or
tmux/screen session. Alternatively switch to vsock which does not depend
on SSH.
vsock not available in the guest
Check: ls /dev/vsock inside the guest. If absent, the VM was started without
a vsock device — add the <vsock> element to the libvirt XML (see above) and
restart the VM.
Security notes
- SSH tunnel: the tunnel is authenticated by your SSH keypair. No extra secrets are needed.
- vsock: the pre-shared token (
SYSKNIFE_TOKEN) prevents other host processes from connecting. Keep it out of version control — add.mcp.jsonto.gitignore. - Socket file permissions: the daemon socket at
/run/sysknife/daemon.sockis owned byroot:sysknifewith mode0660— only members of thesysknifegroup can connect locally. The SSH tunnel and vsock bypass this by terminating at the daemon's authenticated IPC layer.
Audit storage and recovery
SysKnife's durable transaction audit log has two backends:
- SQLite is the default at
/var/lib/sysknife/daemon.sqlite. It is suitable for a single host when that file and its audit key are backed up. - PostgreSQL is recommended when audit history must survive a host loss or be centralized. The live contract is tested against PostgreSQL 17.
Journald and optional RFC 5424 syslog forwarding are complementary operational signals. They are best effort and are not the database of record.
PostgreSQL configuration
[storage]
backend = "postgres"
url = "postgres://sysknife:${PG_PASSWORD}@db.example.com:5432/sysknife_audit?sslmode=verify-full"
[storage.pool]
max_connections = 8
acquire_timeout_secs = 10
statement_cache_capacity = 100
Use a dedicated database and role. The role currently needs connection, schema usage/create, and DML privileges because the daemon applies migrations at startup. Do not grant cluster administration or access to unrelated databases. Store the URL in a root-readable environment file or secret manager, not in a world-readable config file.
On startup, the daemon:
- Opens a transaction and takes a PostgreSQL advisory transaction lock.
- Creates
schema_migrationsif needed. - Applies each pending migration once and records its version.
- Refuses to start if the database schema is newer than the binary.
The CI contract verifies migration idempotence, approval claims, history, and hash-chain validation against a live PostgreSQL server.
Transaction-mode PgBouncer deployments may require
statement_cache_capacity = 0. CockroachDB is not currently supported: its
PostgreSQL wire compatibility does not include the migration and concurrency
semantics SysKnife relies on.
Provider notes
Use a writer endpoint and TLS certificate verification wherever the provider supports it.
| Provider | Endpoint guidance |
|---|---|
| AWS RDS / Aurora PostgreSQL | Use the writer endpoint with sslmode=verify-full and the AWS CA bundle. Do not use an Aurora reader endpoint. |
| GCP Cloud SQL / AlloyDB | Prefer the Auth Proxy or connector sidecar; connect to its loopback endpoint. |
| Azure Database for PostgreSQL | Use Flexible Server with sslmode=verify-full and the current Azure CA chain. |
| Supabase | Direct port 5432 supports prepared statements. For a transaction-mode pooler, set the statement cache to 0. |
| Neon | Use TLS; increase acquire_timeout_secs if scale-to-zero cold starts exceed the default. |
| Self-hosted PostgreSQL | Use verify-full, a private network, restricted role, monitored storage, and tested backups. |
Provider CA bundles and authentication behavior change. Verify current provider documentation during deployment rather than copying a stale certificate URL from this guide.
SQLite backup and restore
Back up the database and audit key together. The key defaults to
/var/lib/sysknife/audit-key and should remain mode 0600.
sudo systemctl stop sysknife-daemon
sudo sqlite3 /var/lib/sysknife/daemon.sqlite \
".backup '/var/backups/sysknife/daemon.sqlite'"
sudo install -m 0600 /var/lib/sysknife/audit-key \
/var/backups/sysknife/audit-key
sudo systemctl start sysknife-daemon
Restore both files while the daemon is stopped, preserve ownership and modes,
then run sysknife doctor and sysknife audit verify. A database restored
without its matching key cannot validate new signatures against the original
key identity.
PostgreSQL backup and restore
For production, enable provider-managed point-in-time recovery and retention appropriate to your incident-response policy. Also take a logical backup before every SysKnife upgrade that introduces a migration.
pg_dump --format=custom --no-owner --file=sysknife-audit.dump "$DATABASE_URL"
createdb sysknife_restore_test
pg_restore --no-owner --dbname=sysknife_restore_test sysknife-audit.dump
Restore into an isolated database first. Point a temporary SysKnife config at that database, provide a copy of the matching audit key, then verify:
XDG_CONFIG_HOME=/tmp/sysknife-restore-config \
SYSKNIFE_AUDIT_KEY_PATH=/secure/restore-test/audit-key \
sysknife audit verify
XDG_CONFIG_HOME=/tmp/sysknife-restore-config sysknife doctor
The temporary config's [storage] URL must target the isolated restore, never
the production database.
Do not claim recoverability until a timed restore drill has succeeded and its recovery point and recovery time are recorded. Database backups and audit-key backups should have separate access controls so compromise of one system does not silently permit forged history.
Forwarding and retention
- The transaction database is authoritative for preview, approval, execution, status, and chain history.
- The safety-fence JSONL file records plans rejected before daemon execution.
- Journald receives safety-fence events and audit-chain watermarks where available.
- UDP syslog forwarding can lose, reorder, or duplicate messages. Treat it as alerting telemetry, not a durable copy.
Define retention, deletion authorization, backup encryption, restore testing, and SIEM ingestion before production use. Monitor daemon startup failures, database capacity, backup age, restore-test age, and chain verification.
Ubuntu action reference
Canonical CLI invocations for the core Ubuntu action families in SysKnife (apt, snap, ufw, netplan, distrobox), verified against Ubuntu 24.04 LTS (Noble Numbat) authoritative sources on 2026-04-25.
This page covers the source-verified core families only, with manpage citations, a known-divergence list, and a live-validation checklist. For the complete catalogue of every action (all families, generated from the code) see the Action Reference; the narrative rationale is in Typed Actions.
Sources consulted:
- Ubuntu 24.04 manpage:
apt-get(8)— https://manpages.ubuntu.com/manpages/noble/en/man8/apt-get.8.html - Ubuntu 24.04 manpage:
ufw(8)— https://manpages.ubuntu.com/manpages/noble/en/man8/ufw.8.html - Ubuntu 24.04 manpage:
netplan(8)— https://manpages.ubuntu.com/manpages/noble/en/man8/netplan.8.html - Ubuntu 24.04 manpage:
distrobox(1)— https://manpages.ubuntu.com/manpages/noble/man1/distrobox.1.html - Snapcraft official docs — https://snapcraft.io/docs/
- Snapcraft manage-updates guide — https://snapcraft.io/docs/how-to-guides/manage-snaps/manage-updates/
- distrobox-rm official docs — https://distrobox.it/usage/distrobox-rm/
- dpkg status flag reference — https://manpages.ubuntu.com/manpages/focal/man1/dpkg-query.1.html
apt (apt-get, apt-mark, apt-cache, dpkg)
AptUpdate — Refresh package index
- Canonical:
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - SysKnife argv:
["sudo", "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "apt-get", "update"] - Status: ✅ matches
- Notes:
DEBIAN_FRONTEND=noninteractivesuppresses all debconf prompts.NEEDRESTART_MODE=aauto-restarts services post-install without prompting. Theenvwrapper is the standard way to inject these into asudoinvocation becausesudostrips most environment variables by default.
AptUpgrade — Full system upgrade
- Canonical:
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get dist-upgrade -y - SysKnife argv:
["sudo", "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "apt-get", "dist-upgrade", "-y"] - Status: ✅ matches
- Notes:
dist-upgraderesolves dependency changes by removing packages where necessary.upgrade(withoutdist-) is safer but may not complete all upgrades. The choice ofdist-upgradeis intentional and documented in the code. Exit code 0 = success; 100 = error. - Recommendation: Consider adding
--no-install-recommendsto reduce blast radius; not strictly required but commonly advised for headless/server use.
AptInstall — Install package
- Canonical:
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y <pkg> - SysKnife argv:
["sudo", "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "apt-get", "install", "-y", "<pkg>"] - Status: ✅ matches
- Notes: Exit code 0 = success; 100 = error (package not found, broken deps, etc.).
-y/--assume-yesrequired for non-interactive use.
AptRemove — Remove package (keep config files)
- Canonical:
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get remove -y <pkg> - SysKnife argv:
["sudo", "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "apt-get", "remove", "-y", "<pkg>"] - Status: ✅ matches
- Notes: Config files in
/etcare preserved. Usepurgeto also remove them.
AptPurge — Remove package and its config files
- Canonical:
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get purge -y <pkg> - SysKnife argv:
["sudo", "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "apt-get", "purge", "-y", "<pkg>"] - Status: ✅ matches
AptAutoremove — Remove orphaned dependency packages
- Canonical:
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get autoremove -y - SysKnife argv:
["sudo", "env", "DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a", "apt-get", "autoremove", "-y"] - Status: ✅ matches
AptHold — Pin package at current version
- Canonical:
sudo apt-mark hold <pkg> - SysKnife argv:
["sudo", "apt-mark", "hold", "<pkg>"] - Status: ✅ matches
- Notes:
apt-mark holddoes not requireDEBIAN_FRONTEND; it only writes to the dpkg hold file (/var/lib/dpkg/info/<pkg>.list) without invoking debconf or needrestart.
AptUnhold — Remove version pin
- Canonical:
sudo apt-mark unhold <pkg> - SysKnife argv:
["sudo", "apt-mark", "unhold", "<pkg>"] - Status: ✅ matches
AptSearch — Search repositories
- Canonical:
apt-cache search <term> - SysKnife argv:
["apt-cache", "search", "<term>"](no sudo) - Status: ✅ matches
- Notes:
apt-cacheis read-only and does not require sudo. Output format is<pkg-name> - <short description>, one line per match.
AptListInstalled — List installed packages
- Canonical (SysKnife uses):
dpkg -l - Alternative (better for parsing):
dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' - SysKnife argv:
["dpkg", "-l"] - Status: ⚠ functional but suboptimal for machine parsing
- Notes:
dpkg -loutput format:- Header line:
Desired=Unknown/Install/Remove/Purge/Hold - Data lines:
<desired><status><error-flag> <pkg> <version> <arch> <description> - Two-letter prefix meaning:
ii= installed+ok,rc= removed+config-files-present,hi= on-hold+installed,un= not installed. - The output is human-oriented with variable-width columns padded to align.
dpkg-query -Wis cleaner for programmatic parsing but is not a bug —dpkg -lis well-understood and widely used.
- Header line:
- No sudo required.
AptShow — Show package metadata
- Canonical:
apt-cache show <pkg> - SysKnife argv:
["apt-cache", "show", "<pkg>"](no sudo) - Status: ✅ matches
- Notes: Output is RFC 822 style (key: value). No sudo required.
AptListUpgradable — List packages with available upgrades
- Canonical:
apt list --upgradable - SysKnife argv:
["bash", "-c", "apt list --upgradable 2>/dev/null"](no sudo) - Status: ✅ matches
- Notes: Read-only.
2>/dev/nullsuppresses apt's "WARNING: apt does not have a stable CLI interface" notice on stderr. No sudo required.
AptHistoryList — Show recent apt transaction history
- Canonical:
grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80 - SysKnife argv:
["bash", "-c", "grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80"](no sudo) - Status: ✅ matches
- Notes: Read-only file inspection of
/var/log/apt/history.log. Returns the 80 most recent log lines, covering the last severalStart-Datetransaction blocks, for auditing what was installed/removed/upgraded.
ConfigureUnattendedUpgrades — Enable or disable automatic security updates
- Canonical: no single canonical CLI — SysKnife ships a helper script
- SysKnife argv:
["sudo", "/usr/lib/sysknife/unattended-upgrades-edit", "--enable"]or--disable— param:enabled(bool) - Status: ✅ implemented via bundled helper
- Notes: the helper (
packaging/sysknife-unattended-upgrades-edit) takes no free-form input — it writes one of two fixed file contents to/etc/apt/apt.conf.d/20auto-upgrades(no injection surface), and when enabling, first ensuresunattended-upgradesis installed. Risk: High — toggles whether the host auto-applies security updates unattended.
snap
SnapInstall — Install snap (with auto-hold)
- Canonical (with hold):
sudo sh -c "snap install --channel=<ch> <name> && snap refresh --hold <name>" - Canonical (auto-update allowed):
sudo snap install --channel=<ch> <name> - SysKnife argv (auto_update=false):
["sudo", "sh", "-c", "snap install --channel=<ch> <name> && snap refresh --hold <name>"] - SysKnife argv (auto_update=true):
["sudo", "snap", "install", "--channel=<ch>", "<name>"] - Status: ✅ matches
- Notes:
--channelformat is<track>/<risk>(e.g.,latest/stable,beta). Default channel when none specified by SysKnife:stable(passed as--channel=stable). The hold-after-install pattern is correct:snap refresh --hold <name>pins the snap indefinitely. Exit code 0 = success. - Classic confinement:
--classicis required for snaps with classic confinement (e.g., VS Code:snap install --classic code).SnapInstallitself does not expose aclassicflag — that case is handled by a separate action,SnapClassicInstall(see below), rather than a flag on this one.
SnapRemove — Remove snap
- Canonical:
sudo snap remove <name> - Canonical (purge user data too):
sudo snap remove --purge <name> - SysKnife argv:
["sudo", "snap", "remove", "<name>"] - Status: ✅ matches (basic remove)
- Notes: Without
--purge, user data in~/snap/<name>/is archived as a snapshot, not deleted. SysKnife does not expose--purge; this is a deliberate conservative choice (data is recoverable). Not a bug.
SnapRefresh — Update snap(s)
- Canonical (one snap):
sudo snap refresh <name> - Canonical (all snaps):
sudo snap refresh - SysKnife argv:
["sudo", "snap", "refresh", "<name>"]or["sudo", "snap", "refresh"] - Status: ✅ matches
SnapHold — Prevent auto-refresh
- Canonical:
sudo snap refresh --hold <name> - SysKnife argv:
["sudo", "snap", "refresh", "--hold", "<name>"] - Status: ✅ matches
- Notes:
--holdwithout a=<duration>argument defaults toforever. Confirmed in Snapcraft docs: "If no duration is specified, the time duration defaults to forever." This is correct for the SysKnife intent.
SnapUnhold — Re-enable auto-refresh
- Canonical:
sudo snap refresh --unhold <name> - SysKnife argv:
["sudo", "snap", "refresh", "--unhold", "<name>"] - Status: ✅ matches
- Notes: Both
--holdand--unholdare flags onsnap refresh, not separate subcommands. SysKnife correctly uses this form.
SnapList — List installed snaps
- Canonical:
snap list - SysKnife argv:
["snap", "list"](no sudo) - Status: ✅ matches
- Output columns:
Name Version Rev Tracking Publisher NotesNotesfield can contain:classic,disabled,-, or hold info.- No
--jsonflag exists in snapd as of 24.04. Parsing is tab/space aligned.
SnapInfo — Show snap details
- Canonical:
snap info <name> - SysKnife argv:
["snap", "info", "<name>"](no sudo) - Status: ✅ matches
- Output: Multi-section human-readable; includes name, summary, publisher, available channels, installed revision, and tracking channel.
SnapRevert — Revert to the previous revision
- Canonical:
sudo snap revert [--revision=<rev>] <name> - SysKnife argv:
["sudo", "snap", "revert", "<name>"]— param:name - Status: ✅ matches (basic revert, no explicit
--revision) - Notes: Rolls the snap back one revision; the reverted-from revision is
preserved on disk and the revert itself can be undone with
SnapRefresh. Risk: Medium.
SnapClassicInstall — Install with classic confinement
- Canonical:
sudo snap install --classic <name> - SysKnife argv:
["sudo", "snap", "install", "--classic", "<name>"]— param:name - Status: ✅ matches
- Notes: Separate action from
SnapInstallrather than a flag on it (see the classic-confinement note above). Classic-confined snaps get full system access with no sandbox, so this carries more risk than a sandboxed install — Risk: Medium.
ufw
UfwEnable — Enable firewall
- Canonical:
sudo ufw --force enable - SysKnife argv:
["sudo", "ufw", "--force", "enable"] - Status: ✅ matches
- Notes:
--forcesuppresses the interactive "Proceed with operation (y|n)?" prompt. Required for daemon/non-interactive invocation. The man page confirms: "SSH administrators should use--force enablewhen enabling remotely." Exit code 0 = success.
UfwDisable — Disable firewall
- Canonical:
sudo ufw disable - SysKnife argv:
["sudo", "ufw", "disable"] - Status: ✅ matches
- Notes:
disabledoes not prompt;--forceis not needed here (onlyenableandresethave interactive prompts in the default ufw implementation).
UfwAllow — Allow traffic
- Canonical:
sudo ufw allow <port_or_service> - Extended canonical:
sudo ufw allow <port>/<proto>orsudo ufw allow from <ip> to any port <port> - SysKnife argv:
["sudo", "ufw", "allow", "<port_or_service>"] - Status: ✅ matches (simple form)
- Notes: The simple form (
ufw allow 22,ufw allow 22/tcp,ufw allow OpenSSH) covers the most common cases. SysKnife does not currently expose direction (in/out) or source IP filtering — not a bug for the current scope.
UfwDeny — Deny traffic
- Canonical:
sudo ufw deny <port_or_service> - SysKnife argv:
["sudo", "ufw", "deny", "<port_or_service>"] - Status: ✅ matches
UfwReset — Reset to defaults
- Canonical:
sudo ufw --force reset - SysKnife argv:
["sudo", "ufw", "--force", "reset"] - Status: ✅ matches
- Notes:
--forcesuppresses the "Proceed with operation?" prompt.resetdisables ufw AND removes all rules. Backup copies of old rules are written to/etc/ufw/*.rules.YYYYMMDD_HHMMSS— SysKnife does not document this side effect but it is not a functional issue.
UfwStatus — Show firewall status
- Canonical:
sudo ufw status verbose - SysKnife argv:
["sudo", "ufw", "status", "verbose"] - Status: ✅ matches
- Notes:
verboseadds default policies (incoming/outgoing/routed) to the output. Withoutverbose,ufw statusshows only active rules and the Enabled/Disabled state.status numberedadds rule numbers, which is how a user finds therule_numberargument forUfwDeleteRule. - Output format: Plain text, columns:
To Action Fromwith directional qualifiers (ALLOW,ALLOW IN,ALLOW OUT). No JSON output available. - sudo required: Yes — ufw reads privileged iptables state.
UfwDeleteRule — Delete a rule by number
- Canonical:
sudo ufw --force delete <rule_number> - SysKnife argv:
["sudo", "ufw", "--force", "delete", "<rule_number>"]— param:rule_number(positive integer fromufw status numbered) - Status: ✅ matches
- Notes:
--forcesuppresses the confirmation prompt, same asUfwEnable/UfwReset. Risk: High — a mistaken deletion can expose services or drop needed traffic. Rejectsrule_number == 0(ufw rule numbers are 1-based).
UfwLimit — Rate-limit connections
- Canonical:
sudo ufw limit <port_or_service> - SysKnife argv:
["sudo", "ufw", "limit", "<target>"]— param:target(port number,port/proto, or app profile name, e.g.ssh) - Status: ✅ matches
- Notes: Blocks IPs making more than 6 connections within 30 seconds — the standard ufw brute-force mitigation, commonly applied to SSH (port 22). Risk: High — can inadvertently rate-limit legitimate traffic under high-connection workloads.
netplan
NetplanGetConfig — Read current configuration
- SysKnife argv:
["find", "/etc/netplan", "-maxdepth", "1", "-name", "*.yaml", "-print", "-exec", "cat", "{}", "+"] - Canonical alternative:
sudo netplan get(merges all YAML from/etc/netplan/,/lib/netplan/, and/run/netplan/into a single output) - Status: ⚠ functional divergence — not wrong, but differs from canonical
- Notes: SysKnife shells out to
finddirectly (nobash -c), printing each matched file's path followed by its contents.netplan getreturns a single merged representation instead. Thefindapproach:- Does not merge configs across
/lib/netplan/and/run/netplan/overlay paths. - May include YAML comments;
netplan getoutput is stripped and canonical. - Surfaces three distinct states instead of collapsing them: a missing/
permission-denied
/etc/netplanmakesfindexit non-zero with a stderr diagnostic, while a directory with no*.yamlfiles exits 0 with empty stdout. (An earlier version shelled out tobash -c "cat /etc/netplan/*.yaml 2>/dev/null || echo 'no netplan files found'", which collapsed all three states into one fake-success exit 0 — fixed by moving tofinddirectly.)
- For read-only inspection, this is harmless and does not require sudo for
files readable by the daemon user.
netplan getrequires sudo on most Ubuntu installs because/etc/netplan/is root-owned mode 600. - Not a blocking bug. Document as a known divergence.
- Does not merge configs across
NetplanApply — Apply configuration
- Canonical:
sudo netplan apply - SysKnife argv:
["sudo", "netplan", "apply"] - Status: ✅ matches
- Notes:
netplan applyimmediately reconfigures network interfaces. Can terminate SSH sessions if IP changes.netplan try(with a rollback timeout) is safer for interactive use but requires a TTY and is intentionally not implemented as a daemon action (documented in the source). Exit code 0 = success; non-zero on YAML parse or backend errors.
NetplanSet — Set a single netplan key
- Canonical:
sudo netplan set <key>=<value> - SysKnife argv:
["sudo", "netplan", "set", "<key>=<value>"]— params:key(e.g.ethernets.eth0.dhcp4),value - Status: ✅ matches
- Notes:
key=valueis passed as a single argument with no shell involved;validated_safe_arg(executor boundary) rejects spaces invalue, so quoting a multi-word value would only inject literal quote bytes. Risk: High — modifies the active netplan configuration in-memory. RunNetplanApplyafterward to activate the change.
NetplanGenerate — Regenerate backend config without applying
- Canonical:
sudo netplan generate - SysKnife argv:
["sudo", "netplan", "generate"] - Status: ✅ matches
- Notes: Risk: Medium. Regenerates
systemd-networkd/NetworkManagerbackend config files from the current netplan YAML but does not reload interfaces — safe to use as a dry-run check beforeNetplanApply.
Not implemented — netplan get
- netplan get: Returns merged netplan config as YAML across
/etc/netplan/,/lib/netplan/, and/run/netplan/. More canonical than thefind-basedNetplanGetConfig, which only reads/etc/netplan/. See theNetplanGetConfigdivergence above — this is a coverage gap, not a bug in existing code.
distrobox
DistroboxList — List containers
- Canonical:
distrobox list - SysKnife argv:
["distrobox", "list"] - Status: ✅ matches
- Notes: No sudo required (user-namespace containers).
--no-colorflag is available for cleaner parsing but not used — not a bug. Output includes: container name, status (Up/Exited), image. No JSON output.
DistroboxCreate — Create container
- Canonical:
distrobox create --yes --name <name> --image <image> - SysKnife argv:
["distrobox", "create", "--yes", "--name", "<name>", "--image", "<image>"] - Status: ✅ matches (was missing
--yesuntil 2026-04-26 — fixed in PR that landsdistrobox_create_includes_yes_flagtest) - Why
--yesmatters: Without--yes/-Y, distrobox-create prompts the user to confirm pulling the image if it is not already cached locally. In a daemon context with no TTY, this prompt hangs indefinitely. The canonical non-interactive form requires--yes(documented as "non-interactive, pull images without asking").- Fix: Add
"--yes"to the argv:["distrobox", "create", "--name", "<name>", "--image", "<image>", "--yes"]
- Fix: Add
DistroboxRemove — Remove container
- Canonical:
distrobox rm --force <name> - SysKnife argv:
["distrobox", "rm", "--force", "<name>"] - Status: ✅ matches
- Notes:
--force/-fsets bothforce=1andnon_interactive=1in distrobox internals — no confirmation prompt. The flag name--force(not-f) is correct and matches the official docs. No sudo required unless the container was created with--root.
Bug list
| # | Family | Action | Description | Status |
|---|---|---|---|---|
| B1 | distrobox | DistroboxCreate | Missing --yes flag caused interactive prompt hang in daemon context | ✅ fixed 2026-04-26 — --yes added + regression test |
Divergences (not bugs, but worth knowing)
| # | Family | Action | Description |
|---|---|---|---|
| D1 | netplan | NetplanGetConfig | Uses find /etc/netplan -maxdepth 1 -name '*.yaml' -print -exec cat {} + instead of netplan get. Only reads /etc/netplan/; does not merge /lib/netplan/ or /run/netplan/ overlays. |
| D2 | apt | AptListInstalled | Uses dpkg -l (human-oriented, wide output). dpkg-query -W -f='${Package}\t${Version}\n' is cleaner for machine parsing. |
| D3 | snap | SnapRemove | No --purge flag exposed. User data snapshots are retained (safe default). |
Live validation checklist
Run these commands inside an Ubuntu 24.04 VM to verify each action family's
preconditions and post-state. All commands that need root are shown with sudo.
apt — checklist
# Precondition: verify apt-get is available and lock is free
which apt-get && apt-get --version
sudo fuser /var/lib/dpkg/lock 2>/dev/null && echo "LOCKED" || echo "free"
# AptUpdate
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update
echo "exit=$?"
# AptInstall (use a small safe package)
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y curl
dpkg -l curl | grep '^ii' # should print one ii line
# AptHold / AptUnhold
sudo apt-mark hold curl
apt-mark showhold | grep curl
sudo apt-mark unhold curl
apt-mark showhold | grep curl && echo "BUG: still held" || echo "unhold ok"
# AptRemove / AptPurge (install something small first)
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y hello
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get remove -y hello
dpkg -l hello | grep '^rc' # rc = config files remain
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get purge -y hello
dpkg -l hello | grep '^un' # un = not installed, no config files
# AptAutoremove
sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get autoremove -y
echo "exit=$?"
# AptSearch
apt-cache search curl | head -5
# AptShow
apt-cache show curl | grep '^Package:'
# AptListInstalled
dpkg -l | head -10
dpkg -l | grep '^ii' | wc -l # count installed packages
snap — checklist
# Precondition: snapd running
snap version
systemctl is-active snapd
# SnapList
snap list # columns: Name Version Rev Tracking Publisher Notes
# SnapInstall (use hello-world, tiny and always available)
sudo snap install hello-world
snap list | grep hello-world # should appear with Notes: -
# SnapHold
sudo snap refresh --hold hello-world
snap list hello-world | awk '{print $NF}' # Notes column should show "held"
# SnapUnhold
sudo snap refresh --unhold hello-world
snap list hello-world
# SnapRefresh
sudo snap refresh hello-world
echo "exit=$?"
# SnapInfo
snap info hello-world | grep '^name:'
# SnapRemove
sudo snap remove hello-world
snap list | grep hello-world && echo "BUG: still listed" || echo "removed ok"
ufw — checklist
# Precondition: ufw installed
which ufw && ufw --version
# UfwStatus (initial state)
sudo ufw status verbose
echo "exit=$?"
# UfwEnable
sudo ufw --force enable
sudo ufw status | grep 'Status: active'
# UfwAllow
sudo ufw allow 8080/tcp
sudo ufw status verbose | grep 8080
# UfwDeny
sudo ufw deny 8081/tcp
sudo ufw status verbose | grep 8081
# UfwDisable
sudo ufw disable
sudo ufw status | grep 'Status: inactive'
# UfwReset (destructive — do last)
sudo ufw --force reset
sudo ufw status | grep 'Status: inactive'
# Verify backup files were created
ls /etc/ufw/*.rules.* 2>/dev/null || echo "no backup files (expected on fresh VM)"
netplan — checklist
# Precondition: netplan installed and config present
which netplan && netplan --version
ls /etc/netplan/
# NetplanGetConfig (SysKnife approach)
find /etc/netplan -maxdepth 1 -name '*.yaml' -print -exec cat {} +
# Canonical alternative
sudo netplan get
# NetplanGenerate (dry run — regenerates backend config, does not apply)
sudo netplan generate
echo "exit=$?"
# NetplanSet (in-memory change — run NetplanApply after to activate)
sudo netplan set ethernets.eth0.dhcp4=true
# NetplanApply (safe: only apply if no config change)
# WARNING: can disconnect SSH — run only on a console or via OOB access if changing IP
sudo netplan apply
echo "exit=$?"
# Verify network is still up
ip addr show
distrobox — checklist
# Precondition: distrobox and podman/docker installed
which distrobox && distrobox --version
which podman || which docker
# DistroboxList
distrobox list
# DistroboxCreate (always pass --yes — without it the daemon hangs without a TTY).
# The canonical form is:
distrobox create --name test-sysknife --image ubuntu:24.04 --yes
echo "exit=$?"
# Verify created
distrobox list | grep test-sysknife
# DistroboxRemove
distrobox rm --force test-sysknife
echo "exit=$?"
# Verify removed
distrobox list | grep test-sysknife && echo "BUG: still listed" || echo "removed ok"
SysKnife vs. the alternatives
Letting an AI operate a Linux host is now a real category, and several tools address it. This page is an honest map of that space and where SysKnife sits in it. The short version: most tools either filter shell strings or watch after the fact. SysKnife removes the shell string entirely, gates execution with a hard interlock, and produces an audit trail anyone can verify with a public key.
At a glance
generic mcp-shell / mcp-firewall | AIShell-Gate | gate-oc-audit | MCP traffic gateways | SysKnife | |
|---|---|---|---|---|---|
| What the AI emits | shell strings (allowlisted) | typed JSON commands | nothing (observe-only) | proxied MCP calls | typed semantic actions |
| Blocks execution on the host | policy filter | yes | no (advisory) | at the proxy, not the host | yes — server-enforced interlock |
| Audit scheme | logs | HMAC-SHA256 (symmetric) | Merkle tree + optional chain anchor | logs / immutable trails | Ed25519 (asymmetric) |
| Third-party verifiable | no | no — verifier holds the secret | via an external network | varies | yes — public key alone |
| Automatic rollback | no | no | no | no | yes (rpm-ostree) |
| License | OSS | proprietary | Apache-2.0 (audit only) | OSS | MIT (spec is CC0) |
The two things nobody else pairs: public-key-verifiable audit and automatic rollback. Everything below explains why those matter.
Typed actions beat string allowlists
The common design is to let the model produce a shell command and then gate that string with an allowlist or regex. Published red-team work ("GuardFall") found that the large majority of agents can talk their way past such guards — the guard is filtering a language rich enough to hide intent.
SysKnife takes the string out of the loop. The model proposes typed actions from a fixed catalog; the daemon builds the command internally and is the sole executor. There is no shell string to smuggle a payload into. See Typed Actions for the mechanism.
Public-key audit beats symmetric audit
An audit trail is only worth what its verification proves. SysKnife signs the hash chain with Ed25519: verification needs only the public key, so a third party — an auditor, a customer, a court — can confirm the log is intact and was produced by the holder of the private key, and the signer cannot later deny it. See The Audit Chain.
Symmetric schemes (HMAC-SHA256, as used by AIShell-Gate) use one shared secret for both signing and verifying. Whoever can verify can also forge, so a "valid" log convinces no one who did not already hold the secret. It is a good integrity check for yourself; it is not non-repudiable evidence for anyone else.
Automatic rollback
On atomic hosts (Fedora/Silverblue via rpm-ostree), a failed change is
automatically reverted to the prior deployment. No other tool in this space
does this. Be aware of the scope limit: on Ubuntu/apt there is no automatic
rollback yet — SysKnife still gates and audits, but does not undo package
changes. That scope limit is enforced, not just documented: every catalogued
action's rollback_available flag is tested against the actual rollback
mechanism, so a Debian-family action (PPAs, netplan, GRUB kargs, Ubuntu Pro
attach/detach included) cannot claim automatic rollback it doesn't have, even
when it happens to have an obvious manual inverse. See
Automatic Rollback.
Open beats closed — especially here
A security and audit tool you cannot read is a contradiction: you are asked to trust the thing whose entire job is to be trustworthy. SysKnife is MIT, the LACS protocol it implements is CC0. You can read the gate, self-host it, fork it, and verify every claim on this page against the source.
The field, honestly
- AIShell-Gate — the closest peer: typed JSON commands, a hash-chain audit, a confirmation model, and an executor-separation design much like SysKnife's daemon boundary. But it is proprietary and license-required, and its audit is HMAC-SHA256 (symmetric; by its own documentation, with no persistent key set, post-hoc verification is not possible). No automatic rollback.
- gate-oc-audit (Apache-2.0) — a genuinely good audit-only layer: tamper-evident records via a Merkle tree, optionally anchored to an external network. It validates that this category matters. It does not gate or execute and does not roll back — it observes.
- MCP traffic gateways (agentgateway, MCPX, Obot, mcp-firewall, and others) — these govern the protocol stream: routing, authn/z, threat detection, and audit at the proxy. They are complementary to SysKnife, not competitive: none execute typed actions on the host or roll changes back. You can run SysKnife behind one.
- generic
mcp-shellservers — hand the model a shell with an allowlist. Lowest friction, weakest guarantee (see the GuardFall result above).
When SysKnife is the wrong tool
Being honest about the fit:
- You want the AI to run arbitrary commands and accept the risk — a generic
mcp-shellserver is lower friction. SysKnife's typed catalog is finite by design; operations outside it are not (yet) available. - You only need observability, not enforcement — a pure audit layer like gate-oc-audit is lighter.
- Your fleet is Ubuntu/apt and you require automatic rollback today — that is roadmap, not shipped (gate + audit work now).
If you want the AI to change a Linux box while a human stays in control and every action is provably recorded, that is exactly what SysKnife is for.
Accepted cargo audit advisories
cargo audit is run in CI (.github/workflows/ci.yml, security-audit job).
It fails the build on any vulnerability. The advisories listed here are the
accepted unmaintained and unsound warnings — every one of them enters
the dependency graph only through the Tauri desktop shell
(sysknife-shell) and its GTK3 / webview stack.
None of these crates are reachable from the release-published crates —
sysknife-proto, sysknife-core, sysknife-types, sysknife-brain,
sysknife-daemon, or sysknife-cli. The privileged daemon and the CLI (the
security-sensitive trust boundary) do not link any of them. Verify with:
cargo tree -e no-dev -i <crate> # shows the path is via tauri/gtk only
cargo audit # full current report
Unsound (informational)
| Advisory | Crate | Note |
|---|---|---|
| RUSTSEC-2026-0097 | rand 0.7.3 | Unsound with a custom logger. Pulled via phf → selectors → kuchikiki → tauri-utils. Explicitly --ignored in CI (it is a non-unmaintained class that would otherwise fail the build). |
| RUSTSEC-2024-0429 | glib | Unsoundness in VariantStrIter iterator impls. GTK3 binding stack. |
Unmaintained
| Advisory | Crate | Note |
|---|---|---|
| RUSTSEC-2024-0411 | gdkwayland-sys | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0412 | gdk | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0413 | atk | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0414 | gdkx11-sys | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0415 | gtk | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0416 | atk-sys | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0417 | gdkx11 | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0418 | gdk-sys | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0419 | gtk3-macros | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0420 | gtk-sys | gtk-rs GTK3 bindings — unmaintained |
| RUSTSEC-2024-0384 | instant | unmaintained (transitive via GUI/webview) |
| RUSTSEC-2024-0370 | proc-macro-error | unmaintained (transitive) |
| RUSTSEC-2025-0012 | backoff | unmaintained (transitive) |
| RUSTSEC-2025-0057 | fxhash | unmaintained (transitive) |
| RUSTSEC-2025-0075 | unic-char-range | unmaintained (transitive) |
| RUSTSEC-2025-0080 | unic-common | unmaintained (transitive) |
| RUSTSEC-2025-0081 | unic-char-property | unmaintained (transitive) |
| RUSTSEC-2025-0098 | unic-ucd-version | unmaintained (transitive) |
| RUSTSEC-2025-0100 | unic-ucd-ident | unmaintained (transitive) |
Policy
- Vulnerabilities (RUSTSEC advisories of type
vulnerability) are never accepted here — they fail CI and must be fixed or the dependency dropped. - Unmaintained advisories are reported as non-fatal warnings by default; they are left visible (not globally ignored) so the team notices if the set grows. This file is the record of the currently-accepted set and why.
- Re-evaluate the GTK3 entries when Tauri migrates its Linux backend off the
GTK3 binding stack; re-evaluate
randwhen the webview dep chain bumps it.
ADR 0001: System Boundaries
Status
Accepted.
Context
SysKnife exists to let an agent perform real Linux administration without granting it arbitrary shell access or root.
Decision
SysKnife uses three separate roles:
sysknife-brainplanssysknife-shellpresents and collects approvalsysknife-daemonexecutes
The daemon is the only privileged executor. The brain is never allowed to mutate the system directly.
Consequences
- The trust boundary is simple and auditable.
- The shell remains a client instead of a second privileged runtime.
- The daemon can enforce policy, preview, transaction logging, and rollback in one place.
- The project can grow without collapsing into an unsafe command runner.
ADR 0002: Brain Provider Layer
Status
Accepted.
Context
sysknife-brain needs to talk to an LLM to plan system actions.
Different users will have different LLM setups: some will use the
Anthropic API, others will run a local model via Ollama, and future
contributors may want to add other providers.
Additionally, the API key must never be visible outside sysknife-brain
to prevent accidental logging or exposure through sysknife-shell.
Decision
sysknife-brain exposes a single LlmProvider trait:
#![allow(unused)] fn main() { #[async_trait] pub trait LlmProvider: Send + Sync { async fn complete( &self, system: &str, messages: &[Message], tools: &[ToolDefinition], max_tokens: u32, ) -> Result<Completion, ProviderError>; } }
Two implementations ship with the crate:
AnthropicProvider— POST/v1/messageswithinput_schematool definitions (Anthropic wire format).OllamaProvider— POST/v1/chat/completionswith OpenAI function-calling format (parametersfield,role: "tool"messages).
LlmPlanner::from_config(BrainConfig, Box<dyn StateClient>) builds
the provider from environment-derived config, keeping the API key
inside sysknife-brain. ProviderConfig and BrainConfig.provider are
pub(crate) so the shell cannot read credentials.
BrainConfig::from_env() validates inputs at the boundary:
SYSKNIFE_BRAIN_MAX_TURNSmust be a positive integer when set.ANTHROPIC_API_KEYmust be non-empty when provider isanthropic.
Alternatives Considered
- Single hard-coded Anthropic client — ruled out; excludes local models and makes the project inaccessible to contributors without API keys.
- Runtime plugin loading (
.so/ WASM) — too complex for v0; the two-implementation trait covers the main use cases.
Consequences
- Adding a new provider requires implementing
LlmProviderand a newProviderConfigvariant; no changes toLlmPlanner. - The shell layer cannot accidentally read or log API keys.
- Integration tests use
MockProvider— no network calls in CI.
Amendment (2026-07-23)
The original decision above described the two providers that shipped at the
time (Anthropic, Ollama). The LlmProvider trait design anticipated more —
see "Consequences" — and the provider set has since grown to eight:
anthropic, ollama, openai, gemini, groq, deepseek, mistral, and
xai (see crates/sysknife-brain/src/config.rs). Each new provider was a
pure addition — a new ProviderConfig variant and a require_api_key-backed
from_env() arm — with no change to LlmPlanner or the trait itself,
confirming the extensibility this ADR designed for. The original Decision
and Alternatives Considered sections above are left as written for historical
context; this amendment records the outcome.
ADR 0003: IPC Wire Protocol
Status
Accepted.
Context
The daemon and shell need a local IPC protocol. The codebase already
has a .proto file (sysknife-proto) with message definitions for
RequestEnvelope, PreviewEnvelope, ResultEnvelope, and
TransactionRecord, and a bind_unix_listener helper. The question
is what framing and encoding to use over the Unix domain socket.
Candidates considered:
- gRPC over Unix socket — uses the proto definitions directly
with
tonic. Full service interface. Requires HTTP/2 and aserviceblock in the proto. - Length-prefixed protobuf — binary, compact. Requires a custom dispatcher (no HTTP/2). Harder to inspect without tooling.
- Length-prefixed JSON — 4-byte LE
u32length + UTF-8 JSON body. Uses the same Rust structs (sysknife-types) already tested for serialization. Human-readable, easy to debug withsocat.
Decision
Use length-prefixed JSON (option 3) for the Unix socket protocol.
Each message carries a "type" discriminant string so the dispatcher
can route without a full decode. The sysknife-types structs are reused
as-is — no additional generated code or build-time proto compilation
beyond what already exists.
The proto definitions in sysknife-proto are kept for potential future
use (e.g., a networked or gRPC-based control plane) but are not the
primary on-wire encoding for local IPC.
Consequences
- No new build-time dependencies for the daemon or shell.
- Messages are inspectable with standard tools (
socat,nc,jq). - A framing crate is not needed —
tokio::io::AsyncReadExt::read_exactis sufficient. - If a binary protocol is needed later (performance, cross-language clients), the socket path changes and both sides are updated together. There are no external clients to coordinate with.
ADR 0004: Per-distro Prompt Dispatch
Status
Accepted (merged in PR #203, 2026-04-25).
Context
SysKnife gained Ubuntu/Debian action support in Phase 2b. The action catalogue split into two mostly non-overlapping sets:
- Fedora-family:
AddLayeredPackage,RemoveLayeredPackage,rpm-ostreedeployment controls,flatpak,toolbox,firewalld, … - Debian-family:
AptInstall,AptRemove,AptUpdate,snap,distrobox,ufw,netplan, …
The original single-distro prompt listed all action names in one block. After adding Debian actions this caused two problems:
-
Prompt confusion: the model saw both
AddLayeredPackageandAptInstallfor every intent and had to infer which to use from context. In practice, GPT-4o and Claude sometimes proposed the wrong family —AptInstallon Silverblue, orAddLayeredPackageon Ubuntu — especially for compound intents that did not name the package manager explicitly. -
Context window waste: every planning call carried ~40% action names that could never legally be executed on the current host.
The E2E story suite (65/65 passing on Ubuntu 24.04 with gpt-4.1) validated that per-distro isolation fixes (1) and reduces prompt size meaningfully.
Decision
build_system_prompt dispatches to one of three pure render functions
based on distro_hint.family:
distro_hint.family == "fedora" → render_fedora_prompt
distro_hint.family == "debian" → render_debian_prompt
_ → render_generic_prompt
Each render function concatenates:
- Shared
constblocks: role, the single-planning-rule, cross-distro action catalogue, and the six worked examples (A–F). - Per-distro
constblocks: distro header (with detected version), distro-specific action catalogue, risk overrides, selection rules, and action parameter reference.
The FEDORA_ONLY_ACTIONS and DEBIAN_ONLY_ACTIONS string-slice constants
back safety-fence unit tests that assert no cross-contamination at the type
level.
The DistroHint is provided by sysknife-core's detect_distro() function
which reads /etc/os-release at startup. If detection fails, distro_hint is
None and render_generic_prompt is used.
Consequences
- The model cannot propose a Fedora-specific action on a Debian host (or vice versa) because the action name simply does not appear in its context window.
- Adding a new distro family requires: a new
render_*function, a newDISTRO_FAMILY_*constant, a dispatch arm inbuild_system_prompt, and a*_ONLY_ACTIONSslice for the safety fence. This is intentionally mechanical — each step has a test to confirm it. - Prompt size decreases by roughly 20–25% for Fedora hosts and 15–20% for Debian hosts compared to the pre-dispatch single-prompt.
- The generic fallback deliberately omits all distro-specific actions. A host
with an unrecognised
/etc/os-releasegets cross-distro-only planning until the user adds a manualdistro_hintoverride in~/.config/sysknife/config.toml.
References
- PR #203 — "refactor(prompt): per-distro dispatch"
crates/sysknife-brain/src/prompt.rs— implementationdocs/architecture.md— prompt construction overviewdocs/research/prompt-composition-patterns.md— survey of dynamic prompt patterns that informed this design (dynamic middleware, template composition, conditional blocks)
Developer Guide
Welcome to SysKnife. This guide gets you from zero to a running dev environment and covers everything you need to contribute confidently.
Read First
- Architecture overview — understand the four-crate structure and the trust boundary before writing code
- ADR 0001: System boundaries
- ADR 0002: Brain provider layer
- ADR 0003: IPC wire protocol
Prerequisites
| Tool | Version | Install |
|---|---|---|
| Rust stable | latest stable | rustup.rs |
| Node.js | 20+ | nodejs.org or your distro |
| pnpm | latest | npm install -g pnpm |
| Tauri system deps | — | tauri.app/start/prerequisites |
| pre-commit | latest | pip install pre-commit |
No API key is required to get started. SysKnife auto-detects a local
Ollama instance (http://localhost:11434) when no cloud API key is set.
If you do not have Ollama installed, you can still run all unit and
integration tests without it.
Clone and Set Up
git clone https://github.com/lacs-project/sysknife
cd sysknife
# Install git hooks (run once)
pip install pre-commit
pre-commit install
# Install frontend dependencies
cd apps/sysknife-shell && pnpm install && cd ../..
Building
# Build all Rust crates (fast, no linking of the Tauri app)
cargo build --workspace
# Build the Tauri app (includes the GUI)
cd apps/sysknife-shell && pnpm tauri build
Running Tests
These run in under 15 seconds and are required before every push:
# Rust unit + integration tests
cargo nextest run --workspace --locked
# TypeScript / React tests
cd apps/sysknife-shell && pnpm test && pnpm exec tsc --noEmit
See docs/contributing/testing.md for the full test pyramid, including how to run the LLM-driven E2E stories on your workstation and in a Fedora Atomic VM.
Running the Full Stack Locally
You need two terminals.
Terminal 1 — daemon
# Binds $SYSKNIFE_LISTEN_URI, else $XDG_RUNTIME_DIR/sysknife/daemon.sock
# (last resort /tmp/sysknife-$UID.sock). The CLI resolves the same default,
# so `sysknife doctor` works without setting a socket env var.
# Privileged system actions (rpm-ostree, useradd, etc.) require root.
# For development you can run without root — read-only queries still work.
cargo run -p sysknife-daemon
Terminal 2 — shell (GUI)
cd apps/sysknife-shell
pnpm tauri dev
The shell opens as a desktop window. Type an intent and the daemon responds. The LLM is auto-detected from your environment.
Running the E2E Stories on Your Dev Machine
tests/e2e/dev-stories.sh runs the 7 read-only user stories without
a VM. It validates that the LLM proposes the correct typed plan — it
does not execute the actions against your host.
# With an Anthropic key
ANTHROPIC_API_KEY=sk-ant-... tests/e2e/dev-stories.sh
# With an OpenAI key
OPENAI_API_KEY=sk-proj-... tests/e2e/dev-stories.sh
# With local Ollama (must have a tool-capable model pulled)
tests/e2e/dev-stories.sh
# Specific stories only
OPENAI_API_KEY=sk-... tests/e2e/dev-stories.sh 3 6 7
Run this tier after any change to crates/sysknife-brain/src/prompt.rs or
the planning tools. See the testing guide for full details.
Inspecting the IPC Protocol
The daemon speaks length-prefixed JSON over a Unix socket. You can poke it manually without the GUI:
cargo run -p sysknife-daemon &
socat - UNIX-CONNECT:"$XDG_RUNTIME_DIR/sysknife/daemon.sock"
Type or paste a JSON message (with a 4-byte LE length prefix). This is useful for debugging the dispatcher or previewing action output.
Pre-commit Hooks
Pre-commit runs on every git commit. Run all hooks manually before
pushing:
pre-commit run --all-files
Hooks included:
| Hook | What it checks |
|---|---|
| trailing-whitespace | Removes trailing spaces |
| end-of-file-fixer | Ensures files end with a newline |
| check-yaml / check-toml / check-json | Syntax validity |
| no-commit-to-branch | Blocks direct commits to main |
| gitleaks | Detects hardcoded secrets |
| cargo fmt | Rust formatting (--check mode) |
| cargo check | Workspace compilation |
| tsc --noEmit | TypeScript type checking |
| markdownlint-cli2 | Markdown style |
| yamllint | YAML style |
Intentionally excluded from pre-commit (they run in CI instead):
cargo clippy (20–30 s), cargo nextest run (minutes), vitest (minutes).
Configuration
Config file: ~/.config/sysknife/config.toml (created manually, optional):
[daemon]
socket = "/run/sysknife/daemon.sock" # raw path, not URI
database = "/var/lib/sysknife/daemon.sqlite"
[llm]
provider = "ollama" # ollama | anthropic | openai | gemini | groq | deepseek | mistral | xai
model = "llama3.2:3b"
ollama_url = "http://localhost:11434"
max_turns = 10
Config file values act as defaults. Environment variables always win.
| Variable | Default | Description |
|---|---|---|
SYSKNIFE_LISTEN_URI | $XDG_RUNTIME_DIR/sysknife/daemon.sock (prod: /run/sysknife/daemon.sock) | Daemon socket URI (where the daemon binds) |
SYSKNIFE_SOCKET | falls back to the same default as SYSKNIFE_LISTEN_URI | CLI / MCP-server socket override — where the client dials (unix://, vsock://, or a bare path) |
SYSKNIFE_DATABASE_PATH | $XDG_STATE_HOME/sysknife/daemon.sqlite (fallback ~/.local/state/sysknife/daemon.sqlite) | SQLite database path |
SYSKNIFE_LLM_PROVIDER | auto-detect | anthropic, openai, gemini, ollama, groq, deepseek, mistral, or xai |
ANTHROPIC_API_KEY | — | Required for the Anthropic provider |
OPENAI_API_KEY | — | Required for the OpenAI provider |
GEMINI_API_KEY | — | Required for the Gemini provider |
GROQ_API_KEY | — | Required for the Groq provider |
DEEPSEEK_API_KEY | — | Required for the DeepSeek provider |
MISTRAL_API_KEY | — | Required for the Mistral provider |
XAI_API_KEY | — | Required for the xAI provider |
SYSKNIFE_OLLAMA_URL | http://localhost:11434 | Ollama base URL |
SYSKNIFE_LLM_MODEL | provider default | Override the model name |
SYSKNIFE_BRAIN_MAX_TURNS | 10 | Planning loop turn limit |
User Preferences
SysKnife remembers user preferences in ~/.config/sysknife/prefs.md. The
planner injects them at the start of each plan_intent() call.
Preferences are user-stated intentions that inform planning decisions. Do not store system facts as preferences — those are queried live.
Manage preferences through natural language:
- "Remember that I always prefer vim-enhanced over vim"
- "Forget my vim preference"
Or edit ~/.config/sysknife/prefs.md directly. Maximum 10 KB; SysKnife
rejects passwords, API keys, and tokens automatically.
Transaction History
The ListJobHistory action and query_job_history planning tool
expose the daemon's SQLite transaction log. Ask "what has SysKnife done
recently?" or "did my update succeed?" and the planner queries the
log directly.
ListJobHistory is Observer-level (read-only, no approval required).
Repository Layout
crates/
sysknife-brain/ LLM planner, provider adapters, safety fence
sysknife-types/ Shared domain types (CallerRole, RiskLevel, JobState, …)
sysknife-core/ Config loading, shared constants
sysknife-daemon/ Privileged executor, 189 actions, IPC, rollback, SQLite
sysknife-proto/ Protobuf definitions (future use)
apps/
sysknife-shell/ Tauri + React GUI
tests/
e2e/
dev-stories.sh Run E2E stories on any Linux host (uses sysknife --dry-run --json)
atomic-vm.sh Manage a Silverblue QEMU/KVM VM for full E2E
docs/
adr/ Architectural decision records
contributing/ Testing guide
CI
CI runs on every pull request and push to main.
| Check | Command |
|---|---|
| Rust formatting | cargo fmt --all --check |
| Clippy (warnings as errors) | cargo clippy --workspace --all-features --locked -- -D warnings |
| Rust tests | cargo nextest run --workspace --locked |
| TypeScript type check | npx tsc --noEmit (in apps/sysknife-shell) |
| Frontend tests | pnpm test (in apps/sysknife-shell) |
| Markdown lint | markdownlint-cli2 on contributor-facing docs |
| Link check | markdown-link-check on contributor-facing docs |
| YAML lint | yamllint on issue templates and workflows |
Run the Rust checks locally before pushing:
cargo fmt --all --check
cargo clippy --workspace --all-features --locked -- -D warnings
cargo nextest run --workspace --locked
Running CI locally
scripts/ci-local.sh mirrors the runnable jobs from
.github/workflows/ci.yml (rust, frontend, hygiene, security, and the
optional postgres-contract job) so you catch failures before pushing,
without spending GitHub Actions minutes:
# Full run — everything CI runs, including the optional Postgres contract
# test if docker/podman is available (or SYSKNIFE_TEST_POSTGRES_URL is set)
scripts/ci-local.sh
# Fast subset — rust fmt/clippy/nextest + frontend tsc/vitest only
scripts/ci-local.sh --fast
# Skip the postgres-contract job even if a container runtime is available
scripts/ci-local.sh --no-postgres
It detects which tools are installed first: cargo and node are required
(missing either is a hard failure with an install link); an optional linter
that's missing (cargo-nextest, cargo-audit, markdownlint-cli2,
markdown-link-check, yamllint, shellcheck) just prints a warning with
an install hint and skips that one check. Every check still runs even after
an earlier one fails — a PASS/FAIL/WARN/SKIP summary prints at the end, and
the script exits non-zero only if something in the summary actually failed.
Pre-push hook
scripts/ci-local.sh --install-hooks runs git config core.hooksPath .githooks, which wires up .githooks/pre-push to run scripts/ci-local.sh --fast automatically before every git push and block the push on
failure. Bypass a single push with git push --no-verify; undo the hook
entirely with git config --unset core.hooksPath.
.githooks/ already ships a pre-commit hook (cargo fmt --all --check +
cargo nextest run --workspace --locked) alongside the new pre-push one.
Both are opt-in via the same core.hooksPath setting. Note that
core.hooksPath is a single switch: pointing it at .githooks means Git
stops looking in .git/hooks, so it supersedes hooks installed by the
pre-commit framework (see Pre-commit Hooks above) —
use one mechanism or the other, not both, per clone.
Full workflow replay
ci-local.sh runs the same commands as CI, but not inside the same
container/runner image, so it cannot catch environment-specific breakage.
For an exact, Docker-based replay of the GitHub Actions workflow (all jobs,
the real ubuntu-latest image), use
act instead.
Working Style
- keep changes small and reviewable
- keep behavior typed and explicit
- keep the daemon as the only privileged executor
- update docs when user-facing behavior changes
- add or update tests for every behavior change
- write the failing test first; verify it fails before writing code
Quality Bar
Before merging, a change should be:
- understandable without reading every dependency
- covered by deterministic tests
- documented if it changes user-visible behavior
- safe by default (fail closed, not open)
- consistent with the trust boundary (daemon is the only executor)
Testing SysKnife
This guide explains how to run SysKnife tests at every level — from the fast unit tests you run locally on every change to the VM-based end-to-end validation before a release.
Test pyramid
| Level | What it tests | Speed | When |
|---|---|---|---|
| Unit tests (Rust) | Individual functions, parsers, traits | <5s | Every commit, every CI run |
| Unit tests (TypeScript) | React components, reducers, IPC shims | <5s | Every commit, every CI run |
| Integration (Rust) | Daemon IPC, safety fence, policy | <10s | Every commit, every CI run |
| Dev stories (local, no VM) | LLM plan structure for read-only stories; runs on any Linux host | 1-3 min | After brain/prompt changes |
| CI smoke (container) | Daemon + Ollama + read-only stories in a Linux runner | 5-10 min | Opt-in (PR label e2e or manual trigger) |
| E2E Atomic VM | Real Silverblue in QEMU/KVM, full stack, all 54 stories | 15-30 min first boot; 2-3 min subsequent | Local / pre-release |
| E2E Ubuntu VM | Ubuntu 24.04 cloud image, full story suite, 65/65 stories | ~15 min first boot; ~2 min subsequent | After Ubuntu action changes |
| Manual QA | Real Silverblue/Kinoite hardware, destructive actions, GUI | 30-60 min | Before releases + demo video |
No single layer is enough on its own. Use the ones that match your change.
Running unit and integration tests (required)
These run on every CI build and must pass before merge:
# Rust
cargo fmt --all --check
cargo clippy --workspace --all-features --locked -- -D warnings
cargo nextest run --workspace --locked
# TypeScript / React
cd apps/sysknife-shell
pnpm install --frozen-lockfile
pnpm test
pnpm exec tsc --noEmit
Running stories on your dev machine (no VM required)
tests/e2e/dev-stories.sh runs the 7 read-only user stories directly on
your workstation. It builds the daemon and test CLI, starts the daemon on
/tmp/sysknife-daemon.sock, runs the stories, and then stops the daemon.
What it validates: the LLM proposes the correct plan (right action
names, risk levels, parameters). It does not execute the actions — it
tests plan structure only. This works on any Linux host because the story
scripts check the JSON plan output, not the results of df, ps, etc.
LLM provider: auto-detected from environment variables (same logic as
the product's BrainConfig::from_env):
| Variable set | Provider used | Model |
|---|---|---|
ANTHROPIC_API_KEY | anthropic | claude-sonnet-4-6 |
OPENAI_API_KEY | openai | gpt-4.1 |
GEMINI_API_KEY | gemini | gemini-2.0-flash |
| neither | ollama | qwen3:8b (must be pulled; CPU-only is impractical — use SYSKNIFE_LLM_MODEL=llama3.2:3b on CPU) |
Override with SYSKNIFE_LLM_PROVIDER and SYSKNIFE_LLM_MODEL.
# Run the default read-only stories with an Anthropic key
ANTHROPIC_API_KEY=sk-ant-... tests/e2e/dev-stories.sh
# Run with OpenAI
OPENAI_API_KEY=sk-proj-... tests/e2e/dev-stories.sh
# Run with local Ollama (must have qwen3:8b or llama3.2:3b pulled)
tests/e2e/dev-stories.sh
# Run specific stories
OPENAI_API_KEY=sk-... tests/e2e/dev-stories.sh 3 6 7
# Stories 8-10 require SYSKNIFE_ALLOW_DESTRUCTIVE=1 — they will fail on a
# non-Fedora-Atomic host because query_packages / query_authorized_keys
# call rpm-ostree and SSH tools that are not installed. This is expected.
# Run them on a provisioned VM for real coverage.
SYSKNIFE_ALLOW_DESTRUCTIVE=1 OPENAI_API_KEY=sk-... tests/e2e/dev-stories.sh
When to use this tier:
- After any change to
crates/sysknife-brain/src/prompt.rs - After adding or changing a
query_*tool orGet*/List*action - As a quick sanity check for brain/planner changes before pushing
Expected results on a dev machine:
- Stories 1-7: all pass
- Story 8 (install vim): fails — model calls
query_packages, daemon can't runrpm-ostree, model escalates toget_system_state→StateUnavailable. Passes on a real VM whererpm-ostreeis present. - Story 9 (create toolbox): may pass plan-structure check (plan structure only, no toolbox is created).
- Story 10 (add SSH key): fails for the same reason as story 8 —
query_authorized_keysfails without the SSH tooling. Passes on a real VM.
For full story 8 and 10 coverage, use the VM path.
Running the CI smoke test (opt-in)
The smoke test boots Ollama and the daemon directly in a GitHub Actions
runner (no VM, no real atomic desktop), pulls a small tool-capable model
(llama3.2:3b), and runs the 7 read-only user stories.
Triggers:
- Label a PR with
e2e— the workflow runs automatically - Manual dispatch via Actions → e2e → Run workflow
Results appear as the container-smoke job. Story logs are uploaded as
build artifacts.
What the smoke test covers:
- Daemon startup, IPC framing, policy enforcement
- Brain ↔ Ollama integration, tool-use loop, safety fence
- All 7 read-only query tools and read-only action stories (1–7)
What it does NOT cover (that's the VM path below):
- rpm-ostree actions, real systemd host management
- Reboot / kernel-argument flows, rollback execution
- Tauri GUI rendering
Running the full E2E suite in a Fedora Atomic VM
This is the high-fidelity path. The VM is a real Fedora Atomic Desktop (Silverblue, Kinoite, Sway Atomic, Budgie Atomic, or COSMIC Atomic) install with rpm-ostree, systemd, flatpak, podman, and toolbox. All 54 user stories — including destructive ones — execute authentically.
Linux and macOS hosts (recommended)
We use quickemu to download the official Fedora ISO and boot it in QEMU/KVM with SSH forwarding pre-configured. One-time setup, then a reproducible VM you can snapshot and restore.
Install quickemu:
You also need qemu-system-x86_64, qemu-utils (for qemu-img), rsync,
netcat, and ssh — these are all standard packages on every supported
distro.
# Fedora Atomic 44 (default repos have a current quickemu)
sudo dnf install quickemu qemu qemu-img
# Fedora Atomic Desktops
sudo rpm-ostree install quickemu qemu qemu-img
# Reboot to activate, then proceed.
# Ubuntu 24.04 / Debian — the version in default Ubuntu repos may be too
# old (missing the Nov 2024 .ociarchive fix for Fedora Atomic). Use the PPA:
sudo add-apt-repository -y ppa:flexiondotorg/quickemu
sudo apt-get update
sudo apt-get install -y quickemu qemu-system-x86 qemu-utils \
qemu-system-modules-spice rsync netcat-openbsd
# macOS (Homebrew)
brew install --cask quickemu
After installing, verify your user can access KVM (Linux only):
ls -l /dev/kvm # should exist
test -r /dev/kvm \
|| sudo usermod -aG kvm "$USER" # then log out and back in
# (or use ACL: setfacl -m u:$USER:rw /dev/kvm)
You also need libguestfs-tools for the offline disk patches we apply
between Anaconda's install and the first SSH login (set passwords,
install our SSH key, enable sshd). Ubuntu 24.04 keeps kernel images at
mode 0600 by default, which prevents libguestfs from running
unprivileged — fix once with sudo chmod +r /boot/vmlinuz-*.
sudo apt-get install -y libguestfs-tools
sudo chmod +r /boot/vmlinuz-*
One-time VM setup:
# From the repo root.
# 1. Generate a passphrase-less SSH key dedicated to the VM (you do
# not want to reuse your personal id_ed25519 — rsync/non-interactive
# ssh cannot prompt for a passphrase). Idempotent.
./tests/e2e/atomic-vm.sh keygen
# 2. Download the Silverblue 44 ISO (~2.5 GB, cached under tests/e2e/vm/).
./tests/e2e/atomic-vm.sh download
# 3. Run the Fedora installer interactively (GUI window opens).
# Just click through it — you don't need to set a password or create
# a user; we patch all of that into the disk image afterwards via
# libguestfs. Shut the VM down when the installer finishes (close
# the QEMU window or pick "Power Off" in the post-install screen —
# DO NOT click "Reboot": the ISO will re-mount as CD-ROM).
./tests/e2e/atomic-vm.sh install
# 4. Patch the disk image with our test user, password, sudoers, sshd,
# and SSH key. (Implemented via guestfish so it works offline,
# bypassing Silverblue's interactive first-boot wizard which has
# gnome-initial-setup quirks on some hosts.)
./tests/e2e/atomic-vm.sh bootstrap
Why no
enable-sshstep? Earlier versions of this script tried to boot the VM visibly so the contributor could enable sshd by hand. That ran into Fedora 42's gnome-initial-setup crashing on the third-party-repo screen with virgl/Wayland bugs. The current flow sidesteps the GUI entirely by configuring the VM offline vialibguestfs. Theenable-sshsubcommand is still there as a fallback if your Anaconda install did create a usable user.What
bootstrapdoes, in one shot: create userlacsdev, set the password (lacsdev), set root password (sysknife), install your VM SSH key, NOPASSWD-sudoerslacsdev, enablesshd, set SELinux to permissive, and pre-markgnome-initial-setupas done. Idempotent — safe to re-run afterinstall.
Run the tests:
# Boot the VM headlessly (in the background)
./tests/e2e/atomic-vm.sh start
# First-ever provision: rsyncs the repo into the VM, layers build tools
# via rpm-ostree, reboots the VM, then runs again to build SysKnife and
# pull the Ollama model. Re-run after the auto-reboot (the script tells
# you when). Expect 30-60 minutes total on first run (mostly waiting
# for Ollama tarball + Rust deps download). ~2 minutes on subsequent
# provisions.
./tests/e2e/atomic-vm.sh provision
# RECOMMENDED: take a "baseline" snapshot now, before any test run.
# Future test runs can `restore baseline` instead of re-provisioning.
./tests/e2e/atomic-vm.sh stop
./tests/e2e/atomic-vm.sh snapshot baseline
./tests/e2e/atomic-vm.sh start
# Run the read-only stories (non-destructive default)
./tests/e2e/atomic-vm.sh run
# Run ALL 54 stories including destructive — restore the baseline afterwards.
SYSKNIFE_ALLOW_DESTRUCTIVE=1 ./tests/e2e/atomic-vm.sh run
# Roll back to the clean baseline so the next run is fast
./tests/e2e/atomic-vm.sh stop
./tests/e2e/atomic-vm.sh restore baseline
Other useful commands:
./tests/e2e/atomic-vm.sh ssh # interactive shell in the VM
./tests/e2e/atomic-vm.sh stop # clean shutdown
./tests/e2e/atomic-vm.sh destroy # delete VM disk (ISO kept)
./tests/e2e/atomic-vm.sh help
Try a different atomic variant:
SYSKNIFE_VM_VARIANT=kinoite ./tests/e2e/atomic-vm.sh download
SYSKNIFE_VM_VARIANT=kinoite ./tests/e2e/atomic-vm.sh install
# ... all management commands respect SYSKNIFE_VM_VARIANT.
Supported variants (these are the names quickget uses):
SYSKNIFE_VM_VARIANT | Atomic Desktop | Desktop |
|---|---|---|
silverblue (default) | Fedora Silverblue | GNOME |
kinoite | Fedora Kinoite | KDE Plasma |
sericea | Fedora Sway Atomic | Sway |
onyx | Fedora Budgie Atomic | Budgie |
cosmic-atomic | Fedora COSMIC Atomic | COSMIC |
Windows hosts
quickemu does not support Windows as a host. Contributors on Windows should use WSL2 (with KVM nested virtualization) or VirtualBox with a manual ISO install:
- Download the Silverblue ISO from fedoraproject.org/atomic-desktops/silverblue
- Create a VirtualBox VM (4 GB RAM, 2 vCPUs, 20 GB disk) with SSH port forwarded from host 22220 → guest 22
- Attach the ISO, boot, and run the Fedora installer. Create user
lacsdev. Enable sshd during install. - SSH into the VM:
ssh -p 22220 lacsdev@127.0.0.1 - Clone the repo into
/home/lacsdev/sysknifeand runsudo bash tests/e2e/provision.shinside the VM - Run stories with
sudo -E tests/e2e/run-stories.sh
The atomic-vm.sh helper does not automate VirtualBox — that's a
follow-up if Windows contributor interest warrants it.
Running the Ubuntu 24.04 E2E suite
Ubuntu 24.04 support is validated (65/65 stories pass on a live VM with
gpt-4.1). The ubuntu-vm.sh script mirrors the atomic-vm.sh workflow but
uses a Ubuntu 24.04 cloud image instead of a Fedora Atomic ISO.
See docs/contributing/ubuntu-vm-testing.md for the full setup and daily-use instructions. Quick reference:
# One-time setup
./tests/e2e/ubuntu-vm.sh download
./tests/e2e/ubuntu-vm.sh install
# Daily use
./tests/e2e/ubuntu-vm.sh start
./tests/e2e/ubuntu-vm.sh provision
./tests/e2e/ubuntu-vm.sh run
# Snapshot / restore for fast subsequent runs
./tests/e2e/ubuntu-vm.sh stop
./tests/e2e/ubuntu-vm.sh snapshot baseline
./tests/e2e/ubuntu-vm.sh start
When to use this tier: after any change to Ubuntu-specific actions
(AptInstall, AptRemove, UfwAllow, …), the render_debian_prompt
function, or the Ubuntu story scripts.
Running individual stories
Inside the VM (or on any provisioned Fedora Atomic Desktop):
cd /home/lacsdev/sysknife
# Run a specific story by number
sudo -E tests/e2e/run-stories.sh 3
# Run multiple specific stories
sudo -E tests/e2e/run-stories.sh 1 4 7
Per-story logs are written to tests/e2e/logs/story-N.log.
Before opening a PR
cargo nextest run --workspace && pnpm test— required, fastcargo clippy --workspace --all-features --locked -- -D warningscargo fmt --all --check- For changes to the brain, daemon, IPC, or action catalogue:
- Run the VM tests locally (
atomic-vm.shflow) - Add the
e2elabel to trigger the CI smoke test on your PR
- Run the VM tests locally (
Before a release
The maintainer runs these in order:
- All automated tests green on main
- VM tests (at least Silverblue + one other atomic variant) pass locally
- Manual QA on real Silverblue hardware using docs/testing/user-stories.md as the checklist — all 54 stories including destructive ones
- Record the demo video on real hardware (issue #32)
Troubleshooting
quickget fedora 43 silverblue fails
Check the quickemu wiki
for current supported editions. Older or newer Silverblue releases may
also be available; adjust SYSKNIFE_VM_RELEASE.
VM boots but atomic-vm.sh ssh times out
The Fedora installer doesn't enable sshd by default. Either enable
sshd during the interactive install, or boot the VM's GUI console
once to run:
sudo systemctl enable --now sshd
provision step fails during rpm-ostree install
rpm-ostree install requires a reboot. The provision script auto-reboots
and asks you to re-run it. If it got stuck, just run provision again —
it's idempotent.
Ollama download or model pull is very slow
Two distinct downloads can be slow:
-
The Ollama tarball itself (~1.5 GB, downloaded by
install.shfromollama.com/download/ollama-linux-amd64.tgz). On some networks / geos / times of day this CDN serves at <100 KB/s. There's no workaround inside the script — wait it out, or pre-stage the binary on the host and copy it in via SSH if you're going to re-provision often. -
The model pull (~2 GB for the default
llama3.2:3b, or ~5 GB forqwen3:8bif you override). Happens after Ollama is installed, viaollama pull. Goes through Ollama's registry (usually faster than the ollama.com CDN).
Override the model size with SYSKNIFE_TEST_MODEL:
SYSKNIFE_TEST_MODEL=llama3.2:3b ./tests/e2e/atomic-vm.sh provision # default, CPU-only friendly
SYSKNIFE_TEST_MODEL=qwen2.5:3b ./tests/e2e/atomic-vm.sh provision # alt tool-capable 3B
SYSKNIFE_TEST_MODEL=qwen3:8b ./tests/e2e/atomic-vm.sh provision # GPU passthrough only
We default to llama3.2:3b after empirical live testing on a
CPU-only 4-vCPU / 10 GB VM: ~2 GB download, no thinking mode, tool
calling works reliably, ~2-4 min/story.
Qwen3 models (including qwen3:8b) default to "thinking mode" which
emits thousands of hidden reasoning tokens before the real answer. On
CPU this blows past Ollama's internal 120-second request timeout and
every /api/chat call fails with HTTP 500 before a plan is emitted.
qwen3:8b is only a viable default if you have GPU passthrough
configured. Gemma 3 (1b / 4b) is fast but Ollama currently rejects
tool calls with 400: does not support tools.
Per-story timeout defaults to 10 minutes (SYSKNIFE_STORY_TIMEOUT=600) —
small tool-capable models on 4 vCPUs need that much headroom.
For the full history of what we tried and why, see HACKING.md §8.
Tip: once provision succeeds end-to-end, immediately stop the VM
and snapshot baseline. Then every subsequent test cycle becomes
restore baseline → start → run, skipping all the slow downloads.
CPU-only inference is too slow
Stories take 10-30 seconds each instead of 1-3 seconds with GPU. GPU passthrough to QEMU/KVM is possible but requires VFIO setup, which is out of scope for this guide.
Stories fail with "daemon socket not found"
Check the daemon inside the VM:
./tests/e2e/atomic-vm.sh ssh -- sudo systemctl status sysknife-daemon
./tests/e2e/atomic-vm.sh ssh -- sudo journalctl -u sysknife-daemon -n 100
The provision log at /var/log/sysknife-e2e-provision.log usually has the
root cause.
Getting help
- Check existing issues
- Open a new issue with:
- The failing story log (
tests/e2e/logs/story-N.log) - The daemon journal:
sudo journalctl -u sysknife-daemon -n 200 - Your Fedora variant and release (from
rpm-ostree status)
- The failing story log (
Contributing to SysKnife
Thank you for your interest in SysKnife. Contributions of all sizes are welcome — from typo fixes to new action families to multi-distro support. This document explains how to go from idea to merged PR.
Licensing
SysKnife is released under the MIT License. By submitting a pull request you agree that your contribution will be included under the same MIT terms.
No CLA is required. Optionally, sign off your commits with
git commit -s to add a DCO line (Signed-off-by: Your Name <email>)
as a lightweight record of your agreement to the Developer Certificate of Origin
(https://developercertificate.org).
If you have questions, open a Discussion — we would rather answer questions than lose a contributor to a misunderstanding.
Before You Start
- Read the architecture overview to understand the four-crate structure and the trust boundary.
- Read the developer guide for prerequisites and build instructions.
- Read the testing guide for which tests to run and when.
- Check open issues to avoid duplicating work in progress.
- For any substantial change, open an issue first and describe what you want to build. This prevents wasted effort and keeps the architecture coherent.
Finding Something to Work On
good first issue— well-scoped, clear acceptance criteria, and a good way to learn the codebase. Start here if this is your first contribution.help wanted— higher-impact tasks where outside help is especially welcome.security— security issues take priority over everything else. See SECURITY.md for the disclosure process.
High-impact areas where contributions are most needed:
- Multi-distro action families (apt / dnf / pacman)
- Integration test hardening against a real daemon socket
- Broader LLM provider support and model testing
- Demo recording on real Silverblue hardware
Setting Up Your Development Environment
git clone https://github.com/lacs-project/sysknife
cd sysknife
# Install git hooks (once)
pip install pre-commit
pre-commit install
# Install frontend dependencies
cd apps/sysknife-shell && pnpm install && cd ../..
# Run all tests to verify everything works
cargo nextest run --workspace --locked
cd apps/sysknife-shell && pnpm test && pnpm exec tsc --noEmit && cd ../..
See docs/developer-guide.md for the full list of prerequisites (Rust, Node.js 20, pnpm, Tauri system deps).
Filing Issues
Use one of the issue templates on GitHub. Every issue should have:
- Title: one-sentence summary in imperative mood
- Why: context and motivation
- Where: file paths and function names when known
- Acceptance criteria: concrete, testable conditions for "done"
Do not open issues for questions — use GitHub Discussions or tag the
issue with question.
Pull Request Workflow
1. Branch
Create a focused branch from a fresh main:
git fetch origin
git checkout -b <issue-number>-<short-slug> origin/main
# Example: git checkout -b 42-apt-action-family origin/main
Keep one branch per issue. For larger changes, use a git worktree to keep your main checkout clean:
git worktree add ~/.config/superpowers/worktrees/sysknife/<branch-name> -b <branch-name>
2. Implement
Follow TDD:
- Write the failing test first.
- Run it and confirm it fails for the right reason.
- Write the minimal implementation that makes it pass.
- Refactor with the tests green.
Keep changes small and reviewable. If a PR does two unrelated things, split it.
3. Check Locally
# Required before every push
cargo fmt --all --check
cargo clippy --workspace --all-features --locked -- -D warnings
cargo nextest run --workspace --locked
# Frontend
cd apps/sysknife-shell && pnpm test && pnpm exec tsc --noEmit && cd ../..
# All pre-commit hooks
pre-commit run --all-files
For changes touching the brain, planning tools, or the prompt:
# Run the read-only E2E stories (requires a running daemon + LLM)
ANTHROPIC_API_KEY=sk-ant-... tests/e2e/dev-stories.sh
4. Open the PR
Target main. Use the PR template:
## Summary
One paragraph on what and why. Reference the issue: Closes #N.
## Changes
- bullet list of what changed
## Test plan
- [ ] what to verify manually
- [ ] what the automated tests cover
Title format: type(scope): short description — for example:
feat(daemon): add apt install action.
Add the e2e label if your change touches the brain, daemon IPC,
or the action catalogue — this triggers the CI smoke test.
5. Review
Every PR requires at least one review before merge.
- Address every review comment with code or a documented reason not to.
- Do not merge around an unresolved finding.
- CI must be green before merge.
- After merge, delete the remote and local branch.
Commit Style
Follow Conventional Commits:
type(scope): message
| Type | When |
|---|---|
feat | New user-visible feature |
fix | Bug fix |
docs | Documentation only |
chore | Build, CI, tooling |
test | Tests only |
refactor | No behavior change |
Subject line under 72 characters. Add a body for non-obvious changes.
How to Add a New Daemon Action
- Add the action name to
KNOWN_ACTIONSincrates/sysknife-brain/src/planning_tools/propose_plan.rs(action_name.rsonly imports and re-exports it). - Add the execution spec to
crates/sysknife-daemon/src/executor.rs(build_action_spec). - Add the preview logic to
crates/sysknife-daemon/src/preview.rs(preview_action). - Add the rollback spec (if applicable) to
crates/sysknife-daemon/src/executor.rs(rollback_spec_for). - Add input validation to
crates/sysknife-daemon/src/actions/validate.rs. - Add an entry to the system prompt's action catalogue in
crates/sysknife-brain/src/prompt.rs(name, description, params, risk level). - Write unit tests for the preview and execution logic.
- Write or extend an E2E story if the action is user-facing.
Keep each step atomic and reviewable as a separate commit if the implementation is large.
How to Add an E2E Story
E2E stories live in tests/e2e/. Each story is a shell script that:
- Calls
sysknife --dry-run --json "<intent>"and captures the plan. - Parses the resulting JSON plan.
- Asserts that the correct action names, risk levels, and parameters are present.
Rules for story assertions:
- Assert exact action names — do not accept a superset.
- Assert risk levels — they are part of the contract.
- Never weaken an assertion to accept wrong model behavior. If the model produces a bad plan, fix the prompt, not the test.
Run stories locally before opening a PR:
ANTHROPIC_API_KEY=sk-ant-... tests/e2e/dev-stories.sh <story-number>
Code Standards
- No dead code. Remove superseded workarounds immediately.
- No fallback flags or "just in case" parameters — every line must be reachable and load-bearing.
- Prefer explicit types and explicit error handling over
unwrap. - Preserve the trust boundary: the daemon is the only privileged executor. Brain and shell must not touch the system directly.
- Preserve approval, audit, and rollback semantics on every action.
Security-sensitive areas
Changes to the following require the security label on the PR and
extra reviewer scrutiny:
| Area | File(s) | Why sensitive |
|---|---|---|
| Intent validation | crates/sysknife-brain/src/planner.rs (INTENT_MAX_BYTES, guards in plan_intent) | First line of defense before any LLM call |
| Secret patterns | crates/sysknife-brain/src/prefs.rs (SENSITIVE_PATTERNS, SENSITIVE_PREFIXES) | Controls what the planner and prefs storage will reject |
| Action allowlist | crates/sysknife-brain/src/planning_tools/propose_plan.rs (KNOWN_ACTIONS) | Adding a name here makes it proposable by the LLM |
| Role policy | crates/sysknife-daemon/src/policy.rs (min_role_for_action) | Governs which groups can execute which actions |
| Approval / replay | crates/sysknife-daemon/src/transactions.rs | Hash freshness and TOCTOU protection |
| Caller auth | crates/sysknife-daemon/src/dispatcher.rs (resolve_caller_role) | SO_PEERCRED group-to-role mapping |
| Audit log | crates/sysknife-brain/src/audit.rs, crates/sysknife-brain/src/journal.rs | Safety fence record and journald forwarding |
When adding a new action to KNOWN_ACTIONS, you must also:
- Add it to
min_role_for_actioninpolicy.rswith the correct minimum role. Omitting it causes the daemon to deny the action for all callers with a validation-failure error — an obvious regression, but better than a silent allow. - Add it to the action catalogue in
crates/sysknife-brain/src/prompt.rswith a correct risk level. The LLM uses this catalogue to decide what to propose; a wrong risk level produces wrong approval gates.
Questions
Open a GitHub Discussion
or tag your issue with question. We are friendly and genuinely want
to help new contributors succeed.
Ubuntu story audit (2026-04-25)
Summary
- Total stories audited: 116 (104 plan-structure + 12 exec)
- Distro-agnostic: 35 (work on any Linux)
- Fedora-only assertions needing Ubuntu equivalence: 19
- Ubuntu-native (no Fedora equivalent, leave alone): 50
- Destructive (gated by
SYSKNIFE_ALLOW_DESTRUCTIVE): 11 plan-structure + 10 exec = 21 - Exec stories safe on Ubuntu host: 3 (exec-1, exec-2, exec-6)
- Exec stories Fedora-only (use firewalld/firewall-cmd): 2 (exec-7, exec-11)
Counts above are for plan-structure stories only unless stated otherwise. Stories can appear in more than one category (e.g. distro-agnostic + destructive).
Distro-agnostic stories (35)
These assert actions that resolve on any Linux host. No changes needed.
story-1, story-2, story-3, story-6, story-7, story-10, story-12, story-14, story-17, story-21, story-22, story-23, story-24, story-25, story-26, story-27, story-29, story-30, story-31, story-32, story-36, story-37, story-38, story-39, story-41, story-42, story-44, story-48, story-49, story-50, story-51
story-15 (ListJobHistory — distro-agnostic, but asserted action stems from the RollbackDeployment Fedora framing — intent mentions "rollback operations"; on Ubuntu there are no deployment rollbacks, so the intent itself is Fedora-flavoured even though the asserted action is generic).
story-45 (RebootSystem — distro-agnostic action, but intent says "new kernel was just installed" which is RPM/OSTree phrasing; the assertion itself is fine on any host).
story-20, story-18 (AddUserToGroup, RestartService — distro-agnostic actions).
Strictly distro-agnostic, no changes needed: story-1, story-2, story-3, story-6, story-7, story-12, story-14, story-17, story-21, story-22, story-25, story-26, story-29, story-32, story-38, story-41, story-48, story-49
Fedora-only assertions to update (19)
Every story below hardcodes a Fedora-specific action name in its pass/fail assertion. On an Ubuntu host the planner will (correctly) propose the Ubuntu equivalent, causing the story to emit FAIL despite correct behaviour.
| Story | Intent | Fedora action asserted | Ubuntu equivalent | Suggested jq fragment |
|---|---|---|---|---|
| story-4 | "what ports are currently open on the firewall?" | GetFirewallState | UfwStatus | select(.action == "GetFirewallState" or .action == "UfwStatus") |
| story-5 | "what packages have I layered on top of the base system?" | GetLayeredPackages | AptListInstalled | select(.action == "GetLayeredPackages" or .action == "AptListInstalled") |
| story-8 | "install vim as a layered package" | InstallPackages / AddLayeredPackage | AptInstall | select(.action == "InstallPackages" or .action == "AddLayeredPackage" or .action == "AptInstall") |
| story-9 | "create a toolbox container called dev-test" | CreateToolbox | DistroboxCreate | select(.action == "CreateToolbox" or .action == "DistroboxCreate") |
| story-11 | "post-update diagnostic: deployment history + layered packages + services + disk" | GetLayeredPackages (also ListDeployments/GetDeploymentHistory) | AptListInstalled (no Ubuntu deployment equivalent) | For layered-packages check: accept GetLayeredPackages or AptListInstalled; relax the deployment check to be optional on Ubuntu |
| story-13 | "show me the logs for the firewalld service" | GetServiceLogs with unit == "firewalld" | GetServiceLogs with unit == "ufw" | Relax unit param check: select(.params.unit != null) — or change intent to "sshd" (service on both) |
| story-16 | "show me the network status and the current firewall rules" | GetFirewallState | UfwStatus | accept GetFirewallState or UfwStatus |
| story-28 | "show me the kernel boot arguments and list all my deployments" | GetKernelArguments + ListDeployments/GetDeploymentHistory | no Ubuntu equivalent for GetKernelArguments or deployment listing | Intent is Fedora/OSTree-specific; on Ubuntu this story should be skipped or rewritten |
| story-33 | "add rd.driver.blacklist=nouveau to the kernel arguments" | SetKernelArguments | no Ubuntu equivalent | Fedora/OSTree-only; skip on Ubuntu |
| story-34 | "my system broke after the last rpm-ostree update, roll it back" | RollbackDeployment | no Ubuntu equivalent | Intent hardcodes "rpm-ostree" phrasing; Fedora-only |
| story-35 | "open port 8080 on the firewall for my web app" | ConfigureFirewall | UfwAllow | select(.action == "ConfigureFirewall" or .action == "UfwAllow") |
| story-40 | "rebase my Silverblue system to Fedora 41" | RebaseSystem | no Ubuntu equivalent | Fedora/OSTree-only; skip on Ubuntu |
| story-43 | "free up disk space by removing old system deployments" | CleanupDeployments | no Ubuntu equivalent | Fedora/OSTree-only; skip on Ubuntu |
| story-45 | "the new kernel was just installed, I need to reboot to activate it" | RebootSystem | RebootSystem | Action itself is fine; intent framing is OSTree-flavoured but assertion is distro-agnostic — no change needed to assertion |
| story-46 | "are there any OS updates available?" | GetPendingUpdates | AptCheckUpdates (not in catalogue) / AptUpdate (refresh only) | select(.action == "GetPendingUpdates" or .action == "AptUpdate") — note: no AptCheckUpdates exists; closest read-only Ubuntu action is AptListInstalled |
| story-47 | "show me all my installed flatpak apps" | ListInstalledFlatpaks | no Ubuntu-native flatpak action in catalogue | Flatpak runs on Ubuntu but no Ubuntu-specific action; assertion is Fedora-focused |
| story-52 | "update Firefox flatpak" | UpdateFlatpak | no Ubuntu-specific flatpak action | Same as above |
| story-53 | "remove gedit from the base image" | RemoveBasePackage | no Ubuntu equivalent (no rpm-ostree override concept) | Fedora/OSTree-only; skip on Ubuntu |
| story-54 | "update all my flatpak apps" | UpdateFlatpak | no Ubuntu-specific flatpak action | Flatpak on Ubuntu would still use UpdateFlatpak — assertion is OK if action is in catalogue |
Fedora-only stories with no Ubuntu equivalent (skip gate needed)
These stories encode OSTree or Silverblue concepts that have no Ubuntu
analogue. They should gain a SYSKNIFE_DISTRO_FAMILY skip guard:
story-28, story-33, story-34, story-40, story-43, story-53
Stories where assertion fix is a simple or clause
story-4, story-5, story-8, story-9, story-16, story-35, story-46
Stories where the intent needs rewording to be distro-neutral
story-11 (rpm-ostree phrasing in intent), story-13 (firewalld service), story-34 (rpm-ostree in intent)
Ubuntu-native stories (50)
Stories 55–104 assert apt/snap/ufw/distrobox/netplan actions only. All are Ubuntu-only by design. Run these on an Ubuntu host; skip on Fedora.
| Range | Action families |
|---|---|
| story-55 – story-65 | apt (AptUpdate, AptUpgrade, AptInstall, AptRemove, AptPurge, AptAutoremove, AptHold, AptUnhold, AptSearch, AptListInstalled, AptShow) |
| story-66 – story-72 | snap (SnapInstall, SnapRemove, SnapHold, SnapUnhold, SnapList, SnapInfo, SnapRefresh) |
| story-73 – story-79 | ufw (UfwStatus, UfwEnable, UfwDisable, UfwAllow, UfwDeny, UfwReset) |
| story-80 – story-82 | distrobox (DistroboxList, DistroboxCreate, DistroboxRemove) |
| story-83 – story-84 | netplan (NetplanGetConfig, NetplanApply) |
| story-85 – story-104 | compound + edge-case Ubuntu stories (rejection, multi-action, param extraction) |
Compound Ubuntu stories (test multi-action plans): story-85 (AptUpdate + AptInstall), story-86 (AptListInstalled + SnapList), story-87 (UfwEnable + UfwAllow), story-88 (AptListInstalled + AptShow), story-103 (AptHold + AptShow), story-104 (NetplanGetConfig + NetplanApply)
Rejection/edge-case stories (pass on any host — assertions are soft): story-91 (metacharacter injection), story-92 (port 0 boundary), story-93 (empty snap name)
Exec story distro classification
| Story | Intent / Action | Category | Ubuntu-safe? |
|---|---|---|---|
| exec-1 | show disk usage → GetDiskUsage | distro-agnostic, read-only | yes |
| exec-2 | show memory → GetMemoryInfo | distro-agnostic, read-only | yes |
| exec-3 | service status → GetServiceStatus | distro-agnostic, read-only | yes |
| exec-4 | SSH key round-trip → AddAuthorizedKey + RemoveAuthorizedKey | destructive, distro-agnostic | yes |
| exec-5 | create/delete user | destructive, distro-agnostic | yes |
| exec-6 | list running services → ListServices | distro-agnostic, read-only | yes |
| exec-7 | "restart firewalld" → RestartService(firewalld) | Fedora-only — asserts systemctl is-active firewalld | no — firewalld absent on Ubuntu |
| exec-8 | hostname round-trip | destructive, distro-agnostic | yes |
| exec-9 | timezone round-trip | destructive, distro-agnostic | yes |
| exec-10 | user/group membership | destructive, distro-agnostic | yes |
| exec-11 | ConfigureFirewall ftp cycle, asserts firewall-cmd | Fedora-only — uses firewall-cmd --list-services | no — needs Ubuntu replacement using ufw status |
| exec-12 | UfwAllow + UfwDeny cycle → allow port 8080, assert open, deny port 8080, assert closed | destructive, Ubuntu-only — asserts ufw status; gated on SYSKNIFE_DISTRO_FAMILY=ubuntu|debian | yes |
exec-7 Ubuntu fix: change intent to a service present on Ubuntu (e.g. restart ssh)
and replace systemctl is-active firewalld with systemctl is-active ssh.
exec-11 Ubuntu fix: exec-12.sh is the parallel Ubuntu story — it exercises
UfwAllow/UfwDeny cycle using ufw status for verification; exec-11 stays
gated behind SYSKNIFE_DISTRO_FAMILY=fedora.
Recommended grouping for live VM run
Phase 1 — distro-agnostic + read-only (must pass on Ubuntu VM)
story-1, story-2, story-3, story-6, story-7, story-12, story-14, story-17, story-21, story-22, story-25, story-26, story-29, story-32, story-38, story-41, story-48, story-49, exec-1, exec-2, exec-3, exec-6
Phase 2 — Ubuntu plan-structure (asserts apt/snap/ufw/distrobox/netplan)
story-55 through story-104
Phase 3 — distro-agnostic destructive (live daemon, any host)
story-10, story-18, story-20, story-23, story-24, story-27, story-30, story-31, story-36, story-37, story-39, story-42, story-44, story-50, story-51, exec-4, exec-5, exec-8, exec-9, exec-10
Phase 4 — Fedora-only (run on Fedora Silverblue / Atomic VM only)
story-5, story-9, story-11, story-15, story-16, story-19, story-28, story-33, story-34, story-40, story-43, story-46, story-47, story-52, story-53, story-54, exec-7, exec-11
Phase 5 — Cross-distro fixed stories (after applying the or clause patches)
story-4 (GetFirewallState|UfwStatus), story-8 (AddLayeredPackage|AptInstall), story-9 (CreateToolbox|DistroboxCreate), story-16 (GetFirewallState|UfwStatus), story-35 (ConfigureFirewall|UfwAllow) — these should pass on both Fedora and Ubuntu after the assertion is widened.
Release process
SysKnife releases are intentionally tag-driven and one way. npm and crates.io versions cannot be replaced after publication, so a tag is pushed only after the release-readiness checklist is complete.
What the workflow publishes
Pushing a tag matching vMAJOR.MINOR.PATCH on main starts
.github/workflows/release.yml. It:
- Verifies the tag against every Cargo and npm package version.
- Builds
sysknifeandsysknife-daemonon native Linux x86_64 and aarch64 runners. - Generates SPDX SBOMs, checksums, and GitHub artifact attestations.
- Publishes
sysknife-setupto npm through trusted publishing (OIDC). - Publishes the public Rust crates to crates.io in dependency order.
- Creates the GitHub Release and uploads the binaries, SBOMs, and checksums.
Publication is never silently skipped. The release is created only after both registries accept the packages.
One-time repository setup
Before the first tag:
- Configure an npm trusted publisher for package
sysknife-setup, repositorylacs-project/sysknife, workflowrelease.yml, and the exact GitHub owner. The npm job uses Node 24 andid-token: write; no long-livedNPM_TOKENis used. See npm trusted publishing. - Add
CARGO_REGISTRY_TOKENas a GitHub Actions secret. Restrict the token to only the SysKnife crates where crates.io token scopes allow it. - Protect
mainwith a ruleset requiring the CI, E2E, and Postgres contract checks, at least one approval, resolved review conversations, and no force pushes. See GitHub rulesets. - Enable private vulnerability reporting and immutable releases in repository settings before announcing the project.
- Confirm the GitHub Actions runners and action versions used by the release workflow are available to the repository.
Rehearse without publishing
Run the manual release-rehearsal workflow on the exact commit intended for
release. It packages every public crate, packs the npm installer, builds native
binaries, smoke-tests the CLI, and emits checksums without contacting a
registry or creating a release.
The same check is available locally:
scripts/release_rehearsal.sh --check
scripts/release_rehearsal.sh --full --output dist/rehearsal
release_rehearsal.sh deliberately refuses --publish.
Cut a release
Use a clean, reviewed main checkout. Replace v0.2.5 with the intended
version.
cargo nextest run --workspace --locked
bash scripts/check_release_versions.sh v0.2.5
scripts/release_rehearsal.sh --full --output dist/rehearsal
git tag -s v0.2.5 -m "SysKnife v0.2.5"
git push origin v0.2.5
The tag pattern does not accept prerelease suffixes. Do not move or reuse a published tag. If publication partly fails, diagnose and rerun the workflow on the same commit; do not publish a different tree under the same version.
Registry details
npm
packages/setup/package.json runs its prepublishOnly smoke test before
upload. For a local package inspection:
cd packages/setup
npm pack --dry-run
Trusted publishing requires the npm package's publisher configuration to
match the GitHub repository and workflow exactly. Keep id-token: write
scoped to the npm job.
crates.io
The public crates are published in dependency order:
sysknife-proto
sysknife-core
sysknife-types
sysknife-brain
sysknife-daemon
sysknife-cli
The private sysknife-daemon-test and desktop shell crates are not published.
Verify the published release
After the workflow succeeds:
npx sysknife-setup --help
gh attestation verify sysknife-vX.Y.Z-linux-x86_64 \
--repo lacs-project/sysknife
sha256sum --check sha256sums-linux-x86_64.txt
Also perform a clean install, sysknife doctor, one preview/approve/execute
cycle, and uninstall on the supported OS image before announcing the release.
Keep the release private or draft until these checks pass.
OSS release readiness
This checklist is the launch gate for a public SysKnife release. A green unit test suite is necessary but not sufficient: privileged system software needs real-host evidence, recovery evidence, repository controls, and an independent security review.
Automated gates
Run these on the exact candidate commit and retain the CI links:
-
cargo fmt --all --check -
cargo clippy --workspace --all-features --locked -- -D warnings -
cargo nextest run --workspace --locked - frontend typecheck, 72 tests, production build, and production npm audit
- live PostgreSQL migration and store contract
- ShellCheck, YAML lint, repository completeness, release versions, public claims, and bootstrap contracts
-
scripts/release_rehearsal.sh --fullon native x86_64 and aarch64 runners - dependency review, RustSec audit, documentation links, and docs build
Host validation
- Ubuntu 24.04 LTS full story suite passes on a clean VM for this commit.
- Ubuntu 22.04 and 26.04 bootstrap, install, daemon, doctor, and uninstall smoke tests pass.
- Fedora Silverblue 44 full atomic VM suite passes before the README badge is promoted from current validation required.
- Clean install, preview, terminal approval, execution, audit verification, upgrade, rollback/uninstall, and reinstall are exercised with no undocumented manual repair.
- x86_64 and aarch64 release artifacts run on clean supported images.
- Failure cases are exercised: expired/replayed approval, daemon restart, unavailable LLM provider, unavailable audit database, and interrupted package operation.
Record image identifiers, architecture, kernel, model/provider, commit SHA, test command, and result. Do not summarize a historical VM run as evidence for a new release commit.
Data and operations
- SQLite backup plus audit-key restore is tested on an isolated host.
- PostgreSQL migration and a backup/PITR restore drill both pass against the production provider.
- Audit retention, backup encryption, recovery objectives, restore owner, and deletion authorization are documented for operators.
- Monitoring covers daemon startup, database failures/capacity, dropped forwarding events, backup age, restore-test age, and audit-chain failure.
- Secrets and file permissions are inspected on a clean installation; logs and diagnostics do not expose provider keys, database URLs, or receipts.
Repository and supply chain
- The public namespace, npm package, crate names, and project links are owned and resolve to the intended maintainers.
-
mainruleset requires CI, E2E script lint, Postgres contract, approval, resolved conversations, and blocks force pushes. -
npm trusted publishing matches
lacs-project/sysknifeandrelease.yml; no long-lived npm token is present. - The scoped crates.io token is configured and the non-publishing rehearsal packages every public crate.
- Private vulnerability reporting, immutable releases, signed tags, artifact attestations, SBOMs, checksums, and least-privilege workflow permissions are enabled and verified.
-
SECURITY.md, support policy, contribution guide, code of conduct, license, issue forms, and release process are visible from the README.
Security and launch decision
-
An independent reviewer examines the full
main...candidatediff with emphasis on authorization, receipt binding/replay, command construction, audit integrity, migrations, installer privilege boundaries, and CI publication permissions. - Every actionable review finding is fixed and the full suite is rerun.
- Known limitations are accurate, externally understandable, and linked to issues where maintainers intend to fix them.
- The demo animation and all public claims match actual behavior.
- Maintainers explicitly record go only after every launch-blocking item above is checked. Unchecked items remain blockers, not follow-up polish.
First 72 hours
- Monitor install failures, security reports, dependency alerts, audit corruption reports, and support questions with a named on-call owner.
- Be prepared to deprecate installation instructions or publish a fixed new version; never replace registry artifacts or move a published tag.
- Convert repeated setup failures into tested installer diagnostics and update the support matrix only from recorded evidence.