L-ingqin12/dsh-redaction

dsh-plugin-redact

会话日志的原地脱敏与按行回退:守住「每行 seq 必须等于它的行号」这一不变式(删中间行会让整份日志打不开)。任何写入前先把原文复制成隔离备份,并以读取端自身的语义作为写前闸门。

安全与权限cordisdeepseek-harnessdsh

dsh-plugin-redact

In-place redaction and row-level rollback for DSH session logs (session.vN.jsonl.zstd), usable from inside dsh-tui.

No new session, no deleting the whole conversation, no moving the log around: it rewrites the exact bytes in the original file and leaves the log readable by the DSH reader.

Packagedsh-plugin-redact
Version0.1.0
LicenseMIT
RuntimeNode.js >= 22.15.0 (uses zlib.zstdCompressSync / zstdDecompressSync)
In-TUI command/redact list|nodes|pick|scan|hide|plan|apply|verify|purge|rollback|undo
Offline CLIdsh-redact inspect|paths|verify|plan|apply|cut|graph
Hard dependencyConsumes the host commands service only (inject: ['commands']); publishes no service. /redact pick additionally needs the TUI row's tuiDialogs and an explicit allowDialogs: true (off by default — see §2.5)

0. What problem it solves

Once a tool result, command output, or fetched page enters a session, a persistence batch writes it into session.vN.jsonl.zstd, where it then sits on disk indefinitely — replayed in later history, fed back into later context, indexed or exported. The content need not be a "secret": it may be material you are not allowed to retain, a credential, or simply wrong or outdated information that should stop influencing later decisions. You decide the criterion; this tool only guarantees that the bytes already on disk are removed or rewritten — without corrupting the log.

It does two things: rewrite (replace matched text/fields in place, or blank a whole line) and cut (drop trailing rows, or middle rows with an explicit renumber pass). Afterwards the session still opens, replays, and appends normally.

Keep four different outcomes apart, because four subcommands own them:

  • /redact hide — immediately removes matching tool results from the current session's model view by appending surfaceOp: {op:'replace'} replacement nodes. The model stops seeing them from the next request, with no restart. Disk bytes are unchanged. It locates its targets three ways: a find in plan.json, an index from /redact nodes, or a log line number (see §3.1). To see the complete list and pick from it, use /redact pick: it renders the nodes multi-line in the TUI's own panel and lets you choose with the arrow keys, free of the 200-cell single-line limit. Each entry shows index / log line / turn / tool name / size, with the tool call's command line on the line below it (governed by argsCells, which can truncate it or switch it off entirely) — together these are what let you tell which node you are looking at (see §3.2). The tool result's body is never shown. ⚠️ pick is off by default (allowDialogs: false): a modal dialog makes the TUI yield the chat keyboard, and a pending approval panel then deadlocks the UI (see §2.5). Enabling it takes explicit configuration; without it, use /redact nodes for the numbering plus /redact hide <index> --commit — nothing else is affected.
  • /redact apply — actually rewrites the log bytes on disk (in place, same row count). It requires the session not to be in use (otherwise an explicit --allow-live), so it is best run through the offline CLI while the session is closed.
  • /redact rollback <turns> — retracts conversation that already happened: truncates the last N complete turns at a turn/end boundary. This is a suffix drop, the safest kind of deletion.
  • /redact undo — reverts your own redaction: restores from the newest quarantine backup (validated before it is installed: frames decodable, header legal, seq dense, references legal, no torn tail, and no fewer events than the current log; a backup that fails is refused with a pointer to an older .quarantine-* / .before-undo-* copy).

They are complementary: hide to stop the bleeding (content stops entering later context) → apply once the session is closed to remove the bytes → undo if you regret it. With hide alone the original stays on disk in full.


1. ⚠️ Read this first: deleting a middle row makes the whole log unreadable

[!WARNING] The DSH reader has one non-negotiable invariant: every row's seq must equal its row index (0-based; row 1 is the header and is exempt). The decoder asserts event.seq === eventCount row by row.

Therefore deleting any middle row makes the reader reject the entire log as corrupt — you do not lose one row, you lose the session.

In-place rewriting, by contrast, is safe: row count, row order, type, seq, time, and all cross-references stay identical, so it is completely transparent to the reader.

So the primary mechanism is three kinds of in-place rewrite; deletion is a supplement:

