@steerable/agent-shell 0.6.74 → 0.7.2

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 (43) hide show
  1. package/dist/fence-path.d.ts +16 -0
  2. package/dist/fence-path.js +54 -0
  3. package/dist/host/runtime.d.ts +0 -13
  4. package/dist/host/runtime.js +26 -0
  5. package/dist/host-tools.d.ts +1 -0
  6. package/dist/host-tools.js +1 -0
  7. package/dist/i18n.d.ts +2 -0
  8. package/dist/i18n.js +6 -1
  9. package/dist/json-store.d.ts +1 -0
  10. package/dist/json-store.js +50 -4
  11. package/dist/local-backend/coreloop-stream.d.ts +29 -5
  12. package/dist/local-backend/coreloop-stream.js +122 -1
  13. package/dist/local-backend/fork-copy.d.ts +9 -0
  14. package/dist/local-backend/fork-copy.js +75 -0
  15. package/dist/local-backend/history-edit.d.ts +12 -2
  16. package/dist/local-backend/history-edit.js +98 -4
  17. package/dist/local-backend/router.d.ts +1 -0
  18. package/dist/local-backend/router.js +189 -53
  19. package/dist/local-backend/task-process.js +5 -0
  20. package/dist/local-backend/task-service.js +6 -7
  21. package/dist/local-backend/turn-files.js +3 -1
  22. package/dist/local-executor.d.ts +1 -3
  23. package/dist/local-executor.js +10 -13
  24. package/dist/product-config.d.ts +7 -1
  25. package/dist/project-registry.d.ts +33 -0
  26. package/dist/project-registry.js +206 -78
  27. package/dist/project-rules.d.ts +2 -0
  28. package/dist/project-rules.js +17 -13
  29. package/dist/scenario/pack.d.ts +1 -1
  30. package/dist/sidecar/approval-label.d.ts +55 -0
  31. package/dist/sidecar/approval-label.js +102 -0
  32. package/dist/sidecar/reverse-approval.d.ts +26 -0
  33. package/dist/sidecar/reverse-approval.js +15 -4
  34. package/dist/sidecar/types.d.ts +14 -0
  35. package/dist/storage/index.d.ts +8 -1
  36. package/dist/storage/index.js +7 -2
  37. package/dist/storage/process-locks.d.ts +6 -0
  38. package/dist/storage/process-locks.js +6 -0
  39. package/dist/storage/scoped-store.d.ts +1 -1
  40. package/dist/storage/sqlite-driver.js +6 -5
  41. package/dist/tool-router.d.ts +22 -9
  42. package/dist/tool-router.js +43 -29
  43. package/package.json +4 -4
