dsh-dlp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,6 +64,11 @@ export interface SyncRule {
64
64
  * delimiters make a match structurally unambiguous, plus PEM blocks and
65
65
  * credential-bearing URLs. Anything requiring entropy heuristics is left to
66
66
  * tier 2, where a false positive costs a redaction rather than a denial.
67
+ *
68
+ * Prefix-anchored is the whole membership criterion, and the reason this table
69
+ * keeps growing rather than deferring to tier 2: the `session-telemetry/record`
70
+ * waterfall is synchronous and cannot reach tier 2 at all, so a format missing
71
+ * here is exported in the clear when telemetry is on.
67
72
  */
68
73
  export declare const SYNC_RULES: readonly SyncRule[];
69
74
  /**
@@ -72,8 +77,14 @@ export declare const SYNC_RULES: readonly SyncRule[];
72
77
  *
73
78
  * `strip` classes have no legitimate use in tool output, so they are replaced
74
79
  * like any other detection. `report` classes do: `U+200D` joins an emoji
75
- * sequence and a variation selector chooses a glyph, so replacing them would
76
- * corrupt ordinary text. They are counted and never rewritten.
80
+ * sequence, a variation selector chooses a glyph, and a CSI sequence colours
81
+ * the output of `git diff`, so replacing them would corrupt ordinary text.
82
+ * They are counted and never rewritten.
83
+ *
84
+ * The split is per class and per lane both: {@link stripControlSequences}
85
+ * applies the `strip` treatment to a `report` class on the audit and
86
+ * approval-facing lanes, where colour buys nothing and a forged record costs
87
+ * everything.
77
88
  */
78
89
  export type UnicodeAction = 'strip' | 'report';
79
90
  /** One class of invisible or direction-changing characters. */
@@ -84,12 +95,37 @@ export interface UnicodeRule {
84
95
  readonly action: UnicodeAction;
85
96
  /** Global, unicode-flagged, matching one run of this class. */
86
97
  readonly pattern: RegExp;
87
- /** The class's ranges as a character-class body, for the combined run pattern. */
88
- readonly ranges: string;
98
+ /**
99
+ * The class's ranges as a character-class body, for the combined run pattern.
100
+ *
101
+ * Absent for a class whose matches are not a run of one character class —
102
+ * a terminal control sequence has an ASCII body — which is scanned over the
103
+ * whole input instead of within a combined run.
104
+ */
105
+ readonly ranges?: string;
89
106
  }
107
+ /**
108
+ * Text substituted for a stripped control sequence. Visible on purpose: the
109
+ * lanes that strip are the ones an operator reads as evidence, and silently
110
+ * deleting the bytes would hide that a forgery was attempted.
111
+ */
112
+ export declare const CONTROL_SEQUENCE_PLACEHOLDER = "[REDACTED:dsh-dlp:control-sequence]";
113
+ /**
114
+ * Remove every terminal control sequence from one string.
115
+ *
116
+ * This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
117
+ * lanes that must never carry forgeable bytes: an audit record and the strings
118
+ * an operator or an approval prompt reads back. Ordinary tool-result text takes
119
+ * the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
120
+ * colourise their output.
121
+ * @param text - the string to clean.
122
+ * @returns the string with each control sequence replaced by a visible marker.
123
+ */
124
+ export declare function stripControlSequences(text: string): string;
90
125
  /**
91
126
  * Character classes that hide text from the reader while the model still reads
92
- * it, verified against the Unicode character database.
127
+ * it, verified against the Unicode character database, plus the terminal
128
+ * control sequences that show the reader something other than what is there.
93
129
  *
94
130
  * Every class is `medium`. These are injection *indicators*, not credentials:
95
131
  * the guard floor denies at `high` and above, so an argument carrying one is
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Neutralising remote markdown images in assistant output, on the `llm/stream`
3
+ * waterfall.
4
+ *
5
+ * The web UI renders any absolute `http:`/`https:` markdown image a model
6
+ * emits as a real `<img src>`, and the harness sets no Content-Security-Policy,
7
+ * so the fetch happens in the user's browser where no host-side listener can
8
+ * see it. This module rewrites the destination out of the assistant's text
9
+ * before it becomes an `assistant/chunk` or `assistant/message` session event,
10
+ * so the log and the rendered answer stay in agreement.
11
+ *
12
+ * Two properties this module exists to hold:
13
+ *
14
+ * - **A destination split across chunks is still caught.** The mock and real
15
+ * adapters both emit text in small deltas, so `![alt](https://host/x)` is
16
+ * routinely spread over several of them and the browser renders the
17
+ * accumulation. Text that could still be the start of an image is held back
18
+ * until it either completes or exceeds {@link MAX_HELD_CHARACTERS}.
19
+ * - **Only the destination is replaced.** The alt text survives, so the
20
+ * sentence the model wrote still reads, and the renderer's own
21
+ * non-absolute-URL arm shows that alt text instead of fetching anything.
22
+ *
23
+ * This does not close the channel. It matches inline image syntax only:
24
+ * reference-style images, an alt text carrying a `]`, and any destination form
25
+ * the pattern does not model still reach the renderer. Raw HTML needs no
26
+ * handling — the renderer keeps it as literal text and no HTML enters the DOM
27
+ * (`packages/client/ui-primitives/src/markdown/render.tsx:261-263`). The
28
+ * upstream fix is one `img-src` directive.
29
+ * @module dsh-dlp/images
30
+ */
31
+ import type { StreamChunk } from '@deepseek-ai/dsh-llm';
32
+ /**
33
+ * Destination substituted for a remote image URL.
34
+ *
35
+ * It is deliberately not a URL: `new URL()` throws on it, which is the
36
+ * renderer's own "not an absolute destination" arm, and that arm renders the
37
+ * alt text as a `<span>` instead of emitting an `<img>`.
38
+ */
39
+ export declare const BLOCKED_IMAGE_DESTINATION = "dsh-dlp-blocked-remote-image";
40
+ /**
41
+ * Longest suffix held back waiting for an image to complete.
42
+ *
43
+ * Held text is text the user cannot see yet, so the wait is bounded: past this
44
+ * many characters the suffix is emitted as it stands and a destination that
45
+ * completes later is caught only by the assembled block. A protocol bound on
46
+ * this module's own buffering, not a deployment choice.
47
+ */
48
+ export declare const MAX_HELD_CHARACTERS = 4096;
49
+ /** One string after its remote image destinations were replaced. */
50
+ export interface NeutralizedText {
51
+ readonly text: string;
52
+ /** Hostnames of the replaced destinations, in match order; never the full URL. */
53
+ readonly hosts: readonly string[];
54
+ }
55
+ /**
56
+ * Replace every absolute HTTP(S) inline image destination in one string.
57
+ * @param text - assistant text, whole or partial.
58
+ * @returns the rewritten text and the hosts whose destinations were replaced.
59
+ */
60
+ export declare function neutralizeRemoteImages(text: string): NeutralizedText;
61
+ /**
62
+ * Where the held suffix of a partially streamed string starts.
63
+ * @param text - everything accumulated for one block and not yet emitted.
64
+ * @returns the offset to emit up to; the string's length when nothing is held.
65
+ */
66
+ export declare function heldSuffixStart(text: string): number;
67
+ /**
68
+ * Wrap one model stream, replacing remote image destinations in its text.
69
+ *
70
+ * Text deltas are rewritten as they pass, with a possible image start held
71
+ * back until it resolves, and the assembled block on `block-end` — which is
72
+ * what the agent loop turns into the assistant message — is rewritten too. A
73
+ * held suffix is always flushed as a delta before the block closes and before
74
+ * the terminal finish, so no text is lost and the emitted chunks still satisfy
75
+ * the stream grammar.
76
+ * @param source - the stream from the rest of the waterfall.
77
+ * @param onNeutralized - notified once per host per text block.
78
+ * @returns the rewritten stream.
79
+ */
80
+ export declare function neutralizeImageStream(source: AsyncIterable<StreamChunk>, onNeutralized: (host: string) => void): AsyncIterable<StreamChunk>;
81
+ //# sourceMappingURL=images.d.ts.map
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Four registrations, in descending order of how much they can be trusted:
4
+ * Six registrations, in descending order of how much they can be trusted:
5
5
  *
6
6
  * 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
7
7
  * credential paths named in a path-typed argument and for secrets heading
@@ -9,11 +9,24 @@
9
9
  * has no allow arm.
10
10
  * 2. `tools/pre-execute` — the async breadth tier, which can await
11
11
  * `@secretlint/core`. Neutralizable by any listener registered ahead of it.
12
+ * 2b. `tools/pre-execute` — the `ask` tier for writes to behaviour-changing
13
+ * config paths. Deliberately here rather than on the floor: its rules have a
14
+ * real false-positive rate and the floor cannot ask. Neutralizable, and it
15
+ * abstains entirely when no approval service is mounted.
12
16
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
13
17
  * session event is appended, so the durable log records the redacted copy;
14
18
  * a result that cannot be cleaned is withheld rather than accepted.
15
19
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
16
20
  * reaching tier 1 only because the waterfall is synchronous.
21
+ * 5. `llm/stream` — neutralising remote markdown image destinations in
22
+ * assistant output, before the text becomes a session event.
23
+ *
24
+ * Three of those registrations mitigate defects in the harness rather than in
25
+ * a deployment's own configuration: the missing Content-Security-Policy behind
26
+ * (5), the mutable execution object behind the guard's mutation check, and the
27
+ * silently inert telemetry seam behind the notice reported at mount. Each one
28
+ * is partial, none closes its channel, and README.md says so beside the
29
+ * feature.
17
30
  *
18
31
  * This plugin is not a containment boundary. It runs in-process at the agent's
19
32
  * own uid; anything the agent can execute can read the same files the guard
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Detecting a tool call that was rewritten after it was logged.
3
+ *
4
+ * The registry deep-freezes `exec.arguments` but does not freeze the execution
5
+ * object until results are notified, so a `tools/pre-execute` listener can
6
+ * reassign `exec.arguments` or `exec.name` — and reassigning `exec.name`
7
+ * changes which tool body runs. The agent loop appended `tool/call` from the
8
+ * model's own response block before the waterfall ran, so nothing in the
9
+ * session log records the change: the durable record then describes a
10
+ * different call than the one about to execute.
11
+ *
12
+ * This module snapshots the call as early in the waterfall as it can and
13
+ * compares in the guard, which runs after the whole waterfall and cannot be
14
+ * out-ordered. It is detection, not prevention: preventing the rewrite would
15
+ * mean freezing an object this plugin does not own, and the snapshot itself is
16
+ * best-effort — a later `{ prepend: true }` registration runs ahead of ours and
17
+ * would be snapshotted after its own rewrite.
18
+ * @module dsh-dlp/mutation
19
+ */
20
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
21
+ import type { SpanHasher } from './redaction.ts';
22
+ /** The parts of an execution this module compares. */
23
+ type Comparable = Pick<ToolExecution, 'name' | 'arguments'>;
24
+ /** One rewritten field. */
25
+ export type MutatedField = 'name' | 'arguments';
26
+ /** A call whose identity changed between the snapshot and the guard. */
27
+ export interface ExecutionMutation {
28
+ /** Which fields differ, in a stable order. */
29
+ readonly fields: readonly MutatedField[];
30
+ /** The tool name the session log recorded. */
31
+ readonly originalTool: string;
32
+ }
33
+ /**
34
+ * Render a JSON value with object keys in a fixed order, so two equal argument
35
+ * sets hash equally whatever order a listener rebuilt them in.
36
+ * @param value - the argument value; JSON-serializable by the registry's own snapshot step.
37
+ * @returns a canonical string for hashing.
38
+ */
39
+ export declare function canonicalJson(value: unknown): string;
40
+ /**
41
+ * Remembers what each pending call looked like before the rest of the
42
+ * `tools/pre-execute` waterfall ran.
43
+ *
44
+ * Keyed by the execution object's identity in a `WeakMap`, the way the
45
+ * registry keys its own per-execution state, so the entry is found again in
46
+ * the guard and released with the execution.
47
+ */
48
+ export declare class ExecutionSnapshots {
49
+ #private;
50
+ /**
51
+ * @param hasher - mints the keyed digest of the arguments; the values themselves are never stored.
52
+ */
53
+ constructor(hasher: SpanHasher);
54
+ /**
55
+ * Snapshot one pending call.
56
+ * @param exec - the execution as the earliest listener sees it.
57
+ */
58
+ record(exec: Comparable): void;
59
+ /**
60
+ * Compare one call against its snapshot.
61
+ *
62
+ * A call with no snapshot is not a finding: the listener may never have run
63
+ * for it, and reporting absence as mutation would deny calls this plugin
64
+ * simply did not observe.
65
+ * @param exec - the execution as the guard stage sees it.
66
+ * @returns what changed, or `undefined` when nothing did.
67
+ */
68
+ detect(exec: Comparable): ExecutionMutation | undefined;
69
+ }
70
+ /**
71
+ * Denial text for a rewritten call.
72
+ *
73
+ * Both tool names are named: a tool name is already in the session log and in
74
+ * every other denial this plugin writes, and naming them is the whole point —
75
+ * the operator needs to know which call the log describes and which one was
76
+ * about to run. No argument value appears.
77
+ * @param exec - the call as the guard sees it, after the rewrite.
78
+ * @param mutation - the fields that changed and the recorded tool name.
79
+ * @returns the model-facing reason.
80
+ */
81
+ export declare function mutationReason(exec: Comparable, mutation: ExecutionMutation): string;
82
+ export {};
83
+ //# sourceMappingURL=mutation.d.ts.map
@@ -40,6 +40,31 @@ export interface CredentialPathRule {
40
40
  * authenticates with is agent-readable. That is the gap this table closes.
41
41
  */
42
42
  export declare const CREDENTIAL_PATH_RULES: readonly CredentialPathRule[];
43
+ /**
44
+ * Escape one literal path so it can anchor a regular expression.
45
+ * @param literal - the path to quote.
46
+ * @returns the same text with every metacharacter escaped.
47
+ */
48
+ export declare function escapePathPattern(literal: string): string;
49
+ /**
50
+ * Credential-path rules anchored at the user's home directory, resolved at
51
+ * mount because the directory is not known until then.
52
+ *
53
+ * A coding agent's *home* configuration decides how every future session in
54
+ * every repository behaves: the Miasma worm's `SessionStart` hooks went into
55
+ * exactly these files. Writing one is never ordinary repository work, so it is
56
+ * on the floor. Reading one is — a user asking the agent why its own
57
+ * configuration behaves a certain way is a normal request — so the rule is
58
+ * `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
59
+ * stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
60
+ *
61
+ * The *repository-local* copies of these same file names are a different
62
+ * question with a different answer: they are edited legitimately and often, so
63
+ * they sit in the neutralizable `ask` tier rather than on the floor.
64
+ * @param home - the user's home directory.
65
+ * @returns rules appended after the built-in table.
66
+ */
67
+ export declare function homeCredentialPathRules(home: string): readonly CredentialPathRule[];
43
68
  /**
44
69
  * Normalize one candidate path for matching: Windows separators become
45
70
  * forward slashes, surrounding quotes come off, `~` expands to a
@@ -33,12 +33,20 @@ export interface Config {
33
33
  resultRedaction: boolean;
34
34
  /** Whether `session-telemetry/record` redaction runs. */
35
35
  telemetryRedaction: boolean;
36
+ /** Whether remote markdown image destinations are neutralised in assistant output. */
37
+ remoteImageNeutralization: boolean;
36
38
  /** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
37
39
  redactTelemetryWorkspacePaths: boolean;
40
+ /**
41
+ * Whether a write to a behaviour-changing config path asks the user first.
42
+ * Needs an approval service; without one the tier abstains rather than
43
+ * letting an `ask` degrade into a denial.
44
+ */
45
+ configWriteAsk: boolean;
38
46
  }
39
47
  export declare const Config: z<Config>;
40
48
  /** Config toggles a repo-local policy may switch on, and never off. */
41
- declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "redactTelemetryWorkspacePaths"];
49
+ declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk"];
42
50
  /** One toggle name a repo-local policy may name in `enable`. */
