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

SysKnife in Claude Code via MCP — plan in chat, approve in a terminal, execute with a one-time receipt


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 neovim not rpm-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
ComponentPrivilegeJob
brainnoneTalks to the LLM, proposes a typed plan
shelluserShows you the plan, collects your approval
daemonrootExecutes 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.

sysknife CLI — plain language to a typed plan to live execution in the terminal

ℹ️ 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 dnf action 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-setup and 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-run works anywhere and is a great way to test the planner without installing the daemon. Full execution requires sysknife-daemon running 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.


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.

SysKnife MCP flow — Claude Code plans, user approves, daemon executes

💡 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

FieldTypeDescription
intentstringNatural-language intent, e.g. "check disk usage"

OutputPlanOutput

FieldTypeDescription
intentstringThe original intent
summarystringOne-line plan summary
explanationstringWhy this plan was chosen
stepsPlanStep[]Ordered steps to execute

Each PlanStep:

FieldTypeDescription
action_namestringCanonical action name, e.g. GetDiskUsage
summarystringWhat this step does
risk_levelstring"low", "medium", or "high"
paramsobjectAction-specific parameters
commandstringDaemon-resolved shell command, e.g. "timedatectl"
transaction_idstringDaemon 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

FieldTypeDescription
stepsStepToExecute[]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.

OutputExecuteOutput

FieldTypeDescription
stepsStepResult[]Per-step results
needs_rebootboolTrue if any step requires a reboot

Each StepResult:

FieldTypeDescription
action_namestringAction that was executed
statusstring"succeeded", "failed", etc.
summarystringHuman-readable outcome
outputstring[]Progress lines (ANSI stripped)
warningsstring[]Daemon warnings
needs_rebootboolWhether this step needs a reboot
transaction_idstringDaemon 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

LevelMeaningApproval
lowRead-only or fully reversibleOne-time receipt
mediumModifies state but reversible (e.g. set timezone)One-time receipt
highDestructive 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.

sysknife CLI demo


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:

  1. A spinner appears while the LLM plans (Thinking…Querying …Proposing plan…).
  2. The coloured plan is printed — each step shows a risk badge (● low / ● medium / ● HIGH), the action name, and a summary.
  3. If any step requires approval, you are prompted. HIGH-risk steps always require confirmation regardless of --yes.
  4. 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:

FlagDefaultDescription
--limit N20Maximum entries to return
--status STATUSFilter by job status (succeeded, failed, canceled, …)
--action ACTIONFilter by action name (e.g. InstallPackages)
--since DATETIMEOnly 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
FlagDescription
--jsonMachine-readable JSON report instead of human text
--pubkey FILEVerify 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
FlagDescription
--db URLPostgres 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:

KeyAction
↑ / ↓Navigate command history
Ctrl+RReverse incremental history search
Ctrl+A / Ctrl+EJump to line start / end
Ctrl+WDelete word before cursor
Ctrl+CCancel current line (does not exit)
Ctrl+DExit the REPL
exit / quitExit 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.

FlagDescription
--yesAuto-approve LOW-risk steps. With --max-risk medium, also approves MEDIUM. HIGH always requires human confirmation.
--max-risk LEVELAbort if the plan contains any step above this ceiling. Values: low, medium, high.
--non-interactiveFail immediately (exit 3) if any step would require interactive approval. Use in scripts and CI.
--dry-runPrint the plan and exit without executing anything.
--step-by-stepPrompt for approval before each individual step instead of once for the whole plan.
--jsonEmit NDJSON to stdout — one JSON object per event (plan, preview, result). All colour and spinner output is suppressed. Safe to pipe.
--timeout SECSHard wall-clock timeout in seconds. Aborts the whole operation if exceeded.
--log-to FILETee all stdout output to FILE in addition to the terminal. Appends if the file exists.

Exit codes

CodeMeaning
0Success
1Plan or step refused — you rejected it, it exceeded the configured risk ceiling, or approval was required but the session is non-interactive
2Execution failed — the action ran but returned an error (also returned when --timeout expires)
3Planning failed — LLM error, provider unreachable, or the intent could not be turned into a plan
4Configuration 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).

VariableDescription
SYSKNIFE_LLM_PROVIDERForce a provider: anthropic, openai, gemini, ollama, groq, deepseek, mistral, xai
SYSKNIFE_LLM_MODELOverride the model name for the selected provider
ANTHROPIC_API_KEYUse the Anthropic provider (default model: claude-sonnet-4-6)
OPENAI_API_KEYUse the OpenAI provider (default model: gpt-4.1)
GEMINI_API_KEYUse the Gemini provider (default model: gemini-2.0-flash)
GROQ_API_KEYUse the Groq provider (default model: llama-3.3-70b-versatile)
DEEPSEEK_API_KEYUse the DeepSeek provider (default model: deepseek-chat)
MISTRAL_API_KEYUse the Mistral provider (default model: mistral-large-latest)
XAI_API_KEYUse the xAI provider (default model: grok-3)
SYSKNIFE_ANTHROPIC_URLOverride the Anthropic base URL (default: https://api.anthropic.com)
SYSKNIFE_OLLAMA_URLOverride the Ollama base URL (default: http://localhost:11434)
SYSKNIFE_BRAIN_MAX_TURNSPlanning loop turn limit — integer ≥ 1 (default: 10)
SYSKNIFE_OLLAMA_THINKSet true/false to override thinking-mode detection for Ollama models

Auto-detection (when SYSKNIFE_LLM_PROVIDER is not set):

  1. ANTHROPIC_API_KEY present and non-empty → anthropic
  2. 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

VariableDescription
SYSKNIFE_SOCKETDaemon 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

Configuration reference

SysKnife reads configuration from three places, in lowest-to-highest priority:

  1. Built-in defaults — compiled into sysknife-brain and sysknife-core
  2. ~/.config/sysknife/config.toml — optional, user-owned
  3. 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.

VariableDefaultWhat it sets
SYSKNIFE_LISTEN_URIunix://$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_PROVIDERauto-detectLLM provider name (8 supported)
SYSKNIFE_LLM_MODELprovider defaultModel identifier
SYSKNIFE_OLLAMA_URLhttp://localhost:11434Ollama base URL
SYSKNIFE_OLLAMA_THINKauto-detecttrue / false thinking-mode override
SYSKNIFE_ANTHROPIC_URLhttps://api.anthropic.comAnthropic base URL
SYSKNIFE_BRAIN_MAX_TURNS10Planning loop turn limit
SYSKNIFE_MAX_RPM20Rate limit (requests / 60s sliding window)
SYSKNIFE_AUDIT_KEY_PATH<db_dir>/audit-keyEd25519 signing key path for the audit chain
SYSKNIFE_CHECKPOINT_DBPostgres URL for audit checkpoint external anchoring (keeps DB credentials off the command line)
SYSKNIFE_SOCKETfalls back to the same default as SYSKNIFE_LISTEN_URICLI / MCP daemon address
SYSKNIFE_TOKENVsock auth token (when daemon runs in a VM)
XDG_CONFIG_HOME~/.configBase path for sysknife/config.toml

Provider API keys

Required when the corresponding provider is selected:

  • OPENAI_API_KEY — OpenAI
  • ANTHROPIC_API_KEY — Anthropic
  • GEMINI_API_KEY — Gemini
  • GROQ_API_KEY — Groq
  • DEEPSEEK_API_KEY — DeepSeek
  • MISTRAL_API_KEY — Mistral
  • XAI_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:

VariablePurpose
SYSKNIFE_AUDIT_KEY_PATHEd25519 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.rsLacsConfig, DaemonSection, LlmSection, PolicySection, AuditSection, StorageSection, StoragePoolSection
  • crates/sysknife-brain/src/config.rsBrainConfig, ProviderConfig
  • crates/sysknife-core/src/lib.rsdefault_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

TierMeaning
ValidatedThe documented full story suite passed on a real VM.
Smoke-testedBootstrap and basic daemon/tooling checks passed; full action parity was not exercised.
Current validation requiredAn action backend exists, but the current distro release still needs its launch-gate VM run.
ExperimentalDetection or partial code exists, but production support is not claimed.
PlannedNo complete action backend exists.

Launch matrix

DistroAction backendEvidenceLaunch tier
Ubuntu 24.04 LTSapt, ufw, netplan, snap, AppArmor, systemd, containers65/65 stories on a live VM with gpt-4.1Validated
Ubuntu 22.04 LTSUbuntu/apt familyVM bootstrap and smoke testsSmoke-tested
Ubuntu 26.04 LTSUbuntu/apt familyVM 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 44rpm-ostree, Flatpak, toolbox, firewalld, systemd, containersHarness and fixture coverage; current live-VM run must be recorded before releaseCurrent validation required
Other Fedora Atomic 41+ variantsrpm-ostree familyDetection and shared action testsExperimental until variant-specific VM evidence exists
Fedora Workstation / Serverdnf family incompleteDetection tests onlyExperimental

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.
  • apt can contend with unattended upgrades and needrestart; the Ubuntu actions use non-interactive execution and bounded lock handling.
  • Fedora Workstation and Server require a dedicated dnf action 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:

  • ID selects Fedora, Ubuntu, Debian, or another exact distribution.
  • ID_LIKE supplies a family fallback for planning.
  • VERSION_ID determines the release.
  • VARIANT_ID distinguishes 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

DistroState
Debian stable/testingPlanned after Ubuntu hardening
Arch / EndeavourOSPlanned; requires a pacman action family
openSUSE Leap / TumbleweedPlanned; requires zypper and transactional-update design
NixOSOut of scope; configuration evaluation does not fit per-action mutation
macOS, Windows, WSLOut 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

  1. Document the action mapping and unsupported semantics.
  2. Add real /etc/os-release fixtures and detection tests.
  3. Implement typed actions without raw shell strings.
  4. Add policy, preview, and executor consistency tests.
  5. 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

LocationPackageRole
crates/sysknife-brain/sysknife-brainUnprivileged LLM planner
crates/sysknife-types/sysknife-typesShared domain types
crates/sysknife-core/sysknife-coreConfig file loading, constants
crates/sysknife-daemon/sysknife-daemonPrivileged executor
crates/sysknife-proto/sysknife-protoProtobuf definitions (future use)
apps/sysknife-shell/sysknife-shellTauri + React GUI
apps/sysknife-cli/sysknifeProduction 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 point
  • BrainConfig — 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 familyRender functionActions included
Fedora (fedora)render_fedora_promptrpm-ostree, flatpak, toolbox, firewalld, …
Debian (debian)render_debian_promptapt, snap, distrobox, ufw, netplan, …
Unknownrender_generic_promptCross-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:

  • CallerRoleObserver | Dev | Admin | Boot
  • RiskLevelLow | Medium | High
  • JobStateQueued | 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 (ObserverDevAdmin)
  • 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 JobProgress frames
  • 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

  1. A user enters intent in the shell.
  2. The brain proposes a typed plan.
  3. The shell sends each mutating step to the daemon for preview.
  4. The daemon persists an immutable preview and returns its transaction ID, risk level, side effects, reboot requirement, and rollback availability.
  5. The shell shows the preview and captures approval. High-risk steps require the user to type the action name explicitly.
  6. The daemon issues a deterministic, domain-separated Ed25519 receipt and stores its SHA-256 commitment inside the signed transaction row.
  7. The client sends the exact transaction, action, params, and receipt.
  8. The daemon verifies the preview is fresh and atomically consumes the receipt, then runs the action.
  9. During execution, the daemon streams live stdout output line-by-line as JobProgress frames.
  10. The shell displays each line as it arrives.
  11. On failure, if rollback_available is true, the daemon runs the rollback action automatically and reports the result.
  12. 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.

What the model can never do

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:

FieldWhat it fixes
mechanismExactly one privileged operation: a Command with a fixed program and a typed argument list, or a scoped FileWrite / FilePatch / FileDelete / FileScan
risk_levelLow, Medium, or High — drives approval UX and policy
reboot_requiredWhether the daemon should warn the caller before proceeding
rollback_availableWhether 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 ActionSpec carries rollback_available: true fails, 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.md is 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)

ActionCommandRiskDistroRbRoDescription
GetSystemStaterpm-ostree status --jsonLowAllfull rpm-ostree deployment snapshot: layered packages, pinned/staged deployments, booted/pending OSTree refs
CollectDiagnosticsjournalctl -b -n 500 --no-pagerLowAllrecent system journal log (last 500 lines) for error diagnosis and troubleshooting
GetDeploymentHistoryrpm-ostree status --jsonLowFedorarpm-ostree deployment history: past and current OSTree commits with timestamps
ListDeploymentsrpm-ostree status --jsonLowFedoralist all currently staged, pending, and booted deployments
UpdateSystemsudo rpm-ostree upgradeHighAlldownload and stage the latest OSTree update (does not reboot)
PinDeploymentsudo ostree admin pin 0HighFedorapin a deployment so it is not GC'd — param: index (u32, deployment index from ListDeployments)
UnpinDeploymentsudo ostree admin pin --unpin 0HighFedoraunpin a previously pinned deployment — param: index (u32)
RebaseSystemsudo rpm-ostree rebase fedora/41/x86_64/silverblueHighFedoraswitch to a different OSTree ref/remote — param: target_ref (string, e.g. fedora/40/x86_64/silverblue)
CleanupDeploymentssudo rpm-ostree cleanup --rollback --pendingHighFedoraremove old staged deployments to free disk space
RebootSystemsudo systemctl rebootHighAllreboot the machine into the current or staged deployment
RollbackDeploymentsudo rpm-ostree rollbackHighFedoraroll back to the previous booted deployment
GetKernelArgumentsrpm-ostree kargsLowFedoralist current kernel command-line arguments (kargs)
SetKernelArgumentssudo rpm-ostree kargsHighFedoraadd/remove kernel command-line args — params: add (string[]), remove (string[]) — either may be []

Package layering (rpm-ostree)

ActionCommandRiskDistroRbRoDescription
InstallPackagessudo rpm-ostree install --idempotent podmanHighAlllayer multiple RPM packages — param: packages* (string[])
RemovePackagessudo rpm-ostree uninstall podmanHighAllremove layered RPM packages — param: packages* (string[])
GetLayeredPackagesrpm-ostree status --jsonLowFedoralist RPM packages layered on top of the base OS image — no params
AddLayeredPackagesudo rpm-ostree install --idempotent podmanHighFedoralayer a single RPM package (requires reboot) — param: package* (string)
RemoveLayeredPackagesudo rpm-ostree uninstall podmanHighFedoraremove a single layered RPM package (requires reboot) — param: package* (string)
ReplaceLayeredPackagesudo rpm-ostree install neovim --uninstall vimHighFedorareplace one layered package with another — params: old* (string), new* (string)
ResetLayeredPackageOverridesudo rpm-ostree override reset --allHighFedorareset all rpm-ostree override changes — no params
RemoveBasePackagesudo rpm-ostree override remove geditHighFedoraexclude a base OS package from the deployment — param: package* (string)
GetPendingUpdatesrpm-ostree upgrade --checkLowAllcheck for a staged update and show its diff — no params

Filesystem

ActionCommandRiskDistroRbRoDescription
GetDiskUsagedf -h --output=source,fstype,size,used,avail,pcent,target --exclude-type=composefs --exclude-type=tmpfs --exclude-type=devtmpfs --exclude-type=efivarfsLowAllshow disk space usage for all mounted filesystems (df -h) — no params

Flatpak

ActionCommandRiskDistroRbRoDescription
InstallFlatpaksudo runuser -u testuser -- flatpak install --user -y flathub app-idMediumAllinstall a Flatpak app — params: username* (Linux user), app_id* (e.g. org.mozilla.firefox), remote* (e.g. flathub)
RemoveFlatpaksudo runuser -u testuser -- flatpak uninstall --user -y app-idMediumAlluninstall a Flatpak app — params: username*, app_id*
SearchFlatpakAppsflatpak search search-termLowAllsearch Flatpak remotes for apps — param: term* (query string) — no username needed
ListFlatpakRemotessudo runuser -u testuser -- flatpak remotes --user --columns=name,urlLowAlllist configured Flatpak remotes — param: username*
ListInstalledFlatpakssudo runuser -u testuser -- flatpak list --user --app --columns=application,name,version,originLowAlllist installed Flatpak apps for a user — param: username*
AddFlatpakRemotesudo runuser -u testuser -- flatpak remote-add --user --if-not-exists remote https://example.invalidMediumAlladd a Flatpak remote — params: username*, remote* (name), url*
RemoveFlatpakRemotesudo runuser -u testuser -- flatpak remote-delete --user remoteMediumAllremove a Flatpak remote — params: username*, remote* (name)
GetFlatpakAppInfosudo runuser -u testuser -- flatpak info --user app-idLowAllshow metadata for an installed Flatpak — params: username*, app_id*
UpdateFlatpaksudo runuser -u testuser -- flatpak update --user -y com.example.AppMediumAllupdate Flatpak apps — params: username* (required); app_id (optional — omit to update all)
UbuntuInstallFlatpaksudo runuser -u testuser -- flatpak install --user -y flathub app-idMediumUbuntuinstall a Flatpak app on Ubuntu — params: username*, app_id*, remote* (e.g. flathub); Ubuntu only; Medium risk
UbuntuRemoveFlatpaksudo runuser -u testuser -- flatpak uninstall --user -y app-idMediumUbunturemove a Flatpak app on Ubuntu — params: username*, app_id*; Ubuntu only; Medium risk
UbuntuUpdateFlatpaksudo runuser -u testuser -- flatpak update --user -y com.example.AppMediumUbuntuupdate Flatpak app(s) on Ubuntu — param: username*; optional: app_id (omit for all); Ubuntu only; Medium risk
UbuntuListFlatpakssudo runuser -u testuser -- flatpak list --user --app --columns=application,name,version,originLowUbuntulist installed Flatpak apps on Ubuntu — param: username*; Ubuntu only; read-only

Toolbox

ActionCommandRiskDistroRbRoDescription
ListToolboxessudo runuser -l testuser -c XDG_RUNTIME_DIR=/run/user/$(id -u) toolbox listLowAlllist toolbox containers for a user — param: username*
CreateToolboxsudo runuser -l testuser -c XDG_RUNTIME_DIR=/run/user/$(id -u) toolbox create --container 'sysknife-dev' --release '41'MediumAllcreate a toolbox container — params: username*, name*; optional: image, release
RemoveToolboxsudo runuser -l testuser -c XDG_RUNTIME_DIR=/run/user/$(id -u) toolbox rm 'sysknife-dev'MediumAllremove a toolbox container — params: username*, name*

Services

ActionCommandRiskDistroRbRoDescription
ListServicessystemctl list-units --type=service --all --no-legend --no-pagerLowAlllist all systemd units and their active/enabled state — no params
StartServicesudo systemctl start NetworkManager.serviceMediumAllstart a systemd service — param: unit* (e.g. sshd.service)
StopServicesudo systemctl stop NetworkManager.serviceMediumAllstop a systemd service — param: unit*
RestartServicesudo systemctl restart NetworkManager.serviceMediumAllrestart a systemd service — param: unit*
SetServiceEnabledsudo systemctl enable sshd.serviceMediumAllenable or disable a service at boot — params: unit*, enabled* (bool)
MaskServicesudo systemctl mask cups.serviceHighAllmask a unit so it cannot start by any means — param: unit*
UnmaskServicesudo systemctl unmask cups.serviceMediumAllunmask a previously masked unit — param: unit*
GetServiceLogsjournalctl -u NetworkManager.service -n 200 --no-pagerLowAllfetch recent journald log lines for a service — param: unit*
GetServiceStatussystemctl status nginx.service --no-pagerLowAllshow detailed status of a service — param: unit*
ReloadServicesudo systemctl reload nginx.serviceMediumAllreload a service config without restart (SIGHUP) — param: unit*
ListTimerssystemctl list-timers --all --no-legend --no-pagerLowAlllist all systemd timer units with next trigger time — no params
ReloadDaemonsudo systemctl daemon-reloadMediumAllrun systemctl daemon-reload to pick up changed unit files — no params
CreateScheduledJobsudo /usr/lib/sysknife/scheduled-job-edit --name sysknife-example --command /usr/bin/true --schedule *-*-* 02:00:00HighAllschedule 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")
GetServiceResourceLimitssystemctl show nginx.service --property=MemoryMax,MemoryHigh,CPUQuotaPerSecUSec,TasksMaxLowAllshow a service's cgroup limits (MemoryMax/CPUQuota/TasksMax) via systemctl show — param: unit*; read-only
SetServiceResourceLimitssudo systemctl set-property nginx.service MemoryMax=500M CPUQuota=50%MediumAllcap 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

ActionCommandRiskDistroRbRoDescription
ListProcessesps aux --sort=-%memLowAlllist running processes with CPU and memory usage — no params
SignalProcesssudo kill -s TERM 1234HighAllsend a signal to a process to stop it — params: pid* (integer > 1); signal (TERM|KILL|HUP|INT, default TERM)

Journald

ActionCommandRiskDistroRbRoDescription
GetJournalLogjournalctl --output=json --no-pager --lines=100 --unit=ssh.service --priority=err --bootLowAllread 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
VacuumJournaljournalctl --vacuum-size=500MHighAllreclaim journal disk space — supply exactly one of size_mb (cap total journal size) or retain_days (delete entries older than N days)

Storage — LVM

ActionCommandRiskDistroRbRoDescription
GetLvmReportlvs --reportformat json --units b -o lv_name,vg_name,lv_size,lv_attr,origin,data_percentLowAlllist logical volumes with VG, size, attributes, and usage as JSON (lvs) — no params; read-only
ExtendLogicalVolumesudo lvextend -L +10G -r ubuntu-vg/ubuntu-lvHighAllgrow 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
CreateLogicalVolumesudo lvcreate -L 20G -n data ubuntu-vgMediumAllcreate a new logical volume in a volume group (lvcreate) — params: vg*, name*, size* (e.g. '20G'); Medium risk
CreateLvSnapshotsudo lvcreate -s -L 5G -n ubuntu-lv-snap ubuntu-vg/ubuntu-lvMediumAllsnapshot 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

ActionCommandRiskDistroRbRoDescription
GetSysctlsysctl -- net.ipv4.ip_forwardLowAllread a kernel parameter (sysctl) — param: key (optional, dotted e.g. 'net.ipv4.ip_forward'; omit to dump all); read-only
SetSysctlsudo /usr/lib/sysknife/sysctl-edit --key net.ipv4.ip_forward --value 1HighAllset 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

ActionCommandRiskDistroRbRoDescription
GetMountsfindmnt --jsonLowAlllist mounted filesystems as JSON (findmnt) — no params; read-only
AddMountsudo /usr/lib/sysknife/mount-edit --op mount --device /dev/sdb1 --mountpoint /mnt/data --fstype ext4 --options defaultsHighAllmount 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
RemoveMountsudo /usr/lib/sysknife/mount-edit --op unmount --mountpoint /mnt/dataHighAllunmount and drop the /etc/fstab entry for a mountpoint — param: mountpoint*; High risk
AddSwapsudo /usr/lib/sysknife/mount-edit --op addswap --file /swapfile --size-mb 2048HighAllcreate a swap file, enable it, and persist to /etc/fstab — params: file* (absolute path), size_mb* (integer MB); High risk
RemoveSwapsudo /usr/lib/sysknife/mount-edit --op rmswap --file /swapfileHighAlldisable a swap file, remove it, and drop its /etc/fstab entry — param: file*; High risk

Log management

ActionCommandRiskDistroRbRoDescription
GetLogrotateStatuslogrotate -d /etc/logrotate.confLowAlldry-run logrotate to show what would rotate (logrotate -d) — param: config (optional path); read-only
ConfigureLogRotationsudo /usr/lib/sysknife/log-edit --op logrotate --name nginx --path /var/log/nginx/*.log --frequency daily --rotate 14 --compressMediumAllwrite 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
RemoveLogRotationsudo /usr/lib/sysknife/log-edit --op rm-logrotate --name nginxMediumAllremove a SysKnife-managed logrotate drop-in — param: name*; Medium risk
ConfigureRemoteSyslogsudo /usr/lib/sysknife/log-edit --op rsyslog-forward --host logs.example.com --port 514 --protocol tcpHighAllforward 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
RemoveRemoteSyslogsudo /usr/lib/sysknife/log-edit --op rm-forwardHighAllstop remote syslog forwarding (remove the rsyslog drop-in) — no params; High risk

PAM password policy

ActionCommandRiskDistroRbRoDescription
GetPasswordAgingchage -l aliceLowAllshow a user's password-aging fields (chage -l) — param: user*; read-only
SetPasswordAgingsudo chage -M 90 aliceHighAllset a user's password aging (chage) — params: user*, plus at least one of max_days/min_days/warn_days (0-99999); High risk
SetPasswordPolicysudo /usr/lib/sysknife/pam-edit --op pwquality --minlen 12HighAllset 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
SetAccountLockoutsudo /usr/lib/sysknife/pam-edit --op faillock --deny 5HighAllconfigure 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

ActionCommandRiskDistroRbRoDescription
GetAuditRulesauditctl -lLowAlllist loaded audit rules (auditctl -l) — no params; read-only; needs auditd installed
AddAuditRulesudo /usr/lib/sysknife/audit-edit --op add --path /etc/passwd --perms wa --key passwd-watchMediumAlladd 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
RemoveAuditRulesudo /usr/lib/sysknife/audit-edit --op remove --key passwd-watchHighAllremove a SysKnife-managed audit rule by key — param: key*; High risk

certbot / ACME

ActionCommandRiskDistroRbRoDescription
GetCertificatescertbot certificatesLowAlllist certbot-managed certificates — no params; read-only; needs certbot installed
ObtainCertificatesudo certbot certonly --non-interactive --agree-tos --standalone -m admin@example.com -d example.comHighAllobtain 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
RenewCertificatessudo certbot renewMediumAllrenew due certbot certificates (certbot renew) — no params; Medium risk; needs certbot + network

Scoped sudoers.d

ActionCommandRiskDistroRbRoDescription
GetSudoGrants/usr/lib/sysknife/sudoers-edit --op listLowAlllist SysKnife-managed sudoers.d drop-ins — no params; read-only
GrantSudoAccesssudo /usr/lib/sysknife/sudoers-edit --op grant --name deploy-restart --user deploy --commands /usr/bin/systemctl --nopasswdHighAllgrant 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
RevokeSudoAccesssudo /usr/lib/sysknife/sudoers-edit --op revoke --name deploy-restartHighAllremove a SysKnife-managed sudoers.d drop-in — param: name*; High risk

Network

ActionCommandRiskDistroRbRoDescription
ConfigureWifisudo nmcli device wifi connect CafeHotspotHighAllconnect to a Wi-Fi network — params: ssid*, password (optional for open networks)
SetDnsServerssudo resolvectl dns wlp1s0 1.1.1.1 8.8.8.8HighAllset DNS servers for an interface — params: interface* (e.g. wlp1s0), servers* (string[])
ConfigureFirewallsudo sh -c firewall-cmd --permanent --zone='public' --add-service='ssh' && firewall-cmd --reloadHighAlladd/remove a service in a firewalld zone — params: zone*, service*, enabled* (bool)
GetFirewallStatefirewall-cmd --list-allLowAllshow current firewalld zones, open services, and port rules — no params
GetNetworkStatusip -brief addrLowAllshow network interfaces, IP addresses, and connection state — no params
GetListeningPortsss -tulpnHLowAllshow 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

ActionCommandRiskDistroRbRoDescription
ResolvectlStatusresolvectl statusLowAllshow DNS resolution status for all network interfaces (resolvectl status) — no params; cross-distro (any systemd-resolved host); read-only
ResolvectlSetDnssudo resolvectl dns eth0 1.1.1.1 8.8.8.8HighAllset DNS servers for a network interface — params: interface* (e.g. eth0), servers* (string[]); cross-distro; High risk

Identity / time / locale

ActionCommandRiskDistroRbRoDescription
GetDateTimetimedatectlLowAllcurrent date, time, timezone, and NTP status (timedatectl) — no params
SetHostnamesudo hostnamectl set-hostname sysknife-labMediumAllchange the system hostname — param: hostname* (string)
SetTimezonesudo timedatectl set-timezone America/Mexico_CityMediumAllchange the system timezone — param: timezone* (e.g. America/Chicago)
SetLocalesudo localectl set-locale en_US.UTF-8MediumAllchange the system locale — param: locale* (e.g. en_US.UTF-8)
SetNtpsudo timedatectl set-ntp trueMediumAllenable or disable NTP sync — param: enabled* (bool)

Users & groups

ActionCommandRiskDistroRbRoDescription
ListUsersgetent passwdLowAlllist all local user accounts — no params
ListGroupsgetent groupLowAlllist all local groups — no params
CreateUsersudo useradd --create-home --home-dir /home/alice --shell /bin/bash aliceHighAllcreate a local user account — param: username*; optional: shell, home
DeleteUsersudo userdel aliceHighAlldelete a local user account — param: username*
AddUserToGroupsudo sh -c grep -q '^wheel:' /etc/group || getent group 'wheel' >> /etc/group; usermod --append --groups 'wheel' 'alice'HighAlladd a user to a group — params: username*, group*
RemoveUserFromGroupsudo sh -c grep -q '^wheel:' /etc/group || getent group 'wheel' >> /etc/group; gpasswd --delete 'alice' 'wheel'HighAllremove a user from a group — params: username*, group*
CreateGroupsudo groupadd developersMediumAllcreate a local group — param: group*; optional: system (bool → system GID range)
DeleteGroupsudo groupdel developersHighAlldelete a local group — param: group*; irreversible
LockUserAccountsudo usermod --lock aliceHighAlldisable password login for a user without deleting it — param: username*
UnlockUserAccountsudo usermod --unlock aliceHighAllre-enable password login for a locked user — param: username*

SSH keys

ActionCommandRiskDistroRbRoDescription
GetAuthorizedKeyscat /home/alice/.ssh/authorized_keysLowAlllist SSH authorized_keys for a user — param: username*
AddAuthorizedKeysudo sh -c grep -Fxq 'ssh-ed25519 AAAA...' '/home/alice/.ssh/authorized_keys' 2>/dev/null || echo 'ssh-ed25519 AAAA...' >> '/home/alice/.ssh/authorized_keys'HighAllappend an SSH public key to a user's authorized_keys — params: username*, public_key* (full key string)
RemoveAuthorizedKeysudo sh -c sed -i '\\|^ssh-ed25519 AAAA...$|d' '/home/alice/.ssh/authorized_keys'HighAllremove an SSH public key from a user's authorized_keys — params: username*, public_key* (full key string)
SetSshdOptionsudo /usr/lib/sysknife/sshd-option-edit --option PermitRootLogin --value prohibit-passwordHighAllharden 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

ActionCommandRiskDistroRbRoDescription
ListPackageRepositoriesscan /etc/yum.repos.dLowAlllist configured DNF/rpm-ostree repos and their enabled state — no params
AddPackageRepositorywrite /etc/yum.repos.d/repo-id.repoHighAlladd a DNF repo — params: repo_id*, repo_url*
RemovePackageRepositorydelete /etc/yum.repos.d/repo-id.repoMediumAllremove a DNF repo — param: repo_id*
EnablePackageRepositorypatch /etc/yum.repos.d/repo-id.repoMediumAllenable a disabled DNF repo — param: repo_id*
DisablePackageRepositorypatch /etc/yum.repos.d/repo-id.repoMediumAlldisable a DNF repo without removing it — param: repo_id*

System info

ActionCommandRiskDistroRbRoDescription
GetMemoryInfofree -hLowAllshow RAM and swap usage (free -h) — no params

Containers

ActionCommandRiskDistroRbRoDescription
ListContainerssudo runuser -l testuser -c podman ps --all --format jsonLowAlllist Podman containers for a user — param: username*
CreateContainersudo runuser -l testuser -c podman create --name 'sysknife-dev' 'registry.fedoraproject.org/fedora-toolbox:41'MediumAllcreate a Podman container — params: username*, name*, image* (e.g. ubuntu:22.04)
StartContainersudo runuser -l testuser -c podman start 'sysknife-dev'MediumAllstart a Podman container — params: username*, name*
StopContainersudo runuser -l testuser -c podman stop 'sysknife-dev'MediumAllstop a Podman container — params: username*, name*
RemoveContainersudo runuser -l testuser -c podman rm 'sysknife-dev'MediumAllremove a stopped Podman container — params: username*, name*
GetContainerInfosudo runuser -l testuser -c podman inspect 'sysknife-dev'LowAllinspect a Podman container — params: username*, name*

Reboot

ActionCommandRiskDistroRbRoDescription
CheckPendingRebootbash -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.'; fiLowUbuntucheck whether a reboot is pending (/var/run/reboot-required) — no params; Ubuntu/Debian only; read-only

AppArmor

ActionCommandRiskDistroRbRoDescription
AppArmorStatussudo aa-statusLowUbuntushow status of all loaded AppArmor profiles (aa-status) — no params; Ubuntu only; read-only
AppArmorEnforcesudo aa-enforce /etc/apparmor.d/usr.bin.firefoxHighUbuntuput an AppArmor profile into enforce mode (aa-enforce) — param: profile_path* (e.g. /etc/apparmor.d/usr.bin.firefox); Ubuntu only; High risk
AppArmorComplainsudo aa-complain /etc/apparmor.d/usr.bin.firefoxHighUbuntuput an AppArmor profile into complain/learning mode (aa-complain) — param: profile_path*; Ubuntu only; High risk (disables MAC enforcement for the profile)

cloud-init

ActionCommandRiskDistroRbRoDescription
CloudInitStatuscloud-init status --longLowUbuntushow cloud-init provisioning status (cloud-init status --long) — no params; Ubuntu only; read-only

fail2ban

ActionCommandRiskDistroRbRoDescription
Fail2banStatussudo fail2ban-client statusLowUbuntushow fail2ban jail status — optional param: jail (omit for all jails); Ubuntu only; read-only
Fail2banBanIpsudo fail2ban-client set sshd banip 192.0.2.1HighUbuntuban an IP address in a fail2ban jail — params: jail* (string), ip* (IPv4 or IPv6); Ubuntu only; High risk
Fail2banUnbanIpsudo fail2ban-client set sshd unbanip 192.0.2.1MediumUbuntuunban an IP address from a fail2ban jail — params: jail*, ip*; Ubuntu only; Medium risk
ConfigureFail2banJailsudo /usr/lib/sysknife/fail2ban-jail-edit --name sshd --maxretry 3HighUbuntuwrite 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

ActionCommandRiskDistroRbRoDescription
AptUpdatesudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get updateLowUbunturefresh apt package index (apt-get update) — no params; Ubuntu only
AptUpgradesudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get dist-upgrade -yHighUbuntuupgrade all installed packages via dist-upgrade — no params; Ubuntu only; High risk
AptInstallsudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y curlMediumUbuntuinstall a package — param: package* (string, e.g. nginx); Ubuntu only
AptRemovesudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get remove -y curlMediumUbunturemove a package, keep config files — param: package*; Ubuntu only
AptPurgesudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get purge -y curlMediumUbunturemove a package AND its config files — param: package*; Ubuntu only
AptAutoremovesudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get autoremove -yMediumUbunturemove automatically-installed packages no longer needed — no params; Ubuntu only
AptHoldsudo apt-mark hold curlMediumUbuntupin a package at its current version (apt-mark hold) — param: package*; Ubuntu only
AptUnholdsudo apt-mark unhold curlMediumUbuntuunpin a package to allow upgrades (apt-mark unhold) — param: package*; Ubuntu only
AptSearchapt-cache search curlLowUbuntusearch apt repos for packages — param: term*; Ubuntu only; read-only
AptListInstalleddpkg -lLowUbuntulist all installed packages (dpkg -l) — no params; Ubuntu only; read-only
AptShowapt-cache show curlLowUbuntushow package details (version, deps, description) — param: package*; Ubuntu only; read-only
AptListUpgradablebash -c apt list --upgradable 2>/dev/nullLowUbuntulist packages with available upgrades — no params; Ubuntu only; read-only. Use for 'are there pending updates?' or 'what updates are available?'
AptHistoryListbash -c grep -A 4 '^Start-Date' /var/log/apt/history.log | tail -n 80LowUbuntushow recent apt transaction history — no params; Ubuntu only; read-only
ConfigureUnattendedUpgradessudo /usr/lib/sysknife/unattended-upgrades-edit --enableHighUbuntuenable or disable automatic security updates (unattended-upgrades) — param: enabled* (bool); Ubuntu only; High risk

apt preferences / pinning

ActionCommandRiskDistroRbRoDescription
GetAptPinsapt-cache policyLowUbuntushow apt pin priorities (apt-cache policy) — param: package (optional); Ubuntu only; read-only
SetAptPinsudo /usr/lib/sysknife/apt-pin-edit --op set --name hold-nginx --package nginx --pin version 1.24.* --priority 990MediumUbuntupin 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
RemoveAptPinsudo /usr/lib/sysknife/apt-pin-edit --op remove --name hold-nginxMediumUbunturemove a SysKnife-managed apt pin — param: name*; Ubuntu only; Medium risk

PPA

ActionCommandRiskDistroRbRoDescription
AddPpasudo add-apt-repository -y ppa:deadsnakes/ppaHighUbuntuadd a Launchpad PPA — param: name* in <user>/<ppa> format (e.g. 'deadsnakes/ppa'); Ubuntu only; requires software-properties-common
RemovePpasudo add-apt-repository -y --remove ppa:deadsnakes/ppaMediumUbunturemove a Launchpad PPA — param: name* in <user>/<ppa> format; Ubuntu only

snap

ActionCommandRiskDistroRbRoDescription
SnapInstallsudo sh -c snap install --channel=stable firefox && snap refresh --hold firefoxMediumUbuntuinstall a snap (auto-holds to prevent auto-refresh) — params: name*; optional: channel (default stable), auto_update (bool, default false); Ubuntu only
SnapRemovesudo snap remove firefoxMediumUbunturemove a snap — param: name*; Ubuntu only
SnapRefreshsudo snap refresh firefoxMediumUbuntuupdate a snap or all snaps — param: name (optional, omit for all); Ubuntu only
SnapHoldsudo snap refresh --hold firefoxMediumUbuntupin a snap at its current version (snap refresh --hold) — param: name*; Ubuntu only
SnapUnholdsudo snap refresh --unhold firefoxMediumUbuntuallow a held snap to auto-refresh again — param: name*; Ubuntu only
SnapListsnap listLowUbuntulist installed snaps — no params; Ubuntu only; read-only
SnapInfosnap info firefoxLowUbuntushow snap details (version, channel, description) — param: name*; Ubuntu only; read-only
SnapRevertsudo snap revert firefoxMediumUbunturevert a snap to its previous revision — param: name*; Ubuntu only
SnapClassicInstallsudo snap install --classic codeMediumUbuntuinstall a snap with classic confinement (full system access) — param: name*; Ubuntu only

ufw

ActionCommandRiskDistroRbRoDescription
UfwEnablesudo ufw --force enableHighUbuntuenable the ufw firewall — no params; Ubuntu only; High risk
UfwDisablesudo ufw disableHighUbuntudisable the ufw firewall — no params; Ubuntu only; High risk
UfwAllowsudo ufw allow 22HighUbuntuallow inbound traffic on a port or service — param: port_or_service* (e.g. 22, 22/tcp, OpenSSH); Ubuntu only; High risk
UfwDenysudo ufw deny 23HighUbuntudeny inbound traffic on a port or service — param: port_or_service*; Ubuntu only; High risk
UfwResetsudo ufw --force resetHighUbuntureset ufw to defaults, removing all rules — no params; Ubuntu only; High risk; irreversible
UfwStatussudo ufw status verboseLowUbuntushow current ufw status and rules — no params; Ubuntu only; read-only
UfwDeleteRulesudo ufw --force delete 1HighUbuntudelete a ufw rule by number — param: rule_number* (positive integer from 'ufw status numbered'); Ubuntu only; High risk
UfwLimitsudo ufw limit 22HighUbuntuadd 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

ActionCommandRiskDistroRbRoDescription
NetplanGetConfigfind /etc/netplan -maxdepth 1 -name *.yaml -print -exec cat {} +LowUbunturead current netplan YAML config from /etc/netplan/ — no params; Ubuntu only; read-only
NetplanApplysudo netplan applyHighUbuntuapply netplan network configuration immediately — no params; Ubuntu only; High risk; can disconnect SSH
NetplanSetsudo netplan set ethernets.eth0.dhcp4=trueHighUbuntuset a single netplan key to a value — params: key* (e.g. 'ethernets.eth0.dhcp4'), value*; Ubuntu only; High risk; run NetplanApply to activate
NetplanGeneratesudo netplan generateMediumUbunturegenerate netplan backend config without applying — no params; Ubuntu only; Medium risk; dry-run before NetplanApply

distrobox

ActionCommandRiskDistroRbRoDescription
DistroboxListdistrobox listLowUbuntulist distrobox containers — no params; Ubuntu only; read-only
DistroboxCreatedistrobox create --yes --name dev --image ubuntu:24.04MediumUbuntucreate a distrobox container — params: name*, image* (e.g. ubuntu:24.04, fedora:41); Ubuntu only
DistroboxRemovedistrobox rm --force devMediumUbunturemove a distrobox container — param: name*; Ubuntu only

GRUB

ActionCommandRiskDistroRbRoDescription
GrubGetKargsgrep -E ^GRUB_CMDLINE_LINUX /etc/default/grubLowUbunturead current GRUB_CMDLINE_LINUX from /etc/default/grub — no params; Ubuntu only; read-only
GrubSetKargssudo /usr/lib/sysknife/grub-kargs-edit --append quiet --delete splashHighUbuntumodify GRUB kernel arguments and run update-grub — params: append (list), delete (list); Ubuntu only; High risk; requires reboot

Ubuntu release upgrade

ActionCommandRiskDistroRbRoDescription
UbuntuReleaseUpgradesudo do-release-upgrade -f DistUpgradeViewNonInteractiveHighUbuntuupgrade 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

ActionCommandRiskDistroRbRoDescription
ProStatuspro status --allLowUbuntushow Ubuntu Pro subscription status — no params; Ubuntu only; read-only
ProAttachsudo pro attach <REDACTED>HighUbuntuattach machine to an Ubuntu Pro subscription — param: token* (credential, never log); Ubuntu only; High risk
ProDetachsudo pro detach --assume-yesHighUbuntudetach from Ubuntu Pro subscription — no params; Ubuntu only; High risk
EnableProServicesudo pro enable esm-apps --assume-yesHighUbuntuenable 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
DisableProServicesudo pro disable esm-apps --assume-yesHighUbuntudisable one Ubuntu Pro service (pro disable <service>) — param: service* (same allowlist as EnableProService); Ubuntu only; High risk

Livepatch

ActionCommandRiskDistroRbRoDescription
LivepatchStatussudo canonical-livepatch status --verboseLowUbuntushow Canonical Livepatch kernel-patch status — no params; Ubuntu only; read-only; requires canonical-livepatch installed and Ubuntu Pro

Multipass

ActionCommandRiskDistroRbRoDescription
MultipassListmultipass listLowUbuntulist 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:

FieldWhat it commits to
seqMonotonic position in the chain
key_idWhich signing key generation wrote this row
transaction_id, request_idIdentifiers for the preview/approve/execute round-trip
request_hashCommitment to the exact request that was previewed
action_name, risk_levelThe action and its policy-assigned risk tier
summaryHuman-readable description of the planned action
approval_idWhich signed approval receipt authorized execution, if any
warnings_jsonWarnings surfaced to the user before approval
created_atWhen 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.

What is deliberately excluded

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 summary or risk_level) and its own signature no longer verifies.
  • Reorder or delete — remove or reorder a row and the next row's prev_chain_hash no 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 seq is still present in the chain and its chain_hash at that seq matches the anchored tip.
  • Truncated — the checkpoint's seq is no longer present at all (the chain is now shorter than a previously anchored tip proves it once was).
  • Tip mismatch (rewrite) — the seq is present, but its chain_hash no 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.

Checkpoints require an external sink

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.
  • status is 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 verify by 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:

  1. Did the job land in JobState::Failed?
  2. Does the executed ActionSpec carry rollback_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.

Note

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:

  • UpdateSystem
  • InstallPackages, RemovePackages
  • RebaseSystem
  • SetKernelArguments
  • AddLayeredPackage, 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

Warning

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 the transactions table as JobState::RolledBack, not Failed. 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) so RolledBack is visible to external monitoring, not just the local database.
  • The JobResult returned to the client includes rollback_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_execute surfaces rollback_ref to 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:

TransportBest forRequirement
SSH socket tunnelAny VM, any hypervisorSSH access to the guest
virtio-vsockKVM/QEMU guests on the same hostvhost_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 install uses 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>

-fN forks the SSH process and keeps the tunnel open without running a remote command. The local socket /tmp/sysknife-vm.sock is 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_vsock is loaded (lsmod | grep vhost_vsock) and the CID is correct (cat /sys/class/vsock/local_cid inside the guest)
  • Confirm the daemon is running: systemctl status sysknife-daemon inside 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.json to .gitignore.
  • Socket file permissions: the daemon socket at /run/sysknife/daemon.sock is owned by root:sysknife with mode 0660 — only members of the sysknife group 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:

  1. Opens a transaction and takes a PostgreSQL advisory transaction lock.
  2. Creates schema_migrations if needed.
  3. Applies each pending migration once and records its version.
  4. 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.

ProviderEndpoint guidance
AWS RDS / Aurora PostgreSQLUse the writer endpoint with sslmode=verify-full and the AWS CA bundle. Do not use an Aurora reader endpoint.
GCP Cloud SQL / AlloyDBPrefer the Auth Proxy or connector sidecar; connect to its loopback endpoint.
Azure Database for PostgreSQLUse Flexible Server with sslmode=verify-full and the current Azure CA chain.
SupabaseDirect port 5432 supports prepared statements. For a transaction-mode pooler, set the statement cache to 0.
NeonUse TLS; increase acquire_timeout_secs if scale-to-zero cold starts exceed the default.
Self-hosted PostgreSQLUse 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:


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=noninteractive suppresses all debconf prompts. NEEDRESTART_MODE=a auto-restarts services post-install without prompting. The env wrapper is the standard way to inject these into a sudo invocation because sudo strips 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-upgrade resolves dependency changes by removing packages where necessary. upgrade (without dist-) is safer but may not complete all upgrades. The choice of dist-upgrade is intentional and documented in the code. Exit code 0 = success; 100 = error.
  • Recommendation: Consider adding --no-install-recommends to 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-yes required 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 /etc are preserved. Use purge to 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 hold does not require DEBIAN_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-cache is 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 -l output 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 -W is cleaner for programmatic parsing but is not a bug — dpkg -l is well-understood and widely used.
  • 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/null suppresses 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 several Start-Date transaction 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 ensures unattended-upgrades is 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: --channel format 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: --classic is required for snaps with classic confinement (e.g., VS Code: snap install --classic code). SnapInstall itself does not expose a classic flag — 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: --hold without a =<duration> argument defaults to forever. 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 --hold and --unhold are flags on snap 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 Notes
    • Notes field can contain: classic, disabled, -, or hold info.
    • No --json flag 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 SnapInstall rather 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: --force suppresses the interactive "Proceed with operation (y|n)?" prompt. Required for daemon/non-interactive invocation. The man page confirms: "SSH administrators should use --force enable when enabling remotely." Exit code 0 = success.

UfwDisable — Disable firewall

  • Canonical: sudo ufw disable
  • SysKnife argv: ["sudo", "ufw", "disable"]
  • Status: ✅ matches
  • Notes: disable does not prompt; --force is not needed here (only enable and reset have interactive prompts in the default ufw implementation).

UfwAllow — Allow traffic

  • Canonical: sudo ufw allow <port_or_service>
  • Extended canonical: sudo ufw allow <port>/<proto> or sudo 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: --force suppresses the "Proceed with operation?" prompt. reset disables 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: verbose adds default policies (incoming/outgoing/routed) to the output. Without verbose, ufw status shows only active rules and the Enabled/Disabled state. status numbered adds rule numbers, which is how a user finds the rule_number argument for UfwDeleteRule.
  • Output format: Plain text, columns: To Action From with 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 from ufw status numbered)
  • Status: ✅ matches
  • Notes: --force suppresses the confirmation prompt, same as UfwEnable / UfwReset. Risk: High — a mistaken deletion can expose services or drop needed traffic. Rejects rule_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 find directly (no bash -c), printing each matched file's path followed by its contents. netplan get returns a single merged representation instead. The find approach:
    1. Does not merge configs across /lib/netplan/ and /run/netplan/ overlay paths.
    2. May include YAML comments; netplan get output is stripped and canonical.
    3. Surfaces three distinct states instead of collapsing them: a missing/ permission-denied /etc/netplan makes find exit non-zero with a stderr diagnostic, while a directory with no *.yaml files exits 0 with empty stdout. (An earlier version shelled out to bash -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 to find directly.)
    • For read-only inspection, this is harmless and does not require sudo for files readable by the daemon user. netplan get requires sudo on most Ubuntu installs because /etc/netplan/ is root-owned mode 600.
    • Not a blocking bug. Document as a known divergence.

NetplanApply — Apply configuration

  • Canonical: sudo netplan apply
  • SysKnife argv: ["sudo", "netplan", "apply"]
  • Status: ✅ matches
  • Notes: netplan apply immediately 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=value is passed as a single argument with no shell involved; validated_safe_arg (executor boundary) rejects spaces in value, so quoting a multi-word value would only inject literal quote bytes. Risk: High — modifies the active netplan configuration in-memory. Run NetplanApply afterward 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 / NetworkManager backend config files from the current netplan YAML but does not reload interfaces — safe to use as a dry-run check before NetplanApply.

Not implemented — netplan get

  • netplan get: Returns merged netplan config as YAML across /etc/netplan/, /lib/netplan/, and /run/netplan/. More canonical than the find-based NetplanGetConfig, which only reads /etc/netplan/. See the NetplanGetConfig divergence 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-color flag 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 --yes until 2026-04-26 — fixed in PR that lands distrobox_create_includes_yes_flag test)
  • Why --yes matters: 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"]

DistroboxRemove — Remove container

  • Canonical: distrobox rm --force <name>
  • SysKnife argv: ["distrobox", "rm", "--force", "<name>"]
  • Status: ✅ matches
  • Notes: --force / -f sets both force=1 and non_interactive=1 in 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

#FamilyActionDescriptionStatus
B1distroboxDistroboxCreateMissing --yes flag caused interactive prompt hang in daemon context✅ fixed 2026-04-26 — --yes added + regression test

Divergences (not bugs, but worth knowing)

#FamilyActionDescription
D1netplanNetplanGetConfigUses 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.
D2aptAptListInstalledUses dpkg -l (human-oriented, wide output). dpkg-query -W -f='${Package}\t${Version}\n' is cleaner for machine parsing.
D3snapSnapRemoveNo --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-firewallAIShell-Gategate-oc-auditMCP traffic gatewaysSysKnife
What the AI emitsshell strings (allowlisted)typed JSON commandsnothing (observe-only)proxied MCP callstyped semantic actions
Blocks execution on the hostpolicy filteryesno (advisory)at the proxy, not the hostyes — server-enforced interlock
Audit schemelogsHMAC-SHA256 (symmetric)Merkle tree + optional chain anchorlogs / immutable trailsEd25519 (asymmetric)
Third-party verifiablenono — verifier holds the secretvia an external networkvariesyes — public key alone
Automatic rollbacknonononoyes (rpm-ostree)
LicenseOSSproprietaryApache-2.0 (audit only)OSSMIT (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-shell servers — 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-shell server 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 cratessysknife-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)

AdvisoryCrateNote
RUSTSEC-2026-0097rand 0.7.3Unsound with a custom logger. Pulled via phfselectorskuchikikitauri-utils. Explicitly --ignored in CI (it is a non-unmaintained class that would otherwise fail the build).
RUSTSEC-2024-0429glibUnsoundness in VariantStrIter iterator impls. GTK3 binding stack.

Unmaintained

AdvisoryCrateNote
RUSTSEC-2024-0411gdkwayland-sysgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0412gdkgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0413atkgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0414gdkx11-sysgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0415gtkgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0416atk-sysgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0417gdkx11gtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0418gdk-sysgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0419gtk3-macrosgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0420gtk-sysgtk-rs GTK3 bindings — unmaintained
RUSTSEC-2024-0384instantunmaintained (transitive via GUI/webview)
RUSTSEC-2024-0370proc-macro-errorunmaintained (transitive)
RUSTSEC-2025-0012backoffunmaintained (transitive)
RUSTSEC-2025-0057fxhashunmaintained (transitive)
RUSTSEC-2025-0075unic-char-rangeunmaintained (transitive)
RUSTSEC-2025-0080unic-commonunmaintained (transitive)
RUSTSEC-2025-0081unic-char-propertyunmaintained (transitive)
RUSTSEC-2025-0098unic-ucd-versionunmaintained (transitive)
RUSTSEC-2025-0100unic-ucd-identunmaintained (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 rand when 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-brain plans
  • sysknife-shell presents and collects approval
  • sysknife-daemon executes

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/messages with input_schema tool definitions (Anthropic wire format).
  • OllamaProvider — POST /v1/chat/completions with OpenAI function-calling format (parameters field, 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_TURNS must be a positive integer when set.
  • ANTHROPIC_API_KEY must be non-empty when provider is anthropic.

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 LlmProvider and a new ProviderConfig variant; no changes to LlmPlanner.
  • 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:

  1. gRPC over Unix socket — uses the proto definitions directly with tonic. Full service interface. Requires HTTP/2 and a service block in the proto.
  2. Length-prefixed protobuf — binary, compact. Requires a custom dispatcher (no HTTP/2). Harder to inspect without tooling.
  3. Length-prefixed JSON — 4-byte LE u32 length + UTF-8 JSON body. Uses the same Rust structs (sysknife-types) already tested for serialization. Human-readable, easy to debug with socat.

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_exact is 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-ostree deployment 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:

  1. Prompt confusion: the model saw both AddLayeredPackage and AptInstall for every intent and had to infer which to use from context. In practice, GPT-4o and Claude sometimes proposed the wrong family — AptInstall on Silverblue, or AddLayeredPackage on Ubuntu — especially for compound intents that did not name the package manager explicitly.

  2. 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:

  1. Shared const blocks: role, the single-planning-rule, cross-distro action catalogue, and the six worked examples (A–F).
  2. Per-distro const blocks: 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 new DISTRO_FAMILY_* constant, a dispatch arm in build_system_prompt, and a *_ONLY_ACTIONS slice 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-release gets cross-distro-only planning until the user adds a manual distro_hint override in ~/.config/sysknife/config.toml.

References

  • PR #203 — "refactor(prompt): per-distro dispatch"
  • crates/sysknife-brain/src/prompt.rs — implementation
  • docs/architecture.md — prompt construction overview
  • docs/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

Prerequisites

ToolVersionInstall
Rust stablelatest stablerustup.rs
Node.js20+nodejs.org or your distro
pnpmlatestnpm install -g pnpm
Tauri system depstauri.app/start/prerequisites
pre-commitlatestpip 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:

HookWhat it checks
trailing-whitespaceRemoves trailing spaces
end-of-file-fixerEnsures files end with a newline
check-yaml / check-toml / check-jsonSyntax validity
no-commit-to-branchBlocks direct commits to main
gitleaksDetects hardcoded secrets
cargo fmtRust formatting (--check mode)
cargo checkWorkspace compilation
tsc --noEmitTypeScript type checking
markdownlint-cli2Markdown style
yamllintYAML 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.

VariableDefaultDescription
SYSKNIFE_LISTEN_URI$XDG_RUNTIME_DIR/sysknife/daemon.sock (prod: /run/sysknife/daemon.sock)Daemon socket URI (where the daemon binds)
SYSKNIFE_SOCKETfalls back to the same default as SYSKNIFE_LISTEN_URICLI / 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_PROVIDERauto-detectanthropic, openai, gemini, ollama, groq, deepseek, mistral, or xai
ANTHROPIC_API_KEYRequired for the Anthropic provider
OPENAI_API_KEYRequired for the OpenAI provider
GEMINI_API_KEYRequired for the Gemini provider
GROQ_API_KEYRequired for the Groq provider
DEEPSEEK_API_KEYRequired for the DeepSeek provider
MISTRAL_API_KEYRequired for the Mistral provider
XAI_API_KEYRequired for the xAI provider
SYSKNIFE_OLLAMA_URLhttp://localhost:11434Ollama base URL
SYSKNIFE_LLM_MODELprovider defaultOverride the model name
SYSKNIFE_BRAIN_MAX_TURNS10Planning 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.

CheckCommand
Rust formattingcargo fmt --all --check
Clippy (warnings as errors)cargo clippy --workspace --all-features --locked -- -D warnings
Rust testscargo nextest run --workspace --locked
TypeScript type checknpx tsc --noEmit (in apps/sysknife-shell)
Frontend testspnpm test (in apps/sysknife-shell)
Markdown lintmarkdownlint-cli2 on contributor-facing docs
Link checkmarkdown-link-check on contributor-facing docs
YAML lintyamllint 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

LevelWhat it testsSpeedWhen
Unit tests (Rust)Individual functions, parsers, traits<5sEvery commit, every CI run
Unit tests (TypeScript)React components, reducers, IPC shims<5sEvery commit, every CI run
Integration (Rust)Daemon IPC, safety fence, policy<10sEvery commit, every CI run
Dev stories (local, no VM)LLM plan structure for read-only stories; runs on any Linux host1-3 minAfter brain/prompt changes
CI smoke (container)Daemon + Ollama + read-only stories in a Linux runner5-10 minOpt-in (PR label e2e or manual trigger)
E2E Atomic VMReal Silverblue in QEMU/KVM, full stack, all 54 stories15-30 min first boot; 2-3 min subsequentLocal / pre-release
E2E Ubuntu VMUbuntu 24.04 cloud image, full story suite, 65/65 stories~15 min first boot; ~2 min subsequentAfter Ubuntu action changes
Manual QAReal Silverblue/Kinoite hardware, destructive actions, GUI30-60 minBefore 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 setProvider usedModel
ANTHROPIC_API_KEYanthropicclaude-sonnet-4-6
OPENAI_API_KEYopenaigpt-4.1
GEMINI_API_KEYgeminigemini-2.0-flash
neitherollamaqwen3: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 or Get*/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 run rpm-ostree, model escalates to get_system_stateStateUnavailable. Passes on a real VM where rpm-ostree is 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_keys fails 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:

  1. Label a PR with e2e — the workflow runs automatically
  2. 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.

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-ssh step? 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 via libguestfs. The enable-ssh subcommand is still there as a fallback if your Anaconda install did create a usable user.

What bootstrap does, in one shot: create user lacsdev, set the password (lacsdev), set root password (sysknife), install your VM SSH key, NOPASSWD-sudoers lacsdev, enable sshd, set SELinux to permissive, and pre-mark gnome-initial-setup as done. Idempotent — safe to re-run after install.

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_VARIANTAtomic DesktopDesktop
silverblue (default)Fedora SilverblueGNOME
kinoiteFedora KinoiteKDE Plasma
sericeaFedora Sway AtomicSway
onyxFedora Budgie AtomicBudgie
cosmic-atomicFedora COSMIC AtomicCOSMIC

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:

  1. Download the Silverblue ISO from fedoraproject.org/atomic-desktops/silverblue
  2. Create a VirtualBox VM (4 GB RAM, 2 vCPUs, 20 GB disk) with SSH port forwarded from host 22220 → guest 22
  3. Attach the ISO, boot, and run the Fedora installer. Create user lacsdev. Enable sshd during install.
  4. SSH into the VM: ssh -p 22220 lacsdev@127.0.0.1
  5. Clone the repo into /home/lacsdev/sysknife and run sudo bash tests/e2e/provision.sh inside the VM
  6. 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

  1. cargo nextest run --workspace && pnpm test — required, fast
  2. cargo clippy --workspace --all-features --locked -- -D warnings
  3. cargo fmt --all --check
  4. For changes to the brain, daemon, IPC, or action catalogue:
    • Run the VM tests locally (atomic-vm.sh flow)
    • Add the e2e label to trigger the CI smoke test on your PR

Before a release

The maintainer runs these in order:

  1. All automated tests green on main
  2. VM tests (at least Silverblue + one other atomic variant) pass locally
  3. Manual QA on real Silverblue hardware using docs/testing/user-stories.md as the checklist — all 54 stories including destructive ones
  4. 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:

  1. The Ollama tarball itself (~1.5 GB, downloaded by install.sh from ollama.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.

  2. The model pull (~2 GB for the default llama3.2:3b, or ~5 GB for qwen3:8b if you override). Happens after Ollama is installed, via ollama 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)

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

  1. Read the architecture overview to understand the four-crate structure and the trust boundary.
  2. Read the developer guide for prerequisites and build instructions.
  3. Read the testing guide for which tests to run and when.
  4. Check open issues to avoid duplicating work in progress.
  5. 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:

  1. Write the failing test first.
  2. Run it and confirm it fails for the right reason.
  3. Write the minimal implementation that makes it pass.
  4. 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

TypeWhen
featNew user-visible feature
fixBug fix
docsDocumentation only
choreBuild, CI, tooling
testTests only
refactorNo behavior change

Subject line under 72 characters. Add a body for non-obvious changes.

How to Add a New Daemon Action

  1. Add the action name to KNOWN_ACTIONS in crates/sysknife-brain/src/planning_tools/propose_plan.rs (action_name.rs only imports and re-exports it).
  2. Add the execution spec to crates/sysknife-daemon/src/executor.rs (build_action_spec).
  3. Add the preview logic to crates/sysknife-daemon/src/preview.rs (preview_action).
  4. Add the rollback spec (if applicable) to crates/sysknife-daemon/src/executor.rs (rollback_spec_for).
  5. Add input validation to crates/sysknife-daemon/src/actions/validate.rs.
  6. Add an entry to the system prompt's action catalogue in crates/sysknife-brain/src/prompt.rs (name, description, params, risk level).
  7. Write unit tests for the preview and execution logic.
  8. 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:

  1. Calls sysknife --dry-run --json "<intent>" and captures the plan.
  2. Parses the resulting JSON plan.
  3. 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:

AreaFile(s)Why sensitive
Intent validationcrates/sysknife-brain/src/planner.rs (INTENT_MAX_BYTES, guards in plan_intent)First line of defense before any LLM call
Secret patternscrates/sysknife-brain/src/prefs.rs (SENSITIVE_PATTERNS, SENSITIVE_PREFIXES)Controls what the planner and prefs storage will reject
Action allowlistcrates/sysknife-brain/src/planning_tools/propose_plan.rs (KNOWN_ACTIONS)Adding a name here makes it proposable by the LLM
Role policycrates/sysknife-daemon/src/policy.rs (min_role_for_action)Governs which groups can execute which actions
Approval / replaycrates/sysknife-daemon/src/transactions.rsHash freshness and TOCTOU protection
Caller authcrates/sysknife-daemon/src/dispatcher.rs (resolve_caller_role)SO_PEERCRED group-to-role mapping
Audit logcrates/sysknife-brain/src/audit.rs, crates/sysknife-brain/src/journal.rsSafety fence record and journald forwarding

When adding a new action to KNOWN_ACTIONS, you must also:

  1. Add it to min_role_for_action in policy.rs with 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.
  2. Add it to the action catalogue in crates/sysknife-brain/src/prompt.rs with 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.

StoryIntentFedora action assertedUbuntu equivalentSuggested jq fragment
story-4"what ports are currently open on the firewall?"GetFirewallStateUfwStatusselect(.action == "GetFirewallState" or .action == "UfwStatus")
story-5"what packages have I layered on top of the base system?"GetLayeredPackagesAptListInstalledselect(.action == "GetLayeredPackages" or .action == "AptListInstalled")
story-8"install vim as a layered package"InstallPackages / AddLayeredPackageAptInstallselect(.action == "InstallPackages" or .action == "AddLayeredPackage" or .action == "AptInstall")
story-9"create a toolbox container called dev-test"CreateToolboxDistroboxCreateselect(.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"GetFirewallStateUfwStatusaccept GetFirewallState or UfwStatus
story-28"show me the kernel boot arguments and list all my deployments"GetKernelArguments + ListDeployments/GetDeploymentHistoryno Ubuntu equivalent for GetKernelArguments or deployment listingIntent is Fedora/OSTree-specific; on Ubuntu this story should be skipped or rewritten
story-33"add rd.driver.blacklist=nouveau to the kernel arguments"SetKernelArgumentsno Ubuntu equivalentFedora/OSTree-only; skip on Ubuntu
story-34"my system broke after the last rpm-ostree update, roll it back"RollbackDeploymentno Ubuntu equivalentIntent hardcodes "rpm-ostree" phrasing; Fedora-only
story-35"open port 8080 on the firewall for my web app"ConfigureFirewallUfwAllowselect(.action == "ConfigureFirewall" or .action == "UfwAllow")
story-40"rebase my Silverblue system to Fedora 41"RebaseSystemno Ubuntu equivalentFedora/OSTree-only; skip on Ubuntu
story-43"free up disk space by removing old system deployments"CleanupDeploymentsno Ubuntu equivalentFedora/OSTree-only; skip on Ubuntu
story-45"the new kernel was just installed, I need to reboot to activate it"RebootSystemRebootSystemAction 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?"GetPendingUpdatesAptCheckUpdates (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"ListInstalledFlatpaksno Ubuntu-native flatpak action in catalogueFlatpak runs on Ubuntu but no Ubuntu-specific action; assertion is Fedora-focused
story-52"update Firefox flatpak"UpdateFlatpakno Ubuntu-specific flatpak actionSame as above
story-53"remove gedit from the base image"RemoveBasePackageno Ubuntu equivalent (no rpm-ostree override concept)Fedora/OSTree-only; skip on Ubuntu
story-54"update all my flatpak apps"UpdateFlatpakno Ubuntu-specific flatpak actionFlatpak 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.

RangeAction families
story-55 – story-65apt (AptUpdate, AptUpgrade, AptInstall, AptRemove, AptPurge, AptAutoremove, AptHold, AptUnhold, AptSearch, AptListInstalled, AptShow)
story-66 – story-72snap (SnapInstall, SnapRemove, SnapHold, SnapUnhold, SnapList, SnapInfo, SnapRefresh)
story-73 – story-79ufw (UfwStatus, UfwEnable, UfwDisable, UfwAllow, UfwDeny, UfwReset)
story-80 – story-82distrobox (DistroboxList, DistroboxCreate, DistroboxRemove)
story-83 – story-84netplan (NetplanGetConfig, NetplanApply)
story-85 – story-104compound + 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

StoryIntent / ActionCategoryUbuntu-safe?
exec-1show disk usage → GetDiskUsagedistro-agnostic, read-onlyyes
exec-2show memory → GetMemoryInfodistro-agnostic, read-onlyyes
exec-3service status → GetServiceStatusdistro-agnostic, read-onlyyes
exec-4SSH key round-trip → AddAuthorizedKey + RemoveAuthorizedKeydestructive, distro-agnosticyes
exec-5create/delete userdestructive, distro-agnosticyes
exec-6list running services → ListServicesdistro-agnostic, read-onlyyes
exec-7"restart firewalld" → RestartService(firewalld)Fedora-only — asserts systemctl is-active firewalldno — firewalld absent on Ubuntu
exec-8hostname round-tripdestructive, distro-agnosticyes
exec-9timezone round-tripdestructive, distro-agnosticyes
exec-10user/group membershipdestructive, distro-agnosticyes
exec-11ConfigureFirewall ftp cycle, asserts firewall-cmdFedora-only — uses firewall-cmd --list-servicesno — needs Ubuntu replacement using ufw status
exec-12UfwAllow + UfwDeny cycle → allow port 8080, assert open, deny port 8080, assert closeddestructive, Ubuntu-only — asserts ufw status; gated on SYSKNIFE_DISTRO_FAMILY=ubuntu|debianyes

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.


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:

  1. Verifies the tag against every Cargo and npm package version.
  2. Builds sysknife and sysknife-daemon on native Linux x86_64 and aarch64 runners.
  3. Generates SPDX SBOMs, checksums, and GitHub artifact attestations.
  4. Publishes sysknife-setup to npm through trusted publishing (OIDC).
  5. Publishes the public Rust crates to crates.io in dependency order.
  6. 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, repository lacs-project/sysknife, workflow release.yml, and the exact GitHub owner. The npm job uses Node 24 and id-token: write; no long-lived NPM_TOKEN is used. See npm trusted publishing.
  • Add CARGO_REGISTRY_TOKEN as a GitHub Actions secret. Restrict the token to only the SysKnife crates where crates.io token scopes allow it.
  • Protect main with 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 --full on 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.
  • main ruleset requires CI, E2E script lint, Postgres contract, approval, resolved conversations, and blocks force pushes.
  • npm trusted publishing matches lacs-project/sysknife and release.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...candidate diff 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.