























@@ -39,48 +39,6 @@ type ExecDockerRawFn = (
3939) => Promise<import("../agents/sandbox/docker.js").ExecDockerRawResult>;
40404141type CodeSafetySummaryCache = Map<string, Promise<unknown>>;
42-type WorkspaceSkillScanLimits = {
43-maxFiles?: number;
44-maxDirVisits?: number;
45-};
46-const MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE = 2_000;
47-const MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS = 12;
48-49-/**
50- * Resolves the realpath of `p` with a 2 s timeout.
51- *
52- * Returns the realpath string on success, or `null` if realpath fails or the
53- * timeout fires first. Note: fs.realpath cannot be cancelled once submitted to
54- * libuv — the underlying OS call continues running in the background after the
55- * timeout resolves. Callers make sequential (not concurrent) calls so at most
56- * one libuv thread is occupied at a time; the OS will eventually time out the
57- * stuck NFS/SMB call independently.
58- *
59- * Timer cleanup: when realpath resolves before the deadline the timer is
60- * cleared immediately so it does not linger across the rest of the audit run.
61- * The timer is also unref'd so it cannot prevent process exit even if it fires
62- * late (e.g. the process finishes while a hang is still in-flight).
63- */
64-function realpathWithTimeout(p: string, timeoutMs = 2000): Promise<string | null> {
65-let timerHandle: ReturnType<typeof setTimeout> | undefined;
66-67-const realpathPromise = fs
68-.realpath(p)
69-.catch(() => null)
70-.then((result) => {
71-clearTimeout(timerHandle);
72-return result;
73-});
74-75-const timeoutPromise = new Promise<null>((resolve) => {
76-timerHandle = setTimeout(() => resolve(null), timeoutMs);
77-// Prevent the timer from keeping the process alive while waiting on a
78-// potentially hanging NFS/SMB path during a large audit run.
79-timerHandle.unref?.();
80-});
81-82-return Promise.race([realpathPromise, timeoutPromise]);
83-}
8442let skillsModulePromise: Promise<typeof import("../agents/skills.js")> | undefined;
8543let configModulePromise: Promise<typeof import("../config/config.js")> | undefined;
8644let agentScopeModulePromise: Promise<typeof import("../agents/agent-scope.js")> | undefined;
@@ -302,66 +260,6 @@ async function getCodeSafetySummary(params: {
302260});
303261}
304262305-async function listWorkspaceSkillMarkdownFiles(
306-workspaceDir: string,
307-limits: WorkspaceSkillScanLimits = {},
308-): Promise<{ skillFilePaths: string[]; truncated: boolean }> {
309-const skillsRoot = path.join(workspaceDir, "skills");
310-const rootStat = await safeStat(skillsRoot);
311-if (!rootStat.ok || !rootStat.isDir) {
312-return { skillFilePaths: [], truncated: false };
313-}
314-315-const maxFiles = limits.maxFiles ?? MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE;
316-const maxTotalDirVisits = limits.maxDirVisits ?? maxFiles * 20;
317-const skillFiles: string[] = [];
318-const queue: string[] = [skillsRoot];
319-const visitedDirs = new Set<string>();
320-let totalDirVisits = 0;
321-322-while (queue.length > 0 && skillFiles.length < maxFiles && totalDirVisits++ < maxTotalDirVisits) {
323-const dir = queue.shift()!;
324-// Use the module-level realpathWithTimeout so a hanging network FS doesn't
325-// block the BFS indefinitely (same 2 s guard as the outer escape-detection loop).
326-const dirRealPath = (await realpathWithTimeout(dir)) ?? path.resolve(dir);
327-if (visitedDirs.has(dirRealPath)) {
328-continue;
329-}
330-visitedDirs.add(dirRealPath);
331-332-const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
333-for (const entry of entries) {
334-if (entry.name.startsWith(".") || entry.name === "node_modules") {
335-continue;
336-}
337-const fullPath = path.join(dir, entry.name);
338-if (entry.isDirectory()) {
339-queue.push(fullPath);
340-continue;
341-}
342-if (entry.isSymbolicLink()) {
343-const stat = await fs.stat(fullPath).catch(() => null);
344-if (!stat) {
345-continue;
346-}
347-if (stat.isDirectory()) {
348-queue.push(fullPath);
349-continue;
350-}
351-if (stat.isFile() && entry.name === "SKILL.md") {
352-skillFiles.push(fullPath);
353-}
354-continue;
355-}
356-if (entry.isFile() && entry.name === "SKILL.md") {
357-skillFiles.push(fullPath);
358-}
359-}
360-}
361-362-return { skillFilePaths: skillFiles, truncated: queue.length > 0 };
363-}
364-365263// --------------------------------------------------------------------------
366264// Exported collectors
367265// --------------------------------------------------------------------------
@@ -552,110 +450,6 @@ export async function collectSandboxBrowserHashLabelFindings(params?: {
552450return findings;
553451}
554452555-export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
556-cfg: OpenClawConfig;
557-skillScanLimits?: WorkspaceSkillScanLimits;
558-}): Promise<SecurityAuditFinding[]> {
559-const findings: SecurityAuditFinding[] = [];
560-const { listAgentWorkspaceDirs } = await loadAgentWorkspaceDirsModule();
561-const workspaceDirs = listAgentWorkspaceDirs(params.cfg);
562-if (workspaceDirs.length === 0) {
563-return findings;
564-}
565-566-const escapedSkillFiles: Array<{
567-workspaceDir: string;
568-skillFilePath: string;
569-skillRealPath: string;
570-}> = [];
571-const seenSkillPaths = new Set<string>();
572-573-for (const workspaceDir of workspaceDirs) {
574-const workspacePath = path.resolve(workspaceDir);
575-const workspaceRealPath = (await realpathWithTimeout(workspacePath)) ?? workspacePath;
576-const { skillFilePaths, truncated } = await listWorkspaceSkillMarkdownFiles(
577-workspacePath,
578-params.skillScanLimits,
579-);
580-581-if (truncated) {
582-// The BFS visit cap was hit before the full skills/ tree was scanned.
583-// Escaped SKILL.md symlinks in the unvisited portion will not be detected.
584-// Surface this as a warning so the user knows coverage was incomplete.
585-findings.push({
586-checkId: "skills.workspace.scan_truncated",
587-severity: "warn",
588-title: "Workspace skill scan reached the directory visit limit",
589-detail:
590-`The skills/ directory scan in ${workspacePath} stopped early after reaching the ` +
591-`BFS visit cap. Skill files in the unscanned portion of the tree were not checked ` +
592-"for symlink escapes.",
593-remediation:
594-"Flatten or simplify the skills/ directory hierarchy to stay within the scan budget, " +
595-"or move deeply-nested skill collections to a managed skill location.",
596-});
597-}
598-599-for (const skillFilePath of skillFilePaths) {
600-const canonicalSkillPath = path.resolve(skillFilePath);
601-if (seenSkillPaths.has(canonicalSkillPath)) {
602-continue;
603-}
604-seenSkillPaths.add(canonicalSkillPath);
605-606-const skillRealPath = await realpathWithTimeout(canonicalSkillPath);
607-if (!skillRealPath) {
608-// realpath timed out or failed — cannot verify the symlink target.
609-// Treat as a potential escape rather than silently bypassing the check.
610-// An attacker on a slow/network FS could otherwise hang realpath to
611-// prevent escape detection.
612-escapedSkillFiles.push({
613-workspaceDir: workspacePath,
614-skillFilePath: canonicalSkillPath,
615-skillRealPath: "(realpath timed out \u2014 symlink target unverifiable)",
616-});
617-continue;
618-}
619-if (isPathInside(workspaceRealPath, skillRealPath)) {
620-continue;
621-}
622-escapedSkillFiles.push({
623-workspaceDir: workspacePath,
624-skillFilePath: canonicalSkillPath,
625- skillRealPath,
626-});
627-}
628-}
629-630-if (escapedSkillFiles.length === 0) {
631-return findings;
632-}
633-634-findings.push({
635-checkId: "skills.workspace.symlink_escape",
636-severity: "warn",
637-title: "Workspace skill files resolve outside the workspace root",
638-detail:
639-"Detected workspace `skills/**/SKILL.md` paths whose realpath escapes their workspace root:\n" +
640-escapedSkillFiles
641-.slice(0, MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS)
642-.map(
643-(entry) =>
644-`- workspace=${entry.workspaceDir}\n` +
645-` skill=${entry.skillFilePath}\n` +
646-` realpath=${entry.skillRealPath}`,
647-)
648-.join("\n") +
649-(escapedSkillFiles.length > MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS
650- ? `\n- +${escapedSkillFiles.length - MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS} more`
651- : ""),
652-remediation:
653-"Keep workspace skills inside the workspace root (replace symlinked escapes with real in-workspace files), or move trusted shared skills to managed/bundled skill locations.",
654-});
655-656-return findings;
657-}
658-659453export async function collectIncludeFilePermFindings(params: {
660454configSnapshot: ConfigFileSnapshot;
661455env?: NodeJS.ProcessEnv;
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。