@@ -0,0 +1,102 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ import { canonicalFencePath, canonicalPathInside } from '../fence-path.js';
4
+ const COMMAND_TOOLS = new Set(['local_exec_shell', 'local_run_snippet']);
5
+ // Only the shell starts an explicit cwd outside the writable roots. A snippet
6
+ // is rejected before it runs, so its card must not say the command still runs.
7
+ const OUTSIDE_CWD_TOOLS = new Set(['local_exec_shell']);
8
+ const BUILTIN_CATEGORY_LABEL = {
9
+ network_egress: 'Internet access',
10
+ sandbox_escalation: 'Sandbox',
11
+ };
12
+ /**
13
+ * Display name for an approval category.
14
+ * Pack declarations win. Built-in categories and shell tools use the shell dictionary.
15
+ *
16
+ * @param input Locale, category, and the tool's declared labels.
17
+ * @returns A label for the card, or undefined when the category is just the tool name.
18
+ */
19
+ export function resolveApprovalCategoryLabel(input) {
20
+ const declared = declaredLabel(input.declaredLabel, input.locale);
21
+ if (declared)
22
+ return declared;
23
+ const builtin = BUILTIN_CATEGORY_LABEL[input.category];
24
+ if (builtin)
25
+ return input.translate(builtin);
26
+ if (COMMAND_TOOLS.has(input.toolName) || COMMAND_TOOLS.has(input.category))
27
+ return input.translate('Command');
28
+ return undefined;
29
+ }
30
+ /** English source string for the approval card. The web view translates it. */
31
+ export function hostApprovalCategoryLabel(input) {
32
+ return resolveApprovalCategoryLabel({
33
+ locale: 'en',
34
+ category: input.category,
35
+ toolName: input.toolName,
36
+ ...(input.declaredLabel ? { declaredLabel: input.declaredLabel } : {}),
37
+ translate: (message) => message,
38
+ });
39
+ }
40
+ /**
41
+ * Which approval sentence to show when `local_exec_shell` names a cwd
42
+ * outside the writable root. A bound project uses its folder and source
43
+ * folders. A chat without a project uses the conversation workspace.
44
+ *
45
+ * @param input Tool name, cwd, and the chat's project or workspace root.
46
+ * @returns `false` when the card should not mention an outside directory.
47
+ */
48
+ export function approvalCwdFence(input) {
49
+ const bound = Boolean(input.projectRoot);
50
+ const outside = shellCwdOutsideProject({
51
+ toolName: input.toolName,
52
+ cwd: input.cwd,
53
+ projectRoot: input.projectRoot ?? input.workspaceRoot,
54
+ sourceFolders: bound ? input.sourceFolders : [],
55
+ ...(input.home ? { home: input.home } : {}),
56
+ });
57
+ if (!outside)
58
+ return false;
59
+ return bound ? 'project' : 'workspace';
60
+ }
61
+ /**
62
+ * True when `local_exec_shell` names a cwd outside the given root and its source folders.
63
+ *
64
+ * The root is the project folder, or the conversation workspace when the chat
65
+ * has no project. `home` defaults to the process home directory, the same
66
+ * directory the shell fence uses when it expands `~`.
67
+ *
68
+ * @param input Tool name, cwd argument, and the chat's writable folders.
69
+ * @returns Whether that cwd is outside those folders.
70
+ */
71
+ export function shellCwdOutsideProject(input) {
72
+ if (!OUTSIDE_CWD_TOOLS.has(input.toolName))
73
+ return false;
74
+ if (!input.projectRoot || typeof input.cwd !== 'string' || !input.cwd.trim())
75
+ return false;
76
+ const target = resolveCwd(input.cwd.trim(), input.projectRoot, input.home ?? homedir());
77
+ const roots = [input.projectRoot, ...input.sourceFolders];
78
+ return !roots.some((root) => canonicalPathInside(root, target));
79
+ }
80
+ function declaredLabel(label, locale) {
81
+ if (!label)
82
+ return undefined;
83
+ const exact = label[locale];
84
+ if (exact)
85
+ return exact;
86
+ const normalized = locale.trim().toLowerCase().replace(/_/g, '-');
87
+ if (normalized === 'zh' || normalized.startsWith('zh-')) {
88
+ return label['zh-CN'] || label.zh || label['zh-Hans'];
89
+ }
90
+ if (normalized === 'en' || normalized.startsWith('en-'))
91
+ return label.en;
92
+ return label.en;
93
+ }
94
+ function resolveCwd(cwd, projectRoot, home) {
95
+ // Same `~` rule as ToolRouter.applyProjectCwdSandbox: slice(1) keeps the
96
+ // separator, and path.join folds it into the home directory. The fence
97
+ // comparison uses the real path, so `/tmp` and `/private/tmp` match.
98
+ const expanded = cwd.startsWith('~') ? path.join(home, cwd.slice(1)) : cwd;
99
+ const root = canonicalFencePath(path.resolve(projectRoot));
100
+ const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
101
+ return canonicalFencePath(resolved);
102
+ }
@@ -31,6 +31,20 @@ export interface ApprovalPromptRequest {
31
31
  round: number;
32
32
  /** Chat that asked. The renderer only shows the card in that composer. */
33
33
  chatId?: string;
34
+ /** Localized category name. Absent when the category is the tool name. */
35
+ categoryLabel?: string;
36
+ /** Shell cwd is outside the chat project's writable folders. */
37
+ outsideWorkspace?: boolean;
38
+ /**
39
+ * `workspace` when the chat has no project and the cwd is outside the
40
+ * conversation workspace. Absent means the project sentence.
41
+ */
42
+ outsideWorkspaceKind?: 'workspace';
43
+ /**
44
+ * Live command sandbox. `false` means the card must not say writes are
45
+ * blocked. Absent keeps that sentence for an older sidecar.
46
+ */
47
+ sandboxEnforced?: boolean;
34
48
  }
