Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ and versions are tracked in the repo-root `VERSION` file.

### Fixed

- Redact sensitive option values across every declared alias and Click value
form, redact sensitive positional arguments, and protect conventional secret
parameter names automatically before argv reaches logs or history writers.
- Claim invocation temp leaves exclusively and refuse recursive cleanup unless
ownership, strict run-root containment, the run-ID marker, and a symlink-free
path can all be proven; content erasure uses a retained directory handle and
intentionally leaves the empty directory skeleton instead of reopening a
pathname-removal race.
- Finalize core-owned run metadata for successful, failed, aborted, interrupted,
and unexpected command outcomes without letting secondary persistence
failures replace the command result.
Expand Down
53 changes: 45 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,32 @@ def main(ctx: base_cli.Context, project: str, workspace: str | None) -> None:
...
```

Use `sensitive=True` for options whose values should not appear in invocation
logs:
Use `sensitive=True` for options or arguments whose values must not reach
invocation logs or history writers:

```python
@base_cli.option("--token", sensitive=True, required=True)
def main(ctx: base_cli.Context, token: str) -> None:
...
```

Both `--token secret` and `--token=secret` are accepted and redacted in debug
logs.
All aliases declared for a sensitive option are protected, including short and
alternate long forms. Spaced values, equals forms, and attached short-option
values are redacted. Sensitive positional arguments are redacted according to
the Click command schema:

```python
@app.command()
@base_cli.argument("credential", sensitive=True)
def login(ctx: base_cli.Context, credential: str) -> None:
...
```

Parameters whose names contain `token`, `password`, `secret`, `api-key`
(`api_key`), or `authorization` are protected automatically. Use
`sensitive=True` for domain-specific secret names. Custom history writers
receive already-redacted argv, so raw secret-bearing argv never crosses the
framework's persistence boundary.

Use `dry_run=True` when a nonstandard option should drive `ctx.dry_run` and
the lifecycle's default durable-write suppression:
Expand Down Expand Up @@ -501,9 +516,31 @@ owns a bundle and therefore do not create one. Neither do inherited runtimes,
`log_to_file=False`, or dry-run invocations; an explicit log path can still
receive diagnostics in the latter two modes. Context startup is transactional:
if directory creation, logger setup, or retention fails, base-cli closes
partially installed handlers and removes new bundle-local temp/log artifacts
and empty directories. Pre-existing content, persistent component caches, and
parent-runtime data are preserved.
partially installed handlers and erases new bundle-local temp files through the
same retained handle used by normal teardown. It retains log files and empty
directory boundaries rather than attempting race-prone pathname removal.
Pre-existing content, persistent component caches, and parent-runtime data are
preserved.

Recursive temp cleanup is fail-closed. Base-cli erases contents only when
its leaf was claimed exclusively for the invocation, its retained directory
handle and creation-time filesystem identity still match, and the path remains
a strict, run-ID-marked descendant of the selected run root with no symlinked
component. Filesystem roots, the run root itself, replaced directories,
traversal paths, mounted targets, external paths, and paths whose ownership or
mount identity cannot be proven are kept and reported as cleanup warnings.
Content erasure is descriptor-relative. Empty directory nodes—including the
leaf and its ancestors—are intentionally retained because portable POSIX APIs
cannot atomically remove an already-verified open directory; avoiding pathname
`rmdir` closes the final replacement race. Platforms without the required
handle operations retain files too and warn. `--keep-temp` preserves both the
directory tree and files.

This boundary assumes the per-user runtime tree is not maliciously mutated by
another process running with the same account while ownership is acquired or
cleanup runs. Processes with the same filesystem authority can otherwise
rename or replace any user-owned runtime path; base-cli still verifies the
retained handle against the published path before erasing contents.

On POSIX, base-cli enforces owner-only `0600`/`0700` modes. On Windows, the
default user-local cache root relies on inherited user-profile ACLs; consumers
Expand All @@ -525,7 +562,7 @@ def close_connection() -> None:
ctx.on_cleanup(close_connection)
```

Cleanup hooks run before temp directory removal. Hook failures are logged as
Cleanup hooks run before temp-content erasure. Hook failures are logged as
warnings and do not prevent later hooks from running.

## Testing
Expand Down
29 changes: 26 additions & 3 deletions docs/cache-ownership-and-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,32 @@ The ownership boundary intentionally excludes parser failures, help and version
requests, inherited runtime bindings, `log_to_file=False`, and dry-run mode.
Those invocations do not create or finalize a bundle. If context construction
fails after creating artifacts, rollback closes partial logging handlers and
removes new bundle-local temp/log artifacts and empty directories. It does not
delete pre-existing content, persistent component caches, paths outside the
selected run root, or a parent runtime's metadata.
erases new bundle-local temp files through the retained ownership handle. It
retains log files and empty directory boundaries rather than reopening pathname
replacement races, and does not delete pre-existing content, persistent
component caches, paths outside the selected run root, or a parent runtime's
metadata.

Temp cleanup uses the same fail-closed ownership proof during normal teardown
and startup rollback. The final leaf is claimed exclusively through a stable
parent handle, retained for the invocation, and checked against its captured
filesystem identity. Its path must remain a strict lexical and resolved
descendant of the selected run root, carry the invocation's run ID as its final
component, and contain no symlinked component. Cleanup refuses roots, the run
root itself, replaced directories, traversal paths, mounted targets, external
paths, pre-existing directories, missing Linux mount identities, and anything
it cannot inspect safely.

Files and symlinks are erased relative to the retained directory handle and
cleanup refuses cross-device descendants. All empty directory nodes are
retained: portable POSIX APIs cannot atomically bind `rmdir` to an
already-verified open directory, so pathname removal would reopen a replacement
race at every depth. If the host lacks the required handle operations, files
are retained with a warning. `--keep-temp` also retains files. A refusal never
replaces the command's primary result. The ownership claim assumes the private
per-user runtime tree is not maliciously mutated by another process with the
same account while ownership is acquired or cleanup runs; it is not a
cryptographic proof against a hostile same-account process.

Persistent component caches live under the owner's `cache/components/` path.
On POSIX systems, runtime directories are owner-only (`0700`) and runtime files
Expand Down
9 changes: 9 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ Base or `basectl` natively Windows-compatible; those consumers have their own
Unix-tooling and shell boundaries. The package does not provide package-manager
integration, shell startup management, or WSL/Windows path translation.

Recursive invocation-temp content erasure requires descriptor-relative,
no-follow directory operations. Linux, macOS, and WSL2 provide those
primitives; the empty leaf is retained on every platform because portable
POSIX has no identity-bound `rmdir`. Empty nested directories and ancestors are
retained for the same reason. Linux additionally requires readable mount IDs
and fails closed if they are unavailable. Native Windows currently uses the
secure fallback: it retains both directories and files and emits a cleanup
warning rather than perform race-prone pathname recursion.

The supported Python range is Python 3.10 through 3.14. Bug reports should
include the operating system, distribution or WSL version when relevant,
Python version, and whether paths live on the native filesystem or a mounted
Expand Down
Loading