Security Reference
Fourteen layered security guards in muse/core/validation.py
and adjacent modules. Every guard is documented with the attack it
prevents, the function that implements it, and every site where it is
applied.
Threat model
Muse operates in three environments — local developer machines, CI pipelines, and multi-agent clusters running hundreds of commits per minute. The threat surface shifts between them, but the guards apply everywhere.
| Threat | Severity | Guard |
|---|---|---|
| Path traversal via manifest keys | CRITICAL | contain_path() — zip-slip defence |
| Path traversal via object IDs | CRITICAL | validate_object_id() — hex-only, no slashes or dots |
| XXE / Billion Laughs via XML | CRITICAL | SafeET — defusedxml wrapper |
| Authorization header leakage via redirect | CRITICAL | _NoRedirectHandler in HttpTransport |
| ANSI/OSC escape injection via commit messages | HIGH | sanitize_display() — strips C0/C1 controls |
| Null-byte / CR injection via branch names | HIGH | validate_branch_name() — allowlist + reject rules |
| Symlink escape during checkout | HIGH | contain_path() resolves symlinks before containment check |
| Identity store TOCTOU race | HIGH | os.open() + fchmod() + atomic rename + flock |
| Glob injection in prefix lookup | MEDIUM | sanitize_glob_prefix() — strips metacharacters |
| OOM via unbounded HTTP response | MEDIUM | MAX_RESPONSE_BYTES = 64 MB cap in HttpTransport |
| NaN propagation in numeric computation | MEDIUM | finite_float() — returns fallback for Inf/NaN |
| Scheme downgrade (HTTPS → HTTP) | MEDIUM | HTTPS enforcement in _build_request() |
| Hash collision via separator ambiguity | MEDIUM | Null-byte separator in compute_snapshot_id() |
| Credential capture via dotfile snapshot | MEDIUM | walk_workdir() excludes symlinks and hidden files |
Trust boundary
All data from outside the trust boundary is treated as adversarial. Validation happens at the boundary — never inside the trusted zone.
All fourteen guards at a glance
Rejects any object ID that is not exactly 64 lowercase hex characters.
Blocks path traversal via crafted IDs like
../../.ssh/authorized_keys.
Content integrity check: write_object() verifies
the written content actually hashes to the provided ID.
object_path(), restore_object(),
write_object(), resolve_commit_ref(),
store_pulled_commit(), read_merge_state(),
apply_resolution()
Rejects backslashes, null bytes, CR/LF, leading/trailing dots, consecutive dots, consecutive slashes, and names longer than 255 characters. Prevents branch names from escaping ref path construction or injecting into log output.
init, commit, branch,
checkout -b, merge, reset,
get_head_commit_id(), LocalFileTransport.push_pack()
Joins base / rel, resolves all symlinks, then asserts
the result is inside base. A manifest key like
../../.ssh/authorized_keys or a pre-placed symlink
pointing outside the workdir are both caught.
checkout, merge, reset --hard,
revert, cherry_pick, shelf pop,
all 7 semantic write commands, read_merge_state(),
apply_resolution(), LocalFileTransport
Strips C0 control characters (\x00–\x1f except tab
and newline), DEL (\x7f), and C1 controls
(\x80–\x9f). Prevents commit messages or branch names
from injecting OSC/CSI terminal escape sequences when echoed.
typer.echo() paths that output
user-controlled strings: log, tag,
branch, checkout, merge,
reset, revert, cherry_pick,
commit
Strips glob metacharacters * ? [ ] { } before
Path.glob() is called. Without this, a crafted prefix
like **/* would enumerate the entire directory tree
rooted at .muse/commits/.
_find_commit_by_prefix() in store.py
clamp_int raises ValueError for
out-of-range integers. finite_float returns a safe
fallback for Inf, -Inf, NaN.
Prevents resource exhaustion via large arguments and silent
computation corruption via NaN propagation.
log --max-count, find_phrase --depth/--min-score,
humanize --timing/--velocity, invert --pivot,
MIDI parser tempo and divisions
Wraps defusedxml.ElementTree.parse() behind a typed
class. Prevents Billion Laughs (exponential entity expansion),
XXE (external entity credential theft via
file:///etc/passwd), and SSRF via XML.
SafeET.parse(), never
xml.etree.ElementTree.parse().
Four controls: (1) redirect refusal via _NoRedirectHandler
— prevents Authorization: MSign header leakage to
redirected hosts; (2) HTTPS enforcement — rejects non-HTTPS URLs;
(3) MAX_RESPONSE_BYTES = 64 MB cap — prevents OOM;
(4) content-type guard — checks first byte is { or
[ before JSON parsing.
muse push, muse fetch,
muse hub, muse auth operations that make
outbound HTTP requests to MuseHub or staging
Three controls for file:// URLs: (1) symlink
canonicalisation via resolve() on the repo root path;
(2) validate_branch_name() before any I/O;
(3) contain_path() on the final ref path — defence-in-depth
against pre-placed symlinks in .muse/refs/heads/.
_repo_root(), push_pack()
Null-byte separator between key and value in
compute_snapshot_id() makes filename/ID separator
collisions structurally impossible (null bytes cannot appear in
POSIX paths). walk_workdir() skips symlinks and
dotfiles — prevents .env, .muse/, and
other credential files from being captured in snapshots.
muse commit, muse shelf save,
and snapshot creation path
Seven layered controls: 0o700 directory permissions;
0o600 file permissions set before first byte is
written (no TOCTOU window); atomic rename via temp file +
os.replace(); symlink guard before write; exclusive
write lock via fcntl.flock; token masking in logs
("MSign ***"); URL normalisation so
https://admin:secret@host/ and host
resolve to the same key.
muse auth keygen, muse auth register,
muse auth rotate, all identity read/write operations
Hard limits on per-blob reads (256 MB), HTTP response bodies (64 MB), and SysEx data per MIDI message (64 KiB). A crafted request or response that exceeds these limits is rejected before the bytes are buffered in memory.
object_store.read_object() · transport._execute() ·
midi_merge._msg_to_dict() · midi_parser.parse_file()
Size caps quick reference
| Constant | Value | Where enforced |
|---|---|---|
| MAX_FILE_BYTES | 256 MB | object_store.read_object() — per-blob reads |
| MAX_RESPONSE_BYTES | 64 MB | transport._execute() — HTTP response body |
| MAX_SYSEX_BYTES | 64 KiB | midi_merge._msg_to_dict() — SysEx data per message |
| MIDI file size | MAX_FILE_BYTES | midi_parser.parse_file() — cap before parse begins |