dsh-dlp 0.3.0 → 0.4.1

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.
package/lib/policy.js CHANGED
@@ -15,12 +15,13 @@
15
15
  * @module dsh-dlp/policy
16
16
  */
17
17
  import { readFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
18
19
  import { resolve } from 'node:path';
19
20
  import { JSON_SCHEMA, load } from 'js-yaml';
20
21
  import z from '@deepseek-ai/schemastery';
21
- import { SYNC_RULES, severityRank } from "./detectors.js";
22
+ import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js";
22
23
  import { resolveDshHome } from "./home.js";
23
- import { CREDENTIAL_PATH_RULES } from "./paths.js";
24
+ import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
24
25
  export const Config = z.object({
25
26
  auditLog: z.string().required(),
26
27
  redactionKeyFile: z.string().required(),
@@ -31,6 +32,7 @@ export const Config = z.object({
31
32
  telemetryRedaction: z.boolean().default(true),
32
33
  remoteImageNeutralization: z.boolean().default(true),
33
34
  redactTelemetryWorkspacePaths: z.boolean().default(true),
35
+ configWriteAsk: z.boolean().default(true),
34
36
  });
35
37
  /** Config toggles a repo-local policy may switch on, and never off. */
36
38
  const ENABLEABLE = [
@@ -39,6 +41,7 @@ const ENABLEABLE = [
39
41
  'telemetryRedaction',
40
42
  'remoteImageNeutralization',
41
43
  'redactTelemetryWorkspacePaths',
44
+ 'configWriteAsk',
42
45
  ];
43
46
  /** Keys a repo-local policy file may carry; anything else fails the load. */
44
47
  const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
@@ -119,6 +122,12 @@ function parseCredentialPathEntry(node, index) {
119
122
  if (typeof id !== 'string' || id.length === 0) {
120
123
  throw new PolicyError(`addCredentialPaths[${index}].id must be a non-empty string`);
121
124
  }
125
+ // A rule id is quoted verbatim in a model-facing denial and in every audit
126
+ // record the rule produces, and this file is attacker-controlled.
127
+ if (stripControlSequences(id) !== id) {
128
+ throw new PolicyError(`addCredentialPaths[${index}].id carries a terminal control sequence; a rule id is quoted in a denial`
129
+ + ' the user reads and in the audit record, so it may not rewrite what is on screen');
130
+ }
122
131
  if (typeof pattern !== 'string' || pattern.length === 0) {
123
132
  throw new PolicyError(`addCredentialPaths[${index}].pattern must be a non-empty string`);
124
133
  }
@@ -218,10 +227,6 @@ export function loadRepoPolicy(path) {
218
227
  return { kind: 'invalid', problem: String(error) };
219
228
  }
220
229
  }
221
- /** Escape one literal path so it can anchor a regular expression. */
222
- function escapePattern(literal) {
223
- return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
224
- }
225
230
  /**
226
231
  * Deny rules protecting this plugin's own state and the harness home.
227
232
  *
@@ -244,10 +249,10 @@ function escapePattern(literal) {
244
249
  * @returns rules appended after the built-in table.
245
250
  */
246
251
  function selfProtectionRules(config, dshHome) {
247
- const home = escapePattern(resolve(dshHome));
252
+ const home = escapePathPattern(resolve(dshHome));
248
253
  return [
249
- { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.redactionKeyFile))}$`, 'i') },
250
- { id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.auditLog))}$`, 'i') },
254
+ { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.redactionKeyFile))}$`, 'i') },
255
+ { id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.auditLog))}$`, 'i') },
251
256
  { id: 'dsh-dlp/path-dsh-sessions', version: 1, pattern: new RegExp(`^${home}/sessions(/|$)`, 'i') },
252
257
  { id: 'dsh-dlp/path-dsh-home', version: 2, enforcement: 'writes-only', pattern: new RegExp(`^${home}(/|$)`, 'i') },
253
258
  ];
