sanook-cli 0.4.0 → 0.5.0

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 (235) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +153 -20
  4. package/README.th.md +136 -0
  5. package/dist/agentContext.js +4 -0
  6. package/dist/approval.js +6 -0
  7. package/dist/bin.js +394 -51
  8. package/dist/brain.js +92 -59
  9. package/dist/brand.js +47 -0
  10. package/dist/checkpoint.js +37 -0
  11. package/dist/commands.js +86 -6
  12. package/dist/compaction.js +76 -5
  13. package/dist/config.js +100 -12
  14. package/dist/cost.js +60 -3
  15. package/dist/doctor.js +92 -0
  16. package/dist/gateway/auth.js +2 -2
  17. package/dist/gateway/ledger.js +2 -2
  18. package/dist/gateway/scheduler.js +1 -0
  19. package/dist/gateway/serve.js +6 -4
  20. package/dist/gateway/server.js +10 -2
  21. package/dist/git.js +11 -2
  22. package/dist/hooks.js +43 -17
  23. package/dist/knowledge.js +48 -49
  24. package/dist/loop.js +182 -66
  25. package/dist/lsp/client.js +173 -0
  26. package/dist/lsp/framing.js +56 -0
  27. package/dist/lsp/index.js +138 -0
  28. package/dist/lsp/servers.js +82 -0
  29. package/dist/mcp-server.js +244 -0
  30. package/dist/mcp.js +184 -29
  31. package/dist/memory-store.js +559 -0
  32. package/dist/memory.js +143 -29
  33. package/dist/orchestrate.js +150 -0
  34. package/dist/providers/codex.js +2 -2
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/registry.js +133 -1
  37. package/dist/repomap.js +93 -0
  38. package/dist/search/chunk.js +158 -0
  39. package/dist/search/embed-store.js +187 -0
  40. package/dist/search/engine.js +203 -0
  41. package/dist/search/fuse.js +35 -0
  42. package/dist/search/index-core.js +187 -0
  43. package/dist/search/indexer.js +241 -0
  44. package/dist/search/store.js +77 -0
  45. package/dist/session.js +42 -8
  46. package/dist/skill-install.js +10 -10
  47. package/dist/skills.js +12 -9
  48. package/dist/summarize.js +31 -0
  49. package/dist/tools/bash.js +21 -2
  50. package/dist/tools/diagnostics.js +41 -0
  51. package/dist/tools/edit.js +29 -7
  52. package/dist/tools/index.js +8 -1
  53. package/dist/tools/list.js +7 -2
  54. package/dist/tools/permission.js +90 -9
  55. package/dist/tools/read.js +23 -4
  56. package/dist/tools/remember.js +1 -1
  57. package/dist/tools/sandbox.js +61 -0
  58. package/dist/tools/search.js +105 -4
  59. package/dist/tools/task.js +195 -29
  60. package/dist/tools/timeout.js +35 -0
  61. package/dist/tools/util.js +10 -0
  62. package/dist/tools/write.js +6 -4
  63. package/dist/trust.js +89 -0
  64. package/dist/ui/app.js +218 -27
  65. package/dist/ui/banner.js +4 -9
  66. package/dist/ui/history.js +30 -0
  67. package/dist/ui/mentions.js +44 -0
  68. package/dist/ui/setup.js +6 -5
  69. package/dist/ui/useEditor.js +83 -0
  70. package/dist/update.js +114 -0
  71. package/dist/worktree.js +173 -0
  72. package/package.json +11 -5
  73. package/scripts/postinstall.mjs +33 -0
  74. package/second-brain/.agents/_Index.md +30 -0
  75. package/second-brain/.agents/skills/_Index.md +30 -0
  76. package/second-brain/.agents/workflows/_Index.md +30 -0
  77. package/second-brain/AGENTS.md +4 -4
  78. package/second-brain/Acceptance/_Index.md +30 -0
  79. package/second-brain/Acceptance/golden-case-template.md +39 -0
  80. package/second-brain/Areas/_Index.md +30 -0
  81. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  82. package/second-brain/Bugs/_Index.md +30 -0
  83. package/second-brain/CLAUDE.md +4 -1
  84. package/second-brain/Checklists/_Index.md +30 -0
  85. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  86. package/second-brain/Distillations/_Index.md +30 -0
  87. package/second-brain/Entities/_Index.md +30 -0
  88. package/second-brain/Entities/entity-template.md +33 -0
  89. package/second-brain/Evals/_Index.md +30 -0
  90. package/second-brain/Evals/correction-pairs.md +24 -0
  91. package/second-brain/Evals/failure-taxonomy.md +24 -0
  92. package/second-brain/Evals/golden-set.md +25 -0
  93. package/second-brain/Evals/quality-ledger.md +23 -0
  94. package/second-brain/Evals/self-eval-rubric.md +23 -0
  95. package/second-brain/GEMINI.md +4 -4
  96. package/second-brain/Goals/_Index.md +30 -0
  97. package/second-brain/Handoffs/_Index.md +30 -0
  98. package/second-brain/Home.md +7 -0
  99. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  100. package/second-brain/Intake/_Index.md +30 -0
  101. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  102. package/second-brain/Learning/_Index.md +30 -0
  103. package/second-brain/Playbooks/_Index.md +30 -0
  104. package/second-brain/Playbooks/playbook-template.md +23 -0
  105. package/second-brain/Projects/_Index.md +30 -0
  106. package/second-brain/Prompts/_Index.md +30 -0
  107. package/second-brain/README.md +2 -1
  108. package/second-brain/Research/_Index.md +30 -0
  109. package/second-brain/Retrospectives/_Index.md +30 -0
  110. package/second-brain/Reviews/_Index.md +30 -0
  111. package/second-brain/Runbooks/_Index.md +30 -0
  112. package/second-brain/Runbooks/eval-loop.md +24 -0
  113. package/second-brain/Sessions/_Index.md +30 -0
  114. package/second-brain/Shared/AI-Context-Index.md +20 -0
  115. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  116. package/second-brain/Shared/Archive/_Index.md +30 -0
  117. package/second-brain/Shared/Assets/_Index.md +30 -0
  118. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  119. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  120. package/second-brain/Shared/Coordination/NOW.md +28 -0
  121. package/second-brain/Shared/Coordination/_Index.md +30 -0
  122. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  123. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  124. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  125. package/second-brain/Shared/Coordination/task-board.md +32 -0
  126. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  127. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  128. package/second-brain/Shared/Glossary/_Index.md +30 -0
  129. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  130. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  131. package/second-brain/Shared/Prompting/_Index.md +30 -0
  132. package/second-brain/Shared/Provenance/_Index.md +30 -0
  133. package/second-brain/Shared/Rules/_Index.md +30 -0
  134. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  135. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  136. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  137. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  138. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  139. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  140. package/second-brain/Shared/Scripts/_Index.md +30 -0
  141. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  142. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  143. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  144. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  145. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  146. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  147. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/_Index.md +30 -0
  149. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  150. package/second-brain/Skills/_Index.md +30 -0
  151. package/second-brain/Templates/_Index.md +30 -0
  152. package/second-brain/Templates/bug.md +2 -0
  153. package/second-brain/Templates/handoff.md +2 -0
  154. package/second-brain/Templates/session.md +2 -0
  155. package/second-brain/Tools/_Index.md +30 -0
  156. package/second-brain/Traces/_Index.md +30 -0
  157. package/second-brain/Vault Structure Map.md +33 -1
  158. package/second-brain/copilot/_Index.md +30 -0
  159. package/skills/audit-license-compliance/SKILL.md +117 -0
  160. package/skills/author-codemod/SKILL.md +110 -0
  161. package/skills/build-audit-logging/SKILL.md +112 -0
  162. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  163. package/skills/build-cli-tool/SKILL.md +108 -0
  164. package/skills/build-data-table/SKILL.md +141 -0
  165. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  166. package/skills/build-offline-first-sync/SKILL.md +118 -0
  167. package/skills/build-realtime-channel/SKILL.md +122 -0
  168. package/skills/build-vector-search/SKILL.md +131 -0
  169. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  170. package/skills/configure-bundler-build/SKILL.md +166 -0
  171. package/skills/configure-dns-tls/SKILL.md +142 -0
  172. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  173. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  174. package/skills/contract-testing/SKILL.md +140 -0
  175. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  176. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  177. package/skills/debug-flaky-tests/SKILL.md +128 -0
  178. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  179. package/skills/deliver-webhooks/SKILL.md +116 -0
  180. package/skills/design-api-pagination/SKILL.md +144 -0
  181. package/skills/design-authorization-model/SKILL.md +119 -0
  182. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  183. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  184. package/skills/design-multi-tenancy/SKILL.md +100 -0
  185. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  186. package/skills/design-relational-schema/SKILL.md +129 -0
  187. package/skills/design-search-index-infra/SKILL.md +151 -0
  188. package/skills/design-state-machine/SKILL.md +108 -0
  189. package/skills/design-token-system/SKILL.md +109 -0
  190. package/skills/distributed-locks-leases/SKILL.md +120 -0
  191. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  192. package/skills/feature-flags-rollout/SKILL.md +130 -0
  193. package/skills/file-upload-object-storage/SKILL.md +107 -0
  194. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  195. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  196. package/skills/i18n-localization-setup/SKILL.md +113 -0
  197. package/skills/idempotency-keys/SKILL.md +107 -0
  198. package/skills/implement-push-notifications/SKILL.md +142 -0
  199. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  200. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  201. package/skills/load-stress-test/SKILL.md +129 -0
  202. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  203. package/skills/model-nosql-data/SKILL.md +118 -0
  204. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  205. package/skills/monitor-ml-drift/SKILL.md +109 -0
  206. package/skills/numeric-precision-units/SKILL.md +144 -0
  207. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  208. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  209. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  210. package/skills/payments-billing-integration/SKILL.md +114 -0
  211. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  212. package/skills/plan-strangler-migration/SKILL.md +95 -0
  213. package/skills/property-based-testing/SKILL.md +108 -0
  214. package/skills/publish-package-registry/SKILL.md +130 -0
  215. package/skills/recover-git-state/SKILL.md +119 -0
  216. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  217. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  218. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  219. package/skills/rewrite-git-history/SKILL.md +109 -0
  220. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  221. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  222. package/skills/send-transactional-email/SKILL.md +126 -0
  223. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  224. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  225. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  226. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  227. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  228. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  229. package/skills/structured-output-llm/SKILL.md +86 -0
  230. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  231. package/skills/test-data-factories/SKILL.md +158 -0
  232. package/skills/threat-model-stride/SKILL.md +123 -0
  233. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  234. package/skills/unicode-text-correctness/SKILL.md +109 -0
  235. package/skills/visual-regression-testing/SKILL.md +120 -0
