@triflux/core 10.0.3 → 10.25.1

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 (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +47 -7
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +279 -33
  7. package/hooks/hook-registry.json +104 -7
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +308 -18
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +832 -291
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -3
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -17
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +171 -39
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +303 -107
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +116 -58
  75. package/hub/research.mjs +34 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +129 -63
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +76 -72
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +393 -249
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1282 -116
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +72 -12
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +36 -11
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -11,12 +11,7 @@
11
11
  "skill": null,
12
12
  "action": "suppress_all",
13
13
  "priority": 0,
14
- "supersedes": [
15
- "tfx-multi",
16
- "tfx-unified",
17
- "tfx-codex",
18
- "tfx-gemini"
19
- ],
14
+ "supersedes": ["tfx-multi", "tfx-unified", "tfx-codex", "tfx-gemini"],
20
15
  "exclusive": true,
21
16
  "state": null,
22
17
  "mcp_route": null
@@ -29,7 +24,7 @@
29
24
  "flags": "i"
30
25
  }
31
26
  ],
32
- "skill": "tfx-multi",
27
+ "skill": "tfx-auto",
33
28
  "priority": 1,
34
29
  "supersedes": [],
35
30
  "exclusive": false,
@@ -62,9 +57,7 @@
62
57
  ],
63
58
  "skill": "tfx-swarm",
64
59
  "priority": 1,
65
- "supersedes": [
66
- "tfx-codex"
67
- ],
60
+ "supersedes": ["tfx-codex"],
68
61
  "exclusive": false,
69
62
  "state": null,
70
63
  "mcp_route": null
@@ -72,15 +65,54 @@
72
65
  {
73
66
  "id": "tfx-unified",
74
67
  "patterns": [
75
- { "source": "\\btfx[\\s-]?auto\\b", "flags": "i" },
76
- { "source": "\\btfx[\\s-]?auto[\\s-]?codex\\b", "flags": "i" },
77
- { "source": "(?:만들어|고쳐|구현해|짜줘|수정해|바꿔)", "flags": "i" },
78
- { "source": "(?:리뷰해|검토해|봐줘|괜찮아)", "flags": "i" },
79
- { "source": "(?:테스트|검증|돌려봐|QA)", "flags": "i" },
80
- { "source": "(?:분석해|계획|설계해)", "flags": "i" },
81
- { "source": "(?:찾아봐|조사해|검색해)", "flags": "i" },
82
- { "source": "(?:정리해|슬롭|클린업)", "flags": "i" },
83
- { "source": "\\b(?:implement|build|fix|review|test|plan|analyze)\\b", "flags": "i" }
68
+ {
69
+ "source": "\\btfx[\\s-]?auto\\b",
70
+ "flags": "i"
71
+ },
72
+ {
73
+ "source": "\\btfx[\\s-]?auto[\\s-]?codex\\b",
74
+ "flags": "i"
75
+ },
76
+ {
77
+ "source": "(?:만들어|고쳐|구현해|짜줘|수정해|바꿔)",
78
+ "flags": "i"
79
+ },
80
+ {
81
+ "source": "(?:리뷰해|검토해|봐줘|괜찮아)",
82
+ "flags": "i"
83
+ },
84
+ {
85
+ "source": "(?:테스트|검증|돌려봐|QA)",
86
+ "flags": "i"
87
+ },
88
+ {
89
+ "source": "(?:분석해|계획|설계해)",
90
+ "flags": "i"
91
+ },
92
+ {
93
+ "source": "(?:찾아봐|조사해|검색해)",
94
+ "flags": "i"
95
+ },
96
+ {
97
+ "source": "(?:정리해|슬롭|클린업)",
98
+ "flags": "i"
99
+ },
100
+ {
101
+ "source": "(?:병렬|동시에|parallel|concurrent)",
102
+ "flags": "i"
103
+ },
104
+ {
105
+ "source": "(?:점검|진단|확인해)",
106
+ "flags": "i"
107
+ },
108
+ {
109
+ "source": "(?:계속해|이어서|진행해)",
110
+ "flags": "i"
111
+ },
112
+ {
113
+ "source": "\\b(?:implement|build|fix|review|test|plan|analyze)\\b",
114
+ "flags": "i"
115
+ }
84
116
  ],
85
117
  "skill": "tfx-auto",
86
118
  "priority": 2,
@@ -97,7 +129,7 @@
97
129
  "flags": "i"
98
130
  }
