sanook-cli 0.5.2 → 0.5.7

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 (127) hide show
  1. package/CHANGELOG.md +112 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +637 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-link.js +73 -0
  10. package/dist/brain-metrics.js +277 -0
  11. package/dist/brain-new.js +402 -0
  12. package/dist/brain-pack.js +210 -0
  13. package/dist/brain-repair.js +280 -0
  14. package/dist/brain.js +3 -0
  15. package/dist/brand.js +4 -0
  16. package/dist/cli-args.js +47 -9
  17. package/dist/cli-option-values.js +1 -1
  18. package/dist/clipboard.js +65 -0
  19. package/dist/commands.js +98 -15
  20. package/dist/config.js +66 -34
  21. package/dist/context-pack.js +145 -0
  22. package/dist/cost.js +20 -0
  23. package/dist/dashboard/api-helpers.js +87 -0
  24. package/dist/dashboard/server.js +179 -0
  25. package/dist/dashboard/static/app.js +277 -0
  26. package/dist/dashboard/static/index.html +39 -0
  27. package/dist/dashboard/static/styles.css +85 -0
  28. package/dist/diff.js +10 -2
  29. package/dist/gateway/auth.js +14 -3
  30. package/dist/gateway/deliver.js +45 -3
  31. package/dist/gateway/doctor.js +456 -0
  32. package/dist/gateway/email.js +30 -1
  33. package/dist/gateway/ledger.js +20 -1
  34. package/dist/gateway/session.js +34 -11
  35. package/dist/hotkeys.js +21 -0
  36. package/dist/i18n/en.js +98 -0
  37. package/dist/i18n/index.js +19 -0
  38. package/dist/i18n/th.js +98 -0
  39. package/dist/i18n/types.js +1 -0
  40. package/dist/insights-args.js +24 -4
  41. package/dist/knowledge.js +55 -29
  42. package/dist/loop.js +65 -9
  43. package/dist/mcp-hub.js +33 -0
  44. package/dist/mcp-registry.js +153 -9
  45. package/dist/mcp-risk.js +71 -0
  46. package/dist/mcp.js +77 -5
  47. package/dist/memory-log.js +90 -0
  48. package/dist/memory-store.js +37 -1
  49. package/dist/memory.js +51 -7
  50. package/dist/model-picker.js +58 -0
  51. package/dist/orchestrate.js +7 -5
  52. package/dist/plan-handoff.js +17 -0
  53. package/dist/polyglot.js +162 -0
  54. package/dist/process-runner.js +96 -0
  55. package/dist/project-init.js +91 -0
  56. package/dist/project-registry.js +143 -0
  57. package/dist/project-scaffold.js +124 -0
  58. package/dist/prompt-size.js +155 -0
  59. package/dist/providers/codex-login.js +138 -0
  60. package/dist/providers/codex.js +20 -8
  61. package/dist/providers/keys.js +21 -0
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +11 -1
  64. package/dist/search/cli.js +9 -1
  65. package/dist/search/embedding-config.js +22 -0
  66. package/dist/search/engine.js +2 -13
  67. package/dist/search/indexer.js +10 -10
  68. package/dist/session-brain.js +103 -0
  69. package/dist/session-distill.js +84 -0
  70. package/dist/session.js +1 -11
  71. package/dist/skill-install.js +24 -1
  72. package/dist/skills.js +33 -0
  73. package/dist/slash-completion.js +155 -0
  74. package/dist/support-dump.js +31 -0
  75. package/dist/tool-catalog.js +59 -0
  76. package/dist/tools/index.js +5 -0
  77. package/dist/tools/permission.js +82 -16
  78. package/dist/tools/polyglot.js +126 -0
  79. package/dist/tools/sandbox.js +38 -13
  80. package/dist/tools/search.js +9 -2
  81. package/dist/tools/task.js +22 -2
  82. package/dist/tools/timeout.js +7 -5
  83. package/dist/tools/web-fetch-tool.js +33 -0
  84. package/dist/turn-retrieval.js +83 -0
  85. package/dist/ui/app.js +874 -35
  86. package/dist/ui/banner.js +78 -4
  87. package/dist/ui/markdown.js +122 -0
  88. package/dist/ui/overlay.js +496 -0
  89. package/dist/ui/queue.js +23 -0
  90. package/dist/ui/render.js +30 -2
  91. package/dist/ui/session-panel.js +115 -0
  92. package/dist/ui/setup-providers.js +40 -0
  93. package/dist/ui/setup.js +163 -50
  94. package/dist/ui/status.js +142 -0
  95. package/dist/ui/thinking-panel.js +36 -0
  96. package/dist/ui/tool-trail.js +97 -0
  97. package/dist/ui/transcript.js +26 -0
  98. package/dist/ui/useBusyElapsed.js +19 -0
  99. package/dist/ui/useEditor.js +144 -5
  100. package/dist/ui/useGitBranch.js +57 -0
  101. package/dist/update.js +32 -6
  102. package/dist/usage-cli.js +160 -0
  103. package/dist/usage-ledger.js +169 -0
  104. package/dist/web-fetch.js +637 -0
  105. package/dist/web-surface.js +190 -0
  106. package/package.json +4 -3
  107. package/scripts/postinstall.mjs +4 -4
  108. package/second-brain/Projects/_Index.md +17 -4
  109. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  110. package/second-brain/Projects/sanook-cli/context.md +35 -0
  111. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  112. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  113. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  114. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  115. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  116. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  117. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  118. package/second-brain/Research/_Index.md +2 -0
  119. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  120. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  121. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  122. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  123. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  124. package/second-brain/Templates/project-workspace/context.md +28 -0
  125. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  126. package/second-brain/Templates/project-workspace/overview.md +39 -0
  127. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -0,0 +1,155 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
