Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/cmd/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,53 @@ func staleTTL() time.Duration {
return time.Duration(n) * time.Second
}

// currentRepoRoot resolves the absolute repo root the current Claude
// Code session is operating in, for the stop-guard scope check (issue
// #91). Prefer CLAUDE_PROJECT_DIR because Claude Code sets it per
// session for every hook invocation and it is not spoofable from
// within a prompt — Claude cannot edit its own session env. Fall back
// to walking up from pwd so a non-Claude-Code caller (direct stdio
// test) still resolves something sensible. Returns "" if no repo root
// can be determined; callers fall through to the legacy block in that
// case so a missing signal never silently approves.
func currentRepoRoot() string {
if dir := strings.TrimSpace(os.Getenv("CLAUDE_PROJECT_DIR")); dir != "" {
return dir
}
if dir, err := os.Getwd(); err == nil {
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
return ""
}

// samePath compares two filesystem paths by resolving symlinks and
// cleaning separators. Used by the stop-guard repo-match check so
// symlinked checkouts ("/Users/me/repos/x" via "/tmp/x") resolve
// equal. Falls back to cleaned-path comparison if EvalSymlinks fails
// (path does not exist yet, permission denied) so the check never
// panics on transient filesystem state.
func samePath(a, b string) bool {
norm := func(p string) string {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
if resolved, err := filepath.EvalSymlinks(p); err == nil {
return filepath.Clean(resolved)
}
return filepath.Clean(p)
}
return norm(a) == norm(b)
}

// sessionIsStale mirrors the lib/read-session.sh TTL rule: prefer
// UpdatedAt, fall back to StartedAt for pre-UpdatedAt state files
// written by older engine binaries. An unparseable (zero) timestamp is
Expand Down Expand Up @@ -451,6 +498,27 @@ func runStopGuard() {
return
}

// Scope restriction (issue #91): if the workflow was started in a
// different repo than the one the current Claude Code session is
// operating in, approve silently. The block is not a bypass — it
// remains active when the user returns to the originating repo;
// this only prevents a stuck workflow in repo A from nagging every
// turn of unrelated work in repo B. No TTL escape, no env override,
// no branch carve-out: if the session started in repo A, only repo A
// sees the block. Empty state.RepoRoot (pre-#91 sessions) or empty
// current repo root (hook env missing) falls through to the legacy
// block behavior — we do not silently approve on missing signals.
if state.RepoRoot != "" {
if cur := currentRepoRoot(); cur != "" && !samePath(state.RepoRoot, cur) {
fmt.Fprintf(guardStderr,
"devkit-stop-guard: session %s belongs to %s, current repo is %s — approving Stop (block remains active in originating repo)\n",
state.Workflow, state.RepoRoot, cur)
writeStopVerdict(stopVerdict{Decision: "approve"})
guardExit(0)
return
}
}

remaining := 0
if state.TotalSteps > 0 {
remaining = state.TotalSteps - state.CurrentIndex
Expand Down
229 changes: 229 additions & 0 deletions src/cmd/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,235 @@ func TestGuardStopHook(t *testing.T) {
}
}

// TestGuardStopHookRepoScope pins the issue-#91 scope restriction:
// the stop-guard nag fires only when the current Claude Code session's
// repo matches the repo that started the workflow. Cross-repo sessions
// approve silently; the block remains active when the user returns to
// the originating repo. The legacy empty-RepoRoot case (pre-#91
// sessions) falls through to the historical block behavior — no silent
// bypass on missing scope signal.
func TestGuardStopHookRepoScope(t *testing.T) {
tests := []struct {
name string
stateRepoRoot string
claudeProjectDir string
wantDecision string
}{
{
name: "matching repo → block",
stateRepoRoot: "/tmp/repo-a",
claudeProjectDir: "/tmp/repo-a",
wantDecision: "block",
},
{
name: "matching repo with trailing slash → block",
stateRepoRoot: "/tmp/repo-a",
claudeProjectDir: "/tmp/repo-a/",
wantDecision: "block",
},
{
name: "different repo → approve",
stateRepoRoot: "/tmp/repo-a",
claudeProjectDir: "/tmp/repo-b",
wantDecision: "approve",
},
{
name: "empty state.RepoRoot (pre-#91) → block",
stateRepoRoot: "",
claudeProjectDir: "/tmp/repo-b",
wantDecision: "block",
},
{
name: "empty CLAUDE_PROJECT_DIR (cannot resolve current) → block",
stateRepoRoot: "/tmp/repo-a",
claudeProjectDir: "",
wantDecision: "block",
},
}

for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeSession(t, dir, lib.SessionState{
Status: "running",
Workflow: "pr-ready",
StepEnforce: lib.EnforceHard,
TotalSteps: 5,
RepoRoot: tc.stateRepoRoot,
})
env := newGuardTestEnv(t, "", "", true, dir)
// CLAUDE_PROJECT_DIR drives currentRepoRoot() for the
// stop-guard's repo-match check. Unset forces the
// pwd-walk fallback, which tempdirs won't satisfy → "".
if tc.claudeProjectDir != "" {
t.Setenv("CLAUDE_PROJECT_DIR", tc.claudeProjectDir)
} else {
t.Setenv("CLAUDE_PROJECT_DIR", "")
// Also move pwd into a non-git tempdir so the
// fallback walk returns "" deterministically.
prev, _ := os.Getwd()
nowhere := t.TempDir()
if err := os.Chdir(nowhere); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(prev) })
}
runGuard(guardCmd, nil)
var v stopVerdict
if err := json.Unmarshal(env.stdout.Bytes(), &v); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if v.Decision != tc.wantDecision {
t.Fatalf("decision=%q want=%q (stderr=%q)", v.Decision, tc.wantDecision, env.stderr.String())
}
})
}
}

