sanook-cli 0.5.1 → 0.5.5

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 (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -1,21 +1,103 @@
1
1
  import { homedir } from 'node:os';
2
- import { realpath, stat } from 'node:fs/promises';
3
- import { basename, dirname, resolve, join, sep } from 'node:path';
2
+ import { realpath, stat, lstat, readlink } from 'node:fs/promises';
3
+ import { dirname, resolve, join, sep } from 'node:path';
4
4
  import { getBrainPath } from '../memory.js';
5
5
  import { BRAND_ENV, envFlag } from '../brand.js';
6
6
  import { agentCwd } from '../agentContext.js';
7
7
  // Permission gate (M1): ก่อนมี interactive ask (M4) — hard-deny อันตราย, allow ที่เหลือ
8
8
  // คำสั่ง shell ที่ทำลายล้าง irreversible
9
9
  const DESTRUCTIVE_CMD = /(\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force|\bmkfs\b|\bdd\s+if=|:\(\)\s*\{|\bchmod\s+-R\s+777\b|>\s*\/dev\/sd|\bsudo\b|\bcrontab\b)/i;
10
- const PROTECTED_CMD_PATH = /(\$HOME|~)?\/?(\.ssh|\.aws|\.gnupg|\.sanook)(\/|\b)|(^|\s)(cat|less|more|sed|awk|tail|head)\s+[^|;&]*\.env(\.|\b)/i;
10
+ const PROTECTED_CMD_PATH = /(\$HOME|~)?\/?(\.ssh|\.aws|\.gnupg|\.sanook)(\/|\b)/i;
11
+ const ENV_READ_CMD = /(?:^|[\r\n;&|]\s*|\$\(\s*|[<>]\(\s*|`\s*)(?:(?:if|then|elif|while|until|do|time|command|builtin|exec)\s+)*(?:env\s+(?:-\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*)?(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(cat|less|more|sed|awk|tail|head|grep|rg)\b/gi;
12
+ const ENV_SOURCE_CMD = /(?:^|[\r\n;&|]\s*|\$\(\s*|[<>]\(\s*|`\s*)(?:(?:if|then|elif|while|until|do|time|command|builtin|exec)\s+)*(source\b|\.)/gi;
13
+ const ENV_WRITE_CMD = /(?:^|[\r\n;&|]\s*|\$\(\s*|[<>]\(\s*|`\s*)(?:(?:if|then|elif|while|until|do|time|command|builtin|exec)\s+)*(?:env\s+(?:-\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*)?(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(tee)\b/gi;
14
+ const NESTED_SHELL_CMD = /(?:^|[\r\n;&|]\s*|\$\(\s*|[<>]\(\s*|`\s*)(?:(?:if|then|elif|while|until|do|time|command|builtin|exec)\s+)*(?:env\s+(?:-\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*)?(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(sh|bash|zsh|dash|ksh|fish|csh|tcsh)\b/gi;
15
+ const ENV_CMD = /(?:^|[\r\n;&|]\s*|\$\(\s*|[<>]\(\s*|`\s*)(?:(?:if|then|elif|while|until|do|time|command|builtin|exec)\s+)*(env)\b/gi;
11
16
  const HOME = homedir();
12
17
  // ไฟล์ที่ห้ามเขียน (persistence backdoor): shell rc, git/npm config, ~/.sanook (token/mcp/hooks)
13
18
  const PROTECTED_EXACT = new Set(['.gitconfig', '.zshrc', '.bashrc', '.bash_profile', '.profile', '.zprofile', '.npmrc'].map((f) => join(HOME, f)));
14
19
  // โฟลเดอร์ที่ห้ามเขียนเข้าไป (credentials + sanook internal)
15
20
  const PROTECTED_DIRS = ['.ssh', '.aws', '.gnupg', '.sanook'].map((d) => join(HOME, d));
16
21
  const PROTECTED_SEGMENTS = new Set(['.git', 'node_modules', '.ssh', '.aws', '.gnupg', '.sanook']);
22
+ const ENV_OPTIONS_WITH_VALUE = new Set(['-C', '--chdir', '-S', '--split-string', '-u', '--unset']);
23
+ const GIT_OPTIONS_WITH_VALUE = new Set([
24
+ '-C',
25
+ '-c',
26
+ '--config-env',
27
+ '--exec-path',
28
+ '--git-dir',
29
+ '--namespace',
30
+ '--super-prefix',
31
+ '--work-tree',
32
+ ]);
33
+ const SHELL_OPTIONS_WITH_VALUE = new Set(['--init-file', '--rcfile']);
34
+ const SEARCH_COMMANDS_WITH_LITERAL_PATTERN = new Set(['grep', 'rg']);
35
+ const SEARCH_SHORT_OPTIONS_WITHOUT_VALUE = new Map([
36
+ ['grep', new Set(['E', 'F', 'G', 'P', 'R', 'r', 'I', 'i', 'v', 'w', 'x', 'c', 'l', 'L', 'n', 'H', 'h', 'o', 'q', 's', 'a', 'b', 'U', 'Z', 'z'])],
37
+ ['rg', new Set(['F', 'H', 'I', 'L', 'N', 'P', 'S', 'T', 'U', 'V', 'a', 'b', 'c', 'h', 'i', 'l', 'n', 'p', 'q', 's', 'u', 'v', 'w', 'x', 'z'])],
38
+ ]);
39
+ const SEARCH_OPTIONS_WITH_VALUE = new Map([
40
+ [
41
+ 'grep',
42
+ new Set([
43
+ '-A',
44
+ '-B',
45
+ '-C',
46
+ '-D',
47
+ '-d',
48
+ '-m',
49
+ '--after-context',
50
+ '--before-context',
51
+ '--binary-files',
52
+ '--context',
53
+ '--devices',
54
+ '--directories',
55
+ '--exclude',
56
+ '--exclude-dir',
57
+ '--include',
58
+ '--label',
59
+ '--max-count',
60
+ ]),
61
+ ],
62
+ [
63
+ 'rg',
64
+ new Set([
65
+ '-A',
66
+ '-B',
67
+ '-C',
68
+ '-E',
69
+ '-M',
70
+ '-g',
71
+ '-m',
72
+ '-t',
73
+ '-T',
74
+ '--after-context',
75
+ '--before-context',
76
+ '--context',
77
+ '--encoding',
78
+ '--engine',
79
+ '--glob',
80
+ '--iglob',
81
+ '--max-columns',
82
+ '--max-count',
83
+ '--max-depth',
84
+ '--path-separator',
85
+ '--pre',
86
+ '--pre-glob',
87
+ '--regex-size-limit',
88
+ '--sort',
89
+ '--sortr',
90
+ '--type',
91
+ '--type-not',
92
+ ]),
93
+ ],
94
+ ]);
95
+ const SHELL_COMMAND_PREFIXES = new Set(['if', 'then', 'elif', 'while', 'until', 'do', 'time', 'command', 'builtin', 'exec']);
17
96
  function hasRmRecursiveForce(cmd) {
97
+ const literalPatternRanges = literalSearchPatternRanges(cmd);
18
98
  for (const match of cmd.matchAll(/\brm\b([^;&|]*)/gi)) {
99
+ if (match.index !== undefined && inRanges(match.index, literalPatternRanges))
100
+ continue;
19
101
  const parts = match[1].split(/\s+/).filter(Boolean);
20
102
  const shortFlags = parts.filter((part) => /^-[^-]/.test(part)).join('');
21
103
  const recursive = /r/i.test(shortFlags) || parts.includes('--recursive') || parts.includes('--dir');
@@ -25,12 +107,881 @@ function hasRmRecursiveForce(cmd) {
25
107
  }
26
108
  return false;
27
109
  }
28
- export function checkBash(cmd) {
29
- if (hasRmRecursiveForce(cmd) || DESTRUCTIVE_CMD.test(cmd)) {
30
- return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
110
+ function hasDestructiveCommand(cmd) {
111
+ const literalPatternRanges = literalSearchPatternRanges(cmd);
112
+ const destructive = new RegExp(DESTRUCTIVE_CMD.source, 'gi');
113
+ for (const match of cmd.matchAll(destructive)) {
114
+ if (match.index !== undefined && inRanges(match.index, literalPatternRanges))
115
+ continue;
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+ function inRanges(index, ranges) {
121
+ return ranges.some(({ start, end }) => index >= start && index < end);
122
+ }
123
+ function isLiteralSingleQuotedToken(raw) {
124
+ const token = raw.trim();
125
+ return /^'(?:[^']*)'$/.test(token) || /^\$'(?:\\.|[^'])*'$/.test(token);
126
+ }
127
+ function literalSearchPatternRanges(cmd) {
128
+ const ranges = [];
129
+ const bySegment = new Map();
130
+ for (const entry of shellishTokenEntries(cmd)) {
131
+ const segment = bySegment.get(entry.segment);
132
+ if (segment)
133
+ segment.push(entry);
134
+ else
135
+ bySegment.set(entry.segment, [entry]);
136
+ }
137
+ for (const entries of bySegment.values()) {
138
+ const commandIndex = shellSegmentCommandIndex(entries);
139
+ if (commandIndex < 0)
140
+ continue;
141
+ const command = cleanShellToken(entries[commandIndex].raw).toLowerCase();
142
+ if (!SEARCH_COMMANDS_WITH_LITERAL_PATTERN.has(command))
143
+ continue;
144
+ let patternSourceSeen = false;
145
+ let optionsDone = false;
146
+ for (let j = commandIndex + 1; j < entries.length; j += 1) {
147
+ const clean = cleanShellToken(entries[j].raw);
148
+ if (!optionsDone) {
149
+ if (clean === '--') {
150
+ optionsDone = true;
151
+ continue;
152
+ }
153
+ if (searchPatternOptionConsumesNext(clean)) {
154
+ const value = entries[j + 1];
155
+ if (value && isLiteralSingleQuotedToken(value.raw))
156
+ ranges.push({ start: value.start, end: value.end });
157
+ patternSourceSeen = true;
158
+ j += 1;
159
+ continue;
160
+ }
161
+ const attachedPattern = attachedLiteralSearchPatternOption(command, entries[j].raw);
162
+ if (attachedPattern !== undefined) {
163
+ if (attachedPattern)
164
+ ranges.push({ start: entries[j].start, end: entries[j].end });
165
+ patternSourceSeen = true;
166
+ continue;
167
+ }
168
+ if (searchPatternFileOptionConsumesNext(clean)) {
169
+ patternSourceSeen = true;
170
+ j += 1;
171
+ continue;
172
+ }
173
+ if (attachedSearchPatternFileOption(clean)) {
174
+ patternSourceSeen = true;
175
+ continue;
176
+ }
177
+ if (searchOptionConsumesNext(command, clean)) {
178
+ j += 1;
179
+ continue;
180
+ }
181
+ if (clean.startsWith('-'))
182
+ continue;
183
+ }
184
+ if (patternSourceSeen)
185
+ break;
186
+ if (isLiteralSingleQuotedToken(entries[j].raw))
187
+ ranges.push({ start: entries[j].start, end: entries[j].end });
188
+ break;
189
+ }
190
+ }
191
+ return ranges;
192
+ }
193
+ function searchPatternOptionConsumesNext(clean) {
194
+ return clean === '-e' || clean === '--regexp';
195
+ }
196
+ function searchPatternFileOptionConsumesNext(clean) {
197
+ return clean === '-f' || clean === '--file';
198
+ }
199
+ function attachedLiteralSearchPatternOption(command, raw) {
200
+ const token = raw.trim();
201
+ if (token.startsWith('-e') && token.length > 2)
202
+ return isLiteralSingleQuotedToken(token.slice(2));
203
+ const longRegexp = token.match(/^--regexp=(.*)$/);
204
+ if (longRegexp)
205
+ return isLiteralSingleQuotedToken(longRegexp[1]);
206
+ return attachedShortLiteralSearchPatternOption(command, token);
207
+ }
208
+ function attachedShortLiteralSearchPatternOption(command, token) {
209
+ if (!token.startsWith('-') || token.startsWith('--'))
210
+ return undefined;
211
+ const patternOptionIndex = token.indexOf('e', 1);
212
+ if (patternOptionIndex <= 1 || patternOptionIndex === token.length - 1)
213
+ return undefined;
214
+ const prefix = token.slice(1, patternOptionIndex);
215
+ const optionsWithoutValue = SEARCH_SHORT_OPTIONS_WITHOUT_VALUE.get(command);
216
+ if (!optionsWithoutValue || [...prefix].some((option) => !optionsWithoutValue.has(option)))
217
+ return undefined;
218
+ return isLiteralSingleQuotedToken(token.slice(patternOptionIndex + 1));
219
+ }
220
+ function attachedSearchPatternFileOption(clean) {
221
+ return /^-f.+/.test(clean) || clean.startsWith('--file=');
222
+ }
223
+ function searchOptionConsumesNext(command, clean) {
224
+ return SEARCH_OPTIONS_WITH_VALUE.get(command)?.has(clean) ?? false;
225
+ }
226
+ function shellSegmentCommandIndex(entries) {
227
+ let envMode = false;
228
+ let envOptionsDone = false;
229
+ for (let i = 0; i < entries.length; i += 1) {
230
+ const clean = cleanShellToken(entries[i].raw);
231
+ if (!clean)
232
+ continue;
233
+ if (!envMode && shellEnvAssignment(clean))
234
+ continue;
235
+ if (!envMode && SHELL_COMMAND_PREFIXES.has(clean))
236
+ continue;
237
+ if (!envMode && clean === 'env') {
238
+ envMode = true;
239
+ envOptionsDone = false;
240
+ continue;
241
+ }
242
+ if (envMode) {
243
+ if (!envOptionsDone && clean === '--') {
244
+ envOptionsDone = true;
245
+ continue;
246
+ }
247
+ if (!envOptionsDone && envOptionConsumesNext(clean)) {
248
+ i += 1;
249
+ continue;
250
+ }
251
+ if (!envOptionsDone && clean.startsWith('-'))
252
+ continue;
253
+ if (shellEnvAssignment(clean))
254
+ continue;
255
+ return i;
256
+ }
257
+ return i;
258
+ }
259
+ return -1;
260
+ }
261
+ function hasDangerousGitOperation(cmd) {
262
+ const literalPatternRanges = literalSearchPatternRanges(cmd);
263
+ for (const match of cmd.matchAll(/\bgit\b/gi)) {
264
+ if (match.index !== undefined && inRanges(match.index, literalPatternRanges))
265
+ continue;
266
+ const args = shellishArgsAfter(cmd, match.index + match[0].length).map(cleanShellToken);
267
+ const envAssignments = shellEnvAssignmentsBeforeCommand(cmd, match.index);
268
+ const { aliases, unknownAliases } = gitAliasesFromEnvConfig(envAssignments);
269
+ for (let i = 0; i < args.length; i += 1) {
270
+ const arg = args[i];
271
+ if (arg === '--')
272
+ break;
273
+ const configValue = gitInlineConfigValue(arg, args[i + 1]);
274
+ if (configValue) {
275
+ const alias = gitAliasFromConfig(configValue.value);
276
+ if (alias)
277
+ aliases.set(alias.name, alias.command);
278
+ if (configValue.consumesNext)
279
+ i += 1;
280
+ continue;
281
+ }
282
+ const configEnvValue = gitConfigEnvValue(arg, args[i + 1]);
283
+ if (configEnvValue) {
284
+ const alias = gitAliasFromConfigEnv(configEnvValue.value, envAssignments);
285
+ if (alias) {
286
+ if (alias.command === undefined) {
287
+ aliases.delete(alias.name);
288
+ unknownAliases.add(alias.name);
289
+ }
290
+ else {
291
+ unknownAliases.delete(alias.name);
292
+ aliases.set(alias.name, alias.command);
293
+ }
294
+ }
295
+ if (configEnvValue.consumesNext)
296
+ i += 1;
297
+ continue;
298
+ }
299
+ if (arg === 'push') {
300
+ if (gitPushHasDeniedOperation(args.slice(i + 1)))
301
+ return true;
302
+ break;
303
+ }
304
+ if (arg === 'reset') {
305
+ if (gitResetHasHardFlag(args.slice(i + 1)))
306
+ return true;
307
+ break;
308
+ }
309
+ if (arg === 'clean') {
310
+ if (gitCleanHasForceFlag(args.slice(i + 1)))
311
+ return true;
312
+ break;
313
+ }
314
+ const aliasCommand = aliases.get(arg);
315
+ if (aliasCommand) {
316
+ if (gitAliasHasDeniedOperation(aliasCommand, args.slice(i + 1)))
317
+ return true;
318
+ break;
319
+ }
320
+ if (unknownAliases.has(arg))
321
+ return true;
322
+ if (gitOptionConsumesNext(arg)) {
323
+ i += 1;
324
+ continue;
325
+ }
326
+ if (arg.startsWith('-'))
327
+ continue;
328
+ break;
329
+ }
330
+ }
331
+ return false;
332
+ }
333
+ function gitInlineConfigValue(arg, next) {
334
+ if (arg === '-c')
335
+ return next ? { value: next, consumesNext: true } : undefined;
336
+ if (arg.startsWith('-c') && arg.length > 2)
337
+ return { value: arg.slice(2), consumesNext: false };
338
+ return undefined;
339
+ }
340
+ function gitConfigEnvValue(arg, next) {
341
+ if (arg === '--config-env')
342
+ return next ? { value: next, consumesNext: true } : undefined;
343
+ if (arg.startsWith('--config-env='))
344
+ return { value: arg.slice('--config-env='.length), consumesNext: false };
345
+ return undefined;
346
+ }
347
+ function gitAliasFromConfig(config) {
348
+ const match = config.match(/^alias\.([^=\s]+)=(.+)$/i);
349
+ return match ? { name: match[1], command: match[2] } : undefined;
350
+ }
351
+ function gitAliasFromConfigEnv(configEnv, envAssignments) {
352
+ const match = configEnv.match(/^alias\.([^=\s]+)=([A-Za-z_][A-Za-z0-9_]*)$/i);
353
+ if (!match)
354
+ return undefined;
355
+ return { name: match[1], command: envAssignments.get(match[2]) };
356
+ }
357
+ function gitAliasesFromEnvConfig(envAssignments) {
358
+ const aliases = new Map();
359
+ const unknownAliases = new Set();
360
+ const countValue = envAssignments.get('GIT_CONFIG_COUNT');
361
+ if (!countValue || !/^\d+$/.test(countValue))
362
+ return { aliases, unknownAliases };
363
+ const count = Number.parseInt(countValue, 10);
364
+ const entries = [];
365
+ for (const [name, key] of envAssignments) {
366
+ const match = name.match(/^GIT_CONFIG_KEY_(\d+)$/);
367
+ if (!match)
368
+ continue;
369
+ const index = Number.parseInt(match[1], 10);
370
+ if (index >= count)
371
+ continue;
372
+ entries.push({ index, key });
373
+ }
374
+ entries.sort((a, b) => a.index - b.index);
375
+ for (const { index, key } of entries) {
376
+ const alias = key.match(/^alias\.([^=\s]+)$/i);
377
+ if (!alias)
378
+ continue;
379
+ const command = envAssignments.get(`GIT_CONFIG_VALUE_${index}`);
380
+ if (command === undefined) {
381
+ aliases.delete(alias[1]);
382
+ unknownAliases.add(alias[1]);
383
+ }
384
+ else {
385
+ unknownAliases.delete(alias[1]);
386
+ aliases.set(alias[1], command);
387
+ }
388
+ }
389
+ return { aliases, unknownAliases };
390
+ }
391
+ function gitOptionConsumesNext(arg) {
392
+ return GIT_OPTIONS_WITH_VALUE.has(arg);
393
+ }
394
+ function gitAliasHasDeniedOperation(command, remainingArgs) {
395
+ const aliasArgs = shellishArgsAfter(command, 0).map(cleanShellToken);
396
+ if (aliasArgs.length === 0)
397
+ return false;
398
+ if (aliasArgs[0].startsWith('!')) {
399
+ const shellCommand = [aliasArgs[0].slice(1), ...aliasArgs.slice(1), ...remainingArgs].join(' ');
400
+ return hasRmRecursiveForce(shellCommand) || hasDangerousGitOperation(shellCommand) || hasDestructiveCommand(shellCommand);
401
+ }
402
+ return gitExpandedArgsHaveDeniedOperation([...aliasArgs, ...remainingArgs]);
403
+ }
404
+ function gitExpandedArgsHaveDeniedOperation(args) {
405
+ for (let i = 0; i < args.length; i += 1) {
406
+ const arg = args[i];
407
+ if (arg === '--')
408
+ break;
409
+ if (arg === 'push')
410
+ return gitPushHasDeniedOperation(args.slice(i + 1));
411
+ if (arg === 'reset')
412
+ return gitResetHasHardFlag(args.slice(i + 1));
413
+ if (arg === 'clean')
414
+ return gitCleanHasForceFlag(args.slice(i + 1));
415
+ if (gitOptionConsumesNext(arg)) {
416
+ i += 1;
417
+ continue;
418
+ }
419
+ if (arg.startsWith('-'))
420
+ continue;
421
+ break;
422
+ }
423
+ return false;
424
+ }
425
+ function gitResetHasHardFlag(args) {
426
+ for (const arg of args) {
427
+ if (arg === '--')
428
+ break;
429
+ if (arg === '--hard')
430
+ return true;
431
+ }
432
+ return false;
433
+ }
434
+ function gitCleanHasForceFlag(args) {
435
+ let force = false;
436
+ let dryRun = false;
437
+ for (const arg of args) {
438
+ if (arg === '--')
439
+ break;
440
+ if (arg === '--dry-run')
441
+ dryRun = true;
442
+ if (arg === '--force' || /^-[^-]*f/.test(arg))
443
+ force = true;
444
+ if (/^-[^-]*n/.test(arg))
445
+ dryRun = true;
446
+ }
447
+ return force && !dryRun;
448
+ }
449
+ function gitPushHasDeniedOperation(args) {
450
+ let optionsDone = false;
451
+ for (const arg of args) {
452
+ if (arg === '--') {
453
+ optionsDone = true;
454
+ continue;
455
+ }
456
+ if (/^\+.+/.test(arg))
457
+ return true;
458
+ if (/^\+?:[^:]/.test(arg))
459
+ return true;
460
+ if (optionsDone)
461
+ continue;
462
+ if (/^--force(?:$|[=-])/.test(arg) || /^-[^-]*f/.test(arg))
463
+ return true;
464
+ if (arg === '--delete' || /^-[^-]*d/.test(arg) || arg === '--mirror' || arg === '--prune')
465
+ return true;
466
+ }
467
+ return false;
468
+ }
469
+ function protectedEnvToken(token) {
470
+ const clean = cleanShellToken(token);
471
+ if (!clean || clean.startsWith('-'))
472
+ return false;
473
+ return hasProtectedEnvSegment(clean);
474
+ }
475
+ function decodeEscapedCodePoint(match, value, radix) {
476
+ const codePoint = Number.parseInt(value, radix);
477
+ return codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
478
+ }
479
+ function decodeAnsiCQuoteBody(body) {
480
+ return body
481
+ .replace(/\\x([0-9a-fA-F]{1,2})/g, (match, hex) => decodeEscapedCodePoint(match, hex, 16))
482
+ .replace(/\\u([0-9a-fA-F]{1,4})/g, (match, hex) => decodeEscapedCodePoint(match, hex, 16))
483
+ .replace(/\\U([0-9a-fA-F]{1,8})/g, (match, hex) => decodeEscapedCodePoint(match, hex, 16))
484
+ .replace(/\\([0-7]{1,3})/g, (match, octal) => decodeEscapedCodePoint(match, octal, 8))
485
+ .replace(/\\([abefnrtv\\'"])/g, (_match, escaped) => {
486
+ const mapped = {
487
+ a: '\x07',
488
+ b: '\b',
489
+ e: '\x1b',
490
+ f: '\f',
491
+ n: '\n',
492
+ r: '\r',
493
+ t: '\t',
494
+ v: '\v',
495
+ '\\': '\\',
496
+ "'": "'",
497
+ '"': '"',
498
+ };
499
+ return mapped[escaped] ?? escaped;
500
+ });
501
+ }
502
+ function expandAnsiCQuotes(token) {
503
+ return token.replace(/\$'((?:\\.|[^'])*)'/g, (_match, body) => decodeAnsiCQuoteBody(body));
504
+ }
505
+ function cleanShellToken(token) {
506
+ return expandAnsiCQuotes(token)
507
+ .trim()
508
+ .replace(/\$(['"])/g, '$1')
509
+ .replace(/^['"`]+|['"`]+$/g, '')
510
+ .replace(/['"`]/g, '')
511
+ .replace(/\\(.)/g, '$1')
512
+ .replace(/^\d*(?:<|>)+/, '')
513
+ .replace(/[),\]}]+$/g, '');
514
+ }
515
+ function cleanRedirectionToken(token) {
516
+ return token
517
+ .trim()
518
+ .replace(/^['"`]+|['"`]+$/g, '')
519
+ .replace(/\\(.)/g, '$1')
520
+ .replace(/[),\]}]+$/g, '');
521
+ }
522
+ function cleanCommandPayloadToken(token) {
523
+ return expandAnsiCQuotes(token)
524
+ .trim()
525
+ .replace(/[),\]}]+$/g, '')
526
+ .replace(/\$(['"])/g, '$1')
527
+ .replace(/^['"`]+|['"`]+$/g, '')
528
+ .replace(/\\(.)/g, '$1');
529
+ }
530
+ function readerOptionReadsProtectedEnv(command, token) {
531
+ if (!['awk', 'grep', 'rg', 'sed'].includes(command))
532
+ return false;
533
+ const clean = cleanShellToken(token);
534
+ const longFile = clean.match(/^--file=(.+)$/);
535
+ if (longFile)
536
+ return optionValueHasProtectedEnvSegment(longFile[1]);
537
+ const shortFile = clean.match(/^-f(.+)$/);
538
+ if (shortFile)
539
+ return optionValueHasProtectedEnvSegment(shortFile[1]);
540
+ if (command === 'grep') {
541
+ const include = clean.match(/^--include=(.+)$/);
542
+ return include ? optionValueHasProtectedEnvSegment(include[1]) : false;
543
+ }
544
+ if (command === 'rg') {
545
+ const glob = clean.match(/^--i?glob=(.+)$/);
546
+ if (glob)
547
+ return rgGlobReadsProtectedEnv(glob[1]);
548
+ const shortGlob = clean.match(/^-g(.+)$/);
549
+ return shortGlob ? rgGlobReadsProtectedEnv(shortGlob[1]) : false;
550
+ }
551
+ return false;
552
+ }
553
+ function optionValueHasProtectedEnvSegment(value) {
554
+ return hasProtectedEnvSegment(cleanShellToken(value));
555
+ }
556
+ function rgGlobReadsProtectedEnv(value) {
557
+ const clean = cleanShellToken(value);
558
+ return !clean.startsWith('!') && hasProtectedEnvSegment(clean);
559
+ }
560
+ function hasProtectedEnvSegment(path) {
561
+ return path.split(/[\\/]+/).some((part) => part.startsWith('.env') && part !== '.env.example');
562
+ }
563
+ function shellTokenExcludesProtectedEnvPath(token) {
564
+ const clean = cleanShellToken(token);
565
+ return /^(?:--exclude|--exclude-dir)=/.test(clean) && clean.split('=').slice(1).every(optionValueHasProtectedEnvSegment);
566
+ }
567
+ function mentionsProtectedEnvPath(cmd) {
568
+ return shellishTokens(cmd).some((token) => {
569
+ if (shellTokenExcludesProtectedEnvPath(token))
570
+ return false;
571
+ return cleanShellToken(token)
572
+ .split(/[=$(){}\[\],;<>|&]+/)
573
+ .some((part) => hasProtectedEnvSegment(cleanShellToken(part)));
574
+ });
575
+ }
576
+ function shellishArgsAfter(cmd, start) {
577
+ const args = [];
578
+ let token = '';
579
+ let quote = '';
580
+ let escaping = false;
581
+ for (let i = start; i < cmd.length; i += 1) {
582
+ const ch = cmd[i];
583
+ if (escaping) {
584
+ token += ch;
585
+ escaping = false;
586
+ continue;
587
+ }
588
+ if (ch === '\\' && quote !== "'") {
589
+ escaping = true;
590
+ token += ch;
591
+ continue;
592
+ }
593
+ if (quote) {
594
+ token += ch;
595
+ if (ch === quote)
596
+ quote = '';
597
+ continue;
598
+ }
599
+ if (ch === "'" || ch === '"') {
600
+ quote = ch;
601
+ token += ch;
602
+ continue;
603
+ }
604
+ if (ch === '`' || ch === ';' || ch === '&' || ch === '|')
605
+ break;
606
+ if (/\s/.test(ch)) {
607
+ if (token) {
608
+ args.push(token);
609
+ token = '';
610
+ }
611
+ continue;
612
+ }
613
+ token += ch;
614
+ }
615
+ if (token)
616
+ args.push(token);
617
+ return args;
618
+ }
619
+ function shellishTokens(cmd) {
620
+ const args = [];
621
+ let token = '';
622
+ let quote = '';
623
+ let escaping = false;
624
+ for (let i = 0; i < cmd.length; i += 1) {
625
+ const ch = cmd[i];
626
+ if (escaping) {
627
+ token += ch;
628
+ escaping = false;
629
+ continue;
630
+ }
631
+ if (ch === '\\' && quote !== "'") {
632
+ escaping = true;
633
+ token += ch;
634
+ continue;
635
+ }
636
+ if (quote) {
637
+ token += ch;
638
+ if (ch === quote)
639
+ quote = '';
640
+ continue;
641
+ }
642
+ if (ch === "'" || ch === '"') {
643
+ quote = ch;
644
+ token += ch;
645
+ continue;
646
+ }
647
+ if (/[\s;|&]/.test(ch)) {
648
+ if (token) {
649
+ args.push(token);
650
+ token = '';
651
+ }
652
+ continue;
653
+ }
654
+ token += ch;
655
+ }
656
+ if (token)
657
+ args.push(token);
658
+ return args;
659
+ }
660
+ function shellishTokenEntries(cmd) {
661
+ const entries = [];
662
+ let token = '';
663
+ let tokenStart = 0;
664
+ let segment = 0;
665
+ let quote = '';
666
+ let escaping = false;
667
+ const flush = (end) => {
668
+ if (!token)
669
+ return;
670
+ entries.push({ raw: token, start: tokenStart, end, segment });
671
+ token = '';
672
+ };
673
+ const append = (ch, index) => {
674
+ if (!token)
675
+ tokenStart = index;
676
+ token += ch;
677
+ };
678
+ for (let i = 0; i < cmd.length; i += 1) {
679
+ const ch = cmd[i];
680
+ if (escaping) {
681
+ append(ch, i);
682
+ escaping = false;
683
+ continue;
684
+ }
685
+ if (ch === '\\' && quote !== "'") {
686
+ escaping = true;
687
+ append(ch, i);
688
+ continue;
689
+ }
690
+ if (quote) {
691
+ append(ch, i);
692
+ if (ch === quote)
693
+ quote = '';
694
+ continue;
695
+ }
696
+ if (ch === "'" || ch === '"') {
697
+ quote = ch;
698
+ append(ch, i);
699
+ continue;
700
+ }
701
+ if (/[;|&\n\r]/.test(ch)) {
702
+ flush(i);
703
+ segment += 1;
704
+ continue;
705
+ }
706
+ if (/\s/.test(ch)) {
707
+ flush(i);
708
+ continue;
709
+ }
710
+ append(ch, i);
711
+ }
712
+ flush(cmd.length);
713
+ return entries;
714
+ }
715
+ function shellCommandSegmentStart(cmd, end) {
716
+ let start = 0;
717
+ let quote = '';
718
+ let escaping = false;
719
+ for (let i = 0; i < end; i += 1) {
720
+ const ch = cmd[i];
721
+ if (escaping) {
722
+ escaping = false;
723
+ continue;
724
+ }
725
+ if (ch === '\\' && quote !== "'") {
726
+ escaping = true;
727
+ continue;
728
+ }
729
+ if (quote) {
730
+ if (ch === quote)
731
+ quote = '';
732
+ continue;
733
+ }
734
+ if (ch === "'" || ch === '"') {
735
+ quote = ch;
736
+ continue;
737
+ }
738
+ if (ch === '`' || ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '\r')
739
+ start = i + 1;
740
+ }
741
+ return start;
742
+ }
743
+ function shellEnvAssignment(clean) {
744
+ const match = clean.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
745
+ return match ? { name: match[1], value: match[2] } : undefined;
746
+ }
747
+ function shellEnvAssignmentsBeforeCommand(cmd, commandStart) {
748
+ const segmentStart = shellCommandSegmentStart(cmd, commandStart);
749
+ const tokens = shellishTokens(cmd.slice(segmentStart, commandStart));
750
+ const assignments = new Map();
751
+ let envMode = false;
752
+ let envOptionsDone = false;
753
+ let commandSeen = false;
754
+ for (let i = 0; i < tokens.length; i += 1) {
755
+ const clean = cleanShellToken(tokens[i]);
756
+ const assignment = shellEnvAssignment(clean);
757
+ if (assignment && (!commandSeen || envMode)) {
758
+ assignments.set(assignment.name, assignment.value);
759
+ continue;
760
+ }
761
+ if (!commandSeen && clean === 'env') {
762
+ envMode = true;
763
+ envOptionsDone = false;
764
+ commandSeen = true;
765
+ continue;
766
+ }
767
+ if (envMode) {
768
+ if (!envOptionsDone && clean === '--') {
769
+ envOptionsDone = true;
770
+ continue;
771
+ }
772
+ if (!envOptionsDone && envOptionConsumesNext(clean)) {
773
+ i += 1;
774
+ continue;
775
+ }
776
+ if (!envOptionsDone && clean.startsWith('-'))
777
+ continue;
778
+ }
779
+ commandSeen = true;
780
+ envMode = false;
781
+ }
782
+ return assignments;
783
+ }
784
+ function inlineRedirectionTarget(token) {
785
+ const clean = cleanRedirectionToken(token);
786
+ const match = clean.match(/^(?:\d*|&)(?:<>|>>|>|<)(?![<(])(.+)$/);
787
+ return match?.[1];
788
+ }
789
+ function standaloneRedirection(token) {
790
+ return /^(?:\d*|&)(?:<>|>>|>|<)$/.test(cleanRedirectionToken(token));
791
+ }
792
+ function commandSubstitutionTouchesProtectedEnv(cmd) {
793
+ for (const match of cmd.matchAll(/\$\(\s*(?:\d*|&)(?:<>|>>|>|<)\s*([^\s)]+)/g)) {
794
+ if (optionValueHasProtectedEnvSegment(match[1]))
795
+ return true;
796
+ }
797
+ return false;
798
+ }
799
+ function redirectionTouchesProtectedEnv(cmd) {
800
+ if (commandSubstitutionTouchesProtectedEnv(cmd))
801
+ return true;
802
+ const tokens = shellishTokens(cmd);
803
+ for (let i = 0; i < tokens.length; i += 1) {
804
+ const inlineTarget = inlineRedirectionTarget(tokens[i]);
805
+ if (inlineTarget && optionValueHasProtectedEnvSegment(inlineTarget))
806
+ return true;
807
+ if (standaloneRedirection(tokens[i]) && protectedEnvToken(tokens[i + 1] ?? ''))
808
+ return true;
31
809
  }
32
- if (PROTECTED_CMD_PATH.test(cmd)) {
33
- return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
810
+ return false;
811
+ }
812
+ function writerArgsTouchProtectedEnv(args) {
813
+ let optionsDone = false;
814
+ for (const arg of args) {
815
+ const clean = cleanShellToken(arg);
816
+ if (!optionsDone) {
817
+ if (clean === '--') {
818
+ optionsDone = true;
819
+ continue;
820
+ }
821
+ if (clean.startsWith('-'))
822
+ continue;
823
+ }
824
+ if (protectedEnvToken(arg))
825
+ return true;
826
+ }
827
+ return false;
828
+ }
829
+ function shellOptionConsumesNext(clean) {
830
+ return SHELL_OPTIONS_WITH_VALUE.has(clean) || /^[-+]o$/i.test(clean);
831
+ }
832
+ function envOptionConsumesNext(clean) {
833
+ return ENV_OPTIONS_WITH_VALUE.has(clean);
834
+ }
835
+ function inlineEnvSplitStringPayload(clean) {
836
+ return clean.match(/^-S(.+)$/)?.[1] ?? clean.match(/^--split-string=(.+)$/)?.[1];
837
+ }
838
+ function envWrappedCommandDenied(cmd, depth) {
839
+ if (depth >= 4)
840
+ return false;
841
+ for (const match of cmd.matchAll(ENV_CMD)) {
842
+ const args = shellishArgsAfter(cmd, match.index + match[0].length);
843
+ let commandIndex = -1;
844
+ for (let i = 0; i < args.length; i += 1) {
845
+ const clean = cleanShellToken(args[i]);
846
+ if (clean === '--') {
847
+ commandIndex = i + 1;
848
+ break;
849
+ }
850
+ const inlineSplitPayload = inlineEnvSplitStringPayload(clean);
851
+ if (inlineSplitPayload !== undefined) {
852
+ const payload = [inlineSplitPayload, ...args.slice(i + 1)].join(' ');
853
+ if (payload && !checkBash(cleanCommandPayloadToken(payload), depth + 1).ok)
854
+ return true;
855
+ break;
856
+ }
857
+ if (clean === '-S' || clean === '--split-string') {
858
+ const payload = args.slice(i + 1).join(' ');
859
+ if (payload && !checkBash(cleanCommandPayloadToken(payload), depth + 1).ok)
860
+ return true;
861
+ break;
862
+ }
863
+ if (envOptionConsumesNext(clean)) {
864
+ i += 1;
865
+ continue;
866
+ }
867
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(clean))
868
+ continue;
869
+ if (clean.startsWith('-'))
870
+ continue;
871
+ commandIndex = i;
872
+ break;
873
+ }
874
+ if (commandIndex >= 0) {
875
+ const payload = args.slice(commandIndex).join(' ');
876
+ if (payload && !checkBash(payload, depth + 1).ok)
877
+ return true;
878
+ }
879
+ }
880
+ return false;
881
+ }
882
+ function nestedShellCommandDenied(cmd, depth) {
883
+ if (depth >= 4)
884
+ return false;
885
+ for (const match of cmd.matchAll(NESTED_SHELL_CMD)) {
886
+ const args = shellishArgsAfter(cmd, match.index + match[0].length);
887
+ for (let i = 0; i < args.length; i += 1) {
888
+ const clean = cleanShellToken(args[i]);
889
+ if (clean === '--')
890
+ break;
891
+ if (shellOptionConsumesNext(clean)) {
892
+ i += 1;
893
+ continue;
894
+ }
895
+ if (clean.startsWith('--'))
896
+ continue;
897
+ if (!clean.startsWith('-'))
898
+ break;
899
+ const inlineCommand = clean.match(/^-[^-]*?c(.+)$/);
900
+ if (inlineCommand || /^-[^-]*?c$/.test(clean)) {
901
+ const payload = inlineCommand?.[1] || args[i + 1];
902
+ if (payload && !checkBash(cleanCommandPayloadToken(payload), depth + 1).ok)
903
+ return true;
904
+ break;
905
+ }
906
+ }
907
+ }
908
+ return false;
909
+ }
910
+ function readsProtectedEnvFile(cmd) {
911
+ for (const match of cmd.matchAll(ENV_READ_CMD)) {
912
+ const args = shellishArgsAfter(cmd, match.index + match[0].length);
913
+ const command = match[1].toLowerCase();
914
+ if (args.some((arg) => protectedEnvToken(arg) || readerOptionReadsProtectedEnv(command, arg)))
915
+ return true;
916
+ }
917
+ for (const match of cmd.matchAll(ENV_SOURCE_CMD)) {
918
+ const args = shellishArgsAfter(cmd, match.index + match[0].length);
919
+ if (protectedEnvToken(args[0] ?? ''))
920
+ return true;
921
+ }
922
+ for (const match of cmd.matchAll(ENV_WRITE_CMD)) {
923
+ const args = shellishArgsAfter(cmd, match.index + match[0].length);
924
+ if (writerArgsTouchProtectedEnv(args))
925
+ return true;
926
+ }
927
+ if (redirectionTouchesProtectedEnv(cmd))
928
+ return true;
929
+ return false;
930
+ }
931
+ // Rebuild `cmd` with each segment's COMMAND token de-quoted/de-escaped (e.g. `r\m`, `'rm'`, `g\it`
932
+ // → `rm`, `git`), leaving every other token byte-identical so the literal-search-pattern exemption
933
+ // (e.g. `grep 'rm -rf'`) still holds. Lets the matchers below see the real command name.
934
+ function deobfuscateCommandTokens(cmd) {
935
+ const entries = shellishTokenEntries(cmd);
936
+ if (entries.length === 0)
937
+ return cmd;
938
+ const bySegment = new Map();
939
+ for (const entry of entries) {
940
+ const seg = bySegment.get(entry.segment);
941
+ if (seg)
942
+ seg.push(entry);
943
+ else
944
+ bySegment.set(entry.segment, [entry]);
945
+ }
946
+ const replacements = [];
947
+ for (const segEntries of bySegment.values()) {
948
+ const ci = shellSegmentCommandIndex(segEntries);
949
+ if (ci < 0)
950
+ continue;
951
+ const entry = segEntries[ci];
952
+ const cleaned = cleanShellToken(entry.raw);
953
+ if (cleaned && cleaned !== entry.raw)
954
+ replacements.push({ start: entry.start, end: entry.end, text: cleaned });
955
+ }
956
+ if (replacements.length === 0)
957
+ return cmd;
958
+ replacements.sort((a, b) => b.start - a.start); // right-to-left so offsets stay valid
959
+ let out = cmd;
960
+ for (const r of replacements)
961
+ out = out.slice(0, r.start) + r.text + out.slice(r.end);
962
+ return out;
963
+ }
964
+ export function checkBash(cmd, depth = 0) {
965
+ // Also test a de-obfuscated copy so a backslash/quote-mangled command name can't slip past.
966
+ const deob = deobfuscateCommandTokens(cmd);
967
+ const variants = deob === cmd ? [cmd] : [cmd, deob];
968
+ for (const c of variants) {
969
+ if (hasRmRecursiveForce(c) || hasDangerousGitOperation(c) || hasDestructiveCommand(c)) {
970
+ return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
971
+ }
972
+ }
973
+ for (const c of variants) {
974
+ if (PROTECTED_CMD_PATH.test(c) || mentionsProtectedEnvPath(c) || readsProtectedEnvFile(c)) {
975
+ return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
976
+ }
977
+ }
978
+ for (const c of variants) {
979
+ if (nestedShellCommandDenied(c, depth)) {
980
+ return { ok: false, reason: `คำสั่ง nested shell ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
981
+ }
982
+ if (envWrappedCommandDenied(c, depth)) {
983
+ return { ok: false, reason: `คำสั่ง env wrapper ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
984
+ }
34
985
  }
35
986
  return { ok: true };
36
987
  }
@@ -42,6 +993,21 @@ async function canonicalExisting(path) {
42
993
  return resolve(path);
43
994
  }
44
995
  }
996
+ // If `path`'s leaf is a symlink, return where it actually points (resolved against its
997
+ // dir). A DANGLING leaf symlink (target missing, parent present) otherwise slips past
998
+ // existingAncestor (which stat()s through the link, fails, and falls back to the parent),
999
+ // letting a write follow the link outside the workspace.
1000
+ async function symlinkLeafTarget(path) {
1001
+ try {
1002
+ const st = await lstat(path);
1003
+ if (!st.isSymbolicLink())
1004
+ return null;
1005
+ return resolve(dirname(path), await readlink(path));
1006
+ }
1007
+ catch {
1008
+ return null;
1009
+ }
1010
+ }
45
1011
  async function existingAncestor(path) {
46
1012
  let dir = resolve(path);
47
1013
  for (;;) {
@@ -75,8 +1041,7 @@ function protectedSegment(abs) {
75
1041
  const parts = abs.split(/[\\/]+/);
76
1042
  if (parts.some((p) => PROTECTED_SEGMENTS.has(p)))
77
1043
  return true;
78
- const base = basename(abs);
79
- return base.startsWith('.env') && base !== '.env.example';
1044
+ return hasProtectedEnvSegment(abs);
80
1045
  }
81
1046
  async function checkPathScope(path, intent) {
82
1047
  const abs = intent === 'write' ? await existingAncestor(path) : await canonicalExisting(path);
@@ -100,12 +1065,27 @@ export async function checkReadPath(path) {
100
1065
  /** กันเขียนทับ secrets/shell-rc/.sanook + กันเขียนนอก workspace/brain */
101
1066
  export async function checkWritePath(path) {
102
1067
  const abs = resolve(path);
103
- const inProtectedDir = PROTECTED_DIRS.some((d) => abs === d || abs.startsWith(d + sep));
104
- if (PROTECTED_EXACT.has(abs) || inProtectedDir || protectedSegment(abs)) {
1068
+ const canonical = await existingAncestor(path);
1069
+ // Where a symlinked leaf would actually write (closes the dangling-leaf-symlink escape).
1070
+ const linkTarget = await symlinkLeafTarget(path);
1071
+ const targetReal = linkTarget ? await existingAncestor(linkTarget) : null;
1072
+ const candidates = [abs, canonical, ...(linkTarget ? [linkTarget, targetReal] : [])];
1073
+ const inProtectedDir = (p) => PROTECTED_DIRS.some((d) => p === d || p.startsWith(d + sep));
1074
+ if (candidates.some((p) => PROTECTED_EXACT.has(p) || inProtectedDir(p) || protectedSegment(p))) {
105
1075
  return {
106
1076
  ok: false,
107
1077
  reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / shell-rc / .sanook / .git / .env / node_modules)`,
108
1078
  };
109
1079
  }
1080
+ // A symlinked leaf must resolve INSIDE the workspace/brain, not just sit there as a link.
1081
+ if (linkTarget) {
1082
+ const roots = await allowedRoots();
1083
+ if (!roots.some((root) => inside(targetReal, root))) {
1084
+ return {
1085
+ ok: false,
1086
+ reason: `symlink ชี้ออกนอก workspace/brain ที่อนุญาต: "${path}" → "${linkTarget}" (ตั้ง ${BRAND_ENV.allowOutsideWorkspace}=1 เพื่อ opt-in)`,
1087
+ };
1088
+ }
1089
+ }
110
1090
  return checkPathScope(path, 'write');
111
1091
  }