4
+ const PATH_TOKEN_RE = /((?:\.{1,2}\/|~\/?|\/|@|[^"'`\s]+\/)[^"'`\s]*)$/;
5
+ const MAX_PATH_COMPLETIONS = 40;
6
+ const DETAIL_SECTIONS = [
7
+ { text: 'thinking ', display: 'thinking', meta: 'details section' },
8
+ { text: 'tools ', display: 'tools', meta: 'details section' },
9
+ ];
10
+ const DETAIL_MODES = [
11
+ { text: 'hidden', display: 'hidden', meta: 'details mode' },
12
+ { text: 'collapsed', display: 'collapsed', meta: 'details mode' },
13
+ { text: 'expanded', display: 'expanded', meta: 'details mode' },
14
+ ];
15
+ const TRAIL_MODES = [
16
+ { text: 'compact', display: 'compact', meta: 'trail mode' },
17
+ { text: 'expanded', display: 'expanded', meta: 'trail mode' },
18
+ ];
19
+ const COPY_TARGETS = [{ text: 'last', display: 'last', meta: 'copy target' }];
20
+ const BUILTIN_SLASH_COMPLETIONS = [
21
+ { text: '/help', display: '/help', meta: 'command list + pager' },
22
+ { text: '/hotkeys', display: '/hotkeys', meta: 'keyboard shortcuts' },
23
+ { text: '/details', display: '/details', meta: 'thinking/tool trail visibility' },
24
+ { text: '/model', display: '/model', meta: 'pick provider then model' },
25
+ { text: '/setup', display: '/setup', meta: 'setup wizard sections' },
26
+ { text: '/dashboard', display: '/dashboard', meta: 'open web dashboard' },
27
+ { text: '/mcp', display: '/mcp', meta: 'browse MCP servers' },
28
+ { text: '/skills', display: '/skills', meta: 'browse loaded skills' },
29
+ { text: '/sessions', display: '/sessions', meta: 'resume saved sessions' },
30
+ { text: '/tasks', display: '/tasks', meta: 'background task_spawn jobs' },
31
+ { text: '/status', display: '/status', meta: 'session/model status' },
32
+ { text: '/platforms', display: '/platforms', meta: 'providers + gateways' },
33
+ { text: '/trail', display: '/trail', meta: 'toggle tool trail detail' },
34
+ { text: '/tools', display: '/tools', meta: 'agent tools' },
35
+ { text: '/diff', display: '/diff', meta: 'git diff stat' },
36
+ { text: '/copy', display: '/copy', meta: 'copy latest assistant response' },
37
+ { text: '/retry', display: '/retry', meta: 'rerun last prompt' },
38
+ { text: '/stop', display: '/stop', meta: 'stop current turn' },
39
+ { text: '/undo', display: '/undo', meta: 'stash recent file edits' },
40
+ { text: '/rewind', display: '/rewind', meta: 'restore previous turn' },
41
+ { text: '/cost', display: '/cost', meta: 'last usage/cost' },
42
+ { text: '/usage', display: '/usage', meta: 'last usage/cost' },
43
+ { text: '/insights', display: '/insights', meta: 'local usage insights' },
44
+ { text: '/personality', display: '/personality', meta: 'set response style' },
45
+ { text: '/compact', display: '/compact', meta: 'compress context' },
46
+ { text: '/compress', display: '/compress', meta: 'compress context' },
47
+ { text: '/new', display: '/new', meta: 'new conversation' },
48
+ { text: '/reset', display: '/reset', meta: 'new conversation' },
49
+ { text: '/clear', display: '/clear', meta: 'clear conversation' },
50
+ { text: '/quit', display: '/quit', meta: 'exit REPL' },
51
+ ];
52
+ export function slashCompletionItems(input) {
53
+ if (!/^\/[a-z0-9-?]*$/i.test(input))
54
+ return [];
55
+ const query = input.slice(1).toLowerCase();
56
+ return BUILTIN_SLASH_COMPLETIONS.filter((item) => item.text.slice(1).startsWith(query));
57
+ }
58
+ export function completionForInput(input, cwd = process.cwd()) {
59
+ const slash = slashCompletionItems(input);
60
+ if (slash.length)
61
+ return { items: slash, replaceFrom: 0 };
62
+ const slashArgs = slashArgumentCompletion(input);
63
+ if (slashArgs.items.length)
64
+ return slashArgs;
65
+ const path = pathCompletion(input, cwd);
66
+ if (path.items.length)
67
+ return path;
68
+ return { items: [], replaceFrom: 0 };
69
+ }
70
+ function slashArgumentCompletion(input) {
71
+ const commandMatch = /^\/([a-z0-9-?]+)\s+/i.exec(input);
72
+ if (!commandMatch)
73
+ return { items: [], replaceFrom: 0 };
74
+ const command = commandMatch[1].toLowerCase();
75
+ const rawArgs = input.slice(commandMatch[0].length);
76
+ const hasTrailingSpace = /\s$/.test(input);
77
+ const args = rawArgs.trim() ? rawArgs.trim().split(/\s+/) : [];
78
+ const activeIndex = hasTrailingSpace ? args.length : Math.max(0, args.length - 1);
79
+ const prefix = hasTrailingSpace ? '' : (args.at(-1) ?? '');
80
+ const replaceFrom = input.length - prefix.length;
81
+ if (command === 'trail' && activeIndex === 0) {
82
+ return { items: filterArgumentItems(TRAIL_MODES, prefix), replaceFrom };
83
+ }
84
+ if (command === 'copy' && activeIndex === 0) {
85
+ return { items: filterArgumentItems(COPY_TARGETS, prefix), replaceFrom };
86
+ }
87
+ if (command === 'details') {
88
+ if (activeIndex === 0)
89
+ return { items: filterArgumentItems(DETAIL_SECTIONS, prefix), replaceFrom };
90
+ const section = args[0]?.toLowerCase();
91
+ if (activeIndex === 1 && (section === 'thinking' || section === 'tools')) {
92
+ return { items: filterArgumentItems(DETAIL_MODES, prefix), replaceFrom };
93
+ }
94
+ }
95
+ return { items: [], replaceFrom: 0 };
96
+ }
97
+ function filterArgumentItems(items, prefix) {
98
+ const query = prefix.toLowerCase();
99
+ return items.filter((item) => item.text.toLowerCase().startsWith(query));
100
+ }
101
+ function pathCompletion(input, cwd) {
102
+ const match = PATH_TOKEN_RE.exec(input);
103
+ if (!match)
104
+ return { items: [], replaceFrom: 0 };
105
+ const token = match[1];
106
+ const replaceFrom = input.length - token.length;
107
+ const mention = token.startsWith('@');
108
+ const rawToken = mention ? token.slice(1) : token;
109
+ const raw = rawToken === '~' ? '~/' : rawToken;
110
+ const hasTrailingSlash = raw.endsWith('/');
111
+ const rawDir = hasTrailingSlash ? raw : dirname(raw);
112
+ const prefix = hasTrailingSlash ? '' : basename(raw);
113
+ const dirPart = rawDir === '.' ? '' : rawDir;
114
+ const absoluteDir = resolveInputPath(dirPart || '.', cwd);
115
+ if (!existsSync(absoluteDir))
116
+ return { items: [], replaceFrom };
117
+ let entries;
118
+ try {
119
+ entries = readdirSync(absoluteDir, { withFileTypes: true });
120
+ }
121
+ catch {
122
+ return { items: [], replaceFrom };
123
+ }
124
+ const head = `${mention ? '@' : ''}${dirPart ? `${dirPart.replace(/\/?$/, '/')}` : ''}`;
125
+ const items = entries
126
+ .filter((entry) => !entry.name.startsWith('.') && entry.name.startsWith(prefix))
127
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
128
+ .slice(0, MAX_PATH_COMPLETIONS)
129
+ .map((entry) => {
130
+ const suffix = entry.isDirectory() ? '/' : '';
131
+ const text = `${head}${entry.name}${suffix}`;
132
+ return { display: text, meta: entry.isDirectory() ? 'dir' : 'file', text };
133
+ });
134
+ return { items, replaceFrom };
135
+ }
136
+ function resolveInputPath(input, cwd) {
137
+ if (input === '~')
138
+ return homedir();
139
+ if (input.startsWith('~/'))
140
+ return resolve(homedir(), input.slice(2));
141
+ if (isAbsolute(input))
142
+ return input;
143
+ return resolve(cwd, input);
144
+ }
145
+ export function clampCompletionIndex(index, count) {
146
+ if (count <= 0)
147
+ return 0;
148
+ return ((index % count) + count) % count;
149
+ }
150
+ export function completionReplaceValue(input, item, replaceFrom = 0) {
151
+ if (!item)
152
+ return null;
153
+ const next = `${input.slice(0, replaceFrom)}${item.text}`;
154
+ return next === input ? null : next;
155
+ }
@@ -89,6 +89,12 @@ export async function buildSupportDump(options = {}) {
89
89
  const currentSessions = await listSessions({ cwd });
90
90
  const allSessions = await listSessions({ cwd: null });
91
91
  const { tools } = await import('./tools/index.js');
92
+ const polyglot = await import('./polyglot.js')
93
+ .then((m) => m.inspectPolyglotRuntimes({ cwd }))
94
+ .catch((e) => e);
95
+ const webSurface = await import('./web-surface.js')
96
+ .then((m) => m.inspectWebSurface({ cwd, loadConfig: async () => mcp }))
97
+ .catch((e) => e);
92
98
  lines.push(`${BRAND.productName} support dump`);
93
99
  lines.push(`version: ${options.version ?? '(dev)'}`);
94
100
  if (options.packageName)
@@ -123,6 +129,7 @@ export async function buildSupportDump(options = {}) {
123
129
  lines.push(` brainPath: ${valueOrUnset(loadedConfig.brainPath)}`);
124
130
  lines.push(` cacheTtl: ${loadedConfig.cacheTtl}`);
125
131
  lines.push(` compaction: ${loadedConfig.compaction}`);
132
+ lines.push(` contextCompression: ${loadedConfig.contextCompression}`);
126
133
  lines.push(` thinking: ${valueOrUnset(loadedConfig.thinking)}`);
127
134
  lines.push(` summaryModel: ${valueOrUnset(loadedConfig.summaryModel)}`);
128
135
  lines.push(` embeddingModel: ${valueOrUnset(loadedConfig.embeddingModel)}`);
@@ -161,6 +168,20 @@ export async function buildSupportDump(options = {}) {
161
168
  for (const log of mcpLogs)
162
169
  lines.push(` note: ${redactKey(log)}`);
163
170
  lines.push('');
171
+ lines.push('web search:');
172
+ if (webSurface instanceof Error) {
173
+ lines.push(` load error: ${redactKey(webSurface.message)}`);
174
+ }
175
+ else {
176
+ lines.push(` local search internet: ${yesNo(webSurface.localSearch.internet)}`);
177
+ lines.push(` web candidates: ${webSurface.webCandidates.length}`);
178
+ for (const candidate of webSurface.webCandidates.slice(0, 10)) {
179
+ lines.push(` ${candidate.name}: ${candidate.transport} ${candidate.reasons.join(' · ')}`);
180
+ }
181
+ if (webSurface.webCandidates.length > 10)
182
+ lines.push(` ... ${webSurface.webCandidates.length - 10} more`);
183
+ }
184
+ lines.push('');
164
185
  lines.push('inventory:');
165
186
  lines.push(` built-in tools: ${Object.keys(tools).length}`);
166
187
  lines.push(` skills: ${skills.length}`);
@@ -170,6 +191,16 @@ export async function buildSupportDump(options = {}) {
170
191
  if (latest)
171
192
  lines.push(` latest session: ${latest.id} updated ${latest.updated}`);
172
193
  lines.push('');
194
+ lines.push('runtimes:');
195
+ if (polyglot instanceof Error) {
196
+ lines.push(` load error: ${redactKey(polyglot.message)}`);
197
+ }
198
+ else {
199
+ for (const runtime of polyglot.runtimes) {
200
+ lines.push(` ${runtime.id}: ${runtime.status}${runtime.version ? ` (${runtime.version})` : ''}`);
201
+ }
202
+ }
203
+ lines.push('');
173
204
  lines.push(options.showKeys ? 'secrets: redacted prefixes/suffixes shown; raw keys are never printed' : 'secrets: hidden; use --show-keys to show redacted key fingerprints');
174
205
  return `${lines.join('\n')}\n`;
175
206
  }
@@ -0,0 +1,59 @@
1
+ export const TOOL_CATALOG = [
2
+ {
3
+ detail: 'Read, write, patch, list, glob, grep, and run bounded shell commands in the current workspace.',
4
+ group: 'Files',
5
+ name: 'workspace tools',
6
+ summary: 'read/write/edit/list/glob/grep/bash',
7
+ },
8
+ {
9
+ detail: 'Inspect diffs, status, logs, and create commits when the user explicitly wants a commit.',
10
+ group: 'Git',
11
+ name: 'git tools',
12
+ summary: 'status/diff/log/commit',
13
+ },
14
+ {
15
+ detail: 'Remember facts, recall local memory, discover skills, and create reusable skill workflows.',
16
+ group: 'Memory',
17
+ name: 'memory + skills',
18
+ summary: 'remember/recall/find_skills/create_skill',
19
+ },
20
+ {
21
+ detail: 'Fetch public pages via the ethical web ladder (robots.txt, SSRF guard, reader/Tavily/Wayback fallbacks).',
22
+ group: 'Research',
23
+ name: 'web fetch',
24
+ summary: 'web_fetch (ethical ladder)',
25
+ },
26
+ {
27
+ detail: 'Use local brain search for vault/session/skill retrieval, and configured MCP web/search/fetch servers for current external facts with citations.',
28
+ group: 'Research',
29
+ name: 'local + web grounding',
30
+ summary: 'sanook search + web MCP readiness',
31
+ },
32
+ {
33
+ detail: 'Schedule recurring or future tasks for the Sanook gateway service to run later.',
34
+ group: 'Gateway',
35
+ name: 'scheduled tasks',
36
+ summary: 'schedule/list/cancel',
37
+ },
38
+ {
39
+ detail: 'Fan work out to sub-agents, collect results, cancel background jobs, and inspect task status.',
40
+ group: 'Agents',
41
+ name: 'agent orchestration',
42
+ summary: 'task/task_parallel/task_spawn/task_collect',
43
+ },
44
+ {
45
+ detail: 'Ask the language server for type errors and lint-like diagnostics after code edits.',
46
+ group: 'Quality',
47
+ name: 'diagnostics',
48
+ summary: 'LSP diagnostics',
49
+ },
50
+ {
51
+ detail: 'Run optional Python or Rust snippets/files without shell strings for data analysis and native-helper prototypes.',
52
+ group: 'Polyglot',
53
+ name: 'python + rust runtime tools',
54
+ summary: 'run_python/run_rust',
55
+ },
56
+ ];
57
+ export function formatToolCatalog(tools = TOOL_CATALOG) {
58
+ return tools.map((tool) => `${tool.group}: ${tool.summary}`).join('\n ');
59
+ }
@@ -12,6 +12,8 @@ import { taskTool, taskParallelTool, taskSpawnTool, taskCollectTool, taskCancelT
12
12
  import { diagnosticsTool } from './diagnostics.js';
13
13
  import { gitStatusTool, gitDiffTool, gitLogTool, gitCommitTool } from './git.js';
14
14
  import { haCallServiceTool, haGetStateTool, haListEntitiesTool, haListServicesTool } from './homeassistant.js';
15
+ import { pythonTool, rustTool } from './polyglot.js';
16
+ import { webFetchTool } from './web-fetch-tool.js';
15
17
  /** tool registry ที่ส่งให้ agent loop */
16
18
  export const tools = {
17
19
  read_file: readFileTool,
@@ -21,6 +23,8 @@ export const tools = {
21
23
  glob: globTool,
22
24
  grep: grepTool,
23
25
  run_bash: bashTool,
26
+ run_python: pythonTool,
27
+ run_rust: rustTool,
24
28
  remember: rememberTool,
25
29
  recall: recallTool,
26
30
  skill: skillTool,
@@ -44,5 +48,6 @@ export const tools = {
44
48
  ha_get_state: haGetStateTool,
45
49
  ha_list_services: haListServicesTool,
46
50
  ha_call_service: haCallServiceTool,
51
+ web_fetch: webFetchTool,
47
52
  };
48
53
  export { readFileTool, writeFileTool, editFileTool, listDirTool, globTool, grepTool, bashTool };
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { realpath, stat } from 'node:fs/promises';
2
+ import { realpath, stat, lstat, readlink } from 'node:fs/promises';
3
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';
@@ -928,18 +928,60 @@ function readsProtectedEnvFile(cmd) {
928
928
  return true;
929
929
  return false;
930
930
  }
931
- export function checkBash(cmd, depth = 0) {
932
- if (hasRmRecursiveForce(cmd) || hasDangerousGitOperation(cmd) || hasDestructiveCommand(cmd)) {
933
- return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
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]);
934
945
  }
935
- if (PROTECTED_CMD_PATH.test(cmd) || mentionsProtectedEnvPath(cmd) || readsProtectedEnvFile(cmd)) {
936
- return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
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 });
937
955
  }
938
- if (nestedShellCommandDenied(cmd, depth)) {
939
- return { ok: false, reason: `คำสั่ง nested shell ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
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
+ }
940
972
  }
941
- if (envWrappedCommandDenied(cmd, depth)) {
942
- return { ok: false, reason: `คำสั่ง env wrapper ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
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
+ }
943
985
  }
944
986
  return { ok: true };
945
987
  }
@@ -951,6 +993,21 @@ async function canonicalExisting(path) {
951
993
  return resolve(path);
952
994
  }
953
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
+ }
954
1011
  async function existingAncestor(path) {
955
1012
  let dir = resolve(path);
956
1013
  for (;;) {
@@ -1009,17 +1066,26 @@ export async function checkReadPath(path) {
1009
1066
  export async function checkWritePath(path) {
1010
1067
  const abs = resolve(path);
1011
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] : [])];
1012
1073
  const inProtectedDir = (p) => PROTECTED_DIRS.some((d) => p === d || p.startsWith(d + sep));
1013
- if (PROTECTED_EXACT.has(abs) ||
1014
- PROTECTED_EXACT.has(canonical) ||
1015
- inProtectedDir(abs) ||
1016
- inProtectedDir(canonical) ||
1017
- protectedSegment(abs) ||
1018
- protectedSegment(canonical)) {
1074
+ if (candidates.some((p) => PROTECTED_EXACT.has(p) || inProtectedDir(p) || protectedSegment(p))) {
1019
1075
  return {
1020
1076
  ok: false,
1021
1077
  reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / shell-rc / .sanook / .git / .env / node_modules)`,
1022
1078
  };
1023
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
+ }
1024
1090
  return checkPathScope(path, 'write');
1025
1091
  }
@@ -0,0 +1,126 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { findBinary } from '../lsp/servers.js';
7
+ import { runProcess, formatProcessResult } from '../process-runner.js';
8
+ import { agentCwd } from '../agentContext.js';
9
+ import { checkReadPath } from './permission.js';
10
+ import { maybeSandboxExec } from './sandbox.js';
11
+ import { resolveAgentPath } from './util.js';
12
+ const MAX_TIMEOUT_MS = 300_000;
13
+ // Run a runtime (python/rustc/compiled exe) under the same OS sandbox as run_bash so
14
+ // inline code cannot write outside the workspace (cwd + brain + tmp). No-shell: argv is
15
+ // wrapped directly, so there is no shell interpolation of the code/args.
16
+ async function runConfined(file, args, cwd, opts) {
17
+ const sb = await maybeSandboxExec(file, args, cwd);
18
+ return sb ? runProcess(sb.file, sb.args, { cwd, ...opts }) : runProcess(file, args, { cwd, ...opts });
19
+ }
20
+ const RuntimeScriptSchema = z
21
+ .object({
22
+ code: z.string().optional().describe('source code to run as a temporary script'),
23
+ path: z.string().optional().describe('existing script/source file to run instead of code'),
24
+ args: z.array(z.string()).optional().describe('argv passed to the script/program'),
25
+ stdin: z.string().optional().describe('stdin content for the process'),
26
+ timeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional().describe('timeout in ms (default 120000, max 300000)'),
27
+ })
28
+ .refine((v) => Boolean(v.code) !== Boolean(v.path), 'provide exactly one of code or path');
29
+ async function existingSourcePath(path) {
30
+ const full = resolveAgentPath(path);
31
+ const guard = await checkReadPath(full);
32
+ if (!guard.ok)
33
+ return { ok: false, reason: guard.reason };
34
+ return { ok: true, path: full };
35
+ }
36
+ async function tempSource(suffix, content) {
37
+ const dir = await mkdtemp(join(tmpdir(), 'sanook-polyglot-'));
38
+ const path = join(dir, `main${suffix}`);
39
+ await writeFile(path, content, 'utf8');
40
+ return { dir, path };
41
+ }
42
+ async function findRuntime(command) {
43
+ const bin = await findBinary(command, agentCwd());
44
+ return bin ?? findBinary(command, process.cwd()) ?? null;
45
+ }
46
+ async function runPython(input) {
47
+ const cwd = agentCwd();
48
+ const python = (await findRuntime('python3')) ?? (await findRuntime('python'));
49
+ if (!python)
50
+ return 'PYTHON: ยังไม่พบ python3/python — ติดตั้ง Python 3.11+ แล้วลองใหม่';
51
+ let tempDir;
52
+ let scriptPath;
53
+ try {
54
+ if (input.path) {
55
+ const source = await existingSourcePath(input.path);
56
+ if (!source.ok)
57
+ return `BLOCKED: ${source.reason}`;
58
+ scriptPath = source.path;
59
+ }
60
+ else {
61
+ const temp = await tempSource('.py', input.code ?? '');
62
+ tempDir = temp.dir;
63
+ scriptPath = temp.path;
64
+ }
65
+ const result = await runConfined(python, [scriptPath, ...(input.args ?? [])], cwd, {
66
+ input: input.stdin,
67
+ timeoutMs: input.timeoutMs,
68
+ });
69
+ return formatProcessResult(result);
70
+ }
71
+ finally {
72
+ if (tempDir)
73
+ await rm(tempDir, { recursive: true, force: true }).catch(() => { });
74
+ }
75
+ }
76
+ async function runRust(input) {
77
+ const cwd = agentCwd();
78
+ const rustc = await findRuntime('rustc');
79
+ if (!rustc)
80
+ return 'RUST: ยังไม่พบ rustc — ติดตั้ง Rust ผ่าน rustup แล้วลองใหม่';
81
+ let tempDir;
82
+ try {
83
+ const temp = await mkdtemp(join(tmpdir(), 'sanook-rust-'));
84
+ tempDir = temp;
85
+ const sourcePath = input.path
86
+ ? await (async () => {
87
+ const source = await existingSourcePath(input.path);
88
+ if (!source.ok)
89
+ return source;
90
+ return { ok: true, path: source.path };
91
+ })()
92
+ : { ok: true, path: join(temp, 'main.rs') };
93
+ if (!sourcePath.ok)
94
+ return `BLOCKED: ${sourcePath.reason}`;
95
+ if (!input.path)
96
+ await writeFile(sourcePath.path, input.code ?? '', 'utf8');
97
+ const exe = join(temp, process.platform === 'win32' ? 'sanook-rust-helper.exe' : 'sanook-rust-helper');
98
+ const compile = await runConfined(rustc, ['--edition=2021', sourcePath.path, '-o', exe], cwd, {
99
+ timeoutMs: input.timeoutMs,
100
+ });
101
+ if (!compile.ok)
102
+ return `RUST COMPILE ${formatProcessResult(compile)}`;
103
+ const run = await runConfined(exe, input.args ?? [], cwd, {
104
+ input: input.stdin,
105
+ timeoutMs: input.timeoutMs,
106
+ });
107
+ return formatProcessResult(run);
108
+ }
109
+ finally {
110
+ if (tempDir)
111
+ await rm(tempDir, { recursive: true, force: true }).catch(() => { });
112
+ }
113
+ }
114
+ export const pythonTool = tool({
115
+ description: 'รัน Python แบบ no-shell สำหรับงานที่ Python ถนัด: data/JSON/CSV transform, document/text parsing, ML/OCR helper, research script. ' +
116
+ 'ใช้ code สำหรับ snippet สั้น หรือ path สำหรับไฟล์ .py ใน workspace. ต้องมี python3/python ใน PATH.',
117
+ inputSchema: RuntimeScriptSchema,
118
+ execute: runPython,
119
+ });
120
+ export const rustTool = tool({
121
+ description: 'compile+run Rust single-file/snippet แบบ no-shell สำหรับงานที่ Rust ถนัด: parser/checker ที่เร็ว, algorithm ที่ต้อง type-safe, native helper prototype. ' +
122
+ 'ใช้ code สำหรับ main.rs ชั่วคราว หรือ path สำหรับไฟล์ .rs เดี่ยวใน workspace. ต้องมี rustc ใน PATH; งาน Cargo project ให้ใช้ run_bash ตามปกติ.',
123
+ inputSchema: RuntimeScriptSchema,
124
+ execute: runRust,
125
+ });
126
+ export const runtimeScriptSchema = RuntimeScriptSchema;
@@ -27,15 +27,12 @@ function seatbeltProfile(writable) {
27
27
  ' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr"))',
28
28
  ].join('\n');
29
29
  }
30
- function bwrapArgs(writable, cmd) {
30
+ function bwrapArgs(writable, tail) {
31
31
  const binds = writable.flatMap((w) => ['--bind', w, w]);
32
- return ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds, '/bin/sh', '-c', cmd];
32
+ return ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds, ...tail];
33
33
  }
34
- /**
35
- * คืน {file,args} สำหรับรัน cmd แบบ sandbox (ผ่าน execFile) — หรือ null ถ้าไม่มี sandbox/ปิดไว้
36
- * (caller รัน cmd ตรงๆ ตามเดิม). path ที่มี '"' → ข้าม sandbox (กัน profile พัง)
37
- */
38
- export async function maybeSandbox(cmd, cwd = process.cwd()) {
34
+ // writable dirs (cwd + tmp + brain) — or null when sandbox is disabled / unusable
35
+ async function sandboxWritable(cwd) {
39
36
  if (envFlag(BRAND_ENV.allowOutsideWorkspace) || envFlag('SANOOK_NO_SANDBOX'))
40
37
  return null;
41
38
  const writable = [canon(cwd), canon(tmpdir())];
@@ -44,18 +41,46 @@ export async function maybeSandbox(cmd, cwd = process.cwd()) {
44
41
  writable.push(canon(brain));
45
42
  if (writable.some((w) => w.includes('"')))
46
43
  return null;
44
+ return writable;
45
+ }
46
+ function sandboxBin() {
47
47
  const os = platform();
48
48
  if (os === 'darwin') {
49
49
  const bin = ['/usr/bin/sandbox-exec', '/usr/sbin/sandbox-exec'].find((p) => existsSync(p));
50
- if (!bin)
51
- return null;
52
- return { file: bin, args: ['-p', seatbeltProfile(writable), '/bin/sh', '-c', cmd] };
50
+ return bin ? { os, bin } : null;
53
51
  }
54
52
  if (os === 'linux') {
55
53
  const bin = ['/usr/bin/bwrap', '/bin/bwrap'].find((p) => existsSync(p));
56
- if (!bin)
57
- return null;
58
- return { file: bin, args: bwrapArgs(writable, cmd) };
54
+ return bin ? { os, bin } : null;
59
55
  }
60
56
  return null;
61
57
  }
58
+ // wrap an execFile-style argv (already split — no shell) under the sandbox tail
59
+ function wrap(writable, tail) {
60
+ const sb = sandboxBin();
61
+ if (!sb)
62
+ return null;
63
+ if (sb.os === 'darwin')
64
+ return { file: sb.bin, args: ['-p', seatbeltProfile(writable), ...tail] };
65
+ return { file: sb.bin, args: bwrapArgs(writable, tail) };
66
+ }
67
+ /**
68
+ * คืน {file,args} สำหรับรัน cmd แบบ sandbox (ผ่าน execFile) — หรือ null ถ้าไม่มี sandbox/ปิดไว้
69
+ * (caller รัน cmd ตรงๆ ตามเดิม). path ที่มี '"' → ข้าม sandbox (กัน profile พัง)
70
+ */
71
+ export async function maybeSandbox(cmd, cwd = process.cwd()) {
72
+ const writable = await sandboxWritable(cwd);
73
+ if (!writable)
74
+ return null;
75
+ return wrap(writable, ['/bin/sh', '-c', cmd]);
76
+ }
77
+ /**
78
+ * เหมือน maybeSandbox แต่สำหรับ "no-shell" exec (run_python/run_rust): ห่อ argv โดยตรง
79
+ * ผ่าน execFile (ไม่มี shell interpolation) — confine write ให้อยู่ใน cwd+brain+tmp เท่ากับ run_bash.
80
+ */
81
+ export async function maybeSandboxExec(file, argv, cwd = process.cwd()) {
82
+ const writable = await sandboxWritable(cwd);
83
+ if (!writable)
84
+ return null;
85
+ return wrap(writable, [file, ...argv]);
86
+ }
@@ -9,8 +9,15 @@ import { checkReadPath } from './permission.js';
9
9
  import { agentCwd } from '../agentContext.js';
10
10
  // pure-JS grep fallback — ใช้เมื่อ ripgrep (rg) ไม่ได้ติดตั้ง (เช่น Windows สะอาด) → grep ใช้ได้ทุกแพลตฟอร์ม
11
11
  const FALLBACK_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.cache', '.turbo', '.vercel', 'vendor']);
12
+ const FALLBACK_IGNORE_FILES = new Set(['.ds_store', '.localized', 'desktop.ini', 'thumbs.db']);
12
13
  const FALLBACK_MAX_FILE = 2 * 1024 * 1024; // ข้ามไฟล์ใหญ่ (กันช้า/binary)
13
14
  const PER_FILE_CAP = 50; // เหมือน rg --max-count 50
15
+ function isFallbackIgnoredFile(name) {
16
+ return FALLBACK_IGNORE_FILES.has(name.toLowerCase()) || name.startsWith('._');
17
+ }
18
+ function isFallbackIgnoredDir(name) {
19
+ return FALLBACK_IGNORE.has(name.toLowerCase()) || name.startsWith('.');
20
+ }
14
21
  function otherAsciiCase(ch) {
15
22
  const code = ch.charCodeAt(0);
16
23
  if (code >= 65 && code <= 90)
@@ -250,10 +257,10 @@ export async function jsGrep(pattern, base, target) {
250
257
  if (!guard.ok)
251
258
  continue;
252
259
  if (e.isDirectory()) {
253
- if (!FALLBACK_IGNORE.has(e.name) && !e.name.startsWith('.'))
260
+ if (!isFallbackIgnoredDir(e.name))
254
261
  await walk(full);
255
262
  }
256
- else if (e.isFile()) {
263
+ else if (e.isFile() && !isFallbackIgnoredFile(e.name)) {
257
264
  await scanFile(full);
258
265
  }
259
266
  }