# delete-guard policy reference (V1) The shared Core supports POSIX/cmd/PowerShell command facts. Full suites run on Linux/macOS; focused Windows Core evidence is available, while complete native Windows harness execution remains unverified. POSIX hook bridges have their own platform requirements. See the [compatibility contract](../../../docs/design/compatibility.md). This reference covers the 0.2.5-rc3 source candidate, retaining RC1 maintenance and shared audit metadata projection. The published 0.2.4 baseline lacks the RC's control-path, GC and audit-writer maintenance. ## Decision Protocol The stable cross-harness interface is Decision + ReasonCode + Explanation (+ RecoveryPlan in payload). The deletion path uses the five classes below; the shared text policy also supports `SANITIZE`. | Decision | Meaning | Interaction tier | |---|---|---| | `ALLOW` | run unchanged (noop / provably regenerable / trash GC) | SAFE - silent | | `RELOCATE` | quarantine targets, then run | SAFE - silent, txid audited | | `SNAPSHOT` | git snapshot first, then run | SAFE - silent, txid audited | | `ASK` | single-execution authorization (ASK_ONCE, never a rule exemption) | AMBIGUOUS | | `BLOCK` | refuse: policy violation or true effect-uncertainty | FORBIDDEN | ASK is reserved for well-understood operations whose safe *automation* is unavailable (`COMPOUND_CWD_DELETE`, `COMPOUND_CREATE_DELETE`). True effect-uncertainty (`BLOCK_UNDETERMINABLE_EFFECT`: `$VAR`, `bash -c`, `find -delete`, `xargs`-fed lists) stays on the BLOCK path - allowing it would forfeit the core guarantee. Hard boundaries (`BLOCK_PROTECTED_PATH`, `BLOCK_OUT_OF_WORKSPACE`, `BLOCK_FORCE_PUSH`, RESTRICTED prohibitions) are policy violations and never askable. check.py exit codes: 0 proceed/advisory-ok · 2 blocked · 3 ask · 1 error. ### Assessment and dry-run side effects Advisory `check.py` and `safe_delete.py --dry-run` keep command targets unchanged. They do not execute the assessed shell text, relocate targets, create Git stashes or create recovery transactions. They may still write metadata through their existing best-effort audit path: | Invocation | Exit / target behavior | Possible metadata writes | |---|---|---| | Advisory `check.py`, including ALLOW, ASK or BLOCK | Exit 0 with the actual decision; targets unchanged | Layout initialization and an appended `check` audit | | `safe_delete.py --dry-run` with concrete targets, including absent literals | Exit 0 unless BLOCK; targets unchanged | An advisory audit, or a blocked-decision audit | | Policy-blocked `safe_delete.py` before compensation, including dry-run | Exit 2; targets unchanged | Best-effort blocked-decision audit | | Unmatched-glob-only `safe_delete.py` | Exit 0 before layout/audit setup | None from that invocation | | Non-dry-run `safe_delete.py` with an absent literal / ALLOW_NOOP | Exit 0 without mutation or layout setup | None from that invocation | Layout initialization may create the selected quarantine directory and `audit.jsonl`. If a quarantine inside a Git workspace is not already ignored, it may append an ignore entry to Git's local `info/exclude`, including the resolved exclude path of a linked worktree. It does not edit tracked `.gitignore`. A preexisting ignore rule or an external bucket avoids that exclude write. Repeated checks append audit lines without duplicating the exclude rule; recovery manifests and authorization state are not created by these assessment paths. Git status may hide metadata that was previously untracked, so an unchanged Git status alone does not prove zero writes. If audit/layout setup fails, assessments retain their decision and report an audit warning; partial metadata changes may remain. `ensure_layout` establishes the Git ignore rule before creating the bucket. Audit failure after that step does not roll it back. This best-effort behavior is distinct from enforced execution/ASK intents, which must be durable or execution is refused. Advisory exit 0 must never be treated as authorization to run a BLOCK or ASK command. Failed real compensations can leave partial target changes and recovery IDs; this table does not promise rollback for them. No general zero-write assessment flag is introduced. ### Dialects `check.py` lexes the command line for a shell dialect: `--dialect posix` (default) `| cmd | powershell`, or `AGENT_GUARD_DIALECT`. Native adapters forward it from the payload, tool arguments, configuration, or environment. An unusable selector is a BLOCK, never a silent POSIX fallback: | Condition | Verdict code | Effect | |---|---|---| | dialect name not recognised (e.g. `fish`) | `BLOCK_DIALECT_UNKNOWN` | refuse; fix the selector | | dialect selector malformed (e.g. `posix:cmd`) | `BLOCK_DIALECT_INVALID` | refuse; fix the selector | Both are configuration defects rather than one-off authorization, so they BLOCK instead of ASKing: the selector would fail again on the next command. The command is never classified in that case, which is why allowing it is not an option. Under PowerShell, unambiguous parameter prefixes (`-r`, `-rec`, `-fo`) are expanded to their full names; a prefix matching two different effects (`-wi`, `-c`, `-p`) stays unknown and BLOCKs. ## Rule table (first match wins) The quarantine control-path changes below were introduced in 0.2.5-rc1; the published 0.2.4 artifact retains its original housekeeping order. | # | Condition | Verdict code | Effect | |---|---|---|---| | 1 | dry run / no targets | `ALLOW_NOOP` | proceed unchanged | | 2 | unresolvable targets: shell vars, command substitution, unbalanced quotes, `bash -c` with destructive smell, `find -delete`, `find -exec rm`, stdin-fed (`xargs`) | `BLOCK_UNDETERMINABLE_EFFECT` | refuse - allowing it would forfeit the core guarantee | | 2a | SHAPE F1: destructive op preceded by `cd` in the same command line, target in-workspace and resolvable | `ASK` (`COMPOUND_CWD_DELETE`) | single-execution authorization; splitting the command avoids the prompt. A shape rule describes *compensation* difficulty, never effect scope: if the target set also trips rule 2, 4, 5 or 6, that BLOCK wins (`tests/test_incident_regression.py`) | | 2b | SHAPE F2: file-creation op (`touch/mkdir/cp/mv/install/ln/tee`, `>`/`>>`) precedes a target-dependent destructive op in the same line | `ASK` (`COMPOUND_CREATE_DELETE`) | single-execution authorization; force-push is refused separately. Hard reset in a compound line is effect-uncertain and BLOCKED before shape rules | | 2c | target *is* a filesystem root (`/`, `/home`, `/usr`, `/etc`, `/var`, `/tmp`, `/opt`, `/boot`, `$HOME`, ...) | `BLOCK_PROTECTED_ANCESTOR` | refuse. Matched exactly, so paths *under* these roots keep the ordinary codes; enforced by identity rather than workspace geometry, so it holds even when the workspace is `/` | | 5 | workspace root, `.git` metadata, quarantine root/control paths, or an in-workspace parent containing configured storage | `BLOCK_PROTECTED_PATH` | refuse before the housekeeping exemption; root manifest/audit/state, state temp and sessions are control paths | | 3 | explicit ordinary unprotected payload inside quarantine (`.agent-trash/`) | `ALLOW_TRASH_GC` | opaque globs BLOCK_WILDCARD; safe_delete may enumerate first, and an audited transaction purge uses gc.py --execute | | 4 | target outside workspace | `BLOCK_OUT_OF_WORKSPACE` | refuse | | 6 | any glob target | `BLOCK_WILDCARD` | refuse; use safe_delete which enumerates | | 7 | RESTRICTED mode, git/remote destructive op | `BLOCK_RESTRICTED_MODE` | refuse | | 8 | RESTRICTED mode, fs delete | narrow files only -> `RELOCATE_NARROW`, else `BLOCK_RESTRICTED_MODE` | refuse or quarantine | | 9 | force/mirror/ref-deletion push | `BLOCK_FORCE_PUSH` | refuse | | 10 | `git clean` dry run | `ALLOW_NOOP` | proceed | | 11 | supported `git clean -f...` | `RELOCATE_VIA_CLEAN_ENUMERATE` | enumerate via `clean -n`, decode every Git-quoted path, relocate every match, proceed only on full coverage | | 11a | `git clean -ff`, interactive `-i`, or exclude `-e` | `BLOCK_UNDETERMINABLE_EFFECT` | refuse; nested-repository and interactive/exclusion semantics are not safely mirrored | | 11b | supported `git clean -f...` whose target set cannot be enumerated (not a repository, `git` cannot run) | `BLOCK_UNDETERMINABLE_EFFECT` | refuse; the targets exist but their set is unknowable, so nothing was attempted and nothing may be reported as a failed compensation | | 12 | supported `git reset --hard [commit]` without untracked/ignored collisions, `git restore `, `git checkout -- ` | `SNAPSHOT_GIT_STASH` | hard reset first checks its target tree; `git stash create`+`store` then protects tracked modifications; any create/store failure blocks | | 12a | hard reset target overlaps untracked/ignored content or quarantine storage | `BLOCK_GIT_RESET_COLLISION` | refuse before compensation; preserve conflicting paths separately before retrying | | 12b | hard reset target cannot be resolved/enumerated, has unsupported options or submodule effects, is stash-relative, or uses shell wrappers/compound commands | `BLOCK_UNDETERMINABLE_EFFECT` | refuse before compensation; use a standalone reset with an explicit stable commit | | 13 | all targets git-ignored AND match artifact patterns | `ALLOW_REGENERABLE` | direct delete | | 14 | rooted recursive delete | `RELOCATE_TREE` | quarantine whole tree, proceed | | 15 | named files/dirs | `RELOCATE_PATHS` | quarantine, proceed | Precedence inside rule groups: `outside-workspace` > `workspace-root` > `git-metadata`. RESTRICTED narrows before NORMAL relaxes. ## Verdict actions - `ALLOW` - run unchanged. - `RELOCATE` - move targets to quarantine, then run (the run itself then finds nothing - effect preserved, loss reversed). - `SNAPSHOT` - durably store a Git snapshot, then run. - `BLOCK` - refuse; nothing is executed. ## Modes ``` NORMAL ──(human veto / high-risk event)──> RESTRICTED RESTRICTED ──(interactive human terminal ONLY)──> NORMAL ``` State: `.agent-trash/state.json`. Non-interactive promotion raises `AuthorizationRequired`. Harness adapters SHOULD keep the authoritative mode in host memory instead (agent shells cannot touch it) and treat the file as a portable fallback. ## Configuration (environment) | Variable | Meaning | |---|---| | `AGENT_GUARD_WORKSPACE` | override workspace boundary discovery | | `AGENT_GUARD_TRASH` | override quarantine location | | `AGENT_GUARD_SESSION` | selects session authorization state; RC2 audit metadata uses opaque correlation | | `AGENT_GUARD_ARTIFACTS` | `os.pathsep`-joined extra artifact patterns | | `AGENT_GUARD_ALLOW_REGENERABLE` | `0` disables direct deletion of artifacts | Default artifact patterns: `node_modules dist build out target __pycache__ .cache coverage .next .nuxt .venv venv .pytest_cache .mypy_cache .tox .turbo .parcel-cache *.pyc *.pyo *.egg-info` ## Files ``` .agent-trash/ ├── state.json # authorization mode (portable fallback) ├── manifest.jsonl # tx-start / relocate-intent / relocate / snapshot / restore └── audit.jsonl # decisions, compensation intents, and outcomes ``` The guard accepts an existing `.gitignore` rule or writes a workspace-local `.git/info/exclude` rule (the user's `.gitignore` is never modified). Relocations use a durable write-ahead intent; an intent whose move completed but whose completion record failed remains discoverable and restorable. Retention/GC is intentionally manual in V1; `restore.py list` distinguishes `RESTORABLE`, `RESTORED`, `FAILED`, and `PURGED` transaction state, while `status.py` reports both historical and currently restorable counts. ## Retention / GC (B4) Soft policy: 30-day retention, 5 GiB cap. Thresholds only MARK transactions `GC_ELIGIBLE` (oldest first under the cap); purging is an explicit `gc.py --execute` maintenance action. Lifecycle is fully audited: `QUARANTINED -> RESTORABLE -> GC_ELIGIBLE -> PURGED`; the audit log itself is never garbage-collected. Local maintenance excludes `PURGED` tombstones from active GC inventory and age/capacity plans. History remains in the manifest and transaction listing; a directory recreated under a purged ID is unmanaged. Failed purges retain their prior state and can still be planned. This does not add stash pruning. Restore audit metadata now records `restored_count`, `conflict_count` and `error_count` instead of raw path arrays/error bodies. Known `txid`, `force`, `ok` and preservation `backup_txids` retain correlation; invalid/unknown input IDs use `txid_sha256`. Exact restoration paths and errors remain in the recovery journal and CLI result. Historical audit lines are not rewritten. At released RC1, automatic session/generic handling is unchanged; the RC2 [shared projection](../../../docs/guides/audit-metadata.md) extends it. ### safe_delete audit metadata New `safe_delete` audit events keep `event`, `tool`, `decision`, `code` and `target_count`, plus `phase`/static `outcome` where applicable. Established relocation reports add `txid`, `moved_count` and `skipped_count`, including partial relocation and storage-failure decisions. `target_count` counts classified entries after glob expansion, including absent literals and unresolved/rejected entries in a BLOCK assessment; it is not a count of existing or deletable files. Unmatched patterns are excluded; duplicate input entries remain separate entries. `skipped_count` counts the engine's skipped entries, not every target left unmoved after a storage failure. Target arrays, policy reason bodies, free-form `--reason` and moved/skipped payloads are not copied into these events. `--reason` remains accepted but its text is no longer persisted in the audit or `tx-start.meta`. Exact origin/trash paths, write-ahead relocation records and failure diagnostics stay in the recovery manifest; CLI target/recovery paths and errors stay accurate. Those surfaces can still contain sensitive text. Mutation intents remain durable before deletion; assessment and outcome audits retain their existing best-effort behavior. Historical audit lines and manifest metadata are not rewritten. At released RC1, automatic session and generic append/tail handling is unchanged. The [shared projection](../../../docs/guides/audit-metadata.md) introduced in RC2 limits these fields and legacy reads, while raw historical files and recovery surfaces remain. Authority model: `gc.py --execute` is intentionally permitted non-interactively (scheduled maintenance is legitimate; purging already-quarantined evidence is lower-risk than capability escalation, which alone requires a human terminal). Every purge is audited with session identity. Hard principle: **capacity limits never downgrade to permanent deletion.** If the quarantine cannot accept a relocation, the decision is `RELOCATE_FAILED_STORAGE` -> BLOCK; the untouched target stays at its origin. ## Authorization scope NORMAL/RESTRICTED is a **session/agent capability**, not workspace state: one degraded agent must not DoS concurrent agents. State lives in `.agent-trash/sessions/.json` (portable fallback); harness adapters SHOULD keep the authoritative mode in host memory. Promotion to NORMAL is host-side authority (`policy.force_mode`); agents never receive it as a model tool. ## Known limitations (V1) - Command-level interception covers the recognized vocabulary; arbitrary scripts that delete internally are invisible to `check.py` (see `docs/design/threat-model.md`). - `git stash create` captures tracked modifications only. `reset --hard` can delete untracked and ignored content that obstructs its target tree. `check.py` checks file/directory/ancestor collisions and refuses them; this version does not automatically archive those paths. It also refuses target-tree writes into quarantine. Ordinary unrelated untracked files remain allowed. Only `--hard`, optional `-q`/`--quiet`, and at most one commit are supported; submodules and stash-relative targets are refused. - Hard reset must be standalone without shell wrappers: other segments can change its revision or create a collision after preflight; wrappers can change its Git environment. The preflight and host execution are separate; concurrent changes are not atomically excluded. Direct callers of `snapshot_git` receive a tracked-only snapshot and must run the hard-reset preflight themselves before authorizing that operation. - Windows cmd/PowerShell, databases, cloud resources: future skills (`git-guard`, `database-guard`, `cloud-guard`) on the same core.