OperationWhat it doesStructural effectWhen to use it
substitutionsReplace a matching substring inside every string value (can be narrowed with path / lines)None: row count / seq / type unchangedThe same text is scattered across many rows (tool results, meta, echoed arguments) and you want it gone everywhere at once
setFieldsReplace the value at one JSON path on one rowNoneYou have located the exact spot with paths and want only that one field changed
blankLinesReplace every string value on those rows with the placeholder (default [已移除])None: type / seq / time / id untouchedThe whole row's content is void, but the row itself must stay (event counts, turn structure, tool-call pairing)
dropLinesDelete those rows⚠️ Yes: row indices shiftSafe only for a trailing suffix (references always point backwards). A middle drop requires renumber: true
renumberPermit middle deletion: renumber every seq and remap all backward references⚠️ LargeYou genuinely need the row to not exist and accept rewritten seqs. A dangling reference (some row references a deleted row) is still refused

Prefer the first three when you only need the content gone; reach for dropLines only when the row itself must disappear.


2. Install

Two ways. A is the normal one (the package declares dsh.bundle, so it becomes a profile layer); B is for a hand-written row.

# from a local directory (relative specs are anchored to the invoking directory first)
dsh plugin --profile <name> add ./dsh-plugin-redact

# or from npm / git
dsh plugin --profile <name> add dsh-plugin-redact
dsh plugin --profile <name> add github:<you>/dsh-plugin-redact#<commit>

dsh plugin --profile <name> <args...> ensures the profile exists, then forwards the arguments to pnpm with the profile directory as the working directory — so add / remove / why / update all work, and pnpm must be on PATH. Because the package declares

"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }

a successful install adds it to that profile's dsh.profile.bundles and applies its cordis.patch.yml as a layer (a single - insert: row with id: dsh-redact).

2.2 B: as a plain profile row

Edit <DSH_HOME>/profiles/<name>/cordis.patch.yml (this is the user layer; the neighbouring cordis.yml is a generated empty root — do not edit it):

# top-level YAML array; an insert with no id appends top-level rows
- insert:
    - id: dsh-redact
      name: dsh-plugin-redact          # bare name: resolved from <profile>/node_modules
      config:
        root: !!js dshHomePath('sessions')
        cacheRoot: !!js dshHomePath('storages')
        placeholder: '[已移除]'

Two resolution rules that matter:

  • The package must be resolvable from the profile's node_modules. A bare name is looked up by Node starting at the profile directory ( <profile>/node_modules, <DSH_HOME>/profiles/node_modules, …), so either run dsh plugin --profile <name> add ./dsh-plugin-redact first, or install the package into the profile's dependencies. Leaving the package in some unrelated directory and referencing it by name will not resolve.

  • On Windows an absolute local path must be a file:// URL. The loader uses dynamic import(); a raw C:\...\index.js is parsed as the URL scheme c: and fails with ERR_UNSUPPORTED_ESM_URL_SCHEME. Generate it with pathToFileURL, and point at the entry file (a directory fails with ERR_UNSUPPORTED_DIR_IMPORT):

    node -p "require('node:url').pathToFileURL('C:/Users/me/dsh-plugin-redact/index.js').href"
    # => file:///C:/Users/me/dsh-plugin-redact/index.js
    

    A relative path (./dsh-plugin-redact/index.js) also works; it resolves relative to the including file (the profile directory).

2.3 Config

