Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 51 additions & 16 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Invalid configs (YAML errors, missing env vars, validation failures) are logged
sudo bash scripts/install.sh --system
```

This installs the binary to `/usr/local/bin/`, creates `/etc/rustifymyclaw/` with a starter config, env file, and systemd unit.
This installs the binary to `/usr/local/bin/`, creates the `rustifymyclaw` system user and group, sets up `/etc/rustifymyclaw/` with a starter config, env file, and systemd unit.

### Manual setup

Expand All @@ -210,14 +210,20 @@ sudo cp systemd/rustifymyclaw.service /etc/systemd/system/
sudo systemctl daemon-reload
```

2. Create the config directory and files:
2. Create the service user and config directory:

```bash
sudo groupadd -r rustifymyclaw
sudo useradd -r -g rustifymyclaw -s /usr/sbin/nologin -d /nonexistent rustifymyclaw
sudo mkdir -p /etc/rustifymyclaw
sudo cp examples/config.yaml /etc/rustifymyclaw/config.yaml
sudo cp systemd/env.example /etc/rustifymyclaw/env
sudo chown root:rustifymyclaw /etc/rustifymyclaw
sudo chmod 750 /etc/rustifymyclaw
sudo chown root:rustifymyclaw /etc/rustifymyclaw/config.yaml
sudo chmod 640 /etc/rustifymyclaw/config.yaml
sudo chmod 600 /etc/rustifymyclaw/env
sudo chown root:rustifymyclaw /etc/rustifymyclaw/env
sudo chmod 640 /etc/rustifymyclaw/env
```

3. Edit `config.yaml` with your workspaces and channels.
Expand Down Expand Up @@ -247,36 +253,65 @@ If you wish to use a configuration file other than the default `/etc/rustifymycl
RUSTIFYMYCLAW_CONFIG=/path/to/your/custom-config.yaml
```

**Important:** The daemon runs as a transient non-root user. Any custom configuration file must be world-readable (e.g., `chmod 644`) so the daemon can access it.
**Important:** The daemon runs as the `rustifymyclaw` system user. Any custom configuration file must be readable by this user (e.g., `chown root:rustifymyclaw` and `chmod 640`).

### Enable daemon to access your workspaces / directory permissions

Same as before. Because the daemon runs as an ephemeral user, workspace directories are read-only by default. Without this step your CLI agent can still read the project and answer questions about it, but cannot write or edit files. If you expect your agent to have write permissions, you must explicitly allow each workspace path.
The daemon runs as the dedicated `rustifymyclaw` system user. Workspace directories are read-only by default. Without this step your CLI agent can still read the project and answer questions about it, but cannot write or edit files. If you expect your agent to have write permissions, you must explicitly allow each workspace path.

Use the built-in command:

```bash
sudo rustifymyclaw config allow-path /home/user/projects/my-project
```

Or, manually:
This command does three things:
1. **Grants traverse (`x`) ACLs** on each parent directory so the daemon can reach the workspace (e.g., `/home/user`, `/home/user/projects`).
2. **Grants recursive read/write ACLs** on the workspace directory and all contents, with default ACLs so newly created files inherit permissions.
3. **Adds a `ReadWritePaths=` entry** to the systemd override so the sandbox allows writes.

```ini
# /etc/systemd/system/rustifymyclaw.service.d/override.conf
[Service]
ReadWritePaths=/home/user/projects/my-project
```
Then reload: `sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw`

**Prerequisites:** The `acl` package must be installed (`sudo apt install acl` on Debian/Ubuntu, `sudo dnf install acl` on Fedora/RHEL).

To revoke access manually, remove the ACLs and the systemd override entry:

```bash
sudo setfacl -R -x u:rustifymyclaw /home/user/projects/my-project
sudo setfacl -R -d -x u:rustifymyclaw /home/user/projects/my-project
# Then edit /etc/systemd/system/rustifymyclaw.service.d/override.conf
# and remove the corresponding ReadWritePaths= line.
sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw
```

**Note on default ACLs:** When `allow-path` sets default ACLs on a workspace directory, newly created files inherit permissions for the `rustifymyclaw` user. This changes how `umask` interacts with file creation in that directory. The ACL mask, not the process `umask`, determines effective permissions for new files.

### Security hardening

The included systemd unit uses:

