s-app-scoder-agent 0.6.0 → 0.7.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.
- package/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -468
- package/dist/agent.js +825 -175
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +121 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +498 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +182 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* models.dev catalog (I-02) — model metadata: contextWindow, pricing,
|
|
3
|
+
* supportCache, supportReasoning.
|
|
4
|
+
*
|
|
5
|
+
* Source: anomalyco/opencode (packages/core/src/models-dev/) — pattern
|
|
6
|
+
* adapted. Sync from https://models.dev/api.json into ~/.scoder/
|
|
7
|
+
* models-cache.json (TTL 24h); offline fallback = small built-in snapshot.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
const CACHE_PATH = join(homedir(), ".scoder", "models-cache.json");
|
|
13
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const CATALOG_URL = "https://models.dev/api.json";
|
|
15
|
+
/** Offline snapshot — ~25 popular models (fallback when fetch fails / no network). */
|
|
16
|
+
const FALLBACK = [
|
|
17
|
+
{ id: "gpt-4o", provider: "openai", contextWindow: 128_000, input: 2.5, output: 10 },
|
|
18
|
+
{ id: "gpt-4o-mini", provider: "openai", contextWindow: 128_000, input: 0.15, output: 0.6 },
|
|
19
|
+
{ id: "gpt-4.1", provider: "openai", contextWindow: 1_047_576, input: 2, output: 8 },
|
|
20
|
+
{ id: "gpt-4.1-mini", provider: "openai", contextWindow: 1_047_576, input: 0.4, output: 1.6 },
|
|
21
|
+
{ id: "o3", provider: "openai", contextWindow: 200_000, input: 2, output: 8, reasoning: true },
|
|
22
|
+
{ id: "o4-mini", provider: "openai", contextWindow: 200_000, input: 1.1, output: 4.4, reasoning: true },
|
|
23
|
+
{ id: "claude-sonnet-4-5", provider: "anthropic", contextWindow: 200_000, input: 3, output: 15, cache: true, reasoning: true },
|
|
24
|
+
{ id: "claude-opus-4-1", provider: "anthropic", contextWindow: 200_000, input: 15, output: 75, cache: true, reasoning: true },
|
|
25
|
+
{ id: "claude-haiku-4-5", provider: "anthropic", contextWindow: 200_000, input: 1, output: 5, cache: true, reasoning: true },
|
|
26
|
+
{ id: "claude-3-5-haiku", provider: "anthropic", contextWindow: 200_000, input: 0.8, output: 4, cache: true },
|
|
27
|
+
{ id: "gemini-2.5-pro", provider: "google", contextWindow: 1_048_576, input: 1.25, output: 10, reasoning: true },
|
|
28
|
+
{ id: "gemini-2.5-flash", provider: "google", contextWindow: 1_048_576, input: 0.3, output: 2.5, reasoning: true },
|
|
29
|
+
{ id: "deepseek-chat", provider: "deepseek", contextWindow: 128_000, input: 0.27, output: 1.1 },
|
|
30
|
+
{ id: "deepseek-reasoner", provider: "deepseek", contextWindow: 128_000, input: 0.55, output: 2.19, reasoning: true },
|
|
31
|
+
{ id: "glm-4.6", provider: "zai", contextWindow: 200_000, input: 0.6, output: 2.2, reasoning: true },
|
|
32
|
+
{ id: "glm-4.5-air", provider: "zai", contextWindow: 128_000, input: 0.2, output: 1.1 },
|
|
33
|
+
{ id: "qwen3-coder", provider: "qwen", contextWindow: 262_144, input: 0.3, output: 1.2 },
|
|
34
|
+
{ id: "kimi-k2", provider: "moonshotai", contextWindow: 200_000, input: 0.6, output: 2.5 },
|
|
35
|
+
{ id: "llama-3.3-70b", provider: "groq", contextWindow: 131_072, input: 0.59, output: 0.79 },
|
|
36
|
+
{ id: "llama-4-scout", provider: "groq", contextWindow: 131_072, input: 0.11, output: 0.34 },
|
|
37
|
+
{ id: "mistral-large", provider: "mistral", contextWindow: 128_000, input: 2, output: 6 },
|
|
38
|
+
{ id: "command-r-plus", provider: "cohere", contextWindow: 128_000, input: 2.5, output: 10 },
|
|
39
|
+
{ id: "jamba-1.5-large", provider: "ai21", contextWindow: 256_000, input: 2, output: 8 },
|
|
40
|
+
{ id: "grok-4", provider: "xai", contextWindow: 256_000, input: 3, output: 15, reasoning: true },
|
|
41
|
+
{ id: "nova-pro", provider: "amazon", contextWindow: 300_000, input: 0.8, output: 3.2 },
|
|
42
|
+
];
|
|
43
|
+
function key(provider, id) {
|
|
44
|
+
return `${provider}/${id}`;
|
|
45
|
+
}
|
|
46
|
+
function fromFallback() {
|
|
47
|
+
const models = {};
|
|
48
|
+
for (const m of FALLBACK) {
|
|
49
|
+
models[key(m.provider, m.id)] = {
|
|
50
|
+
id: m.id,
|
|
51
|
+
provider: m.provider,
|
|
52
|
+
contextWindow: m.contextWindow,
|
|
53
|
+
pricing: { input: m.input, output: m.output },
|
|
54
|
+
supportCache: m.cache === true,
|
|
55
|
+
supportReasoning: m.reasoning === true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { fetchedAt: 0, models };
|
|
59
|
+
}
|
|
60
|
+
/** Read the cache file; null when missing/malformed. */
|
|
61
|
+
function readCache() {
|
|
62
|
+
if (!existsSync(CACHE_PATH))
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const c = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
|
|
66
|
+
if (typeof c.fetchedAt !== "number" || typeof c.models !== "object")
|
|
67
|
+
return null;
|
|
68
|
+
return c;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function writeCache(c) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(join(CACHE_PATH, ".."), { recursive: true });
|
|
77
|
+
const tmp = `${CACHE_PATH}.tmp`;
|
|
78
|
+
writeFileSync(tmp, JSON.stringify(c));
|
|
79
|
+
renameSync(tmp, CACHE_PATH);
|
|
80
|
+
}
|
|
81
|
+
catch { /* best-effort */ }
|
|
82
|
+
}
|
|
83
|
+
/** Fetch models.dev and update the cache. Returns null on failure. */
|
|
84
|
+
async function fetchCatalog() {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(CATALOG_URL, { signal: AbortSignal.timeout(10_000) });
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
return null;
|
|
89
|
+
const raw = (await res.json());
|
|
90
|
+
const models = {};
|
|
91
|
+
for (const [provider, entry] of Object.entries(raw)) {
|
|
92
|
+
for (const [id, m] of Object.entries(entry.models ?? {})) {
|
|
93
|
+
models[key(provider, id)] = {
|
|
94
|
+
id,
|
|
95
|
+
provider,
|
|
96
|
+
contextWindow: m.limit?.context ?? 128_000,
|
|
97
|
+
maxTokens: m.limit?.output,
|
|
98
|
+
pricing: m.cost ? { input: m.cost.input ?? 0, output: m.cost.output ?? 0 } : undefined,
|
|
99
|
+
supportCache: m.cache === true,
|
|
100
|
+
supportReasoning: m.reasoning === true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(models).length === 0)
|
|
105
|
+
return null;
|
|
106
|
+
return { fetchedAt: Date.now(), models };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Load the catalog: cache (fresh ≤24h) → fetch → fallback snapshot.
|
|
114
|
+
* Never throws; offline-safe.
|
|
115
|
+
*/
|
|
116
|
+
export async function loadCatalog(forceRefresh = false) {
|
|
117
|
+
if (!forceRefresh) {
|
|
118
|
+
const cached = readCache();
|
|
119
|
+
if (cached && Date.now() - cached.fetchedAt < TTL_MS) {
|
|
120
|
+
return { models: cached.models, source: "cache" };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const fresh = await fetchCatalog();
|
|
124
|
+
if (fresh) {
|
|
125
|
+
writeCache(fresh);
|
|
126
|
+
return { models: fresh.models, source: "network" };
|
|
127
|
+
}
|
|
128
|
+
const stale = readCache();
|
|
129
|
+
if (stale)
|
|
130
|
+
return { models: stale.models, source: "cache" };
|
|
131
|
+
return { models: fromFallback().models, source: "fallback" };
|
|
132
|
+
}
|
|
133
|
+
/** Synchronous metadata lookup — cache then fallback (never fetches). */
|
|
134
|
+
export function lookupModel(provider, id) {
|
|
135
|
+
const cached = readCache();
|
|
136
|
+
if (cached) {
|
|
137
|
+
const m = cached.models[key(provider, id)];
|
|
138
|
+
if (m)
|
|
139
|
+
return m;
|
|
140
|
+
}
|
|
141
|
+
const fb = fromFallback();
|
|
142
|
+
// Exact key, then bare-id match across providers.
|
|
143
|
+
return fb.models[key(provider, id)] ?? fb.models[key("openai", id)] ?? null;
|
|
144
|
+
}
|
|
145
|
+
/** contextWindow for the compaction budget (I-02 → H-10). Fallback 128k. */
|
|
146
|
+
export function contextWindowFor(provider, id) {
|
|
147
|
+
return lookupModel(provider, id)?.contextWindow ?? 128_000;
|
|
148
|
+
}
|
|
149
|
+
/** Catalog age in hours (for `scoder models --refresh` UX). -1 when no cache. */
|
|
150
|
+
export function cacheAgeHours() {
|
|
151
|
+
if (!existsSync(CACHE_PATH))
|
|
152
|
+
return -1;
|
|
153
|
+
try {
|
|
154
|
+
return (Date.now() - statSync(CACHE_PATH).mtimeMs) / 3_600_000;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return -1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** I6: TTL cho refresh() — 1h. */
|
|
161
|
+
const REFRESH_TTL_MS = 3_600_000;
|
|
162
|
+
/** Monotonic last-refresh stamp (process lifetime — tránh re-fetch mỗi lookup). */
|
|
163
|
+
let lastRefreshAt = 0;
|
|
164
|
+
/**
|
|
165
|
+
* I6: đảm bảo catalog tươi (TTL 1h) — process dùng lâu (server/TUI) gọi trước
|
|
166
|
+
* khi tra cứu. Cache còn mới → no-op. Lỗi network → giữ catalogue hiện có
|
|
167
|
+
* (không bao giờ throw). Returns source hiện hành.
|
|
168
|
+
*/
|
|
169
|
+
export async function refreshCatalog() {
|
|
170
|
+
if (Date.now() - lastRefreshAt < REFRESH_TTL_MS)
|
|
171
|
+
return "cache";
|
|
172
|
+
const r = await loadCatalog(false);
|
|
173
|
+
lastRefreshAt = Date.now();
|
|
174
|
+
return r.source;
|
|
175
|
+
}
|
package/dist/permissions.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Cung cấp 3 mode: auto (run everything), ask (prompt for shell commands), readonly.
|
|
6
6
|
* Luôn deny catastrophic commands bất kể mode nào.
|
|
7
7
|
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
8
9
|
import { createInterface } from "node:readline";
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Catastrophic command patterns — always denied, no exceptions
|
|
@@ -32,6 +33,7 @@ const CATASTROPHIC_PATTERNS = [
|
|
|
32
33
|
/chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
|
|
33
34
|
// chmod -R 777 on / or ~ (any flag spelling: -R, -r, -Rf, --recursive)
|
|
34
35
|
/\bchmod\s+(?:-{1,2}[\w-]+\s+)+[0-7]{3,4}\s+["']?[/~](?:\s|$|\/)/,
|
|
36
|
+
/\bchmod\s+--recursive\s+[0-7]{3,4}\s+\/(?!home\/)[^\n]*/,
|
|
35
37
|
// Redirect raw writes to block devices: > /dev/sda, > /dev/nvme0n1, dd > disk...
|
|
36
38
|
/>\s*\/dev\/(?:sd[a-z]+|hd[a-z]+|nvme\d+n\d+(?:p\d+)?|disk\d|rdisk\d)/i,
|
|
37
39
|
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/i,
|
|
@@ -49,10 +51,72 @@ const CATASTROPHIC_PATTERNS = [
|
|
|
49
51
|
/\|\s*bash\s+-i/i,
|
|
50
52
|
/sysctl\s+-w\s+kernel\.panic=/i,
|
|
51
53
|
/echo\s+[^|]*>\s*\/etc\/sudoers/i,
|
|
54
|
+
// H-13 additions: remote-code-exec variants + macOS host tampering
|
|
55
|
+
/curl\s+.*[/.].*\s*\|\s*python(?:\d)?\b/i,
|
|
56
|
+
/wget\s+.*[/.].*\s*\|\s*python(?:\d)?\b/i,
|
|
57
|
+
/\bpowershell(?:\.exe)?\s+(-enc|-e\b|-encodedcommand\b)/i,
|
|
58
|
+
/\bdscl\s+\.\s+-create\s+\/Users\//i,
|
|
59
|
+
/\bdefaults\s+write\s+com\.apple/i,
|
|
60
|
+
/\bmv\s+(-\w+\s+)*["']?~(?:\s|$|\/(?!Projects|work))/,
|
|
61
|
+
/\bcurl\s+.*[/.].*\s*\|\s*python3?\s+-c\b/i,
|
|
62
|
+
/\bperl\s+-e\b.*\bsystem\s*\(/i,
|
|
52
63
|
];
|
|
53
64
|
function isCatastrophic(command) {
|
|
54
65
|
return CATASTROPHIC_PATTERNS.some((p) => p.test(command));
|
|
55
66
|
}
|
|
67
|
+
/** Wildcard match: supports * (any run) and ? (single char). */
|
|
68
|
+
export function wildcardMatch(pattern, value) {
|
|
69
|
+
if (pattern === "*")
|
|
70
|
+
return true;
|
|
71
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
72
|
+
// Literal match — also accept prefix semantics for shell resources
|
|
73
|
+
// ("npm " allows "npm run build") to stay compatible with legacy lists.
|
|
74
|
+
return value === pattern || value.startsWith(pattern);
|
|
75
|
+
}
|
|
76
|
+
const rx = new RegExp("^" +
|
|
77
|
+
pattern
|
|
78
|
+
.split("")
|
|
79
|
+
.map((c) => c === "*" ? "[\\s\\S]*" : c === "?" ? "." : c.replace(/[.+^${}()|[\]\\]/g, "\\$&"))
|
|
80
|
+
.join("") +
|
|
81
|
+
"$");
|
|
82
|
+
return rx.test(value);
|
|
83
|
+
}
|
|
84
|
+
export function matchRule(rules, action, resource) {
|
|
85
|
+
let effect = null;
|
|
86
|
+
for (const r of rules) {
|
|
87
|
+
// Action match: exact, or a wildcard action pattern (e.g. "mcp__prompt-gate__*").
|
|
88
|
+
if (r.action !== action && !wildcardMatch(r.action, action))
|
|
89
|
+
continue;
|
|
90
|
+
if (!wildcardMatch(r.resource, resource))
|
|
91
|
+
continue;
|
|
92
|
+
effect = r.effect; // last match wins
|
|
93
|
+
}
|
|
94
|
+
return effect;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Aggregate a multi-resource operation (a shell command produces several
|
|
98
|
+
* subcommand resources): deny wins over ask wins over allow; null when no
|
|
99
|
+
* rule matched at all.
|
|
100
|
+
*/
|
|
101
|
+
export function aggregate(effects) {
|
|
102
|
+
if (effects.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
let sawAsk = false;
|
|
105
|
+
let sawAllow = false;
|
|
106
|
+
for (const e of effects) {
|
|
107
|
+
if (e === "deny")
|
|
108
|
+
return "deny";
|
|
109
|
+
if (e === "ask")
|
|
110
|
+
sawAsk = true;
|
|
111
|
+
if (e === "allow")
|
|
112
|
+
sawAllow = true;
|
|
113
|
+
}
|
|
114
|
+
if (sawAsk)
|
|
115
|
+
return "ask";
|
|
116
|
+
if (sawAllow)
|
|
117
|
+
return "allow";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
56
120
|
export function isDangerousCommand(command) {
|
|
57
121
|
return isCatastrophic(command);
|
|
58
122
|
}
|
|
@@ -82,40 +146,22 @@ function extractFirstWord(command) {
|
|
|
82
146
|
/**
|
|
83
147
|
* Classify a command given current permission config and session allowlist.
|
|
84
148
|
*/
|
|
149
|
+
/**
|
|
150
|
+
* G2: classifyCommand đã bị xoá — dùng `PermissionChecker.classify()`.
|
|
151
|
+
* Giữ lại chỉ vì test cũ; body chuyển hướng sang checker thuần (không
|
|
152
|
+
* config lists — G2 lists là builtin rules trong checker).
|
|
153
|
+
*/
|
|
85
154
|
export function classifyCommand(command, mode, configAllow, configDeny, sessionAllow) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
// 3. Session allowlist
|
|
96
|
-
if (sessionAllow.hasMatch(command)) {
|
|
155
|
+
// Reconstruct semantics qua rule engine: lists → builtin rules.
|
|
156
|
+
const checker = new PermissionChecker({
|
|
157
|
+
mode,
|
|
158
|
+
allowCommands: configAllow,
|
|
159
|
+
denyCommands: configDeny,
|
|
160
|
+
nonInteractive: true,
|
|
161
|
+
});
|
|
162
|
+
if (sessionAllow.hasMatch(command))
|
|
97
163
|
return { decision: "allow", reason: "allowed by session allowlist" };
|
|
98
|
-
|
|
99
|
-
// 4. Config allow list
|
|
100
|
-
if (configAllow.some((p) => command.startsWith(p.trim()))) {
|
|
101
|
-
return { decision: "allow", reason: "allowed by config allowCommands" };
|
|
102
|
-
}
|
|
103
|
-
// 5. Mode-dependent
|
|
104
|
-
switch (mode) {
|
|
105
|
-
case "auto":
|
|
106
|
-
return { decision: "allow", reason: "auto mode — run without confirmation" };
|
|
107
|
-
case "readonly":
|
|
108
|
-
return {
|
|
109
|
-
decision: "deny",
|
|
110
|
-
reason: "readonly mode — shell commands are disabled",
|
|
111
|
-
};
|
|
112
|
-
case "ask":
|
|
113
|
-
return {
|
|
114
|
-
decision: "ask",
|
|
115
|
-
reason: "requires user confirmation",
|
|
116
|
-
alwaysAllowPrefix: safeFirst + " ",
|
|
117
|
-
};
|
|
118
|
-
}
|
|
164
|
+
return checker.classify(command);
|
|
119
165
|
}
|
|
120
166
|
/**
|
|
121
167
|
* Prompt user for permission. Returns true/false. "always" is signaled via
|
|
@@ -195,19 +241,212 @@ export async function promptForPermission(opts, onAlwaysAllow) {
|
|
|
195
241
|
catch { /* already closed */ }
|
|
196
242
|
}
|
|
197
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* G2: legacy allow/deny prefix lists → builtin rules (last-match-wins:
|
|
246
|
+
* deny list đặt sau allow list để deny luôn thắng khi cả hai khớp).
|
|
247
|
+
* Được append SAU user rules trong activeRules() — giữ nguyên ngữ nghĩa cũ
|
|
248
|
+
* (config lists override rules cho prefix trùng).
|
|
249
|
+
*/
|
|
250
|
+
function builtinRulesFromLists(allow, deny) {
|
|
251
|
+
const toRules = (list, effect) => list.filter((p) => p.trim().length > 0).map((p) => ({ action: "shell", resource: p.trim(), effect }));
|
|
252
|
+
return [...toRules(allow, "allow"), ...toRules(deny, "deny")];
|
|
253
|
+
}
|
|
198
254
|
export class PermissionChecker {
|
|
199
255
|
config;
|
|
200
256
|
sessionAllow = new SessionAllowlist();
|
|
201
257
|
allowAllEdits = false;
|
|
258
|
+
/**
|
|
259
|
+
* T-03: programmatic prompt hook (TUI). When set, confirm/confirmEdit/
|
|
260
|
+
* confirmAsk route through it instead of readline stdin (which corrupts
|
|
261
|
+
* alt-screen). Returns a Promise resolved by the frontend modal.
|
|
262
|
+
*/
|
|
263
|
+
promptHandler = null;
|
|
202
264
|
constructor(config) {
|
|
203
265
|
this.config = config;
|
|
204
266
|
}
|
|
267
|
+
/** Dispatch to promptHandler with timeout ⇒ deny (safe default). */
|
|
268
|
+
async ask(req) {
|
|
269
|
+
if (!this.promptHandler)
|
|
270
|
+
return false; // no handler in alt-screen = deny
|
|
271
|
+
try {
|
|
272
|
+
// T3: timeout enforced HERE (checker-level) — a hung frontend handler
|
|
273
|
+
// (TUI modal never answered, server client disconnected) must not leak
|
|
274
|
+
// the pending permission promise forever.
|
|
275
|
+
const answer = await Promise.race([
|
|
276
|
+
this.promptHandler(req),
|
|
277
|
+
new Promise((_, rej) => setTimeout(() => {
|
|
278
|
+
const e = new Error("permission handler timed out");
|
|
279
|
+
e.name = "AbortError";
|
|
280
|
+
rej(e);
|
|
281
|
+
}, req.timeoutSecs * 1000)),
|
|
282
|
+
]);
|
|
283
|
+
if (answer === "quit")
|
|
284
|
+
this.abortRequested = true;
|
|
285
|
+
if (answer === "always" && req.kind === "edit")
|
|
286
|
+
this.allowAllEdits = true;
|
|
287
|
+
if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
|
|
288
|
+
this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
|
|
289
|
+
}
|
|
290
|
+
return answer === "yes" || answer === "always";
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return false; // handler error/timeout — deny
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Rules for one evaluation: config rules + saved rules (config wins on deny). */
|
|
297
|
+
activeRules() {
|
|
298
|
+
// G2: legacy allow/deny lists trở thành builtin rules (priority thấp
|
|
299
|
+
// nhất — user/saved rules override). Deny list sau allow list trong
|
|
300
|
+
// builtin để deny thắng khi cả hai khớp (last-match-wins).
|
|
301
|
+
return [
|
|
302
|
+
...builtinRulesFromLists(this.config.allowCommands, this.config.denyCommands),
|
|
303
|
+
...(this.config.savedRules ?? []),
|
|
304
|
+
...(this.config.rules ?? []),
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Policy hard-floor check: policy deny wins over everything except the
|
|
309
|
+
* catastrophic list; policy allow bypasses ask-requirements.
|
|
310
|
+
*/
|
|
311
|
+
policyEffect(action, resource) {
|
|
312
|
+
if (!this.config.policyRules || this.config.policyRules.length === 0)
|
|
313
|
+
return null;
|
|
314
|
+
return matchRule(this.config.policyRules, action, resource);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Rule-engine classify for a (action, resource) pair.
|
|
318
|
+
* Order: config/saved rules (last match wins) → mode fallback.
|
|
319
|
+
* Returns null when nothing matched (caller decides via mode).
|
|
320
|
+
*/
|
|
321
|
+
classifyAction(action, resource) {
|
|
322
|
+
const effect = matchRule(this.activeRules(), action, resource);
|
|
323
|
+
if (effect === "deny")
|
|
324
|
+
return { decision: "deny", reason: `denied by permission rule (${action}: ${resource})` };
|
|
325
|
+
if (effect === "allow")
|
|
326
|
+
return { decision: "allow", reason: `allowed by permission rule (${action}: ${resource})` };
|
|
327
|
+
if (effect === "ask")
|
|
328
|
+
return { decision: "ask", reason: `requires approval by permission rule (${action}: ${resource})` };
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
205
331
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
332
|
+
* G5: unified MCP tool decision — policy floor → rules → null (caller
|
|
333
|
+
* falls back to mode). Every mcp__* call goes through here; no bypass.
|
|
208
334
|
*/
|
|
335
|
+
classifyMcpTool(fullName) {
|
|
336
|
+
const policy = this.policyEffect(fullName, "*");
|
|
337
|
+
if (policy === "deny")
|
|
338
|
+
return { decision: "deny", reason: "denied by policy (hard floor)" };
|
|
339
|
+
if (policy === "allow")
|
|
340
|
+
return { decision: "allow", reason: "allowed by policy" };
|
|
341
|
+
return this.classifyAction(fullName, "*");
|
|
342
|
+
}
|
|
343
|
+
/** Shell decision: policy floor → rules → legacy lists/mode. */
|
|
209
344
|
classify(command) {
|
|
210
|
-
|
|
345
|
+
// Catastrophic deny is the absolute floor.
|
|
346
|
+
if (isCatastrophic(command)) {
|
|
347
|
+
return { decision: "deny", reason: "matches catastrophic command pattern" };
|
|
348
|
+
}
|
|
349
|
+
// Policy deny wins over everything (enterprise hard floor).
|
|
350
|
+
const policy = this.policyEffect("shell", command);
|
|
351
|
+
if (policy === "deny")
|
|
352
|
+
return { decision: "deny", reason: "denied by policy (hard floor)" };
|
|
353
|
+
if (policy === "allow") {
|
|
354
|
+
const deny = matchRule(this.activeRules(), "shell", command);
|
|
355
|
+
if (deny === "deny")
|
|
356
|
+
return { decision: "deny", reason: "denied by permission rule" };
|
|
357
|
+
return { decision: "allow", reason: "allowed by policy" };
|
|
358
|
+
}
|
|
359
|
+
if (policy === "ask") {
|
|
360
|
+
const deny = matchRule(this.activeRules(), "shell", command);
|
|
361
|
+
if (deny === "deny")
|
|
362
|
+
return { decision: "deny", reason: "denied by permission rule" };
|
|
363
|
+
const allow = matchRule(this.activeRules(), "shell", command);
|
|
364
|
+
if (allow === "allow")
|
|
365
|
+
return { decision: "allow", reason: "allowed by permission rule" };
|
|
366
|
+
return { decision: "ask", reason: "requires approval by policy", alwaysAllowPrefix: extractFirstWord(command) + " " };
|
|
367
|
+
}
|
|
368
|
+
// G2: một đường duy nhất — rules (bao gồm builtin từ allow/deny lists,
|
|
369
|
+
// last-match-wins) → mode fallback. Session allowlist map thành rule allow.
|
|
370
|
+
if (this.sessionAllow.hasMatch(command)) {
|
|
371
|
+
return { decision: "allow", reason: "allowed by session allowlist" };
|
|
372
|
+
}
|
|
373
|
+
const ruleEffect = matchRule(this.activeRules(), "shell", command);
|
|
374
|
+
if (ruleEffect === "allow")
|
|
375
|
+
return { decision: "allow", reason: "allowed by permission rule" };
|
|
376
|
+
if (ruleEffect === "deny")
|
|
377
|
+
return { decision: "deny", reason: "denied by permission rule" };
|
|
378
|
+
if (ruleEffect === "ask") {
|
|
379
|
+
return { decision: "ask", reason: "requires approval by permission rule", alwaysAllowPrefix: extractFirstWord(command) + " " };
|
|
380
|
+
}
|
|
381
|
+
switch (this.config.mode) {
|
|
382
|
+
case "auto":
|
|
383
|
+
return { decision: "allow", reason: "auto mode — run without confirmation" };
|
|
384
|
+
case "readonly":
|
|
385
|
+
return { decision: "deny", reason: "readonly mode — shell commands are disabled" };
|
|
386
|
+
case "ask":
|
|
387
|
+
return { decision: "ask", reason: "requires user confirmation", alwaysAllowPrefix: extractFirstWord(command) + " " };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Shell decision with AST scanner (pattern from opencode: every extracted
|
|
392
|
+
* subcommand is checked individually; scanner errors ⇒ ask, never execute
|
|
393
|
+
* unverified). Falls back to whole-line rules when the scanner is
|
|
394
|
+
* unavailable (e.g. native module failed to load).
|
|
395
|
+
*/
|
|
396
|
+
classifyShell(command) {
|
|
397
|
+
// Catastrophic deny is the absolute floor — before scanner/splitting.
|
|
398
|
+
if (isCatastrophic(command)) {
|
|
399
|
+
return { decision: "deny", reason: "matches catastrophic command pattern" };
|
|
400
|
+
}
|
|
401
|
+
let scan;
|
|
402
|
+
try {
|
|
403
|
+
// Lazy require so a broken native build degrades instead of crashing.
|
|
404
|
+
// (ESM dist — plain `require` is undefined; use createRequire.)
|
|
405
|
+
const req = createRequire(import.meta.url);
|
|
406
|
+
const { scanCommand } = req("./shell-scanner.js");
|
|
407
|
+
scan = scanCommand(command);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
scan = null; // scanner unavailable — fall back to whole-line behavior
|
|
411
|
+
}
|
|
412
|
+
if (scan && !scan.ok) {
|
|
413
|
+
return {
|
|
414
|
+
decision: "ask",
|
|
415
|
+
reason: `shell scanner: ${scan.error}`,
|
|
416
|
+
alwaysAllowPrefix: extractFirstWord(command) + " ",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (scan && scan.ok && scan.commandLines.length > 1) {
|
|
420
|
+
// Compound command — aggregate per-subcommand rule effects.
|
|
421
|
+
const effects = scan.commandLines.map((l) => {
|
|
422
|
+
const e = matchRule(this.activeRules(), "shell", l);
|
|
423
|
+
if (e)
|
|
424
|
+
return e;
|
|
425
|
+
// No rule for this subcommand → fall back to whole-line decision chain
|
|
426
|
+
// to keep legacy behavior (allowCommands/denyCommands prefixes).
|
|
427
|
+
return this.classify(l).decision;
|
|
428
|
+
});
|
|
429
|
+
const agg = aggregate(effects);
|
|
430
|
+
if (agg) {
|
|
431
|
+
return {
|
|
432
|
+
decision: agg,
|
|
433
|
+
reason: `compound command (${scan.commandLines.length} subcommands) — aggregated: ${agg}`,
|
|
434
|
+
alwaysAllowPrefix: agg === "ask" ? extractFirstWord(command) + " " : undefined,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Dynamic eval without a rule → ask (never execute unverified dynamic code).
|
|
439
|
+
if (scan?.ok && scan.hasDynamicEval) {
|
|
440
|
+
const e = matchRule(this.activeRules(), "shell", command);
|
|
441
|
+
if (e !== "allow") {
|
|
442
|
+
return {
|
|
443
|
+
decision: "ask",
|
|
444
|
+
reason: "dynamic evaluation (eval/source) — approval required",
|
|
445
|
+
alwaysAllowPrefix: extractFirstWord(command) + " ",
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return this.classify(command);
|
|
211
450
|
}
|
|
212
451
|
/**
|
|
213
452
|
* Ask user interactively. Returns true = allowed, false = denied.
|
|
@@ -224,6 +463,16 @@ export class PermissionChecker {
|
|
|
224
463
|
// In non-interactive (CI), deny commands that need asking
|
|
225
464
|
return false;
|
|
226
465
|
}
|
|
466
|
+
// T-03: programmatic handler (TUI) — no stdin readline.
|
|
467
|
+
if (this.promptHandler) {
|
|
468
|
+
return this.ask({
|
|
469
|
+
kind: "shell",
|
|
470
|
+
label: command.length > 80 ? command.slice(0, 80) + "..." : command,
|
|
471
|
+
command,
|
|
472
|
+
alwaysAllowPrefix: result.alwaysAllowPrefix,
|
|
473
|
+
timeoutSecs: 60,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
227
476
|
return promptForPermission({
|
|
228
477
|
command,
|
|
229
478
|
alwaysAllowPrefix: result.alwaysAllowPrefix,
|
|
@@ -240,9 +489,30 @@ export class PermissionChecker {
|
|
|
240
489
|
async confirmEdit(path, diffText, timeoutSecs = 120) {
|
|
241
490
|
if (this.allowAllEdits)
|
|
242
491
|
return true;
|
|
492
|
+
// Rule engine for edit action (deny wins, ask prompts, allow skips).
|
|
493
|
+
const ruleDecision = this.classifyAction("edit", path);
|
|
494
|
+
if (ruleDecision?.decision === "deny")
|
|
495
|
+
return false;
|
|
496
|
+
if (ruleDecision?.decision === "allow")
|
|
497
|
+
return true;
|
|
498
|
+
// Policy floor for edit.
|
|
499
|
+
const policy = this.policyEffect("edit", path);
|
|
500
|
+
if (policy === "deny")
|
|
501
|
+
return false;
|
|
502
|
+
if (policy === "allow")
|
|
503
|
+
return true;
|
|
243
504
|
if (this.config.nonInteractive) {
|
|
244
505
|
return false;
|
|
245
506
|
}
|
|
507
|
+
// T-03: programmatic handler (TUI) — modal shows the diff.
|
|
508
|
+
if (this.promptHandler) {
|
|
509
|
+
return this.ask({
|
|
510
|
+
kind: "edit",
|
|
511
|
+
label: path,
|
|
512
|
+
diff: diffText.length > 4000 ? diffText.slice(0, 4000) + "\n… (truncated)" : diffText,
|
|
513
|
+
timeoutSecs,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
246
516
|
const ac = new AbortController();
|
|
247
517
|
const timer = setTimeout(() => ac.abort(), timeoutSecs * 1000);
|
|
248
518
|
const rl = createInterface({ input: process.stdin, output: process.stdout, signal: ac.signal });
|
|
@@ -293,6 +563,29 @@ export class PermissionChecker {
|
|
|
293
563
|
catch { /* already closed */ }
|
|
294
564
|
}
|
|
295
565
|
}
|
|
566
|
+
/** Ask user to approve an MCP tool call; "always" persists via onAlways callback. */
|
|
567
|
+
async confirmMcpTool(fullName, onAlwaysAllow) {
|
|
568
|
+
return this.confirmAsk(`MCP tool ${fullName}`, onAlwaysAllow);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Generic interactive ask (used for MCP tool approval etc.). Reuses the
|
|
572
|
+
* same readline prompt as shell confirm. onAlways is invoked when the
|
|
573
|
+
* user answers "a" (persist a saved approval).
|
|
574
|
+
*/
|
|
575
|
+
async confirmAsk(label, onAlways) {
|
|
576
|
+
if (this.config.nonInteractive)
|
|
577
|
+
return false;
|
|
578
|
+
// T-03: programmatic handler (TUI).
|
|
579
|
+
if (this.promptHandler) {
|
|
580
|
+
const ok = await this.ask({ kind: "mcp", label, timeoutSecs: 60 });
|
|
581
|
+
if (ok && onAlways)
|
|
582
|
+
onAlways();
|
|
583
|
+
return ok;
|
|
584
|
+
}
|
|
585
|
+
return promptForPermission({ command: label, alwaysAllowPrefix: undefined },
|
|
586
|
+
// promptForPermission passes a prefix; we only need the "a" signal.
|
|
587
|
+
() => onAlways?.());
|
|
588
|
+
}
|
|
296
589
|
/** Set when the user pressed [q]uit in confirmEdit — the agent should stop. */
|
|
297
590
|
abortRequested = false;
|
|
298
591
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* I3: Prompt builder — lắp system prompt từ các phần: base + env snapshot +
|
|
3
|
+
* skills advertised + subagents. Module thuần (chỉ spawnSync git), test được
|
|
4
|
+
* độc lập. Trước đây logic này rải trong agent.ts (~50 dòng).
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { formatSkillIndex } from "./skills.js";
|
|
8
|
+
/** One-line environment snapshot for the system prompt (cache-stable per run). */
|
|
9
|
+
export function buildEnvInfo(workdir) {
|
|
10
|
+
let git = "";
|
|
11
|
+
try {
|
|
12
|
+
const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: workdir, encoding: "utf8" });
|
|
13
|
+
if (branch.status === 0)
|
|
14
|
+
git = ` · git:${branch.stdout.trim()}`;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* git unavailable */
|
|
18
|
+
}
|
|
19
|
+
return `Environment: ${process.platform} · node ${process.version}${git} · today ${new Date().toISOString().slice(0, 10)}`;
|
|
20
|
+
}
|
|
21
|
+
/** Skills advertised in system prompt (passive listing only — agent does NOT auto-load). */
|
|
22
|
+
export function buildSkillsBlock(skills) {
|
|
23
|
+
if (skills.length === 0)
|
|
24
|
+
return "";
|
|
25
|
+
return (`\n\nAVAILABLE SKILLS (load with /skill <name> or when the task clearly matches a skill domain):\n${formatSkillIndex(skills)}\n` +
|
|
26
|
+
"Only load a skill when the user explicitly requests it with /skill, or when the task description clearly matches a skill's domain. Do NOT load skills merely because they are listed.");
|
|
27
|
+
}
|
|
28
|
+
/** Subagents khả dụng (task tool) — chỉ khi agent được phép spawn. */
|
|
29
|
+
export function buildAgentsBlock(agents, allowSubagents) {
|
|
30
|
+
const usable = agents.filter((a) => a.mode !== "subagent" || a.name === "explore");
|
|
31
|
+
if (!allowSubagents || usable.length === 0)
|
|
32
|
+
return "";
|
|
33
|
+
return (`\n\nSUBAGENTS (via the task tool — delegate focused sub-work):\n` +
|
|
34
|
+
usable.map((a) => ` ${a.name.padEnd(12)} ${a.description}`).join("\n") +
|
|
35
|
+
"\nDelegate exploration or planning when it keeps the main loop focused.");
|
|
36
|
+
}
|
|
37
|
+
/** MCP servers advertised in system prompt (passive listing only — never auto-used). */
|
|
38
|
+
export function buildMcpBlock(config) {
|
|
39
|
+
const servers = Object.keys(config.mcpServers ?? {});
|
|
40
|
+
if (servers.length === 0)
|
|
41
|
+
return "";
|
|
42
|
+
return (`\n\nMCP SERVERS (available tools — use mcp__<server>__<tool> ONLY when the task explicitly requires external integration):\n` +
|
|
43
|
+
servers.map((n) => `- ${n}`).join("\n") +
|
|
44
|
+
"\nDo NOT call MCP tools unless the user explicitly asks for external integration or the task requires it.");
|
|
45
|
+
}
|
|
46
|
+
/** Ghép phần động vào system prompt gốc (buildSystemPrompt giữ ở prompt.ts). */
|
|
47
|
+
export { buildSystemPrompt } from "./prompt.js";
|