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.
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 anywhereThe 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.

pyRPC