43
51
  export type EnableableToggle = typeof ENABLEABLE[number];
44
52
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -59,7 +67,9 @@ export interface ResolvedPolicy {
59
67
  readonly breadthTier: boolean;
60
68
  readonly resultRedaction: boolean;
61
69
  readonly telemetryRedaction: boolean;
70
+ readonly remoteImageNeutralization: boolean;
62
71
  readonly redactTelemetryWorkspacePaths: boolean;
72
+ readonly configWriteAsk: boolean;
63
73
  }
64
74
  /** Thrown when a policy file is malformed or attempts to loosen the policy. */
65
75
  export declare class PolicyError extends Error {
@@ -28,7 +28,7 @@ export declare function newDecisionId(): DecisionId;
28
28
  /** Payload version carried inside every record this plugin writes. */
29
29
  export declare const RECORD_VERSION = 1;
30
30
  /** What produced one audit record. */
31
- export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'result-redaction' | 'telemetry-redaction' | 'audit-failure';
31
+ export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
32
32
  /** One durable record. Never carries matched secret text. */
33
33
  export interface AuditRecord {
34
34
  readonly v: number;
@@ -60,8 +60,25 @@ export interface AuditRecord {
60
60
  * instruction is exactly the content this file must not repeat.
61
61
  */
62
62
  readonly unicode?: Readonly<Record<string, number>>;
63
+ /**
64
+ * The single rule behind a decision that has no matched region to describe,
65
+ * which is every `pre-execute-ask`: the finding is that a path names a
66
+ * behaviour-changing file, not that any part of it matched a secret.
67
+ */
68
+ readonly ruleId?: string;
63
69
  /** Telemetry record channel, for `telemetry-redaction`. */
64
70
  readonly channel?: string;
71
+ /** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
72
+ readonly mutatedFields?: readonly string[];
73
+ /** Tool name the session log recorded, when a rewrite changed it. */
74
+ readonly originalTool?: string;
75
+ /**
76
+ * Hostname of a neutralised remote image destination, for
77
+ * `assistant-image-neutralized`. The hostname only: a path and a query
78
+ * string are where an exfiltration payload rides, and this file must not
79
+ * carry it.
80
+ */
81
+ readonly host?: string;
65
82
  }
66
83
  /** Append-only JSONL sink for this plugin's decisions. */
67
84
  export declare class AuditSink {
@@ -25,9 +25,24 @@
25
25
  * workspace path.
26
26
  * @module dsh-dlp/telemetry
27
27
  */
28
- import type { SessionTelemetryRecord } from '@deepseek-ai/dsh-session-telemetry';
28
+ import type { SessionTelemetryRecord, SessionTelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry';
29
29
  import type { ResolvedPolicy } from './policy.ts';
30
30
  import { type RedactedSpan, type SpanHasher } from './redaction.ts';
31
+ /**
32
+ * What to tell the operator when the redaction seam will never dispatch.
33
+ *
34
+ * A `session-telemetry/record` listener mounts successfully and never runs
35
+ * unless a backend built a coordinator, and the shipped default builds none:
36
+ * the mode is `DISABLED`, so nothing is exported and nothing is dispatched.
37
+ * That is the safe posture, not a leak — but an operator who mounts a redactor
38
+ * under it sees every signal of success and has verified nothing. The
39
+ * backend's own `sharing` disclosure is the resolved answer, so this never
40
+ * guesses at `DSH_TELEMETRY_MODE`, which is only the base patch's default
41
+ * expression for a `mode` a deployment can also set directly.
42
+ * @param sharing - the mounted backend's disclosure, or `undefined` when no backend is mounted.
43
+ * @returns the line to report, or `undefined` when the seam does dispatch.
44
+ */
45
+ export declare function telemetrySeamNotice(sharing: SessionTelemetrySharingStatus | undefined): string | undefined;
31
46
  /** Rule identity recorded when a workspace path is replaced. */
32
47
  export declare const WORKSPACE_PATH_RULE = "dsh-dlp/telemetry-workspace-path";
33
48
  /** One redacted telemetry record and what was replaced in it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Data-loss-prevention plugin for DeepSeek Harness: a non-configurable tool guard floor, tool-result redaction, and fail-closed telemetry redaction",
5
5
  "license": "MIT",
6
6
  "author": "Ivan Tyshchenko <nsof@protonmail.com>",