What happens
SecurityPolicy::validate_parent_path refuses a write with:
[policy-blocked] Resolved parent path escapes workspace: <path>
for two conditions that have nothing to do with each other:
- A genuine escape — a symlink inside the sandbox pointing out, where the
immediate parent cannot be canonicalized up front (e.g. escape/nested/x.txt
with escape -> /outside and nested/ not yet created). The ancestor walk
lands outside. This is an attack, and refusing it is right.
- A workspace that does not exist. With
workspace_dir absent, the walk in
path_checks.rs (loop { if existing_ancestor.exists() { break } … }) climbs
straight past the sandbox to a parent that genuinely is outside it, and the
guard refuses a plainly-inside relative path such as notes.md.
The two are refused in identical words. Only the resolved path differs, and
nothing in the message tells a reader which condition they are in.
The refusal also appends the standard workaround text:
Raise the agent's access tier / autonomy (Settings → Agent access …)
which is actively wrong for condition 2 — no autonomy tier creates a directory,
and an operator or agent following it burns the turn.
Why this matters more than it looks
In a workspace_only sandbox, condition 2 is by far the more likely of the two.
Every easy escape is already caught earlier and more clearly by
is_path_string_allowed: .. components, absolute paths, and symlinks whose
immediate parent resolves all come back as Path not allowed by security policy,
which is unambiguous. The resolved-parent arm is reached mainly when nothing up
front can be canonicalized — and "the workspace was never created" is exactly
that shape.
We hit this in OpenCompany (tinyhumansai/opencompany#409). An agent granted file
tools but not shell could not write a relative path at all, because nothing had
created its sandbox directory; an agent with a shell grant never saw it, because
the first command creates the directory as a side effect. The message sent
several people looking for a traversal attempt that did not exist. We have fixed
the provisioning on our side, so the condition should no longer arise for us —
but the guard will keep reporting a missing directory as an escape for anyone
else who hands it one.
Suggested change
In validate_parent_path, before or alongside the containment check, distinguish
the case where the workspace root itself does not exist (or where the ancestor
walk left the workspace subtree only because nothing inside it exists yet), and
say so:
[policy-blocked] Workspace directory does not exist: <workspace_dir>.
Nothing can be written until it is created; this is not a path-traversal refusal.
Two things worth keeping:
- The refusal itself. Failing closed on a missing sandbox is correct — this is a
wording and diagnosis change, not a permission change.
- The distinct workaround text. The autonomy-tier suggestion should not be
attached to the missing-directory case.
A test coupling the two conditions to two different messages would keep them from
converging again.
Where
src/openhuman/security/policy/path_checks.rs — validate_parent_path, the
ancestor walk and the is_resolved_path_allowed_for refusal.
- Same arm reached from
tools/impl/filesystem/file_write.rs and
tools/impl/filesystem/csv_export.rs.
Notes
Filed from tinyhumansai/opencompany#409, which fixed the provisioning half in
OpenCompany. No OpenHuman change was made there — the vendored checkout was left
untouched deliberately.
What happens
SecurityPolicy::validate_parent_pathrefuses a write with:for two conditions that have nothing to do with each other:
immediate parent cannot be canonicalized up front (e.g.
escape/nested/x.txtwith
escape -> /outsideandnested/not yet created). The ancestor walklands outside. This is an attack, and refusing it is right.
workspace_dirabsent, the walk inpath_checks.rs(loop { if existing_ancestor.exists() { break } … }) climbsstraight past the sandbox to a parent that genuinely is outside it, and the
guard refuses a plainly-inside relative path such as
notes.md.The two are refused in identical words. Only the resolved path differs, and
nothing in the message tells a reader which condition they are in.
The refusal also appends the standard workaround text:
which is actively wrong for condition 2 — no autonomy tier creates a directory,
and an operator or agent following it burns the turn.
Why this matters more than it looks
In a
workspace_onlysandbox, condition 2 is by far the more likely of the two.Every easy escape is already caught earlier and more clearly by
is_path_string_allowed:..components, absolute paths, and symlinks whoseimmediate parent resolves all come back as
Path not allowed by security policy,which is unambiguous. The resolved-parent arm is reached mainly when nothing up
front can be canonicalized — and "the workspace was never created" is exactly
that shape.
We hit this in OpenCompany (tinyhumansai/opencompany#409). An agent granted file
tools but not shell could not write a relative path at all, because nothing had
created its sandbox directory; an agent with a shell grant never saw it, because
the first command creates the directory as a side effect. The message sent
several people looking for a traversal attempt that did not exist. We have fixed
the provisioning on our side, so the condition should no longer arise for us —
but the guard will keep reporting a missing directory as an escape for anyone
else who hands it one.
Suggested change
In
validate_parent_path, before or alongside the containment check, distinguishthe case where the workspace root itself does not exist (or where the ancestor
walk left the workspace subtree only because nothing inside it exists yet), and
say so:
Two things worth keeping:
wording and diagnosis change, not a permission change.
attached to the missing-directory case.
A test coupling the two conditions to two different messages would keep them from
converging again.
Where
src/openhuman/security/policy/path_checks.rs—validate_parent_path, theancestor walk and the
is_resolved_path_allowed_forrefusal.tools/impl/filesystem/file_write.rsandtools/impl/filesystem/csv_export.rs.Notes
Filed from tinyhumansai/opencompany#409, which fixed the provisioning half in
OpenCompany. No OpenHuman change was made there — the vendored checkout was left
untouched deliberately.