dsh-dlp 0.1.0 → 0.3.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.
package/lib/telemetry.js CHANGED
@@ -27,6 +27,35 @@
27
27
  */
28
28
  import { scanSync } from "./detectors.js";
29
29
  import { placeholderFor, redactJson, redactText } from "./redaction.js";
30
+ /**
31
+ * What to tell the operator when the redaction seam will never dispatch.
32
+ *
33
+ * A `session-telemetry/record` listener mounts successfully and never runs
34
+ * unless a backend built a coordinator, and the shipped default builds none:
35
+ * the mode is `DISABLED`, so nothing is exported and nothing is dispatched.
36
+ * That is the safe posture, not a leak — but an operator who mounts a redactor
37
+ * under it sees every signal of success and has verified nothing. The
38
+ * backend's own `sharing` disclosure is the resolved answer, so this never
39
+ * guesses at `DSH_TELEMETRY_MODE`, which is only the base patch's default
40
+ * expression for a `mode` a deployment can also set directly.
41
+ * @param sharing - the mounted backend's disclosure, or `undefined` when no backend is mounted.
42
+ * @returns the line to report, or `undefined` when the seam does dispatch.
43
+ */
44
+ export function telemetrySeamNotice(sharing) {
45
+ const consequence = 'nothing dispatches the session-telemetry/record waterfall and this plugin\'s telemetry'
46
+ + ' redaction never runs. Nothing is exported in this state, so this is not a leak — it means the redaction'
47
+ + ' rules are unverified, and they begin running the moment telemetry is turned on. Informational only: the'
48
+ + ' plugin\'s other seams are unaffected.';
49
+ switch (sharing) {
50
+ case 'disabled':
51
+ return 'dsh-dlp: telemetryRedaction is enabled, but the mounted session-telemetry backend reports sharing'
52
+ + ` "disabled", so ${consequence}`;
53
+ case undefined:
54
+ return `dsh-dlp: telemetryRedaction is enabled, but no session-telemetry backend is mounted, so ${consequence}`;
55
+ default:
56
+ return undefined;
57
+ }
58
+ }
30
59
  /** Attribute keys whose values are filesystem paths rather than payload text. */
31
60
  const PATH_ATTRIBUTES = ['session.cwd'];
