Skip to content

validatePath() has no system-path floor — a project can be linked at /, /etc or /dev (and managed:true writes CLAUDE.md there) #720

Description

@edspencer

Summary

validatePath() has no allowed-root or system-path floor, so a project can be linked at /, /etc, or /dev. The keeper's cwd becomes that directory, and it runs with permissionMode: acceptEdits by default.

Reproduction

Via the API:

curl -sX POST /api/projects -H 'content-type: application/json' \
     -d '{"name":"X","path":"/etc","managed":false}'
# -> 201 Created, workingDir: "/etc"

Via the UI: /projectsNew project → set "Directory on this machine" to / or /etcCreate. Accepted.

Confirmed 2× (API + New Project modal). Also accepted: /dev, and /proc/self/cwd (which canonicalises to Paddock's own source checkout).

Escalation via managed

managed: true + an existing directory makes contentDir be that directory, and Paddock then writes CLAUDE.md and CHANGELOG.md into it (verified on a scratch dir). The New Project modal exposes this as the "These are notes — let Paddock curate them" checkbox.

So managed: true, path: "/etc" writes /etc/CLAUDE.md and /etc/CHANGELOG.md. I deliberately did not execute that variant on a real system directory — the two halves are each proven independently on safe paths.

What validatePath() already does correctly

It refuses relative paths, a file rather than a directory (409 not_directory), .. traversal, the data dir, the projects root, a data-dir subdir, a symlink into the projects root, and managed: true combined with repo. Symlinks are canonicalised to their real target. The gap is only the absence of a floor beneath which no path may be linked.

Suggested fix

Refuse a small denylist of system roots (/, /etc, /dev, /proc, /sys, /boot), or — better — require linked paths to sit under a configurable allowed root (e.g. $HOME plus an operator-set list), defaulting to something sane. A /-rooted project also defeats the overlap guard entirely (see the isPathInside issue).

Note on severity

This is a footgun, not a privilege escalation — anyone who can call this API can already create projects and run turns, so it grants no capability an operator did not already have. It matters because the failure is silent and the blast radius (an agent with acceptEdits rooted at /etc) is large.

Found during a v0.65.0 exploratory QA session; verified independently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions