@fusengine/harness 0.1.42 → 0.1.44
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/dist/adapters/claude/index.d.mts +2 -2
- package/dist/adapters/claude/index.mjs +2 -2
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.d.mts +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
- package/dist/cli/bin.mjs +233 -11
- package/dist/cli/index.mjs +1 -1
- package/dist/config/index.d.mts +1 -1
- package/dist/config/index.mjs +1 -2
- package/dist/{doc-helpers-D14nkD5D.d.mts → doc-helpers-BNfYWvYv.d.mts} +9 -1
- package/dist/{doc-helpers-BhzDmJ18.mjs → doc-helpers-CWZegVdR.mjs} +14 -5
- package/dist/{dotenv-DGyLln7U.mjs → dotenv-B9nM4cuQ.mjs} +26 -1
- package/dist/evaluate-d7Pp8XJH.mjs +784 -0
- package/dist/freshness/index.d.mts +1 -1
- package/dist/freshness/index.mjs +1 -1
- package/dist/{handle-BTHcKWQ5.mjs → handle-CtMMVoxT.mjs} +789 -357
- package/dist/home-state-mKZxP4oZ.mjs +52 -0
- package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -1
- package/dist/{index-DXQfL1u8.d.mts → index-BXPySPxE.d.mts} +7 -1
- package/dist/{index-CwOdFBOr.d.mts → index-BxjzFraL.d.mts} +3 -1
- package/dist/{index-DN4cZDbU.d.mts → index-CVhw7eA0.d.mts} +111 -23
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +7 -8
- package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
- package/dist/policy/index.d.mts +2 -2
- package/dist/policy/index.mjs +3 -3
- package/dist/refs/index.mjs +2 -2
- package/dist/{router-BfX0hJg8.mjs → router-PKVNBHge.mjs} +11 -1
- package/dist/{run-jgivVDv6.mjs → run-DLXtA5DH.mjs} +1 -1
- package/dist/runtime/index.d.mts +71 -14
- package/dist/runtime/index.mjs +3 -3
- package/dist/{session-state-Dzq6yrw7.d.mts → session-state-D4F_Dub6.d.mts} +1 -1
- package/dist/state/index.d.mts +1 -1
- package/dist/{store-CdWOQ9zD.mjs → store-CNjFenWe.mjs} +3 -50
- package/dist/tracking/index.d.mts +1 -1
- package/dist/tracking/index.mjs +1 -1
- package/dist/{validate-xi-zc-22.mjs → validate-Ca7NSp-r.mjs} +495 -83
- package/package.json +1 -1
- package/dist/evaluate-CeivW6G0.mjs +0 -477
- package/dist/ttl-BG55s6HZ.mjs +0 -20
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { i as splitTarget, r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
|
|
2
|
+
import { r as fusengineCache, t as claudeHome } from "./home-state-mKZxP4oZ.mjs";
|
|
3
|
+
import { basename, dirname, join, normalize } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
//#region src/policy/file-size.ts
|
|
7
|
+
/**
|
|
8
|
+
* Fixed marketplace plugins root — parity with Python `enforce-file-size.py`'s
|
|
9
|
+
* literal, unexpanded `~/...` string. Exported: reused by
|
|
10
|
+
* `policy/apex.ts::solidReadGate` for its "no reference matched" deny message.
|
|
11
|
+
*/
|
|
12
|
+
const PLUGINS_DIR = "~/.claude/plugins/marketplaces/fusengine-plugins/plugins";
|
|
13
|
+
/**
|
|
14
|
+
* Skill-dir fragment per framework — parity with Python
|
|
15
|
+
* `enforce-file-size.py::get_solid_ref()` (falls back to `generic/`). Exported:
|
|
16
|
+
* reused by `policy/apex.ts::solidReadGate` (see {@link PLUGINS_DIR}).
|
|
17
|
+
*/
|
|
18
|
+
const SOLID_REF = {
|
|
19
|
+
react: "react-expert/skills/solid-react/",
|
|
20
|
+
nextjs: "nextjs-expert/skills/solid-nextjs/",
|
|
21
|
+
laravel: "laravel-expert/skills/solid-php/",
|
|
22
|
+
swift: "swift-apple-expert/skills/solid-swift/"
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Count physical lines — parity with the Python `enforce-file-size.py`
|
|
26
|
+
* (`sum(1 for _ in f)`): every line counts (blanks and comments included), and a
|
|
27
|
+
* single trailing newline does not add a phantom line. The SOLID ceiling is
|
|
28
|
+
* measured on raw file length, not substantive code, to match the upstream plugin.
|
|
29
|
+
*/
|
|
30
|
+
function countLines(content) {
|
|
31
|
+
if (content === "") return 0;
|
|
32
|
+
return content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Count non-empty, non-comment lines — parity with the Python `count_code_lines`
|
|
36
|
+
* shared by the framework-specific SOLID validators: `_shared/scripts/validate_solid_common.py`
|
|
37
|
+
* (imported by `nextjs-expert/scripts/validate-nextjs-solid.py` and
|
|
38
|
+
* `swift-apple-expert/scripts/validate-swift-solid.py`), duplicated verbatim in
|
|
39
|
+
* `react-expert/scripts/validate-react-solid.py` / `laravel-expert/scripts/validate-laravel-solid.py`.
|
|
40
|
+
* Strips blank lines and lines starting with `//` or `*` — a SINGLE fixed rule
|
|
41
|
+
* for all 4 callers (the Python `comment` param defaults to, and every real
|
|
42
|
+
* call site leaves it at, `"//"` — never per-language despite covering
|
|
43
|
+
* ts/tsx/js/jsx, php and swift).
|
|
44
|
+
*
|
|
45
|
+
* Deliberately distinct from two other "code-only" counters already in this
|
|
46
|
+
* repo, neither of which is a faithful substitute here:
|
|
47
|
+
* - `countLoc` (`runtime/lifecycle/check-file-size.ts`): a genuinely
|
|
48
|
+
* per-language table (PHP additionally strips `#`, Python strips
|
|
49
|
+
* `#`/`"""`/`'''`) — ported from the unrelated `solid/scripts/check-file-size.py`.
|
|
50
|
+
* - `countCodeLines` (`runtime/lifecycle/aipilot/solid-compliance.ts`): also
|
|
51
|
+
* strips `#` — ported from `ai-pilot/scripts/check-solid-compliance.py`.
|
|
52
|
+
* Reusing either would silently strip PHP `#`/Python-style comments that the
|
|
53
|
+
* real react/nextjs/laravel/swift validators do NOT strip.
|
|
54
|
+
* @param content - The file content to measure.
|
|
55
|
+
*/
|
|
56
|
+
function countFrameworkCodeLines(content) {
|
|
57
|
+
let count = 0;
|
|
58
|
+
for (const raw of content.split("\n")) {
|
|
59
|
+
const line = raw.trim();
|
|
60
|
+
if (!line || line.startsWith("//") || line.startsWith("*")) continue;
|
|
61
|
+
count++;
|
|
62
|
+
}
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Evaluate a file's line count against the SOLID limit.
|
|
67
|
+
* @param lines - the file's line count
|
|
68
|
+
* @param max - the limit (defaults to `resolveMaxLines()`)
|
|
69
|
+
*/
|
|
70
|
+
function evaluateFileSize(lines, max = resolveMaxLines(), filePath = "", framework = "generic", displayLines = lines) {
|
|
71
|
+
if (lines <= max) return {
|
|
72
|
+
ok: true,
|
|
73
|
+
lines,
|
|
74
|
+
max,
|
|
75
|
+
message: null
|
|
76
|
+
};
|
|
77
|
+
const split = splitTarget(max);
|
|
78
|
+
const fname = filePath ? basename(filePath) : "file";
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
lines,
|
|
82
|
+
max,
|
|
83
|
+
message: `BLOCKED: '${fname}' has ${displayLines} lines (max: ${max}). TO SPLIT: 1) Read SOLID rules: ${PLUGINS_DIR}/${SOLID_REF[framework] ?? "generic/"} 2) Create new module files (<${split} lines each) 3) Use Write to replace '${fname}' with <${max} lines version.`
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/policy/patterns.ts
|
|
88
|
+
/**
|
|
89
|
+
* Guard pattern data, ported verbatim from the fusengine git/install guards.
|
|
90
|
+
* Note (faithful): `git push.*--force` also matches `--force-with-lease` —
|
|
91
|
+
* preserved from the source guard.
|
|
92
|
+
*/
|
|
93
|
+
/** Destructive git operations to block outright. */
|
|
94
|
+
const GIT_BLOCKED = [
|
|
95
|
+
/git push.*--force/,
|
|
96
|
+
/git push.*-f/,
|
|
97
|
+
/git reset.*--hard/,
|
|
98
|
+
/git clean.*-fd/,
|
|
99
|
+
/git branch.*-D/,
|
|
100
|
+
/git rebase.*--force/
|
|
101
|
+
];
|
|
102
|
+
/** Git operations that warrant a confirmation prompt. */
|
|
103
|
+
const GIT_ASK = [
|
|
104
|
+
/git push/,
|
|
105
|
+
/git checkout/,
|
|
106
|
+
/git reset/,
|
|
107
|
+
/git rebase/,
|
|
108
|
+
/git merge/,
|
|
109
|
+
/git stash/,
|
|
110
|
+
/git clean/,
|
|
111
|
+
/git rm/,
|
|
112
|
+
/git mv/,
|
|
113
|
+
/git restore/,
|
|
114
|
+
/git revert/,
|
|
115
|
+
/git cherry-pick/,
|
|
116
|
+
/git commit/,
|
|
117
|
+
/git add/,
|
|
118
|
+
/git branch -d/
|
|
119
|
+
];
|
|
120
|
+
/** System-level package installs (need confirmation). */
|
|
121
|
+
const SYSTEM_INSTALL = [
|
|
122
|
+
/brew install/,
|
|
123
|
+
/brew upgrade/,
|
|
124
|
+
/brew cask/,
|
|
125
|
+
/apt install/,
|
|
126
|
+
/apt-get install/,
|
|
127
|
+
/dnf install/,
|
|
128
|
+
/pacman -S/
|
|
129
|
+
];
|
|
130
|
+
/** Project-level package installs. */
|
|
131
|
+
const PROJECT_INSTALL = [
|
|
132
|
+
/npm install/,
|
|
133
|
+
/npm i /,
|
|
134
|
+
/yarn add/,
|
|
135
|
+
/pnpm add/,
|
|
136
|
+
/pip install/,
|
|
137
|
+
/pip3 install/,
|
|
138
|
+
/composer require/,
|
|
139
|
+
/bun add/,
|
|
140
|
+
/bun install/,
|
|
141
|
+
/cargo install/,
|
|
142
|
+
/go install/,
|
|
143
|
+
/gem install/,
|
|
144
|
+
/pipx install/
|
|
145
|
+
];
|
|
146
|
+
/** True when `cmd` matches any pattern in `patterns`. */
|
|
147
|
+
function matchPatterns(cmd, patterns) {
|
|
148
|
+
return patterns.some((re) => re.test(cmd));
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/policy/file-size-scope.ts
|
|
152
|
+
/**
|
|
153
|
+
* File-size gate scope — parity `enforce-file-size.py` CODE_EXT (both pre- and
|
|
154
|
+
* post-tool-use variants; deliberately excludes `.css`, matching the product
|
|
155
|
+
* decision already applied to `runtime/gate-helpers.ts::isApexScoped` for the
|
|
156
|
+
* sibling `require-apex-agents.py`). NOT `util/project-root.ts::isCodeFile`, a
|
|
157
|
+
* broader general-purpose predicate whose other two callers (`cli/run.ts`,
|
|
158
|
+
* `lifecycle/lessons/dispatch.ts`) are unrelated to this Python source.
|
|
159
|
+
*/
|
|
160
|
+
const FILE_SIZE_CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
|
|
161
|
+
/** True when `filePath` is in scope for the SOLID file-size gate (see {@link FILE_SIZE_CODE_EXT}). */
|
|
162
|
+
function isFileSizeScoped(filePath) {
|
|
163
|
+
return FILE_SIZE_CODE_EXT.test(filePath);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the SOLID_REF framework key for a file — parity with Python
|
|
167
|
+
* `enforce-file-size.py::get_solid_ref()` (NOT `detectFramework()`, which backs
|
|
168
|
+
* the unrelated require-solid-read pipeline and keys off filename/content
|
|
169
|
+
* heuristics that script never checks). Matches Python exactly:
|
|
170
|
+
* - ts/tsx/js/jsx: "nextjs" only when `next.config.js`/`next.config.ts` sits in
|
|
171
|
+
* the SAME directory as the file (Python's literal `os.path.dirname(fp)`
|
|
172
|
+
* check, not a project-root search) — else "react".
|
|
173
|
+
* - vue/svelte/py/go/rs/java/cpp/c/rb/kt/dart/astro: "generic" (Python's
|
|
174
|
+
* `SOLID_MAP.get(ext, 'generic/')` fallback — none of these are map keys).
|
|
175
|
+
* - php: "laravel"; swift: "swift".
|
|
176
|
+
* @param filePath - Absolute path of the file being written/edited.
|
|
177
|
+
*/
|
|
178
|
+
function resolveSolidRefFramework(filePath) {
|
|
179
|
+
const ext = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".") + 1) : "";
|
|
180
|
+
if (ext === "ts" || ext === "tsx" || ext === "js" || ext === "jsx") {
|
|
181
|
+
const dir = dirname(filePath);
|
|
182
|
+
return ["next.config.js", "next.config.ts"].some((c) => existsSync(join(dir, c))) ? "nextjs" : "react";
|
|
183
|
+
}
|
|
184
|
+
if (ext === "php") return "laravel";
|
|
185
|
+
if (ext === "swift") return "swift";
|
|
186
|
+
return "generic";
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/policy/guards/security.ts
|
|
190
|
+
/** Critical patterns that must always be blocked — parity `security_rules.py`'s cumulated violation names. */
|
|
191
|
+
const CRITICAL_PATTERNS = [
|
|
192
|
+
{
|
|
193
|
+
re: /\brm\s+(?:-[a-z]*\s+)*-[a-z]*[rf][a-z]*\s+(?:-[a-z]+\s+)*(?:\/|~)(?:\s|$)/,
|
|
194
|
+
label: "DANGEROUS PATTERN: recursive delete of / or ~"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
198
|
+
label: "DANGEROUS PATTERN: fork bomb"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
re: /\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b/,
|
|
202
|
+
label: "DANGEROUS PATTERN: remote script piped to a shell"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
re: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\s+\//,
|
|
206
|
+
label: "DANGEROUS PATTERN: chmod 777 on /"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
re: /\bmkfs(?:\.[a-z0-9]+)?\b/,
|
|
210
|
+
label: "CRITICAL: Detected dangerous command 'mkfs'"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
re: /\bdd\b[^\n]*\bof=\/dev\/(?:r?disk|sd|hd|nvme|mmcblk|vd|xvd)/i,
|
|
214
|
+
label: "CRITICAL: Detected dangerous command 'dd if='"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
re: /\bshred\b/,
|
|
218
|
+
label: "CRITICAL: Detected dangerous command 'shred'"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
re: /\bfdisk\b/,
|
|
222
|
+
label: "CRITICAL: Detected dangerous command 'fdisk'"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
re: /\bdiskutil\s+(?:erase|partitionDisk)/i,
|
|
226
|
+
label: "CRITICAL: Detected dangerous command 'diskutil erase'"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
re: /(?:>|>>)\s*\/dev\/(?:sda|hda|nvme)/,
|
|
230
|
+
label: "DANGEROUS PATTERN: redirect to a raw disk device"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
re: /\brm\s+(?:-[a-z]*\s+)*-[a-z]*[rf][a-z]*\s+(?:-[a-z]+\s+)*\/(?:etc|usr|var|bin|sbin|boot|lib)\b/,
|
|
234
|
+
label: "DANGEROUS PATTERN: recursive delete of a system directory"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
re: /(?:^|[\s;|&])sudo(?:[\s;|&]|$)/,
|
|
238
|
+
label: "PRIVILEGE ESCALATION: sudo"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
re: /(?:^|[\s;|&])su(?:[\s;|&]|$)/,
|
|
242
|
+
label: "PRIVILEGE ESCALATION: su"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
re: /(?:^|[\s;|&])doas(?:[\s;|&]|$)/,
|
|
246
|
+
label: "PRIVILEGE ESCALATION: doas"
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
re: /(?:^|[\s;|&])passwd(?:[\s;|&]|$)/,
|
|
250
|
+
label: "PRIVILEGE ESCALATION: passwd"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
re: /(?:^|[\s;|&])del(?:[\s;|&]|$)/,
|
|
254
|
+
label: "CRITICAL: Detected dangerous command 'del'"
|
|
255
|
+
}
|
|
256
|
+
];
|
|
257
|
+
/** Patterns that warrant explicit confirmation before running — parity `security_rules.py`'s ask-level violation names. */
|
|
258
|
+
const ASK_PATTERNS = [
|
|
259
|
+
{
|
|
260
|
+
re: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\b/,
|
|
261
|
+
label: "DANGEROUS PATTERN: chmod 777"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
re: /\bchown\s+-R\b/,
|
|
265
|
+
label: "DANGEROUS PATTERN: recursive chown"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
re: /\beval\s/,
|
|
269
|
+
label: "DANGEROUS PATTERN: eval"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
re: /(?:>|>>|\btee\b)\s*\/etc\//,
|
|
273
|
+
label: "DANGEROUS PATTERN: write to /etc"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
re: /\brm\s/,
|
|
277
|
+
label: "DELETE: 'rm' permanently deletes - confirmation required"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
re: /\bunlink\s/,
|
|
281
|
+
label: "DELETE: 'unlink' command detected - confirmation required"
|
|
282
|
+
}
|
|
283
|
+
];
|
|
284
|
+
/**
|
|
285
|
+
* Strip heredoc bodies so their content isn't matched as a command (false positives).
|
|
286
|
+
* Two-phase scan: a backreference-free opener regex finds `<<[-]DELIM`, then the body
|
|
287
|
+
* is cut up to the closing delimiter via `indexOf` — O(n), no catastrophic backtracking.
|
|
288
|
+
*/
|
|
289
|
+
function stripHeredoc(cmd) {
|
|
290
|
+
const opener = /<<-?\s*['"]?(\w+)['"]?/g;
|
|
291
|
+
let out = cmd;
|
|
292
|
+
let m;
|
|
293
|
+
while ((m = opener.exec(out)) !== null) {
|
|
294
|
+
const delim = m[1] ?? "";
|
|
295
|
+
const closer = new RegExp(`\\n[ \\t]*${delim}\\b`);
|
|
296
|
+
const rest = out.slice(m.index + m[0].length);
|
|
297
|
+
const hit = closer.exec(rest);
|
|
298
|
+
if (!hit || hit.index === void 0) break;
|
|
299
|
+
const end = m.index + m[0].length + hit.index + hit[0].length;
|
|
300
|
+
out = `${out.slice(0, m.index)} ${out.slice(end)}`;
|
|
301
|
+
opener.lastIndex = m.index;
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
/** Guards against dangerous Bash commands (critical → block, sensitive → ask). */
|
|
306
|
+
function securityGuard(ctx) {
|
|
307
|
+
if (ctx.tool !== "Bash" || !ctx.command) return null;
|
|
308
|
+
const cmd = stripHeredoc(ctx.command);
|
|
309
|
+
for (const { re, label } of CRITICAL_PATTERNS) if (re.test(cmd)) return {
|
|
310
|
+
kind: "block",
|
|
311
|
+
title: "Dangerous command",
|
|
312
|
+
reason: `${label}.`,
|
|
313
|
+
actions: ["Remove the destructive command", "Scope the operation to a specific safe path"]
|
|
314
|
+
};
|
|
315
|
+
for (const { re, label } of ASK_PATTERNS) if (re.test(cmd)) return {
|
|
316
|
+
kind: "ask",
|
|
317
|
+
title: "Dangerous command",
|
|
318
|
+
reason: `${label}.`,
|
|
319
|
+
actions: ["Confirm this command is intended", "Run with least privilege"]
|
|
320
|
+
};
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/policy/guards/protected-path.ts
|
|
325
|
+
/**
|
|
326
|
+
* Path fragments that mark a location as internal/generated state.
|
|
327
|
+
*
|
|
328
|
+
* Parity with safe_paths.py: `~/.fuse-harness/cache` is a *writable*
|
|
329
|
+
* cache the harness owns (lessons, MCP cache, per-type state) — only the
|
|
330
|
+
* `cache/sessions` subtree is protected, not the whole cache.
|
|
331
|
+
*/
|
|
332
|
+
const PROTECTED_FRAGMENTS = [
|
|
333
|
+
".claude/plugins/marketplaces",
|
|
334
|
+
".claude/plugins/cache",
|
|
335
|
+
".claude/logs/00-apex",
|
|
336
|
+
".fuse-harness/cache/sessions",
|
|
337
|
+
".claude/apex/",
|
|
338
|
+
".harness/track",
|
|
339
|
+
".harness/memory/state"
|
|
340
|
+
];
|
|
341
|
+
/**
|
|
342
|
+
* Matches a real `.git` directory segment (`/.git/`, `~/.git`, leading or
|
|
343
|
+
* trailing `.git`) without matching unrelated names like `foo.git/` or
|
|
344
|
+
* `.github/`. Kept separate from the substring fragments for precise scoping.
|
|
345
|
+
*/
|
|
346
|
+
const PROTECTED_GIT_RE = /(?:^|[/~\s])\.git(?:\/|$)/;
|
|
347
|
+
/** Standard block response for any protected-path violation. */
|
|
348
|
+
const BLOCK = {
|
|
349
|
+
kind: "block",
|
|
350
|
+
title: "Protected path",
|
|
351
|
+
reason: "This is internal/generated enforcement state — do not edit it directly.",
|
|
352
|
+
actions: ["Edit the source, not the generated/cache/state copy"]
|
|
353
|
+
};
|
|
354
|
+
/** Returns true if `str` references a protected fragment or a real `.git` segment. */
|
|
355
|
+
function containsProtected(str) {
|
|
356
|
+
return PROTECTED_FRAGMENTS.some((f) => str.includes(f)) || PROTECTED_GIT_RE.test(str);
|
|
357
|
+
}
|
|
358
|
+
/** Strips surrounding quotes from a captured shell token. */
|
|
359
|
+
function unquote(t) {
|
|
360
|
+
return t.replace(/^['"]|['"]$/g, "");
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Extracts the genuine write *targets* of a shell command, so a protected path
|
|
364
|
+
* appearing only as a *read source* (e.g. `grep x .claude/apex/ > out.txt`,
|
|
365
|
+
* `cat .git/config > /dev/null`) is not mistaken for a write.
|
|
366
|
+
*
|
|
367
|
+
* Covers `>` / `>>` redirects (skipping `2>`, `&>`, `1>` fd-redirects and
|
|
368
|
+
* `/dev/null`), `tee`, `dd of=`, and the destination / in-place file of
|
|
369
|
+
* `cp` / `mv` / `sed -i` / `perl -i` (last path token of the segment).
|
|
370
|
+
* Best-effort: obfuscated shell (base64, indirection, process substitution)
|
|
371
|
+
* can still evade this — residual risk, mitigated by the freshness gate.
|
|
372
|
+
*/
|
|
373
|
+
function extractWriteTargets(cmd) {
|
|
374
|
+
const out = [];
|
|
375
|
+
const push = (t) => {
|
|
376
|
+
const v = unquote((t ?? "").trim());
|
|
377
|
+
if (v && v !== "/dev/null") out.push(v);
|
|
378
|
+
};
|
|
379
|
+
for (const m of cmd.matchAll(/(?<![2&\d])>{1,2}\s*('[^']+'|"[^"]+"|\S+)/g)) push(m[1]);
|
|
380
|
+
for (const m of cmd.matchAll(/\btee\b(?:\s+-\S+)*\s+('[^']+'|"[^"]+"|\S+)/g)) push(m[1]);
|
|
381
|
+
for (const m of cmd.matchAll(/\bdd\b[^|;&]*\bof=('[^']+'|"[^"]+"|\S+)/g)) push(m[1]);
|
|
382
|
+
for (const seg of cmd.split(/[;&|]+/)) {
|
|
383
|
+
if (!/\b(?:cp|mv)\b|\b(?:sed|perl)\b[^|;&]*\s-i/.test(seg)) continue;
|
|
384
|
+
const toks = (seg.split(/[<>]/)[0] ?? "").trim().split(/\s+/).filter((t) => Boolean(t) && !t.startsWith("-"));
|
|
385
|
+
push(toks[toks.length - 1]);
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Blocks direct edits to internal/generated state directories.
|
|
391
|
+
*
|
|
392
|
+
* Covers:
|
|
393
|
+
* - Write / Edit tool calls whose `filePath` targets a protected fragment.
|
|
394
|
+
* - Bash commands whose actual write *target* is a protected fragment
|
|
395
|
+
* (read sources are ignored; see `extractWriteTargets`).
|
|
396
|
+
*
|
|
397
|
+
* @param ctx - The guard context (tool, filePath, command).
|
|
398
|
+
* @returns A blocking {@link Prompt}, or null to allow.
|
|
399
|
+
*/
|
|
400
|
+
function protectedPathGuard(ctx) {
|
|
401
|
+
if ((ctx.tool === "Write" || ctx.tool === "Edit") && ctx.filePath) {
|
|
402
|
+
if (containsProtected(ctx.filePath)) return BLOCK;
|
|
403
|
+
}
|
|
404
|
+
if (ctx.tool === "Bash" && ctx.command) {
|
|
405
|
+
if (extractWriteTargets(ctx.command).some(containsProtected)) return BLOCK;
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/policy/guards/bash-write-safe-paths.ts
|
|
411
|
+
/**
|
|
412
|
+
* Writable paths the harness owns — writes here never need Write/Edit's APEX
|
|
413
|
+
* gates. Parity safe_paths.py `SAFE_WRITE_PATHS`; `~/.claude/fusengine-cache`
|
|
414
|
+
* is rebranded to `~/.fuse-harness/cache`, `~/.claude/logs` is unchanged.
|
|
415
|
+
*/
|
|
416
|
+
const SAFE_WRITE_PATHS = [fusengineCache(), join(claudeHome(), "logs")];
|
|
417
|
+
/** Raw, un-expanded forms of {@link SAFE_WRITE_PATHS} for substring matching
|
|
418
|
+
* when a shell didn't expand `~` (parity safe_paths.py `_SAFE_RAW`). */
|
|
419
|
+
const SAFE_WRITE_RAW = ["~/.fuse-harness/cache", "~/.claude/logs"];
|
|
420
|
+
/** Strip quotes, expand `~`/`$HOME`, normalize (parity safe_paths.resolve_path). */
|
|
421
|
+
function resolvePath(raw) {
|
|
422
|
+
const stripped = raw.trim().replace(/^['"]|['"]$/g, "");
|
|
423
|
+
return normalize((stripped === "~" || stripped.startsWith("~/") ? homedir() + stripped.slice(1) : stripped).replace(/\$HOME/g, homedir()));
|
|
424
|
+
}
|
|
425
|
+
/** Extract the file path after a `>`/`>>` redirect (parity extract_redirect_target). */
|
|
426
|
+
function extractRedirectTarget(cmd) {
|
|
427
|
+
const m = cmd.match(/>>\s*(\S+)|(?<![0-9&])>\s*(\S+)/);
|
|
428
|
+
return m ? resolvePath(m[1] ?? m[2] ?? "") : null;
|
|
429
|
+
}
|
|
430
|
+
/** True when a `>`/`>>` redirect targets a harness-owned safe path (parity is_safe_write_path). */
|
|
431
|
+
function isSafeWritePath(cmd) {
|
|
432
|
+
const target = extractRedirectTarget(cmd);
|
|
433
|
+
return target !== null && SAFE_WRITE_PATHS.some((safe) => target === safe || target.startsWith(safe + "/"));
|
|
434
|
+
}
|
|
435
|
+
/** Extract the file argument of `tee`/`dd of=` (parity extract_command_target). */
|
|
436
|
+
function extractCommandTarget(cmd) {
|
|
437
|
+
const tee = cmd.match(/\btee\s+(?:-[a-z]\s+)*(\S+)/);
|
|
438
|
+
if (tee?.[1]) return resolvePath(tee[1]);
|
|
439
|
+
const dd = cmd.match(/\bdd\b[^|]*\bof=(\S+)/);
|
|
440
|
+
return dd?.[1] ? resolvePath(dd[1]) : null;
|
|
441
|
+
}
|
|
442
|
+
/** True when a `tee`/`dd` target is a harness-owned safe path (parity is_safe_command_target). */
|
|
443
|
+
function isSafeCommandTarget(cmd) {
|
|
444
|
+
const target = extractCommandTarget(cmd);
|
|
445
|
+
return target !== null && SAFE_WRITE_PATHS.some((safe) => target === safe || target.startsWith(safe + "/"));
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* True when the command quotes a safe path as a string literal (parity
|
|
449
|
+
* has_safe_write_target, hardened: the Python original — and this function
|
|
450
|
+
* before this fix — does an unanchored substring match, which fail-opens any
|
|
451
|
+
* `node -e` containing the safe path text anywhere, including inside an inert
|
|
452
|
+
* comment. Requiring it to appear as a quoted literal still covers the real
|
|
453
|
+
* use case (`fs.appendFileSync('~/.fuse-harness/cache/x.json', ...)`).
|
|
454
|
+
*/
|
|
455
|
+
function hasSafeWriteTarget(cmd) {
|
|
456
|
+
const quoted = (p) => cmd.includes(`'${p}'`) || cmd.includes(`"${p}"`) || cmd.includes(`'${p}/`) || cmd.includes(`"${p}/`);
|
|
457
|
+
return SAFE_WRITE_PATHS.some(quoted) || SAFE_WRITE_RAW.some(quoted);
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/policy/guards/bash-write-patterns.ts
|
|
461
|
+
/** Redirect (`>`/`>>`) targeting a code-file extension. */
|
|
462
|
+
const CODE_REDIRECT = /(?:>>?)\s*[^\s|;&]*\.(?:ts|tsx|js|jsx|py|go|rb|rs|java|kt|php|swift|vue|svelte|astro|css|c|cpp|h)\b/;
|
|
463
|
+
/**
|
|
464
|
+
* Interpreters / tools that mutate source in place, plus heredoc-into-file —
|
|
465
|
+
* split into labeled sub-patterns (parity bash-write-guard.py `DENY_PATTERNS`,
|
|
466
|
+
* each with its own `desc`) so the deny reason names which motif matched
|
|
467
|
+
* instead of a single generic message for all six.
|
|
468
|
+
*/
|
|
469
|
+
const CODE_MUTATORS = [
|
|
470
|
+
{
|
|
471
|
+
re: /\bpython3?\s+-\s*<</,
|
|
472
|
+
desc: "Python heredoc input"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
re: /\bpython3?\s+-c\b/,
|
|
476
|
+
desc: "Python inline script"
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
re: /\bsed\b[^|]*\s-i/,
|
|
480
|
+
desc: "sed in-place edit"
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
re: /\bperl\b[^|]*\s-[pi]i?\b/,
|
|
484
|
+
desc: "perl in-place edit"
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
re: /\bawk\b[^|]*-i\s*inplace/,
|
|
488
|
+
desc: "awk in-place edit"
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
re: /\bpatch\b/,
|
|
492
|
+
desc: "patch file modification"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
re: /<<[-~]?\s*['"]?\w+['"]?[\s\S]*?>/,
|
|
496
|
+
desc: "heredoc redirected into a file"
|
|
497
|
+
}
|
|
498
|
+
];
|
|
499
|
+
/** File-mutating one-liners via `node -e` / `ruby -e` (parity NODE_WRITES/RUBY_WRITES). */
|
|
500
|
+
const NODE_WRITES = /writeFile|appendFile|createWriteStream|fs\.(?:write|rename|unlink|mkdir|rmdir|copyFile)|execSync|spawnSync|child_process/;
|
|
501
|
+
const RUBY_WRITES = /File\.(?:write|open|delete|rename)|IO\.write|FileUtils|\bsystem\b|\bexec\b|`[^`]/;
|
|
502
|
+
/** Redirect to a non-code file. Excludes `/dev/null`, `2>`/`N>` and `>&N` fd
|
|
503
|
+
* redirects via the `(?<![0-9&])` lookbehind + `(?!…|&)` (parity has_file_redirect). */
|
|
504
|
+
const FILE_REDIRECT = /(?<![0-9&])\s*>>?\s*(?!\/dev\/null|&)[a-zA-Z./~$]/;
|
|
505
|
+
/** Other ambiguous file writers (ASK): `tee <file>` (not `tee -a`/path) and `dd … of=` —
|
|
506
|
+
* labeled sub-patterns (parity bash-write-guard.py `ASK_PATTERNS`). */
|
|
507
|
+
const ASK_WRITERS = [{
|
|
508
|
+
re: /\btee\s+[^-/\s]/,
|
|
509
|
+
desc: "tee to file"
|
|
510
|
+
}, {
|
|
511
|
+
re: /\bdd\b[^|]*\bof=/,
|
|
512
|
+
desc: "dd output to file"
|
|
513
|
+
}];
|
|
514
|
+
/** Commands whose first token never writes, skipped when a real redirect is
|
|
515
|
+
* present (parity bash-write-guard.py `SAFE_PREFIXES`). */
|
|
516
|
+
const SAFE_PREFIXES = [
|
|
517
|
+
"ls",
|
|
518
|
+
"pwd",
|
|
519
|
+
"which",
|
|
520
|
+
"cat ",
|
|
521
|
+
"head ",
|
|
522
|
+
"tail ",
|
|
523
|
+
"wc ",
|
|
524
|
+
"file ",
|
|
525
|
+
"stat ",
|
|
526
|
+
"tree",
|
|
527
|
+
"du ",
|
|
528
|
+
"df ",
|
|
529
|
+
"find ",
|
|
530
|
+
"grep ",
|
|
531
|
+
"rg ",
|
|
532
|
+
"git ",
|
|
533
|
+
"cd ",
|
|
534
|
+
"source ",
|
|
535
|
+
"export ",
|
|
536
|
+
"unset ",
|
|
537
|
+
"env ",
|
|
538
|
+
"printenv",
|
|
539
|
+
"bun test",
|
|
540
|
+
"bun run",
|
|
541
|
+
"bunx ",
|
|
542
|
+
"npm test",
|
|
543
|
+
"npm run",
|
|
544
|
+
"npx ",
|
|
545
|
+
"biome ",
|
|
546
|
+
"eslint ",
|
|
547
|
+
"prettier ",
|
|
548
|
+
"ruff ",
|
|
549
|
+
"pyright ",
|
|
550
|
+
"tsc ",
|
|
551
|
+
"mkdir ",
|
|
552
|
+
"mv ",
|
|
553
|
+
"cp "
|
|
554
|
+
];
|
|
555
|
+
/**
|
|
556
|
+
* Session-state directory the freshness/APEX gates rely on. Any Bash command
|
|
557
|
+
* touching it is a hook-bypass vector, so it is blocked outright — a blunt
|
|
558
|
+
* substring match (read OR write), parity with bash-write-guard.py DENY_PATTERNS
|
|
559
|
+
* `fusengine-cache/sessions` (rebranded to the harness cache path).
|
|
560
|
+
*/
|
|
561
|
+
const SESSION_STATE_FRAGMENT = ".fuse-harness/cache/sessions";
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/policy/guards/bash-write.ts
|
|
564
|
+
function blockCodeWrite(reason) {
|
|
565
|
+
return {
|
|
566
|
+
kind: "block",
|
|
567
|
+
title: "Bash write to code file",
|
|
568
|
+
reason,
|
|
569
|
+
actions: ["Use the Write/Edit tool instead"]
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function askFileWrite(reason) {
|
|
573
|
+
return {
|
|
574
|
+
kind: "ask",
|
|
575
|
+
title: "Bash file write",
|
|
576
|
+
reason,
|
|
577
|
+
actions: ["Use the Write/Edit tool instead"]
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Blocks shell commands that mutate code files in place (and heredocs/redirects
|
|
582
|
+
* to source files); asks before other file-writing shell commands unless the
|
|
583
|
+
* target is a harness-owned safe path. Forces use of the Write/Edit tool so
|
|
584
|
+
* APEX/SOLID checks are not bypassed.
|
|
585
|
+
*/
|
|
586
|
+
function bashWriteGuard(ctx) {
|
|
587
|
+
if (ctx.tool !== "Bash" || !ctx.command) return null;
|
|
588
|
+
const cmd = ctx.command;
|
|
589
|
+
const stripped = cmd.trim();
|
|
590
|
+
if (SAFE_PREFIXES.some((p) => stripped.startsWith(p)) && !FILE_REDIRECT.test(stripped)) return null;
|
|
591
|
+
if (cmd.includes(".fuse-harness/cache/sessions")) return {
|
|
592
|
+
kind: "block",
|
|
593
|
+
title: "Session-state tampering",
|
|
594
|
+
reason: "Bash access to the harness session-state directory is a hook-bypass vector — the freshness/APEX enforcement reads it to decide block/allow.",
|
|
595
|
+
actions: ["Never read or write session state from the shell"]
|
|
596
|
+
};
|
|
597
|
+
const mutator = CODE_MUTATORS.find((m) => m.re.test(cmd));
|
|
598
|
+
if (mutator) return blockCodeWrite(`${mutator.desc} — Use Edit/Write tools instead`);
|
|
599
|
+
if (FILE_REDIRECT.test(cmd)) {
|
|
600
|
+
if (isSafeWritePath(cmd)) return null;
|
|
601
|
+
return CODE_REDIRECT.test(cmd) ? blockCodeWrite("Bash redirect to code file — Use Write/Edit tools (enforces APEX + SOLID specs)") : askFileWrite("Shell redirect to file detected. Authorize?");
|
|
602
|
+
}
|
|
603
|
+
if (/\bnode\s+-e\b/.test(cmd) && NODE_WRITES.test(cmd)) return hasSafeWriteTarget(cmd) ? null : askFileWrite("Node.js write operation detected. Authorize?");
|
|
604
|
+
if (/\bruby\s+-e\b/.test(cmd) && RUBY_WRITES.test(cmd)) return askFileWrite("Ruby write operation detected. Authorize?");
|
|
605
|
+
const asker = ASK_WRITERS.find((a) => a.re.test(cmd));
|
|
606
|
+
if (asker) return isSafeCommandTarget(cmd) ? null : askFileWrite(`${asker.desc} detected. Authorize?`);
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region src/policy/guards/interface-separation.ts
|
|
611
|
+
/** TS/JS component files: top-level `interface`/`type Foo`. */
|
|
612
|
+
const TS_DECL_RE = /^\s*(export\s+)?(interface|type)\s+[A-Z]/m;
|
|
613
|
+
/** Python view models: class subclassing a schema/protocol base. */
|
|
614
|
+
const PY_MODEL_RE = /^\s*class\s+\w+\((BaseModel|TypedDict|Protocol)\)/m;
|
|
615
|
+
/**
|
|
616
|
+
* PHP controllers: top-level `interface`, `abstract class`, or a concrete
|
|
617
|
+
* `class …Interface/DTO/Request`. Union of the TS-only `abstract class` rule
|
|
618
|
+
* and the Python rule (`class [A-Z].*(Interface|DTO|Request)`, enforce-interfaces.py:16).
|
|
619
|
+
*/
|
|
620
|
+
const PHP_DECL_RE = /^\s*(?:abstract\s+class\b|interface\b|class\s+[A-Z].*(?:Interface|DTO|Request))/m;
|
|
621
|
+
/** Swift views: top-level `protocol Foo`. */
|
|
622
|
+
const SWIFT_PROTO_RE = /^\s*protocol\s+[A-Z]/m;
|
|
623
|
+
/** Go handlers/controllers: top-level `type Foo interface`. */
|
|
624
|
+
const GO_DECL_RE = /^\s*type\s+[A-Z]\w*\s+interface\b/m;
|
|
625
|
+
/** Java/Kotlin controllers/handlers: top-level `interface`/`record`. */
|
|
626
|
+
const JAVA_DECL_RE = /^\s*(?:public\s+|private\s+|protected\s+|internal\s+)?(?:interface|record)\s+[A-Z]/m;
|
|
627
|
+
/**
|
|
628
|
+
* Blocks top-level interface/type/protocol declarations in component, view or
|
|
629
|
+
* controller files (Interface Segregation). Fires only when BOTH the path
|
|
630
|
+
* category AND the content pattern match.
|
|
631
|
+
*
|
|
632
|
+
* Destination text for TS/JS/Vue/Svelte, PHP and Swift matches the user's own
|
|
633
|
+
* `claude-rules/rules/04-solid-dry-rules.md` ("SOLID Skill per Stack" table),
|
|
634
|
+
* the current authoritative convention — NOT the older `enforce-interfaces.py`
|
|
635
|
+
* text, which this guard originally ported. Go/Python/Java/Kotlin aren't
|
|
636
|
+
* covered by that table, so their destinations stay as a reasonable default.
|
|
637
|
+
*
|
|
638
|
+
* Parity note: enforce-interfaces.py only inspects `Write` (tool_input.content).
|
|
639
|
+
* We deliberately also fire on `Edit` — an in-place edit can introduce the same
|
|
640
|
+
* violation — and the path fragments accept singular *and* plural directory
|
|
641
|
+
* names (`view/` + `views/`), mirroring the Python `s?` regexes.
|
|
642
|
+
*/
|
|
643
|
+
function interfaceSeparationGuard(ctx) {
|
|
644
|
+
if (ctx.tool !== "Write" && ctx.tool !== "Edit") return null;
|
|
645
|
+
const path = ctx.filePath;
|
|
646
|
+
const content = ctx.content;
|
|
647
|
+
if (!path || !content) return null;
|
|
648
|
+
const blockWith = (msg, action) => ({
|
|
649
|
+
kind: "block",
|
|
650
|
+
title: "Separate the interface",
|
|
651
|
+
reason: `SOLID VIOLATION: ${msg}`,
|
|
652
|
+
actions: [action]
|
|
653
|
+
});
|
|
654
|
+
const inAny = (...frags) => frags.some((f) => path.includes(f));
|
|
655
|
+
if (/\.(tsx|jsx|vue|svelte)$/.test(path) && TS_DECL_RE.test(content)) return blockWith("Interface/type in component file. Move to modules/[feature]/src/interfaces/", "Move the interface/type to modules/[feature]/src/interfaces/");
|
|
656
|
+
if (/\.py$/.test(path) && inAny("view/", "views/", "controller/", "controllers/", "route/", "routes/") && PY_MODEL_RE.test(content)) return blockWith("Type class in view file. Move to src/interfaces/", "Move the type class to src/interfaces/");
|
|
657
|
+
if (/\.go$/.test(path) && inAny("handler/", "handlers/", "controller/", "controllers/") && GO_DECL_RE.test(content)) return blockWith("Interface in handler file. Move to internal/interfaces/", "Move the interface to internal/interfaces/");
|
|
658
|
+
if (/\.(java|kt)$/.test(path) && inAny("controller/", "controllers/", "handler/", "handlers/") && JAVA_DECL_RE.test(content)) return blockWith("Interface in controller file. Move to interfaces/ package", "Move the interface to the interfaces/ package");
|
|
659
|
+
if (/\.php$/.test(path) && inAny("Controller/", "Controllers/", "Handler/", "Handlers/") && PHP_DECL_RE.test(content)) return blockWith("Interface in controller file. Move to app/Contracts/", "Move the interface to app/Contracts/");
|
|
660
|
+
if (/\.swift$/.test(path) && inAny("View/", "Views/", "Component/", "Components/") && SWIFT_PROTO_RE.test(content)) return blockWith("Protocol in view file. Move to Sources/Interfaces/", "Move the protocol to Sources/Interfaces/");
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/policy/guards/install.ts
|
|
665
|
+
/** Asks for confirmation before a dependency or system package install. */
|
|
666
|
+
function installGuard(ctx) {
|
|
667
|
+
if (ctx.tool !== "Bash" || !ctx.command) return null;
|
|
668
|
+
if (matchPatterns(ctx.command, PROJECT_INSTALL) || matchPatterns(ctx.command, SYSTEM_INSTALL)) return {
|
|
669
|
+
kind: "ask",
|
|
670
|
+
title: "Dependency install",
|
|
671
|
+
reason: `This command installs packages: ${ctx.command.trim()}`,
|
|
672
|
+
actions: ["Confirm this install is intended"]
|
|
673
|
+
};
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/policy/guards/index.ts
|
|
678
|
+
/** Ordered guard chain: critical/security + protected first, then writes/installs. */
|
|
679
|
+
const GUARDS = [
|
|
680
|
+
securityGuard,
|
|
681
|
+
protectedPathGuard,
|
|
682
|
+
bashWriteGuard,
|
|
683
|
+
interfaceSeparationGuard,
|
|
684
|
+
installGuard
|
|
685
|
+
];
|
|
686
|
+
/** Block prompt returned when a guard or gate throws (fail-closed). */
|
|
687
|
+
const FAIL_CLOSED = {
|
|
688
|
+
kind: "block",
|
|
689
|
+
title: "Policy error",
|
|
690
|
+
reason: "A policy check errored — blocked for safety (fail-closed).",
|
|
691
|
+
actions: ["Fix the failing guard/gate, then retry"]
|
|
692
|
+
};
|
|
693
|
+
const USER_GUARDS = [];
|
|
694
|
+
/** Register a user guard — runs AFTER the privileged core chain (two-tier). */
|
|
695
|
+
function registerGuard(guard) {
|
|
696
|
+
USER_GUARDS.push(guard);
|
|
697
|
+
}
|
|
698
|
+
/** Remove all registered user guards (mainly for tests). */
|
|
699
|
+
function clearUserGuards() {
|
|
700
|
+
USER_GUARDS.length = 0;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Run the guard chain — privileged core guards first, then user guards — and
|
|
704
|
+
* return the first firing Prompt, else null. Fail-closed: a guard that throws
|
|
705
|
+
* blocks (never silently passes).
|
|
706
|
+
*/
|
|
707
|
+
function runGuards(ctx) {
|
|
708
|
+
for (const guard of [...GUARDS, ...USER_GUARDS]) {
|
|
709
|
+
let hit;
|
|
710
|
+
try {
|
|
711
|
+
hit = guard(ctx);
|
|
712
|
+
} catch {
|
|
713
|
+
return FAIL_CLOSED;
|
|
714
|
+
}
|
|
715
|
+
if (hit) return hit;
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/policy/evaluate.ts
|
|
721
|
+
/**
|
|
722
|
+
* Evaluate a single tool-use against the bundled policies, returning a pure
|
|
723
|
+
* decision plus a portable {@link Prompt}. Adapters translate the prompt into
|
|
724
|
+
* their harness's native response (Claude `permissionDecision`, etc.).
|
|
725
|
+
*/
|
|
726
|
+
function evaluate(ctx) {
|
|
727
|
+
const guard = runGuards(ctx);
|
|
728
|
+
if (guard) return {
|
|
729
|
+
decision: "deny",
|
|
730
|
+
message: guard.reason,
|
|
731
|
+
prompt: guard
|
|
732
|
+
};
|
|
733
|
+
if (ctx.command && matchPatterns(ctx.command, GIT_BLOCKED)) {
|
|
734
|
+
const reason = `Destructive git command: ${ctx.command}`;
|
|
735
|
+
return {
|
|
736
|
+
decision: "deny",
|
|
737
|
+
message: reason,
|
|
738
|
+
prompt: {
|
|
739
|
+
kind: "block",
|
|
740
|
+
title: "Destructive git command",
|
|
741
|
+
reason,
|
|
742
|
+
actions: ["Use a non-destructive alternative (e.g. --force-with-lease; avoid --hard / -D)"]
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (ctx.command && matchPatterns(ctx.command, GIT_ASK)) return {
|
|
747
|
+
decision: "deny",
|
|
748
|
+
message: `Git operation requires confirmation: ${ctx.command}`,
|
|
749
|
+
prompt: {
|
|
750
|
+
kind: "ask",
|
|
751
|
+
title: "Confirm git operation",
|
|
752
|
+
reason: `Authorize: ${ctx.command.trim()}`,
|
|
753
|
+
actions: ["Approve if this git operation is intended"]
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
if (ctx.filePath && isFileSizeScoped(ctx.filePath) && ctx.agentType !== "Explore" && ctx.agentType !== "Plan") {
|
|
757
|
+
const incoming = ctx.content !== void 0 ? countLines(ctx.content) : 0;
|
|
758
|
+
const lines = ctx.tool === "Edit" ? Math.max(incoming, ctx.existingLines ?? 0) : incoming || (ctx.existingLines ?? 0);
|
|
759
|
+
const framework = resolveSolidRefFramework(ctx.filePath);
|
|
760
|
+
const displayLines = ctx.tool === "Write" ? ctx.existingLines ?? lines : lines;
|
|
761
|
+
const verdict = evaluateFileSize(lines, ctx.maxLines, ctx.filePath, framework, displayLines);
|
|
762
|
+
if (lines > 0 && !verdict.ok) return {
|
|
763
|
+
decision: "deny",
|
|
764
|
+
message: verdict.message,
|
|
765
|
+
prompt: {
|
|
766
|
+
kind: "block",
|
|
767
|
+
title: "SOLID file-size limit",
|
|
768
|
+
reason: verdict.message ?? "",
|
|
769
|
+
actions: [`Split into modules under ${verdict.max} lines (Single Responsibility)`, "Then re-run the write"]
|
|
770
|
+
},
|
|
771
|
+
meta: {
|
|
772
|
+
framework,
|
|
773
|
+
lines: verdict.lines,
|
|
774
|
+
max: verdict.max
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
decision: "allow",
|
|
780
|
+
message: null
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
//#endregion
|
|
784
|
+
export { PROJECT_INSTALL as A, PROTECTED_GIT_RE as C, securityGuard as D, CRITICAL_PATTERNS as E, countFrameworkCodeLines as F, countLines as I, evaluateFileSize as L, matchPatterns as M, PLUGINS_DIR as N, GIT_ASK as O, SOLID_REF as P, PROTECTED_FRAGMENTS as S, ASK_PATTERNS as T, CODE_MUTATORS as _, registerGuard as a, SAFE_PREFIXES as b, GO_DECL_RE as c, PY_MODEL_RE as d, SWIFT_PROTO_RE as f, ASK_WRITERS as g, bashWriteGuard as h, clearUserGuards as i, SYSTEM_INSTALL as j, GIT_BLOCKED as k, JAVA_DECL_RE as l, interfaceSeparationGuard as m, FAIL_CLOSED as n, runGuards as o, TS_DECL_RE as p, GUARDS as r, installGuard as s, evaluate as t, PHP_DECL_RE as u, CODE_REDIRECT as v, protectedPathGuard as w, SESSION_STATE_FRAGMENT as x, FILE_REDIRECT as y };
|