// TestGuardStopHookRepoScopeWalkUp covers the pwd-walk fallback path in
// currentRepoRoot() — all cases in TestGuardStopHookRepoScope set
// CLAUDE_PROJECT_DIR, so the `.git`-finding loop never executes there.
// Also covers the subdirectory case (session cwd several levels below
// the repo root).
func TestGuardStopHookRepoScopeWalkUp(t *testing.T) {
// Build a fake repo with a nested subdir: repo/a/b.
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
sub := filepath.Join(repo, "a", "b")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatalf("mkdir sub: %v", err)
}

tests := []struct {
name string
stateRepoRoot string
cwd string
wantDecision string
}{
{
name: "walk-up from repo root resolves match → block",
stateRepoRoot: repo,
cwd: repo,
wantDecision: "block",
},
{
name: "walk-up from nested subdir resolves match → block",
stateRepoRoot: repo,
cwd: sub,
wantDecision: "block",
},
}

for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeSession(t, dir, lib.SessionState{
Status: "running",
Workflow: "pr-ready",
StepEnforce: lib.EnforceHard,
TotalSteps: 5,
RepoRoot: tc.stateRepoRoot,
})
env := newGuardTestEnv(t, "", "", true, dir)
// Unset CLAUDE_PROJECT_DIR to force the pwd-walk
// branch of currentRepoRoot().
t.Setenv("CLAUDE_PROJECT_DIR", "")
prev, _ := os.Getwd()
if err := os.Chdir(tc.cwd); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(prev) })
runGuard(guardCmd, nil)
var v stopVerdict
if err := json.Unmarshal(env.stdout.Bytes(), &v); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if v.Decision != tc.wantDecision {
t.Fatalf("decision=%q want=%q (stderr=%q)", v.Decision, tc.wantDecision, env.stderr.String())
}
})
}
}

// TestGuardStopHookRepoScopeSymlink proves samePath() actually uses
// EvalSymlinks rather than bare Clean(): a symlinked path pointing at
// the originating repo must compare equal and keep the block active.
// Regression guard for macOS /var → /private/var and user-created
// symlinks to worktrees.
func TestGuardStopHookRepoScopeSymlink(t *testing.T) {
real := t.TempDir()
if err := os.Mkdir(filepath.Join(real, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
linkParent := t.TempDir()
link := filepath.Join(linkParent, "linked-repo")
if err := os.Symlink(real, link); err != nil {
t.Skipf("symlink not supported on this platform: %v", err)
}

dir := t.TempDir()
writeSession(t, dir, lib.SessionState{
Status: "running",
Workflow: "pr-ready",
StepEnforce: lib.EnforceHard,
TotalSteps: 5,
RepoRoot: real,
})
env := newGuardTestEnv(t, "", "", true, dir)
t.Setenv("CLAUDE_PROJECT_DIR", link)

runGuard(guardCmd, nil)
var v stopVerdict
if err := json.Unmarshal(env.stdout.Bytes(), &v); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if v.Decision != "block" {
t.Fatalf("decision=%q want=%q — samePath should resolve the symlink (stderr=%q)", v.Decision, "block", env.stderr.String())
}
}

// TestGuardStopHookRepoScopeWorktree pins currentRepoRoot()'s support
// for git worktrees, where `.git` is a FILE (gitlink) instead of a
// directory. devkit itself runs from worktrees; a future refactor to
// IsDir() on the walk-up check would silently bypass the block for
// every worktree user without this test.
func TestGuardStopHookRepoScopeWorktree(t *testing.T) {
worktree := t.TempDir()
// Worktrees have .git as a regular file containing `gitdir: ...`.
if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: /tmp/fake-main/.git/worktrees/wt\n"), 0o644); err != nil {
t.Fatalf("write .git file: %v", err)
}

dir := t.TempDir()
writeSession(t, dir, lib.SessionState{
Status: "running",
Workflow: "pr-ready",
StepEnforce: lib.EnforceHard,
TotalSteps: 5,
RepoRoot: worktree,
})
env := newGuardTestEnv(t, "", "", true, dir)
t.Setenv("CLAUDE_PROJECT_DIR", "")
prev, _ := os.Getwd()
if err := os.Chdir(worktree); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(prev) })

