odai-dsh-agent 0.2.21 → 0.2.23
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/LICENSE +21 -21
- package/README.md +155 -157
- package/build/bin/odai-dsh-agent.mjs +18 -36
- package/build/bin/odai-dsh-agent.mjs.map +1 -1
- package/build/src/installer.d.mts +1 -6
- package/build/src/installer.d.mts.map +1 -1
- package/build/src/installer.mjs +3 -83
- package/build/src/installer.mjs.map +1 -1
- package/client/client.js +36 -58
- package/control-center.cordis.patch.yml +3 -3
- package/package.json +90 -90
- package/preset/odai/agent.cordis.yml +260 -260
- package/preset/odai/preset.yml +3 -3
- package/preset/odai/runtime/control-center-runtime.d.mts +1 -0
- package/preset/odai/runtime/control-center-runtime.d.mts.map +1 -1
- package/preset/odai/runtime/control-center-runtime.mjs +20 -0
- package/preset/odai/runtime/control-center-runtime.mjs.map +1 -1
- package/preset/odai/runtime/index.d.mts.map +1 -1
- package/preset/odai/runtime/index.mjs +9 -7
- package/preset/odai/runtime/index.mjs.map +1 -1
- package/preset/odai/runtime/{session-compat.d.mts → legacy-session-repair.d.mts} +7 -7
- package/preset/odai/runtime/legacy-session-repair.d.mts.map +1 -0
- package/preset/odai/runtime/{session-compat.mjs → legacy-session-repair.mjs} +1 -1
- package/preset/odai/runtime/legacy-session-repair.mjs.map +1 -0
- package/preset/odai/runtime/lifecycle-runtime.d.mts.map +1 -1
- package/preset/odai/runtime/lifecycle-runtime.mjs +10 -2
- package/preset/odai/runtime/lifecycle-runtime.mjs.map +1 -1
- package/preset/odai/runtime/responsibility-gap.d.mts +19 -1
- package/preset/odai/runtime/responsibility-gap.d.mts.map +1 -1
- package/preset/odai/runtime/responsibility-gap.mjs +180 -16
- package/preset/odai/runtime/responsibility-gap.mjs.map +1 -1
- package/preset/odai/runtime/role-overlays.mjs +9 -9
- package/preset/odai/runtime/role-overlays.mjs.map +1 -1
- package/preset/odai/runtime/router.d.mts +1 -0
- package/preset/odai/runtime/router.d.mts.map +1 -1
- package/preset/odai/runtime/router.mjs +5 -1
- package/preset/odai/runtime/router.mjs.map +1 -1
- package/preset/odai/runtime/routing-context.d.mts +9 -2
- package/preset/odai/runtime/routing-context.d.mts.map +1 -1
- package/preset/odai/runtime/routing-context.mjs +30 -9
- package/preset/odai/runtime/routing-context.mjs.map +1 -1
- package/preset/odai/runtime/runtime-support.d.mts +0 -1
- package/preset/odai/runtime/runtime-support.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-support.mjs.map +1 -1
- package/preset/odai/runtime/runtime-types.d.mts +1 -3
- package/preset/odai/runtime/runtime-types.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-types.mjs +1 -5
- package/preset/odai/runtime/runtime-types.mjs.map +1 -1
- package/preset/odai/runtime/session-evidence.d.mts.map +1 -1
- package/preset/odai/runtime/session-evidence.mjs +1 -3
- package/preset/odai/runtime/session-evidence.mjs.map +1 -1
- package/preset/odai/runtime/tool-runtime.d.mts.map +1 -1
- package/preset/odai/runtime/tool-runtime.mjs +23 -3
- package/preset/odai/runtime/tool-runtime.mjs.map +1 -1
- package/preset/odai/skills/odai/SKILL.md +68 -68
- package/preset/odai/skills/odai/agents/openai.yaml +4 -4
- package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -11
- package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -11
- package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -5
- package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -13
- package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -24
- package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -15
- package/preset/odai/skills/odai/assets/routing-roles/frontend.md +7 -7
- package/preset/odai/skills/odai/assets/routing-roles/planner.md +28 -28
- package/preset/odai/skills/odai/assets/routing-roles/researcher.md +6 -6
- package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -11
- package/preset/odai/skills/odai/assets/task-state.md +14 -14
- package/preset/odai/skills/odai/manifest.json +52 -52
- package/preset/odai/skills/odai/references/craft.md +54 -54
- package/preset/odai/skills/odai/references/dao.md +31 -31
- package/preset/odai/skills/odai/references/leverage.md +50 -50
- package/preset/odai/skills/odai/references/planning.md +83 -83
- package/preset/odai/skills/odai/references/support.md +30 -30
- package/preset/odai/skills/odai/references/verification.md +24 -24
- package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -273
- package/preset/odai/skills/odai/scripts/build-routing.mjs +315 -315
- package/preset/odai/skills/odai/scripts/install-routing.mjs +393 -393
- package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -457
- package/preset/odai/skills/odai/scripts/run-role.mjs +285 -285
- package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -103
- package/preset/odai/runtime/session-compat.d.mts.map +0 -1
- package/preset/odai/runtime/session-compat.mjs.map +0 -1
|
@@ -1,457 +1,457 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
|
|
8
|
-
const WRITE_TOOLS = new Set([
|
|
9
|
-
"apply_patch",
|
|
10
|
-
"create",
|
|
11
|
-
"edit",
|
|
12
|
-
"editfile",
|
|
13
|
-
"editfiles",
|
|
14
|
-
"multiedit",
|
|
15
|
-
"replace",
|
|
16
|
-
"str_replace_editor",
|
|
17
|
-
"write",
|
|
18
|
-
"write_file",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
export function loadPolicy(projectRoot) {
|
|
22
|
-
const policyPath = path.join(projectRoot, ".odai", "hooks.json");
|
|
23
|
-
if (!existsSync(policyPath)) return { policy: null, policyPath };
|
|
24
|
-
|
|
25
|
-
let value;
|
|
26
|
-
try {
|
|
27
|
-
value = JSON.parse(readFileSync(policyPath, "utf8"));
|
|
28
|
-
} catch (error) {
|
|
29
|
-
throw new Error(`${displayPath(projectRoot, policyPath)} 不是有效 JSON:${error.message}`);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
validatePolicy(value);
|
|
33
|
-
return { policy: value, policyPath };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function evaluatePreTool(payload, policy, projectRoot) {
|
|
37
|
-
if (!policy) return { blocked: false };
|
|
38
|
-
|
|
39
|
-
const toolName = String(payload.tool_name ?? payload.toolName ?? "").toLowerCase();
|
|
40
|
-
const toolInput = parseToolInput(payload.tool_input ?? payload.toolInput ?? payload.toolArgs);
|
|
41
|
-
if (!WRITE_TOOLS.has(toolName) && !looksLikePatch(toolInput)) return { blocked: false };
|
|
42
|
-
|
|
43
|
-
const cwd = resolveSessionCwd(payload.cwd, projectRoot);
|
|
44
|
-
const resolved = extractPaths(toolInput).map((raw) => ({ raw, ...resolveCandidate(raw, cwd, projectRoot) }));
|
|
45
|
-
|
|
46
|
-
if (policy.blockOutsideWrites === true) {
|
|
47
|
-
const escaped = resolved.find((item) => item.outside);
|
|
48
|
-
if (escaped) {
|
|
49
|
-
return {
|
|
50
|
-
blocked: true,
|
|
51
|
-
reason: `odai hook:${escaped.raw} ${escaped.viaSymlink ? "经由符号链接指向" : "位于"}项目根目录之外;项目启用了 blockOutsideWrites,写入被拒绝。`,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const candidates = resolved.flatMap((item) => item.paths);
|
|
57
|
-
|
|
58
|
-
const protectedPath = candidates.find((candidate) =>
|
|
59
|
-
policy.protectedPaths.some((pattern) => matchGlob(candidate, pattern)),
|
|
60
|
-
);
|
|
61
|
-
if (protectedPath) {
|
|
62
|
-
return {
|
|
63
|
-
blocked: true,
|
|
64
|
-
reason: `odai hook:${protectedPath} 命中项目只读路径;参考或保护对象不得被当前写入修改。`,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (policy.blockUnresolvedWrites === true && candidates.length === 0) {
|
|
69
|
-
return {
|
|
70
|
-
blocked: true,
|
|
71
|
-
reason: `odai hook:无法从 ${toolName || "当前写工具"} 解析目标路径;项目启用了 blockUnresolvedWrites,需先明确写入对象。`,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return { blocked: false };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function evaluateStop(payload, policy, projectRoot) {
|
|
79
|
-
if (!policy || policy.checks.length === 0) return { blocked: false };
|
|
80
|
-
if (payload.stop_hook_active === true || payload.stopHookActive === true) return { blocked: false };
|
|
81
|
-
|
|
82
|
-
const changed = collectChangedPaths(projectRoot);
|
|
83
|
-
if (!changed.ok && policy.checks.some((check) => check.always !== true)) {
|
|
84
|
-
return {
|
|
85
|
-
blocked: true,
|
|
86
|
-
reason: `odai hook:无法确定变更范围,不能选择已声明验收:${changed.error}`,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const selected = policy.checks.filter(
|
|
91
|
-
(check) =>
|
|
92
|
-
check.always === true ||
|
|
93
|
-
changed.paths.some((changedPath) => check.whenChanged.some((pattern) => matchGlob(changedPath, pattern))),
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
for (const check of selected) {
|
|
97
|
-
const cwd = path.resolve(projectRoot, check.cwd ?? ".");
|
|
98
|
-
if (!isInside(projectRoot, cwd)) {
|
|
99
|
-
return {
|
|
100
|
-
blocked: true,
|
|
101
|
-
reason: `odai hook:验收 ${check.name} 的 cwd 越出项目根,拒绝执行。`,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const result = spawnSync(check.run[0], check.run.slice(1), {
|
|
106
|
-
cwd,
|
|
107
|
-
encoding: "utf8",
|
|
108
|
-
timeout: check.timeoutSeconds * 1000,
|
|
109
|
-
maxBuffer: 1024 * 1024,
|
|
110
|
-
windowsHide: true,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
if (result.error) {
|
|
114
|
-
return {
|
|
115
|
-
blocked: true,
|
|
116
|
-
reason: `odai hook:验收 ${check.name} 无法执行:${result.error.message}`,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
if (result.status !== 0) {
|
|
120
|
-
const detail = compactFailure(result.stderr || result.stdout);
|
|
121
|
-
return {
|
|
122
|
-
blocked: true,
|
|
123
|
-
reason: `odai hook:验收 ${check.name} 未通过${detail ? `:${detail}` : `(exit ${String(result.status)})`}。修复或说明阻断后再收口。`,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { blocked: false };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function findProjectRoot(cwd) {
|
|
132
|
-
const candidate = path.resolve(cwd || process.cwd());
|
|
133
|
-
const result = spawnSync("git", ["-C", candidate, "rev-parse", "--show-toplevel"], {
|
|
134
|
-
encoding: "utf8",
|
|
135
|
-
windowsHide: true,
|
|
136
|
-
});
|
|
137
|
-
if (result.status === 0 && result.stdout.trim()) return path.resolve(result.stdout.trim());
|
|
138
|
-
return candidate;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function matchGlob(value, pattern) {
|
|
142
|
-
const normalizedValue = normalizeSlashes(value).replace(/^\.\/+/, "");
|
|
143
|
-
const normalizedPattern = normalizeSlashes(pattern).replace(/^\.\/+/, "");
|
|
144
|
-
return globToRegExp(normalizedPattern).test(normalizedValue);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function main() {
|
|
148
|
-
const action = process.argv[2];
|
|
149
|
-
const host = readFlag("--host") || "generic";
|
|
150
|
-
if (!new Set(["pre-tool", "stop"]).has(action)) {
|
|
151
|
-
console.error("Usage: odai-hook.mjs <pre-tool|stop> --host <host>");
|
|
152
|
-
process.exitCode = 1;
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
let payload;
|
|
157
|
-
try {
|
|
158
|
-
payload = JSON.parse((await readStdin()).trim() || "{}");
|
|
159
|
-
} catch (error) {
|
|
160
|
-
block(action, host, `odai hook:宿主输入不是有效 JSON:${error.message}`);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const projectRoot = findProjectRoot(payload.cwd);
|
|
165
|
-
let policy;
|
|
166
|
-
try {
|
|
167
|
-
({ policy } = loadPolicy(projectRoot));
|
|
168
|
-
} catch (error) {
|
|
169
|
-
block(action, host, `odai hook:策略无效:${error.message}`);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (!policy) return;
|
|
173
|
-
|
|
174
|
-
const result =
|
|
175
|
-
action === "pre-tool"
|
|
176
|
-
? evaluatePreTool(payload, policy, projectRoot)
|
|
177
|
-
: evaluateStop(payload, policy, projectRoot);
|
|
178
|
-
if (result.blocked) block(action, host, result.reason);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function validatePolicy(value) {
|
|
182
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
183
|
-
throw new Error("根值必须是对象");
|
|
184
|
-
}
|
|
185
|
-
rejectUnknownKeys(
|
|
186
|
-
value,
|
|
187
|
-
new Set(["version", "protectedPaths", "blockUnresolvedWrites", "blockOutsideWrites", "checks"]),
|
|
188
|
-
"策略",
|
|
189
|
-
);
|
|
190
|
-
if (value.version !== 1) throw new Error("version 必须为 1");
|
|
191
|
-
|
|
192
|
-
if (value.protectedPaths === undefined) value.protectedPaths = [];
|
|
193
|
-
if (!Array.isArray(value.protectedPaths) || value.protectedPaths.some((item) => !isNonEmptyString(item))) {
|
|
194
|
-
throw new Error("protectedPaths 必须是非空字符串数组");
|
|
195
|
-
}
|
|
196
|
-
for (const pattern of value.protectedPaths) validatePattern(pattern, "protectedPaths");
|
|
197
|
-
|
|
198
|
-
if (value.blockUnresolvedWrites !== undefined && typeof value.blockUnresolvedWrites !== "boolean") {
|
|
199
|
-
throw new Error("blockUnresolvedWrites 必须是布尔值");
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (value.blockOutsideWrites !== undefined && typeof value.blockOutsideWrites !== "boolean") {
|
|
203
|
-
throw new Error("blockOutsideWrites 必须是布尔值");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (value.checks === undefined) value.checks = [];
|
|
207
|
-
if (!Array.isArray(value.checks)) throw new Error("checks 必须是数组");
|
|
208
|
-
for (const [index, check] of value.checks.entries()) {
|
|
209
|
-
if (!check || typeof check !== "object" || Array.isArray(check)) {
|
|
210
|
-
throw new Error(`checks[${index}] 必须是对象`);
|
|
211
|
-
}
|
|
212
|
-
rejectUnknownKeys(
|
|
213
|
-
check,
|
|
214
|
-
new Set(["name", "whenChanged", "always", "run", "cwd", "timeoutSeconds"]),
|
|
215
|
-
`checks[${index}]`,
|
|
216
|
-
);
|
|
217
|
-
if (!isNonEmptyString(check.name)) throw new Error(`checks[${index}].name 必须是非空字符串`);
|
|
218
|
-
if (!Array.isArray(check.run) || check.run.length === 0 || check.run.some((item) => !isNonEmptyString(item))) {
|
|
219
|
-
throw new Error(`checks[${index}].run 必须是非空字符串数组`);
|
|
220
|
-
}
|
|
221
|
-
if (check.always !== true) {
|
|
222
|
-
if (!Array.isArray(check.whenChanged) || check.whenChanged.length === 0 || check.whenChanged.some((item) => !isNonEmptyString(item))) {
|
|
223
|
-
throw new Error(`checks[${index}].whenChanged 必须是非空字符串数组,或设置 always: true`);
|
|
224
|
-
}
|
|
225
|
-
for (const pattern of check.whenChanged) validatePattern(pattern, `checks[${index}].whenChanged`);
|
|
226
|
-
}
|
|
227
|
-
if (check.cwd !== undefined && !isNonEmptyString(check.cwd)) {
|
|
228
|
-
throw new Error(`checks[${index}].cwd 必须是非空字符串`);
|
|
229
|
-
}
|
|
230
|
-
if (
|
|
231
|
-
check.timeoutSeconds !== undefined &&
|
|
232
|
-
(!Number.isInteger(check.timeoutSeconds) || check.timeoutSeconds < 1 || check.timeoutSeconds > 600)
|
|
233
|
-
) {
|
|
234
|
-
throw new Error(`checks[${index}].timeoutSeconds 必须是 1-600 的整数`);
|
|
235
|
-
}
|
|
236
|
-
check.timeoutSeconds ??= 120;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function parseToolInput(value) {
|
|
241
|
-
if (typeof value !== "string") return value ?? {};
|
|
242
|
-
try {
|
|
243
|
-
return JSON.parse(value);
|
|
244
|
-
} catch {
|
|
245
|
-
return value;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function extractPaths(value) {
|
|
250
|
-
const result = [];
|
|
251
|
-
const pathKeys = new Set([
|
|
252
|
-
"file",
|
|
253
|
-
"file_path",
|
|
254
|
-
"filePath",
|
|
255
|
-
"filename",
|
|
256
|
-
"path",
|
|
257
|
-
"target",
|
|
258
|
-
"target_file",
|
|
259
|
-
"targetPath",
|
|
260
|
-
]);
|
|
261
|
-
|
|
262
|
-
function visit(node, key = "") {
|
|
263
|
-
if (typeof node === "string") {
|
|
264
|
-
if (pathKeys.has(key)) result.push(node);
|
|
265
|
-
if (looksLikePatch(node)) result.push(...extractPatchPaths(node));
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
if (Array.isArray(node)) {
|
|
269
|
-
for (const item of node) visit(item, key);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
if (!node || typeof node !== "object") return;
|
|
273
|
-
for (const [childKey, child] of Object.entries(node)) visit(child, childKey);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
visit(value);
|
|
277
|
-
return [...new Set(result.map(cleanCandidate).filter(Boolean))];
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function extractPatchPaths(value) {
|
|
281
|
-
const result = [];
|
|
282
|
-
for (const line of value.split(/\r?\n/)) {
|
|
283
|
-
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/) ||
|
|
284
|
-
line.match(/^\*\*\* Move to:\s*(.+?)\s*$/);
|
|
285
|
-
if (match) result.push(match[1]);
|
|
286
|
-
}
|
|
287
|
-
return result;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function looksLikePatch(value) {
|
|
291
|
-
if (typeof value === "string") return value.includes("*** Begin Patch");
|
|
292
|
-
if (!value || typeof value !== "object") return false;
|
|
293
|
-
return Object.values(value).some((item) => typeof item === "string" && item.includes("*** Begin Patch"));
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function resolveRealOrNearest(absolute) {
|
|
297
|
-
let dir = absolute;
|
|
298
|
-
const trailing = [];
|
|
299
|
-
while (true) {
|
|
300
|
-
try {
|
|
301
|
-
const real = realpathSync(dir);
|
|
302
|
-
return path.join(real, ...trailing.reverse());
|
|
303
|
-
} catch {
|
|
304
|
-
const parent = path.dirname(dir);
|
|
305
|
-
if (parent === dir) return absolute;
|
|
306
|
-
trailing.push(path.basename(dir));
|
|
307
|
-
dir = parent;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function resolveCandidate(candidate, cwd, projectRoot) {
|
|
313
|
-
const empty = { outside: false, viaSymlink: false, paths: [] };
|
|
314
|
-
if (!candidate || candidate.includes("\0")) return empty;
|
|
315
|
-
const absolute = path.isAbsolute(candidate) ? path.normalize(candidate) : path.resolve(cwd, candidate);
|
|
316
|
-
const lexicalInside = isInside(projectRoot, absolute);
|
|
317
|
-
const real = resolveRealOrNearest(absolute);
|
|
318
|
-
if (!isInside(projectRoot, real)) return { outside: true, viaSymlink: lexicalInside, paths: [] };
|
|
319
|
-
|
|
320
|
-
// 目标落在项目内:按真实路径匹配只读规则,别名路径也保留,两种写法都不能绕过 protectedPaths。
|
|
321
|
-
const canonical = normalizeSlashes(path.relative(projectRoot, real));
|
|
322
|
-
const paths = [canonical];
|
|
323
|
-
if (lexicalInside) {
|
|
324
|
-
const lexical = normalizeSlashes(path.relative(projectRoot, absolute));
|
|
325
|
-
if (lexical !== canonical) paths.push(lexical);
|
|
326
|
-
}
|
|
327
|
-
return { outside: false, viaSymlink: false, paths };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function resolveSessionCwd(cwd, projectRoot) {
|
|
331
|
-
if (!cwd) return projectRoot;
|
|
332
|
-
const resolved = path.resolve(cwd);
|
|
333
|
-
return isInside(projectRoot, resolved) ? resolved : projectRoot;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function collectChangedPaths(projectRoot) {
|
|
337
|
-
const commands = [
|
|
338
|
-
["diff", "--name-only", "-z", "--diff-filter=ACMRD"],
|
|
339
|
-
["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMRD"],
|
|
340
|
-
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
341
|
-
];
|
|
342
|
-
const paths = new Set();
|
|
343
|
-
for (const args of commands) {
|
|
344
|
-
const result = spawnSync("git", ["-C", projectRoot, ...args], {
|
|
345
|
-
encoding: "utf8",
|
|
346
|
-
windowsHide: true,
|
|
347
|
-
});
|
|
348
|
-
if (result.status !== 0) {
|
|
349
|
-
return { ok: false, paths: [], error: compactFailure(result.stderr) || `git ${args[0]} 失败` };
|
|
350
|
-
}
|
|
351
|
-
for (const item of result.stdout.split("\0")) {
|
|
352
|
-
if (item) paths.add(normalizeSlashes(item));
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return { ok: true, paths: [...paths] };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function globToRegExp(pattern) {
|
|
359
|
-
let source = "^";
|
|
360
|
-
for (let index = 0; index < pattern.length; index += 1) {
|
|
361
|
-
const char = pattern[index];
|
|
362
|
-
if (char === "*") {
|
|
363
|
-
if (pattern[index + 1] === "*") {
|
|
364
|
-
const followedBySlash = pattern[index + 2] === "/";
|
|
365
|
-
source += followedBySlash ? "(?:.*/)?" : ".*";
|
|
366
|
-
index += followedBySlash ? 2 : 1;
|
|
367
|
-
} else {
|
|
368
|
-
source += "[^/]*";
|
|
369
|
-
}
|
|
370
|
-
} else if (char === "?") {
|
|
371
|
-
source += "[^/]";
|
|
372
|
-
} else {
|
|
373
|
-
source += escapeRegExp(char);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
source += "$";
|
|
377
|
-
return new RegExp(source); // nosemgrep: detect-non-literal-regexp
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function validatePattern(pattern, label) {
|
|
381
|
-
if (path.isAbsolute(pattern) || normalizeSlashes(pattern).split("/").includes("..")) {
|
|
382
|
-
throw new Error(`${label} 只能使用项目内相对 glob:${pattern}`);
|
|
383
|
-
}
|
|
384
|
-
if (pattern.length > 256) {
|
|
385
|
-
throw new Error(`${label} glob 长度不得超过 256 个字符:${pattern}`);
|
|
386
|
-
}
|
|
387
|
-
globToRegExp(pattern);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function block(action, host, reason) {
|
|
391
|
-
if (action === "pre-tool" || host === "kimi") {
|
|
392
|
-
console.error(reason);
|
|
393
|
-
process.exitCode = 2;
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
console.log(JSON.stringify({ decision: "block", reason }));
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function readFlag(name) {
|
|
400
|
-
const index = process.argv.indexOf(name);
|
|
401
|
-
return index >= 0 ? process.argv[index + 1] : "";
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function readStdin() {
|
|
405
|
-
return new Promise((resolve, reject) => {
|
|
406
|
-
let input = "";
|
|
407
|
-
process.stdin.setEncoding("utf8");
|
|
408
|
-
process.stdin.on("data", (chunk) => {
|
|
409
|
-
input += chunk;
|
|
410
|
-
});
|
|
411
|
-
process.stdin.on("end", () => resolve(input));
|
|
412
|
-
process.stdin.on("error", reject);
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function compactFailure(value) {
|
|
417
|
-
return String(value || "")
|
|
418
|
-
.trim()
|
|
419
|
-
.replace(/\s+/g, " ")
|
|
420
|
-
.slice(0, 500);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function cleanCandidate(value) {
|
|
424
|
-
return String(value || "")
|
|
425
|
-
.trim()
|
|
426
|
-
.replace(/^["']|["']$/g, "");
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function displayPath(root, target) {
|
|
430
|
-
return normalizeSlashes(path.relative(root, target)) || ".";
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function normalizeSlashes(value) {
|
|
434
|
-
return String(value).split(path.sep).join("/").replaceAll("\\", "/");
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function isInside(parent, child) {
|
|
438
|
-
const relative = path.relative(parent, child);
|
|
439
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function escapeRegExp(value) {
|
|
443
|
-
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function isNonEmptyString(value) {
|
|
447
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function rejectUnknownKeys(value, allowed, label) {
|
|
451
|
-
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
452
|
-
if (unknown.length > 0) throw new Error(`${label} 含未知字段:${unknown.join(", ")}`);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
456
|
-
await main();
|
|
457
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const WRITE_TOOLS = new Set([
|
|
9
|
+
"apply_patch",
|
|
10
|
+
"create",
|
|
11
|
+
"edit",
|
|
12
|
+
"editfile",
|
|
13
|
+
"editfiles",
|
|
14
|
+
"multiedit",
|
|
15
|
+
"replace",
|
|
16
|
+
"str_replace_editor",
|
|
17
|
+
"write",
|
|
18
|
+
"write_file",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function loadPolicy(projectRoot) {
|
|
22
|
+
const policyPath = path.join(projectRoot, ".odai", "hooks.json");
|
|
23
|
+
if (!existsSync(policyPath)) return { policy: null, policyPath };
|
|
24
|
+
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
value = JSON.parse(readFileSync(policyPath, "utf8"));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`${displayPath(projectRoot, policyPath)} 不是有效 JSON:${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
validatePolicy(value);
|
|
33
|
+
return { policy: value, policyPath };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function evaluatePreTool(payload, policy, projectRoot) {
|
|
37
|
+
if (!policy) return { blocked: false };
|
|
38
|
+
|
|
39
|
+
const toolName = String(payload.tool_name ?? payload.toolName ?? "").toLowerCase();
|
|
40
|
+
const toolInput = parseToolInput(payload.tool_input ?? payload.toolInput ?? payload.toolArgs);
|
|
41
|
+
if (!WRITE_TOOLS.has(toolName) && !looksLikePatch(toolInput)) return { blocked: false };
|
|
42
|
+
|
|
43
|
+
const cwd = resolveSessionCwd(payload.cwd, projectRoot);
|
|
44
|
+
const resolved = extractPaths(toolInput).map((raw) => ({ raw, ...resolveCandidate(raw, cwd, projectRoot) }));
|
|
45
|
+
|
|
46
|
+
if (policy.blockOutsideWrites === true) {
|
|
47
|
+
const escaped = resolved.find((item) => item.outside);
|
|
48
|
+
if (escaped) {
|
|
49
|
+
return {
|
|
50
|
+
blocked: true,
|
|
51
|
+
reason: `odai hook:${escaped.raw} ${escaped.viaSymlink ? "经由符号链接指向" : "位于"}项目根目录之外;项目启用了 blockOutsideWrites,写入被拒绝。`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const candidates = resolved.flatMap((item) => item.paths);
|
|
57
|
+
|
|
58
|
+
const protectedPath = candidates.find((candidate) =>
|
|
59
|
+
policy.protectedPaths.some((pattern) => matchGlob(candidate, pattern)),
|
|
60
|
+
);
|
|
61
|
+
if (protectedPath) {
|
|
62
|
+
return {
|
|
63
|
+
blocked: true,
|
|
64
|
+
reason: `odai hook:${protectedPath} 命中项目只读路径;参考或保护对象不得被当前写入修改。`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (policy.blockUnresolvedWrites === true && candidates.length === 0) {
|
|
69
|
+
return {
|
|
70
|
+
blocked: true,
|
|
71
|
+
reason: `odai hook:无法从 ${toolName || "当前写工具"} 解析目标路径;项目启用了 blockUnresolvedWrites,需先明确写入对象。`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { blocked: false };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function evaluateStop(payload, policy, projectRoot) {
|
|
79
|
+
if (!policy || policy.checks.length === 0) return { blocked: false };
|
|
80
|
+
if (payload.stop_hook_active === true || payload.stopHookActive === true) return { blocked: false };
|
|
81
|
+
|
|
82
|
+
const changed = collectChangedPaths(projectRoot);
|
|
83
|
+
if (!changed.ok && policy.checks.some((check) => check.always !== true)) {
|
|
84
|
+
return {
|
|
85
|
+
blocked: true,
|
|
86
|
+
reason: `odai hook:无法确定变更范围,不能选择已声明验收:${changed.error}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const selected = policy.checks.filter(
|
|
91
|
+
(check) =>
|
|
92
|
+
check.always === true ||
|
|
93
|
+
changed.paths.some((changedPath) => check.whenChanged.some((pattern) => matchGlob(changedPath, pattern))),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
for (const check of selected) {
|
|
97
|
+
const cwd = path.resolve(projectRoot, check.cwd ?? ".");
|
|
98
|
+
if (!isInside(projectRoot, cwd)) {
|
|
99
|
+
return {
|
|
100
|
+
blocked: true,
|
|
101
|
+
reason: `odai hook:验收 ${check.name} 的 cwd 越出项目根,拒绝执行。`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = spawnSync(check.run[0], check.run.slice(1), {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
timeout: check.timeoutSeconds * 1000,
|
|
109
|
+
maxBuffer: 1024 * 1024,
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (result.error) {
|
|
114
|
+
return {
|
|
115
|
+
blocked: true,
|
|
116
|
+
reason: `odai hook:验收 ${check.name} 无法执行:${result.error.message}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (result.status !== 0) {
|
|
120
|
+
const detail = compactFailure(result.stderr || result.stdout);
|
|
121
|
+
return {
|
|
122
|
+
blocked: true,
|
|
123
|
+
reason: `odai hook:验收 ${check.name} 未通过${detail ? `:${detail}` : `(exit ${String(result.status)})`}。修复或说明阻断后再收口。`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { blocked: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function findProjectRoot(cwd) {
|
|
132
|
+
const candidate = path.resolve(cwd || process.cwd());
|
|
133
|
+
const result = spawnSync("git", ["-C", candidate, "rev-parse", "--show-toplevel"], {
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
});
|
|
137
|
+
if (result.status === 0 && result.stdout.trim()) return path.resolve(result.stdout.trim());
|
|
138
|
+
return candidate;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function matchGlob(value, pattern) {
|
|
142
|
+
const normalizedValue = normalizeSlashes(value).replace(/^\.\/+/, "");
|
|
143
|
+
const normalizedPattern = normalizeSlashes(pattern).replace(/^\.\/+/, "");
|
|
144
|
+
return globToRegExp(normalizedPattern).test(normalizedValue);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
const action = process.argv[2];
|
|
149
|
+
const host = readFlag("--host") || "generic";
|
|
150
|
+
if (!new Set(["pre-tool", "stop"]).has(action)) {
|
|
151
|
+
console.error("Usage: odai-hook.mjs <pre-tool|stop> --host <host>");
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let payload;
|
|
157
|
+
try {
|
|
158
|
+
payload = JSON.parse((await readStdin()).trim() || "{}");
|
|
159
|
+
} catch (error) {
|
|
160
|
+
block(action, host, `odai hook:宿主输入不是有效 JSON:${error.message}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const projectRoot = findProjectRoot(payload.cwd);
|
|
165
|
+
let policy;
|
|
166
|
+
try {
|
|
167
|
+
({ policy } = loadPolicy(projectRoot));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
block(action, host, `odai hook:策略无效:${error.message}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!policy) return;
|
|
173
|
+
|
|
174
|
+
const result =
|
|
175
|
+
action === "pre-tool"
|
|
176
|
+
? evaluatePreTool(payload, policy, projectRoot)
|
|
177
|
+
: evaluateStop(payload, policy, projectRoot);
|
|
178
|
+
if (result.blocked) block(action, host, result.reason);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function validatePolicy(value) {
|
|
182
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
183
|
+
throw new Error("根值必须是对象");
|
|
184
|
+
}
|
|
185
|
+
rejectUnknownKeys(
|
|
186
|
+
value,
|
|
187
|
+
new Set(["version", "protectedPaths", "blockUnresolvedWrites", "blockOutsideWrites", "checks"]),
|
|
188
|
+
"策略",
|
|
189
|
+
);
|
|
190
|
+
if (value.version !== 1) throw new Error("version 必须为 1");
|
|
191
|
+
|
|
192
|
+
if (value.protectedPaths === undefined) value.protectedPaths = [];
|
|
193
|
+
if (!Array.isArray(value.protectedPaths) || value.protectedPaths.some((item) => !isNonEmptyString(item))) {
|
|
194
|
+
throw new Error("protectedPaths 必须是非空字符串数组");
|
|
195
|
+
}
|
|
196
|
+
for (const pattern of value.protectedPaths) validatePattern(pattern, "protectedPaths");
|
|
197
|
+
|
|
198
|
+
if (value.blockUnresolvedWrites !== undefined && typeof value.blockUnresolvedWrites !== "boolean") {
|
|
199
|
+
throw new Error("blockUnresolvedWrites 必须是布尔值");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (value.blockOutsideWrites !== undefined && typeof value.blockOutsideWrites !== "boolean") {
|
|
203
|
+
throw new Error("blockOutsideWrites 必须是布尔值");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (value.checks === undefined) value.checks = [];
|
|
207
|
+
if (!Array.isArray(value.checks)) throw new Error("checks 必须是数组");
|
|
208
|
+
for (const [index, check] of value.checks.entries()) {
|
|
209
|
+
if (!check || typeof check !== "object" || Array.isArray(check)) {
|
|
210
|
+
throw new Error(`checks[${index}] 必须是对象`);
|
|
211
|
+
}
|
|
212
|
+
rejectUnknownKeys(
|
|
213
|
+
check,
|
|
214
|
+
new Set(["name", "whenChanged", "always", "run", "cwd", "timeoutSeconds"]),
|
|
215
|
+
`checks[${index}]`,
|
|
216
|
+
);
|
|
217
|
+
if (!isNonEmptyString(check.name)) throw new Error(`checks[${index}].name 必须是非空字符串`);
|
|
218
|
+
if (!Array.isArray(check.run) || check.run.length === 0 || check.run.some((item) => !isNonEmptyString(item))) {
|
|
219
|
+
throw new Error(`checks[${index}].run 必须是非空字符串数组`);
|
|
220
|
+
}
|
|
221
|
+
if (check.always !== true) {
|
|
222
|
+
if (!Array.isArray(check.whenChanged) || check.whenChanged.length === 0 || check.whenChanged.some((item) => !isNonEmptyString(item))) {
|
|
223
|
+
throw new Error(`checks[${index}].whenChanged 必须是非空字符串数组,或设置 always: true`);
|
|
224
|
+
}
|
|
225
|
+
for (const pattern of check.whenChanged) validatePattern(pattern, `checks[${index}].whenChanged`);
|
|
226
|
+
}
|
|
227
|
+
if (check.cwd !== undefined && !isNonEmptyString(check.cwd)) {
|
|
228
|
+
throw new Error(`checks[${index}].cwd 必须是非空字符串`);
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
check.timeoutSeconds !== undefined &&
|
|
232
|
+
(!Number.isInteger(check.timeoutSeconds) || check.timeoutSeconds < 1 || check.timeoutSeconds > 600)
|
|
233
|
+
) {
|
|
234
|
+
throw new Error(`checks[${index}].timeoutSeconds 必须是 1-600 的整数`);
|
|
235
|
+
}
|
|
236
|
+
check.timeoutSeconds ??= 120;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseToolInput(value) {
|
|
241
|
+
if (typeof value !== "string") return value ?? {};
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(value);
|
|
244
|
+
} catch {
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractPaths(value) {
|
|
250
|
+
const result = [];
|
|
251
|
+
const pathKeys = new Set([
|
|
252
|
+
"file",
|
|
253
|
+
"file_path",
|
|
254
|
+
"filePath",
|
|
255
|
+
"filename",
|
|
256
|
+
"path",
|
|
257
|
+
"target",
|
|
258
|
+
"target_file",
|
|
259
|
+
"targetPath",
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
function visit(node, key = "") {
|
|
263
|
+
if (typeof node === "string") {
|
|
264
|
+
if (pathKeys.has(key)) result.push(node);
|
|
265
|
+
if (looksLikePatch(node)) result.push(...extractPatchPaths(node));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(node)) {
|
|
269
|
+
for (const item of node) visit(item, key);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!node || typeof node !== "object") return;
|
|
273
|
+
for (const [childKey, child] of Object.entries(node)) visit(child, childKey);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
visit(value);
|
|
277
|
+
return [...new Set(result.map(cleanCandidate).filter(Boolean))];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function extractPatchPaths(value) {
|
|
281
|
+
const result = [];
|
|
282
|
+
for (const line of value.split(/\r?\n/)) {
|
|
283
|
+
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/) ||
|
|
284
|
+
line.match(/^\*\*\* Move to:\s*(.+?)\s*$/);
|
|
285
|
+
if (match) result.push(match[1]);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function looksLikePatch(value) {
|
|
291
|
+
if (typeof value === "string") return value.includes("*** Begin Patch");
|
|
292
|
+
if (!value || typeof value !== "object") return false;
|
|
293
|
+
return Object.values(value).some((item) => typeof item === "string" && item.includes("*** Begin Patch"));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveRealOrNearest(absolute) {
|
|
297
|
+
let dir = absolute;
|
|
298
|
+
const trailing = [];
|
|
299
|
+
while (true) {
|
|
300
|
+
try {
|
|
301
|
+
const real = realpathSync(dir);
|
|
302
|
+
return path.join(real, ...trailing.reverse());
|
|
303
|
+
} catch {
|
|
304
|
+
const parent = path.dirname(dir);
|
|
305
|
+
if (parent === dir) return absolute;
|
|
306
|
+
trailing.push(path.basename(dir));
|
|
307
|
+
dir = parent;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function resolveCandidate(candidate, cwd, projectRoot) {
|
|
313
|
+
const empty = { outside: false, viaSymlink: false, paths: [] };
|
|
314
|
+
if (!candidate || candidate.includes("\0")) return empty;
|
|
315
|
+
const absolute = path.isAbsolute(candidate) ? path.normalize(candidate) : path.resolve(cwd, candidate);
|
|
316
|
+
const lexicalInside = isInside(projectRoot, absolute);
|
|
317
|
+
const real = resolveRealOrNearest(absolute);
|
|
318
|
+
if (!isInside(projectRoot, real)) return { outside: true, viaSymlink: lexicalInside, paths: [] };
|
|
319
|
+
|
|
320
|
+
// 目标落在项目内:按真实路径匹配只读规则,别名路径也保留,两种写法都不能绕过 protectedPaths。
|
|
321
|
+
const canonical = normalizeSlashes(path.relative(projectRoot, real));
|
|
322
|
+
const paths = [canonical];
|
|
323
|
+
if (lexicalInside) {
|
|
324
|
+
const lexical = normalizeSlashes(path.relative(projectRoot, absolute));
|
|
325
|
+
if (lexical !== canonical) paths.push(lexical);
|
|
326
|
+
}
|
|
327
|
+
return { outside: false, viaSymlink: false, paths };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveSessionCwd(cwd, projectRoot) {
|
|
331
|
+
if (!cwd) return projectRoot;
|
|
332
|
+
const resolved = path.resolve(cwd);
|
|
333
|
+
return isInside(projectRoot, resolved) ? resolved : projectRoot;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function collectChangedPaths(projectRoot) {
|
|
337
|
+
const commands = [
|
|
338
|
+
["diff", "--name-only", "-z", "--diff-filter=ACMRD"],
|
|
339
|
+
["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMRD"],
|
|
340
|
+
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
341
|
+
];
|
|
342
|
+
const paths = new Set();
|
|
343
|
+
for (const args of commands) {
|
|
344
|
+
const result = spawnSync("git", ["-C", projectRoot, ...args], {
|
|
345
|
+
encoding: "utf8",
|
|
346
|
+
windowsHide: true,
|
|
347
|
+
});
|
|
348
|
+
if (result.status !== 0) {
|
|
349
|
+
return { ok: false, paths: [], error: compactFailure(result.stderr) || `git ${args[0]} 失败` };
|
|
350
|
+
}
|
|
351
|
+
for (const item of result.stdout.split("\0")) {
|
|
352
|
+
if (item) paths.add(normalizeSlashes(item));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, paths: [...paths] };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function globToRegExp(pattern) {
|
|
359
|
+
let source = "^";
|
|
360
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
361
|
+
const char = pattern[index];
|
|
362
|
+
if (char === "*") {
|
|
363
|
+
if (pattern[index + 1] === "*") {
|
|
364
|
+
const followedBySlash = pattern[index + 2] === "/";
|
|
365
|
+
source += followedBySlash ? "(?:.*/)?" : ".*";
|
|
366
|
+
index += followedBySlash ? 2 : 1;
|
|
367
|
+
} else {
|
|
368
|
+
source += "[^/]*";
|
|
369
|
+
}
|
|
370
|
+
} else if (char === "?") {
|
|
371
|
+
source += "[^/]";
|
|
372
|
+
} else {
|
|
373
|
+
source += escapeRegExp(char);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
source += "$";
|
|
377
|
+
return new RegExp(source); // nosemgrep: detect-non-literal-regexp
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function validatePattern(pattern, label) {
|
|
381
|
+
if (path.isAbsolute(pattern) || normalizeSlashes(pattern).split("/").includes("..")) {
|
|
382
|
+
throw new Error(`${label} 只能使用项目内相对 glob:${pattern}`);
|
|
383
|
+
}
|
|
384
|
+
if (pattern.length > 256) {
|
|
385
|
+
throw new Error(`${label} glob 长度不得超过 256 个字符:${pattern}`);
|
|
386
|
+
}
|
|
387
|
+
globToRegExp(pattern);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function block(action, host, reason) {
|
|
391
|
+
if (action === "pre-tool" || host === "kimi") {
|
|
392
|
+
console.error(reason);
|
|
393
|
+
process.exitCode = 2;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
console.log(JSON.stringify({ decision: "block", reason }));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readFlag(name) {
|
|
400
|
+
const index = process.argv.indexOf(name);
|
|
401
|
+
return index >= 0 ? process.argv[index + 1] : "";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readStdin() {
|
|
405
|
+
return new Promise((resolve, reject) => {
|
|
406
|
+
let input = "";
|
|
407
|
+
process.stdin.setEncoding("utf8");
|
|
408
|
+
process.stdin.on("data", (chunk) => {
|
|
409
|
+
input += chunk;
|
|
410
|
+
});
|
|
411
|
+
process.stdin.on("end", () => resolve(input));
|
|
412
|
+
process.stdin.on("error", reject);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function compactFailure(value) {
|
|
417
|
+
return String(value || "")
|
|
418
|
+
.trim()
|
|
419
|
+
.replace(/\s+/g, " ")
|
|
420
|
+
.slice(0, 500);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function cleanCandidate(value) {
|
|
424
|
+
return String(value || "")
|
|
425
|
+
.trim()
|
|
426
|
+
.replace(/^["']|["']$/g, "");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function displayPath(root, target) {
|
|
430
|
+
return normalizeSlashes(path.relative(root, target)) || ".";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function normalizeSlashes(value) {
|
|
434
|
+
return String(value).split(path.sep).join("/").replaceAll("\\", "/");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isInside(parent, child) {
|
|
438
|
+
const relative = path.relative(parent, child);
|
|
439
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function escapeRegExp(value) {
|
|
443
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isNonEmptyString(value) {
|
|
447
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
451
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
452
|
+
if (unknown.length > 0) throw new Error(`${label} 含未知字段:${unknown.join(", ")}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
456
|
+
await main();
|
|
457
|
+
}
|