dsh-desktop-kit 2.2.1 → 2.7.3

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 (130) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +114 -27
  3. package/client.js +3270 -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/TEST-2.7.3.md +20 -0
  17. package/docs/TOKENS.md +2 -2
  18. package/docs/TROUBLESHOOTING.md +27 -0
  19. package/docs/UI-QA.md +28 -0
  20. package/docs/images/peak-toast-peak.png +0 -0
  21. package/docs/images/peak-toast-soon.png +0 -0
  22. package/docs/preview/peak-toast.html +155 -0
  23. package/index.js +18 -1
  24. package/package.json +26 -6
  25. package/plugins/dsh-agent-extensions/README.md +107 -0
  26. package/plugins/dsh-agent-extensions/client.js +157 -0
  27. package/plugins/dsh-agent-extensions/index.js +174 -0
  28. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  29. package/plugins/dsh-agent-extensions/lib/api.js +53 -0
  30. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  31. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  32. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  33. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  34. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  35. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  36. package/plugins/dsh-agent-extensions/package.json +20 -0
  37. package/plugins/dsh-agent-extensions/screenshots/agents-1440.png +0 -0
  38. package/plugins/dsh-agent-extensions/screenshots/agents-390.png +0 -0
  39. package/plugins/dsh-browser-qa/README.md +221 -0
  40. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  41. package/plugins/dsh-browser-qa/index.js +165 -0
  42. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  43. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  44. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  45. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  46. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  47. package/plugins/dsh-browser-qa/package.json +31 -0
  48. package/plugins/dsh-checkpoints/README.md +22 -0
  49. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  50. package/plugins/dsh-checkpoints/index.js +80 -0
  51. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  52. package/plugins/dsh-checkpoints/package.json +25 -0
  53. package/plugins/dsh-github-review/README.md +131 -0
  54. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  55. package/plugins/dsh-github-review/index.js +536 -0
  56. package/plugins/dsh-github-review/lib/gh.js +494 -0
  57. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  58. package/plugins/dsh-github-review/package.json +28 -0
  59. package/plugins/dsh-guardrails/README.md +264 -0
  60. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  61. package/plugins/dsh-guardrails/index.js +389 -0
  62. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  63. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  64. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  65. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  66. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  67. package/plugins/dsh-guardrails/package.json +26 -0
  68. package/plugins/dsh-mcp-manager/README.md +78 -0
  69. package/plugins/dsh-mcp-manager/client.js +373 -0
  70. package/plugins/dsh-mcp-manager/index.js +159 -0
  71. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  72. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  73. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  74. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  75. package/plugins/dsh-mcp-manager/package.json +18 -0
  76. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  77. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  78. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  79. package/plugins/dsh-memory-bridge/README.md +161 -0
  80. package/plugins/dsh-memory-bridge/client.js +2264 -0
  81. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  82. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  83. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  84. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  85. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  86. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  87. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  88. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  89. package/plugins/dsh-memory-bridge/index.js +378 -0
  90. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  91. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  92. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  93. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  94. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  95. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  96. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  97. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  98. package/plugins/dsh-memory-bridge/package.json +67 -0
  99. package/plugins/dsh-peak-guard/README.md +16 -3
  100. package/plugins/dsh-peak-guard/client.js +223 -8
  101. package/plugins/dsh-peak-guard/package.json +1 -1
  102. package/plugins/dsh-plan-mode/README.md +24 -0
  103. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  104. package/plugins/dsh-plan-mode/index.js +103 -0
  105. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  106. package/plugins/dsh-plan-mode/package.json +18 -0
  107. package/plugins/dsh-profile-transfer/client.js +277 -17
  108. package/plugins/dsh-profile-transfer/index.js +10 -0
  109. package/plugins/dsh-profile-transfer/package.json +3 -1
  110. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  111. package/plugins/dsh-project-memory/index.js +92 -2
  112. package/plugins/dsh-project-memory/package.json +1 -1
  113. package/plugins/dsh-repo-map/README.md +26 -0
  114. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  115. package/plugins/dsh-repo-map/index.js +99 -0
  116. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  117. package/plugins/dsh-repo-map/package.json +25 -0
  118. package/plugins/dsh-skill-autopilot/index.js +135 -11
  119. package/skills/delivery-gate/SKILL.md +31 -0
  120. package/skills/git-and-ci/SKILL.md +5 -1
  121. package/skills/manifest.json +31 -6
  122. package/skills/requirements-and-planning/SKILL.md +3 -0
  123. package/skills/research-first/SKILL.md +35 -0
  124. package/skills/research-first/references/query-patterns.md +20 -0
  125. package/skills/subagent-playbook/SKILL.md +36 -0
  126. package/skills/subagent-playbook/references/prompts.md +39 -0
  127. package/skills/ui-craft/SKILL.md +43 -0
  128. package/skills/visual-qa/SKILL.md +29 -0
  129. package/skills/visual-qa/references/commands.md +32 -0
  130. package/skills-baseline.json +219 -0
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Guardrails policy: built-in defaults, JSON file merge, protected-path
3
+ * matching, shell-command rules and the process-level kill switch.
4
+ *
5
+ * The effective policy is a plain JSON object. A user file at
6
+ * `<DSH_HOME>/guardrails.json` is merged onto the defaults: objects merge
7
+ * recursively and entries in `rules` / `protectedPaths` merge by `id`, so a
8
+ * user can adjust one rule (`{"id":"git-reset-hard","enabled":false}`) or
9
+ * append a new one without restating the entire policy. Arrays elsewhere
10
+ * replace the default value.
11
+ *
12
+ * Node built-ins only; no I/O beyond reading the policy file.
13
+ */
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ /** Current policy document version. */
19
+ export const POLICY_VERSION = 1;
20
+
21
+ /** Environment variable that disables every hook (documented bypass). */
22
+ export const KILL_SWITCH_ENV = "DSH_GUARDRAILS";
23
+
24
+ /** Name of the user policy file inside the DSH home. */
25
+ export const POLICY_FILE_NAME = "guardrails.json";
26
+
27
+ /** Policy files larger than this are ignored (never loaded unbounded). */
28
+ const MAX_POLICY_BYTES = 1024 * 1024;
29
+
30
+ /** Commands longer than this are truncated before rule matching. */
31
+ const MAX_COMMAND_CHARS = 16 * 1024;
32
+
33
+ /** Scan limits, clamped by `normalizeLimits`. */
34
+ export const DEFAULT_LIMITS = Object.freeze({
35
+ maxFiles: 800,
36
+ maxFileSizeBytes: 262_144,
37
+ maxTotalBytes: 8_388_608,
38
+ maxFindings: 200,
39
+ maxDepth: 12,
40
+ maxScriptBytes: 131_072,
41
+ gitTimeoutMs: 5_000,
42
+ });
43
+
44
+ /**
45
+ * Protected paths for filesystem mutation hooks and the scanner. `match` is
46
+ * `segment` (one path component), `basename` (last component) or `path` (the
47
+ * whole normalized path). Patterns are regular-expression sources; `except`
48
+ * patterns exempt a path that also matches.
49
+ */
50
+ export const DEFAULT_PROTECTED_PATHS = [
51
+ {
52
+ id: "git-metadata",
53
+ description: "Git metadata directory (.git)",
54
+ match: "segment",
55
+ pattern: "^\\.git$",
56
+ },
57
+ {
58
+ id: "ssh-directory",
59
+ description: "SSH directory (.ssh)",
60
+ match: "segment",
61
+ pattern: "^\\.ssh$",
62
+ },
63
+ {
64
+ id: "env-file",
65
+ description: "dotenv file (.env and variants)",
66
+ match: "basename",
67
+ pattern: "^\\.env(?:\\..+)?$",
68
+ except: ["^\\.env\\.(?:example|sample|template|dist)$"],
69
+ },
70
+ {
71
+ id: "dsh-credentials",
72
+ description: "DSH credentials file (~/.dsh/.credentials.yaml)",
73
+ match: "path",
74
+ pattern: "(?:^|/)\\.dsh/\\.credentials\\.yaml$",
75
+ },
76
+ {
77
+ id: "private-key-name",
78
+ description: "SSH private key file names (id_rsa, id_ed25519, …)",
79
+ match: "basename",
80
+ pattern: "^(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)$",
81
+ },
82
+ {
83
+ id: "private-key-ext",
84
+ description: "private key and keystore extensions (.pem, .key, .p12, .pfx, .jks)",
85
+ match: "basename",
86
+ pattern: "\\.(?:pem|key|p12|pfx|jks)$",
87
+ },
88
+ ];
89
+
90
+ /**
91
+ * Shell-command rules. `severity` is `deny` (block) or `ask` (route through
92
+ * the approval seam; degrades to a denial when no approval channel exists).
93
+ */
94
+ export const DEFAULT_COMMAND_RULES = [
95
+ {
96
+ id: "rm-rf-root",
97
+ severity: "deny",
98
+ description: "recursive force delete of a filesystem root or home directory",
99
+ pattern:
100
+ "\\brm\\b(?=[^\\n]*[ \\t]-[A-Za-z]*(?:r|R))(?=[^\\n]*[ \\t]-[A-Za-z]*(?:f|F))[^\\n]*[ \\t](?:/(?=\\s|$)|/\\*(?=\\s|$)|~(?=\\s|$)|(?:\\$HOME|%USERPROFILE%)(?=\\s|$)|[A-Za-z]:[\\\\/](?:\\*)?(?=\\s|$))",
101
+ },
102
+ {
103
+ id: "pwsh-remove-root",
104
+ severity: "deny",
105
+ description: "Windows recursive force delete of a filesystem root",
106
+ pattern:
107
+ "\\b(?:Remove-Item|del|erase|rmdir|rd)\\b(?=[^\\n]*(?:-[Rr]ecurse\\b|-r\\b|/s\\b))(?=[^\\n]*(?:-[Ff]orce\\b|-fo\\b|/q\\b))[^\\n]*[ \\t](?:[A-Za-z]:[\\\\/](?:\\*)?(?=\\s|$)|/(?=\\s|$))",
108
+ },
109
+ {
110
+ id: "drop-database",
111
+ severity: "deny",
112
+ description: "DROP DATABASE / DROP SCHEMA statement",
113
+ pattern: "\\bDROP\\s+(?:DATABASE|SCHEMA)\\b",
114
+ },
115
+ {
116
+ id: "disk-format",
117
+ severity: "deny",
118
+ description: "disk format or raw device wipe (mkfs, dd of=/dev/*, format C:, diskpart)",
119
+ pattern:
120
+ "\\b(?:mkfs(?:\\.[a-z0-9]+)?|wipefs|diskpart)\\b|\\bdd\\b[^\\n]*\\bof\\s*=\\s*/dev/|\\bshred\\b[^\\n]*/dev/|\\bformat\\s+[A-Za-z]:|\\b(?:Format-Volume|Clear-Disk|Initialize-Disk)\\b|(?:^|[;&|]\\s*)format(?=\\s|$)",
121
+ },
122
+ {
123
+ id: "shell-overwrite-credentials",
124
+ severity: "deny",
125
+ description: "shell overwrite, truncation or removal of credentials or private keys",
126
+ pattern:
127
+ "(?:>{1,2}\\s*[\"']?[^\\s\"'|;&]*(?:\\.credentials(?:\\.yaml)?\\b|id_rsa\\b|id_ed25519\\b|id_ecdsa\\b|\\.pem\\b|\\.p12\\b|\\.pfx\\b|\\.ssh[\\\\/]))|(?:\\b(?:Set-Content|Add-Content|Out-File|New-Item|Remove-Item|rm|del|erase|truncate|shred|tee)\\b[^\\n]{0,200}?[ \\t\"'](?:[^\\s\"'|;&]*)(?:\\.credentials(?:\\.yaml)?\\b|id_rsa\\b|id_ed25519\\b|id_ecdsa\\b|\\.pem\\b|\\.p12\\b|\\.pfx\\b|\\.ssh[\\\\/]))",
128
+ },
129
+ {
130
+ id: "shell-copy-credentials",
131
+ severity: "ask",
132
+ description: "shell copy or move of credentials or private keys",
133
+ pattern:
134
+ "\\b(?:cp|copy|mv|move|scp|rsync)\\b[^\\n]{0,200}?[ \\t\"'](?:[^\\s\"'|;&]*)(?:\\.credentials(?:\\.yaml)?\\b|id_rsa\\b|id_ed25519\\b|id_ecdsa\\b|\\.pem\\b|\\.p12\\b|\\.pfx\\b|\\.ssh[\\\\/])",
135
+ },
136
+ {
137
+ id: "git-push-force",
138
+ severity: "ask",
139
+ description: "git push with --force (force-with-lease is allowed)",
140
+ pattern: "\\bgit\\s+push\\b[^\\n]*(?:--force(?!-with-lease)\\b|\\s-f(?=\\s|$))",
141
+ },
142
+ {
143
+ id: "git-reset-hard",
144
+ severity: "ask",
145
+ description: "git reset --hard discards uncommitted work",
146
+ pattern: "\\bgit\\s+reset\\s+--hard\\b",
147
+ },
148
+ {
149
+ id: "truncate-table",
150
+ severity: "ask",
151
+ description: "SQL TRUNCATE statement",
152
+ pattern: "\\bTRUNCATE\\s+(?:TABLE\\s+)?[A-Za-z0-9_\"`\\[\\].]+",
153
+ },
154
+ {
155
+ id: "shell-write-dotenv",
156
+ severity: "ask",
157
+ description: "shell write or removal of a dotenv file",
158
+ pattern:
159
+ "(?:>{1,2}\\s*[\"']?[^\\s\"'|;&]*\\.env\\b)|(?:\\b(?:Set-Content|Add-Content|Out-File|New-Item|Remove-Item|rm|del|erase|copy|cp|move|mv|tee|truncate)\\b[^\\n]{0,200}?[ \\t\"'](?:[^\\s\"'|;&]*)\\.env\\b)",
160
+ except: ["\\.env\\.(?:example|sample|template|dist)\\b"],
161
+ },
162
+ {
163
+ id: "sed-inplace-protected",
164
+ severity: "ask",
165
+ description: "in-place sed/perl edit of a dotenv or credential file",
166
+ pattern:
167
+ "\\b(?:sed\\b[^\\n]*(?:-i\\b|--in-place)|perl\\b[^\\n]*(?:-i\\b|-pi\\b))[^\\n]*(?:\\.env\\b|\\.credentials|\\.ssh[\\\\/]|id_rsa\\b|\\.pem\\b)",
168
+ except: ["\\.env\\.(?:example|sample|template|dist)\\b"],
169
+ },
170
+ ];
171
+
172
+ /* ------------------------------------------------------------------ */
173
+ /* Filesystem-independent helpers */
174
+ /* ------------------------------------------------------------------ */
175
+
176
+ export function homeDirectory(env = process.env) {
177
+ const home = env?.USERPROFILE || env?.HOME;
178
+ return typeof home === "string" && home.trim().length > 0 ? home.trim() : os.homedir();
179
+ }
180
+
181
+ /** Root of local DSH state; honours `DSH_HOME` like the rest of the kit. */
182
+ export function dshHome(env = process.env) {
183
+ const configured = env?.DSH_HOME;
184
+ return typeof configured === "string" && configured.trim().length > 0
185
+ ? configured.trim()
186
+ : path.join(homeDirectory(env), ".dsh");
187
+ }
188
+
189
+ /** Absolute path of the user policy file. */
190
+ export function policyPath(env = process.env) {
191
+ return path.join(dshHome(env), POLICY_FILE_NAME);
192
+ }
193
+
194
+ /**
195
+ * The documented process-level bypass. Accepts `off`, `0`, `false` and
196
+ * `disabled` (case-insensitive) so a launcher script cannot accidentally
197
+ * half-disable the plugin.
198
+ */
199
+ export function killSwitchActive(env = process.env) {
200
+ const raw = String(env?.[KILL_SWITCH_ENV] ?? "").trim().toLowerCase();
201
+ return raw === "off" || raw === "0" || raw === "false" || raw === "disabled";
202
+ }
203
+
204
+ function isPlainObject(value) {
205
+ return value !== null && typeof value === "object" && !Array.isArray(value);
206
+ }
207
+
208
+ function clone(value) {
209
+ return value === undefined ? undefined : structuredClone(value);
210
+ }
211
+
212
+ /** Normalize a path for matching: forward slashes, expanded `~`, lower case on Windows. */
213
+ export function normalizePathForMatch(value, env = process.env) {
214
+ let text = String(value ?? "").trim();
215
+ if (text.length === 0) return "";
216
+ text = text.replaceAll("\\", "/");
217
+ const home = homeDirectory(env).replaceAll("\\", "/").replace(/\/+$/u, "");
218
+ if (text === "~") text = home;
219
+ else if (text.startsWith("~/")) text = `${home}/${text.slice(2)}`;
220
+ text = text.replace(/\/{2,}/gu, "/");
221
+ if (process.platform === "win32") text = text.toLowerCase();
222
+ return text;
223
+ }
224
+
225
+ function basenameOf(normalizedPath) {
226
+ const parts = normalizedPath.split("/").filter((part) => part.length > 0);
227
+ return parts.length > 0 ? parts.at(-1) : "";
228
+ }
229
+
230
+ function segmentsOf(normalizedPath) {
231
+ return normalizedPath.split("/").filter((part) => part.length > 0);
232
+ }
233
+
234
+ /** Only stateless, case-insensitive regexes are supported for policy patterns. */
235
+ function normalizeFlags(value) {
236
+ const flags = new Set(["i"]);
237
+ for (const flag of typeof value === "string" ? value : "") {
238
+ if ("imsu".includes(flag)) flags.add(flag);
239
+ }
240
+ return [...flags].join("");
241
+ }
242
+
243
+ const regexCache = new Map();
244
+
245
+ function compileCached(source, flags) {
246
+ const key = `${source}\u0000${flags}`;
247
+ const cached = regexCache.get(key);
248
+ if (cached !== undefined) return cached;
249
+ let regex;
250
+ try {
251
+ regex = new RegExp(source, flags);
252
+ } catch {
253
+ regex = null;
254
+ }
255
+ if (regexCache.size > 500) regexCache.clear();
256
+ regexCache.set(key, regex);
257
+ return regex;
258
+ }
259
+
260
+ function ruleRegex(rule) {
261
+ return compileCached(rule.pattern, normalizeFlags(rule.flags));
262
+ }
263
+
264
+ function excludes(exclusions, text, flags) {
265
+ for (const source of exclusions ?? []) {
266
+ const regex = compileCached(source, normalizeFlags(flags));
267
+ if (regex !== null && regex.test(text)) return true;
268
+ }
269
+ return false;
270
+ }
271
+
272
+ /* ------------------------------------------------------------------ */
273
+ /* Policy assembly */
274
+ /* ------------------------------------------------------------------ */
275
+
276
+ /** Built-in policy document. Callers receive a fresh clone. */
277
+ export function defaultPolicy() {
278
+ return {
279
+ version: POLICY_VERSION,
280
+ enabled: true,
281
+ askBehavior: "ask",
282
+ limits: { ...DEFAULT_LIMITS },
283
+ hooks: { toolsPreExecute: true, fsIntents: true },
284
+ protectedPaths: clone(DEFAULT_PROTECTED_PATHS),
285
+ rules: clone(DEFAULT_COMMAND_RULES),
286
+ };
287
+ }
288
+
289
+ /** Merge entry arrays by `id`, keeping unmatched defaults and appending new ids. */
290
+ function mergeEntries(baseEntries, overrideEntries) {
291
+ const result = Array.isArray(baseEntries) ? clone(baseEntries) : [];
292
+ if (!Array.isArray(overrideEntries)) return result;
293
+ for (const entry of overrideEntries) {
294
+ if (!isPlainObject(entry) || typeof entry.id !== "string" || entry.id.trim().length === 0) continue;
295
+ const id = entry.id.trim();
296
+ const index = result.findIndex((item) => isPlainObject(item) && item.id === id);
297
+ if (index >= 0) result[index] = { ...result[index], ...clone(entry), id };
298
+ else result.push({ ...clone(entry), id });
299
+ }
300
+ return result;
301
+ }
302
+
303
+ /** Deep-merge a user policy onto a base policy; `rules`/`protectedPaths` merge by id. */
304
+ export function mergePolicy(base, override) {
305
+ const result = clone(base);
306
+ if (!isPlainObject(override)) return result;
307
+ for (const [key, value] of Object.entries(override)) {
308
+ if (key === "rules" || key === "protectedPaths") {
309
+ result[key] = mergeEntries(result[key], value);
310
+ continue;
311
+ }
312
+ if (isPlainObject(value)) result[key] = isPlainObject(result[key]) ? mergePolicy(result[key], value) : clone(value);
313
+ else result[key] = clone(value);
314
+ }
315
+ return result;
316
+ }
317
+
318
+ function integerWithin(value, fallback, minimum, maximum) {
319
+ const number = Number(value);
320
+ if (!Number.isFinite(number) || !Number.isInteger(number)) return fallback;
321
+ return Math.min(maximum, Math.max(minimum, number));
322
+ }
323
+
324
+ function normalizeLimits(raw, warnings) {
325
+ if (raw !== undefined && !isPlainObject(raw)) warnings.push("limits: expected an object, using defaults");
326
+ const source = isPlainObject(raw) ? raw : {};
327
+ return {
328
+ maxFiles: integerWithin(source.maxFiles, DEFAULT_LIMITS.maxFiles, 1, 5_000),
329
+ maxFileSizeBytes: integerWithin(source.maxFileSizeBytes, DEFAULT_LIMITS.maxFileSizeBytes, 256, 16_777_216),
330
+ maxTotalBytes: integerWithin(source.maxTotalBytes, DEFAULT_LIMITS.maxTotalBytes, 1_024, 67_108_864),
331
+ maxFindings: integerWithin(source.maxFindings, DEFAULT_LIMITS.maxFindings, 1, 2_000),
332
+ maxDepth: integerWithin(source.maxDepth, DEFAULT_LIMITS.maxDepth, 1, 64),
333
+ maxScriptBytes: integerWithin(source.maxScriptBytes, DEFAULT_LIMITS.maxScriptBytes, 1_024, 2_097_152),
334
+ gitTimeoutMs: integerWithin(source.gitTimeoutMs, DEFAULT_LIMITS.gitTimeoutMs, 500, 60_000),
335
+ };
336
+ }
337
+
338
+ function normalizeHooks(raw, warnings) {
339
+ if (raw !== undefined && !isPlainObject(raw)) warnings.push("hooks: expected an object, using defaults");
340
+ const source = isPlainObject(raw) ? raw : {};
341
+ return {
342
+ toolsPreExecute: source.toolsPreExecute !== false,
343
+ fsIntents: source.fsIntents !== false,
344
+ };
345
+ }
346
+
347
+ function normalizeProtectedPaths(entries, warnings) {
348
+ const result = [];
349
+ for (const entry of Array.isArray(entries) ? entries : []) {
350
+ if (!isPlainObject(entry)) {
351
+ warnings.push("protectedPaths: ignoring a non-object entry");
352
+ continue;
353
+ }
354
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
355
+ if (id.length === 0) {
356
+ warnings.push("protectedPaths: ignoring an entry without id");
357
+ continue;
358
+ }
359
+ const match = ["segment", "basename", "path"].includes(entry.match) ? entry.match : null;
360
+ if (match === null) {
361
+ warnings.push(`protectedPaths[${id}]: match must be segment, basename or path`);
362
+ continue;
363
+ }
364
+ const pattern = typeof entry.pattern === "string" ? entry.pattern : "";
365
+ const flags = normalizeFlags(entry.flags);
366
+ if (pattern.length === 0 || compileCached(pattern, flags) === null) {
367
+ warnings.push(`protectedPaths[${id}]: invalid pattern, rule disabled`);
368
+ continue;
369
+ }
370
+ const except = Array.isArray(entry.except) ? entry.except.filter((value) => typeof value === "string") : [];
371
+ for (const source of except) {
372
+ if (compileCached(source, flags) === null) warnings.push(`protectedPaths[${id}]: invalid except pattern ignored`);
373
+ }
374
+ result.push({
375
+ id,
376
+ description: typeof entry.description === "string" && entry.description.length > 0 ? entry.description : id,
377
+ match,
378
+ pattern,
379
+ flags,
380
+ except,
381
+ enabled: entry.enabled !== false,
382
+ });
383
+ }
384
+ return result;
385
+ }
386
+
387
+ function normalizeCommandRules(entries, warnings) {
388
+ const result = [];
389
+ for (const entry of Array.isArray(entries) ? entries : []) {
390
+ if (!isPlainObject(entry)) {
391
+ warnings.push("rules: ignoring a non-object entry");
392
+ continue;
393
+ }
394
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
395
+ if (id.length === 0) {
396
+ warnings.push("rules: ignoring a rule without id");
397
+ continue;
398
+ }
399
+ const pattern = typeof entry.pattern === "string" ? entry.pattern : "";
400
+ const flags = normalizeFlags(entry.flags);
401
+ if (pattern.length === 0 || compileCached(pattern, flags) === null) {
402
+ warnings.push(`rules[${id}]: invalid pattern, rule disabled`);
403
+ continue;
404
+ }
405
+ const severity = entry.severity === "ask" ? "ask" : entry.severity === "deny" ? "deny" : "deny";
406
+ if (entry.severity !== "ask" && entry.severity !== "deny") {
407
+ warnings.push(`rules[${id}]: severity must be ask or deny; defaulting to deny`);
408
+ }
409
+ const except = Array.isArray(entry.except) ? entry.except.filter((value) => typeof value === "string") : [];
410
+ result.push({
411
+ id,
412
+ severity,
413
+ description: typeof entry.description === "string" && entry.description.length > 0 ? entry.description : id,
414
+ pattern,
415
+ flags,
416
+ except,
417
+ enabled: entry.enabled !== false,
418
+ });
419
+ }
420
+ return result;
421
+ }
422
+
423
+ /**
424
+ * Validate and clamp a raw policy document. Never throws: malformed entries are
425
+ * skipped with a warning and the defaults stay in force.
426
+ *
427
+ * @param {unknown} candidate - user policy document.
428
+ * @param {string[]} [warnings] - collector for non-fatal problems.
429
+ * @returns {object} effective policy.
430
+ */
431
+ export function normalizePolicy(candidate, warnings = []) {
432
+ if (candidate !== undefined && candidate !== null && !isPlainObject(candidate)) {
433
+ warnings.push("policy: expected a JSON object, using defaults");
434
+ return normalizePolicy(undefined, warnings);
435
+ }
436
+ if (isPlainObject(candidate) && candidate.version !== undefined && candidate.version !== POLICY_VERSION) {
437
+ warnings.push(`policy: version ${JSON.stringify(candidate.version)} is not ${POLICY_VERSION}; merged anyway`);
438
+ }
439
+ const merged = mergePolicy(defaultPolicy(), candidate);
440
+ return {
441
+ version: POLICY_VERSION,
442
+ enabled: merged.enabled !== false,
443
+ askBehavior: merged.askBehavior === "deny" ? "deny" : "ask",
444
+ limits: normalizeLimits(merged.limits, warnings),
445
+ hooks: normalizeHooks(merged.hooks, warnings),
446
+ protectedPaths: normalizeProtectedPaths(merged.protectedPaths, warnings),
447
+ rules: normalizeCommandRules(merged.rules, warnings),
448
+ };
449
+ }
450
+
451
+ /* ------------------------------------------------------------------ */
452
+ /* Loading */
453
+ /* ------------------------------------------------------------------ */
454
+
455
+ const policyCache = new Map();
456
+
457
+ function readPolicyFile(filePath) {
458
+ let stat;
459
+ try {
460
+ stat = fs.statSync(filePath);
461
+ } catch {
462
+ stat = null;
463
+ }
464
+ const signature = stat === null ? "missing" : `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
465
+ const cached = policyCache.get(filePath);
466
+ if (cached !== undefined && cached.signature === signature) return cached.value;
467
+ let value;
468
+ if (stat === null) {
469
+ value = { exists: false, loadedFromFile: false, error: null, warnings: [], policy: normalizePolicy(undefined) };
470
+ } else if (stat.size > MAX_POLICY_BYTES) {
471
+ value = {
472
+ exists: true,
473
+ loadedFromFile: false,
474
+ error: `policy file is larger than ${MAX_POLICY_BYTES} bytes; defaults are in force`,
475
+ warnings: [],
476
+ policy: normalizePolicy(undefined),
477
+ };
478
+ } else {
479
+ const warnings = [];
480
+ try {
481
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
482
+ if (!isPlainObject(parsed)) throw new Error("policy file must contain a JSON object");
483
+ value = { exists: true, loadedFromFile: true, error: null, warnings, policy: normalizePolicy(parsed, warnings) };
484
+ } catch (error) {
485
+ value = {
486
+ exists: true,
487
+ loadedFromFile: false,
488
+ error: error instanceof Error ? error.message : String(error),
489
+ warnings: [],
490
+ policy: normalizePolicy(undefined),
491
+ };
492
+ }
493
+ }
494
+ if (policyCache.size > 20) policyCache.delete(policyCache.keys().next().value);
495
+ policyCache.set(filePath, { signature, value });
496
+ return value;
497
+ }
498
+
499
+ /**
500
+ * Load the effective policy. Parse results are cached by file mtime/size; the
501
+ * kill switch is evaluated on every call so an environment change needs no
502
+ * reload.
503
+ *
504
+ * @param {{env?: Record<string, string|undefined>, filePath?: string}} [options] - environment and file override.
505
+ * @returns {{path: string, exists: boolean, loadedFromFile: boolean, error: string|null, warnings: string[], policy: object, killSwitch: boolean, enabled: boolean}} loaded state.
506
+ */
507
+ export function loadPolicy(options = {}) {
508
+ const env = options.env ?? process.env;
509
+ const filePath = options.filePath ?? policyPath(env);
510
+ const base = readPolicyFile(filePath);
511
+ const killSwitch = killSwitchActive(env);
512
+ return {
513
+ path: filePath,
514
+ exists: base.exists,
515
+ loadedFromFile: base.loadedFromFile,
516
+ error: base.error,
517
+ warnings: base.warnings,
518
+ policy: base.policy,
519
+ killSwitch,
520
+ enabled: base.policy.enabled === true && !killSwitch,
521
+ };
522
+ }
523
+
524
+ /* ------------------------------------------------------------------ */
525
+ /* Matching */
526
+ /* ------------------------------------------------------------------ */
527
+
528
+ /**
529
+ * Find the protected-path entry matching a resolved display path.
530
+ *
531
+ * @param {string} displayPath - resolved or user-supplied path.
532
+ * @param {{policy: object, env?: Record<string, string|undefined>}} options - effective policy and environment.
533
+ * @returns {{id: string, description: string, match: string} | undefined} matching entry.
534
+ */
535
+ export function matchProtectedPath(displayPath, options) {
536
+ const policy = options?.policy;
537
+ const env = options?.env ?? process.env;
538
+ const normalized = normalizePathForMatch(displayPath, env);
539
+ if (normalized.length === 0 || !policy || !Array.isArray(policy.protectedPaths)) return undefined;
540
+ for (const entry of policy.protectedPaths) {
541
+ if (entry.enabled === false) continue;
542
+ const regex = ruleRegex(entry);
543
+ if (regex === null) continue;
544
+ const candidates =
545
+ entry.match === "basename"
546
+ ? [basenameOf(normalized)]
547
+ : entry.match === "segment"
548
+ ? segmentsOf(normalized)
549
+ : [normalized];
550
+ if (!candidates.some((candidate) => regex.test(candidate))) continue;
551
+ if (excludes(entry.except, normalized, entry.flags)) continue;
552
+ return { id: entry.id, description: entry.description, match: entry.match };
553
+ }
554
+ return undefined;
555
+ }
556
+
557
+ /**
558
+ * Evaluate a shell command against the policy rules.
559
+ *
560
+ * @param {unknown} command - raw command text.
561
+ * @param {{policy: object}} options - effective policy.
562
+ * @returns {{action: "allow"|"ask"|"deny", hits: {id: string, severity: string, description: string}[], primary: object|null, truncated: boolean}} verdict.
563
+ */
564
+ export function evaluateCommand(command, options) {
565
+ const policy = options?.policy;
566
+ const raw = String(command ?? "");
567
+ const truncated = raw.length > MAX_COMMAND_CHARS;
568
+ const text = truncated ? raw.slice(0, MAX_COMMAND_CHARS) : raw;
569
+ const hits = [];
570
+ if (!policy || !Array.isArray(policy.rules) || text.trim().length === 0) {
571
+ return { action: "allow", hits, primary: null, truncated };
572
+ }
573
+ for (const rule of policy.rules) {
574
+ if (rule.enabled === false) continue;
575
+ const regex = ruleRegex(rule);
576
+ if (regex === null || !regex.test(text)) continue;
577
+ if (excludes(rule.except, text, rule.flags)) continue;
578
+ hits.push({ id: rule.id, severity: rule.severity, description: rule.description });
579
+ }
580
+ const deny = hits.find((hit) => hit.severity === "deny");
581
+ if (deny !== undefined) return { action: "deny", hits, primary: deny, truncated };
582
+ if (hits.length > 0) {
583
+ const askBehavior = policy.askBehavior === "deny" ? "deny" : "ask";
584
+ return { action: askBehavior, hits, primary: hits[0], truncated };
585
+ }
586
+ return { action: "allow", hits, primary: null, truncated };
587
+ }
588
+
589
+ /** Absolute path of the DSH credentials file, for status output. */
590
+ export function credentialsPath(env = process.env) {
591
+ return path.join(dshHome(env), ".credentials.yaml");
592
+ }