35
49
  export interface ApprovalBridgeDeps {
36
50
  /**
@@ -42,6 +56,18 @@ export interface ApprovalBridgeDeps {
42
56
  /** True when at least one renderer window can receive the prompt. */
43
57
  hasWindow: () => boolean;
44
58
  onLog?: (line: string) => void;
59
+ /** Localized category name from the tool declaration or the shell dictionary. */
60
+ categoryLabel?: (toolName: string, category: string) => string | undefined;
61
+ /**
62
+ * Whether a shell cwd sits outside the writable root.
63
+ * `true` and `'project'` use the project sentence. `'workspace'` uses the
64
+ * conversation-workspace sentence. `false` adds no outside-cwd sentence.
65
+ */
66
+ outsideWorkspace?: (input: {
67
+ toolName: string;
68
+ arguments: Record<string, unknown>;
69
+ chatId?: string;
70
+ }) => boolean | 'project' | 'workspace' | Promise<boolean | 'project' | 'workspace'>;
45
71
  }
46
72
  export interface ApprovalBridge {
47
73
  /** Reverse-channel handler for the sidecar's `approval.request`. */
@@ -44,16 +44,27 @@ export function createApprovalBridge(deps) {
44
44
  }
45
45
  const requestId = randomUUID();
46
46
  const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
47
+ const args = p.arguments && typeof p.arguments === 'object'
48
+ ? p.arguments
49
+ : {};
50
+ const category = typeof p.category === 'string' ? p.category : toolName;
51
+ const categoryLabel = deps.categoryLabel?.(toolName, category);
52
+ const fence = deps.outsideWorkspace
53
+ ? await deps.outsideWorkspace({ toolName, arguments: args, ...(chatId ? { chatId } : {}) })
54
+ : false;
55
+ const outsideWorkspace = fence === true || fence === 'project' || fence === 'workspace';
47
56
  const prompt = {
48
57
  requestId,
49
58
  toolName,
50
- arguments: p.arguments && typeof p.arguments === 'object'
51
- ? p.arguments
52
- : {},
59
+ arguments: args,
53
60
  mode: typeof p.mode === 'string' ? p.mode : 'other',
54
- category: typeof p.category === 'string' ? p.category : toolName,
61
+ category,
55
62
  round: typeof p.round === 'number' ? p.round : 0,
56
63
  ...(chatId ? { chatId } : {}),
64
+ ...(categoryLabel ? { categoryLabel } : {}),
65
+ ...(outsideWorkspace ? { outsideWorkspace: true } : {}),
66
+ ...(fence === 'workspace' ? { outsideWorkspaceKind: 'workspace' } : {}),
67
+ ...(typeof p.sandboxEnforced === 'boolean' ? { sandboxEnforced: p.sandboxEnforced } : {}),
57
68
  };
58
69
  deps.onLog?.(`approval: prompting for ${toolName} (${prompt.mode}/${prompt.category})`);
59
70
  return await new Promise((resolve) => {
@@ -470,6 +470,13 @@ export interface SidecarChatStreamRequest {
470
470
  * host prompt. Independent of whether the OS exec sandbox is on.
471
471
  */
472
472
  writableRoots?: string[];
473
+ /**
474
+ * Directory relative tool paths are joined to before the inside-root
475
+ * check. This is the project home, or the conversation workspace when
476
+ * the chat has no project. Absent, a relative path is inside unless it
477
+ * starts with `..`.
478
+ */
479
+ pathBase?: string;
473
480
  };
474
481
  }
475
482
  /**
@@ -524,6 +531,13 @@ export interface SidecarStreamChunk {
524
531
  backend?: string;
525
532
  enforcement: string;
526
533
  };
534
+ /** Session-allow reuse. `cached` means this call did not show a card. */
535
+ approval?: {
536
+ kind?: string;
537
+ category?: string;
538
+ cached?: boolean;
539
+ waitMs?: number;
540
+ };
527
541
  };
528
542
  /** CoreLoop notices: soft_timeout / budget_exhausted / round_end / hook_action. */
529
543
  notice?: {
@@ -19,6 +19,13 @@ export interface ChatSessionRecord {
19
19
  isPinned: boolean;
20
20
  systemPrompt: string | null;
21
21
  pinnedRefs: unknown[] | null;
22
+ /**
23
+ * 标题从哪来。`user` 表示用户改过名,自动标题不再覆盖(即使用户把标题改回「新对话」)。
24
+ * `auto` 表示上次由标题生成写入。缺省是旧库,仍按标题文本是否还是「新对话」判断。
25
+ */
26
+ titleSource?: 'user' | 'auto' | null;
27
+ /** 分叉来源会话 id。原会话已删除时仍保留 id,读取接口再决定是否展示。 */
28
+ forkedFrom?: string | null;
22
29
  }
23
30
  export interface ChatMessageRecord {
24
31
  id: string;
@@ -183,7 +190,7 @@ export declare class SqliteScopedStore implements ScopedStore {
183
190
  /** 删除项目时调用:把该项目下所有会话降级为无项目对话(不删会话)。 */
184
191
  clearProjectAssignment(projectId: string): Promise<number>;
185
192
  getChat(chatId: string): Promise<ChatSessionRecord | null>;
186
- updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId'>>): Promise<ChatSessionRecord | null>;
193
+ updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId' | 'titleSource' | 'forkedFrom'>>): Promise<ChatSessionRecord | null>;
187
194
  deleteChat(chatId: string): Promise<boolean>;
188
195
  chatHasMessages(chatId: string): Promise<boolean>;
189
196
  /**
@@ -322,6 +322,9 @@ export class SqliteScopedStore {
322
322
  // 项目记录本身存在 json-store(agent-projects.json),这里只存外键;
323
323
  // 删项目时由路由层把本列置 NULL(会话降级为无项目对话)。
324
324
  this.ensureColumn('chat_sessions', 'project_id', 'TEXT');
325
+ // 用户改名后自动标题不再覆盖。NULL 保持旧规则:标题不是「新对话」就跳过。
326
+ this.ensureColumn('chat_sessions', 'title_source', 'TEXT');
327
+ this.ensureColumn('chat_sessions', 'forked_from', 'TEXT');
325
328
  // 任务编排(增量迁移):depends_on 存 JSON string[]——本任务等待的
326
329
  // 依赖 taskId 列表;NULL = 无依赖。依赖必须指向建任务时已存在的
327
330
  // 任务,所以依赖图天然无环。
@@ -725,9 +728,9 @@ export class SqliteScopedStore {
725
728
  };
726
729
  this.db.prepare(`
727
730
  UPDATE chat_sessions
728
- SET title = ?, system_prompt = ?, pinned_refs = ?, is_pinned = ?, project_id = ?, updated_at = ?
731
+ SET title = ?, system_prompt = ?, pinned_refs = ?, is_pinned = ?, project_id = ?, title_source = ?, forked_from = ?, updated_at = ?
729
732
  WHERE tenant_id = ? AND user_id = ? AND id = ?
730
- `).run(next.title, next.systemPrompt, JSON.stringify(next.pinnedRefs ?? null), next.isPinned ? 1 : 0, next.projectId ?? null, next.updatedAt, this.scope.tenantId, this.scope.userId, chatId);
733
+ `).run(next.title, next.systemPrompt, JSON.stringify(next.pinnedRefs ?? null), next.isPinned ? 1 : 0, next.projectId ?? null, next.titleSource ?? null, next.forkedFrom ?? null, next.updatedAt, this.scope.tenantId, this.scope.userId, chatId);
731
734
  emitChatActivity({ chatId, updatedAt: next.updatedAt });
732
735
  return await this.getChat(chatId);
733
736
  }
@@ -1363,6 +1366,8 @@ export class SqliteScopedStore {
1363
1366
  isPinned: Number(row.is_pinned ?? 0) === 1,
1364
1367
  systemPrompt: row.system_prompt ? String(row.system_prompt) : null,
1365
1368
  pinnedRefs: row.pinned_refs ? this.safeJson(row.pinned_refs) : null,
1369
+ titleSource: row.title_source === 'user' || row.title_source === 'auto' ? row.title_source : null,
1370
+ forkedFrom: row.forked_from ? String(row.forked_from) : null,
1366
1371
  };
1367
1372
  }
1368
1373
  mapChatMessage(row) {
@@ -2,6 +2,12 @@ import { type HeldWriteLease } from './write-lease.js';
2
2
  export declare const HOST_SCHEMA_VERSION = 1;
3
3
  /** How long a starting host waits for another host to finish migrating. */
4
4
  export declare const OPEN_LOCK_WAIT_MS = 15000;
5
+ /**
6
+ * How long a new process waits while probing the old exclusive database lock.
7
+ * A current process releases that lock immediately. An older build holds it
8
+ * until it exits, so the wait still fails closed.
9
+ */
10
+ export declare const LEGACY_LOCK_PROBE_MS = 2000;
5
11
  export declare class ChatBusyError extends Error {
6
12
  readonly chatId: string;
7
13
  readonly code = "chat_busy";
@@ -10,6 +10,12 @@ import { acquireWriteLease, StoreAlreadyOwnedError, } from './write-lease.js';
10
10
  export const HOST_SCHEMA_VERSION = 1;
11
11
  /** How long a starting host waits for another host to finish migrating. */
12
12
  export const OPEN_LOCK_WAIT_MS = 15_000;
13
+ /**
14
+ * How long a new process waits while probing the old exclusive database lock.
15
+ * A current process releases that lock immediately. An older build holds it
16
+ * until it exits, so the wait still fails closed.
17
+ */
18
+ export const LEGACY_LOCK_PROBE_MS = 2_000;
13
19
  export class ChatBusyError extends Error {
14
20
  chatId;
15
21
  code = 'chat_busy';
@@ -16,7 +16,7 @@ export interface ScopedStore {
16
16
  createChatWithId(chatId: string, title?: string, agentId?: string | null, projectId?: string | null): Promise<ChatSessionRecord>;
17
17
  clearProjectAssignment(projectId: string): Promise<number>;
18
18
  getChat(chatId: string): Promise<ChatSessionRecord | null>;
19
- updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId'>>): Promise<ChatSessionRecord | null>;
19
+ updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId' | 'titleSource' | 'forkedFrom'>>): Promise<ChatSessionRecord | null>;
20
20
  deleteChat(chatId: string): Promise<boolean>;
21
21
  chatHasMessages(chatId: string): Promise<boolean>;
22
22
  deleteChatIfEmpty(chatId: string): Promise<boolean>;
@@ -5,7 +5,7 @@ import { getProductConfig } from '../product-config.js';
5
5
  import { getUserDataDir } from '../runtime.js';
6
6
  import { LOCAL_SCOPE } from './driver.js';
7
7
  import { SqliteScopedStore } from './index.js';
8
- import { HOST_SCHEMA_VERSION, OPEN_LOCK_WAIT_MS, StorageUpgradeBlockedError, openLockPath, } from './process-locks.js';
8
+ import { HOST_SCHEMA_VERSION, LEGACY_LOCK_PROBE_MS, OPEN_LOCK_WAIT_MS, StorageUpgradeBlockedError, openLockPath, } from './process-locks.js';
9
9
  import { acquireSharedLease, acquireWriteLease, lockPathForDb, StoreAlreadyOwnedError, } from './write-lease.js';
10
10
  import { acquireWriteLeaseOrExit } from './write-lease-error.js';
11
11
  const MAIN_DB_BUSY_TIMEOUT_MS = 5_000;
@@ -101,11 +101,12 @@ export class SqliteStorageDriver {
101
101
  if (this.db)
102
102
  return;
103
103
  const dbPath = path.join(getUserDataDir(), getProductConfig().dbFileName ?? 'agent-shell.db');
104
- // A previous build held this lock for the whole process. If it is still
105
- // held, that process cannot share the database, so refuse to migrate
106
- // or to open alongside it.
104
+ // A previous build held this lock for the whole process. A current
105
+ // process only holds it for this probe, so wait briefly for a peer
106
+ // probe to finish. If the lock is still held, that process cannot
107
+ // share the database.
107
108
  acquireWriteLeaseOrExit(() => {
108
- const legacy = acquireWriteLease(lockPathForDb(dbPath));
109
+ const legacy = acquireWriteLease(lockPathForDb(dbPath), LEGACY_LOCK_PROBE_MS);
109
110
  legacy.release();
110
111
  });
111
112
  const gate = openLockPath(dbPath);
@@ -4,7 +4,7 @@ import type { McpServerRegistry } from './mcp-server-registry.js';
4
4
  import type { ProjectRegistry } from './project-registry.js';
5
5
  import type { TaskService } from './local-backend/task-service.js';
6
6
  import type { WorktreeService } from './local-backend/worktree-service.js';
7
- import type { ToolContribution, ToolMode, ToolExposure } from './scenario/pack.js';
7
+ import type { ToolApprovalSpec, ToolContribution, ToolMode, ToolExposure } from './scenario/pack.js';
8
8
  export type { ToolMode, ToolExposure } from './scenario/pack.js';
9
9
  import { type AgentToolPolicy } from './local-backend/agent-capability.js';
10
10
  import { GoalStore } from './goal-store.js';
@@ -31,7 +31,9 @@ export interface ToolCall {
31
31
  * `projectRoot` 是项目模式的硬沙箱根:非空时
32
32
  * - 读路径必须落在根内,或落在 additionalReadRoots 内(含各自子目录)
33
33
  * - 写路径必须落在根内,或落在 additionalWriteRoots 内(含各自子目录)
34
- * - local_exec_shell 的 cwd 默认改为项目根;显式 cwd 必须落在上述可写根内
34
+ * - local_exec_shell 的 cwd 默认改为项目根;显式 cwd 可以落在可写根外,
35
+ * 命令仍启动。命令沙箱开着时,写入由 exec 沙箱拒绝;沙箱关掉时这次写入不会被沙箱拦住。
36
+ * pwsh 与 local_run_snippet 的 cwd 仍必须落在可写根内
35
37
  * 无项目对话(null/缺省)行为与之前完全一致。
36
38
  */
37
39
  export interface ToolExecContext {
@@ -67,7 +69,16 @@ export interface ToolSchema {
67
69
  mode: ToolMode;
68
70
  /** 曝光层;缺省 `direct`。MCP 动态工具默认 `deferred`。 */
69
71
  exposure?: ToolExposure;
72
+ /** 审批分组。缺省时 sidecar 用工具名做类别。 */
73
+ approval?: ToolApprovalSpec;
70
74
  }
75
+ /** 审批类别:小写开头,最长 64,且不占用内置类别。 */
76
+ export declare const APPROVAL_CATEGORY_PATTERN: RegExp;
77
+ export declare const RESERVED_APPROVAL_CATEGORIES: ReadonlySet<string>;
78
+ /**
79
+ * 装配期校验包工具的审批类别。非法或与内置类别同名时抛错,宿主不装上该包。
80
+ */
81
+ export declare function assertToolApproval(contribution: Pick<ToolContribution, 'name' | 'approval'>): void;
71
82
  type ShellExecutor = (request: LocalExecRequest) => Promise<LocalExecResult>;
72
83
  /**
73
84
  * web_search / web_fetch 的执行缝(W5-2):唯一实现在 sidecar 的
@@ -237,15 +248,17 @@ export declare class ToolRouter {
237
248
  */
238
249
  private autoInstallSkillOnSuccess;
239
250
  /**
240
- * 项目模式硬沙箱(cwd 维度):有可写根时,cwd 默认收到项目家目录;
241
- * 模型显式给的 cwd 必须落在家目录或额外可写根(源文件夹)内,含各自子目录。
242
- * 相对 cwd 按家目录解析(而不是进程 cwd)。
251
+ * 项目模式的 cwd 解析:有可写根时,缺省 cwd 收到项目家目录。
252
+ * 相对 cwd 按家目录解析(而不是进程 cwd)。`~` 与命令围栏同一规则。
243
253
  *
244
- * 注意边界:shell 命令文本里内嵌的绝对路径无法可靠解析,围栏只覆盖
245
- * cwd 与文件读写工具。命令实际写盘还受 exec 沙箱的 writableRoots 约束,
246
- * 那些根同样包含子目录。
254
+ * `allowOutside` 为真时,落在可写根外的 cwd 仍原样交给执行器。只有
255
+ * `local_exec_shell` 走这条路。命令沙箱开着时,审批卡说明写入该目录会被拒绝;
256
+ * 沙箱关掉时卡片不说这句话,这次写入也不会被沙箱拦住。
257
+ * `pwsh` 与代码片段不走这条路,越界 cwd 继续返回路径越界。
247
258
  *
248
- * local_exec_shell 与 local_run_snippet 共用本 helper。
259
+ * 命令文本里内嵌的绝对路径无法可靠解析。文件读写工具另有路径围栏,
260
+ * 不随命令沙箱开关放行。命令沙箱开着时,shell 的实际写盘还受 exec 沙箱的
261
+ * writableRoots 约束,那些根含其子目录。
249
262
  */
250
263
  private applyProjectCwdSandbox;
251
264
  private executeShell;
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { buildProjectRootViolation, expandFencePath, isPathWithinRoot, writableFenceRoots, writableFenceViolation, } from './local-executor.js';
3
+ import { buildProjectRootViolation, canonicalFencePath, expandFencePath, resolveFencePath, isPathWithinRoot, writableFenceRoots, writableFenceViolation, } from './local-executor.js';
4
4
  import { writeShellStdin } from './shell-session.js';
5
5
  import { mcpExecutor } from './mcp-executor.js';
6
6
  import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from './tool-search-rank.js';
@@ -24,6 +24,26 @@ export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
24
24
  */
25
25
  /** deferred 层的发现缝:一个 direct 层搜索工具,关键词命中即调。 */
26
26
  export const TOOL_SEARCH_TOOL_NAME = 'tool_search';
27
+ /** 审批类别:小写开头,最长 64,且不占用内置类别。 */
28
+ export const APPROVAL_CATEGORY_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
29
+ export const RESERVED_APPROVAL_CATEGORIES = new Set([
30
+ 'network_egress',
31
+ 'sandbox_escalation',
32
+ ]);
33
+ /**
34
+ * 装配期校验包工具的审批类别。非法或与内置类别同名时抛错,宿主不装上该包。
35
+ */
36
+ export function assertToolApproval(contribution) {
37
+ const approval = contribution.approval;
38
+ if (approval == null)
39
+ return;
40
+ const category = approval.category;
41
+ if (typeof category !== 'string'
42
+ || !APPROVAL_CATEGORY_PATTERN.test(category)
43
+ || RESERVED_APPROVAL_CATEGORIES.has(category)) {
44
+ throw new Error(`[tool-router] tool ${contribution.name} has invalid approval category '${String(category)}'`);
45
+ }
46
+ }
27
47
  /** 前转 RPC 超时:sidecar 侧单次请求上限默认 30s(可配到 600s),这里给足余量。 */
28
48
  export const WEB_TOOL_RPC_TIMEOUT_MS = 130_000;
29
49
  const PLUGIN_TOOL_MODES = new Set(['read', 'safe_write', 'destructive']);
@@ -70,6 +90,7 @@ export class ToolRouter {
70
90
  if (existing.has(contribution.name)) {
71
91
  throw new Error(`[tool-router] duplicate tool name from contribution: ${contribution.name}`);
72
92
  }
93
+ assertToolApproval(contribution);
73
94
  this.contributedTools.set(contribution.name, contribution);
74
95
  existing.add(contribution.name);
75
96
  }
@@ -489,6 +510,7 @@ export class ToolRouter {
489
510
  inputSchema: c.inputSchema,
490
511
  mode: c.mode,
491
512
  ...(c.exposure ? { exposure: c.exposure } : {}),
513
+ ...(c.approval ? { approval: c.approval } : {}),
492
514
  })),
493
515
  ];
494
516
  }
@@ -983,7 +1005,7 @@ export class ToolRouter {
983
1005
  timeout: typeof args.timeout === 'number' ? args.timeout : undefined,
984
1006
  pty: args.pty !== false,
985
1007
  yieldMs: typeof args.yieldMs === 'number' ? args.yieldMs : undefined,
986
- }, projectRoot, context?.additionalWriteRoots ?? null);
1008
+ }, projectRoot, context?.additionalWriteRoots ?? null, { allowOutside: true });
987
1009
  case 'write_stdin':
988
1010
  return await writeShellStdin({
989
1011
  sessionId: typeof args.sessionId === 'string' ? args.sessionId : '',
@@ -1055,7 +1077,7 @@ export class ToolRouter {
1055
1077
  target: String(args.target || ''),
1056
1078
  });
1057
1079
  case 'local_run_snippet': {
1058
- // 与 local_exec_shell 同款项目沙箱:cwd 默认项目根,显式越界拒绝。
1080
+ // cwd 默认项目根。代码片段不走 exec 沙箱,显式越界 cwd 直接拒绝。
1059
1081
  const sandboxed = this.applyProjectCwdSandbox({ cwd: typeof args.cwd === 'string' ? args.cwd : undefined }, projectRoot, context?.additionalWriteRoots ?? null);
1060
1082
  if ('error' in sandboxed)
1061
1083
  return sandboxed.error;
@@ -1329,17 +1351,19 @@ export class ToolRouter {
1329
1351
  }
1330
1352
  }
1331
1353
  /**
1332
- * 项目模式硬沙箱(cwd 维度):有可写根时,cwd 默认收到项目家目录;
1333
- * 模型显式给的 cwd 必须落在家目录或额外可写根(源文件夹)内,含各自子目录。
1334
- * 相对 cwd 按家目录解析(而不是进程 cwd)。
1354
+ * 项目模式的 cwd 解析:有可写根时,缺省 cwd 收到项目家目录。
1355
+ * 相对 cwd 按家目录解析(而不是进程 cwd)。`~` 与命令围栏同一规则。
1335
1356
  *
1336
- * 注意边界:shell 命令文本里内嵌的绝对路径无法可靠解析,围栏只覆盖
1337
- * cwd 与文件读写工具。命令实际写盘还受 exec 沙箱的 writableRoots 约束,
1338
- * 那些根同样包含子目录。
1357
+ * `allowOutside` 为真时,落在可写根外的 cwd 仍原样交给执行器。只有
1358
+ * `local_exec_shell` 走这条路。命令沙箱开着时,审批卡说明写入该目录会被拒绝;
1359
+ * 沙箱关掉时卡片不说这句话,这次写入也不会被沙箱拦住。
1360
+ * `pwsh` 与代码片段不走这条路,越界 cwd 继续返回路径越界。
1339
1361
  *
1340
- * local_exec_shell 与 local_run_snippet 共用本 helper。
1362
+ * 命令文本里内嵌的绝对路径无法可靠解析。文件读写工具另有路径围栏,
1363
+ * 不随命令沙箱开关放行。命令沙箱开着时,shell 的实际写盘还受 exec 沙箱的
1364
+ * writableRoots 约束,那些根含其子目录。
1341
1365
  */
1342
- applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots) {
1366
+ applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots, options) {
1343
1367
  const roots = writableFenceRoots(projectRoot, additionalWriteRoots);
1344
1368
  if (roots.length === 0)
1345
1369
  return { request };
@@ -1348,11 +1372,11 @@ export class ToolRouter {
1348
1372
  const expanded = request.cwd.startsWith('~')
1349
1373
  ? path.join(os.homedir(), request.cwd.slice(1))
1350
1374
  : request.cwd;
1351
- const resolvedCwd = path.isAbsolute(expanded)
1375
+ const resolvedCwd = canonicalFencePath(path.isAbsolute(expanded)
1352
1376
  ? path.resolve(expanded)
1353
- : path.resolve(home, expanded);
1377
+ : path.resolve(home, expanded));
1354
1378
  const violation = writableFenceViolation(resolvedCwd, roots);
1355
- if (violation) {
1379
+ if (violation && !options?.allowOutside) {
1356
1380
  return {
1357
1381
  error: {
1358
1382
  success: false,
@@ -1365,8 +1389,8 @@ export class ToolRouter {
1365
1389
  }
1366
1390
  return { request: { ...request, cwd: home } };
1367
1391
  }
1368
- async executeShell(request, projectRoot, additionalWriteRoots) {
1369
- const sandboxed = this.applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots);
1392
+ async executeShell(request, projectRoot, additionalWriteRoots, options) {
1393
+ const sandboxed = this.applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots, options);
1370
1394
  if ('error' in sandboxed)
1371
1395
  return sandboxed.error;
1372
1396
  if (this.shellExecutor) {
@@ -1377,21 +1401,11 @@ export class ToolRouter {
1377
1401
  resolveReadablePath(inputPath, projectRoot, additionalReadRoots) {
1378
1402
  if (!inputPath.trim())
1379
1403
  return { error: 'path 不能为空' };
1380
- const expanded = inputPath.startsWith('~')
1381
- ? path.join(os.homedir(), inputPath.slice(1))
1382
- : inputPath;
1383
- const resolved = path.resolve(expanded);
1404
+ const resolved = resolveFencePath(inputPath, projectRoot);
1384
1405
  if (!projectRoot)
1385
1406
  return { path: resolved };
1386
- const root = path.resolve(projectRoot.startsWith('~')
1387
- ? path.join(os.homedir(), projectRoot.slice(1))
1388
- : projectRoot);
1389
- const inAdditionalRoot = (additionalReadRoots ?? []).some((candidate) => {
1390
- const expandedCandidate = candidate.startsWith('~')
1391
- ? path.join(os.homedir(), candidate.slice(1))
1392
- : candidate;
1393
- return isPathWithinRoot(resolved, path.resolve(expandedCandidate));
1394
- });
1407
+ const root = expandFencePath(projectRoot);
1408
+ const inAdditionalRoot = (additionalReadRoots ?? []).some((candidate) => isPathWithinRoot(resolved, expandFencePath(candidate)));
1395
1409
  if (!isPathWithinRoot(resolved, root) && !inAdditionalRoot) {
1396
1410
  return { error: buildProjectRootViolation(resolved, root) };
1397
1411
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.74",
3
+ "version": "0.7.2",
4
4
  "description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -53,9 +53,9 @@
53
53
  "jpeg-js": "^0.4.4",
54
54
  "node-pty": "^1.1.0",
55
55
  "pngjs": "^7.0.0",
56
- "@steerable/agent-harness": "0.6.74",
57
- "@steerable/agent-protocol": "0.6.74",
58
- "@steerable/pack-sdk": "0.6.74"
56
+ "@steerable/agent-harness": "0.7.2",
57
+ "@steerable/agent-protocol": "0.7.2",
58
+ "@steerable/pack-sdk": "0.7.2"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/better-sqlite3": "^7.6.13",