# Security model

This document is the security model of **dsh-rewind** — how the plugin treats
untrusted input, what it is allowed to mutate, and how it survives crashes.
It is derived from the implementation (`src/index.ts`, `src/snapshot.ts`); if
this document and the code ever disagree, the code wins and this document is
a bug.

## Trusted boundary

The plugin runs in the DSH host process and therefore holds the host user's
filesystem authority — it reads and writes files with plain `node:fs`. The
following are treated as **untrusted inputs**:

- **Model arguments** — the `file_path` field of `write` / `edit` tool calls
  (it names the paths the checkpoint store records).
- **Session log contents** — events are parsed structurally; a hostile or
  malformed id must never escape the store root.
- **Current worktree state** — restore planning reconciles against the live
  disk, which may have been changed by anything.
- **Concurrent external modifications** — a restore never assumes the disk
  still matches its records.

The DSH host (and its other plugins) is trusted; this plugin does not
re-verify the host's own authority boundaries.

## Mutation gates

The plugin **automatically captures** before-backups of tracked mutations, but
**never automatically applies** one. A workspace restore happens only through
an explicit user-invoked `/rewind @<seq> both` (the per-message ↶ button or the
command channel), and only when all of the following hold:

1. **A validated target**: `planRewind` accepts only a `user/message` seq that
   is currently on the session surface (`parseRewindTarget` → `RewindPlan`).
2. **A fresh plan**: the plan is derived from the current `events` + `surface`
   at execution time — never cached across events.
3. **Committed backups exist**: every planned action comes from a committed
   checkpoint entry anchored at or after the target.
4. **Live-disk reconciliation**: `planRestore` compares each entry against the
   current disk and plans only actions that would actually change it — an
   already-matching state is a no-op (restores are idempotent).
5. **Exclusive execution**: a per-session in-flight guard rejects concurrent
   rewinds; a running turn is force-cancelled (`keepInbox`) and quiescence is
   awaited before the surface is cut.