package/dist/skills.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { join, dirname } from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
4
+ import { appHomePath } from './brand.js';
5
+ import { projectConfigPathIfTrusted } from './trust.js';
5
6
  // skills = วิธีทำงานเฉพาะทาง/runbook ที่โหลด on-demand (progressive disclosure)
6
7
  // agent เห็นแค่ name+description ใน system prompt → โหลดเต็มด้วย `skill` tool เมื่อ task ตรง
7
8
  // self-improvement: agent สร้าง skill เองด้วย `create_skill` เมื่อเจอ procedure ที่ reuse ได้
8
9
  // 3 ชั้น: bundled (ship กับ CLI) → global (~/.sanook) → project (.sanook) — ชั้นหลัง override ชื่อซ้ำ
9
10
  const BUNDLED_SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
10
- const GLOBAL_SKILLS = join(homedir(), '.sanook', 'skills');
11
- const projectSkills = () => join(process.cwd(), '.sanook', 'skills');
11
+ const GLOBAL_SKILLS = appHomePath('skills');
12
12
  /** minimal frontmatter parser (key: value ใน --- block) — ไม่พึ่ง YAML dep */
13
13
  export function parseFrontmatter(content) {
14
14
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
@@ -34,10 +34,11 @@ export function isValidSkillName(name) {
34
34
  return /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
35
35
  }
36
36
  /** scan project + global skills → list (name+description เท่านั้น สำหรับ inject). project ทับ global ชื่อซ้ำ */
37
- export async function loadSkills() {
37
+ export async function loadSkills(cwd = process.cwd()) {
38
38
  const out = new Map();
39
+ const projectSkills = await projectConfigPathIfTrusted('skills', cwd);
39
40
  // bundled ก่อน → global → project ทับ (specific กว่าอยู่ท้าย)
40
- for (const dir of [BUNDLED_SKILLS, GLOBAL_SKILLS, projectSkills()]) {
41
+ for (const dir of [BUNDLED_SKILLS, GLOBAL_SKILLS, projectSkills].filter((d) => Boolean(d))) {
41
42
  let entries;
42
43
  try {
43
44
  entries = await readdir(dir, { withFileTypes: true });
@@ -51,8 +52,9 @@ export async function loadSkills() {
51
52
  const p = join(dir, e.name, 'SKILL.md');
52
53
  try {
53
54
  const { meta } = parseFrontmatter(await readFile(p, 'utf8'));
54
- out.set(meta.name || e.name, {
55
- name: meta.name || e.name,
55
+ const name = meta.name && isValidSkillName(meta.name) ? meta.name : e.name;
56
+ out.set(name, {
57
+ name,
56
58
  description: meta.description ?? '',
57
59
  whenToUse: meta.when_to_use,
58
60
  path: p,
@@ -66,10 +68,11 @@ export async function loadSkills() {
66
68
  return [...out.values()];
67
69
  }
68
70
  /** อ่านเนื้อหา SKILL.md เต็ม (skill tool เรียกเมื่อ agent ตัดสินใจใช้) */
69
- export async function getSkillBody(name) {
71
+ export async function getSkillBody(name, cwd = process.cwd()) {
70
72
  if (!isValidSkillName(name))
71
73
  return null;
72
- for (const dir of [projectSkills(), GLOBAL_SKILLS, BUNDLED_SKILLS]) {
74
+ const projectSkills = await projectConfigPathIfTrusted('skills', cwd);
75
+ for (const dir of [projectSkills, GLOBAL_SKILLS, BUNDLED_SKILLS].filter((d) => Boolean(d))) {
73
76
  try {
74
77
  return await readFile(join(dir, name, 'SKILL.md'), 'utf8');
75
78
  }
@@ -0,0 +1,31 @@
1
+ // ============================================================================
2
+ // src/summarize.ts — cheap-model transcript summarizer for compaction.
3
+ //
4
+ // compaction='summarize' replaces the dropped middle of a long conversation with
5
+ // a condensed brief instead of truncating it — better recall at the same token
6
+ // budget. The summary runs on a CHEAP model (the fast sibling of the main model,
7
+ // same provider/key) so the saving isn't eaten by the summarization call itself.
8
+ // resolveModel() is called lazily inside the returned fn, so a missing key throws
9
+ // at summarize-time and summarizeCompact() catches it → falls back to truncation.
10
+ // ============================================================================
11
+ import { generateText } from 'ai';
12
+ import { resolveModel, fastSibling } from './providers/registry.js';
13
+ const SUMMARY_PROMPT = 'You are compacting a coding-session transcript so the agent can CONTINUE the work with less context. ' +
14
+ 'Write a terse factual brief (bullet points, no preamble) that preserves: the task/intent, decisions made, ' +
15
+ 'files created or changed, key findings, and unfinished TODOs. Drop chit-chat and verbose tool output.\n\nTRANSCRIPT:\n';
16
+ /**
17
+ * Build a summarizer using a cheap model — `summaryModel` if set, else the fast
18
+ * sibling of `mainModel` (same provider, cheaper tier). Returns a fn ready for
19
+ * compaction.summarizeCompact().
20
+ */
21
+ export function makeSummarizer(mainModel, summaryModel) {
22
+ const spec = summaryModel ?? fastSibling(mainModel);
23
+ return async (transcript) => {
24
+ const { text } = await generateText({
25
+ model: resolveModel(spec), // lazy: throws here if no key → caller falls back to truncation
26
+ prompt: SUMMARY_PROMPT + transcript,
27
+ maxOutputTokens: 1024,
28
+ });
29
+ return text;
30
+ };
31
+ }
@@ -1,10 +1,23 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
- import { exec } from 'node:child_process';
3
+ import { exec, execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { clamp } from './util.js';
6
6
  import { checkBash } from './permission.js';
7
+ import { maybeSandbox } from './sandbox.js';
8
+ import { agentCwd } from '../agentContext.js';
7
9
  const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
+ const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
12
+ function safeEnv() {
13
+ const out = {};
14
+ for (const k of SAFE_ENV_KEYS) {
15
+ const v = process.env[k];
16
+ if (v != null)
17
+ out[k] = v;
18
+ }
19
+ return out;
20
+ }
8
21
  export const bashTool = tool({
9
22
  description: 'รันคำสั่ง shell (ls/grep/find/cat/test/npm ฯลฯ) เพื่อค้นหา ตรวจสอบ หรือรัน build/test',
10
23
  inputSchema: z.object({
@@ -14,8 +27,14 @@ export const bashTool = tool({
14
27
  const guard = checkBash(cmd);
15
28
  if (!guard.ok)
16
29
  return `BLOCKED: ${guard.reason}`;
30
+ const cwd = agentCwd(); // worktree ของ sub-agent ถ้ามี (sandbox confine write ตาม cwd นี้)
31
+ const opts = { cwd, env: safeEnv(), timeout: 120_000, maxBuffer: 10 * 1024 * 1024 };
17
32
  try {
18
- const { stdout, stderr } = await execAsync(cmd, { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 });
33
+ // OS sandbox (Seatbelt/bubblewrap) confine write ให้อยู่ใน workspace ถ้ามี ไม่งั้นรันตรงตามเดิม
34
+ const sb = await maybeSandbox(cmd, cwd);
35
+ const { stdout, stderr } = sb
36
+ ? await execFileAsync(sb.file, sb.args, opts)
37
+ : await execAsync(cmd, opts);
19
38
  const out = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim();
20
39
  return clamp(out) || '(no output)';
21
40
  }
@@ -0,0 +1,41 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { diagnose } from '../lsp/index.js';
4
+ import { resolveAgentPath } from './util.js';
5
+ import { agentCwd } from '../agentContext.js';
6
+ import { checkReadPath } from './permission.js';
7
+ import { clamp } from './util.js';
8
+ const SYM = { error: '✗', warning: '⚠', info: 'ℹ', hint: '·' };
9
+ const MAX_SHOWN = 100;
10
+ /**
11
+ * diagnostics tool — type errors/warnings จาก language server (LSP) ของไฟล์เดียว
12
+ * โดยไม่ต้อง build ทั้งโปรเจค. ปิด verify-loop: agent แก้ไฟล์ → ตรวจ → แก้ error ต่อทันที.
13
+ * graceful: ไม่มี server ติดตั้ง → บอกวิธีติดตั้ง (ไม่ crash). respect worktree (agentCwd).
14
+ */
15
+ export const diagnosticsTool = tool({
16
+ description: 'ตรวจ type error / warning ของไฟล์ด้วย language server (LSP) — เรียก "หลังแก้ไฟล์" เพื่อจับ error ทันทีโดยไม่ต้อง build/test ทั้งโปรเจค. ' +
17
+ 'รองรับ TS/JS · Python · Go · Rust · JSON ฯลฯ (ถ้าติดตั้ง LSP server ไว้; ไม่มี = บอกวิธีติดตั้ง). ' +
18
+ 'ใส่ content เพื่อตรวจฉบับที่ยังไม่ save ได้',
19
+ inputSchema: z.object({
20
+ path: z.string().describe('path ไฟล์ที่จะตรวจ'),
21
+ content: z.string().optional().describe('เนื้อหาที่จะตรวจ (ฉบับยังไม่ save) — ไม่ใส่ = อ่านจากดิสก์'),
22
+ }),
23
+ execute: async ({ path, content }) => {
24
+ const full = resolveAgentPath(path);
25
+ const guard = await checkReadPath(full);
26
+ if (!guard.ok)
27
+ return `BLOCKED: ${guard.reason}`;
28
+ const r = await diagnose(full, { cwd: agentCwd(), content });
29
+ if (!r.ok)
30
+ return `LSP: ${r.reason}`;
31
+ if (!r.diagnostics.length)
32
+ return `✓ ไม่มี diagnostics (${r.serverId}) — ${path}`;
33
+ const errs = r.diagnostics.filter((d) => d.severity === 'error').length;
34
+ const warns = r.diagnostics.filter((d) => d.severity === 'warning').length;
35
+ const lines = r.diagnostics
36
+ .slice(0, MAX_SHOWN)
37
+ .map((d) => `${SYM[d.severity]} ${path}:${d.line}:${d.character} ${d.message}${d.code != null ? ` [${d.code}]` : ''}`);
38
+ const more = r.diagnostics.length > MAX_SHOWN ? `\n… +${r.diagnostics.length - MAX_SHOWN} เพิ่มเติม` : '';
39
+ return clamp(`${errs} error · ${warns} warning (${r.serverId}):\n${lines.join('\n')}${more}`);
40
+ },
41
+ });
@@ -2,6 +2,7 @@ import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { readFile, writeFile } from 'node:fs/promises';
4
4
  import { checkWritePath } from './permission.js';
5
+ import { resolveAgentPath } from './util.js';
5
6
  import { renderEditDiff } from '../diff.js';
6
7
  /** tier 1: exact substring match + นับจำนวนครั้ง */
7
8
  export function exactMatch(content, needle) {
@@ -58,14 +59,18 @@ export function findMatch(content, needle) {
58
59
  return exactMatch(content, needle) ?? whitespaceFlexMatch(content, needle);
59
60
  }
60
61
  export const editFileTool = tool({
61
- description: 'แก้ไฟล์โดยแทนที่ old_string ด้วย new_string. old_string ต้องมีอยู่จริงและ unique ในไฟล์ (ใส่ context รอบๆ ให้พอระบุตำแหน่งเดียว). อ่านไฟล์ด้วย read_file ก่อนเสมอ',
62
+ description: 'แก้ไฟล์แบบ search/replace แทนที่เฉพาะ "ช่วงที่ส่งมา" ไม่ใช่ทั้งไฟล์/ทั้งบรรทัด. ' +
63
+ 'ให้ old_string สั้นที่สุดเท่าที่ยัง unique (ประหยัด token — ไม่ต้องลอกทั้งบรรทัด/ทั้ง block ถ้าไม่จำเป็น). ' +
64
+ 'จะแก้ token เดิมหลายที่ (rename) → ตั้ง replace_all:true แล้วใส่ old_string สั้นๆ ได้เลย ไม่ต้องทำให้ unique. อ่านไฟล์ด้วย read_file ก่อนเสมอ',
62
65
  inputSchema: z.object({
63
66
  path: z.string().describe('path ของไฟล์ที่จะแก้'),
64
- old_string: z.string().describe('ข้อความเดิมที่จะถูกแทนที่ (ต้องตรงและ unique)'),
67
+ old_string: z.string().describe('ข้อความเดิมที่จะถูกแทนที่ — สั้นที่สุดที่ยัง unique (replace_all:true ไม่ต้อง unique)'),
65
68
  new_string: z.string().describe('ข้อความใหม่'),
69
+ replace_all: z.boolean().optional().describe('true = แทนที่ทุกที่ที่ตรง old_string เป๊ะ (เหมาะกับ rename) — old_string ไม่ต้อง unique'),
66
70
  }),
67
- execute: async ({ path, old_string, new_string }) => {
68
- const guard = checkWritePath(path);
71
+ execute: async ({ path, old_string, new_string, replace_all = false }) => {
72
+ const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
73
+ const guard = await checkWritePath(full);
69
74
  if (!guard.ok)
70
75
  return `BLOCKED: ${guard.reason}`;
71
76
  if (old_string === '')
@@ -75,7 +80,7 @@ export const editFileTool = tool({
75
80
  }
76
81
  let raw;
77
82
  try {
78
- raw = await readFile(path, 'utf8');
83
+ raw = await readFile(full, 'utf8');
79
84
  }
80
85
  catch (err) {
81
86
  return `ERROR: อ่านไฟล์ "${path}" ไม่ได้ — ${err.message}`;
@@ -86,18 +91,35 @@ export const editFileTool = tool({
86
91
  const content = usesCRLF ? raw.replace(/\r\n/g, '\n') : raw;
87
92
  const oldNorm = old_string.replace(/\r\n/g, '\n');
88
93
  const newNorm = new_string.replace(/\r\n/g, '\n');
94
+ // replace_all: แทนที่ทุกที่ที่ตรง "เป๊ะ" (exact เท่านั้น — flex หลายช่วงกำกวม) → old_string สั้นได้ ไม่ต้อง unique
95
+ if (replace_all) {
96
+ const exact = exactMatch(content, oldNorm);
97
+ if (!exact) {
98
+ return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — replace_all ใช้ match แบบตรงเป๊ะเท่านั้น (อ่านไฟล์ใหม่แล้วคัดข้อความที่ตรง)`;
99
+ }
100
+ let updated = content.split(oldNorm).join(newNorm); // split/join = แทนที่ทุกที่ (string literal, ไม่ใช่ regex)
101
+ if (usesCRLF)
102
+ updated = updated.replace(/\n/g, '\r\n');
103
+ try {
104
+ await writeFile(full, updated, 'utf8');
105
+ }
106
+ catch (err) {
107
+ return `ERROR: เขียนไฟล์ "${path}" ไม่ได้ — ${err.message}`;
108
+ }
109
+ return `OK: แก้ "${path}" (${exact.count} ที่)\n${renderEditDiff(oldNorm, newNorm)}`;
110
+ }
89
111
  const m = findMatch(content, oldNorm);
90
112
  if (!m) {
91
113
  return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — อ่านไฟล์ใหม่ด้วย read_file แล้วคัดข้อความที่ตรงเป๊ะมาใช้`;
92
114
  }
93
115
  if (m.count > 1) {
94
- return `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" (ต้อง unique) ใส่ context รอบๆ ให้มากขึ้นเพื่อระบุตำแหน่งเดียว`;
116
+ return `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" ตั้ง replace_all:true เพื่อแก้ทุกที่ หรือใส่ context รอบๆ ให้พอ unique (ใช้เท่าที่จำเป็น ประหยัด token)`;
95
117
  }
96
118
  let updated = content.slice(0, m.start) + newNorm + content.slice(m.end);
97
119
  if (usesCRLF)
98
120
  updated = updated.replace(/\n/g, '\r\n');
99
121
  try {
100
- await writeFile(path, updated, 'utf8');
122
+ await writeFile(full, updated, 'utf8');
101
123
  }
102
124
  catch (err) {
103
125
  return `ERROR: เขียนไฟล์ "${path}" ไม่ได้ — ${err.message}`;
@@ -8,7 +8,8 @@ import { rememberTool } from './remember.js';
8
8
  import { skillTool, createSkillTool, findSkillsTool } from './skill.js';
9
9
  import { recallTool } from './recall.js';
10
10
  import { scheduleTaskTool, listScheduledTool, cancelScheduledTool } from './schedule.js';
11
- import { taskTool } from './task.js';
11
+ import { taskTool, taskParallelTool, taskSpawnTool, taskCollectTool, taskCancelTool, taskStatusTool } from './task.js';
12
+ import { diagnosticsTool } from './diagnostics.js';
12
13
  import { gitStatusTool, gitDiffTool, gitLogTool, gitCommitTool } from './git.js';
13
14
  /** tool registry ที่ส่งให้ agent loop */
14
15
  export const tools = {
@@ -28,6 +29,12 @@ export const tools = {
28
29
  list_scheduled: listScheduledTool,
29
30
  cancel_scheduled: cancelScheduledTool,
30
31
  task: taskTool,
32
+ task_parallel: taskParallelTool,
33
+ task_spawn: taskSpawnTool,
34
+ task_collect: taskCollectTool,
35
+ task_cancel: taskCancelTool,
36
+ task_status: taskStatusTool,
37
+ diagnostics: diagnosticsTool,
31
38
  git_status: gitStatusTool,
32
39
  git_diff: gitDiffTool,
33
40
  git_log: gitLogTool,
@@ -1,15 +1,20 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { readdir } from 'node:fs/promises';
4
- import { clamp } from './util.js';
4
+ import { clamp, resolveAgentPath } from './util.js';
5
+ import { checkReadPath } from './permission.js';
5
6
  export const listDirTool = tool({
6
7
  description: 'list ไฟล์และโฟลเดอร์ใน directory (โฟลเดอร์ลงท้ายด้วย /)',
7
8
  inputSchema: z.object({
8
9
  path: z.string().default('.').describe('directory ที่จะ list (default: current dir)'),
9
10
  }),
10
11
  execute: async ({ path }) => {
12
+ const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
13
+ const guard = await checkReadPath(full);
14
+ if (!guard.ok)
15
+ return `BLOCKED: ${guard.reason}`;
11
16
  try {
12
- const entries = await readdir(path, { withFileTypes: true });
17
+ const entries = await readdir(full, { withFileTypes: true });
13
18
  const out = entries
14
19
  .filter((e) => !e.name.startsWith('.') || e.name === '.env.example' || e.name === '.gitignore')
15
20
  .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
@@ -1,30 +1,111 @@
1
1
  import { homedir } from 'node:os';
2
- import { resolve, join, sep } from 'node:path';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { basename, dirname, resolve, join, sep } from 'node:path';
4
+ import { getBrainPath } from '../memory.js';
5
+ import { BRAND_ENV, envFlag } from '../brand.js';
6
+ import { agentCwd } from '../agentContext.js';
3
7
  // Permission gate (M1): ก่อนมี interactive ask (M4) — hard-deny อันตราย, allow ที่เหลือ
4
8
  // คำสั่ง shell ที่ทำลายล้าง irreversible
5
- const DESTRUCTIVE_CMD = /(\brm\s+-rf\b|\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;
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;
6
11
  const HOME = homedir();
7
12
  // ไฟล์ที่ห้ามเขียน (persistence backdoor): shell rc, git/npm config, ~/.sanook (token/mcp/hooks)
8
13
  const PROTECTED_EXACT = new Set(['.gitconfig', '.zshrc', '.bashrc', '.bash_profile', '.profile', '.zprofile', '.npmrc'].map((f) => join(HOME, f)));
9
14
  // โฟลเดอร์ที่ห้ามเขียนเข้าไป (credentials + sanook internal)
10
15
  const PROTECTED_DIRS = ['.ssh', '.aws', '.gnupg', '.sanook'].map((d) => join(HOME, d));
11
- // segment ที่ห้ามไม่ว่าอยู่ที่ไหน (.git internals / .env / deps / credentials dir)
12
- const PROTECTED_SEGMENT = /(^|\/)(\.git|node_modules|\.ssh|\.aws|\.gnupg)(\/|$)|(^|\/)\.env($|\.)/i;
16
+ const PROTECTED_SEGMENTS = new Set(['.git', 'node_modules', '.ssh', '.aws', '.gnupg', '.sanook']);
17
+ function hasRmRecursiveForce(cmd) {
18
+ for (const match of cmd.matchAll(/\brm\b([^;&|]*)/gi)) {
19
+ const parts = match[1].split(/\s+/).filter(Boolean);
20
+ const shortFlags = parts.filter((part) => /^-[^-]/.test(part)).join('');
21
+ const recursive = /r/i.test(shortFlags) || parts.includes('--recursive') || parts.includes('--dir');
22
+ const force = /f/i.test(shortFlags) || parts.includes('--force');
23
+ if (recursive && force)
24
+ return true;
25
+ }
26
+ return false;
27
+ }
13
28
  export function checkBash(cmd) {
14
- if (DESTRUCTIVE_CMD.test(cmd)) {
29
+ if (hasRmRecursiveForce(cmd) || DESTRUCTIVE_CMD.test(cmd)) {
15
30
  return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
16
31
  }
32
+ if (PROTECTED_CMD_PATH.test(cmd)) {
33
+ return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
34
+ }
35
+ return { ok: true };
36
+ }
37
+ async function canonicalExisting(path) {
38
+ try {
39
+ return await realpath(path);
40
+ }
41
+ catch {
42
+ return resolve(path);
43
+ }
44
+ }
45
+ async function existingAncestor(path) {
46
+ let dir = resolve(path);
47
+ for (;;) {
48
+ try {
49
+ await stat(dir);
50
+ return canonicalExisting(dir);
51
+ }
52
+ catch {
53
+ const parent = dirname(dir);
54
+ if (parent === dir)
55
+ return dir;
56
+ dir = parent;
57
+ }
58
+ }
59
+ }
60
+ async function allowedRoots() {
61
+ if (envFlag(BRAND_ENV.allowOutsideWorkspace))
62
+ return ['/'];
63
+ // agentCwd() = worktree ของ sub-agent ที่ถูก isolate (ถ้ามี) ไม่งั้น = process.cwd().
64
+ // ผล: sub-agent ใน worktree เขียนได้เฉพาะใน worktree ตัวเอง (isolation) ส่วน main agent เขียนใน workspace ปกติ
65
+ const roots = [await canonicalExisting(agentCwd())];
66
+ const brain = await getBrainPath();
67
+ if (brain)
68
+ roots.push(await canonicalExisting(brain));
69
+ return roots;
70
+ }
71
+ function inside(abs, root) {
72
+ return abs === root || abs.startsWith(root.endsWith(sep) ? root : root + sep);
73
+ }
74
+ function protectedSegment(abs) {
75
+ const parts = abs.split(/[\\/]+/);
76
+ if (parts.some((p) => PROTECTED_SEGMENTS.has(p)))
77
+ return true;
78
+ const base = basename(abs);
79
+ return base.startsWith('.env') && base !== '.env.example';
80
+ }
81
+ async function checkPathScope(path, intent) {
82
+ const abs = intent === 'write' ? await existingAncestor(path) : await canonicalExisting(path);
83
+ const roots = await allowedRoots();
84
+ if (!roots.some((root) => inside(abs, root))) {
85
+ return {
86
+ ok: false,
87
+ reason: `path อยู่นอก workspace/brain ที่อนุญาต: "${path}" (ตั้ง ${BRAND_ENV.allowOutsideWorkspace}=1 เพื่อ opt-in)`,
88
+ };
89
+ }
17
90
  return { ok: true };
18
91
  }
19
- /** กันเขียนทับ secrets/shell-rc/.sanook resolve เป็น absolute ก่อน (กัน ../ และ symlink-ish bypass) */
20
- export function checkWritePath(path) {
92
+ /** กันอ่าน secrets/.git/node_modules และกันอ่านนอก workspace/brain */
93
+ export async function checkReadPath(path) {
94
+ const abs = await canonicalExisting(path);
95
+ if (protectedSegment(abs)) {
96
+ return { ok: false, reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / .git / .env / node_modules)` };
97
+ }
98
+ return checkPathScope(path, 'read');
99
+ }
100
+ /** กันเขียนทับ secrets/shell-rc/.sanook + กันเขียนนอก workspace/brain */
101
+ export async function checkWritePath(path) {
21
102
  const abs = resolve(path);
22
103
  const inProtectedDir = PROTECTED_DIRS.some((d) => abs === d || abs.startsWith(d + sep));
23
- if (PROTECTED_EXACT.has(abs) || inProtectedDir || PROTECTED_SEGMENT.test(abs)) {
104
+ if (PROTECTED_EXACT.has(abs) || inProtectedDir || protectedSegment(abs)) {
24
105
  return {
25
106
  ok: false,
26
107
  reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / shell-rc / .sanook / .git / .env / node_modules)`,
27
108
  };
28
109
  }
29
- return { ok: true };
110
+ return checkPathScope(path, 'write');
30
111
  }
@@ -1,15 +1,34 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { readFile } from 'node:fs/promises';
4
- import { clamp } from './util.js';
4
+ import { clamp, resolveAgentPath } from './util.js';
5
+ import { checkReadPath } from './permission.js';
5
6
  export const readFileTool = tool({
6
- description: 'อ่านไฟล์ใน workspace แล้วคืนเนื้อหา (UTF-8). อ่านก่อนแก้ไฟล์เสมอ',
7
+ description: 'อ่านไฟล์ใน workspace (UTF-8). อ่านก่อนแก้ไฟล์เสมอ. ' +
8
+ 'ไฟล์ใหญ่หรือต้องการแค่บางส่วน → ใส่ offset/limit อ่านเฉพาะช่วงบรรทัด (ประหยัด token มาก — คู่กับ grep ที่ให้เลขบรรทัด)',
7
9
  inputSchema: z.object({
8
10
  path: z.string().describe('relative หรือ absolute path ของไฟล์ที่จะอ่าน'),
11
+ offset: z.number().int().min(1).optional().describe('บรรทัดเริ่ม (1-based) — อ่านเฉพาะช่วง ไม่ใส่ = ต้นไฟล์'),
12
+ limit: z.number().int().min(1).optional().describe('จำนวนบรรทัดจาก offset — ไม่ใส่ = ถึงท้ายไฟล์'),
9
13
  }),
10
- execute: async ({ path }) => {
14
+ execute: async ({ path, offset, limit }) => {
15
+ const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
16
+ const guard = await checkReadPath(full);
17
+ if (!guard.ok)
18
+ return `BLOCKED: ${guard.reason}`;
11
19
  try {
12
- return clamp(await readFile(path, 'utf8'));
20
+ const content = await readFile(full, 'utf8');
21
+ // ไม่ระบุช่วง → คืนทั้งไฟล์ (clamp) เหมือนเดิม
22
+ if (offset == null && limit == null)
23
+ return clamp(content);
24
+ // ระบุช่วง → อ่านเฉพาะบรรทัด start..end (ส่งเฉพาะที่ต้องการเข้า context, ประหยัด token)
25
+ const lines = content.split('\n');
26
+ const start = Math.max(0, (offset ?? 1) - 1);
27
+ if (start >= lines.length)
28
+ return `(ไฟล์มี ${lines.length} บรรทัด — offset ${offset} เกินช่วง)`;
29
+ const end = limit == null ? lines.length : Math.min(lines.length, start + limit);
30
+ const slice = lines.slice(start, end).join('\n');
31
+ return clamp(`[บรรทัด ${start + 1}-${end} จาก ${lines.length}]\n${slice}`);
13
32
  }
14
33
  catch (err) {
15
34
  return `ERROR: อ่านไฟล์ "${path}" ไม่ได้ — ${err.message}`;
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { appendMemory } from '../memory.js';
4
4
  export const rememberTool = tool({
5
5
  description: 'จำข้อเท็จจริง/preference/decision สำคัญข้าม session — ใช้เมื่อเจอสิ่งที่ควรจำไว้ใช้ครั้งหน้า ' +
6
- '(เช่น user ชอบ/ไม่ชอบอะไร, decision สำคัญ, convention ของ project). บันทึกลง ~/.sanook/memory',
6
+ '(เช่น user ชอบ/ไม่ชอบอะไร, decision สำคัญ, convention ของ project). บันทึกลง ~/.sanook/memory + route เข้า second-brain vault (Memory-Inbox) ถ้าตั้งไว้',
7
7
  inputSchema: z.object({
8
8
  fact: z.string().describe('สิ่งที่ต้องจำ — 1 ประโยคกระชับ atomic'),
9
9
  }),
@@ -0,0 +1,61 @@
1
+ import { platform, tmpdir } from 'node:os';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { getBrainPath } from '../memory.js';
5
+ import { BRAND_ENV, envFlag } from '../brand.js';
6
+ // OS-level sandbox สำหรับ run_bash — confine "write" ให้อยู่ใน workspace (cwd + brain + tmp)
7
+ // ให้สอดคล้องกับ file-tool ที่ confine อยู่แล้ว (bash เคยเป็นช่องโหว่). อ่าน/network = ปกติ (ไม่ break build/test)
8
+ // macOS → sandbox-exec (Seatbelt) Linux → bwrap (bubblewrap) ถ้ามี
9
+ // ปิด: SANOOK_NO_SANDBOX=1 · SANOOK_ALLOW_OUTSIDE_WORKSPACE=1 (อนุญาตนอก workspace อยู่แล้ว = ไม่ sandbox)
10
+ function canon(p) {
11
+ try {
12
+ return realpathSync(p);
13
+ }
14
+ catch {
15
+ return resolve(p);
16
+ }
17
+ }
18
+ function seatbeltProfile(writable) {
19
+ const allow = writable.map((w) => ` (subpath ${JSON.stringify(w)})`).join('\n');
20
+ return [
21
+ '(version 1)',
22
+ '(allow default)',
23
+ '(deny file-write*)',
24
+ '(allow file-write*',
25
+ allow,
26
+ ' (subpath "/dev")',
27
+ ' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr"))',
28
+ ].join('\n');
29
+ }
30
+ function bwrapArgs(writable, cmd) {
31
+ const binds = writable.flatMap((w) => ['--bind', w, w]);
32
+ return ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds, '/bin/sh', '-c', cmd];
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()) {
39
+ if (envFlag(BRAND_ENV.allowOutsideWorkspace) || envFlag('SANOOK_NO_SANDBOX'))
40
+ return null;
41
+ const writable = [canon(cwd), canon(tmpdir())];
42
+ const brain = await getBrainPath().catch(() => null);
43
+ if (brain && existsSync(brain))
44
+ writable.push(canon(brain));
45
+ if (writable.some((w) => w.includes('"')))
46
+ return null;
47
+ const os = platform();
48
+ if (os === 'darwin') {
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] };
53
+ }
54
+ if (os === 'linux') {
55
+ 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) };
59
+ }
60
+ return null;
61
+ }