Skip to content
Open
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
20 changes: 20 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Weekly update PRs for the pinned Python requirements and the GitHub Actions
# versions. They target dev, like every other change, and must pass the
# required checks before reaching main.
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
target-branch: dev
ignore:
# MkDocs 2.0 drops the plugin and theme-override systems the docs use.
- dependency-name: mkdocs
versions: [">=2"]

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
target-branch: dev
4 changes: 2 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- 'docs/**'
- 'overrides/**'
- 'mkdocs.yml'
- 'requirements-docs.txt'
workflow_dispatch:

permissions:
Expand All @@ -28,8 +29,7 @@ jobs:
with:
python-version: '3.12'

# MkDocs 2.0 drops the plugin and theme-override systems this site uses.
- run: pip install 'mkdocs>=1.6,<2' 'mkdocs-material>=9.7,<10'
- run: pip install -r requirements-docs.txt

- run: mkdocs build --strict

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/kernel-backends.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ on:
permissions:
contents: read

# Only a pull request's superseded runs are cancelled; on a branch, cancelling
# would leave an earlier commit with no result.
concurrency:
group: kernel-backends-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# An explicit bash shell adds pipefail, so `make | tee` fails when make does.
defaults:
Expand Down
13 changes: 8 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ on:
branches: [main, dev]
pull_request:
branches: [main, dev]
# Weekly, so unpinned dependencies or a runner image change that breaks the
# suite shows up without a commit to trigger it.
# Weekly, so a runner image change that breaks the suite shows up without a
# commit to trigger it. Dependency updates arrive as Dependabot PRs.
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
Expand Down Expand Up @@ -34,7 +34,11 @@ jobs:
pip install -r requirements-dev.txt

- name: Run tests
run: pytest tests/ -v
# The real-mount tests skip when FUSE or passwordless sudo is missing;
# here that must fail instead, or the suite could go quietly empty.
env:
UGOW_FUSE_TESTS: require
run: pytest tests/ -v -rs

docs:
# docs.yml only builds the site on main, as part of deploying it, so a
Expand All @@ -47,7 +51,6 @@ jobs:
with:
python-version: '3.12'

# MkDocs 2.0 drops the plugin and theme-override systems this site uses.
- run: pip install 'mkdocs>=1.6,<2' 'mkdocs-material>=9.7,<10'
- run: pip install -r requirements-docs.txt

- run: mkdocs build --strict
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ Thanks for your interest in contributing! Here's how to get started.
```bash
pytest
```
`tests/test_fuse_mount.py` mounts the real shim as root, so it runs only on
Linux with `/dev/fuse` and passwordless `sudo`, and is skipped otherwise
(`pytest -rs` shows why). CI sets `UGOW_FUSE_TESTS=require`, which turns
that skip into a failure.
4. If you changed the docs, build the site the way CI does:
```bash
pip install -r requirements-docs.txt
mkdocs build --strict
```

Dependencies are pinned exactly; Dependabot opens weekly PRs against `dev` to
update them.

## Testing on WSL2

Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

**U**nix **G**rant **O**verlay for **W**indows drives in WSL2.

UGOW adds fine-grained, per-user write control to Windows drives mounted in WSL2. Write operations on `/mnt/c`, `/mnt/d`, etc. are gated by a SQLite-backed permission store and optionally mirrored to NTFS ACLs on the Windows host.
UGOW adds fine-grained, per-user write control to Windows drives mounted in WSL2. Write operations on `/mnt/c`, `/mnt/d`, etc. are gated by a SQLite-backed permission store.

---

Expand All @@ -37,7 +37,7 @@ I couldn't find a clean solution, so I built one.
- **Permission inheritance** -- a grant on a directory applies to all descendants.
- **Root exemption** (BPF mode) -- root is always allowed through. In FUSE mode, root is remapped to the user who launched the shim, inheriting their W-bit rights. The kmod makes this a build-time choice (`CONFIG_SECURITY_UGOW_ROOT_EXEMPT`, default `y`).
- **Audited denials** -- every refused operation is logged with the operation, UID, and mount-visible path (`journalctl -u wsl-fuse-shim@c.service`).
- **ACL mirroring** -- best-effort creation of corresponding Windows local users (`wsl_<UID>`) and NTFS ACL grants via PowerShell/`icacls`.
- **ACL mirroring (visibility only)** -- optionally records each grant on the Windows side as an NTFS ACE for a disabled local account `wsl_<UID>`, so it shows up in the folder's Security tab. It enforces nothing: WSL reaches NTFS as the Windows user who launched it, whatever the Linux UID, so an ACE for `wsl_<UID>` never changes what WSL can do.
- **Unified CLI** -- `ugow allow`, `ugow deny`, `ugow check`, `ugow status`, `ugow list`, `ugow sync`, `ugow acl-cleanup` -- works identically across all backends.

