@fusengine/harness 0.1.43 → 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.
Files changed (43) hide show
  1. package/dist/adapters/claude/index.d.mts +2 -2
  2. package/dist/adapters/claude/index.mjs +2 -2
  3. package/dist/adapters/cline/index.mjs +1 -1
  4. package/dist/adapters/codex/index.d.mts +1 -1
  5. package/dist/adapters/codex/index.mjs +1 -1
  6. package/dist/adapters/cursor/index.mjs +1 -1
  7. package/dist/adapters/gemini/index.mjs +1 -1
  8. package/dist/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
  9. package/dist/cli/bin.mjs +227 -7
  10. package/dist/cli/index.mjs +1 -1
  11. package/dist/config/index.d.mts +1 -1
  12. package/dist/config/index.mjs +1 -2
  13. package/dist/{doc-helpers-D14nkD5D.d.mts → doc-helpers-BNfYWvYv.d.mts} +9 -1
  14. package/dist/{doc-helpers-BhzDmJ18.mjs → doc-helpers-CWZegVdR.mjs} +14 -5
  15. package/dist/{dotenv-DGyLln7U.mjs → dotenv-B9nM4cuQ.mjs} +26 -1
  16. package/dist/evaluate-d7Pp8XJH.mjs +784 -0
  17. package/dist/freshness/index.d.mts +1 -1
  18. package/dist/freshness/index.mjs +1 -1
  19. package/dist/{handle-BTHcKWQ5.mjs → handle-CtMMVoxT.mjs} +789 -357
  20. package/dist/home-state-mKZxP4oZ.mjs +52 -0
  21. package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -1
  22. package/dist/{index-DXQfL1u8.d.mts → index-BXPySPxE.d.mts} +7 -1
  23. package/dist/{index-CwOdFBOr.d.mts → index-BxjzFraL.d.mts} +3 -1
  24. package/dist/{index-DN4cZDbU.d.mts → index-CVhw7eA0.d.mts} +111 -23
  25. package/dist/index.d.mts +5 -5
  26. package/dist/index.mjs +7 -8
  27. package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
  28. package/dist/policy/index.d.mts +2 -2
  29. package/dist/policy/index.mjs +3 -3
  30. package/dist/refs/index.mjs +2 -2
  31. package/dist/{router-BfX0hJg8.mjs → router-PKVNBHge.mjs} +11 -1
  32. package/dist/{run-jgivVDv6.mjs → run-DLXtA5DH.mjs} +1 -1
  33. package/dist/runtime/index.d.mts +71 -14
  34. package/dist/runtime/index.mjs +3 -3
  35. package/dist/{session-state-Dzq6yrw7.d.mts → session-state-D4F_Dub6.d.mts} +1 -1
  36. package/dist/state/index.d.mts +1 -1
  37. package/dist/{store-CdWOQ9zD.mjs → store-CNjFenWe.mjs} +3 -50
  38. package/dist/tracking/index.d.mts +1 -1
  39. package/dist/tracking/index.mjs +1 -1
  40. package/dist/{validate-xi-zc-22.mjs → validate-Ca7NSp-r.mjs} +495 -83
  41. package/package.json +1 -1
  42. package/dist/evaluate-CeivW6G0.mjs +0 -477
  43. 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 };