minimal-agent 0.12.0 → 0.13.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/package.json +3 -2
- package/plugins/workflow-runner/src/expressions.js +29 -8
- package/plugins/workflow-runner/src/expressions.ts +28 -10
- package/plugins/workflow-runner/src/index.js +45 -17
- package/plugins/workflow-runner/src/index.ts +44 -18
- package/plugins/workflow-runner/src/recovery.js +9 -2
- package/plugins/workflow-runner/src/recovery.ts +9 -2
- package/plugins/workflow-runner/src/runner.js +31 -5
- package/plugins/workflow-runner/src/runner.ts +33 -5
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
- package/plugins/workflow-runner/src/stepExecutors/tool.js +29 -10
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +29 -11
- package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
- package/plugins/workflow-runner/src/taskIdentity.js +197 -21
- package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
- package/plugins/workflow-runner/src/types.js +113 -7
- package/plugins/workflow-runner/src/types.ts +111 -7
- package/plugins/workflow-runner/src/workflowState.js +455 -54
- package/plugins/workflow-runner/src/workflowState.ts +508 -63
- package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
- package/plugins/workflow-runner/test/expressions.test.ts +42 -0
- package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
- package/plugins/workflow-runner/test/research.load.test.ts +328 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
- package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
- package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
- package/plugins/workflow-runner/test/word.load.test.ts +9 -9
- package/skills/skill-creator/scripts/run_eval.py +3 -2
- package/src/cli/print.js +17 -0
- package/src/cli/print.ts +25 -0
- package/src/config/configService.js +24 -0
- package/src/config/configService.ts +23 -0
- package/src/infra/verification.js +25 -18
- package/src/infra/verification.ts +50 -18
- package/src/plugins/pluginRunner.js +18 -7
- package/src/plugins/pluginRunner.ts +20 -6
- package/src/prompts/policyRegistry.js +28 -1
- package/src/prompts/policyRegistry.ts +28 -1
- package/src/tools/advisor/advisor.js +76 -9
- package/src/tools/advisor/advisor.ts +79 -9
- package/src/tools/bash/bash.js +51 -6
- package/src/tools/bash/bash.ts +56 -7
- package/src/tools/powershell/powershell.js +18 -1
- package/src/tools/powershell/powershell.ts +18 -2
- package/src/tools/read/read.js +9 -4
- package/src/tools/read/read.ts +9 -4
- package/src/tools/shared/permanentDeleteGuard.js +34 -3
- package/src/tools/shared/permanentDeleteGuard.ts +43 -6
- package/src/tools/webbrowser/browserControl.js +5 -1
- package/src/tools/webbrowser/browserControl.ts +7 -1
- package/src/tools/webbrowser/webbrowser.js +8 -4
- package/src/tools/webbrowser/webbrowser.ts +8 -4
- package/src/types.ts +10 -4
- package/workflows/adaptive-research.yaml +5 -5
- package/workflows/book-review-short.yaml +5 -5
- package/workflows/e2e-write-greet.yaml +2 -2
- package/workflows/illustrate.yaml +12 -12
- package/workflows/plan.yaml +18 -18
- package/workflows/ppt.yaml +201 -201
- package/workflows/ralph-loop.yaml +3 -1
- package/workflows/research.md +449 -0
- package/workflows/research.yaml +873 -0
- package/workflows/webppt.yaml +47 -47
- package/workflows/word.yaml +80 -80
- package/workflows/youtube-shorts.yaml +25 -25
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
chat as sdkChat,
|
|
@@ -12,28 +12,96 @@ import { extractJsonPayload } from './jsonPayload.js';
|
|
|
12
12
|
import type { RunContext } from './types.js';
|
|
13
13
|
|
|
14
14
|
// ============================================================
|
|
15
|
-
// 工作流状态目录布局(
|
|
15
|
+
// 工作流状态目录布局(Phase 1 + 2 + 3 升级)
|
|
16
16
|
// ------------------------------------------------------------
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
17
|
+
// 状态根目录优先级:
|
|
18
|
+
// 1. 环境变量 MINIMAL_AGENT_WORKFLOW_STATE_DIR(全局统一目录)
|
|
19
|
+
// 2. <cwd>/.minimal-agent-workflow/(默认,与历史行为一致)
|
|
20
|
+
//
|
|
21
|
+
// 目录结构(三级:工作流名 / session 实例 / taskId 记录):
|
|
22
|
+
// <stateRoot>/<workflowName>/<sessionId>/ ← 活动任务实例(session 即任务实例)
|
|
23
|
+
// <stateRoot>/<workflowName>/<sessionId>.done/ ← 成功完成的实例归档
|
|
24
|
+
// <stateRoot>/<workflowName>/<sessionId>.trash/ ← 清理回收(A4 归档)
|
|
25
|
+
//
|
|
26
|
+
// Phase 3(session 化):实例目录名 = sessionId(一个 session 同时只跑一个
|
|
27
|
+
// 同名工作流,session 即天然的任务实例划分单元)。taskId(goal 内容 hash)
|
|
28
|
+
// 降级为 current.json 里的【记录字段】——用于同 session 内识别「换主题 →
|
|
29
|
+
// new-task 归档重建 / 同主题 → resume/completed」。不同 session 完全隔离,
|
|
30
|
+
// 同主题也可并行互不干扰。
|
|
31
|
+
//
|
|
32
|
+
// 演进历史:
|
|
33
|
+
// - 最初:<cwd>/.minimal-agent-workflow/ 单例(所有工作流共用)
|
|
34
|
+
// - E6:按 workflow 名隔离,<cwd>/.minimal-agent-workflow/<name>/
|
|
35
|
+
// - Phase 1:状态根目录可配置(环境变量指定全局位置,解决 cwd 切换找不到)
|
|
36
|
+
// - Phase 2:新增 taskId 实例层,同名 workflow 多任务并存(根治新任务清掉旧状态)
|
|
37
|
+
// - Phase 3:实例目录名改为 sessionId,taskId 降级为记录字段(多 session 真正并行)
|
|
24
38
|
// ============================================================
|
|
25
39
|
|
|
26
|
-
/**
|
|
40
|
+
/** 工作流状态目录的父目录名(默认落在 cwd 下时使用)。 */
|
|
27
41
|
export const WF_STATE_PARENT = '.minimal-agent-workflow';
|
|
28
42
|
|
|
29
|
-
/**
|
|
43
|
+
/** 全局统一状态目录的环境变量名。 */
|
|
44
|
+
export const WF_STATE_DIR_ENV = 'MINIMAL_AGENT_WORKFLOW_STATE_DIR';
|
|
45
|
+
|
|
46
|
+
/** 缺省 sessionId(宿主未显式传 session 时的兜底判定值;真正落盘的 session 由 generateDefaultSessionId 自动命名)。 */
|
|
47
|
+
export const DEFAULT_SESSION_ID = 'default';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 工作流引擎的默认 session 自动命名:宿主未显式提供有意义 session(缺失或为
|
|
51
|
+
* DEFAULT_SESSION_ID)时,由引擎自行生成唯一可读的 session 名
|
|
52
|
+
* `YYYYMMDD-HHmmss-xxxxxx`(日期时间 + 6 位随机 hex)。
|
|
53
|
+
*
|
|
54
|
+
* 语义:每次工作流运行(CLI -p 默认 / TUI default 会话)都自动落到独立的
|
|
55
|
+
* `<out>/<workflow>/<session>/` 产物目录与 `<stateRoot>/<workflow>/<session>/`
|
|
56
|
+
* 状态目录——多 session 并行互不覆盖;时间前缀便于人识别与排序,随机后缀
|
|
57
|
+
* 防同一秒并行冲突。宿主显式传 `-s <id>`(非 default)时仍优先使用宿主 id。
|
|
58
|
+
*/
|
|
59
|
+
export function generateDefaultSessionId(now = new Date()): string {
|
|
60
|
+
const pad = (n: number, w = 2): string => String(n).padStart(w, '0');
|
|
61
|
+
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
62
|
+
const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
63
|
+
const rand = randomBytes(3).toString('hex');
|
|
64
|
+
return `${date}-${time}-${rand}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 解析状态根目录(绝对路径)。
|
|
69
|
+
* 优先级:环境变量 MINIMAL_AGENT_WORKFLOW_STATE_DIR > cwd 下默认目录。
|
|
70
|
+
* 环境变量允许相对路径——基于 process.cwd() 展开(用户可能写 `.wflow` 这类相对值)。
|
|
71
|
+
* 传入 workingDir 是为了 cwd 默认模式的相对路径基准。
|
|
72
|
+
*/
|
|
73
|
+
export function resolveStateRoot(workingDir: string): string {
|
|
74
|
+
const envDir = process.env[WF_STATE_DIR_ENV];
|
|
75
|
+
if (envDir && envDir.trim()) {
|
|
76
|
+
const p = envDir.trim();
|
|
77
|
+
return isAbsolute(p) ? p : resolve(p);
|
|
78
|
+
}
|
|
79
|
+
return join(workingDir, WF_STATE_PARENT);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 把 workflow 名 / sessionId 清洗成安全的目录名(保留字母数字._-,其余替换为 -)。 */
|
|
30
83
|
export function sanitizeWorkflowDirName(name: string): string {
|
|
31
84
|
return name.replace(/[^\w.-]/g, '-');
|
|
32
85
|
}
|
|
33
86
|
|
|
34
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* 某 workflow 的状态目录绝对路径(workflow 级目录,下含多个 session 实例)。
|
|
89
|
+
* workflowState / taskIdentity 共用唯一权威实现。
|
|
90
|
+
*/
|
|
35
91
|
export function workflowStateDir(workingDir: string, workflowName: string): string {
|
|
36
|
-
return join(workingDir,
|
|
92
|
+
return join(resolveStateRoot(workingDir), sanitizeWorkflowDirName(workflowName));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 具体任务实例(= session)的状态目录。
|
|
97
|
+
* 成功后改名为 <sessionId>.done/;清理归档目录为 <sessionId>.trash/。
|
|
98
|
+
*/
|
|
99
|
+
export function taskInstanceDir(
|
|
100
|
+
workingDir: string,
|
|
101
|
+
workflowName: string,
|
|
102
|
+
sessionId: string,
|
|
103
|
+
): string {
|
|
104
|
+
return join(workflowStateDir(workingDir, workflowName), sanitizeWorkflowDirName(sessionId));
|
|
37
105
|
}
|
|
38
106
|
|
|
39
107
|
/**
|
|
@@ -64,11 +132,19 @@ export interface TaskIdentityResult {
|
|
|
64
132
|
* - 去除所有标点符号(保留中文、英文、数字、空格)
|
|
65
133
|
* - 去除多余空格(连续空格→单空格,首尾 trim)
|
|
66
134
|
* - 截断到 200 字符(防止超长输入导致不必要的差异)
|
|
135
|
+
*
|
|
136
|
+
* 参数:
|
|
137
|
+
* - workingDir:不传只返回 hash(纯计算,不读盘)。
|
|
138
|
+
* - sessionId:Phase 3 后传入——实例查找限定到「当前 session」的实例目录
|
|
139
|
+
* (<wfDir>/<sessionId>/ 与 <sessionId>.done/),读其 current.json 的 taskId
|
|
140
|
+
* 记录与本次 hash 比较。缺省时退化为旧行为(扫 wfDir 下所有 taskId 实例),
|
|
141
|
+
* 兼容历史调用与测试。
|
|
67
142
|
*/
|
|
68
143
|
export function computeTaskId(
|
|
69
144
|
workflowName: string,
|
|
70
145
|
inputs: Record<string, unknown>,
|
|
71
146
|
workingDir?: string,
|
|
147
|
+
sessionId?: string,
|
|
72
148
|
): TaskIdentityResult {
|
|
73
149
|
// 1. 提取 goal 文本(与 buildGoalAnchor 同策略)
|
|
74
150
|
const rawGoal = extractGoal(inputs);
|
|
@@ -91,9 +167,25 @@ export function computeTaskId(
|
|
|
91
167
|
};
|
|
92
168
|
}
|
|
93
169
|
|
|
94
|
-
// 5.
|
|
95
|
-
|
|
170
|
+
// 5. 找当前任务对应的实例目录,读其 current.json 记录与本次 hash 比较
|
|
171
|
+
// (Phase 3:session 语义——只查当前 session 的实例;taskId 是记录字段)
|
|
172
|
+
const wfDir = workflowStateDir(workingDir, workflowName);
|
|
173
|
+
const candidateDirs = findTaskInstanceDirs(wfDir, contentHash, sessionId);
|
|
96
174
|
|
|
175
|
+
if (candidateDirs.length === 0) {
|
|
176
|
+
// 无精确匹配。但若 wfDir 下存在其他任务实例(不同 goal / 已归档),
|
|
177
|
+
// 语义仍是「新任务 + 旧任务在场」——返回 hash_diff 而非 no_previous,
|
|
178
|
+
// 让 workflowState.init 能触发仲裁/清理旧产物(旧布局靠读 wfDir/current.json
|
|
179
|
+
// 天然得到这个信号,新布局必须显式扫描)。
|
|
180
|
+
return {
|
|
181
|
+
hash: contentHash,
|
|
182
|
+
isSameTask: false,
|
|
183
|
+
reason: hasAnyTaskInstanceInDir(wfDir, sessionId) ? 'hash_diff' : 'no_previous',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 取第一个匹配的(hash 一样就是同一任务,不应有多个;有多个也只取第一个)
|
|
188
|
+
const stateFilePath = join(candidateDirs[0], 'current.json');
|
|
97
189
|
if (!existsSync(stateFilePath)) {
|
|
98
190
|
return {
|
|
99
191
|
hash: contentHash,
|
|
@@ -103,14 +195,14 @@ export function computeTaskId(
|
|
|
103
195
|
}
|
|
104
196
|
|
|
105
197
|
try {
|
|
106
|
-
const stateContent = readFileSync(stateFilePath, '
|
|
198
|
+
const stateContent = readFileSync(stateFilePath, 'utf8');
|
|
107
199
|
const prevState = JSON.parse(stateContent) as {
|
|
108
200
|
taskId?: string;
|
|
109
201
|
name?: string;
|
|
110
202
|
startedAt?: string;
|
|
111
203
|
};
|
|
112
204
|
|
|
113
|
-
// a. 不同 workflow 名 →
|
|
205
|
+
// a. 不同 workflow 名 → 不同任务(理论上不会发生,因为已经按目录隔离了,防御性检查)
|
|
114
206
|
if (prevState.name && prevState.name !== workflowName) {
|
|
115
207
|
return {
|
|
116
208
|
hash: contentHash,
|
|
@@ -144,6 +236,95 @@ export function computeTaskId(
|
|
|
144
236
|
}
|
|
145
237
|
}
|
|
146
238
|
|
|
239
|
+
/**
|
|
240
|
+
* 在 workflow 级目录下,找到当前任务对应的实例目录。
|
|
241
|
+
* 支持两种布局:
|
|
242
|
+
* - 新布局(Phase 3,传 sessionId):<wfDir>/<sessionId>/current.json(活动)
|
|
243
|
+
* 与 <wfDir>/<sessionId>.done/current.json(已完成)
|
|
244
|
+
* - 旧布局(Phase 2,不传 sessionId):<wfDir>/<taskId>/current.json
|
|
245
|
+
* 与 <wfDir>/<taskId>.done/current.json
|
|
246
|
+
* - 旧布局兼容(无 session 层):<wfDir>/current.json —— 把整个 wfDir 当成一个实例
|
|
247
|
+
* 返回绝对路径数组。
|
|
248
|
+
*/
|
|
249
|
+
function findTaskInstanceDirs(
|
|
250
|
+
wfDir: string,
|
|
251
|
+
taskId: string,
|
|
252
|
+
sessionId?: string,
|
|
253
|
+
): string[] {
|
|
254
|
+
const results: string[] = [];
|
|
255
|
+
|
|
256
|
+
if (sessionId) {
|
|
257
|
+
// Phase 3:session 语义 —— 只找当前 session 的实例(taskId 是记录字段,目录名是 sessionId)
|
|
258
|
+
const activeDir = join(wfDir, sanitizeWorkflowDirName(sessionId));
|
|
259
|
+
const doneDir = join(wfDir, `${sanitizeWorkflowDirName(sessionId)}.done`);
|
|
260
|
+
if (existsSync(activeDir)) results.push(activeDir);
|
|
261
|
+
if (existsSync(doneDir)) results.push(doneDir);
|
|
262
|
+
} else {
|
|
263
|
+
// Phase 2 旧行为:<wfDir>/<taskId>/ 和 <wfDir>/<taskId>.done/
|
|
264
|
+
const activeDir = join(wfDir, taskId);
|
|
265
|
+
const doneDir = join(wfDir, `${taskId}.done`);
|
|
266
|
+
if (existsSync(activeDir)) results.push(activeDir);
|
|
267
|
+
if (existsSync(doneDir)) results.push(doneDir);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (results.length > 0) return results;
|
|
271
|
+
|
|
272
|
+
// 旧布局兼容:wfDir 本身就是实例目录(current.json 直接在 wfDir 下)
|
|
273
|
+
// 检测方法:wfDir/current.json 存在且 wfDir 下没有子目录是 taskId 格式
|
|
274
|
+
if (existsSync(join(wfDir, 'current.json'))) {
|
|
275
|
+
// 确认这是旧布局(不是某个子实例目录)
|
|
276
|
+
try {
|
|
277
|
+
const entries = readdirSync(wfDir, { withFileTypes: true });
|
|
278
|
+
const hasInstanceSubdir = entries.some(
|
|
279
|
+
(e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name),
|
|
280
|
+
);
|
|
281
|
+
if (!hasInstanceSubdir) {
|
|
282
|
+
results.push(wfDir);
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// 读不到就当旧布局
|
|
286
|
+
results.push(wfDir);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 检查 wfDir 下是否存在任何任务实例(活动态 / .done 归档态 / 旧布局 current.json)。
|
|
295
|
+
* 不含 .trash 回收目录。用于 computeTaskId 在无精确匹配时区分
|
|
296
|
+
* hash_diff(有其他旧任务)与 no_previous(从未跑过)。
|
|
297
|
+
* 传 sessionId 时只统计当前 session 的实例(Phase 3)。
|
|
298
|
+
*/
|
|
299
|
+
function hasAnyTaskInstanceInDir(wfDir: string, sessionId?: string): boolean {
|
|
300
|
+
if (!existsSync(wfDir)) return false;
|
|
301
|
+
try {
|
|
302
|
+
if (sessionId) {
|
|
303
|
+
// Phase 3:只看当前 session 的实例
|
|
304
|
+
const active = join(wfDir, sanitizeWorkflowDirName(sessionId));
|
|
305
|
+
const done = join(wfDir, `${sanitizeWorkflowDirName(sessionId)}.done`);
|
|
306
|
+
if (existsSync(active)) return true;
|
|
307
|
+
if (existsSync(done)) return true;
|
|
308
|
+
// 旧布局:wfDir 本身就是实例目录
|
|
309
|
+
if (existsSync(join(wfDir, 'current.json'))) return true;
|
|
310
|
+
if (existsSync(join(wfDir + '.done', 'current.json'))) return true;
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const entries = readdirSync(wfDir, { withFileTypes: true });
|
|
314
|
+
for (const e of entries) {
|
|
315
|
+
if (!e.isDirectory()) continue;
|
|
316
|
+
if (/^[0-9a-f]{16}$/.test(e.name)) return true; // 活动实例
|
|
317
|
+
if (/^[0-9a-f]{16}\.done$/.test(e.name)) return true; // 归档实例
|
|
318
|
+
}
|
|
319
|
+
// 旧布局:wfDir 本身就是实例目录
|
|
320
|
+
if (existsSync(join(wfDir, 'current.json'))) return true;
|
|
321
|
+
if (existsSync(join(wfDir + '.done', 'current.json'))) return true;
|
|
322
|
+
return false;
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
147
328
|
/**
|
|
148
329
|
* 从 inputs 中提取 goal 文本
|
|
149
330
|
*
|
|
@@ -326,7 +507,7 @@ async function llmArbitrate(
|
|
|
326
507
|
for await (const ev of chatFn({
|
|
327
508
|
provider: deps.provider,
|
|
328
509
|
messages: [
|
|
329
|
-
{ role: 'user', content: prompt, timestamp: Date.now(), id:
|
|
510
|
+
{ role: 'user', content: prompt, timestamp: Date.now(), id: randomUUID() },
|
|
330
511
|
],
|
|
331
512
|
tools: [],
|
|
332
513
|
signal: deps.signal,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* src/types.ts 的 LoopEvent union **不**包含 workflow_*,框架零插件耦合。
|
|
13
13
|
* ============================================================
|
|
14
14
|
*/
|
|
15
|
+
import { resolve, sep } from 'node:path';
|
|
15
16
|
// ---------------- 2. 变量栈(loop scope 用) ----------------
|
|
16
17
|
/**
|
|
17
18
|
* 简单的多帧变量栈:
|
|
@@ -20,9 +21,21 @@
|
|
|
20
21
|
* - 退出 loop 时 pop()
|
|
21
22
|
*
|
|
22
23
|
* get(key) 从栈顶往下找第一个匹配的 frame,类似 JS 的 lexical scope。
|
|
24
|
+
*
|
|
25
|
+
* ════════════════ 原型污染防护(v2) ════════════════
|
|
26
|
+
* 三层防线,任何一层破了都还有下一层兜底:
|
|
27
|
+
* ① 所有 frame 对象用 `Object.create(null)` 创建 → 根本没有原型访问器,
|
|
28
|
+
* obj["__proto__"] = {...} 不会触发 setter(因为 Object.create(null)
|
|
29
|
+
* 的 [[Prototype]] 是 null,没有 Object.prototype.__proto__ 那个 getter/
|
|
30
|
+
* setter),只会写成 own property。
|
|
31
|
+
* ② set* 写入口统一过滤 DANGEROUS_KEYS(`__proto__/constructor/prototype`)
|
|
32
|
+
* → 即使将来有人改回普通 {} 对象,写操作也到不了原型。
|
|
33
|
+
* ③ 读侧(expressions 点路径访问)另有 S1 黑名防护(parsePrimary L451),
|
|
34
|
+
* 即使值真写进去了也读不出来。
|
|
35
|
+
* ════════════════════════════════════════════════════
|
|
23
36
|
*/
|
|
24
37
|
export class VarStack {
|
|
25
|
-
frames = [
|
|
38
|
+
frames = [Object.create(null)];
|
|
26
39
|
/**
|
|
27
40
|
* 哪些帧是「循环迭代帧」—— 只为装 `as` / `as_idx` 这类**每轮重置**的迭代变量而生。
|
|
28
41
|
*
|
|
@@ -37,12 +50,24 @@ export class VarStack {
|
|
|
37
50
|
* 而不是「它只在这一轮迭代里活着」。
|
|
38
51
|
*/
|
|
39
52
|
iterationFrames = new Set();
|
|
53
|
+
/**
|
|
54
|
+
* 本次运行「当前有效工作目录」的解析器,供 fileExists / readJson 这类**读磁盘**的
|
|
55
|
+
* 内置函数取锚点用。不是 yaml 变量(不进 frames,`${cwd}` 取不到它)。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ 存的是**函数**不是字符串:worktree 是运行途中才进入的(模型调 EnterWorktree),
|
|
58
|
+
* 创建 VarStack 时固定成字符串的话,之后所有 done_when 仍会锚在旧目录上。
|
|
59
|
+
* 未设置时由调用方回落 getWorkingDir()。
|
|
60
|
+
*/
|
|
61
|
+
resolveCwd;
|
|
40
62
|
/**
|
|
41
63
|
* 写一个**作者命名**的变量(capture / set 步 / 引擎内部状态)。
|
|
42
64
|
*
|
|
43
65
|
* 穿透所有迭代帧,落到最近的非迭代帧上 —— 这样它在循环结束后依然存在。
|
|
66
|
+
* 危险键(__proto__/constructor/prototype)直接忽略(防线②)。
|
|
44
67
|
*/
|
|
45
68
|
set(key, val) {
|
|
69
|
+
if (DANGEROUS_KEYS.has(key))
|
|
70
|
+
return;
|
|
46
71
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
47
72
|
if (!this.iterationFrames.has(i)) {
|
|
48
73
|
this.frames[i][key] = val;
|
|
@@ -51,15 +76,24 @@ export class VarStack {
|
|
|
51
76
|
}
|
|
52
77
|
this.frames[0][key] = val;
|
|
53
78
|
}
|
|
54
|
-
/** 写一个**迭代变量**(`as` / `as_idx`):落在当前迭代帧,随 pop 一起消失。
|
|
79
|
+
/** 写一个**迭代变量**(`as` / `as_idx`):落在当前迭代帧,随 pop 一起消失。
|
|
80
|
+
* 危险键直接忽略(防线②)。 */
|
|
55
81
|
setIteration(key, val) {
|
|
82
|
+
if (DANGEROUS_KEYS.has(key))
|
|
83
|
+
return;
|
|
56
84
|
this.frames[this.frames.length - 1][key] = val;
|
|
57
85
|
}
|
|
58
|
-
/** 写到栈底(root frame),用于 inputs /
|
|
86
|
+
/** 写到栈底(root frame),用于 inputs / 全局常量。危险键直接忽略(防线②)。 */
|
|
59
87
|
setGlobal(key, val) {
|
|
88
|
+
if (DANGEROUS_KEYS.has(key))
|
|
89
|
+
return;
|
|
60
90
|
this.frames[0][key] = val;
|
|
61
91
|
}
|
|
62
92
|
get(key) {
|
|
93
|
+
// 读侧也过危险键:防止 Object.create(null) 上若后续被别的代码写入了 own property
|
|
94
|
+
// 形式的 __proto__,读出来再被其他地方当对象 key 造成二次污染。
|
|
95
|
+
if (DANGEROUS_KEYS.has(key))
|
|
96
|
+
return undefined;
|
|
63
97
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
64
98
|
if (key in this.frames[i])
|
|
65
99
|
return this.frames[i][key];
|
|
@@ -67,6 +101,8 @@ export class VarStack {
|
|
|
67
101
|
return undefined;
|
|
68
102
|
}
|
|
69
103
|
has(key) {
|
|
104
|
+
if (DANGEROUS_KEYS.has(key))
|
|
105
|
+
return false;
|
|
70
106
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
71
107
|
if (key in this.frames[i])
|
|
72
108
|
return true;
|
|
@@ -74,11 +110,11 @@ export class VarStack {
|
|
|
74
110
|
return false;
|
|
75
111
|
}
|
|
76
112
|
push() {
|
|
77
|
-
this.frames.push(
|
|
113
|
+
this.frames.push(Object.create(null));
|
|
78
114
|
}
|
|
79
115
|
/** 压一个迭代帧:只有 `setIteration` 写进来的东西会随 `pop` 消失。 */
|
|
80
116
|
pushIteration() {
|
|
81
|
-
this.frames.push(
|
|
117
|
+
this.frames.push(Object.create(null));
|
|
82
118
|
this.iterationFrames.add(this.frames.length - 1);
|
|
83
119
|
}
|
|
84
120
|
pop() {
|
|
@@ -87,9 +123,18 @@ export class VarStack {
|
|
|
87
123
|
this.frames.pop();
|
|
88
124
|
}
|
|
89
125
|
}
|
|
90
|
-
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars
|
|
126
|
+
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars 持久化与事件。
|
|
127
|
+
* 危险键不进入快照(双重保险:帧是 Object.create(null),读的时候也过不了 get)。*/
|
|
91
128
|
snapshot() {
|
|
92
|
-
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const frame of this.frames) {
|
|
131
|
+
for (const k of Object.keys(frame)) {
|
|
132
|
+
if (DANGEROUS_KEYS.has(k))
|
|
133
|
+
continue;
|
|
134
|
+
out[k] = frame[k];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
93
138
|
}
|
|
94
139
|
}
|
|
95
140
|
/**
|
|
@@ -109,3 +154,64 @@ export function normalizeCapture(cap) {
|
|
|
109
154
|
return { result: cap };
|
|
110
155
|
return cap;
|
|
111
156
|
}
|
|
157
|
+
// ---------------- 6. 路径安全守卫(declaredArtifacts / 表达式层共用) ----------------
|
|
158
|
+
/**
|
|
159
|
+
* 规范化 + cwd 范围校验的产物路径。
|
|
160
|
+
*
|
|
161
|
+
* 安全背景:declaredArtifacts 来自 YAML 定义,若作者写 "../../.ssh/id_rsa",
|
|
162
|
+
* path.join(workingDir, p) 会跟随 ../ 跳出工作目录,new-task 清理时就可能
|
|
163
|
+
* 删掉工作目录外的文件(唯一能造成真实数据损失的入口)。
|
|
164
|
+
*
|
|
165
|
+
* 行为:
|
|
166
|
+
* - 先用 resolve(workingDir, rawPath) 得到绝对路径(自动归一化 ../)
|
|
167
|
+
* - 再检查它是否严格在 resolve(workingDir) 目录树之内
|
|
168
|
+
* - 命中:返回【相对 workingDir 的规范化相对路径】(./ 前缀去除、\\→/),
|
|
169
|
+
* 保证后续 join(workingDir, result) 一定不会越界
|
|
170
|
+
* - 越界/空串/解析失败:返回 null,调用方应跳过(fail-closed)
|
|
171
|
+
*
|
|
172
|
+
* 跨平台:win32 上 compare 前双方都 normalizeCase(lowercase),其他平台保持大小写敏感。
|
|
173
|
+
*/
|
|
174
|
+
export function safeArtifactPath(workingDir, rawPath) {
|
|
175
|
+
if (!rawPath || typeof rawPath !== 'string')
|
|
176
|
+
return null;
|
|
177
|
+
const base = resolve(workingDir);
|
|
178
|
+
// 必须加路径分隔符作为前缀匹配边界:防止 base=/repo 匹配到 /repo-hack/evil
|
|
179
|
+
const baseWithSep = base.endsWith(sep) ? base : base + sep;
|
|
180
|
+
let abs;
|
|
181
|
+
try {
|
|
182
|
+
abs = resolve(workingDir, rawPath);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const sameCase = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
|
|
188
|
+
const nAbs = sameCase(abs);
|
|
189
|
+
const nBase = sameCase(base);
|
|
190
|
+
const nBaseSep = sameCase(baseWithSep);
|
|
191
|
+
// 恰好等于 base 本身(极端的 rawPath='.' 情形)也视为合法,但 caller 一般
|
|
192
|
+
// 用的是具体文件;这里允许不报错。
|
|
193
|
+
if (nAbs !== nBase && !nAbs.startsWith(nBaseSep))
|
|
194
|
+
return null;
|
|
195
|
+
// 导出【相对 workingDir 的相对路径】(去掉 base 前缀)
|
|
196
|
+
const rel = abs.slice(base.length).replace(/^[/\\]/, '');
|
|
197
|
+
return rel.length === 0 ? '.' : rel;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 表达式/任意场景的通用「绝对路径是否在 cwd 内」判定。
|
|
201
|
+
* - abs 必须是【已 resolve 后的绝对路径】
|
|
202
|
+
* - 越界/空 → false
|
|
203
|
+
*/
|
|
204
|
+
export function isAbsWithinCwd(workingDir, abs) {
|
|
205
|
+
if (!abs)
|
|
206
|
+
return false;
|
|
207
|
+
const base = resolve(workingDir);
|
|
208
|
+
const baseWithSep = base.endsWith(sep) ? base : base + sep;
|
|
209
|
+
const sameCase = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
|
|
210
|
+
const nAbs = sameCase(abs);
|
|
211
|
+
return nAbs === sameCase(base) || nAbs.startsWith(sameCase(baseWithSep));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 危险键黑名单:VarStack.set* / interpolateDeep 对象 key / 点路径访问统一过滤。
|
|
215
|
+
* 防止原型污染 / 构造函数逃逸。与 expressions.ts parsePrimary S1 白名单保持一致。
|
|
216
|
+
*/
|
|
217
|
+
export const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* ============================================================
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { resolve, sep } from 'node:path';
|
|
17
|
+
|
|
16
18
|
import type {
|
|
17
19
|
ImageDetail,
|
|
18
20
|
Message,
|
|
@@ -320,9 +322,21 @@ export interface WorkflowDef {
|
|
|
320
322
|
* - 退出 loop 时 pop()
|
|
321
323
|
*
|
|
322
324
|
* get(key) 从栈顶往下找第一个匹配的 frame,类似 JS 的 lexical scope。
|
|
325
|
+
*
|
|
326
|
+
* ════════════════ 原型污染防护(v2) ════════════════
|
|
327
|
+
* 三层防线,任何一层破了都还有下一层兜底:
|
|
328
|
+
* ① 所有 frame 对象用 `Object.create(null)` 创建 → 根本没有原型访问器,
|
|
329
|
+
* obj["__proto__"] = {...} 不会触发 setter(因为 Object.create(null)
|
|
330
|
+
* 的 [[Prototype]] 是 null,没有 Object.prototype.__proto__ 那个 getter/
|
|
331
|
+
* setter),只会写成 own property。
|
|
332
|
+
* ② set* 写入口统一过滤 DANGEROUS_KEYS(`__proto__/constructor/prototype`)
|
|
333
|
+
* → 即使将来有人改回普通 {} 对象,写操作也到不了原型。
|
|
334
|
+
* ③ 读侧(expressions 点路径访问)另有 S1 黑名防护(parsePrimary L451),
|
|
335
|
+
* 即使值真写进去了也读不出来。
|
|
336
|
+
* ════════════════════════════════════════════════════
|
|
323
337
|
*/
|
|
324
338
|
export class VarStack {
|
|
325
|
-
private frames: Record<string, unknown>[] = [
|
|
339
|
+
private frames: Record<string, unknown>[] = [Object.create(null)];
|
|
326
340
|
/**
|
|
327
341
|
* 哪些帧是「循环迭代帧」—— 只为装 `as` / `as_idx` 这类**每轮重置**的迭代变量而生。
|
|
328
342
|
*
|
|
@@ -338,12 +352,24 @@ export class VarStack {
|
|
|
338
352
|
*/
|
|
339
353
|
private iterationFrames = new Set<number>();
|
|
340
354
|
|
|
355
|
+
/**
|
|
356
|
+
* 本次运行「当前有效工作目录」的解析器,供 fileExists / readJson 这类**读磁盘**的
|
|
357
|
+
* 内置函数取锚点用。不是 yaml 变量(不进 frames,`${cwd}` 取不到它)。
|
|
358
|
+
*
|
|
359
|
+
* ⚠️ 存的是**函数**不是字符串:worktree 是运行途中才进入的(模型调 EnterWorktree),
|
|
360
|
+
* 创建 VarStack 时固定成字符串的话,之后所有 done_when 仍会锚在旧目录上。
|
|
361
|
+
* 未设置时由调用方回落 getWorkingDir()。
|
|
362
|
+
*/
|
|
363
|
+
resolveCwd?: () => string;
|
|
364
|
+
|
|
341
365
|
/**
|
|
342
366
|
* 写一个**作者命名**的变量(capture / set 步 / 引擎内部状态)。
|
|
343
367
|
*
|
|
344
368
|
* 穿透所有迭代帧,落到最近的非迭代帧上 —— 这样它在循环结束后依然存在。
|
|
369
|
+
* 危险键(__proto__/constructor/prototype)直接忽略(防线②)。
|
|
345
370
|
*/
|
|
346
371
|
set(key: string, val: unknown): void {
|
|
372
|
+
if (DANGEROUS_KEYS.has(key)) return;
|
|
347
373
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
348
374
|
if (!this.iterationFrames.has(i)) {
|
|
349
375
|
this.frames[i][key] = val;
|
|
@@ -353,17 +379,23 @@ export class VarStack {
|
|
|
353
379
|
this.frames[0][key] = val;
|
|
354
380
|
}
|
|
355
381
|
|
|
356
|
-
/** 写一个**迭代变量**(`as` / `as_idx`):落在当前迭代帧,随 pop 一起消失。
|
|
382
|
+
/** 写一个**迭代变量**(`as` / `as_idx`):落在当前迭代帧,随 pop 一起消失。
|
|
383
|
+
* 危险键直接忽略(防线②)。 */
|
|
357
384
|
setIteration(key: string, val: unknown): void {
|
|
385
|
+
if (DANGEROUS_KEYS.has(key)) return;
|
|
358
386
|
this.frames[this.frames.length - 1][key] = val;
|
|
359
387
|
}
|
|
360
388
|
|
|
361
|
-
/** 写到栈底(root frame),用于 inputs /
|
|
389
|
+
/** 写到栈底(root frame),用于 inputs / 全局常量。危险键直接忽略(防线②)。 */
|
|
362
390
|
setGlobal(key: string, val: unknown): void {
|
|
391
|
+
if (DANGEROUS_KEYS.has(key)) return;
|
|
363
392
|
this.frames[0][key] = val;
|
|
364
393
|
}
|
|
365
394
|
|
|
366
395
|
get(key: string): unknown {
|
|
396
|
+
// 读侧也过危险键:防止 Object.create(null) 上若后续被别的代码写入了 own property
|
|
397
|
+
// 形式的 __proto__,读出来再被其他地方当对象 key 造成二次污染。
|
|
398
|
+
if (DANGEROUS_KEYS.has(key)) return undefined;
|
|
367
399
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
368
400
|
if (key in this.frames[i]) return this.frames[i][key];
|
|
369
401
|
}
|
|
@@ -371,6 +403,7 @@ export class VarStack {
|
|
|
371
403
|
}
|
|
372
404
|
|
|
373
405
|
has(key: string): boolean {
|
|
406
|
+
if (DANGEROUS_KEYS.has(key)) return false;
|
|
374
407
|
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
375
408
|
if (key in this.frames[i]) return true;
|
|
376
409
|
}
|
|
@@ -378,12 +411,12 @@ export class VarStack {
|
|
|
378
411
|
}
|
|
379
412
|
|
|
380
413
|
push(): void {
|
|
381
|
-
this.frames.push(
|
|
414
|
+
this.frames.push(Object.create(null));
|
|
382
415
|
}
|
|
383
416
|
|
|
384
417
|
/** 压一个迭代帧:只有 `setIteration` 写进来的东西会随 `pop` 消失。 */
|
|
385
418
|
pushIteration(): void {
|
|
386
|
-
this.frames.push(
|
|
419
|
+
this.frames.push(Object.create(null));
|
|
387
420
|
this.iterationFrames.add(this.frames.length - 1);
|
|
388
421
|
}
|
|
389
422
|
|
|
@@ -394,9 +427,17 @@ export class VarStack {
|
|
|
394
427
|
}
|
|
395
428
|
}
|
|
396
429
|
|
|
397
|
-
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars
|
|
430
|
+
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars 持久化与事件。
|
|
431
|
+
* 危险键不进入快照(双重保险:帧是 Object.create(null),读的时候也过不了 get)。*/
|
|
398
432
|
snapshot(): Record<string, unknown> {
|
|
399
|
-
|
|
433
|
+
const out: Record<string, unknown> = {};
|
|
434
|
+
for (const frame of this.frames) {
|
|
435
|
+
for (const k of Object.keys(frame)) {
|
|
436
|
+
if (DANGEROUS_KEYS.has(k)) continue;
|
|
437
|
+
out[k] = frame[k];
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
400
441
|
}
|
|
401
442
|
}
|
|
402
443
|
|
|
@@ -600,3 +641,66 @@ export type WorkflowEvent =
|
|
|
600
641
|
}
|
|
601
642
|
| { type: 'workflow_step_skipped'; id: string; reason: string }
|
|
602
643
|
| { type: 'workflow_done'; name: string; vars: Record<string, unknown> };
|
|
644
|
+
|
|
645
|
+
// ---------------- 6. 路径安全守卫(declaredArtifacts / 表达式层共用) ----------------
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* 规范化 + cwd 范围校验的产物路径。
|
|
649
|
+
*
|
|
650
|
+
* 安全背景:declaredArtifacts 来自 YAML 定义,若作者写 "../../.ssh/id_rsa",
|
|
651
|
+
* path.join(workingDir, p) 会跟随 ../ 跳出工作目录,new-task 清理时就可能
|
|
652
|
+
* 删掉工作目录外的文件(唯一能造成真实数据损失的入口)。
|
|
653
|
+
*
|
|
654
|
+
* 行为:
|
|
655
|
+
* - 先用 resolve(workingDir, rawPath) 得到绝对路径(自动归一化 ../)
|
|
656
|
+
* - 再检查它是否严格在 resolve(workingDir) 目录树之内
|
|
657
|
+
* - 命中:返回【相对 workingDir 的规范化相对路径】(./ 前缀去除、\\→/),
|
|
658
|
+
* 保证后续 join(workingDir, result) 一定不会越界
|
|
659
|
+
* - 越界/空串/解析失败:返回 null,调用方应跳过(fail-closed)
|
|
660
|
+
*
|
|
661
|
+
* 跨平台:win32 上 compare 前双方都 normalizeCase(lowercase),其他平台保持大小写敏感。
|
|
662
|
+
*/
|
|
663
|
+
export function safeArtifactPath(workingDir: string, rawPath: string): string | null {
|
|
664
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
665
|
+
const base = resolve(workingDir);
|
|
666
|
+
// 必须加路径分隔符作为前缀匹配边界:防止 base=/repo 匹配到 /repo-hack/evil
|
|
667
|
+
const baseWithSep = base.endsWith(sep) ? base : base + sep;
|
|
668
|
+
let abs: string;
|
|
669
|
+
try {
|
|
670
|
+
abs = resolve(workingDir, rawPath);
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
const sameCase = (p: string): string =>
|
|
675
|
+
process.platform === 'win32' ? p.toLowerCase() : p;
|
|
676
|
+
const nAbs = sameCase(abs);
|
|
677
|
+
const nBase = sameCase(base);
|
|
678
|
+
const nBaseSep = sameCase(baseWithSep);
|
|
679
|
+
// 恰好等于 base 本身(极端的 rawPath='.' 情形)也视为合法,但 caller 一般
|
|
680
|
+
// 用的是具体文件;这里允许不报错。
|
|
681
|
+
if (nAbs !== nBase && !nAbs.startsWith(nBaseSep)) return null;
|
|
682
|
+
// 导出【相对 workingDir 的相对路径】(去掉 base 前缀)
|
|
683
|
+
const rel = abs.slice(base.length).replace(/^[/\\]/, '');
|
|
684
|
+
return rel.length === 0 ? '.' : rel;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 表达式/任意场景的通用「绝对路径是否在 cwd 内」判定。
|
|
689
|
+
* - abs 必须是【已 resolve 后的绝对路径】
|
|
690
|
+
* - 越界/空 → false
|
|
691
|
+
*/
|
|
692
|
+
export function isAbsWithinCwd(workingDir: string, abs: string): boolean {
|
|
693
|
+
if (!abs) return false;
|
|
694
|
+
const base = resolve(workingDir);
|
|
695
|
+
const baseWithSep = base.endsWith(sep) ? base : base + sep;
|
|
696
|
+
const sameCase = (p: string): string =>
|
|
697
|
+
process.platform === 'win32' ? p.toLowerCase() : p;
|
|
698
|
+
const nAbs = sameCase(abs);
|
|
699
|
+
return nAbs === sameCase(base) || nAbs.startsWith(sameCase(baseWithSep));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 危险键黑名单:VarStack.set* / interpolateDeep 对象 key / 点路径访问统一过滤。
|
|
704
|
+
* 防止原型污染 / 构造函数逃逸。与 expressions.ts parsePrimary S1 白名单保持一致。
|
|
705
|
+
*/
|
|
706
|
+
export const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|