dsh-desktop-kit 2.2.1 → 2.7.2

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +4 -2
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +5 -5
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/index.js +18 -1
  23. package/package.json +26 -6
  24. package/plugins/dsh-agent-extensions/README.md +99 -0
  25. package/plugins/dsh-agent-extensions/index.js +167 -0
  26. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  27. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  28. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  29. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  30. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  31. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  32. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  33. package/plugins/dsh-agent-extensions/package.json +9 -0
  34. package/plugins/dsh-browser-qa/README.md +221 -0
  35. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  36. package/plugins/dsh-browser-qa/index.js +165 -0
  37. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  38. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  39. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  40. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  41. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  42. package/plugins/dsh-browser-qa/package.json +31 -0
  43. package/plugins/dsh-checkpoints/README.md +22 -0
  44. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  45. package/plugins/dsh-checkpoints/index.js +80 -0
  46. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  47. package/plugins/dsh-checkpoints/package.json +25 -0
  48. package/plugins/dsh-github-review/README.md +131 -0
  49. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  50. package/plugins/dsh-github-review/index.js +536 -0
  51. package/plugins/dsh-github-review/lib/gh.js +494 -0
  52. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  53. package/plugins/dsh-github-review/package.json +28 -0
  54. package/plugins/dsh-guardrails/README.md +264 -0
  55. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  56. package/plugins/dsh-guardrails/index.js +389 -0
  57. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  58. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  59. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  60. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  61. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  62. package/plugins/dsh-guardrails/package.json +26 -0
  63. package/plugins/dsh-mcp-manager/README.md +78 -0
  64. package/plugins/dsh-mcp-manager/client.js +373 -0
  65. package/plugins/dsh-mcp-manager/index.js +159 -0
  66. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  67. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  68. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  69. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  70. package/plugins/dsh-mcp-manager/package.json +18 -0
  71. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  72. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  73. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  74. package/plugins/dsh-memory-bridge/README.md +161 -0
  75. package/plugins/dsh-memory-bridge/client.js +2264 -0
  76. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  77. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  78. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  79. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  83. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  84. package/plugins/dsh-memory-bridge/index.js +378 -0
  85. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  86. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  87. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  88. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  89. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  90. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  91. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  92. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  93. package/plugins/dsh-memory-bridge/package.json +67 -0
  94. package/plugins/dsh-peak-guard/README.md +16 -3
  95. package/plugins/dsh-peak-guard/client.js +223 -8
  96. package/plugins/dsh-peak-guard/package.json +1 -1
  97. package/plugins/dsh-plan-mode/README.md +24 -0
  98. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  99. package/plugins/dsh-plan-mode/index.js +103 -0
  100. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  101. package/plugins/dsh-plan-mode/package.json +18 -0
  102. package/plugins/dsh-profile-transfer/client.js +277 -17
  103. package/plugins/dsh-profile-transfer/index.js +10 -0
  104. package/plugins/dsh-profile-transfer/package.json +3 -1
  105. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  106. package/plugins/dsh-project-memory/index.js +92 -2
  107. package/plugins/dsh-project-memory/package.json +1 -1
  108. package/plugins/dsh-repo-map/README.md +26 -0
  109. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  110. package/plugins/dsh-repo-map/index.js +99 -0
  111. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  112. package/plugins/dsh-repo-map/package.json +25 -0
  113. package/plugins/dsh-skill-autopilot/index.js +135 -11
  114. package/skills/delivery-gate/SKILL.md +31 -0
  115. package/skills/git-and-ci/SKILL.md +5 -1
  116. package/skills/manifest.json +31 -6
  117. package/skills/requirements-and-planning/SKILL.md +3 -0
  118. package/skills/research-first/SKILL.md +35 -0
  119. package/skills/research-first/references/query-patterns.md +20 -0
  120. package/skills/subagent-playbook/SKILL.md +36 -0
  121. package/skills/subagent-playbook/references/prompts.md +39 -0
  122. package/skills/ui-craft/SKILL.md +43 -0
  123. package/skills/visual-qa/SKILL.md +29 -0
  124. package/skills/visual-qa/references/commands.md +32 -0
  125. package/skills-baseline.json +219 -0
