Safety
What gripsack guarantees, precisely — separated by surface, because
"safe" is not one property. Everything on this page is enforced by
test: the transaction invariants are also machine-checked (the
journal protocol as an exhaustive state-machine model plus a
TLC-checked TLA+ spec — plan 0028),
and the decision kernels they lean on — commit classification,
ownership authority, GC retention, the merge splice, build closures,
and the scheduler's readiness/release/latch decisions — are proved by
Verus, one implementation serving production and the verifier. The
per-guarantee ledger with admission boundaries, trusted components
and calibrations:
verification/guarantees.md.
If anything on this page ever disagrees with the binary in your hand, that is a release-blocking bug — please file it.
The guarantee table
| Surface | Guarantee |
|---|---|
| Store object publication | atomic final-name rename; payloads land read-only |
| Generation selection | one atomic current flip (the link is validated to resolve under $GRIPSACK_HOME/generations/) |
| Generation contents | immutable once published (manifest + profile stage and rename in together); IDs are never reused, even across gc |
| Destination deploy / prune / rollback | journaled, postcondition-verified, crash-recovered; ownership lineage (origin survival, no drift |
| promotion) holds the same machine-checked standard since 0.25 | |
| Failed apply or rollback | compensation is attempted immediately; if the filesystem prevents it, the durable journal blocks further mutation until recovery completes |
| Tracked-copy drift | detected and preserved by default, in apply AND rollback |
| External package-manager effects (brew/pixi/apt) | adapter-dependent, best effort — never auto-rolled-back |
| Activation intents (caches, services, custom hooks) | durable intent before the flip; ambiguous interrupted delivery may run again with the same identity, so hooks must be idempotent; durable terminal outcomes are not automatically retried; post-activation failure never rolls back. Explicit rollback is a new activation and may execute the retained generation's hooks |
Arbitrary run steps |
not automatically reversible (plan output says so) |
Journaled transitions
Every mutation of a managed destination — deploy, prune-on-undeclare, rollback — records its prior state AND its intended end state, durably, before the mutation. After the mutation, the destination is re-read through the same pinned directory handle and must equal the intent, or the run fails and compensation restores the prior. The generation flip is the single commit point; nothing commits unverified.
Recovery is exact-equality: a run is committed iff current equals
its target generation, uncommitted iff it equals the previous one —
anything else is corruption and blocks, with the journal retained.
What recovery restores exactly
- File bytes — from the content-addressed prior store (deduped, write-once).
- Unix mode — the recorded mode rides the restore (a 0600 secret replaced by a symlink mid-run comes back 0600, not umask-default).
- Symlink targets — verbatim. A non-UTF-8 target cannot be journaled today: gripsack refuses the mutation loudly instead of recording a link it could not restore. (Byte-exact preservation is on the roadmap.)
- Nothing else — mtimes, ownership, xattrs are outside the model by design.
A crash between a mutation and its commit, followed by your edit, keeps your edit. The drift guard reads three ways: landed-intact → restore prior, never-landed → nothing to do, anything else → yours.
The GC safety model
grip gc never removes the current generation or anything any
retained generation references (store paths AND prior blobs). It
fails closed: an unreadable generations inventory, a corrupt manifest,
or a current generation missing from disk all abort before the first
deletion. Uncertainty means do nothing. --dry-run previews the exact
deletion plan.
Corrupt persisted state
A generation is validated when read, not trusted because it parses:
embedded number must equal its directory, destinations must be unique
(case-folded), hashes well-formed, store paths confined to
$GRIPSACK_HOME/store. A current generation whose manifest is
unreadable blocks every mutating command. Corrupt journal entries
quarantine into journal/quarantine/ and block mutation until
inspected — recovery metadata is never silently discarded.
Plugin and tool trust
gripfetch-* plugins and provisioned tools (deno, pixi) are trusted
code running with your privileges. Hash verification protects store
contents — every fetched byte is checked against the lockfile before
it enters the store — it does not make a malicious plugin harmless.
The credential boundary is eval: TypeScript module evaluation runs
sandboxed (no env, no network, no subprocesses) and sees no
credentials; fetching necessarily can. The lockfile is the sole
source of pinning; a tampered pin fails the hash check at apply.
Unattended approval
Approval binds the exact captured source bundle and policy — the digests, not the directory. A fresh ephemeral machine has no TTY and no interactive history, and that is fine: the decision moves out-of-band, the machine only compares digests.
The workflow:
- Keep the reviewed source immutable — a commit, or an archive you reviewed and kept. Review the inventory it captures: the generated lockfile, captured untracked files, capture exclusions.
- Record the expected digests out-of-band — the
.bundle_digestand.policy_digestof exactly what you reviewed. - On the machine, compare before approving, using the recipe below.
- Stop on mismatch. A differing digest means the captured source
changed since review — re-review the changed inventory, then
approve the exact new digests. Never auto-approve whatever
inspectreturns.
set -eu
: "${expected_bundle:?load the separately reviewed bundle digest}"
: "${expected_policy:?load the separately reviewed policy digest}"
source=$(grip trust inspect --json)
[ "$(printf '%s' "$source" | jq -er .bundle_digest)" = "$expected_bundle" ] &&
[ "$(printf '%s' "$source" | jq -er .policy_digest)" = "$expected_policy" ] ||
{ echo "captured source differs from review — NOT approving" >&2; exit 1; }
grip trust add --bundle "$expected_bundle" --policy "$expected_policy"
grip check
The shell recipe requires jq; the two expected values must arrive
through your reviewed deployment configuration, not assignments
from the inspect command in this script. Keep that configuration
outside the captured checkout. Compare the policy as well as the
bundle: frontend/runtime identity and capture policy are part of the
approval decision. A reviewed source edit permits evaluation; it
does not waive the separate frozen-input check. For example, after
reviewing a changed Pixi manifest or lock, grip check still refuses
its stale imported identity until an explicit grip update, review
of the resulting gripsack.lock, and renewed exact-digest approval.
Measured with the promoted 0.45.0 binary: a separately recorded
reviewed pair matched and allowed check; byte changes to
gripsack.lock, pixi.lock, or [capture].exclude stopped before
trust add or evaluation. Trust records and evaluation receipts
remained byte-identical on each mismatch. A wrong expected policy
also stopped even when the expected bundle still matched. These are
fixture-level observations, not blanket fleet-deployment
qualification.
Boundaries that hold everywhere:
- Never
GRIPSACK_TRUST_ALL—=1is refused, not an approval workflow. - No repo-wide or floating approval exists. One approval at the repo root covers nested directories, but changed captured content is a new approval decision — including a lockfile write between updates.
- Inspection and approval themselves evaluate nothing; a change between inspect and approve fails approval rather than blessing newer bytes.
This is still a human decision per content change — "unattended" means no prompt, not unreviewed.
The destination boundary
gripsack enforces one hard boundary: nothing may deploy INTO the env
repo checkout. Destinations are otherwise your paths at your
privileges — absolute paths outside $HOME (e.g. /usr/local/bin)
are allowed and in real use. The residual race we know about: there is
no portable atomic content-compare-and-swap (renameat2
RENAME_EXCHANGE is Linux-only), so between a drift decision and the
write there is a microsecond window — every journaled mutation
re-validates the live object at that boundary and aborts retryably on
mismatch rather than clobbering it.
Putting a launcher in /usr/local/bin does not make its private
package store a multi-user installation. The documented
environment launchers
run as the same UID that owns and deploys the environment. Do not
make all of GRIPSACK_HOME world-readable or writable to let another
UID reach it: trust records, journals and retained generations remain
private authoritative state.
Recorded destination ownership does not migrate merely because a new
workspace wants the same path. --take-over does not transfer that
recorded ownership across identities. Follow the
separately applied legacy prune
before switching entrypoints; review and approve each source/lock
change, and allow for the deliberate interval without the pruned
deployment. Do not edit journals or delete managed files to force it.
Explicit host-runtime dependence
A Linux GNU package may explicitly declare
hostRuntime: { libraryDirectories: ["/opt/bb/lib64"] } in pkg(...).
This is a package-scoped trust decision, not a global filesystem
grant and not permission for evaluator code to read those directories.
A package closure with no such declaration retains the closed default,
including refusal of an undeclared absolute ELF RUNPATH.
Review both the package bytes and who controls the named host
directories. Roots compose only over the selected package's explicit
runtime-package dependency closure, in deterministic traversal and
declaration order. For example, a retained dependency library may use
its own declared host root; an unrelated package selected for the
same invocation cannot lend its policy to this closure. Directories
must be existing normalized absolute paths; canonical duplicate aliases
are refused. A dependency symlink may not escape its declared root.
Transitive ELF dependencies, ABI and search paths still undergo
admission. The existing narrow glibc SONAME allowance remains separate;
arbitrary ld.so.cache, PATH and ambient LD_* fallback are not
enabled. This is not an unsealed-execution switch or a substitute for
the complete Conda closure.
The fetched artifact's identity and the package's runtime-policy identity remain distinct. Host libraries are host prerequisites, not retained portable artifact content: declaring them does not copy them into an OCI closure or authorize mounting them into BuildKit. Packages with this policy are refused for portable image placement.
Persistent environment/profile wrappers for a package closure using
hostRuntime route through the core's managed runner. Each invocation
validates the retained package/descendant receipts and repeats native
admission against the current host libraries before sealed execution;
it does not persist a raw loader command as permanent authority.
Task/check execution and retained hook replay also repeat admission.
This does not make mutable host libraries immutable.
These wrappers retain the absolute core executable location and
the original private state path. Keep both installed and available.
Moving or removing the core or state makes the wrapper fail closed.
Restore the original stable core installation path, or reapply using
the core at its new installed location to recreate the projection.
Then use a fresh shell or explicitly source the managed profile again:
an existing shell's PATH can still select the old generation's
wrappers. Candidate0.46 qualification exercised this recovery on UBI8
glibc2.28 under a WSL2 kernel, including stdin and exit-status preservation.
Policy-free existing wrappers are unchanged.
For the separate guarantee about temporary storage and cross-device store publication, see temporary files.
What gripsack is not
gripsack is alpha software with a journaled transaction core, not a backup system. Keep an independent backup of irreplaceable configuration. The store is a cache of fetchable payloads plus prior blobs — it is not the only copy of anything you cannot re-derive.
Recommended today: dogfood freely, read grip plan before applying,
keep the backup. Not yet: sole recovery boundary for an irreplaceable
home directory, unattended fleet deployment.