fix(isolation): bind recovered records to host-managed identities

Problem: a rebuilt store can trust a copied or mismatched durable record and
use its embedded candidate paths outside that record's actual managed slot.

Root cause: discovery checks the claimed workspace but does not bind snapshot
and candidate identity to the host-opened repository and observed marker slot.
Comparing Git markers only by inode also accepts cross-candidate hard links.

Fix: centralize record admission before every durable consumer; verify managed
directory membership, source identity, prefix roots and the linked candidate's
actual marker/backlink. Retain rejected records without rewriting metadata,
and resolve legacy identity through filesystem checks without launching Git.

Verification: five compiling old-owner negative-control groups fail as expected;
final 238-package root inventory and required builtin/boot pass, owning isolation
race has 30 passes and real boot isolation race has five, all without new skips.
Formatting, root vet, full lint and make check pass with the baseline unchanged.
Native hosted checks and independent review remain separate delivery gates.
This commit is contained in:
SivanCola committed 2026-10-25 02:46:04 +08:00
1 parent 65def98d70
commit dcd09b8352
5 files changed
+542

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-25
---
# Isolated record admission / 隔离记录准入
## Scope / 范围
`entry.json` survives a Store rebuild. Previously discovery checked only its
claimed workspace, then trusted embedded snapshot/candidate paths for inspection,
application and removal. Discovery now checks each record against the actual
managed slot and the repository identity opened by the host.
`entry.json` 会跨 Store 重建保留。以前发现流程只检查记录声称的工作区,随后信任其中的
快照和候选路径。本批将实际托管槽位、宿主已解析的仓库身份与记录绑定后再允许恢复或清理。
## Reachability and authority / 可达性与权限
- Normal `Begin`/`Finish` writes matching fields. The counterexamples deliberately
copy or edit a real published record before constructing a new Store. They do
not show ordinary execution generating mismatched metadata.
- Restart recovery and boot's age sweeper consume these records. This is local
persistence hardening, not evidence of a remote exploit. A principal able to
modify arbitrary host files already has broader authority.
正常 `Begin`/`Finish` 产生匹配字段。反向测试明确在磁盘上复制或修改真实记录,再重建
Store;不能据此声称正常执行会产生错配。实际消费者是重启恢复和启动时的过期清理。
本批加固宿主持久化边界,不把可任意修改宿主文件的主体描述成远程越权者。
## Checked boundaries / 已检查边界
- The marker must be a regular file; its immediate slot and candidate must be
directories, without a leaf symlink. The slot belongs to the configured
managed root and contains the claimed candidate, using existing file identity.
- The snapshot root and optional pinned Repo must match the Store's opened
checkout. The prefix must map both tool roots inside their respective trees.
- The candidate's checked Git identity must belong to that candidate and share
the source common directory, with a distinct linked-worktree administrative
directory. Stored identities are not accepted merely because they are valid
identities of some other repository.
- The administrative backlink must name the actual candidate directory. A
hard-linked `.git` file cannot make another candidate's backlink authoritative
merely by sharing its file identity.
- All durable consumers use this admission: pending discovery, path discovery,
recovery for apply/discard, and the existing sweep. Invalid records are left
byte-for-byte on disk and are not offered as recoverable results. Lookup uses
its existing unknown-ID refusal; a dedicated quarantine diagnostic is deferred.
标记必须为普通文件,槽位和候选目录不能是末端符号链接;按实际目录身份校验所属关系。
源快照、项目子目录和候选 Git 身份均需匹配宿主身份;候选必须属于同一 common directory
下的独立 worktree。所有磁盘消费者共用准入。拒绝的记录原样保留,不提供恢复操作;
当前仍返回既有 unknown-ID 拒绝,专用隔离诊断留待后续批次。
## Compatibility / 兼容
| Surface / 边界 | Current reader / 新读取方 | Previous reader / 旧读取方 |
| --- | --- | --- |
| Existing pinned records / 已有固定身份记录 | Verify filesystem identity without starting Git / 仅文件系统校验 | Existing behavior / 原行为 |
| Legacy absent Repo fields / 缺少 Repo 的旧记录 | Read the linked marker/backlink, no Git launch or rewrite / 读取关联标记和回链,不启动 Git、不重写 | Existing behavior / 原行为 |
| Unknown JSON fields / 未知字段 | Preserve exact bytes on discovery / 发现时原样保留 | No new format written / 不写新格式 |
| Subdirectory / linked source / 子目录和 linked 源 | Remain usable after restart / 重启后可用 | Existing behavior / 原行为 |
| Mismatched/copied records / 错配或复制记录 | Retained, excluded from settlement / 保留并拒绝操作 | Old trust policy remains unsafe / 仍使用旧信任规则 |
No new marker name, serialized field, wire type, tool schema, permission or
default is introduced. Older processes cannot gain these checks from a new
process. Missing files or unverifiable legacy identity fail closed;
records are retained rather than repaired or deleted.
没有新增文件格式、wire、工具定义、权限或默认策略。旧进程不会自动获得新检查。
文件缺失或旧身份无法解析时保留记录,不能据此修复或删除。
## Evidence and limits / 证据与限制
- Real-Git regressions cover copied-record path/apply/discard/sweep, identity
mismatch, symlinked slots/markers, hard-linked Git markers, old records, unknown fields, subdirectory
projects and linked source checkouts.
- Selected unchanged behavior tests compile against the original discovery owner
and fail there. Platform evidence and exact commands are recorded in the PR.
- This is not authentication, a sandbox, an atomic directory handle,
cross-process settlement locking or protection against external check/use races.
- Pending-result expiration, first-publication loss, inspector failures, content
snapshots, duplicate-ID reconciliation and journaling remain separate units.
- No CodeQL alert is dismissed by this author assessment. Independent
code-owner/security review remains required for delivery.
本批不是认证、沙箱、跨进程整合锁,也不承诺消除任意外部进程造成的检查后竞态。
过期策略、首次发布失败、检查器错误、内容快照、重复 ID 和事务日志均未在本批完成。
作者分析不能替代独立审查,也不代表历史 CodeQL 告警已经解决。
+4
View File
@@ -58,6 +58,10 @@ func (s *Store) durable() []durableEntry {
if json.Unmarshal(raw, &d) != nil || d.ID == "" || !sameDir(d.Workspace, s.workspace) {
continue
}
d, ok := s.bindRecord(mark, d)
if !ok {
continue
}
out = append(out, d)
}
return out
@@ -0,0 +1,120 @@
package isolation
import (
"os"
"path/filepath"
"strings"
"reasonix/internal/base/fileutil"
"reasonix/internal/platform/gitcmd"
)
// bindRecord treats stored paths as claims, not authority. Only the actual
// marker slot and the store's opened repository can authorize reconstruction
// or the legacy sweep. Rejected records remain on disk, without Git mutation.
func (s *Store) bindRecord(mark string, d durableEntry) (durableEntry, bool) {
slot := filepath.Dir(mark)
if !regularRecord(mark) || !plainDirectory(slot) ||
!sameExistingDirectory(filepath.Dir(slot), s.managedRoot) ||
!filepath.IsAbs(d.Cand.WorktreeRoot) || !plainDirectory(d.Cand.WorktreeRoot) ||
!sameExistingDirectory(filepath.Dir(d.Cand.WorktreeRoot), slot) {
return d, false
}
source := s.repo.Top()
if source.Verify() != nil || !sameExistingDirectory(d.Snap.RepoRoot, source.WorkTree) ||
!sameExistingDirectory(d.Workspace, s.workspace) {
return d, false
}
// The prefix binds both the original workspace and the candidate's tool root.
prefix := filepath.FromSlash(d.Snap.Prefix)
if filepath.IsAbs(prefix) || prefix == ".." || strings.HasPrefix(filepath.Clean(prefix), ".."+string(filepath.Separator)) ||
!sameExistingDirectory(filepath.Join(source.WorkTree, prefix), s.workspace) ||
!sameExistingDirectory(filepath.Join(d.Cand.WorktreeRoot, prefix), d.Cand.WorkspaceRoot) ||
!containedDirectory(source.WorkTree, s.workspace) || !containedDirectory(d.Cand.WorktreeRoot, d.Cand.WorkspaceRoot) {
return d, false
}
if d.Snap.Repo != (gitcmd.Repo{}) && !sameRepository(d.Snap.Repo, source) {
return d, false
}
candidate, ok := linkedCandidateIdentity(d.Cand.WorktreeRoot, source)
if !ok || d.Cand.Repo != (gitcmd.Repo{}) && !sameRepository(d.Cand.Repo, candidate) {
return d, false
}
// Use checked identities; no persisted record is rewritten during discovery.
d.Snap.Repo, d.Cand.Repo = source, candidate.Top()
return d, true
}
// A linked worktree's marker and administrative backlink establish its identity
// without starting Git, including for old records without pinned Repo fields.
func linkedCandidateIdentity(root string, source gitcmd.Repo) (gitcmd.Repo, bool) {
mark := filepath.Join(root, ".git")
if !regularRecord(mark) {
return gitcmd.Repo{}, false
}
raw, err := os.ReadFile(mark)
if err != nil {
return gitcmd.Repo{}, false
}
admin, ok := strings.CutPrefix(strings.TrimSpace(string(raw)), "gitdir: ")
if !ok || admin == "" {
return gitcmd.Repo{}, false
}
admin = filepath.FromSlash(admin)
if !filepath.IsAbs(admin) {
admin = filepath.Join(root, admin)
}
back, err := os.ReadFile(filepath.Join(admin, "gitdir"))
if err != nil {
return gitcmd.Repo{}, false
}
backPath := filepath.FromSlash(strings.TrimSpace(string(back)))
if !filepath.IsAbs(backPath) {
backPath = filepath.Join(admin, backPath)
}
// File identity alone also accepts hard-linked .git files in different
// candidates. The backlink must name this directory, not merely its inode.
if !sameExistingDirectory(filepath.Dir(backPath), root) {
return gitcmd.Repo{}, false
}
repo := gitcmd.Repo{Dir: root, WorkTree: root, GitDir: admin, CommonDir: source.CommonDir}
return repo, repo.Verify() == nil && !sameExistingDirectory(repo.GitDir, source.GitDir)
}
func sameRepository(a, b gitcmd.Repo) bool {
return a.Verify() == nil && sameExistingDirectory(a.Dir, b.WorkTree) &&
sameExistingDirectory(a.WorkTree, b.WorkTree) && sameExistingDirectory(a.GitDir, b.GitDir) &&
sameExistingDirectory(a.CommonDir, b.CommonDir)
}
func sameExistingDirectory(a, b string) bool {
left, err := os.Stat(a)
if err != nil || !left.IsDir() {
return false
}
right, err := os.Stat(b)
return err == nil && right.IsDir() && os.SameFile(left, right)
}
func plainDirectory(path string) bool {
info, err := os.Lstat(path)
return err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0
}
func regularRecord(path string) bool {
info, err := os.Lstat(path)
return err == nil && info.Mode().IsRegular()
}
func containedDirectory(root, path string) bool {
root, err := fileutil.ResolveExistingPath(root)
if err != nil {
return false
}
path, err = fileutil.ResolveExistingPath(path)
if err != nil {
return false
}
rel, err := filepath.Rel(root, path)
return err == nil && !filepath.IsAbs(rel) && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
@@ -0,0 +1,324 @@
package isolation
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/gitcmd"
)
// These are deliberately edited disk records, not states produced by Execute.
// Restart and the boot sweeper ingest this same host-local persistence boundary.
func TestDurableRecordCannotRedirectSettlement(t *testing.T) {
for _, operation := range []string{"paths", "apply", "discard", "sweep"} {
t.Run(operation, func(t *testing.T) {
ctx := context.Background()
repo := gitRepo(t)
managed, outside := testenv.TempDir(t), testenv.TempDir(t)
owner := NewStore(outside, repo, opened(t, repo))
report, err := owner.Execute(ctx, writes(map[string]string{"a.txt": "retained work\n"}), repo, "work", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
records := owner.durable()
if len(records) != 1 {
t.Fatalf("records = %v", records)
}
d := records[0]
original, err := os.ReadFile(entryPath(d.Cand))
if err != nil {
t.Fatal(err)
}
// Copy a real valid record under another store's root. Its embedded
// candidate still names the original, outside that store's authority.
write(t, managed, "copied/"+entryFile, string(original))
store := NewStore(managed, repo, opened(t, repo))
switch operation {
case "paths":
_, err = store.Paths(id)
case "apply":
_, err = store.Apply(ctx, id)
case "discard":
_, err = store.Discard(ctx, id)
case "sweep":
store.SweepExpired(ctx, -time.Second)
}
if operation != "sweep" && err == nil {
t.Error("accepted a candidate outside the marker's managed slot")
}
if got := read(t, repo, "a.txt"); got != "one\n" {
t.Errorf("redirected operation changed source: %q", got)
}
if got, err := os.ReadFile(entryPath(d.Cand)); err != nil || string(got) != string(original) {
t.Errorf("outside marker changed: %q, %v", got, err)
}
if got := read(t, d.Cand.WorktreeRoot, "a.txt"); got != "retained work\n" {
t.Errorf("outside candidate changed: %q", got)
}
if got := read(t, managed, "copied/"+entryFile); got != string(original) {
t.Error("invalid marker was changed instead of retained")
}
})
}
}
func TestDurableRecordCompatibility(t *testing.T) {
for _, legacy := range []bool{false, true} {
t.Run(map[bool]string{false: "pinned", true: "legacy"}[legacy], func(t *testing.T) {
repo := gitRepo(t)
write(t, repo, "sub/seed.txt", "seed\n")
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", "subdirectory"}} {
cmd := gitcmd.Command(context.Background(), repo, args...)
cmd.Env = append(cmd.Env, "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
workspace := filepath.Join(repo, "sub")
managed := testenv.TempDir(t)
store := NewStore(managed, workspace, opened(t, workspace))
report, err := store.Execute(context.Background(), writes(map[string]string{"new.txt": "retained\n"}), workspace, "work", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
d := store.durable()[0]
if legacy {
d.Snap.Repo, d.Cand.Repo = gitcmd.Repo{}, gitcmd.Repo{}
}
encoded, err := json.Marshal(d)
if err != nil {
t.Fatal(err)
}
// A future field is retained byte for byte: discovery is not migration.
encoded = append(encoded[:len(encoded)-1], []byte(`,"future":{"keep":true}}`)...)
if err := os.WriteFile(entryPath(d.Cand), encoded, 0o600); err != nil {
t.Fatal(err)
}
resumed := NewStore(managed, workspace, opened(t, workspace))
// Neither identity format starts Git merely to remain discoverable.
originalPath := os.Getenv("PATH")
t.Setenv("PATH", testenv.TempDir(t))
if got := resumed.Pending(); !slices.Equal(got, []string{id}) {
t.Fatalf("pending = %v, want [%s]", got, id)
}
if got := read(t, filepath.Dir(entryPath(d.Cand)), entryFile); got != string(encoded) {
t.Fatal("discovery rewrote metadata")
}
if !legacy {
return
}
t.Setenv("PATH", originalPath)
if _, err := resumed.Apply(context.Background(), id); err != nil {
t.Fatal(err)
}
if got := read(t, workspace, "new.txt"); got != "retained\n" {
t.Fatalf("legacy subdirectory result = %q", got)
}
})
}
}
func TestDurableRecordRejectsSymlinkedSlotAndMarker(t *testing.T) {
for _, kind := range []string{"slot", "marker"} {
t.Run(kind, func(t *testing.T) {
repo := gitRepo(t)
managed := testenv.TempDir(t)
store := NewStore(managed, repo, opened(t, repo))
report, err := store.Execute(context.Background(), writes(map[string]string{"a.txt": "retained\n"}), repo, "work", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
d := store.durable()[0]
mark := entryPath(d.Cand)
var link, target string
if kind == "slot" {
target, link = filepath.Dir(mark), filepath.Join(testenv.TempDir(t), "slot")
} else {
target = filepath.Join(testenv.TempDir(t), "saved.json")
if err := os.Rename(mark, target); err != nil {
t.Fatal(err)
}
link = mark
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink creation unavailable: %v", err)
}
if kind == "slot" {
managed = filepath.Dir(link)
}
resumed := NewStore(managed, repo, opened(t, repo))
resumed.SweepExpired(context.Background(), -time.Second)
if slices.Contains(resumed.Pending(), id) {
t.Error("offered a symlinked record slot or marker")
}
if got := read(t, d.Cand.WorktreeRoot, "a.txt"); got != "retained\n" {
t.Fatalf("candidate changed: %q", got)
}
if _, err := os.Stat(target); err != nil {
t.Fatal(err)
}
})
}
}
func TestDurableRecordAcceptsLinkedSourceWorkspace(t *testing.T) {
ctx := context.Background()
repo := gitRepo(t)
parent := NewStore(testenv.TempDir(t), repo, opened(t, repo))
e, err := parent.Begin(ctx, repo)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { parent.Abort(ctx, e) })
root := e.Cand.WorkspaceRoot
managed := testenv.TempDir(t)
child := NewStore(managed, root, opened(t, root))
report, err := child.Execute(ctx, writes(map[string]string{"a.txt": "child\n"}), root, "work", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
resumed := NewStore(managed, root, opened(t, root))
if got := resumed.Pending(); !slices.Equal(got, []string{id}) {
t.Fatalf("linked-source pending = %v", got)
}
if _, err := resumed.Apply(ctx, id); err != nil {
t.Fatal(err)
}
if got := read(t, root, "a.txt"); got != "child\n" {
t.Fatalf("linked-source apply = %q", got)
}
if got := read(t, repo, "a.txt"); got != "one\n" {
t.Fatal("linked-source apply reached the original workspace")
}
}
func TestDurableRecordChecksCandidateGitMarker(t *testing.T) {
repo := gitRepo(t)
managed := testenv.TempDir(t)
store := NewStore(managed, repo, opened(t, repo))
report, err := store.Execute(context.Background(), writes(map[string]string{"a.txt": "retained\n"}), repo, "work", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
d := store.durable()[0]
write(t, d.Cand.WorktreeRoot, ".git", "gitdir: "+opened(t, repo).GitDir+"\n")
resumed := NewStore(managed, repo, opened(t, repo))
if got := resumed.Pending(); slices.Contains(got, id) {
t.Fatal("trusted a stale pinned candidate identity after its marker changed")
}
if _, err := resumed.Paths(id); err == nil {
t.Fatal("accepted candidate with changed Git marker")
}
if got := read(t, d.Cand.WorktreeRoot, "a.txt"); got != "retained\n" {
t.Fatal("changed candidate was removed")
}
}
func TestDurableRecordRejectsHardlinkedCandidateMarker(t *testing.T) {
repo := gitRepo(t)
managed := testenv.TempDir(t)
store := NewStore(managed, repo, opened(t, repo))
for range 2 {
if _, err := store.Execute(context.Background(), writes(map[string]string{"a.txt": "retained\n"}), repo, "work", ""); err != nil {
t.Fatal(err)
}
}
records := store.durable()
if len(records) != 2 {
t.Fatalf("records = %v", records)
}
left, right := records[0], records[1]
marker := filepath.Join(left.Cand.WorktreeRoot, ".git")
if err := os.Remove(marker); err != nil {
t.Fatal(err)
}
if err := os.Link(filepath.Join(right.Cand.WorktreeRoot, ".git"), marker); err != nil {
t.Fatal(err)
}
left.Cand.Repo = right.Cand.Repo
left.Cand.Repo.Dir, left.Cand.Repo.WorkTree = left.Cand.WorktreeRoot, left.Cand.WorktreeRoot
// Repo.Verify historically compares .git files by inode. The hard link
// makes a different candidate's administrative backlink pass that check.
if err := left.Cand.Repo.Verify(); err != nil {
t.Fatalf("fixture did not establish the shared-inode alias: %v", err)
}
encoded, err := json.Marshal(left)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(entryPath(left.Cand), encoded, 0o600); err != nil {
t.Fatal(err)
}
resumed := NewStore(managed, repo, opened(t, repo))
if got := resumed.Pending(); slices.Contains(got, left.ID) || !slices.Contains(got, right.ID) {
t.Fatalf("hardlinked marker admission = %v; want only %s", got, right.ID)
}
if got := read(t, filepath.Dir(entryPath(left.Cand)), entryFile); got != string(encoded) {
t.Fatal("rejected marker changed")
}
}
func TestDurableRecordBindsSourceAndCandidateIdentity(t *testing.T) {
for _, tc := range []struct {
name string
edit func(*durableEntry, string, string)
}{
{"source-root", func(d *durableEntry, _, other string) { d.Snap.RepoRoot = other }},
{"source-repo", func(d *durableEntry, _, other string) { d.Snap.Repo = opened(t, other) }},
{"candidate-repo", func(d *durableEntry, repo, _ string) { d.Cand.Repo = opened(t, repo) }},
{"candidate-workspace", func(d *durableEntry, repo, _ string) { d.Cand.WorkspaceRoot = repo }},
{"prefix-escape", func(d *durableEntry, _, _ string) { d.Snap.Prefix = "../other" }},
} {
t.Run(tc.name, func(t *testing.T) {
repo, other := gitRepo(t), gitRepo(t)
managed := testenv.TempDir(t)
store := NewStore(managed, repo, opened(t, repo))
report, err := store.Execute(context.Background(), writes(map[string]string{"a.txt": "work\n"}), repo, "work", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
d := store.durable()[0]
mark := entryPath(d.Cand)
raw, err := os.ReadFile(mark)
if err != nil {
t.Fatal(err)
}
changed := d
tc.edit(&changed, repo, other)
encoded, err := json.Marshal(changed)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mark, encoded, 0o600); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.WriteFile(mark, raw, 0o600); err != nil {
t.Error(err)
}
})
restarted := NewStore(managed, repo, opened(t, repo))
if slices.Contains(restarted.Pending(), id) {
t.Error("offered mismatched record")
}
if _, err := restarted.Paths(id); err == nil {
t.Error("resolved paths from mismatched record")
}
if got := read(t, filepath.Dir(mark), entryFile); got != string(encoded) {
t.Error("rewrote mismatched record")
}
})
}
}
@@ -0,0 +1,2 @@
- Fixed: isolated-result recovery checks managed paths and repository identity. Mismatched records remain on disk and cannot trigger cleanup or apply. Refs #14025, #14133.
- 修复:隔离结果恢复前核对托管目录、源仓库和候选 worktree 身份。错配、复制或符号链接重定向的记录保留并拒绝操作;正常旧记录和子目录项目保持兼容。关联 #14025、#14133。