dsh-dlp 0.1.0 → 0.2.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.
@@ -37,6 +37,13 @@ export interface Detection {
37
37
  readonly start: number;
38
38
  /** Exclusive end offset into the scanned string. */
39
39
  readonly end: number;
40
+ /**
41
+ * Set when the offsets cover exactly what must be replaced, so redaction
42
+ * must not widen them to the surrounding delimiters. Only the Unicode
43
+ * indicators set it: their matches are the characters themselves, while a
44
+ * secret's reported span is advisory and verified to under-cover (ADR §4).
45
+ */
46
+ readonly exact?: true;
40
47
  }
41
48
  /** Outcome of one scan. */
42
49
  export interface ScanResult {
@@ -59,11 +66,70 @@ export interface SyncRule {
59
66
  * tier 2, where a false positive costs a redaction rather than a denial.
60
67
  */
61
68
  export declare const SYNC_RULES: readonly SyncRule[];
69
+ /**
70
+ * What the scan does with one class of invisible or direction-changing
71
+ * characters.
72
+ *
73
+ * `strip` classes have no legitimate use in tool output, so they are replaced
74
+ * 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.
77
+ */
78
+ export type UnicodeAction = 'strip' | 'report';
79
+ /** One class of invisible or direction-changing characters. */
80
+ export interface UnicodeRule {
81
+ readonly id: string;
82
+ readonly version: number;
83
+ readonly severity: Severity;
84
+ readonly action: UnicodeAction;
85
+ /** Global, unicode-flagged, matching one run of this class. */
86
+ readonly pattern: RegExp;
87
+ /** The class's ranges as a character-class body, for the combined run pattern. */
88
+ readonly ranges: string;
89
+ }
90
+ /**
91
+ * Character classes that hide text from the reader while the model still reads
92
+ * it, verified against the Unicode character database.
93
+ *
94
+ * Every class is `medium`. These are injection *indicators*, not credentials:
95
+ * the guard floor denies at `high` and above, so an argument carrying one is
96
+ * never denied on that basis. What they buy is a redaction and an audit record
97
+ * on a path the harness does not cover — it strips directional controls in
98
+ * exactly one place, session titles, and never on the tool-result path.
99
+ *
100
+ * Not attempted here: UTS #39 confusables. A Cyrillic `а` needs a data table
101
+ * to detect and is a different cost class, and it defeats every rule in this
102
+ * file. README.md says so rather than implying coverage.
103
+ */
104
+ export declare const UNICODE_RULES: readonly UnicodeRule[];
105
+ /** One indicator match and what the caller should do with it. */
106
+ export interface UnicodeFinding extends Detection {
107
+ readonly action: UnicodeAction;
108
+ }
109
+ /**
110
+ * Find every invisible or direction-changing character in one string.
111
+ *
112
+ * Offsets are UTF-16 indices into `text`, so a caller can splice them
113
+ * directly; they are exact rather than advisory, and {@link Detection.exact}
114
+ * says so.
115
+ * @param text - the string to scan.
116
+ * @returns every indicator run, ordered by start offset.
117
+ */
118
+ export declare function scanUnicode(text: string): UnicodeFinding[];
119
+ /**
120
+ * How many runs of each indicator class one string carries.
121
+ * @param text - the string to scan.
122
+ * @returns a count per rule id; absent means none were found.
123
+ */
124
+ export declare function countUnicodeIndicators(text: string): Record<string, number>;
62
125
  /**
63
126
  * Scan text with tier 1. Pure, synchronous, no I/O, and never capped: a table
64
127
  * of anchored regular expressions costs a linear pass, so there is no reason
65
128
  * to stop scanning where tier 2 has to. `truncated` is therefore always
66
129
  * `false` here and only tier 2 can set it.
130
+ *
131
+ * The `strip` half of {@link UNICODE_RULES} is included, so every seam reading
132
+ * tier 1 — including the synchronous telemetry waterfall — gets it.
67
133
  * @param text - the string to scan.
68
134
  * @param rules - the rule table to apply; defaults to {@link SYNC_RULES}.
69
135
  * @returns every match, ordered by start offset.
@@ -43,10 +43,12 @@ export interface GuardVerdict {
43
43
  * Credential paths are denied for every tool, not only readers: a shell that
44
44
  * can `cat` a key can also copy it. Only path-typed arguments are tested —
45
45
  * running the table over every string matches file content and denies writing
46
- * a `.gitignore` that mentions `.env`. Argument secrets are denied only for
47
- * egress-capable tools, because denying a local editor for holding the text it
48
- * was asked to write would break ordinary work without closing an exfiltration
49
- * path.
46
+ * a `.gitignore` that mentions `.env`. The one exception is a `writes-only`
47
+ * rule, which a tool classified read-only is exempt from; that is how
48
+ * `$DSH_HOME` stays readable while every write to it is denied. Argument
49
+ * secrets are denied only for egress-capable tools, because denying a local
50
+ * editor for holding the text it was asked to write would break ordinary work
51
+ * without closing an exfiltration path.
50
52
  * @param exec - the pending call as the guard stage sees it.
51
53
  * @param policy - the effective policy after the tighten-only merge.
52
54
  * @param hasher - mints the keyed hashes quoted in a denial reason.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Where the harness keeps its state, and where this plugin's audit sink lands
3
+ * by default.
4
+ *
5
+ * Its own module so the `dsh-dlp report` command can resolve the sink without
6
+ * importing the plugin: `policy.ts` pulls in `@secretlint/core`, `js-yaml` and
7
+ * the schema library, none of which a reader of a JSONL file needs.
8
+ * @module dsh-dlp/home
9
+ */
10
+ /**
11
+ * File name the bundle patch gives the audit sink under the harness home.
12
+ * `cordis.patch.yml` spells the same name; a deployment that sets `auditLog`
13
+ * itself must tell `dsh-dlp report` where it put it.
14
+ */
15
+ export declare const DEFAULT_AUDIT_LOG_NAME = "dsh-dlp.audit.jsonl";
16
+ /**
17
+ * Resolve the harness home the same way the harness does: `$DSH_HOME` when it
18
+ * is set to something other than whitespace, otherwise `~/.dsh`. Read here
19
+ * rather than through `@deepseek-ai/dsh-home-paths` to keep the plugin's
20
+ * runtime imports to the ones a profile is guaranteed to resolve.
21
+ * @param env - environment consulted for `DSH_HOME`; defaults to `process.env`.
22
+ * @returns the absolute harness home.
23
+ */
24
+ export declare function resolveDshHome(env?: NodeJS.ProcessEnv): string;
25
+ /**
26
+ * Where the audit sink sits when the deployment did not name one.
27
+ * @param env - environment consulted for `DSH_HOME`; defaults to `process.env`.
28
+ * @returns the absolute path the bundle patch configures.
29
+ */
30
+ export declare function defaultAuditLog(env?: NodeJS.ProcessEnv): string;
31
+ //# sourceMappingURL=home.d.ts.map
@@ -6,12 +6,25 @@
6
6
  * the repo-local policy tier may only add to them.
7
7
  * @module dsh-dlp/paths
8
8
  */
9
+ /**
10
+ * Which calls a credential-path rule is enforced for.
11
+ *
12
+ * `every-call` is the default and the only setting a rule protecting
13
+ * credential *contents* may use. `writes-only` exempts the tools
14
+ * {@link READ_ONLY_TOOLS} classifies as unable to change anything: it exists
15
+ * for a directory whose contents are ordinary work to read and dangerous to
16
+ * modify, which is `$DSH_HOME` — it holds the installed plugin tree and every
17
+ * profile's `cordis.yml`.
18
+ */
19
+ export type RuleEnforcement = 'every-call' | 'writes-only';
9
20
  /** One credential-path pattern. */
10
21
  export interface CredentialPathRule {
11
22
  readonly id: string;
12
23
  readonly version: number;
13
24
  /** Matched against a normalized, forward-slash path. */
14
25
  readonly pattern: RegExp;
26
+ /** Defaults to `every-call`; the repo-local tier cannot set it. */
27
+ readonly enforcement?: RuleEnforcement;
15
28
  }
16
29
  /**
17
30
  * Paths whose contents are credentials. Reading any of them through a tool is
@@ -120,4 +133,29 @@ export declare const LOCAL_TOOLS: ReadonlySet<string>;
120
133
  * @returns `true` when the tool is not a known local-only tool.
121
134
  */
122
135
  export declare function isEgressCapable(toolName: string, extraEgressTools?: ReadonlySet<string>): boolean;
136
+ /**
137
+ * Tools that can only look: they query the filesystem, the language server,
138
+ * the session store or a running job, and have no operation that changes
139
+ * anything.
140
+ *
141
+ * A `writes-only` rule is lifted for these names and for no others. Every
142
+ * shell, `run_code`, every editor, every `mcp__*` tool and any tool this build
143
+ * has never heard of stays on the deny side, so a new tool is denied until it
144
+ * is classified — the same default as {@link LOCAL_TOOLS}, in the same
145
+ * direction.
146
+ */
147
+ export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
148
+ /**
149
+ * Whether a tool is known to be incapable of changing anything.
150
+ * @param toolName - the executing tool's registered name.
151
+ * @returns `true` only for a name in {@link READ_ONLY_TOOLS}.
152
+ */
153
+ export declare function isReadOnlyTool(toolName: string): boolean;
154
+ /**
155
+ * The credential-path rules one tool is judged against.
156
+ * @param toolName - the executing tool's registered name.
157
+ * @param rules - the effective rule table.
158
+ * @returns every rule, minus the `writes-only` ones for a read-only tool.
159
+ */
160
+ export declare function rulesForTool(toolName: string, rules: readonly CredentialPathRule[]): readonly CredentialPathRule[];
123
161
  //# sourceMappingURL=paths.d.ts.map
@@ -108,15 +108,6 @@ export type RepoPolicyLoad =
108
108
  * @returns the validated policy, its absence, or the problem to report.
109
109
  */
110
110
  export declare function loadRepoPolicy(path: string): RepoPolicyLoad;
111
- /**
112
- * Resolve the harness home the same way the harness does: `$DSH_HOME` when it
113
- * is set to something other than whitespace, otherwise `~/.dsh`. Read here
114
- * rather than through `@deepseek-ai/dsh-home-paths` to keep the plugin's
115
- * runtime imports to the ones a profile is guaranteed to resolve.
116
- * @param env - environment consulted for `DSH_HOME`; defaults to `process.env`.
117
- * @returns the absolute harness home.
118
- */
119
- export declare function resolveDshHome(env?: NodeJS.ProcessEnv): string;
120
111
  /**
121
112
  * Merge the deployment config with an optional repo-local policy.
122
113
  * @param config - the deployment-controlled configuration.
@@ -10,7 +10,7 @@
10
10
  * whole rule set applies here and not in the guard.
11
11
  * @module dsh-dlp/results
12
12
  */
13
- import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools';
13
+ import type { JsonSchemaNode, PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools';
14
14
  import type { ResolvedPolicy } from './policy.ts';
15
15
  import { type RedactedSpan, type SpanHasher } from './redaction.ts';
16
16
  /** What a redaction pass produced, before an arm is chosen. */
@@ -18,6 +18,13 @@ export interface ResultRedaction {
18
18
  readonly decision: PostToolDecision;
19
19
  readonly spans: readonly RedactedSpan[];
20
20
  readonly truncatedScan: boolean;
21
+ /**
22
+ * Runs of each invisible-character class the result carried, by rule id.
23
+ * The `strip` classes also appear in `spans`; the `report` classes appear
24
+ * only here, because rewriting them would corrupt legitimate emoji and
25
+ * right-to-left text.
26
+ */
27
+ readonly indicators: Readonly<Record<string, number>>;
21
28
  }
22
29
  /**
23
30
  * Redact whatever the downstream decision settled on.
@@ -36,10 +43,11 @@ export interface ResultRedaction {
36
43
  * carries a secret, or a value that still scans dirty after redaction.
37
44
  * Blocking replaces the whole result, which is the only way to drop `meta`.
38
45
  *
39
- * Replacing the value can fail: the placeholder is re-validated against the
40
- * tool's `output.schema`, and a schema that constrains the string rejects it,
41
- * which the registry reports as a `ToolOutputError`. A failed call is the
42
- * intended outcome there — see README.md.
46
+ * Replacing the value is re-validated by the registry against the tool's
47
+ * `output.schema`, and a schema that pins the redacted string rejects it. That
48
+ * surfaces as a `ToolOutputError` naming a validation failure, which tells the
49
+ * model nothing it can act on, so the schema is checked here first and the
50
+ * result is withheld with this plugin's own explanation instead.
43
51
  *
44
52
  * A downstream `accept{content}` over a dirty value is overruled by the value
45
53
  * arm, which discards that listener's presentation choice. Keeping it would
@@ -49,9 +57,10 @@ export interface ResultRedaction {
49
57
  * @param result - the dispatch outcome the waterfall was called with.
50
58
  * @param policy - the effective policy.
51
59
  * @param hasher - mints each span's keyed hash.
60
+ * @param outputSchema - the executing tool's declared output schema, when one could be resolved.
52
61
  * @returns the decision to return, the spans replaced, and scan completeness.
53
62
  */
54
- export declare function redactDecision(decision: PostToolDecision, result: Readonly<ToolExecutionResult>, policy: ResolvedPolicy, hasher: SpanHasher): Promise<ResultRedaction>;
63
+ export declare function redactDecision(decision: PostToolDecision, result: Readonly<ToolExecutionResult>, policy: ResolvedPolicy, hasher: SpanHasher, outputSchema?: JsonSchemaNode): Promise<ResultRedaction>;
55
64
  /**
56
65
  * Decide whether the breadth tier denies one call before dispatch.
57
66
  *
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Whether a redacted value still satisfies the tool's declared `output.schema`.
3
+ *
4
+ * Replacing a canonical value makes the registry re-validate it, so a schema
5
+ * that pins the redacted string turns a redaction into a `ToolOutputError` the
6
+ * model cannot act on. Asking the question first lets the listener withhold
7
+ * the result with its own explanation instead.
8
+ *
9
+ * This is a re-implementation of the harness's own check rather than a call
10
+ * into it: every harness type this package uses is imported with `import
11
+ * type`, so nothing from `@deepseek-ai/dsh-*` is emitted as a runtime import
12
+ * and the plugin resolves from a profile directory that has none of them
13
+ * installed. The enforced subset is small — `type`, `oneOf`, `properties`,
14
+ * `required`, `additionalProperties`, `items`, `enum`, `const` — and the
15
+ * caller guards against any disagreement by checking the *original* value
16
+ * first: a value this module rejects before redaction means the answer cannot
17
+ * be trusted, and the redaction proceeds as it did before.
18
+ * @module dsh-dlp/schema
19
+ */
20
+ import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools';
21
+ /**
22
+ * Whether one value satisfies one schema node.
23
+ * @param node - a node of the tool's declared output schema.
24
+ * @param value - the candidate value.
25
+ * @returns `true` when the registry's own validation would accept it.
26
+ */
27
+ export declare function satisfiesJsonSchema(node: JsonSchemaNode, value: unknown): boolean;
28
+ /**
29
+ * Whether replacing a value with its redacted copy would fail the tool's
30
+ * output validation.
31
+ *
32
+ * A schema this module already rejects for the original value is one it does
33
+ * not model correctly, so the answer is `false` and the registry decides — the
34
+ * check can withhold a result, and it must never do so on its own confusion.
35
+ * @param schema - the tool's declared output schema, when one could be resolved.
36
+ * @param original - the value the tool produced.
37
+ * @param redacted - the value the redaction pass produced.
38
+ * @returns `true` only when the original validates and the redacted one does not.
39
+ */
40
+ export declare function redactionBreaksSchema(schema: JsonSchemaNode | undefined, original: unknown, redacted: unknown): boolean;
41
+ //# sourceMappingURL=schema.d.ts.map
@@ -54,6 +54,12 @@ export interface AuditRecord {
54
54
  readonly spans?: readonly RedactedSpan[];
55
55
  /** Set when the scanned input exceeded the byte cap. */
56
56
  readonly truncatedScan?: boolean;
57
+ /**
58
+ * Runs of each invisible-character class the scanned text carried, by rule
59
+ * id. Counts only: the characters themselves are content, and a hidden
60
+ * instruction is exactly the content this file must not repeat.
61
+ */
62
+ readonly unicode?: Readonly<Record<string, number>>;
57
63
  /** Telemetry record channel, for `telemetry-redaction`. */
58
64
  readonly channel?: string;
59
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.1.0",
3
+ "version": "0.2.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>",
@@ -27,6 +27,9 @@
27
27
  "type": "module",
28
28
  "main": "lib/index.js",
29
29
  "types": "lib/types/index.d.ts",
30
+ "bin": {
31
+ "dsh-dlp": "lib/cli.js"
32
+ },
30
33
  "exports": {
31
34
  ".": {
32
35
  "types": "./lib/types/index.d.ts",