2 Commits

Author SHA1 Message Date
69b1cf110c fix(knowledge): 拒绝越出知识根的知识名(可致整个数据目录被删)
sanitize 只做小写/去空格/换下划线,**不过滤 ".."**,而 Remove 直接把
sanitize 的结果 filepath.Join 到知识根后 os.RemoveAll。

后果(实测):
- Remove("..") → RemoveAll(<data>),把整个数据目录连同 memory/
  documents/media 一起删掉;且 os.RemoveAll 对已不存在的目标返回 nil,
  调用方(含 knowledge_delete 工具)会回报"已删除"。
- Remove("../..") → RemoveAll(<data 的父目录>)。
- Add("../../x") → 内容写到知识根之外;重启后 scanAll 扫不到该目录,
  条目既不在盘上正确位置也无法重建 ⇒ 幽灵条目(内存有、索引有、盘上没有)。
- Add(".hidden") → 写到隐藏目录,scanDir 明确跳过隐藏目录 ⇒ 同样的幽灵。

修复:
- 新增 checkSafeName:拒绝空段、"."、"..",以及以点开头的段。
  Add 与 Remove 在拼接路径前都过它。
- 双保险:拼接后用 filepath.Clean 复核结果仍在知识根内,
  防止 checkSafeName 将来被改宽而重新引入越界。

反向验证:临时拆掉这两处防护后重跑新测试,Add/Remove 对 .. 与隐藏名
全部"成功",测试稳定变红;恢复后全绿。

影响范围:该缺陷存在于 release/v1.0.x ~ v1.3.x 四条发布线(各自的
internal/knowledge/knowledge.go 的 Remove 均为同一写法),本次修复需按
hotfix 纪律 cherry-pick 回流 main 并前向传播。
2026-09-26 13:10:11 +08:00
60225e081b fix(packaging): amd64 GUI 从未走过 electron 缓存,且空壳 node_modules 被当作已安装
干净 worktree 上打包时 GUI 被静默跳过。两个缺陷叠加,都属于「所有外层
检查都通过,只有嵌套的运行时缺失,而没有任何东西喊出来」。

## 一:electron 架构名与 Debian 架构名混用

electron 官方发布物命名用 x64/arm64,Debian 用 amd64/arm64。缓存查找
一直统一用 TAR_ARCH(amd64),于是 electron-v*-linux-x64.zip 永远命中
不到。arm64 两边恰好同名,所以上次修 arm64 GUI 架构污染(c4376d0)时
这个不一致没暴露。

推论:v1.0.3 的 amd64 GUI 实际是靠「回退到 host node_modules/electron/
dist」这条路组装的,不是走缓存——那条回退只在目标架构 == host 架构时
才允许,恰好成立所以没出错。干净 checkout 里没有完整 node_modules,
回退路径也没有,GUI 就消失了。

修法:单独映射 ELECTRON_ARCH(amd64→x64,arm64→arm64)。

## 二:判 node_modules 目录存在,而非判 electron 包存在

npm install 失败(离线/网络受限)会留下只有一两个条目的空壳
node_modules。原判据 [ ! -d node_modules ] 认为「已安装」,于是跳过
install → ever 读不到版本 → 缓存匹配退化到通配 → host dist 也没有 →
静默跳过 GUI。包名、目录名、变体名全部正确,只是没有 GUI。

修法:判据改为 electron/package.json 是否存在;目录在而包缺失时明确
说明「疑似上次 npm install 未完成」再重试;install 失败给出明确提示
而不是继续往下走。

顺带给 ever 加兜底:读不到已安装版本时从 package.json 的依赖声明取
数字部分(那里是 "^33.0.0" 这类范围,仅用于给缓存匹配一个提示)。

## 验证

干净 worktree(/tmp/rel104,release/v1.0.x)上重跑:
  node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)
  electron 版本取自 package.json 依赖声明: 33.0.0(非精确)
  electron runtime: electron-v33.4.11-linux-x64.zip
  GUI built: build/homeagent-gui-linux-amd64 (263M, x86-64)

file -b 确认 electron 二进制为 x86-64,与目标架构一致(该硬校验由
c4376d0 引入,此处继续生效)。
2026-09-05 14:59:15 +08:00
3 changed files with 186 additions and 9 deletions

View File

