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
10 changes: 10 additions & 0 deletions .context/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/assets/commands/text/errors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 12 additions & 16 deletions internal/cli/agent/core/cooldown/cooldown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions internal/cli/pause/pause_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions internal/cli/resume/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions internal/cli/system/cmd/check_reminder/run_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
53 changes: 37 additions & 16 deletions internal/cli/system/core/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,42 @@ import (
)

// Dir returns the project-scoped runtime state directory
// (`<context dir>/state/`). Ensures the directory exists on each call;
// MkdirAll is a no-op when the directory is already present.
// (`<context dir>/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
Expand All @@ -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
Expand Down
Loading
Loading