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.
- package/.env.example +161 -3
- package/CHANGELOG.md +148 -10
- package/README.md +255 -26
- package/README.th.md +95 -7
- package/dist/approval.js +13 -0
- package/dist/bin.js +3552 -155
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +262 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +377 -0
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +15 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +190 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +266 -27
- package/dist/compaction.js +96 -11
- package/dist/config.js +149 -33
- package/dist/context-compression.js +191 -0
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +49 -15
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +49 -9
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +399 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +501 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +38 -1
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +362 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +55 -0
- package/dist/insights.js +86 -0
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +157 -29
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +494 -0
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +120 -10
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +148 -37
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +51 -19
- package/dist/personality.js +58 -0
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +89 -43
- package/dist/providers/keys.js +22 -1
- package/dist/providers/models.js +2 -2
- package/dist/providers/registry.js +14 -47
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +83 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session-distill.js +84 -0
- package/dist/session.js +92 -16
- package/dist/skill-install.js +53 -13
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +206 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +992 -12
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/read.js +16 -4
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +226 -15
- package/dist/tools/task.js +40 -9
- package/dist/tools/timeout.js +23 -3
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/trust.js +11 -1
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +878 -32
- package/dist/ui/banner.js +78 -4
- package/dist/ui/history.js +37 -5
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +20 -1
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +172 -46
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +56 -17
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/dist/worktree.js +175 -4
- package/package.json +5 -5
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +19 -4
- package/second-brain/Projects/sanook-cli/_Index.md +30 -0
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +8 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -4
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/tools/permission.js
CHANGED
|
@@ -1,21 +1,103 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
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)
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
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
|
}
|