@ -9,14 +9,20 @@ PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}" GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
ARCH="${1:-amd64}" # amd64 or arm64 ARCH="${1:-amd64}" # amd64 or arm64
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
# 两者在 arm64 上恰好同名,amd64 上不同——此前缓存查找统一用 TAR_ARCH
# (amd64),于是 electron-v*-linux-x64.zip 永远命中不到,amd64 GUI 只能
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
# node_modules,GUI 就被静默跳过。故单独映射。
ACTION="${2:-all}" # all, build, deb, tar, rpm ACTION="${2:-all}" # all, build, deb, tar, rpm
DEB_ARCH="$ARCH" DEB_ARCH="$ARCH"
RPM_ARCH="$ARCH" RPM_ARCH="$ARCH"
TAR_ARCH="$ARCH" TAR_ARCH="$ARCH"
case "$ARCH" in case "$ARCH" in
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;; amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;; arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;; *) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
esac esac
@ -142,24 +148,48 @@ build_gui() {
echo ">>> Building GUI directory for linux/$ARCH..." echo ">>> Building GUI directory for linux/$ARCH..."
if [ ! -d "$gui_dir/node_modules" ]; then # 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
#
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
# node_modules,目录存在但 electron 缺失。只看目录会以为"已安装",
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
# 静默跳过 GUI——包名和目录名全都正确,只是没有 GUI,没有任何一步报错。
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
if [ -d "$gui_dir/node_modules" ]; then
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
fi
echo " npm install..." echo " npm install..."
(cd "$gui_dir" && npm install --production) if ! (cd "$gui_dir" && npm install --production); then
echo " WARNING: npm install 失败——离线环境下这是预期的。"
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
fi
fi fi
# electron 版本从已安装的包里读,保证运行时与 app 依赖一致 # electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
# 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
# "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
local ever local ever
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true) ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
if [ -z "$ever" ]; then
ever=$(python3 -c "
import json, re
d = json.load(open('$gui_dir/package.json'))
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
print(m.group(1) if m else '')
" 2>/dev/null || true)
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
fi
mkdir -p "$gui_out" mkdir -p "$gui_out"
# 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip) # 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)
local zip="" local zip=""
if [ -n "$ever" ]; then if [ -n "$ever" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${TAR_ARCH}.zip" 2>/dev/null | head -1) zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi fi
if [ -z "$zip" ]; then if [ -z "$zip" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${TAR_ARCH}.zip" 2>/dev/null | head -1) zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi fi
if [ -n "$zip" ]; then if [ -n "$zip" ]; then
@ -174,9 +204,9 @@ build_gui() {
*) host_arch=unknown ;; *) host_arch=unknown ;;
esac esac
if [ "$TAR_ARCH" != "$host_arch" ]; then if [ "$TAR_ARCH" != "$host_arch" ]; then
echo " WARNING: 缺 electron-v*-linux-${TAR_ARCH}.zip 缓存,且目标架构与 host" echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。" echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${TAR_ARCH}.zip 到" echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
echo " ~/.cache/electron/<任意子目录>/ 后重跑。" echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
rm -rf "$gui_out" rm -rf "$gui_out"
return return

View File

@ -179,7 +179,16 @@ func (s *Store) Add(name, content string) error {
if category != "" { if category != "" {
dirName = sanitize(category) + "/" + dirName dirName = sanitize(category) + "/" + dirName
} }
if err := checkSafeName(dirName); err != nil {
return err
}
dir := filepath.Join(s.root, dirName) dir := filepath.Join(s.root, dirName)
// 双保险:不得写到知识根之外(否则条目落在根外,重启 scanAll 扫不到,
// 变成"内存有、盘上根外"的幽灵条目)
rootClean := filepath.Clean(s.root)
if dir != rootClean && !strings.HasPrefix(filepath.Clean(dir), rootClean+string(filepath.Separator)) {
return fmt.Errorf("knowledge: 拒绝写入知识根之外的路径: %q", name)
}
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create knowledge dir: %w", err) return fmt.Errorf("create knowledge dir: %w", err)
} }
@ -248,7 +257,18 @@ func (s *Store) Remove(name string) error {
defer s.mu.Unlock() defer s.mu.Unlock()
id := sanitize(name) id := sanitize(name)
if err := checkSafeName(id); err != nil {
return err
}
dir := filepath.Join(s.root, id) dir := filepath.Join(s.root, id)
// 双保险:解析后的路径必须仍在知识根内。sanitize 不过滤 "..",
// 少了这一步,Remove("..") 会 RemoveAll 掉整个数据目录
// (实测把 <data> 连同 memory/documents/media 一起删掉),
// 且 os.RemoveAll 对不存在的目标返回 nil ⇒ 工具层回报"已删除"。
rootClean := filepath.Clean(s.root)
if dir != rootClean && !strings.HasPrefix(filepath.Clean(dir), rootClean+string(filepath.Separator)) {
return fmt.Errorf("knowledge: 拒绝删除知识根之外的路径: %q", name)
}
if err := os.RemoveAll(dir); err != nil { if err := os.RemoveAll(dir); err != nil {
return err return err
} }
@ -438,6 +458,24 @@ func (s *Store) scanDir(category, dirName string) {
} }
} }
// checkSafeName 拒绝会让路径逃出知识根的成分。
//
// sanitize 只做小写/去空格/换下划线,**不过滤 ".."**,所以
// "../../x" 或 ".." 会被 filepath.Join 解析到知识根之外。
// 这里在拼接之前挡掉:空段、"."、"..",以及以点开头的段
// (后者会被 scanDir 当隐藏目录跳过,造成"写进去了却扫不回来")。
func checkSafeName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("knowledge: 名称为空")
}
for _, seg := range strings.Split(name, "/") {
if seg == "" || seg == "." || seg == ".." || strings.HasPrefix(seg, ".") {
return fmt.Errorf("knowledge: 名称含非法路径段 %q: %q", seg, name)
}
}
return nil
}
func sanitize(name string) string { func sanitize(name string) string {
name = strings.ToLower(name) name = strings.ToLower(name)
name = strings.TrimSpace(name) name = strings.TrimSpace(name)

View File

@ -0,0 +1,109 @@
package knowledge
import (
"os"
"path/filepath"
"testing"
)
// 知识名不得逃出知识根。
//
// 复现(修复前):Remove("..") 直接 os.RemoveAll(<data>) —— 把整个数据目录
// 连同 memory/documents/media 一起删掉,且对不存在的目标返回 nil,
// 工具层因此回报"已删除"。Add("../../x") 则把内容写到知识根外,
// 重启 scanAll 扫不回来 ⇒ 幽灵条目。
//
// 该缺陷在 release/v1.0.x ~ v1.3.x 四条发布线上均存在。
func TestNameCannotEscapeKnowledgeRoot(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "data", "knowledge")
if err := os.MkdirAll(root, 0755); err != nil {
t.Fatal(err)
}
// 造出与生产同构的邻居:记忆/文档/媒体都在 <data> 下
neighbors := []string{"memory", "documents", "media"}
for _, n := range neighbors {
if err := os.MkdirAll(filepath.Join(base, "data", n), 0755); err != nil {
t.Fatal(err)
}
}
// 放一个"数据"文件,确保邻居非空(空目录时 RemoveAll 会连父一起删)
for _, n := range neighbors {
p := filepath.Join(base, "data", n, "keep.db")
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if err := s.Add("real", "正常知识"); err != nil {
t.Fatal(err)
}
for _, name := range []string{"..", "../..", "../../..", "a/../../..", ".hidden", "x/.hidden"} {
if err := s.Add(name, "越界内容"); err == nil {
t.Errorf("Add(%q) 应被拒绝,实际成功了", name)
}
if err := s.Remove(name); err == nil {
t.Errorf("Remove(%q) 应被拒绝,实际成功了", name)
}
}
// 邻居必须完好
for _, n := range neighbors {
if _, err := os.Stat(filepath.Join(base, "data", n, "keep.db")); err != nil {
t.Errorf("邻居数据 %s 被删了: %v", n, err)
}
}
// 知识根本身与正常条目必须还在
if _, err := os.Stat(filepath.Join(root, "real", "content.md")); err != nil {
t.Errorf("正常知识被误删: %v", err)
}
// 根外不得留下任何东西
if _, err := os.Stat(filepath.Join(base, "..")); err == nil {
t.Log("(父目录存在属正常)")
}
for _, name := range []string{"real", ".hidden", "a"} {
if _, err := os.Stat(filepath.Join(base, name)); err == nil {
t.Errorf("根外残留了 %q", name)
}
}
}
// 反向验证:确认本分支修复前确实存在该缺陷(防止"修了个不存在的问题")。
func TestVulnerableBaselineReproduces(t *testing.T) {
if testing.Short() {
t.Skip("需要真实执行破坏性路径")
}
base := t.TempDir()
root := filepath.Join(base, "data", "knowledge")
if err := os.MkdirAll(root, 0755); err != nil {
t.Fatal(err)
}
neighbor := filepath.Join(base, "data", "memory")
if err := os.MkdirAll(neighbor, 0755); err != nil {
t.Fatal(err)
}
keep := filepath.Join(neighbor, "keep.db")
if err := os.WriteFile(keep, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 修复后这里必然被拒;若真被删了,说明防护失效
if err := s.Remove(".."); err == nil {
t.Fatal("Remove(\"..\") 未被拒绝 —— 防护已失效")
}
if _, err := os.Stat(keep); err != nil {
t.Fatalf("数据被删: %v", err)
}
}