qmQM
Sandbox & Skills

qm sandbox skills

这一页专拆 QM 如何把 resolution layers、credential broker、device-flow login 和 skill 文件树投影进每一轮 Agent computer。

resolution.layers + connectorEnv │ ▼ sandbox.provision(handle) │ ├─ materializeDeviceFlowLogins ├─ materializeSkillIndex / Tree └─ AGENT_OUTBOX + workspace files
§ 00 · OVERVIEW

Sandbox 是 QM 的 turn computer

src/core/orchestrator/sandboxes.ts:28 定义 TurnSandboxContext,里面同时有 actor、session、resolution、connectorEnv、brokeredTools、visibleSkills 和 skillMaterializer。它说明 sandbox 不是纯 shell,而是每轮上下文的承载层。

src/core/orchestrator/sandboxes.ts:60 创建 sandboxes,src/core/orchestrator/sandboxes.ts:131 维护主 sandbox handle,src/core/orchestrator/sandboxes.ts:139 还单独维护 scratch 和 ownerAuth box。

▸TurnSandboxContext 定义了边界输入

src/core/orchestrator/sandboxes.ts · L28-60TypeScript
28export interface TurnSandboxContext {
29  deps: OrchestratorDeps;
30  input: OrchestratorInput;
31  actor: Principal;
32  session: Session;
33  resolution: Resolution;
34  scopeId: ScopeId;
35  memoryScopeId: ScopeId;
36  transferId: string;
37  turnSessionDir: string;
38  turnFilesDir: string;
39  turnOutboxDir: string;
40  connectorEnv: Record<string, string>;
41  egressTokenForTurn: string | undefined;
42  isolateOwnerKeychain: boolean;
43  ownerAuthAvailable: boolean;
44  ownerAuthEnv: Record<string, string>;
45  ownerEnvCredentialIds: string[];
46  brokerVended: Map<string, { tool: BrokeredLayerTool; env: Record<string, string> }>;
47  brokeredTools: readonly BrokeredLayerTool[];
48  quarantinedServices: string[];
49  brokerCutoverServices: string[];
50  cutoverModeOf: (service: string) => DeviceFlowCutoverMode;
51  visibleSkills: SkillResolution[];
52  visibleSkillsForTurn: () => Promise<SkillResolution[]>;
53  skillScopes: ScopeId[];
54  skillMaterializer: ReturnType<typeof createSkillMaterializer>;
55  residentAuthConnectors: () => ResidentAuthConnector[];
56  emitGapWork: (phase: GapPhase, start: number, end: number) => void;
57  perf: { credsMs: number };
58}
59
60export function createTurnSandboxes(ctx: TurnSandboxContext) {
§ 01 · CREDENTIAL WRAPPER

凭据不是静态环境变量,而是按命令路径注入

src/core/orchestrator/sandboxes.ts:93 创建 ownerAuthCommand,src/core/orchestrator/sandboxes.ts:105 用正则检测命令里调用的 brokered tool,src/core/orchestrator/sandboxes.ts:117 拼接 exports。

▸ownerAuthCommand 的审计和 wrapper

src/core/orchestrator/sandboxes.ts · L93-130TypeScript
93  let ownerAuthCommand: ((command: string) => string) | undefined;
94  if (ownerAuthAvailable) {
95    ownerAuthCommand = (command) => {
96      for (const credentialId of ownerEnvCredentialIds) {
97        deps.auditLog.record({
98          at: Date.now(),
99          principalId: actor.id,
100          action: "keychain.materialize",
101          resource: `${credentialId} (owner-auth command)`,
102          scopeLabel: scopeId,
103        });
104      }
105      const invoked = [...brokerVended.values()].filter(({ tool }) =>
106        new RegExp(`(^|[\\s;&|()])${tool.binary}(?=$|[\\s;&|()])`).test(command),
107      );
108      for (const { tool } of invoked) {
109        deps.auditLog.record({
110          at: Date.now(),
111          principalId: actor.id,
112          action: "credential.materialize",
113          resource: `${tool.service} (ephemeral broker)`,
114          scopeLabel: scopeId,
115        });
116      }
117      const exports = Object.entries(ownerAuthEnv)
118        .map(([key, value]) => `${key}=${shq(value)}`)
119        .join(" ");
120      const wrappers = invoked
121        .map(({ tool, env }) => {
122          const brokerExports = Object.entries(env)
123            .map(([key, value]) => `${key}=${shq(value)}`)
124            .join(" ");
125          return `${tool.binary}() { ${brokerExports} command ${tool.binary} "$@"; }; `;
126        })
127        .join("");
128      return `${exports ? `export ${exports}; ` : ""}${wrappers}${command}`;
129    };
130  }

src/core/orchestrator/sandboxes.ts:109 记录 credential.materialize,src/core/orchestrator/sandboxes.ts:125 生成 shell function wrapper。这里把“某个工具用了临时凭据”变成可审计事件,而不是让整个 turn 默认拿到所有秘密。

§ 02 · PROVISION

provision 时把 scope、egress 和 outbox 都落到 handle

src/core/orchestrator/sandboxes.ts:190 创建 provision 函数,src/core/orchestrator/sandboxes.ts:198 进入 doProvision,src/core/orchestrator/sandboxes.ts:200 调 deps.sandbox.provision。

▸主 provision 流程

src/core/orchestrator/sandboxes.ts · L198-260TypeScript
198  const doProvision = async (emit: typeof emitGapWork): Promise<SandboxHandle> => {
199    const provisionStart = Date.now();
200    const handle = await deps.sandbox.provision(resolution.layers, {
201      env: connectorEnv,
202      egress: resolution.egress,
203      ...(egressTokenForTurn ? { egressToken: egressTokenForTurn } : {}),
204      ...(onSandboxStatus ? { onStatus: onSandboxStatus } : {}),
205    });
206    box.pending = handle;
207    emit("provision", provisionStart, Date.now());
208    box.provisionMs = Date.now() - provisionStart;
209    handle.env = { ...handle.env, AGENT_OUTBOX: `${handle.rootDir}/${turnOutboxDir}` };
210    if (deps.keychain) {
211      const deviceFlowStart = Date.now();
212      const restoreOwnerId =
213        input.origin.kind === "automation" && input.origin.useOwnerKeychain && !isolateOwnerKeychain
214          ? actor.id
215          : deviceFlowCredOwner(memoryScopeId, actor.id);
216      const resetGenerations = new Map<string, string>();
217      for (const tool of brokeredTools) {
218        if (cutoverModeOf(tool.service) !== "legacy") continue;
219        const generation = await deps.deviceFlowCutover?.residentResetGeneration(memoryScopeId, tool.service);
220        if (generation) resetGenerations.set(tool.service, generation);
221      }
222      const owned = resetGenerations.size ? await deps.keychain.listByOwner(restoreOwnerId) : [];
223      const resetServices = [...resetGenerations.keys()].filter((service) =>
224        owned.some((record) => record.service === service),
225      );
226      const removeServices = [...new Set([...quarantinedServices, ...resetServices])];
227      if (removeServices.length) {
228        await removeDeviceFlowLogins({
229          sandbox: deps.sandbox,
230          handle,
231          keychain: deps.keychain,
232          ownerId: restoreOwnerId,
233          services: removeServices,
234          canonicalRoots: brokeredTools
235            .filter((tool) => removeServices.includes(tool.service))
236            .flatMap((tool) => tool.roots),
237        });
238      }
239      try {
240        const restoredServices = await materializeDeviceFlowLogins({
241          sandbox: deps.sandbox,
242          handle,
243          keychain: deps.keychain,
244          ownerId: restoreOwnerId,
245          ...(quarantinedServices.length ? { excludeServices: quarantinedServices } : {}),
246        });
247        for (const service of restoredServices) {
248          deps.credentialUsage?.record({
249            slug: `keychain:${service}`,
250            host: "local",
251            status: cutoverModeOf(service) === "prefer_ephemeral" ? "legacy_retained" : "legacy_restored",
252            scopeLabel: scopeId,
253            principalId: actor.id,
254          });
255        }
256        for (const [service, generation] of resetGenerations) {
257          await deps.deviceFlowCutover?.markResidentReset(memoryScopeId, service, generation);
258        }
259      } catch (err) {
260        deps.errors?.record({

src/core/orchestrator/sandboxes.ts:202 传 egress policy,src/core/orchestrator/sandboxes.ts:209 写 AGENT_OUTBOX,src/core/orchestrator/sandboxes.ts:240 恢复 device-flow logins。这个路径就是 QM 所说“agent computer”的运行时实现。

§ 03 · SKILLS

Skill 投影用 marker/hash 管理生命周期

src/skills/materialize.ts:35 用 sandbox handle 生成 materializationKey,src/skills/materialize.ts:44 计算 indexHash,src/skills/materialize.ts:57 计算 treeHash。这说明 materializer 关心“当前 sandbox 里已经是什么版本”。

▸hash 和 rendered body

src/skills/materialize.ts · L35-86TypeScript
35function materializationKey(handle: SandboxHandle): string {
36  const sandboxId = createHash("sha256").update(handle.id).update("\0").update(handle.rootDir).digest("hex");
37  return `skills:projection:${sandboxId}`;
38}
39
40export function safeSkillDirName(name: string): string {
41  return assertSafeSkillName(name);
42}
43
44function indexHash(resolved: SkillResolution[]): string {
45  const h = createHash("sha256");
46  const entries = resolved
47    .filter((r) => r.skill)
48    .map((r) => `${r.skill!.manifest.name}\0${renderedBody(r)}`)
49    .sort();
50  for (const e of entries) {
51    h.update(e);
52    h.update("\n");
53  }
54  return h.digest("hex");
55}
56
57function treeHash(resolution: SkillResolution, bundles: SkillBundle[]): string {
58  const m = resolution.skill!.manifest;
59  const files = [...(m.files ?? [])]
60    .map((f) => `${f.path}\0${f.content}\0${f.executable === true ? "1" : "0"}`)
61    .sort()
62    .join("\0");
63  const folded = [...bundles]
64    .map((b) => b.hash)
65    .sort()
66    .join("\0");
67  return createHash("sha256")
68    .update(renderedBody(resolution))
69    .update("\0")
70    .update(files)
71    .update("\0")
72    .update(folded)
73    .digest("hex");
74}
75
76function packRoot(resolution: SkillResolution): string | null {
77  return resolution.skill?.pack ? `${SKILL_PACKS_DIR}/${safeSkillDirName(resolution.skill.pack.packId)}` : null;
78}
79
80function renderedBody(resolution: SkillResolution): string {
81  const body = resolution.skill!.manifest.body;
82  const root = packRoot(resolution);
83  return root
84    ? `${body}\n\n## Pack files\nResolve repository-relative shared-file paths against \`${root}/\`; pack files never overwrite the workspace root.`
85    : body;
86}

src/skills/materialize.ts:76 处理 pack root,src/skills/materialize.ts:80 渲染 skill body,src/skills/materialize.ts:84 明确 pack files 不覆盖 workspace root。这个设计避免 skill pack 直接污染工作区。

▸index materialize 的 stale cleanup

src/skills/materialize.ts · L210-260TypeScript
210async function materializeSkillIndexUnlocked(
211  sandbox: Sandbox,
212  handle: SandboxHandle,
213  resolved: SkillResolution[],
214): Promise<void> {
215  const want = indexHash(resolved);
216  const names = resolved.flatMap((r) => (r.skill ? [safeSkillDirName(r.skill.manifest.name)] : [])).sort();
217  const raw = await readMarker(sandbox, handle, INDEX_MARKER, "skills: index probe");
218  const prev = indexMarkerState(raw);
219  if (prev?.hash === want && samePaths(prev.names, names)) return;
220  const legacyExternalPathsPreserved = prev?.legacyExternalPathsPreserved === true || Boolean(raw && !prev);
221
222  if (raw && !prev) {
223    await sandbox.removeDir(handle, SKILLS_DIR);
224  } else if (prev) {
225    const activeBundlePaths = new Set<string>();
226    for (const name of names) {
227      const currentRaw = await readMarker(
228        sandbox,
229        handle,
230        `${SKILLS_DIR}/${name}/${TREE_MARKER}`,
231        `skills: tree probe ${name}`,
232      );
233      const current = treeMarkerState(currentRaw, `${SKILLS_DIR}/${name}`);
234      for (const path of current?.bundlePaths ?? []) activeBundlePaths.add(path);
235    }
236    const activeNames = new Set(names);
237    for (const name of prev.names) {
238      if (activeNames.has(name)) continue;
239      const dir = `${SKILLS_DIR}/${name}`;
240      const staleRaw = await readMarker(sandbox, handle, `${dir}/${TREE_MARKER}`, `skills: tree probe ${name}`);
241      const stale = treeMarkerState(staleRaw, dir);
242      const paths = new Set([`${dir}/SKILL.md`, ...(stale?.skillPaths ?? []), ...(stale?.bundlePaths ?? [])]);
243      for (const path of paths) {
244        if (!activeBundlePaths.has(path)) await sandbox.removeDir(handle, path);
245      }
246      const protectsDirectory = [...activeBundlePaths].some((path) => path.startsWith(`${dir}/`));
247      if (protectsDirectory) await sandbox.removeDir(handle, `${dir}/${TREE_MARKER}`);
248      else await sandbox.removeDir(handle, dir);
249    }
250  }
251
252  const entries: LayEntry[] = [];
253  for (const r of resolved) {
254    if (!r.skill) continue;
255    entries.push({
256      path: `${SKILLS_DIR}/${safeSkillDirName(r.skill.manifest.name)}/SKILL.md`,
257      content: renderedBody(r),
258    });
259  }
260  entries.push({

src/skills/materialize.ts:217 读取 marker,src/skills/materialize.ts:222 处理 legacy marker,src/skills/materialize.ts:237 删除不再 active 的 skill。resident sandbox 场景里,如果不做这层清理,旧 skill 会悄悄留在 Agent 可见路径里。

§ 04 · DEPLOYMENT LAYER

部署层工具描述符也被严格解析

src/deployment/deployment-layer.ts:54 解析 tool descriptor,src/deployment/deployment-layer.ts:68 限制 id 格式,src/deployment/deployment-layer.ts:89 校验 egress 字段,src/deployment/deployment-layer.ts:96 解析 auth。

▸ToolDescriptor parser

src/deployment/deployment-layer.ts · L54-116TypeScript
54export function parseToolDescriptor(raw: string, sourcePath: string): ToolDescriptor {
55  let parsed: unknown;
56  try {
57    parsed = JSON.parse(raw);
58  } catch (err) {
59    throw new Error(`${sourcePath} is not valid JSON: ${(err as Error).message}`, { cause: err });
60  }
61  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
62    throw new Error(`${sourcePath} must be a JSON object`);
63  }
64  const d = parsed as Record<string, unknown>;
65  if (typeof d["id"] !== "string" || !d["id"].trim()) {
66    throw new Error(`${sourcePath}: "id" is required and must be a non-empty string`);
67  }
68  if (!TOOL_ID_RE.test(d["id"])) {
69    throw new Error(
70      `${sourcePath}: "id" must match ${TOOL_ID_RE.source} (lowercase alphanumerics and hyphens) — it is interpolated into shell probes`,
71    );
72  }
73  const out: ToolDescriptor = { id: d["id"] };
74
75  for (const key of ["label", "advertise"] as const) {
76    if (d[key] !== undefined) {
77      if (typeof d[key] !== "string") throw new Error(`${sourcePath}: "${key}" must be a string`);
78      out[key] = d[key] as string;
79    }
80  }
81
82  if (d["hints"] !== undefined) {
83    if (!Array.isArray(d["hints"]) || d["hints"].some((h) => typeof h !== "string")) {
84      throw new Error(`${sourcePath}: "hints" must be an array of strings`);
85    }
86    out.hints = d["hints"] as string[];
87  }
88
89  if (d["egress"] !== undefined) {
90    if (!Array.isArray(d["egress"]) || d["egress"].some((h) => typeof h !== "string")) {
91      throw new Error(`${sourcePath}: "egress" must be an array of host strings`);
92    }
93    out.egress = d["egress"] as string[];
94  }
95
96  if (d["auth"] !== undefined) out.auth = parseAuth(d["auth"], sourcePath);
97  if (d["approvals"] !== undefined) out.approvals = parseApprovals(d["approvals"], sourcePath);
98
99  if (d["install"] !== undefined) {
100    const inst = d["install"];
101    if (typeof inst !== "object" || inst === null || Array.isArray(inst)) {
102      throw new Error(`${sourcePath}: "install" must be an object`);
103    }
104    const binary = (inst as Record<string, unknown>)["binary"];
105    if (binary !== undefined && typeof binary !== "string") {
106      throw new Error(`${sourcePath}: "install.binary" must be a string`);
107    }
108    if (binary !== undefined && !TOOL_ID_RE.test(binary as string)) {
109      throw new Error(
110        `${sourcePath}: "install.binary" must match ${TOOL_ID_RE.source} (lowercase alphanumerics and hyphens) — it is interpolated into generated Dockerfile lines`,
111      );
112    }
113    out.install = binary !== undefined ? { binary: binary as string } : {};
114  }
115
116  const credentialPaths = out.auth?.credentialPaths ?? [];

src/deployment/deployment-layer.ts:116 开始检查 credentialPaths,src/deployment/deployment-layer.ts:121 拒绝绝对路径或 traversal,src/deployment/deployment-layer.ts:139 检查和内置 credential paths 的重叠。这让 deployment layer 不能随便声明一个会吞掉整个 HOME 的凭据路径。

▸credential path 和 approval pattern 守门

src/deployment/deployment-layer.ts · L116-183TypeScript
116  const credentialPaths = out.auth?.credentialPaths ?? [];
117  for (const [index, credentialPath] of credentialPaths.entries()) {
118    const { path, kind } = credentialPath;
119    const segments = path.split("/");
120    if (
121      !path ||
122      path.startsWith("/") ||
123      path.startsWith("~") ||
124      path.includes("\\") ||
125      /\s/.test(path) ||
126      segments.includes("..") ||
127      segments.includes(".") ||
128      segments.includes("")
129    ) {
130      throw new Error(
131        `${sourcePath}: credential path ${JSON.stringify(path)} must be a $HOME-relative path with no traversal`,
132      );
133    }
134    if (!segments[0]!.startsWith(".")) {
135      throw new Error(
136        `${sourcePath}: credential path ${JSON.stringify(path)} must start with a dotfile or dot-directory segment — non-hidden $HOME paths are durable agent data, not credentials`,
137      );
138    }
139    const builtIn = BUILT_IN_CREDENTIAL_PATHS.find(
140      (base) => path === base.path || nested(path, base.path) || nested(base.path, path),
141    );
142    if (builtIn && (path !== builtIn.path || kind !== builtIn.kind)) {
143      throw new Error(
144        `${sourcePath}: credential path ${JSON.stringify(path)} (${kind}) overlaps the built-in credential path ${JSON.stringify(builtIn.path)} (${builtIn.kind}) — declare the exact built-in path with its correct kind or a disjoint one`,
145      );
146    }
147    const other = credentialPaths.find(
148      (entry, otherIndex) =>
149        otherIndex !== index && (path === entry.path || nested(path, entry.path) || nested(entry.path, path)),
150    );
151    if (other) {
152      throw new Error(
153        `${sourcePath}: credential paths ${JSON.stringify(path)} and ${JSON.stringify(other.path)} overlap — declare disjoint paths`,
154      );
155    }
156  }
157  const binary = out.install?.binary ?? out.id;
158  if (out.auth?.broker && !POSIX_FUNCTION_NAME_RE.test(binary)) {
159    throw new Error(
160      `${sourcePath}: a brokered tool's binary ${JSON.stringify(binary)} must match ${POSIX_FUNCTION_NAME_RE.source} — vended credentials wrap it in a shell function, and not every sandbox shell accepts hyphenated function names`,
161    );
162  }
163  for (const [i, approval] of (out.approvals ?? []).entries()) {
164    const compiled = compileApproval(binary, approval);
165    try {
166      new RegExp(compiled.pattern, "i");
167    } catch (e) {
168      throw new Error(`${sourcePath}: approvals[${i}] is not a valid regex: ${(e as Error).message}`, { cause: e });
169    }
170    if (compiled.pattern.length > MAX_APPROVAL_PATTERN_LEN) {
171      throw new Error(`${sourcePath}: approvals[${i}] pattern is too long (max ${MAX_APPROVAL_PATTERN_LEN} chars)`);
172    }
173    if (approvalPatternTooSlow(compiled.pattern)) {
174      throw new Error(
175        `${sourcePath}: approvals[${i}] pattern is too slow to evaluate — it may cause catastrophic backtracking`,
176      );
177    }
178    if (approval.pattern !== undefined && !rawApprovalTargetsTool(binary, approval.pattern)) {
179      throw new Error(
180        `${sourcePath}: approvals[${i}].pattern must refer to its own tool binary by starting with \\b${binary}\\b and may not use a top-level alternative`,
181      );
182    }
183  }

src/deployment/deployment-layer.ts:157 推导 binary,src/deployment/deployment-layer.ts:163 编译 approvals,src/deployment/deployment-layer.ts:178 要求 approval pattern 以自己的 tool binary 开头。这个约束防止部署工具用 approvals 规则偷偷影响别的命令。

§ 05 · HIGHLIGHTS

可借鉴点

▸少信任目录,多信任 parser

src/deployment/deployment-layer.ts:54 和 src/skills/materialize.ts:128 都把路径安全放在解析层,而不是寄希望于调用方。

▸resident sandbox 需要清理协议

src/skills/materialize.ts:236 的 activeNames 和 src/skills/materialize.ts:243 的 stale path 删除,就是长期运行机器避免积灰的关键。

§ 06 · RISKS

风险

▸凭据 wrapper 依赖 shell 语义

src/core/orchestrator/sandboxes.ts:125 生成 shell function,src/deployment/deployment-layer.ts:157 又限制 brokered tool binary 名字。这个约束有效,但也意味着非 POSIX 或特殊 shell 环境要单独验证。