runGuard(guardCmd, nil)
var v stopVerdict
if err := json.Unmarshal(env.stdout.Bytes(), &v); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if v.Decision != "block" {
t.Fatalf("decision=%q want=%q — worktree .git as file must still resolve as repo root (stderr=%q)", v.Decision, "block", env.stderr.String())
}
}

func TestGuardStopHookStaleSession(t *testing.T) {
// Stale-during-Stop → approve so a crashed engine doesn't trap the
// user in an un-stoppable session.
Expand Down
10 changes: 10 additions & 0 deletions src/lib/state_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ type SessionState struct {
Busy bool `json:"busy,omitempty"`
LoopIteration int `json:"loop_iteration,omitempty"` // current loop count for loop steps
LoopMax int `json:"loop_max,omitempty"` // max iterations for current loop
// RepoRoot is the absolute path of the repository whose MCP server
// started this workflow. The Stop hook uses it to scope the
// incomplete-workflow nag to the originating repo: opening a Claude
// Code session in a different repo sees an approve verdict instead
// of blocking on a workflow that does not belong to that project.
// This is scope restriction, not a bypass — returning to the
// originating repo resumes the block. Empty on sessions written by
// pre-#91 engines; the guard treats empty as "no scoping, preserve
// legacy block behavior."
RepoRoot string `json:"repo_root,omitempty"`
}

// UnmarshalJSON validates StepEnforce at read time so a stale or
Expand Down
1 change: 1 addition & 0 deletions src/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ func (s *Server) startTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) {
Status: "starting",
StartedAt: time.Now(),
Outputs: map[string]string{},
RepoRoot: s.repoRoot,
}, nil
})
if err != nil {
Expand Down
28 changes: 18 additions & 10 deletions workflows/tri-debug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,32 @@ steps:
- id: smart-diagnosis
model: smart
prompt: |
Debug this issue: {{input}}

Trace the root cause. Read the relevant code, check assumptions,
and propose a specific fix with reasoning.
DISPATCH, DO NOT ANSWER. Tri-debug requires three DIFFERENT models
diagnose independently. Invoke the Agent tool with a non-Claude
smart-tier subagent (`codex:codex-rescue` is the standard external
diagnostician). Pass the issue and report verbatim. If no external
subagent is available, stop and tell the user — do NOT answer as
yourself. Subagent prompt: "Debug this issue: {{input}}. Trace the
root cause. Read the relevant code, check assumptions, and propose
a specific fix with reasoning."

- id: general-diagnosis
model: general
prompt: |
Debug this issue: {{input}}

What's the most likely cause? Suggest a fix.
DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a non-Claude
general-tier subagent (prefer a Gemini-backed agent, or a different
external than smart-diagnosis used). Report verbatim. Stop if none
available. Subagent prompt: "Debug this issue: {{input}}. What's
the most likely cause? Suggest a fix."

- id: fast-diagnosis
model: fast
prompt: |
Debug this issue: {{input}}

Quick diagnosis — what's probably wrong and how to fix it.
DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a fast-tier
external subagent (distinct from the other two tiers). Report
verbatim. Stop if none available. Subagent prompt: "Debug this
issue: {{input}}. Quick diagnosis — what's probably wrong and how
to fix it."

- id: dispatch-debug
parallel: [smart-diagnosis, general-diagnosis, fast-diagnosis]
Expand Down
Loading
Loading