Skip to content

Commit 3d8e572

Browse files
committed
feat: add PreToolUse hook to block non-PATH ctx invocations
Agents were using ./dist/ctx and go run ./cmd/ctx despite documentation saying to use ctx from PATH. This adds runtime enforcement via a PreToolUse hook that blocks forbidden patterns and returns a clear error message pointing to CONSTITUTION.md. - Add block-non-path-ctx.sh hook script (blocks ./ctx, ./dist/ctx, go run ./cmd/ctx) - Embed script in ctx binary for ctx init bootstrapping - Update CreateDefaultHooks to include Bash matcher for blocking hook - Update init.go to write the hook during ctx init Signed-off-by: Jose Alekhinne <alekhinejose@gmail.com>
1 parent 98077ab commit 3d8e572

4 files changed

Lines changed: 171 additions & 9 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/bin/bash
2+
3+
# / Context: https://ctx.ist
4+
# ,'`./ do you remember?
5+
# `.,'\
6+
# \ Copyright 2025-present Context contributors.
7+
# SPDX-License-Identifier: Apache-2.0
8+
9+
# Block non-PATH ctx invocations
10+
# This enforces the CONSTITUTION.md rule: "ALWAYS use ctx from PATH"
11+
#
12+
# BLOCKED PATTERNS:
13+
# - ./ctx, ./dist/ctx, ./dist/ctx-* (relative paths)
14+
# - go run ./cmd/ctx (build-and-run)
15+
# - /absolute/path/to/ctx (hardcoded absolute paths)
16+
#
17+
# ALLOWED:
18+
# - ctx status, ctx agent, etc. (PATH-based invocation)
19+
20+
# Read hook input from stdin (JSON)
21+
HOOK_INPUT=$(cat)
22+
23+
# Extract the command being run
24+
COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty')
25+
26+
# If no command, allow (not a Bash call we care about)
27+
if [ -z "$COMMAND" ]; then
28+
exit 0
29+
fi
30+
31+
# Check for forbidden patterns
32+
BLOCKED_REASON=""
33+
34+
# Pattern 1: ./ctx or ./dist/ctx or ./dist/ctx-*
35+
if echo "$COMMAND" | grep -qE '(\./ctx|\./dist/ctx)'; then
36+
BLOCKED_REASON="Use 'ctx' from PATH, not './ctx' or './dist/ctx'. Install with: sudo make install"
37+
fi
38+
39+
# Pattern 2: go run ./cmd/ctx
40+
if echo "$COMMAND" | grep -qE 'go run \./cmd/ctx'; then
41+
BLOCKED_REASON="Use 'ctx' from PATH, not 'go run ./cmd/ctx'. Install with: sudo make install"
42+
fi
43+
44+
# Pattern 3: Absolute paths to ctx binary (but not just 'ctx' or paths in /usr/local/bin, /usr/bin)
45+
# Match things like /home/user/project/ctx or /tmp/ctx-test but allow /usr/local/bin/ctx
46+
if echo "$COMMAND" | grep -qE '(/home/|/tmp/|/var/)[^ ]*ctx[^ ]* '; then
47+
# Exception: allow /tmp/ctx-test for integration tests
48+
if ! echo "$COMMAND" | grep -qE '/tmp/ctx-test'; then
49+
BLOCKED_REASON="Use 'ctx' from PATH, not absolute paths. Install with: sudo make install"
50+
fi
51+
fi
52+
53+
# If blocked, output JSON that tells Claude Code to reject
54+
if [ -n "$BLOCKED_REASON" ]; then
55+
cat << EOF
56+
{"decision": "block", "reason": "$BLOCKED_REASON\n\nSee CONSTITUTION.md: ctx Invocation Invariants"}
57+
EOF
58+
exit 0
59+
fi
60+
61+
# Allow the command
62+
exit 0

internal/claude/embed.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"fmt"
1313
)
1414

15-
//go:embed tpl/auto-save-session.sh
15+
//go:embed tpl/auto-save-session.sh tpl/block-non-path-ctx.sh
1616
var FS embed.FS
1717

1818
// GetAutoSaveScript returns the auto-save session script.
@@ -24,6 +24,15 @@ func GetAutoSaveScript() ([]byte, error) {
2424
return content, nil
2525
}
2626