FieldDefaultMeaning
root<DSH_HOME>/sessionsSession-log root; layout is <root>/<project-key>/<session-id>/session.vN.jsonl.zstd
cacheRoot<DSH_HOME>/storagesDerived-cache root; purge deletes only here
placeholder[已移除]Text written by blankLines (substitutions' replace defaults to the empty string, i.e. plain deletion)
argsCells120Display-cell budget (a CJK character costs 2) for the tool-call command line shown in listings. The command line is what makes an entry recognisable, but it can itself contain sensitive search terms, hence the switch: 0 shows no command line at all (nothing in the listing file or the panel — not even a …). It affects only the nodes full file and pick's option description; the nodes notification never shows a command line and is unaffected
dialogTimeoutMs15000The timeoutMs passed to both of pick's dialogs. It is the bound on "how long the UI may stay unusable when the panel is not rendered" (see §2.5); 0 disables dialogs entirely — pick returns an error and never calls the dialog service. The default used to be 120000 (2 minutes), which was a bad bet: the user hit exactly that freeze in practice
allowDialogsfalseThe master switch for pick's modal dialogs, off by default (rationale in §2.5): parking the promise in TuiDialogStore makes chat yield the keyboard unconditionally (the prompt is disabled), the dialog's only mount point can be silently overridden by an approval panel, and approval has no timeout — so "press pick while an approval is pending" necessarily locks the keyboard, Ctrl+C cannot even exit, and only the timeout frees it. Set true to enable pick; while it is off, pick returns an explanation pointing at nodes + hide <index>. A non-boolean value throws

<DSH_HOME> falls back to ~/.dsh. The shipped cordis.patch.yml uses the deployment's own !!js dshHomePath(...) helper, exactly like the official session-persistence-jsonl row, so it follows DSH_HOME.

Config validation (done synchronously in apply(), so it fails early and visibly):

  • An empty config: (YAML hands over null) or no config at all (undefined) is treated as {}, with each of the six fields taking its default. This is deliberate: otherwise one empty config: would abort the whole profile's boot.
  • Still rejected: non-objects (strings, arrays) and any non-string or empty-string value for root / cacheRoot / placeholder. The errors read dsh-redact: invalid config: expected an object and dsh-redact: invalid config: $.root must be a non-empty string.
  • The two numeric fields must be non-negative integers: argsCells / dialogTimeoutMs given as -1, 1.5, '5000' or null all throw at boot and register no command — dsh-redact: invalid config: $.argsCells must be a non-negative integer, dsh-redact: invalid config: $.dialogTimeoutMs must be a non-negative integer. 0 is valid and meaningful ("show no command line" and "disable dialogs" respectively).
  • allowDialogs must be a boolean: 'true', 1 or null all throw dsh-redact: invalid config: $.allowDialogs must be a boolean (and register no command); omitting it means the default false.
  • Unknown keys only warn, never reject (matching the platform's own config semantics, so a slipped key name cannot break boot): dsh-redact: ignoring unknown config key(s): palceholder, extra, emitted through ctx.logger.warn.
  • This package does not export a Schemastery Config: when installed as link:, the package's own real path cannot resolve node_modules upwards and @deepseek-ai/schemastery is unresolvable (observed as ERR_MODULE_NOT_FOUND). The equivalent validation above is therefore hand-written inside apply().

2.4 Confirm it mounted

dsh --profile <name> --dump-config     # prints the composed config without booting

The output should contain the id: dsh-redact row, annotated with the layer it came from. (This command also rewrites the profile's generated empty cordis.yml and may heal profiles/node_modules links; both are idempotent.)

In a patchReload: live profile, editing cordis.patch.yml takes effect without a restart, and the new command enters the TUI's / menu live via commands/change. Editing the contents of an already-imported module is not hot — restart or rename the file. Creating the plugin file for the first time needs no restart.

2.5 /redact pick is optional and off by default: enabling it takes two things (install requirement)

[!IMPORTANT] pick is off by default (allowDialogs: false) — not out of caution, but because of a root-cause finding. Parking the dialog's promise in the TUI's TuiDialogStore makes chat yield the keyboard unconditionally (Chat.js:2566: the prompt is disabled while a questionnaire / approval / plugin dialog is pending); the dialog panel's only mount point (Chat.js:3585) is silently overridden by an approval panel, and approval has no timeout — so pressing /redact pick while an approval is pending necessarily locks the keyboard, Ctrl+C cannot even exit (exitOnCtrlC: false), and only the timeout frees it. The only structurally safe move is not to park the promise at all, so the default path is /redact nodes for the numbering → /redact hide <index> --commit (no functionality is lost).

To actually use the panel, two things must both hold:

  1. Config allowDialogs: true — the master switch (default false). While it is off, pick returns this and never touches the service:
    pick 的模态对话框默认关闭(它会让 TUI 键盘卡住,且 approval 挂起时必然复现)· 用 /redact nodes 看编号,再 /redact hide <序号> --commit · 确要启用请设 allowDialogs: true 并自行承担风险
    
  2. A row-level inject: [commands, tuiDialogs] — this removes the startup "admission race" (next paragraph). The packaged cordis.patch.tui.yml already writes this step and the one above.

Why the row-level inject is still needed. The tuiDialogs runtime has an admission guard outside its try/catch: the caller must be a registered, live, non-root activation (@deepseek-harness-tui/dsh-tui, lib/types/dsh-adapter/dialogs.js:103-111: when bindOwnerEffect(...) does not bind, it immediately runs pending.onAbort() and the promise settles with the cancelled value; the registration itself is established by compositionRoot(ctx) in the TuiDialogRuntime constructor, same file :185). If this row becomes ACTIVE before the first dsh-tui adapter module that installs that composition-root tracker, every select / confirm only writes one logger warning and returns — no panel, and the user sees nothing but "cancelled". A row-level inject makes Cordis wait for that service before activating this row, which removes the race (measured: in the worst order the panel went from 0/6 to 6/6).

The packaged layer is cordis.patch.tui.yml (shipped with the package; exports also resolves dsh-plugin-redact/cordis.patch.tui.yml); it writes both the row-level inject and allowDialogs: true into that row. The row you want when enabling pick looks like this:

# the row that enables /redact pick (row-level inject + an explicit opt-in)
- insert:
    - id: dsh-redact
      name: dsh-plugin-redact
      inject: [commands, tuiDialogs]
      config:
        root: !!js dshHomePath('sessions')
        placeholder: '[已移除]'
        cacheRoot: !!js dshHomePath('storages')
        allowDialogs: true

(The packaged cordis.patch.tui.yml also carries a long comment block explaining the admission guard, the cost, and why you must not try an !!js disabled self-guard to choose between the variants automatically — Entry.disabled is a live getter, so the verdict flips with mount progress and the boot aborts.)

Three ways to use it, pick one:

  1. Paste it into your own profile patch layer (recommended, most direct): copy those insert lines into <DSH_HOME>/profiles/<name>/cordis.patch.yml (both the packaged file and the snippet above already carry allowDialogs: true in the config — do not drop it when copying).
  2. Point --patch at the packaged file:
    dsh --profile <name> --patch "C:/path/to/node_modules/dsh-plugin-redact/cordis.patch.tui.yml"
    # Windows absolute paths need a file:// URL: node -p "require('node:url').pathToFileURL('C:/.../cordis.patch.tui.yml').href"
    
    That file's config carries all four fields (including allowDialogs: true), so this route is complete on its own; layers from several --patch flags stack in order.
  3. Copy the snippet: if you only want the panel and not the file itself, copy the YAML above.

Three measured semantics that save rework:

  • A row-level inject is APPENDED, not a replacement: the module's static commands declaration still applies (cordis merges via Inject.resolve(entry.options.inject, fiber.inject)), so commands inside [commands, tuiDialogs] is a harmless duplicate — you do not have to choose between them.
  • config is a whole-row replacement (a matching patch replaces the entire config; there is no deep merge), so write every field you want to take effect into that row — which is exactly why allowDialogs: true has to be in it, not inherited from somewhere else.
  • After applying it, dsh --profile <name> --dump-config shows that row's inject and config (see §2.4).

[!WARNING] Cost: apply this layer only when you run a dsh-tui frontend and genuinely want that panel. If your profile has no tuiDialogs provider (non-TUI frontend, or the dsh-tui row disabled / failed to load), this row stays PENDING (state 0) forever, and the boot promotes a PENDING row to a fatal error — the whole profile fails to boot, not a single row failing quietly.

Why the packaged default layer cordis.patch.yml deliberately omits that inject: on a non-TUI profile (e.g. web) tuiDialogs never exists, so declaring it would leave the row PENDING and abort the whole profile boot. The default layer therefore stays graceful.

Off-by-default and degraded behaviour are both explicit — never silent:

  • allowDialogs !== true → the "off by default" explanation above (kind: error), without touching the dialog service;
  • allowDialogs on but dialogTimeoutMs: 0 → 对话框已被配置禁用(dialogTimeoutMs: 0)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件;
  • allowDialogs on but the frontend has no such service → 当前前端没有对话框服务 tuiDialogs · 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件;
  • allowDialogs on and the service present, but the host did not admit this row (the admission race) → the plugin uses elapsed time to tell (nobody presses Esc within 50 ms):
    对话框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件
    
    The confirm panel is the same:
    确认框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact hide <序号> --commit
    

In other words, whether it is off or degraded, pick never fails silently — it separates "the panel never opened" from "the human really cancelled" (which takes longer and answers 已取消). The 选择 /redact pick · hint in nodes' tail appears only when allowDialogs: true and tuiDialogs is available, so it never advertises a path you have switched off.


3. Usage

3.1 /redact subcommands

/redact addresses sessions by session id (--session <id>), not by log path; without --session the target is the current session. Its argument hint in the / menu is [list|nodes|pick|scan|hide|plan|apply|verify|purge|rollback|undo] [plan.json] [--lines <n>] [--session <id>] [--commit].

nodes / hide / pick read and write the live session's in-memory surface, so they only accept the current session; pointing --session at another one errors out instead of quietly showing the current session.