99
131
  ],
100
- "skill": "tfx-codex",
132
+ "skill": "tfx-auto",
101
133
  "priority": 3,
102
134
  "supersedes": [],
103
135
  "exclusive": false,
@@ -112,7 +144,7 @@
112
144
  "flags": "i"
113
145
  }
114
146
  ],
115
- "skill": "tfx-gemini",
147
+ "skill": "tfx-auto",
116
148
  "priority": 3,
117
149
  "supersedes": [],
118
150
  "exclusive": false,
@@ -172,7 +204,7 @@
172
204
  "flags": "i"
173
205
  }
174
206
  ],
175
- "skill": "tfx-autoresearch",
207
+ "skill": "tfx-research",
176
208
  "priority": 2,
177
209
  "supersedes": [],
178
210
  "exclusive": false,
@@ -187,13 +219,101 @@
187
219
  "flags": "i"
188
220
  }
189
221
  ],
190
- "skill": "tfx-deep-interview",
222
+ "skill": "tfx-interview",
191
223
  "priority": 2,
192
224
  "supersedes": [],
193
225
  "exclusive": false,
194
226
  "state": null,
195
227
  "mcp_route": null
196
228
  },
229
+ {
230
+ "id": "wt-tab-route",
231
+ "patterns": [
232
+ {
233
+ "source": "(?:새\\s*탭|탭\\s*(?:새로|추가|생성|열|띄|파|만들))",
234
+ "flags": "i"
235
+ },
236
+ {
237
+ "source": "(?:패인|화면|pane)\\s*(?:분할|나눠|스플릿|split)",
238
+ "flags": "i"
239
+ },
240
+ {
241
+ "source": "\\bwt\\b\\s*(?:에\\s*)?(?:탭|tab|열|띄|새)",
242
+ "flags": "i"
243
+ },
244
+ {
245
+ "source": "(?:터미널|terminal)\\s*(?:탭|새\\s*탭)",
246
+ "flags": "i"
247
+ },
248
+ {
249
+ "source": "\\b(?:new\\s+tab|split\\s+pane|open\\s+(?:new\\s+)?tab)\\b",
250
+ "flags": "i"
251
+ }
252
+ ],
253
+ "skill": "tfx-wt",
254
+ "priority": 1,
255
+ "supersedes": ["tfx-unified"],
256
+ "exclusive": false,
257
+ "state": null,
258
+ "mcp_route": null
259
+ },
260
+ {
261
+ "id": "wt-tab-rename",
262
+ "patterns": [
263
+ {
264
+ "source": "(?:탭\\s*(?:이름|제목)\\s*(?:바꿔|변경|rename))",
265
+ "flags": "i"
266
+ },
267
+ {
268
+ "source": "(?:rename\\s+tab|tab\\s+rename)",
269
+ "flags": "i"
270
+ }
271
+ ],
272
+ "skill": "tfx-wt",
273
+ "priority": 1,
274
+ "supersedes": ["tfx-unified"],
275
+ "exclusive": false,
276
+ "state": null,
277
+ "mcp_route": null
278
+ },
279
+ {
280
+ "id": "wt-tab-list",
281
+ "patterns": [
282
+ {
283
+ "source": "(?:탭\\s*(?:목록|리스트|열린|현재))",
284
+ "flags": "i"
285
+ },
286
+ {
287
+ "source": "(?:list\\s+tabs|tab\\s+list|열린\\s*탭)",
288
+ "flags": "i"
289
+ }
290
+ ],
291
+ "skill": "tfx-wt",
292
+ "priority": 1,
293
+ "supersedes": ["tfx-unified"],
294
+ "exclusive": false,
295
+ "state": null,
296
+ "mcp_route": null
297
+ },
298
+ {
299
+ "id": "wt-tab-close",
300
+ "patterns": [
301
+ {
302
+ "source": "(?:탭\\s*(?:닫아|닫기|종료|정리|close))",
303
+ "flags": "i"
304
+ },
305
+ {
306
+ "source": "(?:close\\s+tab|tab\\s+close|탭\\s*정리)",
307
+ "flags": "i"
308
+ }
309
+ ],
310
+ "skill": "tfx-wt",
311
+ "priority": 1,
312
+ "supersedes": ["tfx-unified"],
313
+ "exclusive": false,
314
+ "state": null,
315
+ "mcp_route": null
316
+ },
197
317
  {
198
318
  "id": "handoff-route",
199
319
  "patterns": [
@@ -432,6 +552,7 @@
432
552
  },
433
553
  {
434
554
  "id": "gstack-ship",
555
+ "disabled": true,
435
556
  "patterns": [
436
557
  {
437
558
  "source": "(?:배포해|PR\\s*만들|릴리스\\s*해|머지하고\\s*배포)",
@@ -555,6 +676,45 @@
555
676
  "exclusive": false,
556
677
  "state": null,
557
678
  "mcp_route": null
679
+ },
680
+ {
681
+ "id": "tfx-ship",
682
+ "patterns": [
683
+ {
684
+ "source": "\\btfx[\\s-]?ship\\b",
685
+ "flags": "i"
686
+ },
687
+ {
688
+ "source": "/ship\\b",
689
+ "flags": "i"
690
+ },
691
+ {
692
+ "source": "\\brelease\\b",
693
+ "flags": "i"
694
+ },
695
+ {
696
+ "source": "\\bpublish\\b",
697
+ "flags": "i"
698
+ },
699
+ {
700
+ "source": "배포(?!자|사|장|처)",
701
+ "flags": ""
702
+ },
703
+ {
704
+ "source": "릴리[즈스]",
705
+ "flags": ""
706
+ },
707
+ {
708
+ "source": "쉽\\s*(?:해|하자|가자|쳐)",
709
+ "flags": ""
710
+ }
711
+ ],
712
+ "skill": "tfx-ship",
713
+ "priority": 3,
714
+ "supersedes": ["gstack-ship"],
715
+ "exclusive": false,
716
+ "state": null,
717
+ "mcp_route": null
558
718
  }
559
719
  ]
560
720
  }
@@ -51,7 +51,7 @@ export function resolvePluginRoot(callerUrl) {
51
51
 
52
52
  const moduleFallback = toPluginRootFromUrl(import.meta.url) || process.cwd();
53
53
  process.stderr.write(
54
- `[resolve-root] warning: failed to resolve plugin root from breadcrumb/env/caller; fallback=${moduleFallback}\n`
54
+ `[resolve-root] warning: failed to resolve plugin root from breadcrumb/env/caller; fallback=${moduleFallback}\n`,
55
55
  );
56
56
  return moduleFallback;
57
57
  }
@@ -25,10 +25,14 @@ function buildSystemMessage(filePath, stdioServers, result) {
25
25
 
26
26
  if (result.modified) {
27
27
  const actionLabel = result.replacement ? "자동 치환" : "자동 제거";
28
- lines.push(`[mcp-guard] stdio MCP ${actionLabel}: ${stdioServers.map((server) => server.name).join(", ")}`);
28
+ lines.push(
29
+ `[mcp-guard] stdio MCP ${actionLabel}: ${stdioServers.map((server) => server.name).join(", ")}`,
30
+ );
29
31
 
30
32
  if (result.replacement?.name && result.replacement?.url) {
31
- lines.push(`[mcp-guard] 대체 서버: ${result.replacement.name} -> ${result.replacement.url}`);
33
+ lines.push(
34
+ `[mcp-guard] 대체 서버: ${result.replacement.name} -> ${result.replacement.url}`,
35
+ );
32
36
  }
33
37
 
34
38
  if (result.backupPath) {
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { stdin, stdout } from "node:process";
4
+
5
+ const META_PATTERNS = [/&&/, /\|\|/, /[;|<>`]/, /\$\(/];
6
+ const RISKY_PATTERNS = [
7
+ /(^|\s)rm(\s|$)/,
8
+ /(^|\s)mv(\s|$)/,
9
+ /(^|\s)cp(\s|$)/,
10
+ /(^|\s)curl(\s|$)/,
11
+ /(^|\s)wget(\s|$)/,
12
+ /(^|\s)sudo(\s|$)/,
13
+ /(^|\s)chmod(\s|$)/,
14
+ /(^|\s)chown(\s|$)/,
15
+ /(^|\s)git\s+push(\s|$)/,
16
+ /(^|\s)git\s+reset(\s|$)/,
17
+ /(^|\s)git\s+clean(\s|$)/,
18
+ /(^|\s)npm\s+publish(\s|$)/,
19
+ /(^|\s)pnpm\s+publish(\s|$)/,
20
+ /(^|\s)yarn\s+publish(\s|$)/,
21
+ ];
22
+
23
+ function silentlyExit() {
24
+ process.exit(0);
25
+ }
26
+
27
+ function readStdin() {
28
+ return new Promise((resolve) => {
29
+ let input = "";
30
+ stdin.setEncoding("utf8");
31
+ stdin.on("data", (chunk) => {
32
+ input += chunk;
33
+ });
34
+ stdin.on("end", () => resolve(input));
35
+ stdin.resume();
36
+ });
37
+ }
38
+
39
+ function shellWords(command) {
40
+ const words = [];
41
+ const pattern = /"([^"]*)"|'([^']*)'|([^\s]+)/g;
42
+ let match;
43
+ while ((match = pattern.exec(command)) !== null) {
44
+ words.push(match[1] ?? match[2] ?? match[3]);
45
+ }
46
+ return words;
47
+ }
48
+
49
+ function isRepoRelativePath(value) {
50
+ if (!value || value.startsWith("/") || value.startsWith("~")) return false;
51
+ if (value === ".") return true;
52
+ if (value === ".." || value.startsWith("../") || value.includes("/../"))
53
+ return false;
54
+ return /^[A-Za-z0-9._/*?@{}+,:=-][A-Za-z0-9._/*?'@{}+,:=-]*$/.test(value);
55
+ }
56
+
57
+ function hasUnsafeSurface(command) {
58
+ return (
59
+ META_PATTERNS.some((pattern) => pattern.test(command)) ||
60
+ RISKY_PATTERNS.some((pattern) => pattern.test(command))
61
+ );
62
+ }
63
+
64
+ function allowGit(words, command) {
65
+ if (command === "git status" || command === "git status --short")
66
+ return "git status";
67
+ if (command === "git diff --stat" || command === "git diff --cached --stat")
68
+ return "git diff stat";
69
+ if (command === "git branch --show-current") return "git branch";
70
+ if (words[0] === "git" && words[1] === "log" && words[2] === "--oneline") {
71
+ const rest = words.slice(3);
72
+ if (
73
+ rest.every(
74
+ (word) =>
75
+ /^[A-Za-z0-9._/@{}~^:+,=-]+$/.test(word) && !word.startsWith("-"),
76
+ )
77
+ ) {
78
+ return "git log";
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function allowListing(words) {
85
+ if (words[0] === "pwd" && words.length === 1) return "pwd";
86
+
87
+ if (words[0] === "ls") {
88
+ const args = words.slice(1);
89
+ if (
90
+ args.every(
91
+ (word) => /^-[A-Za-z0-9]+$/.test(word) || isRepoRelativePath(word),
92
+ )
93
+ ) {
94
+ return "ls";
95
+ }
96
+ }
97
+
98
+ if (words[0] === "find") {
99
+ const args = words.slice(1);
100
+ if (args.some((word) => word === "-delete" || word.startsWith("-exec")))
101
+ return null;
102
+ const firstOption = args.findIndex((word) => word.startsWith("-"));
103
+ const pathArgs = (
104
+ firstOption === -1 ? args : args.slice(0, firstOption)
105
+ ).filter(Boolean);
106
+ if (pathArgs.length === 0 || pathArgs.every(isRepoRelativePath)) {
107
+ return "find";
108
+ }
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ function allowSearch(words) {
115
+ if (words[0] !== "rg" && words[0] !== "grep") return null;
116
+ const args = words.slice(1);
117
+ if (args.length === 0) return null;
118
+ if (
119
+ args.some(
120
+ (word) =>
121
+ word.startsWith("/") ||
122
+ word === ".." ||
123
+ word.startsWith("../") ||
124
+ word.includes("/../"),
125
+ )
126
+ ) {
127
+ return null;
128
+ }
129
+ return `${words[0]} search`;
130
+ }
131
+
132
+ function allowTargetedTest(words) {
133
+ if (words[0] !== "node" || words[1] !== "scripts/test-lock.mjs") return null;
134
+ if (!words.includes("--test")) return null;
135
+ const args = words.slice(2);
136
+ const allowedFlags = new Set(["--test", "--test-force-exit"]);
137
+ const pathArgs = [];
138
+ for (const word of args) {
139
+ if (allowedFlags.has(word)) continue;
140
+ if (/^--test-concurrency=\d+$/.test(word)) continue;
141
+ if (word.startsWith("-")) return null;
142
+ pathArgs.push(word);
143
+ }
144
+ if (pathArgs.length === 0) return null;
145
+ if (
146
+ pathArgs.every(
147
+ (word) =>
148
+ word.startsWith("tests/") || word.startsWith("scripts/__tests__/"),
149
+ ) &&
150
+ pathArgs.every(isRepoRelativePath)
151
+ ) {
152
+ return "targeted test";
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function allowNpm(command) {
158
+ // npm scripts are intentionally not auto-allowed: package.json can redefine
159
+ // them to perform arbitrary side effects. Prefer explicit node test commands.
160
+ void command;
161
+ return null;
162
+ }
163
+
164
+ function allowanceReason(command) {
165
+ const trimmed = command.trim().replace(/\s+/g, " ");
166
+ if (!trimmed || hasUnsafeSurface(trimmed)) return null;
167
+
168
+ const words = shellWords(trimmed);
169
+ if (words.length === 0) return null;
170
+
171
+ return (
172
+ allowGit(words, trimmed) ??
173
+ allowListing(words) ??
174
+ allowSearch(words) ??
175
+ allowTargetedTest(words) ??
176
+ allowNpm(trimmed)
177
+ );
178
+ }
179
+
180
+ function hasAskSuggestion(value) {
181
+ if (!value || typeof value !== "object") return false;
182
+ if (Array.isArray(value)) return value.some(hasAskSuggestion);
183
+ if (value.behavior === "ask") return true;
184
+ if (value.decision?.behavior === "ask") return true;
185
+ return Object.values(value).some(hasAskSuggestion);
186
+ }
187
+
188
+ function emitAllow(reason) {
189
+ stdout.write(
190
+ JSON.stringify({
191
+ hookSpecificOutput: {
192
+ hookEventName: "PermissionRequest",
193
+ decision: { behavior: "allow" },
194
+ additionalContext: `[permission-safe-allow] allowed: ${reason}`,
195
+ },
196
+ }),
197
+ );
198
+ }
199
+
200
+ const input = await readStdin();
201
+ let payload;
202
+ try {
203
+ payload = JSON.parse(input || "{}");
204
+ } catch {
205
+ silentlyExit();
206
+ }
207
+
208
+ if (
209
+ payload?.hook_event_name !== "PermissionRequest" ||
210
+ payload?.tool_name !== "Bash"
211
+ ) {
212
+ silentlyExit();
213
+ }
214
+
215
+ if (
216
+ hasAskSuggestion(
217
+ payload.permission_suggestions ?? payload.permissionSuggestions,
218
+ )
219
+ ) {
220
+ silentlyExit();
221
+ }
222
+
223
+ const command = payload?.tool_input?.command ?? payload?.command;
224
+ if (typeof command !== "string") {
225
+ silentlyExit();
226
+ }
227
+
228
+ const reason = allowanceReason(command);
229
+ if (!reason) {
230
+ silentlyExit();
231
+ }
232
+
233
+ emitAllow(reason);