27+
// GetBlockNonPathCtxScript returns the script that blocks non-PATH ctx invocations.
28+
func GetBlockNonPathCtxScript() ([]byte, error) {
29+
content, err := FS.ReadFile("tpl/block-non-path-ctx.sh")
30+
if err != nil {
31+
return nil, fmt.Errorf("failed to read block-non-path-ctx.sh: %w", err)
32+
}
33+
return content, nil
34+
}
35+
2736
// SettingsHooks represents the hooks section of settings.local.json
2837
type SettingsHooks struct {
2938
PreToolUse []HookMatcher `json:"PreToolUse,omitempty"`
@@ -59,6 +68,17 @@ func CreateDefaultHooks(projectDir string) SettingsHooks {
5968
return SettingsHooks{
6069
PreToolUse: []HookMatcher{
6170
{
71+
// Block non-PATH ctx invocations (./ctx, ./dist/ctx, go run ./cmd/ctx)
72+
Matcher: "Bash",
73+
Hooks: []Hook{
74+
{
75+
Type: "command",
76+
Command: fmt.Sprintf("%s/block-non-path-ctx.sh", hooksDir),
77+
},
78+
},
79+
},
80+
{
81+
// Auto-load context on every tool use
6282
Matcher: ".*",
6383
Hooks: []Hook{
6484
{
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/bin/bash
2+
3+
# / Context: https://ctx.ist
4+
# ,'`./ do you remember?
5+
# `.,'\
6+
# \ Copyright 2025-present Context contributors.
7+
# SPDX-License-Identifier: Apache-2.0
8+
9+
# Block non-PATH ctx invocations
10+
# This enforces the CONSTITUTION.md rule: "ALWAYS use ctx from PATH"
11+
#
12+
# Generated by: ctx init
13+
#
14+
# BLOCKED PATTERNS:
15+
# - ./ctx, ./dist/ctx, ./dist/ctx-* (relative paths)
16+
# - go run ./cmd/ctx (build-and-run)
17+
# - /absolute/path/to/ctx (hardcoded absolute paths)
18+
#
19+
# ALLOWED:
20+
# - ctx status, ctx agent, etc. (PATH-based invocation)
21+
22+
# Read hook input from stdin (JSON)
23+
HOOK_INPUT=$(cat)
24+
25+
# Extract the command being run
26+
COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty')
27+
28+
# If no command, allow (not a Bash call we care about)
29+
if [ -z "$COMMAND" ]; then
30+
exit 0
31+
fi
32+
33+
# Check for forbidden patterns
34+
BLOCKED_REASON=""
35+
36+
# Pattern 1: ./ctx or ./dist/ctx or ./dist/ctx-*
37+
if echo "$COMMAND" | grep -qE '(\./ctx|\./dist/ctx)'; then
38+
BLOCKED_REASON="Use 'ctx' from PATH, not './ctx' or './dist/ctx'. Install with: sudo make install"
39+
fi
40+
41+
# Pattern 2: go run ./cmd/ctx
42+
if echo "$COMMAND" | grep -qE 'go run \./cmd/ctx'; then
43+
BLOCKED_REASON="Use 'ctx' from PATH, not 'go run ./cmd/ctx'. Install with: sudo make install"
44+
fi
45+
46+
# Pattern 3: Absolute paths to ctx binary (but not just 'ctx' or paths in /usr/local/bin, /usr/bin)
47+
# Match things like /home/user/project/ctx or /tmp/ctx-test but allow /usr/local/bin/ctx
48+
if echo "$COMMAND" | grep -qE '(/home/|/tmp/|/var/)[^ ]*ctx[^ ]* '; then
49+
# Exception: allow /tmp/ctx-test for integration tests
50+
if ! echo "$COMMAND" | grep -qE '/tmp/ctx-test'; then
51+
BLOCKED_REASON="Use 'ctx' from PATH, not absolute paths. Install with: sudo make install"
52+
fi
53+
fi
54+
55+
# If blocked, output JSON that tells Claude Code to reject
56+
if [ -n "$BLOCKED_REASON" ]; then
57+
cat << EOF
58+
{"decision": "block", "reason": "$BLOCKED_REASON\n\nSee CONSTITUTION.md: ctx Invocation Invariants"}
59+
EOF
60+
exit 0
61+
fi
62+
63+
# Allow the command
64+
exit 0

internal/cli/init.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import (
2424
)
2525

2626
const (
27-
contextDirName = ".context"
28-
claudeDirName = ".claude"
29-
claudeHooksDirName = ".claude/hooks"
30-
settingsFileName = ".claude/settings.local.json"
31-
autoSaveScriptName = "auto-save-session.sh"
32-
claudeMdFileName = "CLAUDE.md"
33-
ctxMarkerStart = "<!-- ctx:context -->"
34-
ctxMarkerEnd = "<!-- ctx:end -->"
27+
contextDirName = ".context"
28+
claudeDirName = ".claude"
29+
claudeHooksDirName = ".claude/hooks"
30+
settingsFileName = ".claude/settings.local.json"
31+
autoSaveScriptName = "auto-save-session.sh"
32+
blockNonPathScriptName = "block-non-path-ctx.sh"
33+
claudeMdFileName = "CLAUDE.md"
34+
ctxMarkerStart = "<!-- ctx:context -->"
35+
ctxMarkerEnd = "<!-- ctx:end -->"
3536
)
3637

3738
var (
@@ -209,6 +210,21 @@ func createClaudeHooks(cmd *cobra.Command, force bool) error {
209210
cmd.Printf(" %s %s\n", green("✓"), scriptPath)
210211
}
211212

213+
// Create block-non-path-ctx.sh script (enforces CONSTITUTION.md ctx invocation rules)
214+
blockScriptPath := filepath.Join(claudeHooksDirName, blockNonPathScriptName)
215+
if _, err := os.Stat(blockScriptPath); err == nil && !force {
216+
cmd.Printf(" %s %s (exists, skipped)\n", yellow("○"), blockScriptPath)
217+
} else {
218+
blockScriptContent, err := claude.GetBlockNonPathCtxScript()
219+
if err != nil {
220+
return fmt.Errorf("failed to get block-non-path-ctx script: %w", err)
221+
}
222+
if err := os.WriteFile(blockScriptPath, blockScriptContent, 0755); err != nil {
223+
return fmt.Errorf("failed to write %s: %w", blockScriptPath, err)
224+
}
225+
cmd.Printf(" %s %s\n", green("✓"), blockScriptPath)
226+
}
227+
212228
// Handle settings.local.json - merge rather than overwrite
213229
if err := mergeSettingsHooks(cmd, cwd, force); err != nil {
214230
return err

0 commit comments

Comments
 (0)