Compare commits

..

3 Commits

Author SHA1 Message Date
Rodin d573c14998 fix(docs): address review feedback on architecture clarity and path consistency
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 23s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 45s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m40s
- Clarify SPAWN exits vs HANDOFF continues in architecture diagram (S1)
- Add 'read' to toolsAllow in architecture snippet to match cron config (G2)
- Rephrase safety invariant 6 to clarify workers may push/manage labels (G3)
- Add reserved Rule 1 placeholder to explain numbering gap (S2)
- Clarify Rule 10 skip behavior for already-assigned PRs (S3)
- Standardize invariants checker path to full workspace path (G4/G5)
- Add note explaining SKILL.md deployment to workspace path (G1)
2026-05-15 01:03:04 -07:00
Rodin 151199e436 fix(docs): correct rule numbering and missing sr-fix template reference
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m36s
- Rule 11 (new issue pickup) was incorrectly labeled Rule 10 in SKILL.md
  dispatch rules table
- docs/dev-loop-spec.md referenced non-existent scripts/check-deps.sh
  instead of correct scripts/test/check-invariants.sh
- Add sr-fix.md to worker templates tables in both SKILL.md and spec
2026-05-15 07:47:02 +00:00
Rodin 76931dfee9 docs(#148): add SKILL.md and dev-loop-spec.md for dispatch redesign
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m4s
Document the new pure-shell dispatch architecture that eliminates the
model-reasoning vulnerability that caused issues #144 and #145.

- SKILL.md: overview of architecture, safety invariants, dispatch rules,
  file locations, cron config, and test commands
- docs/dev-loop-spec.md: authoritative spec for dispatch logic; defines
  all 11 rules, output protocol, error handling, and safety invariants
  (S1-S8) verified by check-invariants.sh

The dispatch script itself lives in workspace/scripts/ so it can be
updated without a repo PR cycle. This doc lives here so changes to the
spec are version-controlled alongside the code it governs.
2026-05-15 07:44:48 +00:00
4 changed files with 23 additions and 150 deletions
-10
View File
@@ -2,16 +2,6 @@
## Unreleased ## Unreleased
### Security
- **`validateDocmapPath`: add `EvalSymlinks` to close directory-symlink bypass** ([#150](https://gitea.weiker.me/rodin/review-bot/issues/150)): The previous implementation used `os.Lstat` which only avoids following the *final* path component. An intermediate directory symlink (e.g. `.review-bot/` committed as a symlink to a directory outside the repo) would pass the path-confinement check because the textual path appeared within the repo root. `filepath.EvalSymlinks` is now called first, resolving all symlink components before the `filepath.Rel` confinement check. In-repo symlinks whose resolved targets also reside within the repo root are now allowed; out-of-repo targets are rejected by the confinement check.
### Tests
- **`TestValidateDocmapPath_DirSymlinkBypass`**: verifies that a directory symlink inside the repo pointing outside cannot be used to bypass path confinement ([#150](https://gitea.weiker.me/rodin/review-bot/issues/150)).
- **`TestMainSubprocess_InvalidDocMapPath`**: verifies that a path-traversal `--docmap` argument (`../../../etc/passwd`) is rejected by the validate-docmap subcommand ([#146](https://gitea.weiker.me/rodin/review-bot/issues/146)).
- **`TestMainSubprocess_InvalidDocMapFile`**: verifies that a nonexistent `--docmap` file is rejected by the validate-docmap subcommand ([#146](https://gitea.weiker.me/rodin/review-bot/issues/146)).
### Added ### Added
- **`doc-map` input** (`--doc-map` flag / `DOC_MAP_FILE` env var): Path to a YAML file mapping source path globs to governing design docs. review-bot intersects the map with changed PR paths and injects matching docs into the system prompt under a `## Design Documents` heading. ([#137](https://gitea.weiker.me/rodin/review-bot/issues/137)) - **`doc-map` input** (`--doc-map` flag / `DOC_MAP_FILE` env var): Path to a YAML file mapping source path globs to governing design docs. review-bot intersects the map with changed PR paths and injects matching docs into the system prompt under a `## Design Documents` heading. ([#137](https://gitea.weiker.me/rodin/review-bot/issues/137))
-57
View File
@@ -1506,60 +1506,3 @@ func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
t.Errorf("expected deprecation warning for GITEA_URL, got: %s", out) t.Errorf("expected deprecation warning for GITEA_URL, got: %s", out)
} }
} }
// TestMainSubprocess_InvalidDocMapPath confirms that --doc-map with a path
// traversal attempt (e.g. ../../../etc/passwd) is rejected when the
// validate-docmap subcommand is used. The validate-docmap subcommand shares
// the same path validation logic (validateDocmapPath) that is called when
// --doc-map is provided to the main review-bot command.
func TestMainSubprocess_InvalidDocMapPath(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot", "validate-docmap",
"--docmap", "../../../etc/passwd",
"--repo-root", "/tmp",
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapPath")
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit for path traversal doc-map, got success")
}
combined := string(out)
if !strings.Contains(combined, "invalid") && !strings.Contains(combined, "repo-root") && !strings.Contains(combined, "traversal") && !strings.Contains(combined, "outside") {
t.Errorf("expected path confinement error for traversal path, got: %s", combined)
}
}
// TestMainSubprocess_InvalidDocMapFile confirms that --doc-map pointing at a
// nonexistent file is rejected. The validate-docmap subcommand exercises the
// same validateDocmapPath that is invoked when --doc-map is provided to the
// main review-bot command.
func TestMainSubprocess_InvalidDocMapFile(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
// Create a temp dir to serve as repo-root, then reference a nonexistent file inside it.
repoDir := os.TempDir()
os.Args = []string{"review-bot", "validate-docmap",
"--docmap", "nonexistent-doc-map.yml",
"--repo-root", repoDir,
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapFile")
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit for nonexistent doc-map file, got success")
}
combined := string(out)
if !strings.Contains(combined, "invalid") && !strings.Contains(combined, "cannot stat") && !strings.Contains(combined, "resolve") {
t.Errorf("expected file-not-found error for nonexistent doc-map, got: %s", combined)
}
}
+8 -19
View File
@@ -37,33 +37,22 @@ func validateDocmapPath(localPath, resolvedRoot string) error {
return fmt.Errorf("cannot resolve path: %w", err) return fmt.Errorf("cannot resolve path: %w", err)
} }
// Resolve ALL symlink components, not just the final one. // Lstat: do NOT follow symlinks. We want to inspect the entry itself.
// os.Lstat only avoids following the *final* path component; intermediate fi, err := os.Lstat(absPath)
// directory symlinks are still followed. EvalSymlinks resolves every
// component, closing the directory-symlink bypass: a PR that commits
// .review-bot/ as a directory symlink pointing outside the repo would
// otherwise pass the filepath.Rel confinement check because the textual
// path is inside the root while the actual destination is not.
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return fmt.Errorf("cannot resolve path (symlink): %w", err)
}
// Lstat the resolved path — at this point resolvedPath is symlink-free, so
// ModeSymlink will never be set. We keep the check as defense-in-depth.
fi, err := os.Lstat(resolvedPath)
if err != nil { if err != nil {
return fmt.Errorf("cannot stat file: %w", err) return fmt.Errorf("cannot stat file: %w", err)
} }
// Defense-in-depth: reject any remaining symlink indicator. // Reject symlinks outright — a symlink can point to /dev/zero or arbitrary
// host paths, bypassing both the confinement check and the size check.
if fi.Mode()&os.ModeSymlink != 0 { if fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks are not allowed") return fmt.Errorf("symlinks are not allowed")
} }
// Confine to resolvedRoot: use the fully-resolved path so that a directory // Confine to resolvedRoot: the cleaned absolute path must be a descendant
// symlink inside the repo cannot carry the path outside the root. // of the repo root. This catches paths that escaped via "..", absolute
rel, err := filepath.Rel(resolvedRoot, resolvedPath) // paths that happen to be outside the root, etc.
rel, err := filepath.Rel(resolvedRoot, absPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("path must be within --repo-root") return fmt.Errorf("path must be within --repo-root")
} }
+15 -64
View File
@@ -465,34 +465,23 @@ mappings:
} }
// TestValidateDocmapPath_Symlink verifies that --docmap pointing at a symlink // TestValidateDocmapPath_Symlink verifies that --docmap pointing at a symlink
// whose resolved target is outside --repo-root is rejected (prevents reading // is rejected before the file is read (prevents /dev/zero DOS or arbitrary
// arbitrary host files via PR-controlled symlinks). // host-file reads via PR-controlled symlinks).
//
// Note: after the EvalSymlinks fix (issue #150), in-repo symlinks whose
// targets also reside within the repo root are now allowed — the confinement
// check is applied to the resolved path, not the symlink entry itself. The
// security invariant is: the resolved destination must be within the root.
func TestValidateDocmapPath_Symlink(t *testing.T) { func TestValidateDocmapPath_Symlink(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
outside := t.TempDir()
// Create a docmap file OUTSIDE the repo root to serve as the symlink // Create a real docmap file to serve as the symlink target.
// target. EvalSymlinks will resolve to this path, which the Rel check realDocmap := makeDocmapInDir(t, dir, `
// must then reject. mappings:
if err := os.MkdirAll(filepath.Join(outside, ".review-bot"), 0o755); err != nil { - paths:
t.Fatalf("MkdirAll: %v", err) - "lib/**"
} docs:
outsideDocmap := filepath.Join(outside, ".review-bot", "doc-map.yml") - docs/foo.md
if err := os.WriteFile(outsideDocmap, []byte("mappings: []\n"), 0o644); err != nil { `)
t.Fatalf("WriteFile: %v", err)
}
// Create a symlink inside dir pointing to the file outside the repo. // Create a symlink inside dir pointing to the real docmap.
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml") symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
if err := os.Symlink(outsideDocmap, symlinkPath); err != nil { if err := os.Symlink(realDocmap, symlinkPath); err != nil {
t.Fatalf("Symlink: %v", err) t.Fatalf("Symlink: %v", err)
} }
@@ -501,10 +490,10 @@ func TestValidateDocmapPath_Symlink(t *testing.T) {
[]string{"--docmap", symlinkPath, "--repo-root", dir}, []string{"--docmap", symlinkPath, "--repo-root", dir},
) )
if code != 2 { if code != 2 {
t.Errorf("expected exit 2 for out-of-repo symlink docmap, got %d; stderr: %q", code, stderr) t.Errorf("expected exit 2 for symlink docmap, got %d; stderr: %q", code, stderr)
} }
if !strings.Contains(stderr, "invalid") && !strings.Contains(stderr, "repo-root") { if !strings.Contains(stderr, "symlink") && !strings.Contains(stderr, "invalid") {
t.Errorf("expected confinement rejection in stderr, got %q", stderr) t.Errorf("expected symlink rejection in stderr, got %q", stderr)
} }
} }
@@ -561,41 +550,3 @@ func TestValidateDocmapPath_SizeLimit(t *testing.T) {
t.Errorf("expected size limit error in stderr, got %q", stderr) t.Errorf("expected size limit error in stderr, got %q", stderr)
} }
} }
// TestValidateDocmapPath_DirSymlinkBypass verifies that a directory-symlink
// inside the repo pointing outside cannot be used to read arbitrary host files.
//
// Attack vector: a PR commits .review-bot/ as a directory symlink targeting a
// directory outside the repo. The textual path of the docmap file is inside
// the repo root, so the old Rel-only check passed — but the actual file is
// outside. This is closed by calling EvalSymlinks on the full path before the
// confinement check.
func TestValidateDocmapPath_DirSymlinkBypass(t *testing.T) {
repoDir := t.TempDir()
outsideDir := t.TempDir()
// Secret file outside the repo.
secretPath := filepath.Join(outsideDir, "secret.yml")
if err := os.WriteFile(secretPath, []byte("mappings: []\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create .review-bot/ as a directory symlink pointing outside the repo.
reviewBotDir := filepath.Join(repoDir, ".review-bot")
if err := os.Symlink(outsideDir, reviewBotDir); err != nil {
t.Skipf("cannot create dir symlink (platform may not support it): %v", err)
}
// Textually inside repo — .review-bot/secret.yml — but resolves outside.
attackPath := filepath.Join(repoDir, ".review-bot", "secret.yml")
// Resolve repoDir to a symlink-free path, as runValidateDocmap does.
resolvedRoot, err := filepath.EvalSymlinks(repoDir)
if err != nil {
t.Fatalf("EvalSymlinks(repoDir): %v", err)
}
if err := validateDocmapPath(attackPath, resolvedRoot); err == nil {
t.Error("expected rejection of dir-symlink bypass, got nil error")
}
}