@@ -263,6 +268,7 @@ export function resolvePolicy(config, repo) {
263
268
  return {
264
269
  credentialPathRules: [
265
270
  ...CREDENTIAL_PATH_RULES,
271
+ ...homeCredentialPathRules(resolve(homedir())),
266
272
  ...selfProtectionRules(config, resolveDshHome()),
267
273
  ...repo?.addCredentialPaths ?? [],
268
274
  ],
@@ -277,5 +283,6 @@ export function resolvePolicy(config, repo) {
277
283
  telemetryRedaction: enabled('telemetryRedaction'),
278
284
  remoteImageNeutralization: enabled('remoteImageNeutralization'),
279
285
  redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
286
+ configWriteAsk: enabled('configWriteAsk'),
280
287
  };
281
288
  }
package/lib/sink.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { appendFileSync } from 'node:fs';
18
18
  import { randomUUID } from 'node:crypto';
19
+ import { stripControlSequences } from "./detectors.js";
19
20
  /**
20
21
  * Mint a decision id.
21
22
  * @returns an id unique to one guard verdict or redaction pass.
@@ -25,6 +26,29 @@ export function newDecisionId() {
25
26
  }
26
27
  /** Payload version carried inside every record this plugin writes. */
27
28
  export const RECORD_VERSION = 1;
29
+ /**
30
+ * One record with every string cleaned of terminal control sequences.
31
+ *
32
+ * A record carries strings this plugin did not author — a tool's registered
33
+ * name, a call id, a rule id from the repo-local policy tier — and a reader
34
+ * gets them back unescaped: `JSON.stringify` writes `\u001b` to the file, but
35
+ * `dsh-dlp report`, `jq -r` and any log viewer parse that back into a live
36
+ * escape. A tool named with a CSI sequence could then overwrite the line
37
+ * describing it, which is the forged-audit-record half of CVE-2026-35651.
38
+ * Cleaning here means every consumer of the file gets the cleaned form.
39
+ * @param value - any part of a record.
40
+ * @returns the same structure with control sequences replaced.
41
+ */
42
+ function cleaned(value) {
43
+ if (typeof value === 'string')
44
+ return stripControlSequences(value);
45
+ if (Array.isArray(value))
46
+ return value.map(cleaned);
47
+ if (typeof value === 'object' && value !== null) {
48
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [stripControlSequences(key), cleaned(item)]));
49
+ }
50
+ return value;
51
+ }
28
52
  /** Append-only JSONL sink for this plugin's decisions. */
