From e53a1008f71db6f7a75a37e3c4711f0d9bbabb34 Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:31:18 -0500 Subject: [PATCH 1/3] feat(hooks): inspect client project identity without persistence Resolve hook cwd locally without opening the store or creating repository bindings. Preserve ordinary detector behavior while refusing unresolved Git metadata as a client identity. Refs #1911 --- cmd/engram/hook_claude.go | 6 +- cmd/engram/hook_project.go | 72 +++++++++ cmd/engram/hook_project_test.go | 261 +++++++++++++++++++++++++++++++ cmd/engram/main.go | 2 + docs/PLUGINS.md | 6 + internal/project/detect.go | 115 +++++++++++--- internal/project/inspect.go | 117 ++++++++++++++ internal/project/inspect_test.go | 77 +++++++++ 8 files changed, 632 insertions(+), 24 deletions(-) create mode 100644 cmd/engram/hook_project.go create mode 100644 cmd/engram/hook_project_test.go create mode 100644 internal/project/inspect.go create mode 100644 internal/project/inspect_test.go diff --git a/cmd/engram/hook_claude.go b/cmd/engram/hook_claude.go index 49a440717..a33a04856 100644 --- a/cmd/engram/hook_claude.go +++ b/cmd/engram/hook_claude.go @@ -61,6 +61,10 @@ var claudeHookOutput = func(response []byte) error { } func cmdHook(args []string) { + if len(args) == 1 && args[0] == "resolve-hook-project" { + cmdResolveHookProject() + return + } if len(args) == 1 && (args[0] == "codex-register" || args[0] == "codex-resolve" || args[0] == "codex-session-end") { cmdCodexLifecycle(args[0]) return @@ -70,7 +74,7 @@ func cmdHook(args []string) { return } if len(args) != 1 || (args[0] != "claude-pre-tool-use" && args[0] != "codex-pre-tool-use") { - fmt.Fprintln(os.Stderr, "usage: engram hook claude-pre-tool-use|codex-pre-tool-use|codex-user-prompt-submit") + fmt.Fprintln(os.Stderr, "usage: engram hook claude-pre-tool-use|codex-pre-tool-use|codex-user-prompt-submit|resolve-hook-project") exitFunc(1) return } diff --git a/cmd/engram/hook_project.go b/cmd/engram/hook_project.go new file mode 100644 index 000000000..52dda72fe --- /dev/null +++ b/cmd/engram/hook_project.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "unicode/utf8" + + projectpkg "github.com/Gentleman-Programming/engram/v3/internal/project" +) + +// cmdResolveHookProject is config-free like the other native hook actions. Its +// standalone inspection fits the tightest native prompt budget; native callers +// use InspectLocalProject directly with their own remaining context instead. +func cmdResolveHookProject() { + ctx, cancel := context.WithTimeout(context.Background(), codexUserPromptDeadline) + defer cancel() + output := map[string]string{} + input, err := io.ReadAll(os.Stdin) + if err == nil { + if cwd, ok := hookProjectCWD(input); ok { + if result, err := projectpkg.InspectLocalProject(ctx, cwd); err == nil { + output["project"] = result.Project + output["project_source"] = result.Source + output["project_path"] = result.Path + } + } + } + if err := json.NewEncoder(os.Stdout).Encode(output); err != nil { + exitFunc(1) + } +} + +// hookProjectCWD accepts existing hook fields but gives identity to exactly one +// lowercase cwd field. A map/struct decoder would silently accept duplicate keys. +func hookProjectCWD(input []byte) (string, bool) { + if !utf8.Valid(input) { + return "", false + } + decoder := json.NewDecoder(bytes.NewReader(input)) + start, err := decoder.Token() + if err != nil || start != json.Delim('{') { + return "", false + } + var cwd string + seen := false + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return "", false + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return "", false + } + if key == "cwd" { + if seen || json.Unmarshal(value, &cwd) != nil { + return "", false + } + seen = true + } + } + if end, err := decoder.Token(); err != nil || end != json.Delim('}') { + return "", false + } + if err := decoder.Decode(new(any)); err != io.EOF { + return "", false + } + return cwd, seen +} diff --git a/cmd/engram/hook_project_test.go b/cmd/engram/hook_project_test.go new file mode 100644 index 000000000..75b5af3f0 --- /dev/null +++ b/cmd/engram/hook_project_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + projectpkg "github.com/Gentleman-Programming/engram/v3/internal/project" +) + +func TestHookResolveHookProject(t *testing.T) { + binary := buildCompatibilityBinary(t) + var requests atomic.Int32 + trap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer trap.Close() + + for _, name := range []string{ + "configured", "unicode-config", "non-git", "bound-git", "linked-worktree", + "unbound-git", "unbound-child", "ambiguous", "invalid-config", "missing-directory", "file-directory", + "broken-gitfile", "broken-git-directory", "broken-git-child", "broken-git-descendant", + "malformed-json", "invalid-utf8", "missing-cwd", "null-cwd", "number-cwd", "blank-cwd", "nul-cwd", "wrong-case-cwd", "duplicate-cwd", + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + env := hookProjectFixtureEnvironment(t, root, trap.URL) + cwd := filepath.Join(root, "non-git") + if strings.HasPrefix(name, "broken-git") { + cwd = filepath.Join(root, "feature-x") + } + if err := os.Mkdir(cwd, 0o700); err != nil { + t.Fatal(err) + } + wantProject, wantSource := "", "" + switch name { + case "configured", "unicode-config": + value := "MyProj" + wantProject = "myproj" + if name == "unicode-config" { + value, wantProject = "Cafe\u0301", "café" + } + hookProjectFixtureConfig(t, cwd, value) + wantSource = "config" + case "non-git": + wantProject, wantSource = "non-git", "dir_basename" + case "bound-git", "linked-worktree", "unbound-git": + hookProjectFixtureGit(t, env, "init", "-q", cwd) + hookProjectFixtureGit(t, env, "-C", cwd, "remote", "add", "origin", "https://example.invalid/canonical-bound.git") + if name != "unbound-git" { + // Seed a binding through the existing public detector, not a private format. + resolved := projectpkg.DetectProjectFull(cwd) + if resolved.Error != nil || resolved.Project != "canonical-bound" { + t.Fatalf("fixture binding: %+v", resolved) + } + wantProject, wantSource = "canonical-bound", "git_remote" + } + if name == "linked-worktree" { + hookProjectFixtureGit(t, env, "-C", cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "fixture") + linked := filepath.Join(root, "linked") + hookProjectFixtureGit(t, env, "-C", cwd, "worktree", "add", "-q", "--detach", linked) + cwd = linked + } + case "unbound-child", "ambiguous": + hookProjectFixtureGit(t, env, "init", "-q", filepath.Join(cwd, "first")) + if name == "ambiguous" { + hookProjectFixtureGit(t, env, "init", "-q", filepath.Join(cwd, "second")) + } + case "broken-gitfile": + if err := os.WriteFile(filepath.Join(cwd, ".git"), []byte("gitdir: ../missing-metadata\n"), 0o600); err != nil { + t.Fatal(err) + } + case "broken-git-directory", "broken-git-child", "broken-git-descendant": + marker := filepath.Join(cwd, ".git") + if name == "broken-git-child" { + marker = filepath.Join(cwd, "child", ".git") + } + if err := os.MkdirAll(marker, 0o700); err != nil { + t.Fatal(err) + } + if name == "broken-git-descendant" { + cwd = filepath.Join(cwd, "nested") + if err := os.Mkdir(cwd, 0o700); err != nil { + t.Fatal(err) + } + } + case "invalid-config": + hookProjectFixtureConfig(t, cwd, "../invalid") + case "missing-directory": + cwd = filepath.Join(root, "absent", "feature-x") + case "file-directory": + cwd = filepath.Join(root, "not-a-directory") + if err := os.WriteFile(cwd, []byte("sentinel"), 0o600); err != nil { + t.Fatal(err) + } + } + if strings.HasPrefix(name, "broken-git") { + // The strict new inspector must not change ordinary detection. + legacyBefore := compatibilityTreeHash(t, root) + legacy := projectpkg.DetectProjectFull(cwd) + project, source, path, warning := filepath.Base(cwd), "dir_basename", cwd, "" + if name == "broken-git-child" { + project, source, path = "child", "git_child", filepath.Join(cwd, "child") + warning = "auto-promoted child repository: child" + } + if legacy.Error != nil || legacy.Project != project || legacy.Source != source || legacy.Path != path || legacy.Warning != warning || len(legacy.AvailableProjects) != 0 { + t.Fatalf("ordinary detector changed: %+v", legacy) + } + if compatibilityTreeHash(t, root) != legacyBefore { + t.Fatal("ordinary malformed-Git detection changed fixture state") + } + } + input, err := json.Marshal(map[string]any{"cwd": cwd, "session_id": "host", "private_field": "never-echo-this"}) + if err != nil { + t.Fatal(err) + } + switch name { + case "malformed-json": + input = []byte(`{"cwd":`) + case "invalid-utf8": + input = []byte{'{', '"', 'c', 'w', 'd', '"', ':', '"', 0xff, '"', '}'} + case "missing-cwd": + input = []byte(`{"session_id":"host"}`) + case "null-cwd": + input = []byte(`{"cwd":null}`) + case "number-cwd": + input = []byte(`{"cwd":7}`) + case "blank-cwd": + input = []byte(`{"cwd":" "}`) + case "nul-cwd": + input = []byte(`{"cwd":"bad\u0000path"}`) + case "wrong-case-cwd": + input, _ = json.Marshal(map[string]string{"CWD": cwd}) + case "duplicate-cwd": + encoded, _ := json.Marshal(cwd) + input = []byte(fmt.Sprintf(`{"cwd":%s,"cwd":%s}`, encoded, encoded)) + } + before := compatibilityTreeHash(t, root) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "hook", "resolve-hook-project") + cmd.Dir, cmd.Env, cmd.Stdin = root, env, bytes.NewReader(input) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + runErr := cmd.Run() + if runErr != nil || stderr.Len() != 0 { + t.Errorf("exit=%v stdout=%q stderr=%q; want exit 0 and empty stderr", runErr, stdout.String(), stderr.String()) + } + if wantProject == "" { + if stdout.String() != "{}\n" { + t.Errorf("stdout=%q, want exactly {} followed by newline", stdout.String()) + } + } else { + var result struct { + Project string `json:"project"` + Source string `json:"project_source"` + Path string `json:"project_path"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil || result.Project != wantProject || result.Source != wantSource { + t.Errorf("response=%q error=%v, want project=%q source=%q", stdout.String(), err, wantProject, wantSource) + } + // A linked worktree shares the primary repository's identity path; + // the other positive fixtures resolve to this directory directly. + wantInfo, wantErr := os.Stat(filepath.Join(root, "non-git")) + gotInfo, pathErr := os.Stat(result.Path) + if wantErr != nil || pathErr != nil || !os.SameFile(wantInfo, gotInfo) { + t.Errorf("project_path=%q errors=%v/%v, want the fixture identity directory", result.Path, wantErr, pathErr) + } + if strings.Contains(stdout.String(), "never-echo-this") { + t.Error("response leaked unrelated hook input") + } + } + if after := compatibilityTreeHash(t, root); after != before { + t.Error("inspection changed fixture HOME, data, config or Git state") + } + if requests.Load() != 0 { + t.Errorf("HTTP requests=%d, want 0", requests.Load()) + } + }) + } + + t.Run("existing-read-only-hook", func(t *testing.T) { + root := t.TempDir() + env := hookProjectFixtureEnvironment(t, root, trap.URL) + before := compatibilityTreeHash(t, root) + cmd := exec.Command(binary, "hook", "claude-pre-tool-use") + cmd.Dir, cmd.Env = root, env + cmd.Stdin = strings.NewReader(`{"tool_name":"mcp__other__read","session_id":"host","tool_input":{}}`) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil || stdout.String() != "{}" || stderr.Len() != 0 { + t.Fatalf("existing hook exit=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + if compatibilityTreeHash(t, root) != before || requests.Load() != 0 { + t.Fatal("existing read-only hook caused side effects") + } + }) +} + +func hookProjectFixtureConfig(t *testing.T, cwd, name string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(cwd, ".engram"), 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]string{"project_name": name}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwd, ".engram", "config.json"), data, 0o600); err != nil { + t.Fatal(err) + } +} + +func hookProjectFixtureEnvironment(t *testing.T, root, endpoint string) []string { + t.Helper() + env := make([]string, 0, 24) + for _, key := range []string{"PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"} { + if value, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+value) + } + } + for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TEMP", "TMP", "TMPDIR", "ENGRAM_DATA_DIR"} { + dir := filepath.Join(root, strings.ToLower(key)) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + env = append(env, key+"="+dir) + if key == "HOME" || key == "USERPROFILE" { + t.Setenv(key, dir) + } + } + globalConfig := filepath.Join(root, "git-global-config") + if err := os.WriteFile(globalConfig, nil, 0o600); err != nil { + t.Fatal(err) + } + for key, value := range map[string]string{"GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_COUNT": "0", "GIT_CEILING_DIRECTORIES": root} { + env = append(env, key+"="+value) + t.Setenv(key, value) + } + return append(env, "ENGRAM_URL="+endpoint, "ENGRAM_NO_UPDATE_CHECK=1", "ENGRAM_CLOUD_AUTOSYNC=0") +} + +func hookProjectFixtureGit(t *testing.T, env []string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Env = env + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } +} diff --git a/cmd/engram/main.go b/cmd/engram/main.go index ce777fb69..6b638a376 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -3927,6 +3927,8 @@ Commands: import Import memories from a JSON export file init [name] Initialize an Engram project (.engram/config.json) in current directory --force, -f Overwrite existing .engram/config.json + hook resolve-hook-project + Inspect the cwd in hook JSON on stdin without HTTP, database or binding writes projects list List all projects with observation, session, and prompt counts projects merge --from --to (--dry-run|--apply) Preview or apply a separator or Unicode normalization variant merge diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 44d227843..6b8a3363f 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -22,6 +22,12 @@ | Codex | Codex plugin assets under `plugin/codex/`; `engram setup codex` best-effort installs the marketplace plugin and writes MCP/instruction config. | | Pi | Pi package under `plugin/pi/` exposes Pi-native HTTP memory tools; Pi 0.99.0+ built-in MCP handles other servers, and an installed `pi-mcp-adapter` replaces it, so setup does not add it. | +### Client-local project inspection + +`engram hook resolve-hook-project` reads existing hook JSON from stdin and uses its exact lowercase `cwd` field to inspect the local filesystem. Other hook fields are ignored. It prints canonical `project`, `project_source` and `project_path` JSON, or `{}` when input or identity is unusable. Malformed input, duplicate `cwd`, inaccessible directories, ambiguity and invalid/unbound Git identities never fall back to an HTTP lookup. Malformed Git metadata in the cwd, its ancestors or a promoted child is not treated as a plain directory. Readable non-Git directories retain basename resolution. + +Inspection does not open the database, initialize global configuration, create Git bindings or register sessions. Existing config and repository bindings remain authoritative. The standalone action shares the native Codex prompt's 1.5-second inspection budget; internal callers can instead pass their remaining context to the shared Go inspector. This helper alone does not change the registration hooks or repair already misattributed sessions. + Pi and OpenCode activity renews the local runtime lease through their existing session registration paths. This is local SQLite liveness only: it has no timer, cloud synchronization, or cross-machine coordination. Pi sends `resume: true` for runtime-root registration and adopts only a valid acknowledged effective ID. The Go core selects numeric continuations; Pi persists the mapping in its session entries for reloads and uses it for attributed writes and shutdown `/end`. Legacy `:resume:` mappings are re-registered as-is; an ended mapping falls back to root registration with `resume: true`. Reload does not end the session. Ownership conflicts and an older server's `409 session_already_ended` block writes rather than triggering client-generated identities. diff --git a/internal/project/detect.go b/internal/project/detect.go index 6b00693bc..0c4f51a9e 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -141,6 +141,8 @@ type DetectionResult struct { // DetectionOptions supplies local history only when establishing a Git binding. // InspectOnly never initializes a binding; an unbound candidate is not authority. type DetectionOptions struct { + // Context optionally bounds an inspection and its Git subprocesses. Nil preserves ordinary detection. + Context context.Context HistoryLookup func(string) ([]string, error) InspectOnly bool // RequireExplicitHomeProject opts captures into HOME descendant validation. @@ -152,10 +154,29 @@ func DetectProjectFull(dir string) DetectionResult { } func DetectProjectFullWithOptions(dir string, options DetectionOptions) DetectionResult { - return detectProjectFull(dir, options, DirectoryIdentity(dir)) + ctx := options.detectionContext() + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err} + } + result := detectProjectFull(dir, options, DirectoryIdentity(dir)) + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err} + } + return result +} + +func (options DetectionOptions) detectionContext() context.Context { + if options.Context != nil { + return options.Context + } + return context.Background() } func detectProjectFull(dir string, options DetectionOptions, historyDirectory string) DetectionResult { + ctx := options.detectionContext() + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err} + } if dir == "" { dir = "." } @@ -164,7 +185,7 @@ func detectProjectFull(dir string, options DetectionOptions, historyDirectory st dir = "./" + dir } - if res, ok := detectFromConfig(dir); ok { + if res, ok := detectFromConfigContext(ctx, dir); ok { return res } @@ -174,9 +195,16 @@ func detectProjectFull(dir string, options DetectionOptions, historyDirectory st } // ── Cases 3 & 4: scan child directories ──────────────────────────── - children, timedOut := scanChildren(dir) + children, timedOut := scanChildrenContext(ctx, dir) + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err} + } if timedOut { - // Fall through to dir_basename (Case 5). + if options.InspectOnly && options.Context != nil { + // A bounded client inspection cannot treat an incomplete scan as identity. + return DetectionResult{Error: context.DeadlineExceeded} + } + // Ordinary callers retain the existing basename fallback. goto basename } switch len(children) { @@ -229,11 +257,17 @@ basename: // the repository's shared Git metadata. The binding is private to a clone and // shared by linked worktrees; mutable remotes are only consulted at creation. func detectFromGitBinding(dir string, options DetectionOptions, historyDirectory string) (DetectionResult, bool) { - commonDir := detectGitCommonDir(dir) + ctx := options.detectionContext() + var commonDir string + if options.Context == nil { + commonDir = detectGitCommonDir(dir) + } else { + commonDir = detectGitCommonDirContext(ctx, dir) + } if commonDir == "" { return DetectionResult{}, false } - root := detectGitRootDir(dir) + root := detectGitRootDirContext(ctx, dir) if root == "" { return DetectionResult{ Source: SourceGitRoot, @@ -242,15 +276,21 @@ func detectFromGitBinding(dir string, options DetectionOptions, historyDirectory } if options.RequireExplicitHomeProject { - if err := requireHomeProject(dir, root); err != nil { + if err := requireHomeProjectContext(ctx, dir, root); err != nil { return DetectionResult{Path: root, Error: err}, true } } legacyProject := normalize(filepath.Base(root)) source := SourceGitRoot - if name := detectFromGitRemote(dir); name != "" { - legacyProject = normalize(name) + var remote string + if options.Context == nil { + remote = detectFromGitRemote(dir) + } else { + remote = detectFromGitRemoteContext(ctx, dir) + } + if remote != "" { + legacyProject = normalize(remote) source = SourceGitRemote } binding, err := readRepositoryBinding(commonDir) @@ -273,6 +313,9 @@ func detectFromGitBinding(dir string, options DetectionOptions, historyDirectory } // The history lookup is a snapshot, not a transaction across stores and // Git metadata. Atomic publication still accepts an established winner. + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err}, true + } binding, err = loadOrCreateRepositoryBinding(commonDir, legacyProject) } if err != nil { @@ -283,7 +326,7 @@ func detectFromGitBinding(dir string, options DetectionOptions, historyDirectory // requireHomeProject runs before history lookup or private binding publication. // Query only the requested subtree; an unreadable index is not an empty index. -func requireHomeProject(dir, root string) error { +func requireHomeProjectContext(parent context.Context, dir, root string) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("cannot determine HOME for project selection: %w", err) @@ -292,7 +335,7 @@ func requireHomeProject(dir, root string) error { if DirectoryIdentity(root) != home { return nil } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(parent, 2*time.Second) defer cancel() // Linked worktrees retain their own checkout boundary, even when their // shared identity root is HOME. Failure to establish it must fail closed. @@ -327,7 +370,10 @@ type configFile struct { ProjectName string `json:"project_name"` } -func detectFromConfig(dir string) (DetectionResult, bool) { +func detectFromConfigContext(ctx context.Context, dir string) (DetectionResult, bool) { + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err}, true + } absDir, err := filepath.Abs(dir) if err != nil { absDir = dir @@ -338,8 +384,8 @@ func detectFromConfig(dir string) (DetectionResult, bool) { // cwd is inside git, walk upward only within the enclosing repository so a // nearest subproject .engram/config.json can override the repo root without // letting ~/.engram/config.json leak into nested workspaces under $HOME. - if worktreeRoot := canonicalizePath(detectGitWorktreeDir(absDir)); worktreeRoot != "" { - return readNearestConfigAtOrBelow(absDir, worktreeRoot) + if worktreeRoot := canonicalizePath(detectGitWorktreeDirContext(ctx, absDir)); worktreeRoot != "" { + return readNearestConfigAtOrBelowContext(ctx, absDir, worktreeRoot) } // Outside git, accept only the current directory's config. Do not walk to @@ -347,11 +393,14 @@ func detectFromConfig(dir string) (DetectionResult, bool) { return readConfigAt(absDir) } -func readNearestConfigAtOrBelow(startDir, stopDir string) (DetectionResult, bool) { +func readNearestConfigAtOrBelowContext(ctx context.Context, startDir, stopDir string) (DetectionResult, bool) { current := filepath.Clean(startDir) stop := filepath.Clean(stopDir) for { + if err := ctx.Err(); err != nil { + return DetectionResult{Error: err}, true + } if res, ok := readConfigAt(current); ok { return res, true } @@ -463,7 +512,11 @@ func normalizeConfigProjectName(projectName string) (string, error) { // detectGitRootDir returns the canonical repository root for dir, or "" if it is // not in a repository. Linked worktrees share this root with their primary checkout. func detectGitRootDir(dir string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + return detectGitRootDirContext(context.Background(), dir) +} + +func detectGitRootDirContext(parent context.Context, dir string) string { + ctx, cancel := context.WithTimeout(parent, 2*time.Second) defer cancel() commonDir := gitRevParsePath(ctx, dir, "--git-common-dir") @@ -485,7 +538,11 @@ func detectGitRootDir(dir string) string { } func detectGitCommonDir(dir string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + return detectGitCommonDirContext(context.Background(), dir) +} + +func detectGitCommonDirContext(parent context.Context, dir string) string { + ctx, cancel := context.WithTimeout(parent, 2*time.Second) defer cancel() return gitRevParsePath(ctx, dir, "--git-common-dir") } @@ -513,7 +570,11 @@ func repositoryRootFromCommonDir(commonDir string) string { // detectGitWorktreeDir returns the current checkout root for dir. Config files // are scoped to that checkout rather than the shared repository identity root. func detectGitWorktreeDir(dir string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + return detectGitWorktreeDirContext(context.Background(), dir) +} + +func detectGitWorktreeDirContext(parent context.Context, dir string) string { + ctx, cancel := context.WithTimeout(parent, 2*time.Second) defer cancel() cmd := command.NewContext(ctx, "git", "-C", dir, "rev-parse", "--show-toplevel") @@ -529,20 +590,24 @@ func detectGitWorktreeDir(dir string) string { // short-circuiting as soon as more than one repository is found. // Returns the list of found git-repo paths and a boolean indicating timeout. func scanChildren(dir string) (repos []string, timedOut bool) { + return scanChildrenContext(context.Background(), dir) +} + +func scanChildrenContext(ctx context.Context, dir string) (repos []string, timedOut bool) { deadline := childScanNow().Add(childScanTimeout) directory, err := os.Open(dir) if err != nil { return nil, false } defer directory.Close() - if childScanNow().After(deadline) { + if ctx.Err() != nil || childScanNow().After(deadline) { return nil, true } for { // Check before each bounded read. os.ReadDir materializes the whole // directory before this loop can enforce the deadline. - if childScanNow().After(deadline) { + if ctx.Err() != nil || childScanNow().After(deadline) { return repos, true } entries, readErr := childScanReadDir(directory, 1) @@ -552,7 +617,7 @@ func scanChildren(dir string) (repos []string, timedOut bool) { if readErr != nil { return repos, false } - if childScanNow().After(deadline) { + if ctx.Err() != nil || childScanNow().After(deadline) { return repos, true } if len(entries) == 0 { @@ -575,7 +640,7 @@ func scanChildren(dir string) (repos []string, timedOut bool) { // Check if this child is a git repo (has a .git entry). gitPath := filepath.Join(childPath, ".git") if _, err := os.Stat(gitPath); err == nil { - if childScanNow().After(deadline) { + if ctx.Err() != nil || childScanNow().After(deadline) { return repos, true } repos = append(repos, childPath) @@ -619,7 +684,11 @@ func fallbackProjectName(dir string) string { // remote "origin" URL. Returns empty string if git is unavailable, the // directory is not a repo, or there is no origin remote. func detectFromGitRemote(dir string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + return detectFromGitRemoteContext(context.Background(), dir) +} + +func detectFromGitRemoteContext(parent context.Context, dir string) string { + ctx, cancel := context.WithTimeout(parent, 2*time.Second) defer cancel() cmd := command.NewContext(ctx, "git", "-C", dir, "remote", "get-url", "origin") diff --git a/internal/project/inspect.go b/internal/project/inspect.go new file mode 100644 index 000000000..6dbc7ad92 --- /dev/null +++ b/internal/project/inspect.go @@ -0,0 +1,117 @@ +package project + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// InspectLocalProject resolves client-side identity without opening persistence, +// consulting history or establishing a repository binding. The caller retains +// authority over its total deadline and subsequent session registration. +func InspectLocalProject(ctx context.Context, directory string) (DetectionResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return DetectionResult{}, err + } + if strings.TrimSpace(directory) == "" || strings.ContainsRune(directory, 0) { + return DetectionResult{}, fmt.Errorf("inspection requires a usable cwd") + } + // A basename from a missing or unreadable directory is not client evidence. + // Read one entry to check directory access, without materializing its contents. + dir, err := os.Open(directory) + if err != nil { + return DetectionResult{}, err + } + info, statErr := dir.Stat() + if statErr != nil || !info.IsDir() { + _ = dir.Close() + return DetectionResult{}, fmt.Errorf("inspection requires a readable directory") + } + _, readErr := dir.Readdirnames(1) + closeErr := dir.Close() + if readErr != nil && readErr != io.EOF { + return DetectionResult{}, readErr + } + if closeErr != nil { + return DetectionResult{}, closeErr + } + options := DetectionOptions{Context: ctx, InspectOnly: true, RequireExplicitHomeProject: true} + result := DetectProjectFullWithOptions(directory, options) + if result.Error != nil { + return DetectionResult{}, result.Error + } + if result.Source == SourceGitChild { + // Auto-promotion changes the source label. Inspect the selected identity + // again so an unbound child cannot masquerade as established authority. + child := DetectProjectFullWithOptions(result.Path, options) + if child.Error != nil { + return DetectionResult{}, child.Error + } + if child.Source == SourceUnboundGit || child.Source == SourceDirBasename || child.Project != result.Project { + return DetectionResult{}, ErrRepositoryBinding + } + } + switch result.Source { + case SourceDirBasename: + if err := validateBasenameInspection(ctx, directory); err != nil { + return DetectionResult{}, err + } + case SourceConfig, SourceGitRemote, SourceGitRoot, SourceGitChild: + default: + return DetectionResult{}, ErrRepositoryBinding + } + project, err := normalizeProjectName(result.Project) + if err != nil { + return DetectionResult{}, err + } + if err := ctx.Err(); err != nil { + return DetectionResult{}, err + } + result.Project = project + return result, nil +} + +// A failed Git lookup does not prove that a directory is non-Git. Reject +// fallback across visible metadata, including broken gitfiles and ancestors; +// this validation never selects an identity or changes ordinary detection. +func validateBasenameInspection(ctx context.Context, directory string) error { + current, err := filepath.Abs(directory) + if err != nil { + return err + } + current = canonicalizePath(current) + start := DirectoryIdentity(current) + ceilings := make(map[string]bool) + for _, ceiling := range filepath.SplitList(os.Getenv("GIT_CEILING_DIRECTORIES")) { + if filepath.IsAbs(ceiling) { + ceilings[DirectoryIdentity(ceiling)] = true + } + } + for { + if err := ctx.Err(); err != nil { + return err + } + // Like Git, a ceiling bounds ancestor discovery, not cwd itself. + identity := DirectoryIdentity(current) + if identity != start && ceilings[identity] { + return nil + } + if _, err := os.Lstat(filepath.Join(current, ".git")); err == nil { + return ErrRepositoryBinding + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%w: cannot verify Git metadata: %v", ErrRepositoryBinding, err) + } + parent := filepath.Dir(current) + if parent == current { + return nil + } + current = parent + } +} diff --git a/internal/project/inspect_test.go b/internal/project/inspect_test.go new file mode 100644 index 000000000..f3c1e773c --- /dev/null +++ b/internal/project/inspect_test.go @@ -0,0 +1,77 @@ +package project + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestInspectLocalProjectCanceled(t *testing.T) { + root := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result, err := InspectLocalProject(ctx, root) + if !errors.Is(err, context.Canceled) || result.Project != "" { + t.Fatalf("result=%+v error=%v, want no identity and cancellation", result, err) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("canceled inspection changed fixture: entries=%v error=%v", entries, err) + } +} + +func TestDetectionContextStopsChildScan(t *testing.T) { + root := t.TempDir() + // Git does not use the current directory itself as an ancestor ceiling. + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(root)) + if err := os.Mkdir(filepath.Join(root, "ordinary-child"), 0o700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + original := childScanReadDir + t.Cleanup(func() { childScanReadDir = original }) + childScanReadDir = func(dir *os.File, count int) ([]os.DirEntry, error) { + entries, err := original(dir, count) + cancel() + return entries, err + } + result := DetectProjectFullWithOptions(root, DetectionOptions{Context: ctx, InspectOnly: true}) + if !errors.Is(result.Error, context.Canceled) || result.Project != "" { + t.Fatalf("result=%+v, want no identity after cancellation during scan", result) + } + // Omitting the new context preserves the existing detector's behavior even + // while an unrelated caller's context is canceled by the same test seam. + legacy := DetectProjectFull(root) + if legacy.Error != nil || legacy.Source != SourceDirBasename || legacy.Project != fallbackProjectName(root) { + t.Fatalf("ordinary detection changed: %+v", legacy) + } +} + +func TestInspectLocalProjectScanBudget(t *testing.T) { + root := t.TempDir() + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(root)) + original := childScanNow + t.Cleanup(func() { childScanNow = original }) + start := time.Now() + calls := 0 + childScanNow = func() time.Time { + calls++ + if calls == 1 { + return start + } + return start.Add(childScanTimeout + time.Millisecond) + } + result, err := InspectLocalProject(context.Background(), root) + if !errors.Is(err, context.DeadlineExceeded) || result.Project != "" { + t.Fatalf("result=%+v error=%v, want no identity after incomplete scan", result, err) + } + calls = 0 + legacy := DetectProjectFull(root) + if legacy.Error != nil || legacy.Source != SourceDirBasename { + t.Fatalf("ordinary scan-budget fallback changed: %+v", legacy) + } +} From 0dfb752d0f8483d3087da2fdd2b33d1b03f9282d Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:50:52 -0500 Subject: [PATCH 2/3] fix(project): remove unreachable child scan wrapper Exercise the production context-aware scanner directly in its regression test. Keep the dead-code ratchet and its baseline unchanged. Refs #1911 --- internal/project/detect.go | 6 +----- internal/project/detect_test.go | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/internal/project/detect.go b/internal/project/detect.go index 0c4f51a9e..7dc8d4418 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -585,14 +585,10 @@ func detectGitWorktreeDirContext(parent context.Context, dir string) string { return strings.TrimSpace(string(out)) } -// scanChildren scans dir at depth=1 for git repositories, skipping noise dirs, +// scanChildrenContext scans dir at depth=1 for git repositories, skipping noise dirs, // hidden dirs, enforcing a 200ms timeout, and // short-circuiting as soon as more than one repository is found. // Returns the list of found git-repo paths and a boolean indicating timeout. -func scanChildren(dir string) (repos []string, timedOut bool) { - return scanChildrenContext(context.Background(), dir) -} - func scanChildrenContext(ctx context.Context, dir string) (repos []string, timedOut bool) { deadline := childScanNow().Add(childScanTimeout) directory, err := os.Open(dir) diff --git a/internal/project/detect_test.go b/internal/project/detect_test.go index 63c54387c..3b618de45 100644 --- a/internal/project/detect_test.go +++ b/internal/project/detect_test.go @@ -1,6 +1,7 @@ package project import ( + "context" "encoding/json" "errors" "fmt" @@ -1321,7 +1322,7 @@ func TestChildScan_ContinuesPastNoiseUntilSecondRepository(t *testing.T) { return []os.DirEntry{entry}, nil } - repos, timedOut := scanChildren(parent) + repos, timedOut := scanChildrenContext(context.Background(), parent) if timedOut { t.Fatal("bounded scan unexpectedly timed out") } From deddaf3d84fb6d6952ecfc3d1b149acefe055251 Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:14:31 -0500 Subject: [PATCH 3/3] fix(cli): reject relative hook project directories Require absolute cwd in the standalone resolver so malformed host input cannot select ambient process identity. Preserve shared inspection and align regression tests, help and documentation. --- cmd/engram/hook_project.go | 3 ++- cmd/engram/hook_project_test.go | 10 +++++++++- cmd/engram/main.go | 2 +- docs/PLUGINS.md | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cmd/engram/hook_project.go b/cmd/engram/hook_project.go index 52dda72fe..ddc139d5f 100644 --- a/cmd/engram/hook_project.go +++ b/cmd/engram/hook_project.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "os" + "path/filepath" "unicode/utf8" projectpkg "github.com/Gentleman-Programming/engram/v3/internal/project" @@ -20,7 +21,7 @@ func cmdResolveHookProject() { output := map[string]string{} input, err := io.ReadAll(os.Stdin) if err == nil { - if cwd, ok := hookProjectCWD(input); ok { + if cwd, ok := hookProjectCWD(input); ok && filepath.IsAbs(cwd) { if result, err := projectpkg.InspectLocalProject(ctx, cwd); err == nil { output["project"] = result.Project output["project_source"] = result.Source diff --git a/cmd/engram/hook_project_test.go b/cmd/engram/hook_project_test.go index 75b5af3f0..b0c3fd777 100644 --- a/cmd/engram/hook_project_test.go +++ b/cmd/engram/hook_project_test.go @@ -28,7 +28,7 @@ func TestHookResolveHookProject(t *testing.T) { defer trap.Close() for _, name := range []string{ - "configured", "unicode-config", "non-git", "bound-git", "linked-worktree", + "configured", "unicode-config", "non-git", "relative-cwd", "relative-dot-cwd", "bound-git", "linked-worktree", "unbound-git", "unbound-child", "ambiguous", "invalid-config", "missing-directory", "file-directory", "broken-gitfile", "broken-git-directory", "broken-git-child", "broken-git-descendant", "malformed-json", "invalid-utf8", "missing-cwd", "null-cwd", "number-cwd", "blank-cwd", "nul-cwd", "wrong-case-cwd", "duplicate-cwd", @@ -55,6 +55,14 @@ func TestHookResolveHookProject(t *testing.T) { wantSource = "config" case "non-git": wantProject, wantSource = "non-git", "dir_basename" + case "relative-cwd": + cwd = "sub/dir" + if err := os.MkdirAll(filepath.Join(root, "sub", "dir"), 0o700); err != nil { + t.Fatal(err) + } + case "relative-dot-cwd": + hookProjectFixtureConfig(t, root, "ambient-project") + cwd = "." case "bound-git", "linked-worktree", "unbound-git": hookProjectFixtureGit(t, env, "init", "-q", cwd) hookProjectFixtureGit(t, env, "-C", cwd, "remote", "add", "origin", "https://example.invalid/canonical-bound.git") diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 6b638a376..b723120f4 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -3928,7 +3928,7 @@ Commands: init [name] Initialize an Engram project (.engram/config.json) in current directory --force, -f Overwrite existing .engram/config.json hook resolve-hook-project - Inspect the cwd in hook JSON on stdin without HTTP, database or binding writes + Inspect the absolute cwd in hook JSON on stdin without HTTP, database or binding writes projects list List all projects with observation, session, and prompt counts projects merge --from --to (--dry-run|--apply) Preview or apply a separator or Unicode normalization variant merge diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 6b8a3363f..7422f23f0 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -24,7 +24,7 @@ ### Client-local project inspection -`engram hook resolve-hook-project` reads existing hook JSON from stdin and uses its exact lowercase `cwd` field to inspect the local filesystem. Other hook fields are ignored. It prints canonical `project`, `project_source` and `project_path` JSON, or `{}` when input or identity is unusable. Malformed input, duplicate `cwd`, inaccessible directories, ambiguity and invalid/unbound Git identities never fall back to an HTTP lookup. Malformed Git metadata in the cwd, its ancestors or a promoted child is not treated as a plain directory. Readable non-Git directories retain basename resolution. +`engram hook resolve-hook-project` reads existing hook JSON from stdin and uses its exact lowercase `cwd` field to inspect the local filesystem. The `cwd` must be an absolute path; relative paths return `{}`. Other hook fields are ignored. It prints canonical `project`, `project_source` and `project_path` JSON, or `{}` when input or identity is unusable. Malformed input, duplicate `cwd`, inaccessible directories, ambiguity and invalid/unbound Git identities never fall back to an HTTP lookup. Malformed Git metadata in the cwd, its ancestors or a promoted child is not treated as a plain directory. Readable non-Git directories retain basename resolution. Inspection does not open the database, initialize global configuration, create Git bindings or register sessions. Existing config and repository bindings remain authoritative. The standalone action shares the native Codex prompt's 1.5-second inspection budget; internal callers can instead pass their remaining context to the shared Go inspector. This helper alone does not change the registration hooks or repair already misattributed sessions.