Per-user file protection for macOS, v2: BSD-flag chains on top of the v1
ownership flip. A locked node can't be written, renamed, or deleted even by
a process with write permission on its parent, and the flag can't be
cleared by any process running as you (the node is owned by the lock
account, or carries a root-only system flag). Design: DESIGN-v2.md.
Every privileged action runs via sudo; whatever auth you've set up
(password, pam_tid for Touch ID, sudowhat
for trusted prompts) gates each operation. locked does not touch sudo's
auth chain.
Three tiers, named by what they protect:
content(uchg, lock-account-owned) -- leaf files, or leaf dirs recursively. Edit cycle: unlock, edit, lock.placement(uappnd, lock-account-owned, group and mode unchanged, one ACL entry that lets you add) -- ancestor dirs above a locked leaf's own parent (~/.config,~/Library, ...), derived from the files you protect and never named: new entries fine, replacing or removing existing ones denied. What you create there is ordinary: your group, no ACL -- see How you get into a placement directory.anchor(sappndorschg, ownership unchanged) -- nodes the OS identity-checks against your UID (~,~/.ssh): the system flag binds every user process while sshd's owner checks keep passing.
sudo locked lock <path>-- protect a file, directory or symlink, or relock one. You name what to protect; locked derives the rest: the ancestor chain from the leaf's grandparent up (placement for user-owned dirs, anchor for~), stopping at a root-owned node, and any sealed directory no protected file needs any more, which it releases. The leaf's own parent is not sealed unless it is the anchor -- see The pool is what you protect. Nothing is touched until the whole ceremony is derived: the snapshot diff, then the plan -- one line per node, root-most first -- then one question for all of it (--yesfor scripted runs,--dry-runprints the plan and stops). Naming a symlink seals the link itself, not what it points at.sudo locked unprotect <path>-- stop protecting it for good: flag off, owner, group and mode back to what they were before the first lock, the record retired, and every directory no other protected file needs released in the same plan and under the same question.sudo locked edit <file>-- sudoedit-style edit, the preferred flow: your editor runs as you on a user-owned temp copy, the candidate is staged out of reach, then diff + confirm installs and reseals. The file is never left unlocked and no chain release is needed. With--from <copy>the proposed content comes from a file you prepared instead of from an editor -- see below.sudo locked unlock <path>-- release just that node (snapshot taken);--chainalso drops the ancestors' flags when an edit needs a rename into a frozen parent (atomic-save editors).sudo locked revert <file>-- save current to.attic, restore snapshot, re-seal.sudo locked rm|trash|mv-- mediated placement: remove, trash, or rename something a sealed parent will not let go of. A compiled helper holds the parent by file descriptor, verifies its identity, lifts the flag, performs the one syscall, restores the flag, and diffs the parent's entries across the window -- anything but the expected change raises an alarm instead of being sealed over.trashcalls the real macOS trash service as you (so the bin entry is yours and Put Back works), and suspends the record rather than retiring it: a Put Back of a previously sealed node is flagged byverifyinstead of silently coming back unprotected.rm --recursivetakes a whole tree, sealed nodes included. Pool records follow every operation -- retired with provenance for a removal, re-keyed for a move -- soverifystays honest without training you to ignore it.sudo locked rekey <old> <new>-- record-only repair when something else already moved a pool node; refuses unless<new>is the very inode the record describes.sudo locked tombstone <path>-- assert a recorded path is gone for good (retired, provenance "assertion"). Refused while it still exists.locked why <path>-- what, if anything, stops this path from being changed, renamed, or removed: flags on it and every ancestor, who owns them, whether locked set them, and the command that goes through the constraint. Nosudoneeded.locked status [<path>]-- verify and print the full chain for each path. With no path, lists every node in your pool (tier, flag, and any drift) so you can see at a glance what is currently locked, then what the lastverifyrun found and when; reporting only, so it exits 0 even on drift and never writes the verify status. The one action that needs nosudo: it is read-only, and it shows your own pool only -- another user's pool is unreadable to you. One caveat, printed by the tool itself: without root the recursive interior of a content-tier directory may not be fully readable, so unprivileged status gives leaf checks only there;sudo locked statusis the authoritative view.sudo locked verify-- re-check every locked node against its meta; exit 5 on drift. A launchd timer (installed by setup) runs this every 15 minutes and records each result in/var/db/locked/<user>/verify(see State layout), wherelocked statusand any other reader find it withoutsudo.sudo locked setup-- one-shot install + provision (see below).
Tests: sudo /bin/bash tests/harness.bash (scratch-dir only; stands in
daemon for the lock account, no global state touched).
A tool — Claude, a script, anything you would rather not hand the sealed
file to — writes the whole proposed file somewhere you own, say
.tmp/settings.proposed.json, and gives you one line to run:
sudo locked edit --from .tmp/settings.proposed.json ~/.config/agents/claude/settings/settings.jsonlocked copies the proposal into lock-account staging before it draws
anything on your screen, and the diff, the confirm and the install all
read that frozen copy. Whatever happens to the file under .tmp after
you have looked at the diff — a rewrite by the same tool, or by anything
else running as you — cannot change what installs.
The flake exports darwinModules.default (Darwin-only on purpose: the
whole mechanism is BSD file flags). It declares everything setup does
imperatively -- binary in the system profile, hidden lock account and
group, verify LaunchDaemon -- and generates /etc/sudoers.d/locked at
build time from the same string the installed script is built from, so
the sudoers sha256 digest can never drift from the deployed bytes; a
rebuild moves both atomically with the generation. The module also
compiles the window helper (helper/locked-helper.m) and bakes its store
path into the script, so the digest transitively commits to which helper
binary runs; nothing extra to configure.
# flake input (no inputs of its own), then:
imports = [ inputs.locked.darwinModules.default ];
security.locked = {
enable = true;
user = "jooize";
uid = 403; # declared id (users.knownUsers); pick a free one in 400-499
};The id is declared in the config (spirit of nix; on-disk snapshot
ownership survives account recreation with its meaning intact). For a
config that must not carry a machine-specific number, set
allocateIds = true instead -- the account is then provisioned
imperatively at activation with the first free id in 401-499 (setup's
own logic); exactly one of the two is required. Deletion is a manual
ceremony either way: nix-darwin refuses to delete accounts with ids
<= 501.
A nix-managed install refuses setup (the module owns provisioning) and
accepts /nix/store in the install-ancestry walk -- keyed to that
literal path, so /usr/local-shaped paths get no relaxation.
From this folder, after cloning/copying:
sudo ./locked setupIdempotent. Provisions everything in section "Prerequisites" below in one go:
- Creates
_<user>-lockgroup and user (prefers UID/GID 401 in the 400-499 service lane, falling back to the next free;IsHidden 1; shell/usr/bin/false). 401 is the lock slot; 402 is reserved for the Claude trust group_<user>-readonly. - Adds you to the lock group, which is what lets
locked statusread your pool without sudo. - Writes
/etc/sudoers.d/lockedwith the matching sudoers entry; validates viavisudo -cbefore installing. - Self-installs the script to
/usr/local/sbin/locked(root:wheel, mode 755), refusing if any ancestor up to/is user-writable. - Installs and loads the verify LaunchDaemon (
/Library/LaunchDaemons/locked.verify.plist, every 900 s). - Flushes directory service cache so the new account is visible immediately.
Re-running setup is safe: each step is gated on existence/presence checks.
If you'd rather not run sudo ./locked setup, the same steps by hand. Replace jooize with your username throughout. Sudo auth is independent -- set up pam_tid (Touch ID), sudowhat, or rely on password fallback separately; locked doesn't touch the sudo auth chain.
Verify /usr/local/sbin and every ancestor up to / are root-owned with no group/other write -- mode 755 or stricter. A user-writable ancestor lets a swapped binary inherit root after the next sudo approval.
for p in /usr/local/sbin /usr/local /usr; do
stat -f '%Su:%Sg %OLp' "$p" 2>/dev/null
done
# Expect each line: owner "root" (any group) with mode 755 (or stricter: 750, 700, ...).If any ancestor is user-owned -- common on Intel Macs where Homebrew once chowned /usr/local -- repair before continuing: sudo chown root:wheel <path> && sudo chmod 755 <path>.
Then install:
sudo install -d -m 755 -o root -g wheel /usr/local/sbin
sudo install -m 755 -o root -g wheel ./locked /usr/local/sbin/lockedBuild and install the window helper alongside (needs the Xcode command
line tools; the mediated verbs and every snapshot copy go through it,
and locked verifies its ownership and ancestry on each use):
clang -O2 -Wall -Wextra -framework Foundation \
-o /tmp/locked-helper helper/locked-helper.m
sudo install -m 755 -o root -g wheel /tmp/locked-helper /usr/local/sbin/locked-helperPick an unused UID/GID < 500 (dscl . -list /Users UniqueID and dscl . -list /Groups PrimaryGroupID show the in-use set). 401 is the conventional lock slot here (400 collides with Apple's com.apple.access_remote_ae; 402 is reserved for _jooize-readonly).
GID_LOCK=401; UID_LOCK=401 # adjust on collision
sudo dscl . -create /Groups/_jooize-lock
sudo dscl . -create /Groups/_jooize-lock RealName "Lock group for jooize"
sudo dscl . -create /Groups/_jooize-lock PrimaryGroupID $GID_LOCK
sudo dscl . -create /Users/_jooize-lock
sudo dscl . -create /Users/_jooize-lock UniqueID $UID_LOCK
sudo dscl . -create /Users/_jooize-lock PrimaryGroupID $GID_LOCK
sudo dscl . -create /Users/_jooize-lock UserShell /usr/bin/false
sudo dscl . -create /Users/_jooize-lock NFSHomeDirectory /var/empty
sudo dscl . -create /Users/_jooize-lock RealName "Lock user for jooize"
sudo dscl . -create /Users/_jooize-lock IsHidden 1
sudo dseditgroup -o edit -a jooize -t user _jooize-lockCompute the installed binary's sha256, stage the sudoers line to a tmp file, validate via visudo -c, then install. Skipping validation risks an unparseable /etc/sudoers.d/locked that locks you out of sudo until single-user recovery.
DIGEST=$(shasum -a 256 /usr/local/sbin/locked | awk '{print $1}')
TMP=$(mktemp)
printf 'jooize ALL=(root) sha256:%s /usr/local/sbin/locked\n' "$DIGEST" >"$TMP"
sudo visudo -c -f "$TMP" && \
sudo install -m 440 -o root -g wheel "$TMP" /etc/sudoers.d/locked
rm -f "$TMP"No NOPASSWD -- sudo prompts for your configured auth on each invocation. The sha256: pin means a swapped binary at the install path fails silently at the sudoers stage; sudo refuses without ever prompting.
So chown _jooize-lock and group-membership checks take effect immediately instead of waiting for the cache TTL.
sudo dscacheutil -flushcachesudo /usr/local/sbin/locked unlock /nonexistent # should print 'skip /nonexistent: does not exist'Touch ID/password prompt names /usr/local/sbin/locked unlock /nonexistent; binary runs; logs result=skip-noent via log show --predicate 'eventMessage CONTAINS "locked:"' --last 1m.
sudo locked lock <path> adopts any file or dir you own -- no manual
chown. The first lock captures owner/group/mode as canonical, takes a
baseline snapshot, and provisions the ancestor chain from the leaf's
grandparent up -- the leaf's own parent is deliberately left alone unless
it is the anchor, see below. It shows you the plan for the whole chain and
asks once, before it has touched anything.
A symlink is adopted as the link node itself: the link is what a process running as you could re-point, so that is what gets sealed. Its target is a separate node -- name it too if it should be sealed as well.
You name the files and directories to protect; every sealed directory above them is derived, and released again once nothing needs it.
lock ~/.claude/settings.json seals the file and walks the chain from ~
upward -- ~/.claude itself is left exactly as it was: your ownership,
your group, your mode, no flag.
That is not a gap. A node is held in place by one of two things: its own
flag, or an append-only parent. The file holds itself (uchg). ~/.claude
is held by ~'s sappnd, which is what an anchor or placement seal on a
directory does for every entry in it. A seal on ~/.claude would have
protected only the entries inside it that nothing else was protecting --
and it broke the software that owns the directory. Claude Code takes its
OAuth refresh lock with mkdir ~/.claude/.oauth_refresh.lock and releases
it with rmdir; uappnd refuses the rmdir, so the stale lock could
never be cleared and every token refresh failed. Temp+rename saves in the
same directory stranded a temp file each time.
The anchor is exempt. ~ and ~/.ssh stay in the chain wherever they
sit, including as a leaf's own parent -- which every shell startup file
(~/.zshrc, ~/.bashrc, ...) makes them. An anchor never changes
ownership, so it costs you nothing, and its sappnd is exactly what holds
~/.claude and ~/.config in place.
What follows, all of it visible in the plan before the one question:
- A directory that is one file's parent can be another file's grandparent-or-higher. There it does real work, so it stays sealed.
- A sealed directory nothing needs any more -- its file was unprotected,
removed, or moved elsewhere -- is released: flag off, owner, group and
mode back to what they were, record retired. The plan row says why:
release ~/.claude (not needed: /Users/you keeps it in place)or(no protected file needs it).lock,unprotect,rmand the lock planmvruns for a moved file all do this;statusnames any that are waiting. - If any record in the pool cannot be read for this (written before 0.13.0, say) or a file's chain cannot be worked out, nothing is released that run and a note says why.
The cost, stated: a non-root mount needs a user-owned mountpoint, so a
user-owned leaf parent can be shadowed by a mount. ~ can already be, for
the same reason -- the anchor tier never re-owns it -- so that class is a
mount-table question, not a tier question.
| Node | Sealed? | Why |
|---|---|---|
~ |
anchor | every chain below it runs through it |
~/.zshrc and the other startup files |
content | you protect them |
~/.claude |
no | only ~/.claude/settings.json's parent |
~/.claude/settings.json (a symlink) |
content | you protect it |
~/.config, ~/.config/agents, ~/.config/agents/claude |
placement | the chain of ~/.config/agents/claude/settings/settings.json |
~/.config/agents/claude/settings |
no | only that file's parent |
~/.config/ghostty |
content dir | you protect it |
~/Library |
placement | the chain of ~/Library/Application Support/com.mitchellh.ghostty |
~/Library/Application Support |
no | only that directory's parent |
~/Library/LaunchAgents |
content dir | you protect it |
/var/db/locked/ mode 711 root:wheel
├── run.lock mode 600 root:wheel
└── jooize/ mode 750 root:_jooize-lock
├── pool/ mode 750 _jooize-lock:_jooize-lock
│ ├── %2FUsers%2Fjooize%2F.ssh%2Fauthorized_keys.snap mode 600
│ ├── %2FUsers%2Fjooize%2F.ssh%2Fauthorized_keys.attic mode 600
│ └── %2FUsers%2Fjooize%2F.ssh%2Fauthorized_keys.meta mode 640
└── verify mode 640 root:_jooize-lock
The root dir is execute-only (711): you can reach the one dir whose name
you already know, but the user list is not enumerable. Your own dir and
pool are group-readable (750) by _jooize-lock, which you are a member
of, so locked status works without sudo; another user's belong to a
different lock group and the kernel refuses them. In the pool only .meta
follows -- it records identity and lock state, not content -- while
.snap, .attic and .staging keep their 600/700 shapes. Every root
invocation re-applies these modes.
verify is the result of the last verify run, one key=value per line:
at (UTC start time), interval (the timer's period in seconds; a result
older than three intervals is stale), records, drifted (the count),
then one drift=<path> line per drifted path, at most 20. Every byte of a
path outside printable ASCII, and the backslash, is written as \xHH, so
each line is printable ASCII and no file name can add a line of its own.
The file is replaced whole by a rename in a dir only root can write: a
reader always gets one run's complete answer, and nothing running as you
can change or remove it.
run.lock makes root runs take turns. Every root invocation (a
ceremony, the timer's verify, one you start) holds it until it exits, and
a run that finds it held prints whose it is and waits. So verify never
reads a ceremony halfway, two ceremonies never plan against one pool at
once, and runs finish in the order they started. The kernel drops the lock
when its holder exits, however it exits. A ceremony left open at its
editor or its [y/N] gate holds the lock too, so the timer's verify waits
for it; past three intervals the status reads as stale.
Up to 0.14.0 each pool sat at /var/db/locked-snapshots/<user>. The first
root invocation of 0.15.0 moves every pool into <user>/pool by rename and
removes the emptied old root; it refuses to merge a pool found at both.
.snap-- pre-edit baseline, overwritten on each unlock (.snapdirfor content-tier directories)..attic-- discarded post-edit content, overwritten on each revert..meta-- captured identity plus lock state, onekey=valueper line:owner,group,mode(restore targets),lockmode(expected mode while locked),tier,flag,flagsym(exact post-seal flag word),id(volume uuid plus inode; records written before 0.6.0 carrydev.inoand are rewritten in place on the next rootstatus,lockorunlock),recursive,role(leaffor a node you protect,chainfor a directory sealed because one needs it),state(locked/unlocked/retired/suspended).verifycompares reality against this. Retired and suspended records carry provenance --via(rm,trash-finalized,assertionfor a human tombstone,unprotect, orunneededfor a chain directory nothing needed any more),by,at, and for a suspensionbin, the trash destination:verifyflags the original path reappearing (a Put Back of a formerly sealed node) and finalizes the suspension to a retirement once the bin entry is gone.
The owner/group/mode restore targets are captured on the first lock of a node and carried unchanged through every later meta rewrite (v2 rewrites the file on each state transition to track flags and state, but the captured identity persists). What is done with them depends on the tier.
content. unlock, lock and revert chown and chmod the node back to the recorded values. So:
- Accidental
chmod 666 ~/.ssh/authorized_keyswhile unlocked -- next lock restores the captured mode. - An attacker that gets a single chmod through (somehow) is undone the next cycle.
- To intentionally change the captured mode/owner, edit
.metadirectly:sudo -u _jooize-lock vi /var/db/locked/jooize/pool/<encoded>.meta. (Orunprotectit andlockit again: a fresh seal recaptures them.)
placement. unlock clears uappnd and nothing else. The directory stays lock-account owned, with its ACL entry, for the whole window, on purpose: the owner has to be the lock account because uappnd is a user flag its owner could clear, and the entry is the one way you still reach into it. The entry only adds, so even with the flag off you cannot rename or remove what is in the directory -- locked rm and locked mv do that, as root. The recorded owner/group/mode are the pre-adoption identity, not a restore target for an unlock. The next lock of a file whose chain runs through it re-applies the flag, and refuses if the entry is gone. The one place that identity is restored is a release: once no protected file needs the directory it leaves the pool, so it goes all the way back -- flag off, the seal's entry off, owner, group and mode as they were.
anchor. Ownership never changes, in either direction -- that is the tier's whole point. Only the system flag goes on and off.
A placement seal gives the directory to the lock account, because uappnd is a user flag and its owner can clear it -- leaving the directory yours would hand any process running as you the ability to unseal it. That leaves you outside a directory someone else owns, so the seal adds one ACL entry that lets you back in:
user:jooize allow list,add_file,search,add_subdirectory,readattr,readextattr,readsecurity
The rights are what the directory's owner bits gave you before the seal, minus anything that removes or renames an entry (delete, delete_child) or changes the directory itself (writeattr, writeextattr, writesecurity, chown). Only the owner or root can change an ACL, so you cannot strip or widen the entry, and it carries no inherit flags. The directory's group and mode stay what they were, and so do any ACL entries it already had: ~/Library ships with Apple's group:everyone deny delete. The record keeps the entry, verify checks that it is there exactly, and a release takes off that entry and no other. ls -le <dir> shows it.
What you create there is ordinary. On macOS, as on every BSD, a new file or directory takes its group from the directory it is created in, and the directory's group is still yours (usually staff). Nothing inherits the entry.
Why not a group. Before 0.14.0 the way in was the lock group: the directory went to _<user>-lock:_<user>-lock with group rwx. By that same inheritance, everything created under a seal took the lock group, and so did everything created under that. On one Mac's ~/Library/Application Support, the 88 entries from before the seal were staff and every entry after it _jooize-lock. A sweep reset them, and they came back. The ACL entry ends that by construction. What it costs: ls -l shows a + on the directory, and a tool that copies the directory together with its ACL copies the entry too.
Two things that follow. locked trash of an entry directly inside a placement directory is refused, because the trash service moves the entry as you and the entry does not let you remove anything; locked rm works, and so does locked mv to somewhere you can trash from. And test -w on the directory still says yes, so apps that check before writing see no difference.
A directory that already carries an ACL entry naming you is refused as a placement candidate: chmod +a would merge the seal's entry into yours, and no release could then tell them apart. Remove or reshape your entry first.
locked is Darwin-only; the ACL syntax above is macOS's.
This section collects the sharp edges of the how-to; what each verb does and does not guarantee is written out in DESIGN-v2.md.
- A caller without a tty -- an editor's or an agent's embedded shell, a
script -- cannot answer the confirm gate:
lockedrefuses unless--yesis given, and--yesskips the gate, so the diff is approved unseen. Runlockedfrom a real terminal when you want the gate. - First lock assumes file is currently owned by you. To bring a file owned by someone else into the pool:
sudo chown -h <you>:staff <file>first, thensudo locked lock <file>captures meta and locks. Or hand-write the meta file before unlocking once. - Other admins on the same machine can read snapshots via
sudo(root reads all). Only encryption fixes that; not in scope here. - Records written before 0.6.0 identify their node by
dev.ino. They still match (the inode is what proves the node), and the next rootstatus,lockorunlockrewrites the field to the volume-uuid form with aninfoline. An unprivilegedstatussays so and names the command that does it. readlink -frequires macOS 12+. On older macOS, replace withrealpathor a Python one-liner.- Extended attributes and ACLs travel on file snapshots; directory trees still lose them. File staging copies go through the helper with
COPYFILE_XATTR|COPYFILE_ACL, so quarantine and custom-ACL metadata survive a snapshot/revert cycle. Content-tier directories (.snapdir) still copy withcp -Rp, which keeps the old limitation per file inside the tree -- search forTODO(xattrs)atsnapshot_tree. BSD flags never travel by design (meta records the exact word; sealing re-applies it). locked trashis gated by macOS privacy (TCC) through the app that invoked it. The trash service checks the responsible app's Files-and-Folders grant even undersudo; from an unblessed automation context it fails cleanly (afpAccessDenied) with the window restored, from your own granted terminal it works. Nothing to configure inlocked-- grant the terminal, or userm.- Default install path is
/usr/local/sbin/locked. Why not/usr/libexec/(the natural spot for system helpers):/usr/libexecis on the sealed system volume since macOS 11; even root can't write there without booting to recovery and tearing down SSV. Why not/usr/local/bin/: same writable space but with the legacy Intel-Homebrew "chown -R $USER /usr/local" footgun more common there. Why/usr/local/sbin/: less commonly tampered, semantically right for "needs sudo" admin tools, and in PATH by default. - Setup verifies the entire ancestry up to
/is root-owned with no group/other write -- mode755or stricter -- and refuses to install otherwise. The same verification runs on every subsequent invocation -- if/usr/localever gets chowned to a user after setup (e.g., a later Homebrew permission-fix recipe), the nextsudo locked ...refuses with a clear error. This closes the "post-setup escalation" wedge: a swapped binary at a writable ancestor would otherwise inherit root after the next Touch ID approval. - Override default install path via
INSTALL_TARGET=/path sudo -E ./locked setup. If you go this route, you'll need to re-run setup any time you want to change the path -- the sudoers entry references the path, not a binary identity.
Snapshot/attic/restore writes go through atomic_replace:
- The helper copies the source into
/var/db/locked/<user>/pool/.staging/replace.XXXXXX(lock-account-owned, mode 700 -- invisible to user UID) from a file descriptor it openedO_NOFOLLOWand identity-checked against the volume uuid and inodelockedexpected -- a source swapped between resolution and open is refused, not copied. chownandchmodthe staged temp file.- Verify staging and destination are on the same filesystem (
stat -f '%d'). mv -f-- atomic same-filesystem rename.
This eliminates partial-write windows on the destination side, closes the old source-side swap (TODO(toctou), now retired for files), and keeps the temp file out of user-visible space throughout. The destination's parent is covered by the chain itself: every user-writable ancestor of a locked leaf is sealed, and the mediated window holds parents by fd, which is what retired TODO(parent-swap).
Honest residuals:
- Directory-tree snapshots (
snapshot_tree) still copy by path withcp -Rp; the per-file guarantees above do not apply inside a content-tier directory. - Inside a mediated window there is a nanosecond gap between the helper's identity check of the named entry and the syscall on its name (macOS has no unlink-by-fd). The cross-window entry diff catches every shape that leaves the parent changed; a race that wins that gap and has somewhere unsealed to hide the original is the remaining theoretical escape.
Every action emits a unified-logging entry tagged locked. View recent activity:
log show --predicate 'eventMessage CONTAINS "locked: user="' --last 1hUseful for spotting unexpected lock/unlock cycles. The log records user=, action=, file=, and result= (ok / skip-* / error) for each per-file operation.
To delete the file itself, use the mediated verb -- it does the removal through the window and retires the record in one step:
sudo locked rm <file> # or: sudo locked trash <file>To keep the file but stop protecting it:
sudo locked unprotect <file>It goes back to the owner, group and mode it had before the first lock,
its record is retired, and directories nothing else needs are released
with it.
If something else already deleted the file and verify is flagging it:
sudo locked tombstone <file> records it as gone, with you as the
asserter.