dsh-desktop-kit 2.2.0 → 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.
- package/CHANGELOG.md +86 -0
- package/README.md +113 -27
- package/client.js +3121 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +5 -3
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +6 -6
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/doctor.ps1 +122 -0
- package/index.js +18 -1
- package/install.ps1 +99 -0
- package/package.json +29 -6
- package/plugins/dsh-agent-extensions/README.md +99 -0
- package/plugins/dsh-agent-extensions/index.js +167 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +9 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +246 -0
- package/uninstall.ps1 +74 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, read-only worktree scanning for the guardrails `check` action.
|
|
3
|
+
*
|
|
4
|
+
* The scanner walks a project, reads text files up to a per-file byte cap,
|
|
5
|
+
* detects secrets, dotenv files, protected paths, oversized files and dangerous
|
|
6
|
+
* commands in shell scripts, then runs `git diff --check` (read-only) when the
|
|
7
|
+
* tree is a git worktree. Every emitted string passes through redaction; the
|
|
8
|
+
* raw secret value never leaves `lib/secrets.js`.
|
|
9
|
+
*
|
|
10
|
+
* Node built-ins only. The scanner never executes a scanned file: the only
|
|
11
|
+
* subprocess is `git` with fixed read-only arguments.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
import { redactText, scanTextForSecrets } from "./secrets.js";
|
|
18
|
+
import { DEFAULT_LIMITS, evaluateCommand, matchProtectedPath } from "./policy.js";
|
|
19
|
+
|
|
20
|
+
/** Directories that never produce findings and are never descended into. */
|
|
21
|
+
export const SKIP_DIRECTORIES = new Set([
|
|
22
|
+
".git",
|
|
23
|
+
".hg",
|
|
24
|
+
".svn",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"bower_components",
|
|
27
|
+
"dist",
|
|
28
|
+
"build",
|
|
29
|
+
"out",
|
|
30
|
+
"target",
|
|
31
|
+
"vendor",
|
|
32
|
+
"coverage",
|
|
33
|
+
".next",
|
|
34
|
+
".nuxt",
|
|
35
|
+
".cache",
|
|
36
|
+
".turbo",
|
|
37
|
+
".parcel-cache",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
".mypy_cache",
|
|
40
|
+
".pytest_cache",
|
|
41
|
+
".ruff_cache",
|
|
42
|
+
".venv",
|
|
43
|
+
"venv",
|
|
44
|
+
"virtualenv",
|
|
45
|
+
".tox",
|
|
46
|
+
".gradle",
|
|
47
|
+
".dsh",
|
|
48
|
+
"pgruntime",
|
|
49
|
+
"tmp",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** Extensions scanned for dangerous command lines. */
|
|
53
|
+
export const SCRIPT_EXTENSIONS = new Set([
|
|
54
|
+
".sh",
|
|
55
|
+
".bash",
|
|
56
|
+
".zsh",
|
|
57
|
+
".ksh",
|
|
58
|
+
".fish",
|
|
59
|
+
".ps1",
|
|
60
|
+
".psm1",
|
|
61
|
+
".bat",
|
|
62
|
+
".cmd",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Cap for whitespace findings from one `git diff --check` run. */
|
|
66
|
+
export const GIT_DIFF_MAX_FINDINGS = 50;
|
|
67
|
+
|
|
68
|
+
const BINARY_SAMPLE_BYTES = 4_096;
|
|
69
|
+
const MAX_COMMAND_LINE_CHARS = 2_000;
|
|
70
|
+
const MAX_SCRIPT_FINDINGS_PER_FILE = 5;
|
|
71
|
+
const ENV_FILE_NAME = /^\.env(?:\..+)?$/iu;
|
|
72
|
+
const ENV_EXAMPLE_NAME = /^\.env\.(?:example|sample|template|dist)$/iu;
|
|
73
|
+
|
|
74
|
+
/** Cheap binary sniff: a NUL byte in the sample marks the file as unreadable text. */
|
|
75
|
+
export function isProbablyBinary(buffer) {
|
|
76
|
+
return buffer.subarray(0, BINARY_SAMPLE_BYTES).includes(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Clamp caller overrides onto the policy limits. Used by the `check` tool and
|
|
81
|
+
* directly testable without filesystem access.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} policy - effective policy.
|
|
84
|
+
* @param {object} [overrides] - `maxFiles`, `maxFileSizeBytes`, `maxFindings` overrides.
|
|
85
|
+
* @returns {object} resolved limits.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveLimits(policy, overrides = {}) {
|
|
88
|
+
const base = policy?.limits ?? DEFAULT_LIMITS;
|
|
89
|
+
const pick = (key, minimum, maximum) => {
|
|
90
|
+
const candidate = Number(overrides?.[key]);
|
|
91
|
+
if (!Number.isFinite(candidate)) return base[key];
|
|
92
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(candidate)));
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
maxFiles: pick("maxFiles", 1, 5_000),
|
|
96
|
+
maxFileSizeBytes: pick("maxFileSizeBytes", 256, 16_777_216),
|
|
97
|
+
maxTotalBytes: pick("maxTotalBytes", 1_024, 67_108_864),
|
|
98
|
+
maxFindings: pick("maxFindings", 1, 2_000),
|
|
99
|
+
maxDepth: pick("maxDepth", 1, 64),
|
|
100
|
+
maxScriptBytes: pick("maxScriptBytes", 1_024, 2_097_152),
|
|
101
|
+
gitTimeoutMs: pick("gitTimeoutMs", 500, 60_000),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readFileBounded(absolutePath, limitBytes) {
|
|
106
|
+
const handle = fs.openSync(absolutePath, "r");
|
|
107
|
+
try {
|
|
108
|
+
const buffer = Buffer.allocUnsafe(limitBytes + 1);
|
|
109
|
+
let total = 0;
|
|
110
|
+
while (total < buffer.length) {
|
|
111
|
+
const read = fs.readSync(handle, buffer, total, buffer.length - total, total);
|
|
112
|
+
if (read === 0) break;
|
|
113
|
+
total += read;
|
|
114
|
+
}
|
|
115
|
+
return buffer.subarray(0, total);
|
|
116
|
+
} finally {
|
|
117
|
+
fs.closeSync(handle);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function listDirectory(directory) {
|
|
122
|
+
try {
|
|
123
|
+
return fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function spawnGit(cwd, args, timeoutMs) {
|
|
130
|
+
try {
|
|
131
|
+
const result = spawnSync("git", args, {
|
|
132
|
+
cwd,
|
|
133
|
+
encoding: "utf8",
|
|
134
|
+
timeout: timeoutMs,
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
maxBuffer: 512 * 1024,
|
|
137
|
+
});
|
|
138
|
+
const timedOut = result.error !== undefined && result.error !== null;
|
|
139
|
+
return {
|
|
140
|
+
status: result.status,
|
|
141
|
+
stdout: result.stdout ?? "",
|
|
142
|
+
stderr: result.stderr ?? "",
|
|
143
|
+
error: result.error instanceof Error ? result.error.message : null,
|
|
144
|
+
timedOut,
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return {
|
|
148
|
+
status: null,
|
|
149
|
+
stdout: "",
|
|
150
|
+
stderr: "",
|
|
151
|
+
error: error instanceof Error ? error.message : String(error),
|
|
152
|
+
timedOut: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run `git diff --check` against the staged + unstaged worktree.
|
|
159
|
+
*
|
|
160
|
+
* @param {{root?: string, timeoutMs?: number}} [options] - worktree root and subprocess deadline.
|
|
161
|
+
* @returns {{available: boolean, ok: boolean, clean: boolean, findings: object[], message: string, truncated: boolean}} bounded result.
|
|
162
|
+
*/
|
|
163
|
+
export function runGitDiffCheck(options = {}) {
|
|
164
|
+
const cwd = path.resolve(typeof options.root === "string" ? options.root : process.cwd());
|
|
165
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(500, Number(options.timeoutMs)) : DEFAULT_LIMITS.gitTimeoutMs;
|
|
166
|
+
const empty = { available: false, ok: false, clean: true, findings: [], message: "", truncated: false };
|
|
167
|
+
const inside = spawnGit(cwd, ["rev-parse", "--is-inside-work-tree"], timeoutMs);
|
|
168
|
+
if (inside.error !== null) return { ...empty, message: `git unavailable: ${inside.error}` };
|
|
169
|
+
if (inside.status !== 0 || inside.stdout.trim() !== "true") return { ...empty, message: "not a git worktree" };
|
|
170
|
+
const head = spawnGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], timeoutMs);
|
|
171
|
+
const args = head.status === 0 ? ["diff", "--check", "HEAD"] : ["diff", "--check"];
|
|
172
|
+
const result = spawnGit(cwd, args, timeoutMs);
|
|
173
|
+
if (result.error !== null) {
|
|
174
|
+
return { ...empty, available: true, message: `git diff --check failed: ${result.error}` };
|
|
175
|
+
}
|
|
176
|
+
const output = `${result.stdout}\n${result.stderr}`.trim();
|
|
177
|
+
const findings = [];
|
|
178
|
+
const notes = [];
|
|
179
|
+
const lines = output.length === 0 ? [] : output.split(/\r?\n/u);
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
if (findings.length >= GIT_DIFF_MAX_FINDINGS) break;
|
|
182
|
+
const match = /^(.+?):(\d+):\s*(.+)$/u.exec(line);
|
|
183
|
+
if (match === null) {
|
|
184
|
+
if (notes.length < 5) notes.push(redactText(line.slice(0, 200)));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
findings.push({
|
|
188
|
+
kind: "git-diff-check",
|
|
189
|
+
severity: "low",
|
|
190
|
+
rule: "git-diff-check",
|
|
191
|
+
path: redactText(match[1].replaceAll("\\", "/")),
|
|
192
|
+
line: Number(match[2]),
|
|
193
|
+
message: redactText(match[3].slice(0, 200)),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const clean = findings.length === 0;
|
|
197
|
+
const detail = notes.length > 0 ? `; ${notes.join("; ")}` : "";
|
|
198
|
+
return {
|
|
199
|
+
available: true,
|
|
200
|
+
ok: true,
|
|
201
|
+
clean,
|
|
202
|
+
findings,
|
|
203
|
+
message: clean ? `clean${detail}` : `${findings.length} whitespace problem(s)${detail}`,
|
|
204
|
+
truncated: findings.length >= GIT_DIFF_MAX_FINDINGS,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Scan one worktree. The function is async only to observe cancellation between
|
|
210
|
+
* files; file I/O itself is synchronous and bounded by the resolved limits.
|
|
211
|
+
*
|
|
212
|
+
* @param {{root?: string, policy: object, env?: object, signal?: AbortSignal, overrides?: object, logger?: object}} options - scan inputs.
|
|
213
|
+
* @returns {Promise<object>} losslessly JSON-serializable report; findings never contain raw secret values.
|
|
214
|
+
*/
|
|
215
|
+
export async function scanProject(options = {}) {
|
|
216
|
+
const policy = options.policy;
|
|
217
|
+
const env = options.env ?? process.env;
|
|
218
|
+
const signal = options.signal;
|
|
219
|
+
const limits = resolveLimits(policy, options.overrides);
|
|
220
|
+
const resolvedRoot = path.resolve(typeof options.root === "string" && options.root.length > 0 ? options.root : process.cwd());
|
|
221
|
+
const report = {
|
|
222
|
+
ok: true,
|
|
223
|
+
root: resolvedRoot,
|
|
224
|
+
scannedAt: new Date().toISOString(),
|
|
225
|
+
limits,
|
|
226
|
+
stats: {
|
|
227
|
+
filesConsidered: 0,
|
|
228
|
+
filesScanned: 0,
|
|
229
|
+
bytesRead: 0,
|
|
230
|
+
directoriesVisited: 0,
|
|
231
|
+
skipped: { directories: 0, symlinks: 0, binary: 0, oversized: 0, unreadable: 0, depthLimit: 0, other: 0 },
|
|
232
|
+
limitsHit: { files: false, bytes: false, findings: false },
|
|
233
|
+
},
|
|
234
|
+
findings: [],
|
|
235
|
+
findingsTruncated: false,
|
|
236
|
+
aborted: false,
|
|
237
|
+
gitDiffCheck: null,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
let rootStat;
|
|
241
|
+
try {
|
|
242
|
+
rootStat = fs.statSync(resolvedRoot);
|
|
243
|
+
} catch {
|
|
244
|
+
return { ...report, ok: false, error: `root does not exist: ${resolvedRoot}` };
|
|
245
|
+
}
|
|
246
|
+
if (!rootStat.isDirectory()) return { ...report, ok: false, error: `root is not a directory: ${resolvedRoot}` };
|
|
247
|
+
|
|
248
|
+
const findings = report.findings;
|
|
249
|
+
let stopWalk = false;
|
|
250
|
+
|
|
251
|
+
const addFinding = (finding) => {
|
|
252
|
+
if (findings.length >= limits.maxFindings) {
|
|
253
|
+
report.findingsTruncated = true;
|
|
254
|
+
report.stats.limitsHit.findings = true;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
findings.push(finding);
|
|
258
|
+
return true;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const queue = [{ directory: resolvedRoot, depth: 0 }];
|
|
262
|
+
let cursor = 0;
|
|
263
|
+
|
|
264
|
+
const scanFile = (absolutePath, relativePath) => {
|
|
265
|
+
let fileStat;
|
|
266
|
+
try {
|
|
267
|
+
fileStat = fs.statSync(absolutePath);
|
|
268
|
+
} catch {
|
|
269
|
+
report.stats.skipped.unreadable += 1;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (fileStat.size > limits.maxFileSizeBytes) {
|
|
273
|
+
report.stats.skipped.oversized += 1;
|
|
274
|
+
addFinding({
|
|
275
|
+
kind: "oversized",
|
|
276
|
+
severity: "low",
|
|
277
|
+
rule: "oversized-file",
|
|
278
|
+
path: relativePath,
|
|
279
|
+
bytes: fileStat.size,
|
|
280
|
+
limit: limits.maxFileSizeBytes,
|
|
281
|
+
message: `file is ${fileStat.size} bytes (limit ${limits.maxFileSizeBytes}); content not scanned`,
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (report.stats.bytesRead + fileStat.size > limits.maxTotalBytes) {
|
|
286
|
+
report.stats.limitsHit.bytes = true;
|
|
287
|
+
stopWalk = true;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const protectedHit = matchProtectedPath(relativePath, { policy, env });
|
|
291
|
+
if (protectedHit !== undefined) {
|
|
292
|
+
addFinding({
|
|
293
|
+
kind: "protected-path",
|
|
294
|
+
severity: "high",
|
|
295
|
+
rule: protectedHit.id,
|
|
296
|
+
path: relativePath,
|
|
297
|
+
message: `protected path (${protectedHit.description}) present in the worktree`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
let buffer;
|
|
301
|
+
try {
|
|
302
|
+
buffer = readFileBounded(absolutePath, limits.maxFileSizeBytes);
|
|
303
|
+
} catch {
|
|
304
|
+
report.stats.skipped.unreadable += 1;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (buffer.length > limits.maxFileSizeBytes) {
|
|
308
|
+
report.stats.skipped.oversized += 1;
|
|
309
|
+
addFinding({
|
|
310
|
+
kind: "oversized",
|
|
311
|
+
severity: "low",
|
|
312
|
+
rule: "oversized-file",
|
|
313
|
+
path: relativePath,
|
|
314
|
+
bytes: fileStat.size,
|
|
315
|
+
limit: limits.maxFileSizeBytes,
|
|
316
|
+
message: `file grew past the ${limits.maxFileSizeBytes}-byte limit while scanning; content not scanned`,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (isProbablyBinary(buffer)) {
|
|
321
|
+
report.stats.skipped.binary += 1;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const text = buffer.toString("utf8");
|
|
325
|
+
report.stats.filesScanned += 1;
|
|
326
|
+
report.stats.bytesRead += buffer.length;
|
|
327
|
+
|
|
328
|
+
const baseName = path.basename(relativePath);
|
|
329
|
+
const isEnvExample = ENV_EXAMPLE_NAME.test(baseName);
|
|
330
|
+
if (ENV_FILE_NAME.test(baseName) && !isEnvExample) {
|
|
331
|
+
const assignments = text.split(/\r?\n/u).filter((line) => /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/u.test(line)).length;
|
|
332
|
+
addFinding({
|
|
333
|
+
kind: "env-file",
|
|
334
|
+
severity: "medium",
|
|
335
|
+
rule: "env-file",
|
|
336
|
+
path: relativePath,
|
|
337
|
+
entries: assignments,
|
|
338
|
+
message: `dotenv file with ${assignments} assignment(s); values are never copied to scan output`,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (!isEnvExample) {
|
|
342
|
+
const remaining = Math.max(1, limits.maxFindings - findings.length);
|
|
343
|
+
for (const secret of scanTextForSecrets(text, { maxFindings: remaining })) {
|
|
344
|
+
if (!addFinding({ kind: "secret", ...secretFinding(relativePath, secret) })) return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const extension = path.extname(baseName).toLowerCase();
|
|
349
|
+
if (SCRIPT_EXTENSIONS.has(extension) && text.length <= limits.maxScriptBytes) {
|
|
350
|
+
const lines = text.split(/\r?\n/u);
|
|
351
|
+
let perFile = 0;
|
|
352
|
+
for (let index = 0; index < lines.length && perFile < MAX_SCRIPT_FINDINGS_PER_FILE; index += 1) {
|
|
353
|
+
const line = lines[index].slice(0, MAX_COMMAND_LINE_CHARS);
|
|
354
|
+
if (line.trim().length === 0) continue;
|
|
355
|
+
const verdict = evaluateCommand(line, { policy });
|
|
356
|
+
if (verdict.action === "allow") continue;
|
|
357
|
+
const added = addFinding({
|
|
358
|
+
kind: "dangerous-command",
|
|
359
|
+
severity: verdict.action === "deny" ? "high" : "medium",
|
|
360
|
+
rule: verdict.primary.id,
|
|
361
|
+
path: relativePath,
|
|
362
|
+
line: index + 1,
|
|
363
|
+
action: verdict.action,
|
|
364
|
+
message: verdict.primary.description,
|
|
365
|
+
preview: redactText(line.slice(0, 160)),
|
|
366
|
+
});
|
|
367
|
+
if (!added) return;
|
|
368
|
+
perFile += 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
while (cursor < queue.length) {
|
|
374
|
+
if (signal?.aborted) {
|
|
375
|
+
report.aborted = true;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
const current = queue[cursor];
|
|
379
|
+
cursor += 1;
|
|
380
|
+
report.stats.directoriesVisited += 1;
|
|
381
|
+
const entries = listDirectory(current.directory);
|
|
382
|
+
for (const entry of entries) {
|
|
383
|
+
if (signal?.aborted) {
|
|
384
|
+
report.aborted = true;
|
|
385
|
+
stopWalk = true;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
const absolutePath = path.join(current.directory, entry.name);
|
|
389
|
+
if (entry.isSymbolicLink()) {
|
|
390
|
+
report.stats.skipped.symlinks += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (entry.isDirectory()) {
|
|
394
|
+
if (SKIP_DIRECTORIES.has(entry.name)) {
|
|
395
|
+
report.stats.skipped.directories += 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (current.depth + 1 > limits.maxDepth) {
|
|
399
|
+
report.stats.skipped.depthLimit += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
queue.push({ directory: absolutePath, depth: current.depth + 1 });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!entry.isFile()) {
|
|
406
|
+
report.stats.skipped.other += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (report.stats.filesConsidered >= limits.maxFiles) {
|
|
410
|
+
report.stats.limitsHit.files = true;
|
|
411
|
+
stopWalk = true;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
report.stats.filesConsidered += 1;
|
|
415
|
+
scanFile(absolutePath, path.relative(resolvedRoot, absolutePath).replaceAll("\\", "/"));
|
|
416
|
+
if (stopWalk) break;
|
|
417
|
+
if (findings.length >= limits.maxFindings) {
|
|
418
|
+
report.findingsTruncated = true;
|
|
419
|
+
report.stats.limitsHit.findings = true;
|
|
420
|
+
stopWalk = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (stopWalk) break;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!report.aborted) {
|
|
428
|
+
report.gitDiffCheck = runGitDiffCheck({ root: resolvedRoot, timeoutMs: limits.gitTimeoutMs });
|
|
429
|
+
for (const finding of report.gitDiffCheck.findings ?? []) {
|
|
430
|
+
if (!addFinding({ ...finding })) break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
report.findingsTotal = report.findings.length;
|
|
435
|
+
options.logger?.debug?.(
|
|
436
|
+
`[guardrails] scan files=${report.stats.filesScanned} findings=${report.findingsTotal} aborted=${report.aborted}`,
|
|
437
|
+
);
|
|
438
|
+
return report;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function secretFinding(relativePath, secret) {
|
|
442
|
+
return {
|
|
443
|
+
severity: secret.severity,
|
|
444
|
+
rule: secret.rule,
|
|
445
|
+
path: relativePath,
|
|
446
|
+
line: secret.line,
|
|
447
|
+
column: secret.column,
|
|
448
|
+
masked: secret.masked,
|
|
449
|
+
message: `${secret.label} detected (value redacted)`,
|
|
450
|
+
};
|
|
451
|
+
}
|