29
53
  export class AuditSink {
30
54
  #path;
@@ -49,7 +73,7 @@ export class AuditSink {
49
73
  */
50
74
  write(record) {
51
75
  try {
52
- appendFileSync(this.#path, `${JSON.stringify(record)}\n`);
76
+ appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`);
53
77
  }
54
78
  catch (error) {
55
79
  this.#onFailure(error);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The `ask` tier: files whose contents decide how the agent, the editor or CI
3
+ * behaves next time, and the one config *key* whose value redirects a
4
+ * credential.
5
+ *
6
+ * This tier is deliberately **not** on the guard floor, and that is the whole
7
+ * design. `ctx.tools.guard()` has no `ask` arm and cannot be overridden, so a
8
+ * rule that lands there must be one a developer never legitimately trips. A
9
+ * developer asks the agent to edit `CLAUDE.md`, add a `.github/workflows`
10
+ * job or extend `.vscode/settings.json` constantly. Putting those on an
11
+ * unoverridable floor produces a plugin that gets uninstalled, which removes
12
+ * the floor as well.
13
+ *
14
+ * The cost is stated rather than hidden: `tools/pre-execute` is neutralizable.
15
+ * A listener registered ahead of this one can return without calling `next()`
16
+ * and this tier never runs. Only the guard floor is order-independent.
17
+ * @module dsh-dlp/config-writes
18
+ */
19
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
20
+ /** What a behaviour-config rule is matched against. */
21
+ export type ConfigMatch =
22
+ /** A path-typed argument: the file the call would create or change. */
23
+ 'path'
24
+ /** A content-typed argument: the bytes the call would write. */
25
+ | 'content';
26
+ /** One file, or one written value, that changes what happens next time. */
27
+ export interface ConfigWriteRule {
28
+ readonly id: string;
29
+ readonly version: number;
30
+ readonly match: ConfigMatch;
31
+ readonly pattern: RegExp;
32
+ /** What the file or value does, quoted in the prompt the user answers. */
33
+ readonly effect: string;
34
+ }
35
+ /**
36
+ * Paths whose contents change future behaviour, and the config key whose value
37
+ * redirects credentials.
38
+ *
39
+ * Every path here was used in the wild. The Miasma worm wrote `SessionStart`
40
+ * hooks into `.claude/settings.json` and `.gemini/settings.json`, an
41
+ * always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
42
+ * `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
43
+ * GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
44
+ * over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
45
+ * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
46
+ * CVE-2025-59041.
47
+ *
48
+ * The rules match by name, never by what is on disk, so a file the call is
49
+ * about to *create* is matched exactly like one it would change:
50
+ * CVE-2026-25725 worked precisely because the path did not exist yet and was
51
+ * therefore writable without any prompt.
52
+ */
53
+ export declare const CONFIG_WRITE_RULES: readonly ConfigWriteRule[];
54
+ /**
55
+ * Argument keys whose values are the bytes a call would write.
56
+ *
57
+ * Deliberately separate from the floor's path-typed keys: the floor must never
58
+ * run its path table over file content, and this tier must run its one content
59
+ * rule over nothing else.
60
+ */
61
+ export declare const CONTENT_ARGUMENT_KEYS: ReadonlySet<string>;
62
+ /**
63
+ * The strings one call would write.
64
+ * @param args - the pending call's parsed arguments.
65
+ * @returns every string under a content-typed key, at any depth.
66
+ */
67
+ export declare function contentArguments(args: unknown): string[];
68
+ /** A call this tier wants a human to confirm. */
69
+ export interface ConfigWriteFinding {
70
+ readonly rule: ConfigWriteRule;
71
+ /** Model- and user-facing text; names the rule and what the file does, never the path. */
72
+ readonly reason: string;
73
+ }
74
+ /**
75
+ * Decide whether one call should be confirmed by a human.
76
+ *
77
+ * Only calls that can change something are examined: a tool
78
+ * {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
79
+ * command lines are deliberately **not** tokenised here, unlike in the floor:
80
+ * a command line cannot be told apart from a read of the same path, and
81
+ * prompting on `cat .github/workflows/ci.yml` is exactly the false positive
82
+ * that gets a tier switched off. A shell redirection into one of these files
83
+ * is therefore not covered, which README.md says beside the feature.
84
+ * @param exec - the pending call.
85
+ * @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
86
+ * @returns the finding, or `undefined` to leave the call alone.
87
+ */
88
+ export declare function evaluateConfigWrite(exec: Pick<ToolExecution, 'name' | 'arguments'>, rules?: readonly ConfigWriteRule[]): ConfigWriteFinding | undefined;
89
+ //# sourceMappingURL=config-writes.d.ts.map
@@ -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
@@ -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,6 +9,10 @@
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.
@@ -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
@@ -37,10 +37,16 @@ export interface Config {
37
37
  remoteImageNeutralization: boolean;
38
38
  /** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
39
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;
40
46
  }
41
47
  export declare const Config: z<Config>;
42
48
  /** Config toggles a repo-local policy may switch on, and never off. */
43
- declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths"];
49
+ declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk"];
44
50
  /** One toggle name a repo-local policy may name in `enable`. */
45
51
  export type EnableableToggle = typeof ENABLEABLE[number];
46
52
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -63,6 +69,7 @@ export interface ResolvedPolicy {
63
69
  readonly telemetryRedaction: boolean;
64
70
  readonly remoteImageNeutralization: boolean;
65
71
  readonly redactTelemetryWorkspacePaths: boolean;
72
+ readonly configWriteAsk: boolean;
66
73
  }
67
74
  /** Thrown when a policy file is malformed or attempts to loosen the policy. */
68
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' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | '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,6 +60,12 @@ 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;
65
71
  /** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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>",
@@ -51,10 +51,10 @@
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@deepseek-ai/cordis": "4.0.1",
54
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
55
- "@deepseek-ai/dsh-session": "0.1.0-rc.6",
56
- "@deepseek-ai/dsh-session-telemetry": "0.1.0-rc.6",
57
- "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
54
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-session-telemetry": "^0.1.0-rc.6",
57
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
58
58
  },
59
59
  "dependencies": {
60
60
  "@deepseek-ai/schemastery": "3.18.1",