@hasna/trash
v0.2.3
Published
Reversible deletion for agents - deletes land in a trash store, sync to cloud, expire on a chosen retention
Readme
@hasna/trash
Reversible deletion for agents. The hosted CLI, MCP and SDK use https://api.hasna.com/trash/v1: PostgreSQL owns metadata; versioned S3 stores verified recovery capsules. A station keeps only unfinished filesystem operations locally. Missing credentials never enable a local metadata fallback.
Agent workflow
trash setup
trash doctor
trash put ./obsolete-folder
trash put /absolute/path/to/large-item --low-space
trash list --limit 20 --station station06 --path obsolete
trash info ENTRY_ID
trash restore ENTRY_ID
trash hold ENTRY_ID
trash retention ENTRY_ID --days 180
trash backup ENTRY_ID
trash pending
trash releaseOutput is compact JSON by default. Lists contain at most 20 rows unless requested, capped at 100, with a nextCursor. Pass it using --cursor. Lists never return file contents or signed transfer URLs. info fetches one full metadata record. Filters include station, agent, literal path text, kind, state, Backup state and user hold. Restored and expired records require an explicit state filter.
trash guard -rf PATH accepts rm grammar for the shell hook and uses low-space capture. Force does not authorize an uncaptured deletion. trash guard --plan 'rm -rf ./build' shows the rewrite without authentication or filesystem changes. Exit codes: 0 completed, 1 usage/API failure, 2 deletion refused.
Scripts must invoke the absolute path (~/.bun/bin/trash) or put that bin directory first on PATH. macOS ships /usr/bin/trash, a Finder mover that takes file operands rather than verbs; a non-login shell can resolve it first, and a scripted trash pending would then try to move a file named pending. trash doctor reports that shadowing as pathShadowing.
Credentials resolve through @hasna/contracts, including owner-only ~/.hasna/trash/config/credentials. Its API base is https://api.hasna.com/trash; clients append /v1. Provision a separate signed key for each station. Detection uses HASNA_TRASH_STATION, HASNA_STATION, Tailscale's self identity, then hostname. The service verifies that the detected name matches the signed credential subject; changing an environment label cannot claim another station. Captures record agent name, harness and session when supplied or detected.
trash doctor checks authenticated hosted status against the detected local station and reports both identities plus pending operations. It exits unsuccessfully for an unregistered or mismatched station, invalid local identity, authentication failure or unavailable service. It never registers a station or creates a filesystem operation; use trash setup explicitly for registration. The SDK exposes the same read-only check as createTrash().doctor(). This checks station access, not native hook installation or Backup delivery. status and doctor report the server build as serverVersion and the local CLI as clientVersion, so a deployed server and an installed CLI that differ are visible instead of ambiguous.
The provisioning operator confirms the exact written vault version while it remains current before activating a key. Temporary version invisibility is retried under a 15-second deadline; a mismatched version, invalid envelope or permission denial fails immediately. An initial missing value permits issuance only after checking that the destination exists and reports no staged versions. That metadata omits historical versions without staging labels and can lag recent writes. If confirmation fails after a write, preserve the task receipt and reconcile that exact version before another invocation: a failed task or empty staged-version metadata does not establish that no credential was written.
Capture, restore and retention
- Inspect the source without following symlinks and create a private capsule with a manifest and hashes.
- Reserve hosted metadata, upload with a create-only condition, and verify the entire immutable object version.
- Confirm the source has not changed. Move it into a private sibling staging directory and commit removal through an idempotent API operation.
- Reverify the exact remote version, then remove only unchanged captured members from staging.
For a disk under pressure, use trash put PATH --low-space, SDK createTrash().put(PATH, { lowSpace: true }), or MCP trash_put with lowSpace: true. This reads and hashes the selected source, retains only a private manifest and transaction journal locally, and streams the normal recovery capsule directly to S3 in chunks of at most 64 KiB. It avoids an additional local copy the size of the payload. It still needs disk space for bounded metadata (up to a 16 MiB manifest plus its receipt and journal), and an authenticated, reachable service. A completely full filesystem can still refuse metadata writes; it preserves the source and never falls back to permanent deletion. Multiple hashing passes and upload time still apply: this reduces disk use, not the need to verify recovery.
Low-space operations resume through the same pending and recover workflow, including interruptions after staging or commit. Their local manifest-N.json is recovery metadata, not a standalone copy of the contents; restore downloads the verified remote capsule. Use the capturing client version or newer when recovering a low-space operation: older clients preserve an unreadable record instead of guessing. Existing capsule-based captures and restores retain their prior behavior. The 2 GiB payload limit remains in force.
The default retention is 365 days from committed removal, configurable per removal with trash put PATH --retention 1–365, or afterward with trash retention ENTRY_ID --days 1–365. New settings reject zero, fractions, values over 365 and unlimited retention. Existing longer or indefinite entries retain their deadlines. Explicit holds and pending, running or failed Backup requests prevent expiry; independently held Backup copies retain their own custody.
With primary archiving enabled, the existing server maintenance tick marks each verified version eligible after 30 complete days from committed removal. A tag-filtered S3 lifecycle rule moves those objects to S3 Glacier Flexible Retrieval asynchronously, including capsules smaller than 128 KiB. The object keeps its bucket, key and immutable version. Entries retained for fewer than 30 days expire while still hot. Configure no S3 expiration rule: only Trash's hold-aware expiry worker deletes the exact eligible version and retains its metadata tombstone. Glacier has a 90-day minimum storage charge, so a shorter configured retention can still incur that minimum after transition.
Cold recovery is asynchronous:
trash storage ENTRY_ID
trash retrieve ENTRY_ID --request-id UUID --version CURRENT_VERSION --days 1
trash retrieval ENTRY_ID
# After retrieval reports available:
trash restore ENTRY_IDUse the same request ID and original version to reconcile a lost response. Retrieval uses Bulk, with temporary availability configurable from 1–7 days. A submitted request is not proof that bytes are available. Status distinguishes queued, submitted, waiting, available and attention, with observation timestamps and safe error codes. The runtime persists intent before the single S3 submission and observes uncertain outcomes without automatically resubmitting. A retrieval admitted before retention expiry protects the payload through its observed temporary availability window, and active restore leases protect downloads. The restore path rechecks availability and verifies bytes, modes and collision safety. These primary retrieval commands are separate from Backup archive requests.
The server enables primary archiving by default. Apply the exact transition returned by the package's ARCHIVE_RULE contract and narrow version-tagging and RestoreObject permissions on its existing capsule prefix before deploying this version. Readiness refuses missing/changed transition rules or lifecycle expiry. An explicit HASNA_TRASH_ARCHIVE_ENABLED=false disables primary archive maintenance for a deliberate compatibility rollout. Inspect the deployed configuration before claiming automatic archiving. Direct removal of unrelated existing S3 objects is outside this workflow.
Retrieval reconciliation survives maintenance outages. An unresolved request continues to protect its version after the initial 72-hour observation window, reports attention, and is observed without automatic resubmission. Once available, protection includes the provider's temporary availability deadline. Inspect such requests before explicitly starting a new retrieval intent; elapsed time alone never authorizes deletion of an unresolved retrieval. Archive tagging and retrieval share bounded maintenance batches fairly.
Use trash storage ENTRY_ID, TrashApi.storage(ENTRY_ID) or MCP trash_storage to inspect one selected capsule. The compact response contains its entry ID/version, provider, storage class, availability state, temporary retrieval expiry and observation time. The server checks the recorded immutable object version, size and checksum with one metadata request; it returns no bucket/key, download link or content. Ordinary list pages stay metadata-only without per-entry S3 calls. Availability is an observation, so recovery checks again before granting a download.
Restore creates the destination exclusively and verifies every file. It refuses an occupied path. Restoring on another station requires --to PATH; the original station's path is never silently used there. Files, directories, modes and symlink objects are supported. Capsules currently support up to 2 GiB of content, 100,000 members, 64 directory levels and a 16 MiB manifest. Extended attributes, ACLs, ownership and hardlink relationships are not preserved; special files and privileged mode bits are refused.
Captured trees may contain read-only directories (0500). Moving one needs its own write bit even for its owner, and emptying one needs that bit on the directory rather than on its files, so the move relaxes exactly the owner-write bit for the rename and restores the recorded mode on the staged copy, and cleanup relaxes that one bit on a staged directory before emptying it. The capsule keeps the original modes, so a restore reproduces them.
Interruptions
trash pending
trash release
trash release OPERATION_ID
trash release --dry-run
trash release OPERATION_ID --discard-incomplete
trash recover OPERATION_ID
trash recover CAPTURE_OPERATION_ID --duplicate-of EARLIER_TRASHED_ENTRY_ID
trash recover RESTORE_OPERATION_ID --to /new/empty/destinationAn upload failure leaves the source untouched. A source changed during upload is preserved. An interruption after staging or a lost API response retains the capsule, staged source and operation journal until recovery reconciles authoritative state. A partial restore is preserved; recover --to can select a fresh destination. When overlapping captures of the same source leave a ready entry with its original path absent, recover --duplicate-of requires an earlier exact, still recoverable trashed entry on the same station. The server verifies both immutable objects and commits the duplicate before the local capsule is cleared. A plain recover finishes cleanup if that commit succeeded but its response was lost. pending works without credentials.
A payload transfer is bounded by progress, not by a fixed wall clock: it fails when no bytes move for 90 s, and otherwise runs within a budget sized from the payload (at least 240 s, and payload ÷ 256 KiB/s beyond that). A failure names the step (upload or download), how far it got, how long it ran and why it stopped, and stays retryable — recover first asks the server to verify the recorded object, so an upload that already reached the store (a stalled socket or a lost response) is reconciled without re-sending the payload. trash put never retries by itself: it refuses with exit 2 and preserves the source.
A capsule is released only when the hosted index proves it redundant, and every other operation is retained with the reason:
trashed/restored: the capture or restore is complete, the hosted object is verified and the removal was committed, so only local staging remains.trash pendingreleases these automatically on the next run;trash releasedoes it explicitly.readywith the source already gone (a source removed outside Trash, or a capture interrupted after its verified upload): the operation is finalized — committed totrashedso the entry's retention applies and the never-expiringreadyreservation is closed — and then released.trash release OPERATION_IDopts into that commit; the automaticpendingsweep only reports it.readywith the source still present: the capture is interrupted, not complete. The capsule is kept;trash recover OPERATION_IDfinishes it.uploading(the upload is not verified),expired/deleting(no hosted object is left) and an operation with no hosted entry at all: the capsule may be the only copy, so it is kept and reported.trash release OPERATION_ID --discard-incompleteadds one local proof for an interrupted capture that never verified: while its original source is still present at its recorded path, unchanged and not already staged, the staged copy duplicates it and only the local staging is released — the source and the hosted entry are untouched. The flag is opt-in, never assumed bytrash pending, and a source that is gone, changed or already staged keeps the capsule.
An earlier identical recoverable removal always wins over finalizing: a source-gone capture that a server readback matches to an earlier trashed entry is retained with recover OPERATION_ID --duplicate-of SURVIVOR_ID instead of being committed as a second independent entry.
Operation directories are owner-only. Unknown contents and checksum changes are preserved for inspection. Locks are never stolen by elapsed time. A process killed while holding a filesystem lock leaves it visible: recover and release report the recorded holder, and only --take-abandoned-lock clears that one lock, and only when the record proves the holder is gone (this host, pid no longer running, token unchanged). A live, unreadable or other-host holder is never taken over; stop all Trash writers and verify the recorded host/PID yourself before clearing such a lock by hand. Do not delete a whole spool to clear an error.
A record can also be gone while its lock file remains: a process killed between the final unlink of a completed operation and the lock release leaves exactly that residue. release ID then reports the local condition — the exact id, the operation directory it read, and no staging left to free — instead of a hosted setup error, and release ID --take-abandoned-lock clears that one orphan lock under the same proof (this local decision needs no credentials). An unreadable record is reported as unreadable and preserved; its lock is not taken over.
Backup handoff
trash backup ID marks an entry for the separate Backup worker. It immediately protects the Trash copy. An ordinary station credential cannot claim or complete Backup jobs. Completion requires a trash:backup credential and a receipt for the exact artifact, with a held backup and verified restore. A user hold remains independent.
The private Backup adapter imports through the existing Backup app. Production handoffs additionally require S3 versioning, Object Lock and verified legal holds on the exact archive and manifest versions. Backup failure leaves Trash protection active. Worker deployment is a separate operational prerequisite; requesting Backup is not evidence that the handoff finished. Check backup: "verified" and the detail receipt.
To recover after the Trash retention period, restore the archive through Backup, then recover its payload.capsule:
trash restore-capsule /restored-backup/source/payload.capsule --to /new/destinationCapsule recovery verifies the complete artifact and refuses overwrite. It does not require the original Trash API credential or change hosted history.
SDK and MCP
import { createTrash, TrashApi } from '@hasna/trash/sdk';
const files = createTrash();
const entry = await files.put('./obsolete-folder', { agent: 'codex', retentionDays: 90 });
const page = await new TrashApi().list({ limit: 20, station: 'station06' });
await files.restore(entry.id);Run trash-mcp --stdio for standard newline-delimited MCP. Tools cover status, setup, compact list, detail, storage availability, put, restore, hold, retention, Backup request, pending operations and recovery. No permanent-delete or raw-blob tool is exposed. Mutations report compact metadata and fixed, redacted errors.
The hook-trash-guard integration in @hasna/hooks validates the Hasna binary identity before rewriting supported shell deletions. A shell hook covers only the native tool events where it is installed. It does not intercept arbitrary filesystem syscalls, application-native delete APIs, or an agent's file-edit tool. Configure agents to use the Trash CLI/MCP for removals and verify each harness's installed hook coverage; a shell alias is not system-wide enforcement.
--local / HASNA_TRASH_LOCAL=1 and createLocalTrash() explicitly select the legacy offline store. Its entries have no hosted station index. Legacy config, sweep, empty and purge commands apply only to that store.
Service and verification
trash-serve listens on 0.0.0.0:8080 by default. Configure HASNA_TRASH_DATABASE_URL, HASNA_TRASH_API_SIGNING_KEY, HASNA_TRASH_S3_BUCKET and HASNA_TRASH_S3_REGION. AWS credentials use the standard SDK chain, including an ECS task role. Run trash-serve migrate separately before startup. Migration needs only PostgreSQL authority. Public /health, /ready, /version and /openapi.json describe the service; /ready checks PostgreSQL, bucket versioning and lifecycle constraints.
Every mutation requires an Idempotency-Key; entry actions also require the current numeric If-Match version. Reusing a key for a different canonical request conflicts. Authentication, tenant scope, station bindings, revocation and worker permissions are enforced at the service.
bun run verify
TRASH_TEST_DATABASE_URL=postgresql://.../disposable_trash_test bun run test:postgresThe PostgreSQL gate exercises real metadata/authentication/HTTP/retention and CLI/MCP filesystem flows with a clearly labeled fixture object adapter. It is not live S3 or deployment acceptance. Release acceptance separately requires real object transfer, canonical API authentication denial, native station capture/restore, Backup handoff and exact package/image receipts.
Symlink permission modes are preserved on macOS. Linux supports only 0777 symlink modes; a capsule containing other link modes must be restored on macOS. An incompatible restore is rejected before creating its destination.
Station configuration
trash station check --station station02 is a small, read-only service check:
detected identity, canonical authentication, expected retention and the list
limit contract. serviceReady does not mean that an agent has loaded a hook,
MCP server or instructions.
The station configuration workflow composes the installed Hooks and Instructions
clients. Install the approved exact package versions through the normal package
manager first. Deliver only the station's already-issued credential through the
maintained delivery operator; this workflow never issues or rotates a key. If
authenticated status says station registration is required, use trash setup
explicitly. Initial managed prompt creation remains an Instructions operation.
Supply one station/provider specification to the CLI:
trash station plan --spec /absolute/station-spec.json --out /absolute/new-plan.json
trash station apply --plan /absolute/new-plan.json --expected-plan-digest <returned-sha256>The specification uses schema hasna.trash.station-setup-spec/v1. It binds:
station,retentionDays,provider(codex,claudeorsumi), actual ownerhome, selected projectcwd, explicitpath, nativeproviderHomeandnativeConfig.runtime: {path, sha256}and installedhooks/instructionseach with{root, version, cliSha256}. These are exact execution pins, not permission to install an arbitrary executable or a replacement for release verification.managed: {targetHome, profileId, requiredSource: {id, sha256}}, optionallynamespace: "claude" | "sumi"for a provider-specific project manifest. Pin the hosted recoverable-removal instruction actually selected for that profile.targetHomeis the resolved managed home: a target reached through a symlink is reported bysession refreshas its realpath, and the spec must name that resolved path.mcp: {configId, version, contentSha256, operationId}for the hosted Trash native MCP Config. A conflicting existing entry additionally requires itsreplaceEntrySha256. Codex also requirescodex: {executable: {path, sha256}, tempRoot}.
No credential values, arbitrary command arguments or transport overrides belong in this specification. The clients use their normal owner credential resolvers. The native targets must be within that station's actual owner home.
A plan exposes only the next required stage: managed instructions, hook
registration, Codex trust, MCP registration, or ready. Applying it performs one
guarded owner operation and returns replan_required. Make a new plan before the
next stage. This keeps each native file predecessor current when multiple owners
touch the same file. Plan output files are exclusive, private review artifacts;
the owning applications retain their authoritative sources and operation journals.
MCP planning may prepare private Instructions operation metadata but leaves the
native configuration unchanged.
Keep the original plan and any returned owner operation ID after a failure. Resolve uncertain outcomes through that owner before proceeding; do not create a different operation merely to hide the failure. Configuration, authentication, source drift and unavailable native interfaces fail closed. Unmanaged prompt files require explicit Instructions adoption rather than an automatic overwrite.
Once a ready plan is recorded, the small startup subset is:
trash station check --plan /absolute/ready-plan.jsonIt checks canonical service access, pinned installed consumers, the verified
guard, current managed instructions and the recorded native MCP configuration.
It does not install, apply, prepare MCP journals, capture or restore a file.
Changed configuration requires a fresh plan.
For new Claude ready plans, configuration means user and project MCP definitions,
MCP enable/disable selections, trust and tool-permission fields. Native counters
and caches may change without invalidating this read-only check. Every configuration
write still requires its exact full-file predecessor; older ready plans retain
their original full-file check until explicitly replaced by a fresh plan.
Its output retains
mcpExecutionVerified: false and nativeAdoptionVerified: false: separately prove
a fresh native harness loads the intended instructions and MCP tools and actually
intercepts a disposable authorized removal. Merely writing a configuration file
does not establish that proof. Wiring this check into the supported startup event
is also separate from invoking it manually.
The SDK exports planStationSetup, applyStationSetup, checkStationSetup,
parseStationSetupSpec and their typed specification/plan contracts.
Held Backup archive retrieval
trash storage ID inspects the hot Trash capsule. A held Backup archive has a separate lifetime and can remain recoverable after that capsule expires. The configured Backup worker owns its bucket, object version and provider credentials.
Use trash backup-inspect ID --version VERSION --request-id UUID to queue an exact-version metadata read. trash backup-storage ID returns the compact cached result and its checkedAt time; it does not perform a new provider read.
Cold retrieval is explicit: trash backup-retrieve ID --version VERSION --request-id UUID --days 2 --tier Bulk --confirm. Select Bulk or Standard and 1–7 days deliberately; provider charges can apply. Preserve the same request UUID, version and options after a lost API response. The hosted request is a queue receipt, not proof that bytes are available. The existing Backup worker reports queued, running, waiting, complete or attention, plus the provider's actual archived, restoring or available observation and temporary-copy expiry. Availability does not establish a verified filesystem restore.
After interruption, trash backup-refresh ID --version VERSION --request-id ORIGINAL_UUID queues observation of the original intent. Reclaimed jobs must only inspect the saved provider request; missing or uncertain records require reconciliation and never authorize another submission. Automatic observations stop after 72 hours. Refresh keeps the original intent and attempts; it does not reset that deadline or release any hold. A genuinely new retrieval after a completed temporary copy expires requires a new explicit request.
The SDK exposes TrashApi.archive/requestArchive/refreshArchive; MCP exposes trash_backup_storage, trash_backup_inspect, trash_backup_retrieve and trash_backup_refresh. Worker archive/claim, archive/complete and archive/fail routes require the separate trash:backup scope and a live lease. Station clients never receive bucket paths or AWS credentials.
