Files
HomeAgent/internal/security/harden.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

169 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package security
// 数据目录与敏感文件的权限收敛。
//
// ## 为什么必须有这一层(与内嵌主密钥配套)
//
// 用户选择把主密钥**内嵌**进二进制(`embedded.go`)。那条路的代价是:
// 密钥就在 `homed` 里,而 `homed` 出厂是 0755、`config.db` 是 0644 ——
// 二者同级可达,于是「能读二进制 ⇒ 能拿密钥 ⇒ 能解密数据库」。
//
// 缓解手段很直接:**把可达性收窄到 root**。这不是把内嵌变成安全设计,
// 而是把它的暴露面从"本机任何用户"缩小到"root"——而那正是唯一本来就
// 能读全部凭据的身份(服务以 root 运行,进程内存里本就有明文)。
//
// ## 为什么写进代码而不是手工 chmod
//
// 手工 chmod 会在下一次重建/换机器/恢复备份时**悄悄失效**,
// 而症状是"加密还在,但保护已经没了"——没人会注意到。
// 所以收敛必须每次启动都跑(幂等),且**收紧失败要响亮**:
// 权限没收紧意味着"以为受保护其实没有",属于必须知道的安全事实。
//
// ## 只收紧、不放松
//
// 绝不把已经更严的权限改回宽(除非显式要求):一个被管理员手工设成 0400
// 的文件不该被启动流程"修正"成 0600。所以只在**比目标宽**时才动。
import (
"fmt"
"log"
"os"
"path/filepath"
)
// 目标权限(尽量小;全部服务以 root 运行,实测无其他读者)。
const (
// DirPerm 数据目录:0750 —— 同组(root)可进入,其他用户不可。
// 不用 0700 是因为保留 group 访问的余地(将来若拆出专用组,只需改组属主)。
DirPerm = 0o750
// FilePerm 敏感文件(config.db 及其 WAL/SHM、密钥、密钥环、变量库):0600。
FilePerm = 0o600
)
// HardenResult 报告收敛动作(供日志与自检工具使用)。
type HardenResult struct {
// Changed 是被实际改动的路径(含从→到)。
Changed []string
// Errors 是收紧失败的项。**非空必须响亮**:权限没收紧 = 保护没生效。
Errors []string
}
// sensitiveFileNames 是需要 0600 的文件/通配(相对数据目录)。
//
// 为什么把 config.db 的 `-wal` / `-shm` 也算上:SQLite 的 WAL 模式会把
// **未合并的最新数据**放在 `-wal` 里 —— 只收紧主库而留下 0644 的 `-wal`,
// 等于把最近写入的(可能正是密钥的)内容继续暴露给所有人。
// 这个坑很隐蔽:`ls -l config.db` 看起来是对的。
var sensitiveFileNames = []string{
"config.db",
"config.db-wal",
"config.db-shm",
KeyFileName, // security.key
KeyringFileName, // security.keyring
}
// sensitiveDirs 是需要 0750 的子目录(相对数据目录)。
var sensitiveDirs = []string{
"security", // 变量库 variables.json 所在处
}
// HardenDataDir 幂等地把数据目录与敏感文件收敛到最小权限。
//
// 它**不**因为个别失败就中止启动:一个权限收紧失败不该让服务不可用
// (那会把安全加固变成可用性事故)。但失败项会逐条返回,
// 调用方必须**响亮记录**。
func HardenDataDir(dataDir string) HardenResult {
var res HardenResult
if dataDir == "" {
return res
}
// ① 数据目录本身
if ch, err := tightenPath(dataDir, DirPerm); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", dataDir, err))
} else if ch != "" {
res.Changed = append(res.Changed, ch)
}
// ② 敏感子目录
for _, d := range sensitiveDirs {
p := filepath.Join(dataDir, d)
if _, err := os.Stat(p); err != nil {
continue // 不存在就跳过(不是错误)
}
if ch, err := tightenPath(p, DirPerm); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", p, err))
} else if ch != "" {
res.Changed = append(res.Changed, ch)
}
}
// ③ 敏感文件
for _, f := range sensitiveFileNames {
p := filepath.Join(dataDir, f)
st, err := os.Stat(p)
if err != nil || st.IsDir() {
continue
}
if ch, err := tightenPath(p, FilePerm); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", p, err))
} else if ch != "" {
res.Changed = append(res.Changed, ch)
}
}
// ④ 变量库(在 security/ 下)
for _, f := range []string{"variables.json", "variables.json.tmp"} {
p := filepath.Join(dataDir, "security", f)
st, err := os.Stat(p)
if err != nil || st.IsDir() {
continue
}
if ch, err := tightenPath(p, FilePerm); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", p, err))
} else if ch != "" {
res.Changed = append(res.Changed, ch)
}
}
if len(res.Errors) > 0 {
// 响亮:权限没收紧意味着"以为受保护其实没有"。
for _, e := range res.Errors {
log.Printf("[security] ** permission hardening failed (this entry may still be readable by other local users): %s", e)
}
}
return res
}
// tightenPath 在**当前权限比目标宽**时收紧到目标。
//
// 返回非空字符串表示确实改了(内容形如 "0755→0750 /path"),
// 便于日志说清"改了什么"。
//
// ★ 只收紧不放松:已经更严(如 0400)的文件保持原样 ——
// 一个被管理员特意设严的文件不该被启动流程"修正"。
func tightenPath(p string, want os.FileMode) (string, error) {
st, err := os.Stat(p)
if err != nil {
return "", err
}
cur := st.Mode().Perm()
if cur == want || !isLooser(cur, want) {
return "", nil
}
if err := os.Chmod(p, want); err != nil {
return "", err
}
return fmt.Sprintf("%04o→%04o %s", cur, want, p), nil
}
// isLooser 报告 cur 是否比 want 宽松(任一位是 cur 有而 want 没有的)。
//
// 例如 cur=0644 / want=0600 → 宽松(group/other 可读);
//
// cur=0400 / want=0600 → **不**宽松(反而更严,不该动)
func isLooser(cur, want os.FileMode) bool {
return cur&^want != 0
}