Class: Pikuri::Workspace::Filesystem
- Inherits:
-
Object
- Object
- Pikuri::Workspace::Filesystem
- Defined in:
- lib/pikuri/workspace/filesystem.rb
Overview
Defines which paths the agent can see and write to. Constructed with explicit +readable+/+writable+ prefix lists; every Read/Write/Edit/Grep/ Glob/Bash path is checked against them before touching disk. Returned Pathnames are absolute, post-symlink-resolution.
Project root, readable, writable
project_root is the writable containment ceiling — auto-folded into both
readable and writable (read/write anywhere under it), the base for
resolving relative LLM paths, and the chdir target for Bash/Grep/
Glob. There is no separate "cwd": a host wanting the agent in a subtree
passes that subtree as project_root (+bin/pikuri-code+ also +Dir.chdir+s
the process there so process and workspace share one anchor). The extra
readable list grants read-only roots (toolchains, caches); the extra
writable list grants read+write roots.
#resolve_for_read checks readable ∪ writable; #resolve_for_write
checks writable only. Neither checks existence — the workspace owns
containment, not filesystem state (Read errors on a missing file, Write
+mkdir_p+s its own parents).
Session umbrella (#internal_temp)
Every workspace owns a per-process umbrella dir, minted lazily (workspaces
that never touch it pay nothing) as a Paths.new_temp — so it
lands under ~/.cache/pikuri (NOT /tmp: the sandbox binds #temp at
/tmp inside, and an umbrella already there would bind on top of itself),
is removed at exit via Finalizers, and is stale-swept by
Paths.sweep_stale_temps! at core load. Everything ephemeral lives
inside it — the #temp playground, the Bubblewrap sandbox's per-toolchain
overlay state — so one remove_entry cleans the lot.
Optional temp playground / /tmp alias
temp: true adds <internal_temp>/playground to #writable, exposed via
#temp as LLM scratch space (default false so specs don't pay the
mkdir). alias_tmp_to_temp: true (only with temp:) rewrites /tmp/*
inputs to #temp before containment — the host-side counterpart to the
sandbox's --bind <temp> /tmp, so the LLM uses one path for both.
bin/pikuri-code flips it on with the bubblewrap sandbox.
Subprocess environment (#env)
Workspaces own an #env hash that subprocess tools (Code::Bash)
thread into Subprocess.spawn. Motivating case: the bubblewrap
sandbox doesn't bind ~/.gitconfig, so git commit inside fails; the
workspace resolves the host's effective git identity at #project_root
(so includeIf rules apply) and exposes +GIT_AUTHOR_+/+GIT_COMMITTER_+
that override config with no file in the sandbox. Lazy + memoized (the
constructor doesn't shell out), falling back to {} if git isn't on PATH
or no identity is configured. An explicit env: is used verbatim, skipping
the lookup. Always frozen on the way out.
Containment algorithm
#resolve walks up the input to its deepest existing ancestor, +realpath+s that (resolving symlinks in the existing portion), and checks the resolved base against the candidate roots:
lib/foo.rb(exists) → base matches a root → realpath'd file.lib/new/dir/foo.rb(intermediates missing) → deepest existing parent in a root → the intended new path (caller +mkdir_p+s).lib/../../etc/passwd(+..+ escape) →cleanpathcollapses.., lands outside every root → Error.link/foo.rb,link → /etc(symlink escape) →realpathresolves through the link to/etc, outside every root → Error.
Lexical normalization alone catches 1–3 but misses 4; the walk-up
realpath closes that gap.
Project-root denylist
A project_root of a system root or home dir makes the whole tree
writable — almost always a fat-finger. The constructor rejects
DENIED_PROJECT_ROOTS and any direct child of /home. A sanity guard,
not a security perimeter (the +readable+/+writable+ lists are that).
Linux-first: other-OS home roots (+/Users/$USER+) aren't denied.
Defined Under Namespace
Constant Summary collapse
- DENIED_PROJECT_ROOTS =
System-root project_roots the constructor refuses (exact-match, not prefix —
/home/user/projectpasses;/home/useris caught by the parent-is-/home check). Downstream hosts with unusual layouts can subclass. %w[ / /etc /var /proc /sys /dev /boot /root /usr /opt /lib /lib64 /bin /sbin /tmp ].map { |p| Pathname.new(p) }.freeze
Instance Attribute Summary collapse
-
#alias_tmp_to_temp ⇒ Boolean
readonly
Whether #resolve_for_read/#resolve_for_write rewrite
/tmp/*inputs to #temp. - #project_root ⇒ Pathname readonly
-
#readable ⇒ Array<Pathname>
readonly
Read-only roots (writable roots are also readable).
-
#temp ⇒ Pathname?
readonly
The LLM-visible scratch playground (writable, at
<internal_temp>/playground) whentemp: true, elsenil; wiped with the umbrella at exit. -
#writable ⇒ Array<Pathname>
readonly
Writable roots (read+write); includes #project_root and, with
temp: true, #temp.
Instance Method Summary collapse
-
#denied?(_path) ⇒ Boolean
Whether
pathsits under a credential denylist entry. -
#denied_roots ⇒ Array<Pathname>
The denied roots, for callers that can prune a walk up front (+rg --glob '!…'+) instead of filtering its results.
-
#env ⇒ Hash{String=>String}
Environment variables for subprocesses spawned in this workspace.
-
#initialize(project_root:, readable: [], writable: [], temp: false, alias_tmp_to_temp: false, env: nil, private: false, trusted: false) ⇒ Filesystem
constructor
A new instance of Filesystem.
-
#internal_temp ⇒ Pathname
Per-workspace ephemeral umbrella, minted lazily as a fresh Paths.new_temp (reaped at exit, stale-swept at core load) — so anything placed inside (the playground, Bubblewrap overlay state) is wiped together.
-
#private? ⇒ Boolean
Whether these roots may hold data worth exfiltrating — the lethal trifecta's private leg, which the file tools inherit from here.
-
#resolve_for_read(path) ⇒ Pathname
Resolve a user path against the read-set (readable ∪ writable).
-
#resolve_for_write(path) ⇒ Pathname
Resolve a user path against the write-set.
-
#trusted? ⇒ Boolean
Whether every byte reachable here is user-authored — the opt-out from the file tools' default
:harduntrusted leg.
Constructor Details
#initialize(project_root:, readable: [], writable: [], temp: false, alias_tmp_to_temp: false, env: nil, private: false, trusted: false) ⇒ Filesystem
Returns a new instance of Filesystem.
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/pikuri/workspace/filesystem.rb', line 169 def initialize(project_root:, readable: [], writable: [], temp: false, alias_tmp_to_temp: false, env: nil, private: false, trusted: false) @project_root = Pathname.new(project_root).realpath validate_project_root!(@project_root) @private = private @trusted = trusted @internal_temp = nil @temp = temp ? mint_playground : nil @alias_tmp_to_temp = alias_tmp_to_temp && !@temp.nil? @env_override = env @writable = ([@project_root] + writable.map { |p| Pathname.new(p).realpath } + [@temp].compact).uniq @readable = (@writable + readable.map { |p| Pathname.new(p).realpath }).uniq end |
Instance Attribute Details
#alias_tmp_to_temp ⇒ Boolean (readonly)
Returns whether #resolve_for_read/#resolve_for_write rewrite
/tmp/* inputs to #temp.
188 189 190 |
# File 'lib/pikuri/workspace/filesystem.rb', line 188 def alias_tmp_to_temp @alias_tmp_to_temp end |
#project_root ⇒ Pathname (readonly)
103 104 105 |
# File 'lib/pikuri/workspace/filesystem.rb', line 103 def project_root @project_root end |
#readable ⇒ Array<Pathname> (readonly)
Returns read-only roots (writable roots are also readable). Post-realpath, deduped.
107 108 109 |
# File 'lib/pikuri/workspace/filesystem.rb', line 107 def readable @readable end |
#temp ⇒ Pathname? (readonly)
Returns the LLM-visible scratch playground (writable, at
<internal_temp>/playground) when temp: true, else nil; wiped with
the umbrella at exit.
148 149 150 |
# File 'lib/pikuri/workspace/filesystem.rb', line 148 def temp @temp end |
#writable ⇒ Array<Pathname> (readonly)
Returns writable roots (read+write); includes
#project_root and, with temp: true, #temp. Post-realpath, deduped.
111 112 113 |
# File 'lib/pikuri/workspace/filesystem.rb', line 111 def writable @writable end |
Instance Method Details
#denied?(_path) ⇒ Boolean
Whether path sits under a credential denylist entry.
#resolve_for_read gates a path the model supplied; this gates a
path a tool walked to on its own (a Listing row, an rg hit),
which no resolve_ call ever sees. A scoped Filesystem has no
denylist — containment is the readable/writable ceiling — so this is
always false here; AllowAll overrides it.
311 |
# File 'lib/pikuri/workspace/filesystem.rb', line 311 def denied?(_path) = false |
#denied_roots ⇒ Array<Pathname>
Returns the denied roots, for callers that can prune a walk up front (+rg --glob '!…'+) instead of filtering its results. Empty here; see #denied?.
316 |
# File 'lib/pikuri/workspace/filesystem.rb', line 316 def denied_roots = [] |
#env ⇒ Hash{String=>String}
Environment variables for subprocesses spawned in this workspace. Lazy,
memoized, frozen. With env: nil the first call resolves the host git
identity for #project_root (+GIT_AUTHOR_+/+GIT_COMMITTER_+, or {}
if none / no git); an explicit hash is returned frozen without shelling
out. See §"Subprocess environment".
197 198 199 |
# File 'lib/pikuri/workspace/filesystem.rb', line 197 def env @env ||= (@env_override || compute_git_identity_env).freeze end |
#internal_temp ⇒ Pathname
Per-workspace ephemeral umbrella, minted lazily as a fresh Paths.new_temp (reaped at exit, stale-swept at core load) — so anything placed inside (the playground, Bubblewrap overlay state) is wiped together. Put workspace-owned ephemeral state here, not in a sibling.
208 209 210 |
# File 'lib/pikuri/workspace/filesystem.rb', line 208 def internal_temp @internal_temp ||= Paths.new_temp end |
#private? ⇒ Boolean
Whether these roots may hold data worth exfiltrating — the lethal trifecta's private leg, which the file tools inherit from here.
The framework cannot answer this for you: sensitivity is a property of
the data, not of the tool, and ~/work/scratch and ~/work/payroll
are indistinguishable from the outside. Declaring it is what lets
Trifecta stay quiet over a public checkout and speak up over
your real one.
123 |
# File 'lib/pikuri/workspace/filesystem.rb', line 123 def private? = @private |
#resolve_for_read(path) ⇒ Pathname
Resolve a user path against the read-set (readable ∪ writable). Returned Pathname is absolute and may not exist; the caller checks existence.
218 219 220 |
# File 'lib/pikuri/workspace/filesystem.rb', line 218 def resolve_for_read(path) resolve(path, @readable) end |
#resolve_for_write(path) ⇒ Pathname
Resolve a user path against the write-set.
227 228 229 |
# File 'lib/pikuri/workspace/filesystem.rb', line 227 def resolve_for_write(path) resolve(path, @writable) end |
#trusted? ⇒ Boolean
Whether every byte reachable here is user-authored — the opt-out from
the file tools' default :hard untrusted leg.
Default false, because broad disk access means cloned dependencies,
fetched PR branches, saved downloads and package caches: attacker-authored
bytes are the norm, and a hostile README reaches the model verbatim.
Claim it only when the trusted area is the tool's entire reachable set
— a vouched-for island inside a broad root buys nothing, since the tool
can still reach everything else. That is why AllowAll refuses the flag
outright rather than merely discouraging it.
Distinct from the trust-this-directory question a host may ask at boot:
that one asks whether a hostile .gitattributes can execute code, this
one whether the repo's bytes are attacker-authored. A repo routinely
passes the first and fails the second — you control .git/config;
vendor/ is someone else's.
143 |
# File 'lib/pikuri/workspace/filesystem.rb', line 143 def trusted? = @trusted |