diff --git a/README.md b/README.md index 860e14e..b26e9bd 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ The installer adds the Vulkan driver for Intel and AMD GPUs, and Intel's video d - Commands run over SSH (`ssh {machine} {command}`) can find tools in ~/.local/bin and mise. - You log in to this machine over SSH with a key, not a password, so nobody on the same network can try to guess your password. Put your public key in ~/.ssh/authorized_keys on this machine (`ssh-copy-id` does this from the other machine). To allow passwords again, delete /etc/ssh/sshd_config.d/10-coredesktop-keys-only.conf and run `sudo systemctl reload sshd`. Machines installed earlier, where someone has logged in over SSH with a password, keep accepting passwords until you run `bash ~/.local/share/coredesktop/system/sshd.sh`. - ssh uses Bitwarden's SSH agent while Bitwarden is running it (turn it on in Bitwarden's settings), and GNOME's otherwise. A terminal opened before Bitwarden started uses GNOME's; open a new one. +- WinBoat's Windows container doesn't start at boot: install and every update turn off WinBoat's "Auto Start Container" (in ~/.winboat/docker-compose.yml and on the container), since Windows would otherwise hold 8 GB of memory and 4 cores from boot whether you use it or not. It still starts when you open WinBoat. ### What CoreDesktop changes on your system @@ -267,6 +268,7 @@ It covers: - **Languages** to install with mise, such as Ruby on Rails (see Mise above). - **Printers** on your network. It lists the printers it finds, whether they're reached directly or shared by another computer, and adds the ones you choose as printers on this computer, without drivers (IPP Everywhere), so they're in every print dialog. A printer another computer shares that can also be reached directly is marked, and not chosen unless you pick it, so printing doesn't depend on that computer being on. Printers already added, e.g. in GNOME Settings, are left alone. To choose without being asked, set a default printer or try it first, run `~/.local/share/coredesktop/system/setup-printers.sh --help`. - **Network storage**: shared folders from other machines on your network, mounted in /mnt and added to the Files sidebar. It finds SMB servers (Windows, Samba and NAS boxes, and Macs with File Sharing on) and machines you can ssh to, or you enter an address (`smb://nas.local/files`, `sftp://me@server.local/srv/data`). An SMB share is in /etc/fstab and mounts the first time you open its folder, so a server that's off doesn't hold anything up; its password is kept in /etc/coredesktop/smb, readable only by root. An SFTP folder is mounted by sshfs as you, at login, with the SSH keys in your Bitwarden or GNOME SSH agent, so the server has to accept one of them (it can't ask for a password). Agents in the sandbox can read these folders, and change one only if you start the agent in it. "Remove one" unmounts it and takes it back out. +- **Agent sandbox**, if WinBoat is set up: the sandbox hides WinBoat's settings (~/.winboat) from agents, since they include your Windows password. To let agents read them, e.g. to have one help with WinBoat, run `configure sandbox` and switch it; switch it again to hide them. This only covers the sandboxed agents (opencode, Hermes, Cline), not Claude Code's own sandbox. Your SSH keys, password stores and the rest stay hidden either way. - **AI servers** on your network, such as Ollama on a Mac. It finds the machines on your network over mDNS and asks each one on the ports LLM servers use (11434 Ollama, 1234 LM Studio, 8080 llama.cpp, 8000 vLLM), since LLM servers don't announce themselves; or you enter a server's address. For the server you choose, it lets the sandboxed agents reach it (that port only), adds it to opencode with its models, offers to make it Hermes' model, and lets the commands Claude Code runs reach it too. Claude Code itself keeps using Anthropic's models. A server has to accept connections from other machines: in Ollama's settings, turn on "Expose Ollama to the network"; in LM Studio, "Serve on Local Network". "Remove a server" takes it back out of all of them. To set up one thing, name it, e.g. `configure hermes`; `configure --list` shows the names. @@ -303,7 +305,7 @@ restic snapshots CoreDesktop installs the AI coding agents opencode, Hermes Agent, Cline Desktop and Claude Code, and runs them in a sandbox. In the sandbox, an agent: - can change files only in the directory you start it from (plus its own settings and a few package caches), so start it from a project directory -- can't read your SSH keys, password stores, browser profiles, Flatpak apps' data (browsers, Signal, Telegram and the rest), your Nextcloud password, cloud and Git credentials, or the other agents' logins +- can't read your SSH keys, password stores, browser profiles, Flatpak apps' data (browsers, Signal, Telegram and the rest), your Nextcloud password, WinBoat's settings (which hold your Windows password), cloud and Git credentials, or the other agents' logins - can reach only AI providers and common developer sites: GitHub, Gitea, npm, PyPI, crates.io, RubyGems, the Go module proxy and archlinux.org Typing `opencode`, `hermes` or `hermes-agent` runs it in the sandbox. To run one without it, type `unsandboxed opencode`. Hermes' gateway, the background service that lets you talk to Hermes through Matrix and other messaging apps, runs in the sandbox too, and can reach the Matrix server in Hermes' settings. For other messaging services, add their domains as shown below. Hermes talks to its gateway through a local socket, so its sandbox allows local sockets but hides the ones that would let it act outside the sandbox: your session's D-Bus and SSH agent, Docker, Tailscale, X11, tmux and browsers. Claude Code uses its own built-in sandbox for every command it runs (see `/sandbox` inside Claude). diff --git a/configure.d/50-ai-servers.sh b/configure.d/50-ai-servers.sh index dd41de8..b7b6d2d 100644 --- a/configure.d/50-ai-servers.sh +++ b/configure.d/50-ai-servers.sh @@ -14,6 +14,7 @@ SANDBOX_CONF="$HOME/.config/coredesktop/sandbox/base.json" CLAUDE_SETTINGS="$HOME/.claude/settings.json" OPENCODE_CONF="$HOME/.config/opencode/opencode.json" source "$COREDESKTOP/configure.d/lib/mdns.sh" +source "$COREDESKTOP/configure.d/lib/json.sh" # Ports LLM servers listen on by default, and who uses each. All of them answer # OpenAI's API at /v1, which is how every agent here talks to them. @@ -62,23 +63,6 @@ find_servers() { rm -rf "$dir" } -# Edit a JSON file in place with jq, creating it from $2 if it's missing. -# Leaves the file alone if it isn't valid JSON or jq fails. -edit_json() { - local file="$1" empty="$2" tmp - shift 2 - mkdir -p "$(dirname "$file")" || return 1 - [ -f "$file" ] || echo "$empty" > "$file" - tmp="$(mktemp "$file.XXXXXX")" || return 1 - if jq "$@" "$file" > "$tmp"; then - mv -f "$tmp" "$file" - else - echo "Couldn't change $file; is it valid JSON?" - rm -f "$tmp" - return 1 - fi -} - # Let every sandboxed agent (opencode, Hermes, Cline) reach host:port, and # only that port sandbox_allow() { diff --git a/configure.d/65-sandbox.sh b/configure.d/65-sandbox.sh new file mode 100644 index 0000000..de6d930 --- /dev/null +++ b/configure.d/65-sandbox.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# configure reads TITLE and DESCRIPTION +# shellcheck disable=SC2034 +TITLE="Agent sandbox" +# shellcheck disable=SC2034 +DESCRIPTION="Choose what agents may read, e.g. WinBoat's settings" + +# The sandbox config every sandboxed agent reads (see sandbox/agent) +SANDBOX_CONF="$HOME/.config/coredesktop/sandbox/base.json" +source "$COREDESKTOP/configure.d/lib/json.sh" + +# Folders the sandbox hides from agents (sandbox/config/base.json) that you +# may let them read: "path|what it is". Your SSH keys, password stores, +# browsers and the like aren't here; they're always hidden. A path in +# allowRead can be read even inside one in denyRead, so letting agents read +# one is adding it to allowRead in your own config. +# The paths are written as in sandbox/config/base.json, with ~ for srt to expand +# shellcheck disable=SC2088 +OPTIONAL_HIDDEN=( + "~/.winboat|WinBoat's settings, which include your Windows password" +) + +# Offered only when one of the folders is on this machine +is_installed() { + local entry path + for entry in "${OPTIONAL_HIDDEN[@]}"; do + path="${entry%%|*}" + [ -e "${path/#\~/$HOME}" ] && return 0 + done + return 1 +} + +# Hiding them is the default, so there's nothing you need to do +is_configured() { + true +} + +# True if agents may read $1 (it's in your config's allowRead) +readable() { + jq -e --arg path "$1" '.filesystem.allowRead // [] | index($path)' "$SANDBOX_CONF" &> /dev/null +} + +let_read() { + edit_json "$SANDBOX_CONF" '{}' --arg path "$1" \ + '.filesystem.allowRead = ((.filesystem.allowRead // []) - [$path] + [$path])' +} + +hide() { + [ -f "$SANDBOX_CONF" ] || return 0 + edit_json "$SANDBOX_CONF" '{}' --arg path "$1" \ + 'if .filesystem.allowRead then .filesystem.allowRead -= [$path] else . end' +} + +run_configure() { + local choice i path what state + echo "Agents in the sandbox can't read these unless you let them. Changes apply the next" + echo "time you start an agent (restart Hermes' gateway with: systemctl --user restart hermes-gateway)." + echo "Claude Code's own sandbox doesn't use this list." + + while true; do + echo + for i in "${!OPTIONAL_HIDDEN[@]}"; do + IFS='|' read -r path what <<< "${OPTIONAL_HIDDEN[$i]}" + if readable "$path"; then state="\e[33magents can read it\e[0m"; else state="\e[32mhidden\e[0m"; fi + printf " %d) %-12s $state %s\n" $((i + 1)) "$path" "$what" + done + echo " q) Done" + echo + read -rp "Choose one to switch: " choice || return 0 + if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#OPTIONAL_HIDDEN[@]} )); then + return 0 + fi + IFS='|' read -r path what <<< "${OPTIONAL_HIDDEN[$((choice - 1))]}" + if readable "$path"; then + hide "$path" || return 1 + echo "$path is hidden from agents again." + elif confirm "Let agents read $path ($what)?"; then + let_read "$path" || return 1 + echo "Agents can read $path." + fi + done +} diff --git a/configure.d/lib/json.sh b/configure.d/lib/json.sh new file mode 100644 index 0000000..6a15c10 --- /dev/null +++ b/configure.d/lib/json.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# Purpose : Edit JSON settings files for configure.d items. Source this file. + +# Edit a JSON file in place with jq, creating it from $2 if it's missing. +# Leaves the file alone if it isn't valid JSON or jq fails. +edit_json() { + local file="$1" empty="$2" tmp + shift 2 + mkdir -p "$(dirname "$file")" || return 1 + [ -f "$file" ] || echo "$empty" > "$file" + tmp="$(mktemp "$file.XXXXXX")" || return 1 + if jq "$@" "$file" > "$tmp"; then + mv -f "$tmp" "$file" + else + echo "Couldn't change $file; is it valid JSON?" + rm -f "$tmp" + return 1 + fi +} diff --git a/install.sh b/install.sh index 9e4f7d6..6f9d136 100755 --- a/install.sh +++ b/install.sh @@ -231,6 +231,9 @@ bash ~/.local/share/coredesktop/system/nvidia.sh echo "Configuring the shell..." bash ~/.local/share/coredesktop/shell/setup.sh +# Keep WinBoat's Windows container from starting at boot (if it's set up) +bash ~/.local/share/coredesktop/system/winboat.sh + # Notify in GNOME when a new version of CoreDesktop is available echo "Configuring update notifications..." bash ~/.local/share/coredesktop/system/update-notify.sh diff --git a/sandbox/config/base.json b/sandbox/config/base.json index d321432..c296e0f 100644 --- a/sandbox/config/base.json +++ b/sandbox/config/base.json @@ -63,6 +63,7 @@ "~/.config/net.imput.helium", "~/.var/app", "~/.davfs2", + "~/.winboat", "~/.claude", "~/.claude.json", "~/.hermes", diff --git a/system/winboat.sh b/system/winboat.sh new file mode 100644 index 0000000..f03435b --- /dev/null +++ b/system/winboat.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# +# Purpose : Keep WinBoat's Windows container from starting at boot. Run by +# install.sh and on every update (updates.d/75-winboat.sh). +# +# WinBoat's "Auto Start Container" setting is the restart policy in its +# docker-compose.yml, which WinBoat applies to the container. A container that +# restarts on its own runs Windows (8 GB of RAM and 4 cores by default) from +# boot until you stop it, whether or not you use WinBoat. This sets the policy +# back to "no" in the compose file and on the container, which doesn't need +# recreating. WinBoat starts the container when you open it, as before. + +WINBOAT="$HOME/.winboat" +COMPOSE="$WINBOAT/docker-compose.yml" +CONTAINER="WinBoat" + +# Not set up yet: WinBoat writes these when you first install Windows with it +[ -f "$COMPOSE" ] || exit 0 + +STATUS=0 + +# restart: no, as WinBoat writes it when the setting is off +if grep -qE '^[[:space:]]*restart:' "$COMPOSE" \ + && ! grep -qE "^[[:space:]]*restart:[[:space:]]*[\"']?no[\"']?[[:space:]]*$" "$COMPOSE"; then + echo "Turning off WinBoat's Auto Start Container..." + sed -i -E 's/^([[:space:]]*restart:).*/\1 no/' "$COMPOSE" || STATUS=1 +fi + +# WinBoat uses Docker unless you chose Podman in its settings +runtime="$(jq -r '.containerRuntime // "Docker"' "$WINBOAT/winboat.config.json" 2> /dev/null)" +runtime="$(tr '[:upper:]' '[:lower:]' <<< "${runtime:-docker}")" +command -v "$runtime" > /dev/null || exit $STATUS + +# The container's own policy, or nothing if it doesn't exist or the runtime +# isn't running (then the compose file applies the next time it's created) +policy="$("$runtime" inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER" 2> /dev/null)" +if [ -n "$policy" ] && [ "$policy" != "no" ]; then + echo "Keeping WinBoat's Windows container from starting at boot..." + "$runtime" update --restart=no "$CONTAINER" > /dev/null || STATUS=1 +fi + +exit $STATUS diff --git a/tests/sandbox.bats b/tests/sandbox.bats index 94f3c62..294b4bc 100644 --- a/tests/sandbox.bats +++ b/tests/sandbox.bats @@ -37,3 +37,50 @@ exec "$@"' [ "$status" -eq 0 ] jq -e '.network.allowedDomains | index("llm.local:11434") and index("github.com")' "$SETTINGS_COPY" } + +# --- configure sandbox ---------------------------------------------------------------- + +setup_configure() { + export COREDESKTOP="$REPO" + confirm() { return 0; } + source "$REPO/configure.d/65-sandbox.sh" +} + +@test "the sandbox hides WinBoat's settings by default" { + jq -e '.filesystem.denyRead | index("~/.winboat")' "$REPO/sandbox/config/base.json" + run "$SANDBOX/bin/fakeagent" + [ "$status" -eq 0 ] + jq -e '.filesystem.denyRead | index("~/.winboat")' "$SETTINGS_COPY" + run jq -e '.filesystem.allowRead | index("~/.winboat")' "$SETTINGS_COPY" + [ "$status" -ne 0 ] +} + +@test "configure sandbox lets agents read WinBoat's settings, and hides them again" { + setup_configure + mkdir -p "$HOME/.config/coredesktop/sandbox" + echo '{"network": {"allowedDomains": ["llm.local:11434"]}}' > "$SANDBOX_CONF" + + run run_configure <<< $'1\nq' + [ "$status" -eq 0 ] + [[ "$output" == *"Agents can read ~/.winboat."* ]] + [ "$(jq -c '.filesystem.allowRead' "$SANDBOX_CONF")" = '["~/.winboat"]' ] + [ "$(jq -c '.network.allowedDomains' "$SANDBOX_CONF")" = '["llm.local:11434"]' ] + + # What the agent gets: still denied, but re-allowed, which srt lets win + run "$SANDBOX/bin/fakeagent" + jq -e '(.filesystem.denyRead | index("~/.winboat")) and (.filesystem.allowRead | index("~/.winboat"))' "$SETTINGS_COPY" + + run run_configure <<< $'1\nq' + [ "$status" -eq 0 ] + [[ "$output" == *"~/.winboat is hidden from agents again."* ]] + [ "$(jq -c '.filesystem.allowRead' "$SANDBOX_CONF")" = '[]' ] +} + +@test "configure sandbox is offered only where WinBoat is set up, and is never to do" { + setup_configure + run is_installed + [ "$status" -ne 0 ] + mkdir -p "$HOME/.winboat" + is_installed + is_configured +} diff --git a/tests/winboat.bats b/tests/winboat.bats new file mode 100644 index 0000000..668261d --- /dev/null +++ b/tests/winboat.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats + +load test_helper + +# system/winboat.sh with a WinBoat compose file and fake docker and podman. +# $POLICY is the container's restart policy; empty means there's no container. + +setup() { + common_setup + WINBOAT="$HOME/.winboat" + COMPOSE="$WINBOAT/docker-compose.yml" + mkdir -p "$WINBOAT" + export POLICY=no + stub docker '[ "$1" = inspect ] && { [ -n "$POLICY" ] || exit 1; echo "$POLICY"; }; exit 0' + stub podman '[ "$1" = inspect ] && { [ -n "$POLICY" ] || exit 1; echo "$POLICY"; }; exit 0' +} + +# compose RESTART: a compose file like WinBoat's with that restart line +compose() { + printf 'services:\n windows:\n container_name: WinBoat\n stop_grace_period: 120s\n restart: %s\n privileged: true\n' "$1" > "$COMPOSE" +} + +@test "winboat.sh does nothing before WinBoat is set up" { + rmdir "$WINBOAT" + run bash "$REPO/system/winboat.sh" + [ "$status" -eq 0 ] + [ -z "$output" ] + not_called docker +} + +@test "winboat.sh changes nothing when Auto Start Container is off" { + compose no + cp "$COMPOSE" "$BATS_TEST_TMPDIR/before" + run bash "$REPO/system/winboat.sh" + [ "$status" -eq 0 ] + [ -z "$output" ] + cmp "$COMPOSE" "$BATS_TEST_TMPDIR/before" + uncalled docker update --restart=no WinBoat +} + +@test "winboat.sh turns Auto Start Container off in the compose file and on the container" { + compose unless-stopped + export POLICY=unless-stopped + run bash "$REPO/system/winboat.sh" + [ "$status" -eq 0 ] + grep -qx ' restart: no' "$COMPOSE" + grep -qx ' privileged: true' "$COMPOSE" + called docker update --restart=no WinBoat +} + +@test "winboat.sh accepts a quoted \"no\"" { + compose '"no"' + run bash "$REPO/system/winboat.sh" + [ "$status" -eq 0 ] + grep -qx ' restart: "no"' "$COMPOSE" +} + +@test "winboat.sh fixes the compose file when the container doesn't exist" { + compose always + export POLICY="" + run bash "$REPO/system/winboat.sh" + [ "$status" -eq 0 ] + grep -qx ' restart: no' "$COMPOSE" + uncalled docker update --restart=no WinBoat +} + +@test "winboat.sh uses Podman when WinBoat does" { + compose always + echo '{"containerRuntime": "Podman"}' > "$WINBOAT/winboat.config.json" + export POLICY=always + run bash "$REPO/system/winboat.sh" + [ "$status" -eq 0 ] + called podman update --restart=no WinBoat + not_called docker +} diff --git a/updates.d/75-winboat.sh b/updates.d/75-winboat.sh new file mode 100644 index 0000000..3720c6a --- /dev/null +++ b/updates.d/75-winboat.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +COREDESKTOP="$HOME/.local/share/coredesktop" + +is_installed() { + [ -f "$HOME/.winboat/docker-compose.yml" ] +} + +run_update() { + bash "$COREDESKTOP/system/winboat.sh" +}