6. **Session binding**: the restore reads/writes only the store of the rewound
   session (paths are resolved display paths the session's own tools touched).

**Concurrency scope**: the in-flight guard above only serializes concurrent
*rewinds* of one session; it does not stop other processes from modifying the
workspace. `planRestore` reconciles against the live disk at plan time, but
there is no re-validation between planning and applying — a file changed by
another process in that window is overwritten by the restore. The rescue state
is captured at apply start, so `rollbackRestore` still returns the workspace
to exactly the apply-start state, and per-file failures are reported rather
than hidden.

A failed gate fails closed: an invalid target, a missing store, an absent
backup, or a cancelled invocation aborts the rewind with an error. A store
written by a **newer** build is one of those gates: it is refused as a whole —
no partial restore and no new entry written into it, and the automatic
format-change clear below is skipped — while the conversation rewind itself
still works. An explicit clear (`run --current --apply`) or the age-based sweep,
being a user-directed deletion of whole session directories, is deliberately not
version-gated.

**Automatic store deletion**: two things delete session snapshots. The opt-in
`snapshot-auto-cleanup` sweep (default off) removes the whole directories of
**long-inactive** sessions, unrelated to restores. Separately, a session's
snapshots are cleared automatically when the conversation log's session format
has changed (a DSH upgrade), because those backups are anchored to message
positions that no longer line up. Both stay confined to the store root, use
`lstat` (so they never follow a symlink out of the root), the sweep never targets
the active session, and neither touches the conversation log. When auto-cleanup
is disabled (the default), only the format-change clear runs, and only for an
affected session. The store-format guard runs before that clear, so a session
whose store is **newer** than this build understands is not cleared by it (see
`docs/format.md`).

## Conversation integrity

The session log is **append-only** — the plugin never deletes or rewrites
recorded history. A rewind appends a single marker event (an **empty**
`user/message`) whose `surfaceOp` replaces every surface node after the target.
The raw log (audit trail, search, `/export`) is untouched — only the
model-visible surface is cut, so the next request derives its context from the
target onward. The marker is empty, so it carries no untrusted text into the
model context; the client hides the `[target, marker]` span from the rendered
transcript.

## Filesystem containment

- **Store root**: `<harness home>/rewind-snapshots/` by default (resolved via
  `resolveDshHome`, so `~/.dsh/...` when `DSH_HOME` is unset); the `snapshotDir`
  config, then `DSH_REWIND_SNAPSHOT_DIR` env, override it. Deleting it only
  removes file backups and the store rebuilds from scratch.
- **Path sanitization**: session ids and call ids are scrubbed to
  `[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
  root; `.` and `..` bare values are replaced — hostile ids cannot traverse
  out of the root.
- **Never written through a link or a moved directory**: two checks guard a
  tracked path before any write or unlink. (1) A path whose last component is a
  symlink or a hard link (`lstat().nlink > 1`) is skipped and reported, never
  restored — a symlink would redirect the write outside the checkpoint, and a
  hard link would clobber every other name of the same inode (e.g.
  pnpm-installed files). (2) Every record this build writes — entry, link and
  journal action — carries the `realpath` of the file's directory at commit time
  (the location pin; best-effort, and absent on released-v1 data or a commit
  whose parent could not be resolved), and a restore re-checks it before
  touching the path, including a post-restart continue or rollback, which reads
  the pin from the journal: a directory that no longer resolves there (a
  repointed or moved ancestor) is refused and reported instead of writing
  outside the recorded location, while data with no pin falls back to check (1)
  alone. A parent chain that
  is gone is still recreated (files whose directory was deleted are restorable),
  but only while its nearest surviving ancestor resolves inside the pin. A
  stable symlinked ancestor resolves identically on both sides, so a symlinked
  workspace or temp root is never a false skip. This matches Claude Code's
  checkpoint behavior since v2.1.216.
- **Restores name only recorded paths**: the store contains resolved display
  paths of the session's own write-class tool calls (plus boundary re-checks
  over that same tracked set) — a restore can never write an arbitrary path.
- **Path resolution rule**: relative paths resolve against the session
  workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
- **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
  (`MAX_ANCHOR_GROUPS`), except groups a non-terminal restore journal still
  references (pinned so the op can be finished), so backup accumulation stays
  bounded by the cap plus the number of unresolved journals (the exact cap is
  pinned in `docs/format.md`). Across sessions, the opt-in `pruneStale` sweep
  removes whole **long-inactive** session directories (measured by the newest
  member being idle past `maxAgeDays`); it uses `lstat` (no symlink following),
  skips dot-prefixed temp files, and never targets the active session
  (`keepActiveId`).

## Crash safety

- **Ordered commits**: a checkpoint commit places the before-sidecar (a raw
  byte copy) before publishing the entry JSON, so a crash can leave an
  unreferenced sidecar but never an entry whose bytes are missing. Every JSON
  write (checkpoint entries, restore journals) goes to a sibling temp file and
  is renamed over the target: a host crash mid-write can leave only an inert
  `<target>.tmp` — never a readable half-written file — and readers never pick
  up temp files.
- **Journaled restores**: before mutating anything, the restore captures each
  planned path's pre-restore ("rescue") state and persists an intent journal,
  then marks each action done as it is applied. A crash at any point leaves
  the journal on disk.
- **Disk is truth**: after a restart, `reconcileRestores` re-derives from the
  real disk which paths already match the goal (restored) and which are
  pending; `continueRestore` finishes the interrupted op, `rollbackRestore`
  undoes it to the exact pre-restore state. A journal whose goal is already
  reached auto-heals to its terminal state.
- **Fail-loud vs fail-soft**: a corrupt **journal** is reported
  `recovery-required` — never silently dropped (dropping it would erase the
  interrupted restore's recovery record). Corrupt **checkpoint entries** are
  silently ignored (they only lose one backup, not the recovery path), and a
  record whose sidecar is missing or truncated fails only that file — never a
  delete of the live file.
- **Journal IO never fails the restore**: if the journal cannot be written the
  restore proceeds with pre-journal semantics (crash safety degrades,
  behavior does not).

## Explicit non-goals

- This plugin does **not** sandbox other processes or stop them from changing
  files concurrently.
- It does **not** provide confidentiality or tamper resistance against the
  same operating-system user: **store** files are created with the process
  default permissions (a standard umask applies), and the host user remains
  trusted. Restored **workspace** files do carry their recorded permission
  bits: a content restore reapplies them (`chmod`, best-effort), while a mode
  difference on its own never triggers a restore.
- It does **not** exclude paths from the store, and it never oversteps: the
  plugin only adds backup/restore on top of permissions DSH already holds. Two
  distinct cases follow:
  1. **Sensitive / personal-information files** (e.g. a `.env` file, which may
     sit inside the workspace) the model may read or edit are backed up and
     restorable.
  2. **Files outside the session workspace** the model is allowed to touch are
     likewise backed up and restored.
  In both cases the plugin introduces no authority of its own — DSH already
  granted the read/edit; the plugin merely records a before-backup and can
  restore it. Keeping a path out is a **DSH model-permission** concern (a
  per-path deny), not a rewind feature.
- It does **not** touch git (no refs, index, or worktree operations), makes
  **no network requests**, and does **not** access credentials.
- It does **not** roll back whole-log state: telemetry, search, and `/export`
  still see the withdrawn messages (documented behavior, not a bug).
- It does **not** restore files written by a cancelled tool call that never
  committed a backup.
- **Subagent session edits are not tracked** (Claude Code alignment): a
  subagent runs its own session, so the files it changes are not backed up and
  cannot be restored by a rewind of the parent session; the plugin keeps no
  snapshot for a subagent session and offers no rewind inside one.

## Reporting

Report a vulnerability through the repository's GitHub security channel or to
a repository maintainer. Include: the plugin version/commit, the DSH
(`@deepseek-ai/*`) version, the platform, and a minimal reproduction — and
whether the failure happened before or after workspace mutation.
