diff --git a/.context/TASKS.md b/.context/TASKS.md index e0ea58010..4ec0d6ec5 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -25,6 +25,16 @@ TASK STATUS LABELS: `#in-progress`: currently being worked on (add inline, don't move task) --> +## Phase 0 Grounding + +- [ ] The target project (to be given to the Agent) has a good "phasing" + mechanism for tasks; implement that; maybe `ctx task add` can have a + `--phase` flag too, and we can have a auditor/normalizer for the current + task document; or a skill that does a semantic pass, or both too. +- [x] bug: asking "do you remember" automatically creates a blank .context + directory when using cursor + (Spec: specs/state-dir-no-mkdir-when-uninitialized.md) + ### Misc - [x] If context is not initialized, hooks should not run. Right now they run diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 37112b0c4..73ffc8791 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -69,6 +69,11 @@ err.context.dir-not-a-directory: short: 'CTX_DIR points at a file, not a directory: %s' err.context.dir-stat: short: 'cannot stat CTX_DIR %s: %w' +err.context.not-initialized: + short: |- + ctx is not initialized in this project: %s + Run 'ctx init' from the project root to set up context here. + See: https://ctx.ist/recipes/activating-context/ err.activate.no-candidates: short: |- ctx activate: no .context/ directory found from this location diff --git a/internal/cli/agent/core/cooldown/cooldown.go b/internal/cli/agent/core/cooldown/cooldown.go index b10ee3592..c8912819a 100644 --- a/internal/cli/agent/core/cooldown/cooldown.go +++ b/internal/cli/agent/core/cooldown/cooldown.go @@ -12,11 +12,10 @@ import ( "path/filepath" "time" + "github.com/ActiveMemory/ctx/internal/cli/system/core/state" "github.com/ActiveMemory/ctx/internal/config/agent" - "github.com/ActiveMemory/ctx/internal/config/dir" "github.com/ActiveMemory/ctx/internal/config/fs" ctxIo "github.com/ActiveMemory/ctx/internal/io" - "github.com/ActiveMemory/ctx/internal/rc" ) // Active checks whether the cooldown tombstone for the given @@ -86,21 +85,18 @@ func TouchTombstone(session string) error { // // Returns: // - string: absolute path under the context state directory. -// - error: non-nil when the context directory is not declared or -// the state directory cannot be created. Previously this helper -// logged the mkdir error and returned the path anyway, guaranteeing -// a second failure on the subsequent write; propagating keeps the -// first failure authoritative. +// - error: non-nil when the context directory is not declared, +// the project is not initialized, or the state directory cannot +// be created. Delegates to [state.Dir] so the +// [errCtx.ErrNotInitialized] gate applies here too — see +// specs/state-dir-no-mkdir-when-uninitialized.md. Without this, +// a hook-driven `ctx agent` invocation in a non-ctx project +// (the cross-IDE Cursor leak path) would mkdir `.context/state/` +// directly here, bypassing [state.Dir]'s gate. func TombstonePath(session string) (string, error) { - ctxDir, err := rc.ContextDir() - if err != nil { - return "", err - } - stateDir := filepath.Join(ctxDir, dir.State) - if mkdirErr := ctxIo.SafeMkdirAll( - stateDir, fs.PermRestrictedDir, - ); mkdirErr != nil { - return "", mkdirErr + stateDir, dirErr := state.Dir() + if dirErr != nil { + return "", dirErr } return filepath.Join(stateDir, agent.TombstonePrefix+session), nil } diff --git a/internal/cli/pause/pause_test.go b/internal/cli/pause/pause_test.go index a3f8c24bf..1e2d481c3 100644 --- a/internal/cli/pause/pause_test.go +++ b/internal/cli/pause/pause_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" "github.com/ActiveMemory/ctx/internal/config/dir" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -23,6 +24,17 @@ func setupStateDir(t *testing.T) string { if mkErr := os.MkdirAll(ctxDir, 0o750); mkErr != nil { t.Fatal(mkErr) } + // Seed the required files so state.Dir() considers the project + // initialized. Without these, state.Dir() returns + // errCtx.ErrNotInitialized to prevent the cross-IDE hook leak + // (see specs/state-dir-no-mkdir-when-uninitialized.md). + for _, f := range cfgCtx.FilesRequired { + if wrErr := os.WriteFile( + filepath.Join(ctxDir, f), []byte("# stub"), 0o600, + ); wrErr != nil { + t.Fatalf("seed required file %s: %v", f, wrErr) + } + } t.Setenv("CTX_DIR", ctxDir) rc.Reset() stateDir := filepath.Join(ctxDir, dir.State) diff --git a/internal/cli/resume/resume_test.go b/internal/cli/resume/resume_test.go index 21f2faac6..55a81b58e 100644 --- a/internal/cli/resume/resume_test.go +++ b/internal/cli/resume/resume_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/ActiveMemory/ctx/internal/cli/system/core/nudge" + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" "github.com/ActiveMemory/ctx/internal/config/dir" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -24,6 +25,15 @@ func setupStateDir(t *testing.T) string { if mkErr := os.MkdirAll(ctxDir, 0o750); mkErr != nil { t.Fatal(mkErr) } + // Seed required files so state.Dir() considers the project + // initialized. See specs/state-dir-no-mkdir-when-uninitialized.md. + for _, f := range cfgCtx.FilesRequired { + if wrErr := os.WriteFile( + filepath.Join(ctxDir, f), []byte("# stub"), 0o600, + ); wrErr != nil { + t.Fatalf("seed required file %s: %v", f, wrErr) + } + } t.Setenv("CTX_DIR", ctxDir) rc.Reset() stateDir := filepath.Join(ctxDir, dir.State) diff --git a/internal/cli/system/cmd/check_reminder/run_test.go b/internal/cli/system/cmd/check_reminder/run_test.go new file mode 100644 index 000000000..f1c2e1750 --- /dev/null +++ b/internal/cli/system/cmd/check_reminder/run_test.go @@ -0,0 +1,73 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package check_reminder_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/cli/system/cmd/check_reminder" + "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/env" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// TestRun_NoLeakInUninitializedProject reproduces the cross-IDE +// hook leak (specs/state-dir-no-mkdir-when-uninitialized.md) at +// the entry point that originally triggered it: check-reminder +// was called from Cursor's imported Claude hooks with CTX_DIR +// pointing at a non-ctx workspace, and the call chain +// (Preamble → nudge.Paused → PauseMarkerPath → state.Dir) leaked +// `.context/state/` (mode 0750) into the workspace. +// +// Acceptance: after running check-reminder against a directory +// that has CTX_DIR set but no ctx init, neither `.context/` nor +// `.context/state/` exists on disk. Hook exit must be non-error +// (hooks never fail the parent operation). +func TestRun_NoLeakInUninitializedProject(t *testing.T) { + tempDir := t.TempDir() + ctxDir := filepath.Join(tempDir, dir.Context) // not on disk + stateDir := filepath.Join(ctxDir, dir.State) + + t.Setenv(env.CtxDir, ctxDir) + rc.Reset() + t.Cleanup(rc.Reset) + + // Feed a minimal valid hook envelope on stdin via a pipe so + // Run reads from a real *os.File (its signature is exact). + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatalf("os.Pipe: %v", pipeErr) + } + go func() { + defer func() { _ = w.Close() }() + _, _ = io.Copy(w, bytes.NewReader([]byte( + `{"session_id":"00000000-0000-0000-0000-000000000000"}`, + ))) + }() + t.Cleanup(func() { _ = r.Close() }) + + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := check_reminder.Run(cmd, r); err != nil { + t.Fatalf("Run() error = %v, want nil (hooks must never fail)", err) + } + + if _, statErr := os.Stat(ctxDir); !os.IsNotExist(statErr) { + t.Errorf(".context/ leaked into uninitialized project: stat err = %v (want IsNotExist)", statErr) + } + if _, statErr := os.Stat(stateDir); !os.IsNotExist(statErr) { + t.Errorf(".context/state/ leaked into uninitialized project: stat err = %v (want IsNotExist)", statErr) + } +} diff --git a/internal/cli/system/core/state/state.go b/internal/cli/system/core/state/state.go index c9777a14f..5371c556c 100644 --- a/internal/cli/system/core/state/state.go +++ b/internal/cli/system/core/state/state.go @@ -19,28 +19,42 @@ import ( ) // Dir returns the project-scoped runtime state directory -// (`/state/`). Ensures the directory exists on each call; -// MkdirAll is a no-op when the directory is already present. +// (`/state/`). Creates the directory on demand only +// when the project is initialized; MkdirAll is a no-op when the +// directory is already present. // -// **Always returns an error when the path is empty.** Specifically, -// when CTX_DIR is not declared, Dir returns -// ("", [errCtx.ErrDirNotDeclared]) so callers that gate on -// `dirErr != nil` are uniformly safe. Defensive callers that need -// to special-case the legitimate-absence path can match with -// `errors.Is(dirErr, errCtx.ErrDirNotDeclared)`. +// **Always returns an error when the path is empty.** Three +// legitimate-absence sentinels can fire and should be treated by +// hook callers as silent no-ops, by interactive callers as +// user-facing errors: // -// The contract was tightened from the earlier ("", nil) form because -// that form silently invited `filepath.Join("", rel)` traps: -// callers that only checked `dirErr != nil` would join to a -// CWD-relative path and write to the wrong location. Returning an -// explicit error makes the empty-path case unrepresentable in a -// "looks fine" branch. +// - [errCtx.ErrDirNotDeclared]: CTX_DIR is unset. +// - [errCtx.ErrNotInitialized]: CTX_DIR is set, but the project +// lacks the required context files (`ctx init` has not run). +// +// The Initialized gate inside Dir was added to close a leak: a +// caller that reached Dir before checking [Initialized] would +// mkdir `.context/state/` (mode 0750) into a non-ctx project. That +// happens in practice when Cursor imports the ctx Claude plugin's +// hooks and fires them in every workspace it opens; see +// specs/state-dir-no-mkdir-when-uninitialized.md. Gating mkdir on +// Initialized makes the invariant ("no .context/state/ in +// uninitialized projects") structural rather than convention. +// +// The empty-path-on-error contract was tightened from the earlier +// ("", nil) form because that form silently invited +// `filepath.Join("", rel)` traps: callers that only checked +// `dirErr != nil` would join to a CWD-relative path and write to +// the wrong location. Returning an explicit error makes the +// empty-path case unrepresentable in a "looks fine" branch. // // Returns: // - string: Absolute path to the state directory; always non-empty // when the error is nil. -// - error: [errCtx.ErrDirNotDeclared] when CTX_DIR is unset, -// resolver errors otherwise, mkdir failures otherwise. +// - error: [errCtx.ErrDirNotDeclared] when CTX_DIR is unset; +// [errCtx.ErrNotInitialized] (wrapped via [errCtx.NotInitialized] +// for the user-facing path) when the project is not initialized; +// resolver errors otherwise; mkdir failures otherwise. func Dir() (string, error) { if dirOverride != "" { return dirOverride, nil @@ -52,6 +66,13 @@ func Dir() (string, error) { // errors.Is when they need to special-case the absence. return "", err } + if !ctxContext.Initialized(ctxDir) { + // Refuse to mkdir state/ in a project that has not been + // initialized. Wrap the sentinel so interactive callers + // surface a path-bearing message; hook callers that gate + // on dirErr != nil keep working as before. + return "", errCtx.NotInitialized(ctxDir) + } d := filepath.Join(ctxDir, dir.State) if mkdirErr := ctxIo.SafeMkdirAll(d, fs.PermRestrictedDir); mkdirErr != nil { return "", mkdirErr diff --git a/internal/cli/system/core/state/state_test.go b/internal/cli/system/core/state/state_test.go new file mode 100644 index 000000000..4162d11ca --- /dev/null +++ b/internal/cli/system/core/state/state_test.go @@ -0,0 +1,172 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package state_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/ActiveMemory/ctx/internal/cli/system/core/state" + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/env" + errCtx "github.com/ActiveMemory/ctx/internal/err/context" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// declareCtxDir creates a CTX_DIR-pointed `.context/` under a fresh +// tempDir, optionally seeding the required files so [state.Initialized] +// will return true. Returns the absolute path of the .context dir. +func declareCtxDir(t *testing.T, initialized bool) string { + t.Helper() + tempDir := t.TempDir() + ctxDir := filepath.Join(tempDir, dir.Context) + if mkErr := os.MkdirAll(ctxDir, 0o700); mkErr != nil { + t.Fatalf("mkdir .context: %v", mkErr) + } + if initialized { + for _, f := range cfgCtx.FilesRequired { + path := filepath.Join(ctxDir, f) + if wrErr := os.WriteFile(path, []byte("# stub"), 0o600); wrErr != nil { + t.Fatalf("seed required file %s: %v", f, wrErr) + } + } + } + t.Setenv(env.CtxDir, ctxDir) + rc.Reset() + t.Cleanup(rc.Reset) + return ctxDir +} + +// TestDir_Initialized verifies the happy path: in an initialized +// project, Dir returns the state path and creates the directory. +func TestDir_Initialized(t *testing.T) { + ctxDir := declareCtxDir(t, true) + + got, err := state.Dir() + if err != nil { + t.Fatalf("Dir() error = %v, want nil", err) + } + want := filepath.Join(ctxDir, dir.State) + if got != want { + t.Errorf("Dir() = %q, want %q", got, want) + } + if _, statErr := os.Stat(got); statErr != nil { + t.Errorf("state dir was not created: %v", statErr) + } +} + +// TestDir_Uninitialized is the regression test for the cross-IDE +// hook leak: when CTX_DIR is declared but the project is not +// initialized, Dir must return ErrNotInitialized and must NOT +// create the state directory. This is the structural invariant +// established in specs/state-dir-no-mkdir-when-uninitialized.md. +func TestDir_Uninitialized(t *testing.T) { + ctxDir := declareCtxDir(t, false) + stateDir := filepath.Join(ctxDir, dir.State) + + got, err := state.Dir() + if err == nil { + t.Fatal("Dir() error = nil, want ErrNotInitialized") + } + if !errors.Is(err, errCtx.ErrNotInitialized) { + t.Errorf("Dir() error = %v, want errors.Is(.., ErrNotInitialized)", err) + } + if got != "" { + t.Errorf("Dir() path = %q, want empty string on error", got) + } + if _, statErr := os.Stat(stateDir); !os.IsNotExist(statErr) { + t.Errorf("state/ was created in uninitialized project: stat err = %v (want IsNotExist)", statErr) + } +} + +// TestDir_UninitializedDoesNotCreateContextDir is the strongest +// form of the invariant: if .context/ itself does not exist on +// disk (Initialized returns false because the required files are +// absent — they are absent because the dir is absent), Dir must +// neither create .context/ nor .context/state/. This is the +// observed Cursor leak shape: opening a non-ctx workspace and +// submitting one prompt must leave the filesystem unchanged. +func TestDir_UninitializedDoesNotCreateContextDir(t *testing.T) { + tempDir := t.TempDir() + ctxDir := filepath.Join(tempDir, dir.Context) // does not exist on disk + t.Setenv(env.CtxDir, ctxDir) + rc.Reset() + t.Cleanup(rc.Reset) + + _, err := state.Dir() + if !errors.Is(err, errCtx.ErrNotInitialized) { + t.Fatalf("Dir() error = %v, want errors.Is(.., ErrNotInitialized)", err) + } + if _, statErr := os.Stat(ctxDir); !os.IsNotExist(statErr) { + t.Errorf(".context/ was materialized: stat err = %v (want IsNotExist)", statErr) + } + stateDir := filepath.Join(ctxDir, dir.State) + if _, statErr := os.Stat(stateDir); !os.IsNotExist(statErr) { + t.Errorf(".context/state/ was materialized: stat err = %v (want IsNotExist)", statErr) + } +} + +// TestDir_NotDeclared preserves the existing CTX_DIR-unset +// behavior: the resolver-level ErrDirNotDeclared sentinel +// propagates out of Dir unchanged. +func TestDir_NotDeclared(t *testing.T) { + t.Setenv(env.CtxDir, "") + rc.Reset() + t.Cleanup(rc.Reset) + + _, err := state.Dir() + if !errors.Is(err, errCtx.ErrDirNotDeclared) { + t.Errorf("Dir() error = %v, want errors.Is(.., ErrDirNotDeclared)", err) + } +} + +// TestDir_Override verifies the test-only override bypasses both +// the resolver and the Initialized gate. Tests that explicitly +// opt into a state dir must continue to work without faking the +// initialized state. +func TestDir_Override(t *testing.T) { + override := t.TempDir() + state.SetDirForTest(override) + t.Cleanup(func() { state.SetDirForTest("") }) + + got, err := state.Dir() + if err != nil { + t.Fatalf("Dir() with override error = %v, want nil", err) + } + if got != override { + t.Errorf("Dir() with override = %q, want %q", got, override) + } +} + +// TestInitialized_Uninitialized confirms the helper agrees with +// Dir's gate: an uninitialized project reports false. +func TestInitialized_Uninitialized(t *testing.T) { + declareCtxDir(t, false) + got, err := state.Initialized() + if err != nil { + t.Fatalf("Initialized() error = %v, want nil", err) + } + if got { + t.Error("Initialized() = true, want false") + } +} + +// TestInitialized_Initialized confirms the helper agrees with +// Dir's gate: an initialized project reports true. +func TestInitialized_Initialized(t *testing.T) { + declareCtxDir(t, true) + got, err := state.Initialized() + if err != nil { + t.Fatalf("Initialized() error = %v, want nil", err) + } + if !got { + t.Error("Initialized() = false, want true") + } +} diff --git a/internal/config/embed/text/err_fs.go b/internal/config/embed/text/err_fs.go index 4f0504da3..3159a190c 100644 --- a/internal/config/embed/text/err_fs.go +++ b/internal/config/embed/text/err_fs.go @@ -92,6 +92,11 @@ const ( // DescKeyErrContextDirStat is the text key for stat failures // other than not-exist (permission denied, I/O error). DescKeyErrContextDirStat = "err.context.dir-stat" + // DescKeyErrContextNotInitialized is the text key for the + // "context directory exists but ctx init has not run" rejection. + // Used when state.Dir() is invoked in a project that has CTX_DIR + // declared but lacks the required context files. + DescKeyErrContextNotInitialized = "err.context.not-initialized" ) // DescKeys for filesystem write output. diff --git a/internal/config/rc/messages.go b/internal/config/rc/messages.go index 39d012b6e..d93067bc2 100644 --- a/internal/config/rc/messages.go +++ b/internal/config/rc/messages.go @@ -28,6 +28,12 @@ const ( // ErrMsgContextDirStat is the sentinel message for stat failures // other than not-exist (permission denied, I/O error). ErrMsgContextDirStat = "context directory stat failed" + // ErrMsgNotInitialized is the sentinel message for the + // "context directory exists but ctx init has not run" rejection. + // Used by [state.Dir] to refuse mkdir in an uninitialized project, + // which would otherwise leak a stub `.context/state/` (mode 0750) + // into any directory a hook subprocess runs in. + ErrMsgNotInitialized = "context not initialized" ) // Format strings for sentinel-wrapping in err/context constructors. diff --git a/internal/err/context/context.go b/internal/err/context/context.go index 6bc42bcf6..9a2106ae5 100644 --- a/internal/err/context/context.go +++ b/internal/err/context/context.go @@ -71,6 +71,27 @@ var ErrContextDirNotADirectory = errors.New(cfgRc.ErrMsgContextDirNotADirectory) // underlying cause. var ErrContextDirStat = errors.New(cfgRc.ErrMsgContextDirStat) +// ErrNotInitialized is the sentinel returned when CTX_DIR is +// declared but the project lacks the required context files +// (i.e., `ctx init` has not run there). Distinct from +// [ErrDirNotDeclared] (no CTX_DIR at all) and from +// [ErrContextDirNotFound] (declared dir does not exist on disk): +// here the directory may or may not exist, but the contents do +// not constitute a ctx project. +// +// The motivating bug is the cross-IDE hook leak: Cursor imports +// Claude Code hooks and fires them in every workspace it opens. +// With the ctx Claude plugin enabled globally, hooks resolve +// CTX_DIR=$workspace/.context and call into ctx subcommands. Any +// such caller that reached [state.Dir] previously mkdir'd a stub +// `.context/state/` (mode 0750) into the workspace, even though +// the user never ran `ctx init` there. Returning this sentinel +// from [state.Dir] before the mkdir prevents the leak. +// +// Wrap via [NotInitialized] for user-facing messages so the +// offending path is shown. +var ErrNotInitialized = errors.New(cfgRc.ErrMsgNotInitialized) + // RelativeNotAllowed wraps [ErrRelativeNotAllowed] with the // offending value so the user sees what they declared. // @@ -152,6 +173,21 @@ func StatFailed(path string, cause error) error { ) } +// NotInitialized wraps [ErrNotInitialized] with the offending +// directory so the user sees which project is not initialized. +// +// Parameters: +// - path: absolute path to the (declared but uninitialized) context dir +// +// Returns: +// - error: wrapping [ErrNotInitialized] for [errors.Is] matches +func NotInitialized(path string) error { + return fmt.Errorf(cfgRc.FmtWrapColon, + ErrNotInitialized, + fmt.Sprintf(desc.Text(text.DescKeyErrContextNotInitialized), path), + ) +} + // NotFoundError is returned when the context directory does not exist. type NotFoundError struct { Dir string diff --git a/specs/state-dir-no-mkdir-when-uninitialized.md b/specs/state-dir-no-mkdir-when-uninitialized.md new file mode 100644 index 000000000..6ca4bae3c --- /dev/null +++ b/specs/state-dir-no-mkdir-when-uninitialized.md @@ -0,0 +1,227 @@ +# state.Dir() must not mkdir in uninitialized projects + +## Problem + +`state.Dir()` calls `SafeMkdirAll` unconditionally on every invocation, +materializing `.context/state/` (mode `0750`) even in projects that +have never been initialized with `ctx init`. This violates the +documented invariant on `state.Initialized()`: + +> "Hooks should no-op when this returns false to avoid creating a +> partial state (e.g., logs/) before initialization." + +The invariant is unenforceable as long as `Dir()` mkdirs first: any +caller that touches `Dir()` before consulting `Initialized()` leaks. + +The leak is reachable in practice today. Cursor's docs +(https://cursor.com/docs/hooks) state that Cursor imports Claude Code +hooks and sets `CLAUDE_PROJECT_DIR` to the workspace root for Claude +compatibility. With the `ctx@activememory-ctx` Claude plugin enabled +globally in `~/.claude/settings.json`, Cursor fires the imported +`UserPromptSubmit` hook chain in every workspace it opens. The chain +includes `ctx system check-reminder`, whose `Run` deliberately calls +`coreCheck.Preamble(stdin)` *before* `state.Initialized()` so that +provenance prints unconditionally: + +``` +check_reminder.Run + └─ coreCheck.Preamble # before the Initialized gate + └─ nudge.Paused(sessionID) + └─ PauseMarkerPath + └─ state.Dir() # ← SafeMkdirAll(.context/state, 0750) +``` + +Result: opening any non-ctx project in Cursor and submitting a single +prompt deposits a stub `.context/state/` directory into the project +root. Confirmed via leak inspection: mode bits `drwxr-x---` on the +created directory match `fs.PermRestrictedDir = 0750` exactly, which +is uniquely produced by `state.Dir()`. + +## Approach + +Move the initialization gate inside `state.Dir()` itself, so the +invariant is structural rather than conventional. `Dir()` returns a +new typed error, `errCtx.ErrNotInitialized`, when +`ctxContext.Initialized(ctxDir)` is false. The mkdir runs only on the +initialized path. + +This mirrors the existing handling of `errCtx.ErrDirNotDeclared`: +both are "legitimate absence" conditions that callers should treat +as a silent bail rather than a true error. Callers that already have +the `dirErr != nil → return nil` pattern (the dominant one) continue +to work unchanged. Callers that need to distinguish absence from +failure use `errors.Is(err, errCtx.ErrNotInitialized)`. + +## Behavior + +### Happy Path + +1. Hook process starts with `CTX_DIR` set to a properly initialized + project's `.context/`. +2. Caller invokes `state.Dir()`. +3. `rc.ContextDir()` resolves; `ctxContext.Initialized(ctxDir)` + returns true. +4. `SafeMkdirAll(ctxDir/state, 0o750)` runs (no-op when present). +5. Returns `(stateDir, nil)`. Existing behavior preserved. + +### Uninitialized Path (the new case) + +1. Hook process starts with `CTX_DIR` set to a non-ctx project's + `.context/` (which does not exist on disk). +2. Caller invokes `state.Dir()`. +3. `rc.ContextDir()` resolves; `ctxContext.Initialized(ctxDir)` + returns false (required files like `AGENT_PLAYBOOK.md` are absent). +4. `Dir()` returns `("", errCtx.ErrNotInitialized)` *without* mkdir. +5. Caller bails silently via its existing `dirErr != nil` branch. +6. Filesystem unchanged — no `.context/` materialized. + +### Edge Cases + +| Case | Expected behavior | +|------|-------------------| +| `CTX_DIR` unset | Unchanged: `rc.ContextDir()` returns `errCtx.ErrDirNotDeclared`; `Dir()` propagates as before. | +| `CTX_DIR` set, `.context/` does not exist on disk | New: `Initialized()` returns false (required files absent); returns `ErrNotInitialized`. No mkdir of `.context/` or `.context/state/`. | +| `CTX_DIR` set, `.context/` exists but partial (some required files missing) | New: `Initialized()` returns false; returns `ErrNotInitialized`. Critically, this is *also* a leak path today — `state/` would be created inside an otherwise-correct ctx-shaped dir. The fix closes it. | +| `CTX_DIR` set, fully initialized | Unchanged: mkdir runs (no-op when present), returns `(stateDir, nil)`. | +| Concurrent calls during a single hook invocation | Unchanged: `SafeMkdirAll` is idempotent. Initialized check is a stat-only read; no race introduced. | +| `Initialized()` itself errors (resolver failure) | Unchanged: propagate the error so callers' `dirErr != nil` branch fires. Do not silently treat as uninitialized — that would hide real failures. | +| `dirOverride` (test override) is set | Unchanged: bypass the gate entirely, return `(dirOverride, nil)`. Tests that explicitly opt into a state dir continue to work without needing to fake `Initialized()`. | + +### Validation Rules + +No new input validation. The change is internal to `state.Dir()`. + +### Error Handling + +| Error condition | User-facing message | Recovery | +|-----------------|---------------------|----------| +| `ErrNotInitialized` returned to a hook caller | None — hook bails silently. This is by design: hooks running in non-ctx projects must be invisible. | User runs `ctx init` if they want ctx in this project; otherwise no action needed. | +| `ErrNotInitialized` returned to an interactive command (e.g., `ctx remind list`, `ctx pad show`, `ctx task complete`) that calls `state.Dir()` | Print to stderr: `ctx: this project is not initialized. Run 'ctx init' to set up context here.` Exit non-zero (use the standard CLI error exit code; do not invent a new one). | Run `ctx init` in the project root. | +| `ErrDirNotDeclared` (existing) | Unchanged. | Unchanged. | +| Resolver / mkdir failures | Unchanged: surfaced as today. | Unchanged. | + +## Interface + +No CLI surface change. This is a library-internal contract change. + +## Implementation + +### Files to Create/Modify + +| File | Change | +|------|--------| +| `internal/err/context/errors.go` (or wherever `ErrDirNotDeclared` lives) | Add `ErrNotInitialized` sentinel error. | +| `internal/cli/system/core/state/state.go` | Insert `Initialized()` check between `rc.ContextDir()` and `SafeMkdirAll`. Update package-level docstring on `Dir()` to document the new contract. | +| `internal/cli/system/core/state/state_test.go` (new or existing) | Add unit tests: uninitialized → returns `ErrNotInitialized`, no mkdir occurs; initialized → mkdir runs as before; `dirOverride` bypasses the gate. | +| `internal/cli/system/cmd/check_reminder/run.go` | No code change required — the existing `if dirErr != nil { return nil }` branch in `Preamble`'s call chain absorbs the new error. Add a comment cross-referencing this spec to document why the order is now safe. | +| Other call sites of `state.Dir()` | Two-pass audit during implementation. Pass 1 — hook commands and other non-interactive callers: confirm the existing `dirErr != nil → return nil` branch absorbs `ErrNotInitialized` without warning. Pass 2 — interactive callers (every entry point reachable from a user-typed `ctx ...` subcommand): wrap the call so `errors.Is(err, errCtx.ErrNotInitialized)` produces the stderr message above and a non-zero exit. The full classified list is part of the PR description and must be exhaustive (no "rest as follow-up"). | +| `specs/tests/regression/uninit-no-state-leak/` | Add a test harness that simulates the Cursor scenario end-to-end. See Testing section. | + +### Key Functions + +```go +// internal/err/context/errors.go +var ErrNotInitialized = errors.New("ctx: project is not initialized") + +// internal/cli/system/core/state/state.go +func Dir() (string, error) { + if dirOverride != "" { + return dirOverride, nil + } + ctxDir, err := rc.ContextDir() + if err != nil { + return "", err + } + if !ctxContext.Initialized(ctxDir) { + return "", errCtx.ErrNotInitialized + } + d := filepath.Join(ctxDir, dir.State) + if mkdirErr := ctxIo.SafeMkdirAll(d, fs.PermRestrictedDir); mkdirErr != nil { + return "", mkdirErr + } + return d, nil +} +``` + +### Helpers to Reuse + +- `ctxContext.Initialized(ctxDir)` — already exists at + `internal/context/validate/validate.go:26`. +- `errCtx.ErrDirNotDeclared` pattern — model `ErrNotInitialized` on it + for symmetry (same package, same `errors.Is` ergonomics). + +## Configuration + +None. No new `.ctxrc` keys, env vars, or settings. + +## Testing + +### Unit + +- `state.Dir()` with uninitialized ctxDir → returns `ErrNotInitialized` + with empty path, no `state/` directory created. +- `state.Dir()` with initialized ctxDir → existing happy-path test + continues to pass. +- `state.Dir()` with `dirOverride` set → bypasses the gate. +- `errors.Is(err, errCtx.ErrNotInitialized)` works as expected for + callers that need to distinguish. + +### Integration / Regression + +The acceptance test for this spec: + +``` +specs/tests/regression/uninit-no-state-leak/ +``` + +Setup: +1. Create a tempdir; do NOT run `ctx init`. +2. Set `CTX_DIR=/.context`. +3. Invoke `ctx system check-reminder` directly (the leaking entry + point), feeding it a minimal valid hook JSON envelope on stdin. + +Assertions: +- Exit code 0 (hooks must never fail). +- `/.context` does not exist after the call. +- `/.context/state` does not exist after the call. +- stdout/stderr contain no warnings about the missing directory + (hooks in uninitialized projects are silent by contract). + +Repeat the same harness for every UserPromptSubmit hook in +`internal/assets/claude/hooks/hooks.json` to catch any future +regression. Parameterize over the hook list rather than hand-rolling +per hook. + +### Edge Case Coverage + +- Partial-init case: create `/.context/` with one file but + not all required files; verify `state/` is still not created. +- `CTX_DIR` unset: verify behavior is identical to before this change. + +## Non-Goals + +- **Not changing `event.Append`'s mkdir.** That path uses + `fs.PermExec` (0755) and goes through its own `logFilePath` resolver + which has its own gate semantics. It is not implicated in this leak + (mode bits don't match) and is out of scope. +- **Not introducing a top-level "is ctx active in this project?" CLI + command.** Users diagnose via `ctx system bootstrap` and `ctx + status`; no new surface needed. +- **Not redesigning the `Preamble` / `FullPreamble` split.** The + `check_reminder` "provenance-first" ordering is preserved exactly; + the fix sits below it in the call stack. A future cleanup may + collapse the two preamble variants, but it is not required to close + this leak. +- **Not auditing or fixing other places that may pre-create files in + uninitialized projects.** This spec closes the documented `0750` + leak. The implementation PR must additionally grep for `SafeMkdirAll` + / `os.MkdirAll` calls that target paths inside `ctxDir` and verify + each is reachable only from an initialized-gated path. Any + additional leak found in that grep is fixed in the same PR + (per CONSTITUTION's "No Broken Windows" — fix obvious issues when + encountered). What is excluded from this spec: a wider sweep of + *non-mkdir* writes (e.g., `notify.Send` webhook payloads), because + those do not create persistent filesystem state in the project tree. +- **Not changing Cursor's behavior or asking the user to disable the + Claude-hook import.** The hook firing is intended; only the + resulting filesystem side-effect is wrong.