- **`DynamicUser=yes`** — allocates an ephemeral service user, no manual user creation.
- **`User=rustifymyclaw`** / **`Group=rustifymyclaw`** — dedicated static system user with no login shell and no home directory.
- **`NoNewPrivileges=yes`** — the process cannot gain new privileges.
- **`ProtectSystem=strict`** — the filesystem is read-only except for allowed paths.
- **`ProtectHome=read-only`** — home directories are visible but not writable.
- **`ProtectHome=false`** — home directories are accessible (workspace traversal requires this; actual access is gated by POSIX ACLs).
- **`ReadOnlyPaths=/`** — everything read-only by default; individual workspaces are granted write access via `allow-path`.
- **`PrivateTmp=yes`** — isolated `/tmp` namespace.
- **`EnvironmentFile`** — secrets loaded from `/etc/rustifymyclaw/env` (mode 600).


- **`EnvironmentFile`** — secrets loaded from `/etc/rustifymyclaw/env` (mode 640, owned by `root:rustifymyclaw`).

### Migrating from DynamicUser

Versions prior to this release used `DynamicUser=yes` which allocated an ephemeral UID. If you are upgrading from an earlier version:

1. The service now runs as the static `rustifymyclaw` user instead of a dynamic UID.
2. Any manual ACLs you previously granted to the dynamic UID are orphaned — re-apply them with `sudo rustifymyclaw config allow-path <path>`.
3. The installer (`scripts/install.sh --system`) creates the user automatically. For manual upgrades, create the user first:
```bash
sudo groupadd -r rustifymyclaw
sudo useradd -r -g rustifymyclaw -s /usr/sbin/nologin -d /nonexistent rustifymyclaw
```
4. Update ownership on config files:
```bash
sudo chown root:rustifymyclaw /etc/rustifymyclaw /etc/rustifymyclaw/config.yaml /etc/rustifymyclaw/env
sudo chmod 750 /etc/rustifymyclaw
sudo chmod 640 /etc/rustifymyclaw/config.yaml /etc/rustifymyclaw/env
```
34 changes: 30 additions & 4 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ install_binary() {
write_config() {
if $SYSTEM_INSTALL; then
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
chown root:rustifymyclaw "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR"
fi

if [ -f "$CONFIG_FILE" ]; then
Expand All @@ -226,7 +227,8 @@ write_config() {
download_file "$config_url" "$CONFIG_FILE" || \
die "Failed to download example config from ${config_url}"
if $SYSTEM_INSTALL; then
chmod 644 "$CONFIG_FILE"
chown root:rustifymyclaw "$CONFIG_FILE"
chmod 640 "$CONFIG_FILE"
else
chmod 600 "$CONFIG_FILE"
fi
Expand All @@ -239,11 +241,34 @@ write_config() {
info "Downloading env template..."
download_file "$env_url" "$ENV_FILE" || \
die "Failed to download env template from ${env_url}"
chmod 600 "$ENV_FILE"
info "Env file created at ${ENV_FILE} (chmod 600)"
info "Env file created at ${ENV_FILE}"
else
info "Existing env file preserved at ${ENV_FILE}"
fi
chown root:rustifymyclaw "$ENV_FILE"
chmod 640 "$ENV_FILE"
info "Permissions secured for ${ENV_FILE} (root:rustifymyclaw 640)"
fi
}

# ---------------------------------------------------------------------------
# Service user creation (--system only)
# ---------------------------------------------------------------------------
create_service_user() {
if ! $SYSTEM_INSTALL; then
return
fi

if ! getent group rustifymyclaw >/dev/null 2>&1; then
info "Creating system group: rustifymyclaw"
groupadd -r rustifymyclaw
fi

if ! id -u rustifymyclaw >/dev/null 2>&1; then
info "Creating system user: rustifymyclaw"
useradd -r -g rustifymyclaw -s /usr/sbin/nologin -d /nonexistent rustifymyclaw
else
info "System user rustifymyclaw already exists."
fi
}

Expand Down Expand Up @@ -330,6 +355,7 @@ main() {

download_and_verify
install_binary
create_service_user
write_config
install_systemd_unit
if ! $SYSTEM_INSTALL; then
Expand Down
122 changes: 116 additions & 6 deletions src/cli/config_cmd/allow_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ fn merge_allowed_path(existing: &str, path_str: &str) -> MergeResult {
MergeResult::Updated(new_content)
}

/// Collect parent directories that need execute traversal ACLs.
/// Returns ancestors from shallowest to deepest, excluding `/` and the path itself.
#[cfg(any(not(target_os = "windows"), test))]
fn traversal_parents(path: &Path) -> Vec<&Path> {
let mut parents = Vec::new();
let mut current = path.parent();
while let Some(p) = current {
if p == Path::new("/") {
break;
}
parents.push(p);
current = p.parent();
}
parents.reverse();
parents
}

pub fn run(path: &Path) -> Result<()> {
#[cfg(target_os = "windows")]
{
Expand All @@ -51,15 +68,101 @@ pub fn run(path: &Path) -> Result<()> {

#[cfg(not(target_os = "windows"))]
{
use anyhow::Context;
use anyhow::{bail, Context};

const OVERRIDE_DIR: &str = "/etc/systemd/system/rustifymyclaw.service.d";
const OVERRIDE_FILE: &str = "override.conf";
const SERVICE_USER: &str = "rustifymyclaw";

let canonical = std::fs::canonicalize(path)
.with_context(|| format!("path does not exist: {}", path.display()))?;
let canonical_str = canonical.to_string_lossy();

// ── Fail fast: check setfacl availability ──────────────────────
let setfacl_check = std::process::Command::new("which")
.arg("setfacl")
.output()
.context("failed to check for setfacl")?;

if !setfacl_check.status.success() {
bail!(
"setfacl is not installed. Install the acl package first:\n \
sudo apt install acl # Debian/Ubuntu\n \
sudo dnf install acl # Fedora/RHEL"
);
}

// ── Parent directory traversal ACLs (execute only) ─────────────
let parents = traversal_parents(&canonical);
for parent in &parents {
let status = std::process::Command::new("setfacl")
.args([
"-m",
&format!("u:{SERVICE_USER}:x"),
&parent.to_string_lossy(),
])
.status()
.with_context(|| format!("failed to run setfacl on {}", parent.display()))?;

if !status.success() {
bail!(
"setfacl failed on {}. Are you running with sudo?",
parent.display()
);
}
}

if !parents.is_empty() {
println!(
"Set traverse (x) ACL on {} parent director{}.",
parents.len(),
if parents.len() == 1 { "y" } else { "ies" }
);
}

// ── Workspace ACLs (recursive read/write/traverse) ────────────
let status = std::process::Command::new("setfacl")
.args([
"-R",
"-m",
&format!("u:{SERVICE_USER}:rwX"),
&*canonical_str,
])
.status()
.with_context(|| format!("failed to set ACLs on {}", canonical.display()))?;

if !status.success() {
bail!(
"setfacl -R failed on {}. Are you running with sudo?",
canonical.display()
);
}

// ── Default ACLs (so new files inherit permissions) ────────────
let status = std::process::Command::new("setfacl")
.args([
"-R",
"-d",
"-m",
&format!("u:{SERVICE_USER}:rwX"),
&*canonical_str,
])
.status()
.with_context(|| format!("failed to set default ACLs on {}", canonical.display()))?;

if !status.success() {
bail!(
"setfacl -R -d failed on {}. Are you running with sudo?",
canonical.display()
);
}

println!(
"Set read/write ACLs on {} (with default ACLs for new files).",
canonical.display()
);

// ── Systemd override (ReadWritePaths) ──────────────────────────
let override_dir = Path::new(OVERRIDE_DIR);
let override_path = override_dir.join(OVERRIDE_FILE);

Expand All @@ -72,7 +175,10 @@ pub fn run(path: &Path) -> Result<()> {

match merge_allowed_path(&existing, &canonical_str) {
MergeResult::AlreadyPresent => {
println!("{} is already in the allowed paths.", canonical.display());
println!(
"{} is already in the systemd allowed paths.",
canonical.display()
);
}
MergeResult::Updated(new_content) => {
std::fs::create_dir_all(override_dir).with_context(|| {
Expand All @@ -86,13 +192,17 @@ pub fn run(path: &Path) -> Result<()> {
)
})?;

println!("Allowed workspace path: {}", canonical.display());
println!();
println!("Reload and restart the service to apply:");
println!(" sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw");
println!(
"Added ReadWritePaths={} to systemd override.",
canonical.display()
);
}
}

println!();
println!("Reload and restart the service to apply:");
println!(" sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw");

Ok(())
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,14 @@ pub fn resolve_path(cli_override: Option<PathBuf>) -> PathBuf {
return etc_candidate;
}
}
// Final fallback: return the home path even if it doesn't exist, so
// load_from_path produces a clear "cannot read config file" error.

// Final fallback: if HOME is missing, we're likely a daemon; return /etc path
// so the resulting "file not found" error points to the right location.
#[cfg(not(target_os = "windows"))]
if std::env::var("HOME").is_err() {
return PathBuf::from("/etc/rustifymyclaw/config.yaml");
}

home_candidate
}

Expand Down
Loading
Loading