* fix(sandbox): surface local directory search permission errors
Permission failures during local list_dir and glob walks must reach the
caller instead of looking like an empty successful result. Transient
filesystem races keep using the existing walk race allowlist.
Fixes#7210
* fix(sandbox): keep denied path in ls/glob permission errors
Guard chmod-based permission tests for non-root POSIX, reverse-resolve
PermissionError.filename in LocalSandbox.list_dir/glob like grep, and
surface that path from ls_tool/glob_tool instead of only the requested root.
* test(sandbox): use workspace/tmp_path leak guards for ls/glob denials
* fix(sandbox): let local file tools read the /mnt/user-data root
The local provider maps the aggregate /mnt/user-data root so listing it
matches AIO, but the tool layer only accepted paths below it: ls, glob
and grep on the root answered "Permission denied" (with a trailing
slash, from the resolved-path "path traversal detected" check).
validate_local_tool_path now accepts the bare root for reads, and the
user-data resolver accepts the shared parent of workspace/uploads/
outputs itself when the caller opts in. Only ls and the shared read
path opt in; writes to the root and files placed directly in it stay
rejected.
* docs(changelog): reference #6991 for the /mnt/user-data root fix
* fix(sandbox): list the /mnt/user-data root through its allowed dirs only
The first version let glob, grep and ls start at the user-data root
after validating only the root itself. The walkers then descended into
everything under the host user-data/ directory, so a file or directory
stored directly under it was listed and searched although read_file
rejects it.
ls now lists the root one allowed directory at a time (workspace,
uploads and outputs, each one level deep) through the sandbox's own
list_dir, so the host root is never walked and list_dir's existing
per-directory containment check still filters symlinks. The resolver
is back to main's behavior, so glob, grep and read_file keep rejecting
the root.
* fix(sandbox): report an exactly-full search result as complete in the remote providers
`glob` and `grep` decide `truncated` twice: once for the raw output cap
(`parse_remote_search_output`, unchanged) and once for `max_results` after the
Python-side filters have run. The second decision returned as soon as
`max_results` matches had been collected, which cannot tell a search that held
exactly that many from one that held more — a tree holding exactly
`max_results` eligible matches came back flagged as cut off, and the tool then
told the model the result was incomplete.
These providers hold the whole listing (the raw stream is capped at
`max(max_results * 4, max_results + 50)` lines and reports its own cut-off), so
like AIO's `glob` branches they can look one match past the cap before
deciding: `AioSandbox.grep`, plus `glob`/`grep` in E2B, OpenSandbox, Tenki and
BoxLite now use the same `len(matches) > max_results` rule. This completes what
#5449 started for AIO's `glob`; the local provider's half is #5491.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(sandbox): let remote grep see one match past the per-file cap
E2B and OpenSandbox stopped each file's grep at max(max_results, 50)
matches, so a single file holding more than max_results hits — with a
raw stream far below its limit — ended the Python loop exactly at the
cap and reported the result as complete (#5534 review).
Retain one extra match per file so the one-match lookahead can observe
the overflow and report truncation. A single-file regression at
max_results=50 covers 50 matches (complete) vs 51 (truncated) for both
providers.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): report an exactly-full AIO glob result as complete
AioSandbox.glob's include_dirs branch returned as soon as it had
collected max_results matches, without looking at the rest of the
listing. A listing that held exactly that many matches and nothing more
was therefore reported as truncated, and the glob tool told the model
the result was incomplete — prompting a re-search or distrust of a
complete answer. The same line returned one match for max_results=0,
one past the caller's cap.
Look one match past the cap before deciding, which is what the
include_dirs=False branch in the same function already does and what
#5427 moved parse_remote_search_output to for BoxLite, Tenki, E2B and
OpenSandbox.
* review: filtered-tail cases, the glob contract docstring, and the cap wording
Addresses the three items from the review on #5449.
- Two regression cases over a tail of ignored / out-of-root / pattern-miss
entries: an exactly-full result stays complete when only filtered entries
follow, and a third eligible match after that tail still reports
truncation. Both fail against the previous return-on-the-max-th-match
behaviour.
- 'Sandbox.glob' promised the conservative flag ('``max_results`` was
reached') that this change deliberately stops producing on the AIO branch.
The contract now reads as 'may be incomplete' and records that providers
differ in how precisely they can decide it.
- The changelog no longer lumps 'parse_remote_search_output' in with the
filtered-match cap: its raw-output cap is a separate limit with its own
one-line-past accounting, and the other providers' filtered-match cap is
unchanged.
Also corrects the docstring on the existing test, which still described the
removed early return in the present tense.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): report truncated remote glob and grep results
BoxLite, Tenki, E2B, and OpenSandbox run find/grep in the sandbox, cap
the raw output with `| head`, and then filter those lines in Python:
ignored directories such as node_modules are dropped and grep's glob
scope is applied. They reported truncated only when max_results matches
survived the filter. When the capped lines were mostly filtered out, a
search with real matches past the cap came back short or empty with
truncated=False, and glob_tool/grep_tool rendered it as "No files
matched" / "No matches found". With the default max_results=200 and
1,200 files under node_modules, glob("**/*.py") reported no matches for
a workspace that has src/app.py.
remote_search_command now lets one line past its limit through, and
parse_remote_search_output(..., limit=) returns RemoteSearchOutput(text,
truncated): the first `limit` lines and whether the extra line arrived.
Exactly `limit` lines stays a complete result. Each provider passes the
cap it already computed to both calls and returns that truncated from
glob and grep when fewer than max_results results survive filtering.
The glob and grep tools now describe an empty truncated result as
incomplete instead of reporting no matches, which also covers AIO grep's
forwarded truncated flag. Sandbox.glob/grep document truncated as "the
matches may be incomplete".
* docs(changelog): reference #5427 in the remote search truncation entry
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): stop glob/grep/ls from surfacing disabled skills' files
The disabled-skill gate checks the path a tool is given, but ls, glob and
grep all descend from it and return other paths, so a root above a disabled
skill still serves its files. glob and grep never called the gate at all;
ls called it only on its own argument and still leaked from a category root.
Add the entry gate to glob/grep, and filter what all three return through
the existing fail-closed _is_disabled_skill_path. The verdict is memoized per
skill because ExtensionsConfig.from_file() is uncached, so a per-match check
would turn a 100-match grep into 100 config reads.
* fix(sandbox): normalize trailing slashes in the disabled-skill path check
Review follow-ups on the disabled-skill gate:
- _extract_skill_name_from_skills_path returned "" instead of None for a
category directory carrying a trailing slash. LocalSandbox.list_dir appends
"/" to directories, so `ls /mnt/skills` yields "/mnt/skills/public/", giving
parts ["public", ""]. The empty name skipped the `skill_name is None`
short-circuit and fell through to a config read, landing on the right outcome
only because unknown skills default to enabled. Drop empty segments so a
trailing-slash category root takes the existing category-root branch.
- ls_tool resolved the runtime user id twice per call; hoist it into a local,
matching glob_tool/grep_tool.
- Cover the CUSTOM path: custom/legacy skills resolve their enabled state
through the per-user _skill_states.json, a different store from the public
skills' extensions_config.json, and no automated test exercised it.
* feat(skills): per-user skill isolation (#2905)
Implement user-scoped skill storage that isolates custom skills between
users while sharing public skills globally.
Key changes:
- Add UserScopedSkillStorage class for per-user custom skill directories
- Introduce get_or_new_user_skill_storage() factory with user_id context
- Auth middleware sets effective_user_id for request-scoped storage
- Agent/prompt/middleware now use user-scoped storage and prompt cache
- Sandbox mounts user-scoped skill directories for search/read tools
- Add validate_skill_file_path() to SkillStorage for path security
- Migration script supports --all-users bulk migration
- Frontend: add editable field to Skill type, error check in enableSkill
- All skill categories can be toggled (custom skills default to enabled)
- Update skill-creator SKILL.md with isolation-aware instructions
Tests:
- Add test_user_scoped_skill_storage.py (new)
- Update all existing skill tests for user-scoped storage
- Update sandbox, client, and router tests
* fix(skills): address second-round PR review feedback (#3889)
- P1-1: restrict legacy skill mount to users without custom skills
- P1-2: fail-closed for _is_disabled_skill_path (OSError → return True)
- P2-1: AND-merge global extensions_config skill disabled state
- P2-2: atomic write for _skill_states.json (mkstemp + replace)
- P2-3: normalize X-DeerFlow-Owner-User-Id in trusted boundary
- P2-4: LRU-bounded _enabled_skills_by_config_cache (OrderedDict, maxsize=256)
- P2-5: clear global prompt cache on PUBLIC skill toggle
- P2-6: invalidate skill caches on client.update_skill
* fix(tests): correct tool policy test after merge
* fix(skills): use DEFAULT_SKILLS_CONTAINER_PATH in UserScopedSkillStorage
The "/mnt/skills" literal in UserScopedSkillStorage.__init__ triggers
test_skill_container_path_defaults::test_mnt_skills_literal_is_owned_by_skill_constants_module
on CI. Migrate the default to the existing deerflow.constants constant,
matching the pattern already used by LocalSkillStorage, SkillStorage, and
the durable/tool_error middlewares.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
ls_tool was the only file-system tool that did not call
mask_local_paths_in_output() before returning its result, causing host
absolute paths (e.g. /Users/.../backend/.deer-flow/knowledge-base/...)
to leak to the LLM instead of the expected virtual paths
(/mnt/knowledge-base/...).
This patch:
- Adds the mask_local_paths_in_output() call to ls_tool, consistent
with bash_tool, glob_tool and grep_tool.
- Initialises thread_data = None before the is_local_sandbox branch
(same pattern as glob_tool) so the variable is always in scope.
- Adds three new tests covering user-data path masking, skills path
masking and the empty-directory edge case.