Class: Pikuri::Workspace::Filesystem

Inherits:
Object
  • Object
show all
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:

  1. lib/foo.rb (exists) → base matches a root → realpath'd file.
  2. lib/new/dir/foo.rb (intermediates missing) → deepest existing parent in a root → the intended new path (caller +mkdir_p+s).
  3. lib/../../etc/passwd (+..+ escape) → cleanpath collapses .., lands outside every root → Error.
  4. link/foo.rb, link → /etc (symlink escape) → realpath resolves 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

Classes: AllowAll, Error

Constant Summary collapse

DENIED_PROJECT_ROOTS =

System-root project_roots the constructor refuses (exact-match, not prefix — /home/user/project passes; /home/user is 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

Instance Method Summary collapse

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.

Parameters:

  • project_root (String, Pathname)

    project root; +realpath+'d once, must exist, must not match DENIED_PROJECT_ROOTS or be a /home child.

  • readable (Array<String, Pathname>) (defaults to: [])

    extra read-only roots; +realpath+'d (missing entries raise).

  • writable (Array<String, Pathname>) (defaults to: [])

    extra read+write roots; same.

  • temp (Boolean) (defaults to: false)

    true adds <internal_temp>/playground to #writable and exposes it via #temp (minting the umbrella up-front).

  • alias_tmp_to_temp (Boolean) (defaults to: false)

    true (with temp:) rewrites /tmp/* inputs to #temp; pairs with the sandbox's --bind <temp> /tmp.

  • env (Hash{String=>String}, nil) (defaults to: nil)

    subprocess env via #env; nil lazy-derives host git identity from #project_root, an explicit hash is used verbatim (see §"Subprocess environment").

  • private (Boolean) (defaults to: false)

    these roots may hold sensitive data; see #private?.

  • trusted (Boolean) (defaults to: false)

    every byte under these roots is user-authored; see #trusted?.

Raises:

  • (Errno::ENOENT)

    if any of +project_root+/+readable+/+writable+ doesn't exist.

  • (Error)

    if project_root is denied (system root or /home/*).



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_tempBoolean (readonly)

Returns whether #resolve_for_read/#resolve_for_write rewrite /tmp/* inputs to #temp.

Returns:



188
189
190
# File 'lib/pikuri/workspace/filesystem.rb', line 188

def alias_tmp_to_temp
  @alias_tmp_to_temp
end

#project_rootPathname (readonly)

Returns project root, post-realpath — the writable containment ceiling, relative-path base, and Bash/Grep/Glob chdir target; always in #readable/#writable.

Returns:

  • (Pathname)

    project root, post-realpath — the writable containment ceiling, relative-path base, and Bash/Grep/Glob chdir target; always in #readable/#writable.



103
104
105
# File 'lib/pikuri/workspace/filesystem.rb', line 103

def project_root
  @project_root
end

#readableArray<Pathname> (readonly)

Returns read-only roots (writable roots are also readable). Post-realpath, deduped.

Returns:

  • (Array<Pathname>)

    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

#tempPathname? (readonly)

Returns the LLM-visible scratch playground (writable, at <internal_temp>/playground) when temp: true, else nil; wiped with the umbrella at exit.

Returns:

  • (Pathname, nil)

    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

#writableArray<Pathname> (readonly)

Returns writable roots (read+write); includes #project_root and, with temp: true, #temp. Post-realpath, deduped.

Returns:

  • (Array<Pathname>)

    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.

Parameters:

  • path (String, Pathname)

Returns:

  • (Boolean)


311
# File 'lib/pikuri/workspace/filesystem.rb', line 311

def denied?(_path) = false

#denied_rootsArray<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?.

Returns:

  • (Array<Pathname>)

    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 = []

#envHash{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".

Returns:

  • (Hash{String=>String})


197
198
199
# File 'lib/pikuri/workspace/filesystem.rb', line 197

def env
  @env ||= (@env_override || compute_git_identity_env).freeze
end

#internal_tempPathname

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.

Returns:

  • (Pathname)


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.

Returns:

  • (Boolean)

    default false



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.

Parameters:

  • path (String, Pathname)

Returns:

  • (Pathname)

Raises:

  • (Error)

    if the resolved path falls outside every root



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.

Parameters:

  • path (String, Pathname)

Returns:

  • (Pathname)

Raises:

  • (Error)

    if the resolved path falls outside every writable root



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.

Returns:

  • (Boolean)

    default false



143
# File 'lib/pikuri/workspace/filesystem.rb', line 143

def trusted? = @trusted