← Back to Blog

A filesystem prompt that can’t escape its jail

·7 min read

The wizard’s client-root question used to be a bare text input. You typed a path, hoped it existed, and discovered typos after codegen wrote files somewhere unexpected. v0.13.0 swaps in questionary.path: live directory completion, Tab to accept, and a security boundary that turned out to be the interesting part.

Diagram: the project root jail filters node_modules, dot dirs and symlink escapes; accepted suggestions appear in the prompt

The jail is a filter, not a sandbox

The completer supports a file_filter callback receiving each candidate path. Ours enforces three rules:

def _visible(full_name: str) -> bool:
 if not os.path.isdir(full_name):
 return False
 name = os.path.basename(full_name.rstrip(os.sep))
 if not name or name.startswith(".") or name in _SKIP_DIRS:
 return False
 real = os.path.realpath(full_name) # symlinks resolved
 return real == root_abs or real.startswith(root_abs + os.sep)

The third rule is the whole game. A symlink client -> /home/you/other-project looks innocent as text but resolves outside the tree; os.path.realpath exposes it, and the containment prefix-check rejects it. Suggestions can never point outside the project root, even when the filesystem lies about geography.

Typed input is a different problem

Here is the design decision worth stealing: the filter governs suggestions only. What you type is not filtered, you may absolutely write ../shared-client, because monorepos are real and siblings are legitimate client roots. The submit gate is a separate validator that checks existence and nothing else:

def _exists(path: str):
 if os.path.isdir(os.path.abspath(path)):
 return True
 return "Directory does not exist"

Conflating the two would produce either a broken prompt (can’t leave the root, ever) or a fake one (jail that evaporates on keystroke). Separating them gives good defaults with honest escape hatches.

Testing an interactive component headlessly

Nothing here needs a TTY. prompt_toolkit ships GreatUXPathCompleter (the engine behind questionary’s path prompt), which accepts plain strings and documents:

completer = GreatUXPathCompleter(
 get_paths=lambda: [str(root)],
 only_directories=True,
 file_filter=cli._client_visible_filter(str(root)),
)
out = [c.display[0][1] for c in completer.get_completions(
 Document("src/a"), CompleteEvent())]
assert out == ["api/", "app/"] # node_modules never appears anywhere

The suite covers: junk hidden at the root, nested prefix completion, nonexistent prefixes returning empty, ../ navigation yielding only in-jail entries (the parent lists your own project back, correct!), absolute escapes like /etc, and the symlink case end-to-end. Interactive UX with CI-grade confidence, zero new dependencies.