32
61
  /** Rule identity recorded when a workspace path is replaced. */
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-dlp report` — read this plugin's audit JSONL and say what it decided.
4
+ *
5
+ * The sink is the only evidence a decision happened, and nothing read it: a
6
+ * user could not answer "what did this block today?". This command reads the
7
+ * file directly and imports nothing from the harness, so it runs wherever the
8
+ * package is installed, with no profile and no `dsh` on the path.
9
+ *
10
+ * The file is a durable boundary — written by an older version of this
11
+ * package, appended to under crash — so every line is parsed defensively and a
12
+ * line that is not a record is counted rather than trusted.
13
+ * @module dsh-dlp/cli
14
+ */
15
+ /** One line of the audit file, after the fields this command reads are checked. */
16
+ export interface ReportRecord {
17
+ /** Epoch milliseconds parsed from the record's ISO time. */
18
+ readonly time: number;
19
+ readonly kind: string;
20
+ readonly tool?: string;
21
+ readonly sessionId?: string;
22
+ /** Rule ids named by the record's spans, without repeats. */
23
+ readonly ruleIds: readonly string[];
24
+ /** Invisible-character runs by rule id. */
25
+ readonly unicode: Readonly<Record<string, number>>;
26
+ }
27
+ /**
28
+ * Parse one JSONL line into the fields this command reports on.
29
+ * @param line - one line of the audit file.
30
+ * @returns the record, or `undefined` when the line is not one.
31
+ */
32
+ export declare function parseRecord(line: string): ReportRecord | undefined;
33
+ /** What `report` was asked for. */
34
+ export interface ReportOptions {
35
+ readonly log: string;
36
+ /** Epoch milliseconds; records before it are left out. */
37
+ readonly since?: number;
38
+ readonly session?: string;
39
+ /** Keep only the decisions that let the call through. */
40
+ readonly wouldHave: boolean;
41
+ }
42
+ /** The outcome of reading the command line. */
43
+ export type Invocation = {
44
+ readonly kind: 'report';
45
+ readonly options: ReportOptions;
46
+ } | {
47
+ readonly kind: 'help';
48
+ } | {
49
+ readonly kind: 'error';
50
+ readonly message: string;
51
+ };
52
+ /** Text printed for `--help` and alongside a usage error. */
53
+ export declare const USAGE: string;
54
+ /**
55
+ * Read `--since`: an ISO timestamp, or a span back from now.
56
+ * @param value - the argument as written.
57
+ * @param now - epoch milliseconds a relative span counts back from.
58
+ * @returns epoch milliseconds, or `undefined` when the value is neither.
59
+ */
60
+ export declare function parseSince(value: string, now: number): number | undefined;
61
+ /**
62
+ * Read the command line.
63
+ * @param argv - arguments after the program name.
64
+ * @param env - environment used for the default sink path.
65
+ * @param now - epoch milliseconds a relative `--since` counts back from.
66
+ * @returns what to run, or the usage error to print.
67
+ */
68
+ export declare function parseArguments(argv: readonly string[], env: NodeJS.ProcessEnv, now: number): Invocation;
69
+ /** Outcome of reading the audit file. */
70
+ export type AuditFileRead =
71
+ /** No file at that path: nothing has been recorded, or the sink is elsewhere. */
72
+ {
73
+ readonly kind: 'absent';
74
+ } | {
75
+ readonly kind: 'unreadable';
76
+ readonly problem: string;
77
+ } | {
78
+ readonly kind: 'read';
79
+ readonly records: readonly ReportRecord[];
80
+ /** Lines that were not records; a torn final append is the expected cause. */
81
+ readonly unreadable: number;
82
+ };
83
+ /**
84
+ * Read and parse the audit file.
85
+ * @param path - the file to read.
86
+ * @returns its records, its absence, or the problem to print.
87
+ */
88
+ export declare function readAuditFile(path: string): AuditFileRead;
89
+ /**
90
+ * Render the report.
91
+ * @param records - every record the file yielded.
92
+ * @param unreadable - how many of its lines were not records.
93
+ * @param options - the filters the invocation asked for.
94
+ * @returns the lines to print.
95
+ */
96
+ export declare function formatReport(records: readonly ReportRecord[], unreadable: number, options: ReportOptions): string[];
97
+ /**
98
+ * Run one invocation.
99
+ * @param argv - arguments after the program name.
100
+ * @param write - receives each line of output.
101
+ * @param fail - receives each line of error output.
102
+ * @param env - environment used for the default sink path.
103
+ * @param now - epoch milliseconds a relative `--since` counts back from.
104
+ * @returns the process exit code.
105
+ */
106
+ export declare function main(argv: readonly string[], write: (line: string) => void, fail: (line: string) => void, env?: NodeJS.ProcessEnv, now?: number): number;
107
+ //# sourceMappingURL=cli.d.ts.map
@@ -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
@@ -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
@@ -14,6 +14,15 @@
14
14
  * a result that cannot be cleaned is withheld rather than accepted.
15
15
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
16
16
  * reaching tier 1 only because the waterfall is synchronous.
17
+ * 5. `llm/stream` — neutralising remote markdown image destinations in
18
+ * assistant output, before the text becomes a session event.
19
+ *
20
+ * Three of those registrations mitigate defects in the harness rather than in
21
+ * a deployment's own configuration: the missing Content-Security-Policy behind
22
+ * (5), the mutable execution object behind the guard's mutation check, and the
23
+ * silently inert telemetry seam behind the notice reported at mount. Each one
24
+ * is partial, none closes its channel, and README.md says so beside the
25
+ * feature.
17
26
  *
18
27
  * This plugin is not a containment boundary. It runs in-process at the agent's
19
28
  * 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
@@ -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
@@ -33,12 +33,14 @@ 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;
38
40
  }
39
41
  export declare const Config: z<Config>;
40
42
  /** Config toggles a repo-local policy may switch on, and never off. */
41
- declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "redactTelemetryWorkspacePaths"];
43
+ declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths"];
42
44
  /** One toggle name a repo-local policy may name in `enable`. */
43
45
  export type EnableableToggle = typeof ENABLEABLE[number];
44
46
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -59,6 +61,7 @@ export interface ResolvedPolicy {
59
61
  readonly breadthTier: boolean;
60
62
  readonly resultRedaction: boolean;
61
63
  readonly telemetryRedaction: boolean;
64
+ readonly remoteImageNeutralization: boolean;
62
65
  readonly redactTelemetryWorkspacePaths: boolean;
63
66
  }
64
67
  /** Thrown when a policy file is malformed or attempts to loosen the policy. */
@@ -108,15 +111,6 @@ export type RepoPolicyLoad =
108
111
  * @returns the validated policy, its absence, or the problem to report.
109
112
  */
110
113
  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
114
  /**
121
115
  * Merge the deployment config with an optional repo-local policy.
122
116
  * @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