@@ -0,0 +1,264 @@
1
+ # dsh-guardrails
2
+
3
+ Host guardrails for DSH: a small, inspectable policy layer plus a bounded
4
+ read-only security scan.
5
+
6
+ The plugin is local-only (Node built-ins, no network, no LLM) and has three
7
+ parts:
8
+
9
+ 1. the model-facing `guardrails` tool — `status`, `check`, `policy`;
10
+ 2. a `tools/pre-execute` hook — denies or asks before dangerous shell
11
+ commands, and denies writes/edits to protected paths requested through
12
+ file-mutating tools;
13
+ 3. `fs/write-intent`, `fs/edit-intent`, `fs/delete-intent` hooks — refuse
14
+ filesystem mutations that target protected paths.
15
+
16
+ The package is a child plugin of the desktop kit (`export const inject =
17
+ ["tools"]`): the kit entry mounts it via `ctx.plugin()` next to the other
18
+ bundled plugins. The kit passes no services to it beyond `tools`; the
19
+ `fs/*` hooks are event-only, which is why no `fs` injection is declared
20
+ (the shipped `dsh-fs-observation-policy` also injects nothing).
21
+
22
+ ## Tool `guardrails`
23
+
24
+ Read-only. The canonical value is lossless JSON; the rendered text is a
25
+ bounded human summary.
26
+
27
+ | Action | What it returns |
28
+ |---|---|
29
+ | `status` | Effective command rules (id, severity, description, default/policy source), protected paths, registered/missing hooks, `DSH_GUARDRAILS` kill-switch state, policy file state (loaded / invalid / defaults), limits, bypass list. |
30
+ | `check` | Bounded worktree scan: secrets, `.env` files, protected paths present in the tree, oversized files, dangerous commands in shell scripts, and `git diff --check`. Optional `root` (default: Session cwd), `maxFiles`, `maxFileSizeBytes`, `maxFindings` overrides (clamped). |
31
+ | `policy` | The effective JSON policy (defaults merged with the user file), plus the policy path, load error and warnings. |
32
+
33
+ Example calls:
34
+
35
+ ```json
36
+ { "action": "status" }
37
+ { "action": "check", "root": "C:\\work\\project", "maxFiles": 400 }
38
+ { "action": "policy" }
39
+ ```
40
+
41
+ `check` never executes a scanned file: the only subprocess is `git` with fixed
42
+ read-only arguments (`rev-parse`, `diff --check`). It is bounded by
43
+ `maxFiles` (default 800), `maxFileSizeBytes` (default 256 KiB),
44
+ `maxTotalBytes` (default 8 MiB), `maxDepth` (default 12), `maxFindings`
45
+ (default 200) and a git subprocess timeout (default 5 s). Symlinks are not
46
+ followed; binaries, oversized and skipped directories are counted in `stats`.
47
+
48
+ The scan is cooperative with cancellation: an aborted tool call returns a
49
+ partial report marked `aborted: true`.
50
+
51
+ ## What is blocked
52
+
53
+ Default command rules (`deny` blocks the call, `ask` routes through the DSH
54
+ approval seam and degrades to a denial when no approval channel exists):
55
+
56
+ | Rule | Severity | Matches |
57
+ |---|---|---|
58
+ | `rm-rf-root` | deny | `rm -rf /`, `rm -rf /*`, `sudo rm -fr ~`, `$HOME`, drive roots — but not `rm -rf ./build` |
59
+ | `pwsh-remove-root` | deny | `Remove-Item -Recurse -Force C:\`, `rd /s /q C:\`, `del /f /s /q C:\*` |
60
+ | `drop-database` | deny | `DROP DATABASE`, `DROP SCHEMA` |
61
+ | `disk-format` | deny | `mkfs*`, `wipefs`, `diskpart`, `dd … of=/dev/…`, `format C:`, `Format-Volume`, `Clear-Disk`, `Initialize-Disk` |
62
+ | `shell-overwrite-credentials` | deny | shell redirect/overwrite/truncation/removal of `.credentials*`, `id_rsa`, `id_ed25519`, `id_ecdsa`, `.pem`, `.p12`, `.pfx`, `.ssh/` |
63
+ | `shell-copy-credentials` | ask | shell copy/move of the same credential paths (`cp cert.pem /etc/ssl/`) |
64
+ | `git-push-force` | ask | `git push --force`, `git push -f`; `--force-with-lease` stays allowed |
65
+ | `git-reset-hard` | ask | `git reset --hard` |
66
+ | `truncate-table` | ask | `TRUNCATE TABLE …` / `TRUNCATE …` |
67
+ | `shell-write-dotenv` | ask | shell write/removal of `.env` (redirect, `Set-Content`, `rm`, `cp`, `tee`, …); `.env.example`/`.sample`/`.template`/`.dist` are allowed |
68
+ | `sed-inplace-protected` | ask | `sed -i` / `perl -pi` over `.env`, credentials or key files |
69
+
70
+ Rules are applied to the `command` argument of `bash`, `pwsh`, `powershell`,
71
+ `shell`, `sh`, `zsh`, `cmd` and `terminal` tools. The decision shape is the
72
+ event's own `PreToolDecision`: `{ kind: "deny", reason, info }` or
73
+ `{ kind: "ask", reason, displayReason: { en, ru } }`. Every other call
74
+ delegates with `next()` and is untouched.
75
+
76
+ Default protected paths (denied for `write`, `edit`, `apply_patch` arguments
77
+ and enforced authoritatively by the `fs/*-intent` hooks):
78
+
79
+ | Entry | Match |
80
+ |---|---|
81
+ | `git-metadata` | any path segment equal to `.git` (but not `.gitignore`, `.github`) |
82
+ | `ssh-directory` | any path segment equal to `.ssh` |
83
+ | `env-file` | basename `.env` or `.env.*`, except `.env.example`, `.env.sample`, `.env.template`, `.env.dist` |
84
+ | `dsh-credentials` | `…/.dsh/.credentials.yaml` |
85
+ | `private-key-name` | `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519` |
86
+ | `private-key-ext` | `.pem`, `.key`, `.p12`, `.pfx`, `.jks` |
87
+
88
+ Reads are never blocked.
89
+
90
+ ## Permission presets
91
+
92
+ DSH already owns the picker (`Read Only`, `Workspace Write`, `Full access`,
93
+ `Auto review`) and the approval card. Guardrails does **not** add a second
94
+ picker; it reads the effective `permissionPresets` mode and enforces hard
95
+ limits through the same `tools/pre-execute` hook:
96
+
97
+ | Mode | Files | Mutating shell | Outside workspace |
98
+ |---|---|---|---|
99
+ | `read-only` | deny write/edit/apply_patch/delete/move | deny | deny |
100
+ | `workspace-write` | allow inside cwd | allow | ask |
101
+ | `danger-full-access` | allow | allow | allow |
102
+
103
+ The kit's own policy rules still apply in every mode: `rm -rf /`, prod
104
+ database destruction, protected paths and secrets are denied even in Full
105
+ access. `ask` decisions use the shipped approval UI (`Allow once` / `Always
106
+ allow` / `Reject`).
107
+
108
+ ## Policy file
109
+
110
+ Path: `<DSH_HOME>/guardrails.json` (default `~/.dsh/guardrails.json`; the
111
+ `DSH_HOME` environment variable is honoured). The file is read with an
112
+ mtime/size cache and is never loaded unbounded. Invalid JSON or an invalid
113
+ entry never takes the plugin down: the problem is reported by `status` /
114
+ `policy` and safe defaults stay in force.
115
+
116
+ Merge rules: objects merge recursively; `rules` and `protectedPaths` merge by
117
+ `id` (so one entry can be adjusted or disabled without restating the policy);
118
+ all other arrays replace the default value.
119
+
120
+ Example — disable the force-push ask, tighten `truncate`, add a path:
121
+
122
+ ```json
123
+ {
124
+ "version": 1,
125
+ "enabled": true,
126
+ "askBehavior": "ask",
127
+ "limits": { "maxFiles": 400, "gitTimeoutMs": 3000 },
128
+ "hooks": { "toolsPreExecute": true, "fsIntents": true },
129
+ "rules": [
130
+ { "id": "git-push-force", "enabled": false },
131
+ { "id": "truncate-table", "severity": "deny" },
132
+ { "id": "team-danger", "severity": "deny", "pattern": "deploy\\s+--production", "description": "custom rule" }
133
+ ],
134
+ "protectedPaths": [
135
+ { "id": "team-secrets", "match": "segment", "pattern": "^\\.secrets$", "description": "team secret dir" }
136
+ ]
137
+ }
138
+ ```
139
+
140
+ `askBehavior: "deny"` turns every `ask` rule into a hard denial (useful on
141
+ unattended machines). `enabled: false` disables the hooks and `check` while
142
+ keeping the tool available for inspection.
143
+
144
+ ## Bypass (kill switch)
145
+
146
+ - `DSH_GUARDRAILS=off` — process environment; read on every hook and every
147
+ `check`, so no restart of the plugin is needed. `0`, `false` and `disabled`
148
+ are accepted as well.
149
+ - `guardrails.json` → `enabled: false`, or
150
+ `hooks: { toolsPreExecute: false }` / `hooks: { fsIntents: false }`.
151
+ - one rule or protected-path entry: `{ "id": "…", "enabled": false }`.
152
+
153
+ The tool itself is always readable, so `status` shows exactly what is active.
154
+
155
+ ## Event contracts (verified against the live DSH Event inspect API)
156
+
157
+ ```text
158
+ tools/pre-execute (waterfall)
159
+ 'tools/pre-execute'(this: Scoped<ToolsService>, exec: ToolExecution,
160
+ next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
161
+ PreToolDecision = { kind: 'allow' }
162
+ | { kind: 'deny'; reason: string; info?: ToolErrorInfo }
163
+ | { kind: 'cancel' }
164
+ | { kind: 'ask'; reason?: string; displayReason?: { en; [locale]: string } }
165
+
166
+ fs/write-intent (waterfall, single slot)
167
+ 'fs/write-intent'(target: FsTarget, actor: object | undefined,
168
+ next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>)
169
+ : Promise<FsWriteIntent | undefined>
170
+ FsTarget = { targetKey: FsTargetKey; displayPath: string }
171
+
172
+ fs/edit-intent / fs/delete-intent (waterfall, single slot)
173
+ (target, actor, next) => Promise<{ version: FsVersion } | undefined>
174
+ ```
175
+
176
+ Used exactly:
177
+
178
+ - `tools/pre-execute` returns only `allow` (via `next()`), `deny` or `ask`
179
+ decisions. `next()` is always preferred for the allow path so later policy
180
+ listeners still run.
181
+ - `fs/write-intent` / `fs/edit-intent` / `fs/delete-intent` return `next()`
182
+ for paths that are not protected. **Ambiguity:** the `fs/*-intent`
183
+ contracts have no `deny` decision — their return value is a guard or
184
+ `undefined`, and the "first returned guard wins" slot has no refusal
185
+ variant. The shipped `dsh-fs-observation-policy` refuses by throwing a
186
+ coded `FsError` from the listener, and `dsh-tool-fs` dispatches the
187
+ waterfall so that a throw aborts the mutation. This plugin follows the
188
+ same mechanism with a plain `Error` (`.name =
189
+ "GuardrailsProtectedPathError"`, `.code = "GUARDRAILS_PROTECTED_PATH"`,
190
+ `.ruleId`) only when a path matches the policy and **delegates with
191
+ `next()` otherwise** — the documented conservative-allow fallback. If a
192
+ future DSH contract gains a real deny shape it should replace this throw.
193
+ - `tools/pre-execute` also performs a belt-and-braces check of `write` /
194
+ `edit` / `apply_patch` path arguments, because a first-wins `fs` policy
195
+ mounted before this plugin could own an intent slot (e.g. the shipped
196
+ observation policy). The `fs` hook remains the authoritative check for
197
+ resolved paths.
198
+
199
+ ## Security properties
200
+
201
+ - Secret values never leave `lib/secrets.js` raw: findings carry a masked
202
+ placeholder (`AKIA…[redacted 20 chars]`), a line/column and a rule id.
203
+ `redactText()` is applied to command previews and git output. Tool results
204
+ and debug logs contain no raw token values.
205
+ - Hook logs contain only the event/tool name and the rule id — never command
206
+ text, target paths or secret values.
207
+ - Scanned files are never executed; the scanner performs no network I/O and
208
+ no writes. `git diff --check` is invoked with fixed arguments and a timeout.
209
+ - All matches are bounded by policy limits; the scanner skips symlinks and
210
+ binary content.
211
+
212
+ ## Limitations
213
+
214
+ - These are heuristics, not a security boundary. Obfuscated commands
215
+ (base64, variable indirection, `python -c`, alternative shells), tools that
216
+ are not in the shell-name list, and PTC/`run_code` compositions can evade
217
+ the pre-execute rules. The plugin is a guardrail for a cooperative model,
218
+ not a sandbox — use the process sandbox/kernel isolation for untrusted code.
219
+ - `fs/*-intent` is a single-slot waterfall: another first-wins policy mounted
220
+ before this plugin can own the slot. The pre-execute path check is the
221
+ fallback for `write`/`edit`/`apply_patch`; a path protection that must be
222
+ absolute belongs in a provider-level fence.
223
+ - `ask` rules degrade to a denial when the DSH approval channel is missing or
224
+ the call has no agent.
225
+ - The command rules intentionally accept some false positives (`TRUNCATE` in
226
+ prose, `echo`/`copy` mentioning key files in scripts). Adjust or disable a
227
+ rule in `guardrails.json` when a project needs an exception. `.env.example`,
228
+ `.env.sample`, `.env.template` and `.env.dist` stay writable by default.
229
+ - `check` reports a protected path per file found in the tree, not per
230
+ directory; an empty `.ssh/` directory is not reported.
231
+ - Windows path matching is case-insensitive; POSIX matching uses the same
232
+ case-insensitive regexes (conservative over-matching, not under-matching).
233
+ - Scan limits are caps, not coverage guarantees: skipped/oversized/binary
234
+ counts are reported, and a truncated result is marked.
235
+
236
+ ## Tests
237
+
238
+ ```powershell
239
+ cd plugins/dsh-guardrails
240
+ node tools/guardrails-tests.mjs
241
+ ```
242
+
243
+ The suite has no kit dependency. It covers the pure policy functions (kill
244
+ switch, defaults, merge, clamping, protected paths, command verdicts),
245
+ secret detection/redaction, the bounded scan (including `git diff --check`
246
+ when `git` is available) and the exact event decision shapes with a fake
247
+ Cordis context. Expected result: `guardrails tests: OK (39 tests)`.
248
+
249
+ ## Files
250
+
251
+ ```text
252
+ dsh-guardrails/
253
+ ├── package.json # package manifest + dsh bundle patch
254
+ ├── cordis.patch.yml # Host row inserted by the kit
255
+ ├── index.js # inject, guardrails tool, apply()
256
+ ├── lib/
257
+ │ ├── hooks.js # tools/pre-execute + fs/*-intent listeners
258
+ │ ├── policy.js # defaults, JSON merge, matching, kill switch
259
+ │ ├── scan.js # bounded worktree scan + git diff --check
260
+ │ └── secrets.js # secret regexes, findings, redaction
261
+ ├── tools/
262
+ │ └── guardrails-tests.mjs
263
+ └── README.md
264
+ ```
@@ -0,0 +1,5 @@
1
+ # Host guardrails: shell-command policy, protected-path hooks and a
2
+ # read-only guardrails tool. Mounts as a child plugin of the kit entry.
3
+ - insert:
4
+ - id: guardrails
5
+ name: dsh-guardrails
@@ -0,0 +1,389 @@
1
+ /**
2
+ * dsh-guardrails — Host half.
3
+ *
4
+ * Three capabilities, all local and read-only except for the mutations it
5
+ * refuses:
6
+ *
7
+ * - `guardrails` tool: `status` (effective rules, protected paths, hook
8
+ * state), `check` (bounded worktree scan for secrets, .env files,
9
+ * protected paths, oversized files, dangerous script commands and
10
+ * `git diff --check`) and `policy` (effective JSON policy).
11
+ * - `tools/pre-execute` hook: deny/ask dangerous shell commands and protect
12
+ * file-mutating tool arguments before dispatch.
13
+ * - `fs/write-intent`, `fs/edit-intent`, `fs/delete-intent` hooks: refuse
14
+ * writes, edits and deletions of protected paths at the filesystem seam.
15
+ *
16
+ * No network, no LLM, Node built-ins only. The documented bypass is the
17
+ * `DSH_GUARDRAILS=off` environment variable or the user policy file
18
+ * `<DSH_HOME>/guardrails.json`.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ import { createGuard, createHookState, registerHooks } from "./lib/hooks.js";
24
+ import { resolveMode } from "./lib/modes.js";
25
+ import {
26
+ DEFAULT_COMMAND_RULES,
27
+ DEFAULT_PROTECTED_PATHS,
28
+ KILL_SWITCH_ENV,
29
+ credentialsPath,
30
+ loadPolicy,
31
+ policyPath,
32
+ } from "./lib/policy.js";
33
+ import { scanProject } from "./lib/scan.js";
34
+
35
+ /** Tool and hook plugin depends on the tool registry only. */
36
+ export const inject = ["tools"];
37
+
38
+ const PLUGIN_NAME = "dsh-guardrails";
39
+ const PLUGIN_VERSION = "1.0.0";
40
+ const ACTIONS = ["status", "check", "policy"];
41
+ const DEFAULT_ACTION = "status";
42
+ const TEXT_LIMIT = 24_000;
43
+
44
+ const DEFAULT_RULE_IDS = new Set(DEFAULT_COMMAND_RULES.map((rule) => rule.id));
45
+ const DEFAULT_PROTECTED_IDS = new Set(DEFAULT_PROTECTED_PATHS.map((entry) => entry.id));
46
+
47
+ function messageOf(error) {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+
51
+ function truncate(text, maximum = TEXT_LIMIT) {
52
+ const value = String(text ?? "");
53
+ return value.length <= maximum ? value : `${value.slice(0, Math.max(0, maximum - 1))}…`;
54
+ }
55
+
56
+ function rootOf(args, exec) {
57
+ const explicit = typeof args?.root === "string" ? args.root.trim() : "";
58
+ const sessionCwd = exec?.agent?.session?.header?.cwd;
59
+ return path.resolve(explicit.length > 0 ? explicit : typeof sessionCwd === "string" && sessionCwd.length > 0 ? sessionCwd : process.cwd());
60
+ }
61
+
62
+ function finiteOverride(value) {
63
+ const number = Number(value);
64
+ return Number.isFinite(number) ? number : undefined;
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ */
68
+ /* status */
69
+ /* ------------------------------------------------------------------ */
70
+
71
+ function statusValue(loaded, hookState) {
72
+ const ruleList = loaded.policy.rules.map((rule) => ({
73
+ id: rule.id,
74
+ severity: rule.severity,
75
+ enabled: rule.enabled !== false,
76
+ description: rule.description,
77
+ source: DEFAULT_RULE_IDS.has(rule.id) ? "default" : "policy",
78
+ }));
79
+ const enabledRules = ruleList.filter((rule) => rule.enabled);
80
+ const protectedList = loaded.policy.protectedPaths.map((entry) => ({
81
+ id: entry.id,
82
+ enabled: entry.enabled !== false,
83
+ match: entry.match,
84
+ pattern: entry.pattern,
85
+ description: entry.description,
86
+ source: DEFAULT_PROTECTED_IDS.has(entry.id) ? "default" : "policy",
87
+ }));
88
+ const enabledProtected = protectedList.filter((entry) => entry.enabled);
89
+ const hooks = {
90
+ toolsPreExecute: hookState.toolsPreExecute,
91
+ fsWriteIntent: hookState.fsWriteIntent,
92
+ fsEditIntent: hookState.fsEditIntent,
93
+ fsDeleteIntent: hookState.fsDeleteIntent,
94
+ allRegistered:
95
+ hookState.toolsPreExecute && hookState.fsWriteIntent && hookState.fsEditIntent && hookState.fsDeleteIntent,
96
+ configured: { ...loaded.policy.hooks },
97
+ errors: hookState.errors,
98
+ };
99
+ const value = {
100
+ ok: true,
101
+ enabled: loaded.enabled,
102
+ plugin: { name: PLUGIN_NAME, version: PLUGIN_VERSION },
103
+ killSwitch: {
104
+ active: loaded.killSwitch,
105
+ variable: KILL_SWITCH_ENV,
106
+ acceptedValues: ["off", "0", "false", "disabled"],
107
+ },
108
+ policy: {
109
+ path: loaded.path,
110
+ exists: loaded.exists,
111
+ loadedFromFile: loaded.loadedFromFile,
112
+ version: loaded.policy.version,
113
+ error: loaded.error,
114
+ warnings: loaded.warnings,
115
+ askBehavior: loaded.policy.askBehavior,
116
+ },
117
+ hooks,
118
+ rules: {
119
+ total: ruleList.length,
120
+ enabled: enabledRules.length,
121
+ list: enabledRules,
122
+ disabled: ruleList.filter((rule) => !rule.enabled).map((rule) => rule.id),
123
+ },
124
+ protectedPaths: {
125
+ total: protectedList.length,
126
+ enabled: enabledProtected.length,
127
+ list: enabledProtected,
128
+ disabled: protectedList.filter((entry) => !entry.enabled).map((entry) => entry.id),
129
+ },
130
+ credentialsPath: credentialsPath(process.env),
131
+ limits: loaded.policy.limits,
132
+ bypass: [
133
+ `${KILL_SWITCH_ENV}=off (process environment)`,
134
+ "policy.enabled=false or policy.hooks.{toolsPreExecute,fsIntents}=false",
135
+ "a rule or protected path entry with enabled=false",
136
+ ],
137
+ text: "",
138
+ };
139
+ value.text = renderStatus(value);
140
+ return value;
141
+ }
142
+
143
+ function renderStatus(value) {
144
+ const lines = [];
145
+ const policyState = value.policy.loadedFromFile
146
+ ? `loaded from ${value.policy.path}`
147
+ : value.policy.exists
148
+ ? `${value.policy.path} is invalid, defaults in force`
149
+ : `defaults (no ${value.policy.path})`;
150
+ lines.push(`${PLUGIN_NAME} ${PLUGIN_VERSION} — ${value.enabled ? "ENABLED" : "DISABLED"} (${policyState})`);
151
+ if (value.policy.error) lines.push(`Policy error: ${value.policy.error}`);
152
+ for (const warning of value.policy.warnings.slice(0, 10)) lines.push(`Policy warning: ${truncate(warning, 300)}`);
153
+ lines.push(
154
+ `Kill switch: ${value.killSwitch.variable}=off disables every hook (currently ${value.killSwitch.active ? "ACTIVE" : "inactive"})`,
155
+ );
156
+ lines.push(
157
+ `Hooks: tools/pre-execute=${value.hooks.toolsPreExecute ? "on" : "off"} · fs/write-intent=${value.hooks.fsWriteIntent ? "on" : "off"} · fs/edit-intent=${value.hooks.fsEditIntent ? "on" : "off"} · fs/delete-intent=${value.hooks.fsDeleteIntent ? "on" : "off"}`,
158
+ );
159
+ if (value.hooks.errors.length > 0) {
160
+ for (const error of value.hooks.errors.slice(0, 5)) lines.push(`Hook error: ${error.event}: ${truncate(error.message, 200)}`);
161
+ }
162
+ const denyCount = value.rules.list.filter((rule) => rule.severity === "deny").length;
163
+ const askCount = value.rules.list.filter((rule) => rule.severity === "ask").length;
164
+ lines.push(`Command rules: ${value.rules.enabled}/${value.rules.total} enabled (deny ${denyCount}, ask ${askCount})`);
165
+ for (const rule of value.rules.list) lines.push(`- ${rule.severity.toUpperCase().padEnd(4)} ${rule.id} — ${truncate(rule.description, 160)}`);
166
+ if (value.rules.disabled.length > 0) lines.push(`Disabled rules: ${value.rules.disabled.join(", ")}`);
167
+ lines.push(`Protected paths: ${value.protectedPaths.enabled}/${value.protectedPaths.total} enabled`);
168
+ for (const entry of value.protectedPaths.list) {
169
+ lines.push(`- ${entry.id} (${entry.match} /${truncate(entry.pattern, 120)}/) — ${truncate(entry.description, 120)}`);
170
+ }
171
+ if (value.protectedPaths.disabled.length > 0) {
172
+ lines.push(`Disabled protected paths: ${value.protectedPaths.disabled.join(", ")}`);
173
+ }
174
+ lines.push(`DSH credentials file: ${value.credentialsPath}`);
175
+ lines.push(
176
+ `Limits: maxFiles=${value.limits.maxFiles}, maxFileSizeBytes=${value.limits.maxFileSizeBytes}, maxTotalBytes=${value.limits.maxTotalBytes}, maxFindings=${value.limits.maxFindings}, maxDepth=${value.limits.maxDepth}`,
177
+ );
178
+ lines.push("Bypass: DSH_GUARDRAILS=off · policy.enabled=false · policy hooks off · individual rule disabled");
179
+ return lines.join("\n");
180
+ }
181
+
182
+ /* ------------------------------------------------------------------ */
183
+ /* policy */
184
+ /* ------------------------------------------------------------------ */
185
+
186
+ function policyValue(loaded) {
187
+ const value = {
188
+ ok: true,
189
+ path: loaded.path,
190
+ exists: loaded.exists,
191
+ loadedFromFile: loaded.loadedFromFile,
192
+ error: loaded.error,
193
+ warnings: loaded.warnings,
194
+ killSwitch: loaded.killSwitch,
195
+ enabled: loaded.enabled,
196
+ policy: loaded.policy,
197
+ text: "",
198
+ };
199
+ value.text = truncate(JSON.stringify({
200
+ path: value.path,
201
+ loadedFromFile: value.loadedFromFile,
202
+ error: value.error,
203
+ warnings: value.warnings,
204
+ killSwitch: value.killSwitch,
205
+ enabled: value.enabled,
206
+ policy: value.policy,
207
+ }, null, 2));
208
+ return value;
209
+ }
210
+
211
+ /* ------------------------------------------------------------------ */
212
+ /* check */
213
+ /* ------------------------------------------------------------------ */
214
+
215
+ async function checkValue(loaded, args, exec, logger) {
216
+ const root = rootOf(args, exec);
217
+ if (!loaded.enabled) {
218
+ return {
219
+ ok: true,
220
+ enabled: false,
221
+ root,
222
+ skipped: true,
223
+ reason: loaded.killSwitch
224
+ ? `kill switch ${KILL_SWITCH_ENV} is active`
225
+ : `guardrails policy is disabled (${loaded.path})`,
226
+ findings: [],
227
+ text: "Guardrails are disabled; nothing was scanned. Re-enable by unsetting DSH_GUARDRAILS or setting policy.enabled=true.",
228
+ };
229
+ }
230
+ let rootStat;
231
+ try {
232
+ rootStat = fs.statSync(root);
233
+ } catch {
234
+ return { ok: false, enabled: true, root, error: `root does not exist: ${root}`, findings: [], text: `guardrails check: root does not exist: ${root}` };
235
+ }
236
+ if (!rootStat.isDirectory()) {
237
+ return { ok: false, enabled: true, root, error: `root is not a directory: ${root}`, findings: [], text: `guardrails check: not a directory: ${root}` };
238
+ }
239
+ const overrides = {};
240
+ const maxFiles = finiteOverride(args?.maxFiles);
241
+ if (maxFiles !== undefined) overrides.maxFiles = maxFiles;
242
+ const maxFileSizeBytes = finiteOverride(args?.maxFileSizeBytes);
243
+ if (maxFileSizeBytes !== undefined) overrides.maxFileSizeBytes = maxFileSizeBytes;
244
+ const maxFindings = finiteOverride(args?.maxFindings);
245
+ if (maxFindings !== undefined) overrides.maxFindings = maxFindings;
246
+ const report = await scanProject({ root, policy: loaded.policy, env: process.env, signal: exec?.signal, overrides, logger });
247
+ const value = {
248
+ enabled: true,
249
+ policy: {
250
+ path: loaded.path,
251
+ loadedFromFile: loaded.loadedFromFile,
252
+ error: loaded.error,
253
+ warnings: loaded.warnings,
254
+ },
255
+ ...report,
256
+ text: "",
257
+ };
258
+ value.text = renderScanReport(value);
259
+ return value;
260
+ }
261
+
262
+ function severityCounts(findings) {
263
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
264
+ for (const finding of findings) {
265
+ if (Object.hasOwn(counts, finding.severity)) counts[finding.severity] += 1;
266
+ }
267
+ return counts;
268
+ }
269
+
270
+ function renderScanReport(value) {
271
+ if (value.ok === false) return `guardrails check: ${value.error ?? "scan failed"}`;
272
+ const stats = value.stats;
273
+ const lines = [];
274
+ lines.push(`Guardrails scan (read-only, no network): ${value.root}`);
275
+ lines.push(
276
+ `Files: scanned ${stats.filesScanned}, considered ${stats.filesConsidered}, bytes ${stats.bytesRead}, directories ${stats.directoriesVisited}`,
277
+ );
278
+ lines.push(
279
+ `Skipped: binary ${stats.skipped.binary}, oversized ${stats.skipped.oversized}, symlinks ${stats.skipped.symlinks}, depth-limit ${stats.skipped.depthLimit}, unreadable ${stats.skipped.unreadable}`,
280
+ );
281
+ const limitsHit = Object.entries(stats.limitsHit).filter(([, hit]) => hit === true).map(([key]) => key);
282
+ if (limitsHit.length > 0) lines.push(`Limits hit: ${limitsHit.join(", ")} (results are partial)`);
283
+ const findings = value.findings ?? [];
284
+ if (findings.length === 0) {
285
+ lines.push("Findings: none");
286
+ } else {
287
+ const counts = severityCounts(findings);
288
+ lines.push(
289
+ `Findings: ${findings.length}${value.findingsTruncated ? " (truncated)" : ""} — critical ${counts.critical}, high ${counts.high}, medium ${counts.medium}, low ${counts.low}`,
290
+ );
291
+ for (const finding of findings.slice(0, 40)) {
292
+ const location = finding.line !== undefined ? `${finding.path}:${finding.line}` : String(finding.path ?? "?");
293
+ const masked = typeof finding.masked === "string" ? ` [masked: ${finding.masked}]` : "";
294
+ lines.push(`- ${String(finding.severity).toUpperCase()} ${finding.kind}/${finding.rule} — ${location} — ${truncate(finding.message, 200)}${masked}`);
295
+ }
296
+ if (findings.length > 40) lines.push(`… ${findings.length - 40} more finding(s) are in the JSON value`);
297
+ }
298
+ if (value.gitDiffCheck != null) {
299
+ lines.push(
300
+ `git diff --check: ${value.gitDiffCheck.available ? value.gitDiffCheck.message : `unavailable (${value.gitDiffCheck.message})`}`,
301
+ );
302
+ }
303
+ if (value.aborted) lines.push("Scan aborted by cancellation; partial results only.");
304
+ lines.push("Scanned files are never executed; secret values are redacted in this result.");
305
+ return lines.join("\n");
306
+ }
307
+
308
+ /* ------------------------------------------------------------------ */
309
+ /* Plugin entry */
310
+ /* ------------------------------------------------------------------ */
311
+
312
+ export function apply(ctx) {
313
+ const env = process.env;
314
+ const logger = ctx?.logger;
315
+ const hookState = createHookState();
316
+ const guard = createGuard({ env, logger, modeFor: (agent) => resolveMode(ctx, agent) });
317
+ try {
318
+ registerHooks(ctx, guard, hookState);
319
+ } catch (error) {
320
+ hookState.errors.push({ event: "registration", message: messageOf(error) });
321
+ logger?.warn?.(`[guardrails] hook registration failed: ${messageOf(error)}`);
322
+ }
323
+
324
+ const tool = {
325
+ name: "guardrails",
326
+ description:
327
+ "Read-only guardrails control and security scan. `status` shows effective command rules, protected paths, hook registration and the DSH_GUARDRAILS kill switch. `check` scans a project or worktree with strict limits (no network) for secrets, .env files, protected paths, oversized files, dangerous commands in shell scripts and `git diff --check`; token values are redacted. `policy` prints the effective JSON policy. Scanning never executes files and never writes.",
328
+ parameters: {
329
+ type: "object",
330
+ additionalProperties: false,
331
+ properties: {
332
+ action: {
333
+ type: "string",
334
+ enum: ACTIONS,
335
+ description: "status (rules, protected paths, hooks) | check (bounded read-only scan) | policy (effective JSON policy).",
336
+ },
337
+ root: {
338
+ type: "string",
339
+ description: "Absolute project/worktree root for check; defaults to the current Session cwd.",
340
+ },
341
+ maxFiles: {
342
+ type: "integer",
343
+ description: "Override the check file cap (1..5000, default from policy 800).",
344
+ },
345
+ maxFileSizeBytes: {
346
+ type: "integer",
347
+ description: "Override the per-file byte cap for check (256..16777216, default 262144). Larger files are reported, not read.",
348
+ },
349
+ maxFindings: {
350
+ type: "integer",
351
+ description: "Override the findings cap for check (1..2000, default 200).",
352
+ },
353
+ },
354
+ required: ["action"],
355
+ },
356
+ timeoutMs: 30_000,
357
+ output: {
358
+ schema: { type: "object", additionalProperties: true },
359
+ render(_args, value) {
360
+ const text = typeof value?.text === "string" ? value.text : JSON.stringify(value);
361
+ return [{ type: "text", text: truncate(text) }];
362
+ },
363
+ },
364
+ async execute(args, exec) {
365
+ try {
366
+ const action = typeof args?.action === "string" ? args.action : DEFAULT_ACTION;
367
+ const loaded = loadPolicy({ env });
368
+ if (action === "policy") return policyValue(loaded);
369
+ if (action === "status") return statusValue(loaded, hookState);
370
+ if (action === "check") return await checkValue(loaded, args, exec, logger);
371
+ return {
372
+ ok: false,
373
+ enabled: loaded.enabled,
374
+ error: `unknown action: ${action}`,
375
+ text: `guardrails: unknown action "${action}". Available actions: ${ACTIONS.join(", ")}.`,
376
+ };
377
+ } catch (error) {
378
+ return {
379
+ ok: false,
380
+ error: messageOf(error),
381
+ text: `guardrails: ${messageOf(error)}`,
382
+ };
383
+ }
384
+ },
385
+ };
386
+
387
+ ctx.tools.register(tool);
388
+ logger?.info?.(`[guardrails] tool ready (policy: ${policyPath(env)})`);
389
+ }