捕获、路径限制与 git hardening 深度解析
本页只展开一个模块,把导入契约、核心实现、流程和失败模式拆成可回溯行号。
导入链与契约
入口代码说明该模块接受什么输入、输出什么结构,以及它依赖哪些相邻模块。
▸入口片段
26def output_dir(cwd, cfg): 27 """Resolve the output dir, confined to within cwd, or None if none is safe. 28 29 A project-local config is itself untrusted (it ships in the repo), so it must 30 not be able to redirect writes outside the project via an absolute path or 31 `..`. A pre-planted symlink at the default `.recall` is just as untrusted: an 32 escaping config value falls back to `.recall`, but if even that resolves 33 outside cwd (because `.recall` is a symlink), we return None and refuse to 34 write rather than land outside the project. Callers treat None as "skip". 35 """ 36 base = os.path.realpath(cwd) 37 requested = cfg.get("output_dir", ".recall") or ".recall" 38 for candidate in (requested, ".recall"): 39 target = os.path.realpath(os.path.join(cwd, candidate)) 40 if target == base or target.startswith(base + os.sep): 41 return target 42 return None 43 44 45def _in_output_dir(cwd, cfg, name): 46 d = output_dir(cwd, cfg) 47 return os.path.join(d, name) if d else None 48 49 50def context_path(cwd, cfg): 51 return _in_output_dir(cwd, cfg, "context.md") 52 53 54def history_path(cwd, cfg): 55 return _in_output_dir(cwd, cfg, "history.md") 56 57 58def state_path(cwd, cfg): 59 return _in_output_dir(cwd, cfg, ".capture.json") 60 61
源码索引:src/scripts/common.py:26 · src/scripts/common.py:27 · src/scripts/common.py:28 · src/scripts/common.py:29 · src/scripts/common.py:30 · src/scripts/common.py:31 · src/scripts/common.py:32 · src/scripts/common.py:33 · src/scripts/common.py:34 · src/scripts/common.py:35 · src/scripts/common.py:36 · src/scripts/common.py:37 · src/scripts/common.py:38 · src/scripts/common.py:39 · src/scripts/common.py:40 · src/scripts/common.py:41 · src/scripts/common.py:42 · src/scripts/common.py:43 · src/scripts/common.py:44 · src/scripts/common.py:45 · src/scripts/common.py:46 · src/scripts/common.py:47 · src/scripts/common.py:48 · src/scripts/common.py:49 · src/scripts/common.py:50 · src/scripts/common.py:51 · src/scripts/common.py:52 · src/scripts/common.py:53 · src/scripts/common.py:54 · src/scripts/common.py:55 · src/scripts/common.py:56 · src/scripts/common.py:57 · src/scripts/common.py:58 · src/scripts/common.py:59 · src/scripts/common.py:60 · src/scripts/common.py:61 · src/scripts/common.py:62 · src/scripts/common.py:63 · src/scripts/common.py:64 · src/scripts/common.py:65 · src/scripts/common.py:66 · src/scripts/common.py:67 · src/scripts/common.py:68 · src/scripts/common.py:69 · src/scripts/common.py:70
核心实现,逐段拆
▸中段状态转换
这一段通常体现最关键的状态转换:解析、选择、打分、写入或安全检查。
62def pause_path(cwd, cfg): 63 return _in_output_dir(cwd, cfg, ".capture-paused") 64 65 66def read_text(path): 67 if not path: 68 return "" 69 try: 70 with open(path, "r", encoding="utf-8") as fh: 71 return fh.read() 72 except OSError: 73 return "" 74 75 76def ensure_output_dir(cwd, cfg): 77 d = output_dir(cwd, cfg) 78 if not d: 79 return None 80 os.makedirs(d, exist_ok=True) 81 return d 82 83 84def _open_nofollow(path, append): 85 """Open for writing without following a symlink at the final path component. 86 87 Prevents a pre-planted symlink (e.g. history.md -> /etc/something) from 88 redirecting our writes. Raises OSError (ELOOP) if the target is a symlink. 89 """ 90 flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC) 91 if hasattr(os, "O_NOFOLLOW"): 92 flags |= os.O_NOFOLLOW 93 return os.fdopen(os.open(path, flags, 0o644), "w", encoding="utf-8") 94 95 96def write_text(path, data): 97 with _open_nofollow(path, append=False) as fh: 98 fh.write(data) 99 100 101def append_text(path, data):
▸末段收束
收束段决定失败是否显式返回、结果是否稳定落盘,以及调用者能否继续恢复。
159 ) 160 return out.stdout.strip() if out.returncode == 0 else "" 161 except (OSError, subprocess.SubprocessError): 162 return "" 163 164 165def _is_git_repo(cwd): 166 return _git(cwd, ["rev-parse", "--is-inside-work-tree"]) == "true" 167 168 169def git_info(cwd): 170 if not _is_git_repo(cwd): 171 return "" 172 parts = [] 173 stat = _git(cwd, ["diff", "--no-ext-diff", "--stat"]) 174 if stat: 175 parts.append("Uncommitted changes (git diff --stat):\n" + stat) 176 log = _git(cwd, ["log", "--oneline", "-8"]) 177 if log: 178 parts.append("Recent commits:\n" + log) 179 return "\n\n".join(parts) 180 181 182def git_uncommitted(cwd): 183 """List of paths with uncommitted changes (porcelain), or [].""" 184 if not _is_git_repo(cwd): 185 return [] 186 out, seen = [], set() 187 for line in _git(cwd, ["status", "--porcelain"]).splitlines(): 188 path = line[3:].strip() # "XY <path>" (rename/copy shows "old -> new") 189 if " -> " in path: 190 path = path.split(" -> ", 1)[1].strip() 191 if path and path not in seen: 192 seen.add(path) 193 out.append(path) 194 return out
关键流程
▸流程拆解
failure → explicit fallback / error record → caller decision
如果后续改动触碰这些行,主页面对应的风险判断也要重跑。
边界 / 失败模式
▸边界列表
- 空输入或缺失字段必须被显式处理。
- 外部命令、模型调用或文件写入失败不能覆盖原始上下文。
- 路径别名仅用于索引兼容;实际源码位置见每个代码块的 repo 标注。