Safety Model
The three-class decision
policy::classify() (src/policy/engine.rs) is the single deterministic
module that decides AUTO_SAFE / ASK / PROTECTED for a resource. It is
pure: no I/O, no ambient clock (now is always a parameter) — which is what
makes fail-closed behavior testable and lets the executor call the exact
same function again on freshly re-collected evidence at deletion time.
#![allow(unused)]
fn main() {
pub enum PolicyClass {
AutoSafe,
Ask,
Protected,
}
}
There is no separate “Unknown” fourth outcome. Missing, stale, or failed
evidence maps inside classify to Ask or Protected with a specific
reason code — it never becomes a state a caller might misread as “not
Protected, therefore fine.”
classify()’s fail-closed ordering
classify checks conditions in this exact order, and the order is itself
part of the safety guarantee:
- Unknown resource kind → unconditionally
Protected. Never falls through to any evidence-based judgment. - Path/pattern-based protected check (see below) →
Protected, checked before any freshness/completeness logic. AProtectedclassification never depends on evidence freshness — a stale-but-protected resource is stillProtected, never “upgraded” by fresher evidence. - Staleness — evidence older than
PolicyConfig::max_evidence_age(default 5 minutes), or whosecollected_atis somehow in the future →Ask+EvidenceStale. - Completeness —
Failed→Ask+EvidenceProbeFailed;Partial→Ask+EvidenceIncomplete. - Active-use signals (most-significant first) — an open file handle or
matching process cwd →
ResourceInActiveUse; a dirty or linked git worktree →GitWorktreeDirty; a live owning-tool daemon →OwningToolLive. Any of these →Ask. - Per-instance regenerability —
NotRegenerable→Ask+RebuildCostHigh, regardless of how clean the rest of the evidence looks. - Otherwise →
AutoSafe, with reasonsEvidenceFreshAndComplete(+RegenerableByToolif applicable) +NoActiveUseObserved.
The PROTECTED matcher
policy::protected::protected_reason() is an explicitly conservative,
MVP-scope, non-exhaustive denylist, not a claim of completeness. It
performs no filesystem I/O (no canonicalize, no read_link, no
metadata) — it only inspects the path/locator text already carried by the
resource, which is what keeps classify pure. What it actually matches
today:
- Docker image cache — every
DockerImageCacheresource is treated as a possible persistent volume and classifiedProtectedunconditionally, because detectors do not yet distinguish a persistent volume from disposable image cache. (DockerBuildCacheis not covered by this rule.) - Credential material — any path component
.sshor.gnupg, or a filename ending.pem/.key, or containingcredentials. - Git internals — any path with a
.gitpath component (not merely “inside a git-tracked project,” which is the separate, evidence-drivenGitWorktreeDirtyreason). - Infra state — a
.terraformpath component, or a filename ending.tfstate/.tfstate.backup. - System paths —
/System,/usr(except/usr/local),/bin,/sbin,/private/var/db. - Unsafe mount/symlink targets —
/Volumes,/dev,/Network,/net.
This is a starting denylist to extend, not a guarantee that every dangerous path is covered.
ASK and consent
policy::approval::authorize() is the only way to construct an Approval
— the type an executor is required to hold before acting. It is built with a
private, unconstructible-outside-the-module marker type, so nothing outside
approval.rs can fabricate one.
AutoSafe→ always authorizes, no consent needed.Protected→ never authorizes, unconditionally, with no override parameter that can change that.Ask→ authorizes only if a matchingUserConsentis supplied: the consent’s resource identity and fingerprint must match exactly. Consent granted for one resource instance does not carry over to a different instance at the same path, or to the same resource after its underlying fingerprint has changed.
There is no TTY prompt, but consent is no longer unreachable. Three
surfaces supply a UserConsent today, and none of them is a prompt:
glomeris execute --confirm-ask --observed-fingerprint <token>— you pass back the exactfingerprint_tokena priorexplain --jsonprinted on that same resource. Mismatched or missing, it is a usage error rather than a silent approval.- The menu-bar app’s Clean button, which builds precisely those two flags
(
CandidateDetailView.buildExecuteArguments) and nothing else. The GUI is the confirmation step; the consent still travels as a fingerprint. glomeris autopilot enable --preauthorize-ask <kind>:<reason>— narrow advance consent for oneAskreason on one resource kind, recorded in the envelope. It is a flag onenable, not onrun: the consent is written down before the run andautopilot runrejects the flag outright, so the run cannot grant itself anything the stored envelope does not already say. See Autopilot, and the limitation on that path in Known Limitations.
What has not changed is glomeris free’s recovery loop: it wires
auto_approve_ask: false (src/main.rs), so inside that loop Ask
candidates are still reported as declined/skipped and never executed. That is
the loop’s own choice, not an absence of machinery.
Deletion-time TOCTOU revalidation
executor::execute() never trusts a previously computed PolicyDecision at
face value. Immediately before mutating anything, it:
- Snapshots the resource’s live filesystem identity via
symlink_metadata(nevermetadata, so a symlink is detected as a symlink, never resolved through) — captured before the slower correlation re-probe runs, so a symlink swap during that window can’t backdate the anchor. - Re-collects evidence from scratch (re-probes size/mtime/fingerprint
directly, re-runs the same
EvidenceCollector). - Aborts (
ResourceIdentityChanged) if the fresh fingerprint doesn’t match the one the approval was granted against. - Re-runs
classify()on the fresh evidence. - Aborts (
PolicyClassDowngraded) if the class no longer matches what was approved. - Aborts (
PolicyReasonsWidened) if any new reason appears that wasn’t present at approval time —Askis a heterogeneous bucket, so consent granted againstRebuildCostHighdoes not cover a freshly observedResourceInActiveUse. - Aborts (
EvidenceDegraded) if completeness got worse since approval — a defensive, forward-looking guard. - Only then builds the actual plan from the same fresh evidence just
validated, and runs it. Immediately before any
RunTool/DeletePathstep actually mutates the filesystem, the resource’s identity is re-verified one more time against the early snapshot from step 1.
A plan with more than one step is refused outright rather than executed, because partial-deletion byte accounting has no way to report a correct total if a later step fails after an earlier one already succeeded. No registered action emits more than one step today.
AUTO_SAFE is end-to-end reachable through real execution
Detectors populate reclaimable_bytes at discovery time (HORO-992), and the
deletion-time revalidation path (executor::build_fresh_evidence) reuses
the same bounded recursive size estimate (HORO-1016) for reclaimable_bytes
that it already used for logical_bytes (HORO-994) — it no longer hardcodes
the field back to Unavailable. A real AutoSafe approval built from a detector’s
evidence genuinely survives revalidation and executes for real, proven by
tests/golden_chain_execute.rs. See Known Limitations
for what’s still out of scope (Docker build cache’s own completeness gap).
Actions never receive raw commands
Every registered Action produces a typed ActionPlan/ActionStep from
Evidence — never a caller-supplied string. ActionStep::RunTool invokes
one of a closed set of binaries (ToolBinary::{Brew,Cargo,Npm,Pnpm,Yarn}) via
argument-array Command::new(tool).args(args) calls — never a shell string.
ActionPlan/ActionStep deliberately never derive Deserialize, so no
external input (network payload, LLM text) can ever materialize one directly;
the only way external input reaches execution is by selecting a
pre-registered ActionId, whose real Action::plan implementation then
decides the actual steps from Evidence it independently trusts. See
BYOK LLM Planner for how this applies to the optional LLM path
specifically.
An envelope narrows this model; it never widens it
glomeris autopilot adds the one thing the rest of this page does not
describe: a grant that outlives the moment you typed it. It changes nothing
above. An Autopilot candidate is classified by the same classify(),
authorized by the same policy::approval::authorize, and revalidated by the
same deletion-time TOCTOU check, in that order.
What the envelope adds is a filter in front of authorization, not an
alternative to it. Its allowlist can only remove kinds from consideration;
PROTECTED is refused whatever it says; UNKNOWN_INCOMPLETE is refused
whatever it says; an ASK reason it has not been given by name is refused. The
budgets — actions, bytes, wall clock, and an optional disk-pressure floor —
bound a run’s total effect, so the worst case of an unattended run is a number
you read before you granted it.
The clause this adds to the invariant is the last one: AI can recommend. Policy decides. Executor verifies. Filesystem reality wins — and the envelope bounds the outcome. See Autopilot.