---
Expand Down Expand Up @@ -148,7 +148,8 @@ sudo ugow check --user 9500 /mnt/c/data # can UID 9500 write here?
sudo ugow status /mnt/c/docker # who can write here?
sudo ugow list # show all grants
sudo ugow sync # replay SQLite into kernel backends
sudo ugow acl-cleanup # drop mirrored wsl_* users with no grants
sudo ugow acl-cleanup --dry-run # list mirrored wsl_* users with no grants
sudo ugow acl-cleanup # ...and remove them
```

Users can be specified by name or numeric UID. All commands require root (`sudo`). The `check` command uses `SUDO_UID` to test the *calling* user's permissions, not root's.
Expand Down Expand Up @@ -188,6 +189,16 @@ docker run --user 9500 \

The container (UID 9500) will be able to write under `/data` only if you granted its W-bit.

> **Containers running as root.** Docker runs a container as root unless you
> pass `--user`, and each backend treats root differently:
>
> - **BPF** exempts root unconditionally, so a root container can write
> anywhere on an enforced drive. Always pass `--user`.
> - **FUSE** remaps root to the user who launched the shim, so a root container
> gets that user's grants.
> - **kmod** exempts root unless the kernel was built with
> `CONFIG_SECURITY_UGOW_ROOT_EXEMPT=n`.

---

## Architecture
Expand Down
2 changes: 2 additions & 0 deletions bpf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Key advantage over the FUSE shim: **cannot be bypassed from userspace**.
Every syscall path to the filesystem passes through the LSM hooks.

**Root (uid 0) is always exempt** from enforcement to prevent system lockout.
That includes containers: Docker runs a container as root unless it is started
with `--user`, and such a container can write anywhere on an enforced drive.

---

Expand Down
73 changes: 61 additions & 12 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
ugow status <path> Show who can write to a path
ugow list List all grants
ugow sync Sync SQLite grants into kernel backends
ugow acl-cleanup Drop mirrored Windows users with no grants
ugow acl-cleanup [--dry-run] Drop mirrored Windows users with no grants
ugow mount <drive> Enable UGOW on a Windows drive
ugow unmount <drive> Disable UGOW on a Windows drive
ugow drives List active UGOW-managed drives
Expand Down Expand Up @@ -162,6 +162,26 @@ def _bpf_revoke(uid, path):
return None


# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------

def resolve_path(raw):
"""Return the canonical path a grant for *raw* is stored under.

Symlinks are resolved because enforcement never sees them: the kernel
follows a link before the FUSE shim or an LSM hook is called, so a grant
stored under the link's own path would never match. realpath is non-strict,
so missing trailing components are kept as typed -- a grant may name a path
that does not exist yet.
"""
path = os.path.realpath(raw)
typed = os.path.abspath(raw)
if path != typed:
print(f" note: {typed} resolves to {path}; using that")
return path


# ---------------------------------------------------------------------------
# kmod securityfs helpers
# ---------------------------------------------------------------------------
Expand All @@ -186,6 +206,9 @@ def _kmod_key(path):
expressed the same way, paired with the device so identical relative paths
on different drives stay distinct.
"""
# The kmod renders the dentry the kernel resolved, so the path it is sent
# must be resolved too -- including grants stored before paths were.
path = os.path.realpath(path)
st = os.stat(path)
dev = f"{os.major(st.st_dev)}:{os.minor(st.st_dev)}"
mount = _mount_point(path)
Expand Down Expand Up @@ -276,7 +299,7 @@ def _restore_dac_after_bpf(store, path):
def cmd_allow(args):
require_root("allow")
uid, username = resolve_user(args.user)
path = os.path.abspath(args.path)
path = resolve_path(args.path)

store = PermStore(db_path=args.db, mirror_acl=args.mirror_acl)

Expand Down Expand Up @@ -313,13 +336,17 @@ def cmd_allow(args):
def cmd_deny(args):
require_root("deny")
uid, username = resolve_user(args.user)
path = os.path.abspath(args.path)
path = resolve_path(args.path)

store = PermStore(db_path=args.db, mirror_acl=args.mirror_acl)

# Revoke is the reverse: drop the authoritative record first so a partial
# failure errs toward denying rather than keeping a stale permission.
store.revoke(path, uid)
# Grants made before paths were resolved are stored as typed.
typed = os.path.abspath(args.path)
if typed != path:
store.revoke(typed, uid)

errors = []
if _bpf_active():
Expand Down Expand Up @@ -348,7 +375,7 @@ def cmd_deny(args):

def cmd_check(args):
require_root("check")
path = os.path.abspath(args.path)
path = resolve_path(args.path)

if hasattr(args, "user") and args.user:
uid, username = resolve_user(args.user)
Expand All @@ -368,7 +395,7 @@ def cmd_check(args):

def cmd_status(args):
require_root("status")
path = os.path.abspath(args.path)
path = resolve_path(args.path)
ancestors = set(_path_ancestors(path))

store = PermStore(db_path=args.db, mirror_acl=False)
Expand Down Expand Up @@ -451,8 +478,17 @@ def cmd_sync(args):
def cmd_acl_cleanup(args):
require_root("acl-cleanup")
store = PermStore(db_path=args.db, mirror_acl=False)
store.cleanup_acl()
print("ACL cleanup complete.")
removed, failed = store.cleanup_acl(dry_run=args.dry_run)

verb = "Would remove" if args.dry_run else "Removed"
for name in removed:
print(f" {verb}: {name}")
for name in failed:
print(f" Failed to remove: {name}", file=sys.stderr)
if not removed and not failed:
print("No mirrored wsl_* accounts without grants.")
if failed:
sys.exit(1)


def cmd_list(args):
Expand Down Expand Up @@ -536,12 +572,23 @@ def cmd_mount(args):
file=sys.stderr)
sys.exit(1)
result = _run([UGOW_PYTHON, UGOW_MANAGE, "add-device", mount_path])
if result.returncode == 0:
print(f"\nDrive {letter.upper()}: is now enforced by UGOW BPF at {mount_path}")
else:
if result.returncode != 0:
print(f"Error: {result.stderr.strip()}", file=sys.stderr)
sys.exit(result.returncode)

# BPF grants are keyed by inode, and a Windows drive's inode numbers
# can change across a remount, so reload the map from SQLite now that
# the drive is enforced -- stale keys would match nothing and deny.
result = _run([UGOW_PYTHON, UGOW_MANAGE, "--db", args.db, "sync"])
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
print(f"Error: drive {letter.upper()}: is enforced, but reloading "
f"its grants failed: {err}\n"
f" Grants on it may be refused until 'sudo ugow sync' succeeds.",
file=sys.stderr)
sys.exit(result.returncode)
print(f"\nDrive {letter.upper()}: is now enforced by UGOW BPF at {mount_path}")


def cmd_unmount(args):
require_root("unmount")
Expand Down Expand Up @@ -676,8 +723,10 @@ def main():

sub.add_parser("list", help="List all grants")
sub.add_parser("sync", help="Replay SQLite grants into kernel backends (kmod/BPF)")
sub.add_parser("acl-cleanup",
help="Remove mirrored Windows wsl_* users with no grants")
p = sub.add_parser("acl-cleanup",
help="Remove mirrored Windows wsl_* users with no grants")
p.add_argument("--dry-run", action="store_true",
help="List the accounts that would be removed, removing nothing")

p = sub.add_parser("mount", help="Enable UGOW on a Windows drive")
p.add_argument("drive", help="Drive letter (e.g. d, e, f)")
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ SQLite connections are per-thread (stored in `threading.local()`). WAL mode is e

### ACL mirroring

ACL mirroring is a visibility aid, not an enforcement layer. WSL reaches NTFS as the Windows user who launched it, whatever the Linux UID, so an ACE for `wsl_<UID>` never changes what WSL can do. The mirrored ACEs only show the grant in a folder's Windows Security tab.

When enabled (`--mirror-acl`), grant and revoke operations are queued to a background worker thread that:

1. Creates a Windows local user `wsl_<UID>` if it doesn't exist, with a random password, and immediately disables it -- the account exists only as an ACL principal, nothing ever authenticates as it, and a disabled account's SID still resolves in ACLs
Expand All @@ -150,7 +152,7 @@ Grants are applied inheritably (`(OI)(CI)F`) and recursively (`/T`). Revoke uses

Requesting `--mirror-acl` without Windows Administrator privileges raises `AclMirrorUnavailable`, which the CLI turns into an error message. The library never calls `sys.exit()` on its caller's behalf -- the FUSE daemon imports the same module and would otherwise die at startup with no explanation. Flushing pending ACL work is bounded (60 s) and returns a success flag rather than blocking forever if the worker thread has died.

ACL mirroring is best-effort -- if it fails, the SQLite grant still succeeds and the W-bit enforcement layer works independently. `ugow acl-cleanup` removes mirrored `wsl_*` accounts whose UID no longer holds any grant.
ACL mirroring is best-effort -- if it fails, the SQLite grant still succeeds and the W-bit enforcement layer works independently. `ugow acl-cleanup` removes mirrored `wsl_*` accounts whose UID no longer holds any grant; `--dry-run` lists them without removing anything.

### Path conversion

Expand Down
2 changes: 1 addition & 1 deletion docs/bpf.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The BPF programs hook into the kernel's LSM framework and check every write-clas
Key advantage over the FUSE shim: **cannot be bypassed from userspace**. Every syscall path to the filesystem passes through the LSM hooks.

!!! info "Root exemption"
Root (uid 0) is always exempt from enforcement to prevent system lockout.
Root (uid 0) is always exempt from enforcement to prevent system lockout. That includes containers: Docker runs a container as root unless it is started with `--user`, and such a container can write anywhere on an enforced drive.

## Prerequisites

Expand Down
Loading
Loading