context-mode 0.9.21 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/hooks/hooks.json +46 -4
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -4
- package/README.md +377 -191
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +53 -0
- package/build/adapters/claude-code/hooks.js +88 -0
- package/build/adapters/claude-code/index.d.ts +50 -0
- package/build/adapters/claude-code/index.js +523 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +21 -0
- package/build/adapters/codex/hooks.js +27 -0
- package/build/adapters/codex/index.d.ts +44 -0
- package/build/adapters/codex/index.js +223 -0
- package/build/adapters/detect.d.ts +26 -0
- package/build/adapters/detect.js +131 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +44 -0
- package/build/adapters/gemini-cli/hooks.js +64 -0
- package/build/adapters/gemini-cli/index.d.ts +57 -0
- package/build/adapters/gemini-cli/index.js +468 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +52 -0
- package/build/adapters/opencode/index.js +386 -0
- package/build/adapters/types.d.ts +218 -0
- package/build/adapters/types.js +13 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +49 -0
- package/build/adapters/vscode-copilot/hooks.js +76 -0
- package/build/adapters/vscode-copilot/index.d.ts +58 -0
- package/build/adapters/vscode-copilot/index.js +512 -0
- package/build/cli.d.ts +9 -6
- package/build/cli.js +133 -423
- package/build/db-base.d.ts +84 -0
- package/build/db-base.js +128 -0
- package/build/executor.d.ts +6 -7
- package/build/executor.js +111 -51
- package/build/opencode-plugin.d.ts +37 -0
- package/build/opencode-plugin.js +118 -0
- package/build/runtime.js +1 -1
- package/build/server.js +436 -117
- package/build/session/db.d.ts +110 -0
- package/build/session/db.js +285 -0
- package/build/session/extract.d.ts +51 -0
- package/build/session/extract.js +407 -0
- package/build/session/snapshot.d.ts +70 -0
- package/build/session/snapshot.js +309 -0
- package/build/store.d.ts +4 -22
- package/build/store.js +67 -55
- package/build/truncate.d.ts +59 -0
- package/build/truncate.js +157 -0
- package/build/types.d.ts +101 -0
- package/build/types.js +20 -0
- package/configs/claude-code/CLAUDE.md +62 -0
- package/configs/codex/AGENTS.md +58 -0
- package/configs/codex/config.toml +5 -0
- package/configs/gemini-cli/GEMINI.md +58 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +49 -0
- package/configs/opencode/AGENTS.md +58 -0
- package/configs/opencode/opencode.json +10 -0
- package/configs/vscode-copilot/copilot-instructions.md +58 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +8 -0
- package/hooks/core/formatters.mjs +86 -0
- package/hooks/core/routing.mjs +262 -0
- package/hooks/core/stdin.mjs +19 -0
- package/hooks/formatters/claude-code.mjs +57 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftertool.mjs +58 -0
- package/hooks/gemini-cli/beforetool.mjs +25 -0
- package/hooks/gemini-cli/precompress.mjs +51 -0
- package/hooks/gemini-cli/sessionstart.mjs +117 -0
- package/hooks/hooks.json +46 -4
- package/hooks/posttooluse.mjs +53 -0
- package/hooks/precompact.mjs +55 -0
- package/hooks/pretooluse.mjs +23 -266
- package/hooks/routing-block.mjs +19 -6
- package/hooks/session-directive.mjs +353 -0
- package/hooks/session-helpers.mjs +112 -0
- package/hooks/sessionstart.mjs +123 -16
- package/hooks/userpromptsubmit.mjs +58 -0
- package/hooks/vscode-copilot/posttooluse.mjs +58 -0
- package/hooks/vscode-copilot/precompact.mjs +51 -0
- package/hooks/vscode-copilot/pretooluse.mjs +25 -0
- package/hooks/vscode-copilot/sessionstart.mjs +115 -0
- package/package.json +20 -17
- package/skills/context-mode/SKILL.md +49 -49
- package/skills/{doctor → ctx-doctor}/SKILL.md +3 -3
- package/skills/{stats → ctx-stats}/SKILL.md +3 -3
- package/skills/{upgrade → ctx-upgrade}/SKILL.md +3 -3
- package/start.mjs +47 -0
- package/hooks/pretooluse.sh +0 -147
- package/server.bundle.mjs +0 -341
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{ "type": "command", "command": "context-mode hook vscode-copilot pretooluse" }
|
|
5
|
+
],
|
|
6
|
+
"PostToolUse": [
|
|
7
|
+
{ "type": "command", "command": "context-mode hook vscode-copilot posttooluse" }
|
|
8
|
+
],
|
|
9
|
+
"PreCompact": [
|
|
10
|
+
{ "type": "command", "command": "context-mode hook vscode-copilot precompact" }
|
|
11
|
+
],
|
|
12
|
+
"SessionStart": [
|
|
13
|
+
{ "type": "command", "command": "context-mode hook vscode-copilot sessionstart" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-specific response formatters.
|
|
3
|
+
* Takes normalized decision from routing.mjs -> platform-specific JSON output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const formatters = {
|
|
7
|
+
"claude-code": {
|
|
8
|
+
deny: (reason) => ({
|
|
9
|
+
hookSpecificOutput: {
|
|
10
|
+
hookEventName: "PreToolUse",
|
|
11
|
+
permissionDecision: "deny",
|
|
12
|
+
reason,
|
|
13
|
+
},
|
|
14
|
+
}),
|
|
15
|
+
ask: () => ({
|
|
16
|
+
hookSpecificOutput: {
|
|
17
|
+
hookEventName: "PreToolUse",
|
|
18
|
+
permissionDecision: "ask",
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
modify: (updatedInput) => ({
|
|
22
|
+
hookSpecificOutput: {
|
|
23
|
+
hookEventName: "PreToolUse",
|
|
24
|
+
updatedInput,
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
context: (additionalContext) => ({
|
|
28
|
+
hookSpecificOutput: {
|
|
29
|
+
hookEventName: "PreToolUse",
|
|
30
|
+
additionalContext,
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
"gemini-cli": {
|
|
36
|
+
deny: (reason) => ({ decision: "deny", reason }),
|
|
37
|
+
ask: () => null, // Gemini CLI has no "ask" concept
|
|
38
|
+
modify: (updatedInput) => ({
|
|
39
|
+
hookSpecificOutput: { tool_input: updatedInput },
|
|
40
|
+
}),
|
|
41
|
+
context: (additionalContext) => ({
|
|
42
|
+
hookSpecificOutput: { additionalContext },
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
"vscode-copilot": {
|
|
47
|
+
deny: (reason) => ({
|
|
48
|
+
permissionDecision: "deny",
|
|
49
|
+
reason,
|
|
50
|
+
}),
|
|
51
|
+
ask: () => ({
|
|
52
|
+
permissionDecision: "ask",
|
|
53
|
+
}),
|
|
54
|
+
modify: (updatedInput) => ({
|
|
55
|
+
hookSpecificOutput: {
|
|
56
|
+
hookEventName: "PreToolUse",
|
|
57
|
+
updatedInput,
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
context: (additionalContext) => ({
|
|
61
|
+
hookSpecificOutput: {
|
|
62
|
+
hookEventName: "PreToolUse",
|
|
63
|
+
additionalContext,
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply a formatter to a normalized routing decision.
|
|
71
|
+
* Returns the platform-specific JSON response, or null for passthrough.
|
|
72
|
+
*/
|
|
73
|
+
export function formatDecision(platform, decision) {
|
|
74
|
+
if (!decision) return null;
|
|
75
|
+
|
|
76
|
+
const fmt = formatters[platform];
|
|
77
|
+
if (!fmt) return null;
|
|
78
|
+
|
|
79
|
+
switch (decision.action) {
|
|
80
|
+
case "deny": return fmt.deny(decision.reason);
|
|
81
|
+
case "ask": return fmt.ask();
|
|
82
|
+
case "modify": return fmt.modify(decision.updatedInput);
|
|
83
|
+
case "context": return fmt.context(decision.additionalContext);
|
|
84
|
+
default: return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure routing logic for PreToolUse hooks.
|
|
3
|
+
* Returns NORMALIZED decision objects (NOT platform-specific format).
|
|
4
|
+
*
|
|
5
|
+
* Decision types:
|
|
6
|
+
* - { action: "deny", reason: string }
|
|
7
|
+
* - { action: "ask" }
|
|
8
|
+
* - { action: "modify", updatedInput: object }
|
|
9
|
+
* - { action: "context", additionalContext: string }
|
|
10
|
+
* - null (passthrough)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ROUTING_BLOCK, READ_GUIDANCE, GREP_GUIDANCE, BASH_GUIDANCE } from "../routing-block.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strip heredoc content from a shell command.
|
|
17
|
+
* Handles: <<EOF, <<"EOF", <<'EOF', <<-EOF (indented), with optional spaces.
|
|
18
|
+
*/
|
|
19
|
+
function stripHeredocs(cmd) {
|
|
20
|
+
return cmd.replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip ALL quoted content from a shell command so regex only matches command tokens.
|
|
25
|
+
* Removes heredocs, single-quoted strings, and double-quoted strings.
|
|
26
|
+
* This prevents false positives like: gh issue edit --body "text with curl in it"
|
|
27
|
+
*/
|
|
28
|
+
function stripQuotedContent(cmd) {
|
|
29
|
+
return stripHeredocs(cmd)
|
|
30
|
+
.replace(/'[^']*'/g, "''") // single-quoted strings
|
|
31
|
+
.replace(/"[^"]*"/g, '""'); // double-quoted strings
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Try to import security module — may not exist
|
|
35
|
+
let security = null;
|
|
36
|
+
|
|
37
|
+
export async function initSecurity(buildDir) {
|
|
38
|
+
try {
|
|
39
|
+
const { pathToFileURL } = await import("node:url");
|
|
40
|
+
const secPath = (await import("node:path")).resolve(buildDir, "security.js");
|
|
41
|
+
security = await import(pathToFileURL(secPath).href);
|
|
42
|
+
} catch { /* not available */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize platform-specific tool names to canonical (Claude Code) names.
|
|
47
|
+
*
|
|
48
|
+
* Evidence:
|
|
49
|
+
* - Gemini CLI: https://github.com/google-gemini/gemini-cli (run_shell_command, read_file, grep_search, web_fetch, activate_skill)
|
|
50
|
+
* - OpenCode: https://github.com/opencode-ai/opencode (bash, view, grep, fetch, agent)
|
|
51
|
+
* - Codex CLI: https://github.com/openai/codex (shell, read_file, grep_files, container.exec)
|
|
52
|
+
* - VS Code Copilot: tool names TBD — uses empty matcher until confirmed
|
|
53
|
+
*/
|
|
54
|
+
const TOOL_ALIASES = {
|
|
55
|
+
// Gemini CLI
|
|
56
|
+
"run_shell_command": "Bash",
|
|
57
|
+
"read_file": "Read",
|
|
58
|
+
"read_many_files": "Read",
|
|
59
|
+
"grep_search": "Grep",
|
|
60
|
+
"search_file_content": "Grep",
|
|
61
|
+
"web_fetch": "WebFetch",
|
|
62
|
+
"activate_skill": "Agent",
|
|
63
|
+
// OpenCode
|
|
64
|
+
"bash": "Bash",
|
|
65
|
+
"view": "Read",
|
|
66
|
+
"grep": "Grep",
|
|
67
|
+
"fetch": "WebFetch",
|
|
68
|
+
"agent": "Agent",
|
|
69
|
+
// Codex CLI
|
|
70
|
+
"shell": "Bash",
|
|
71
|
+
"shell_command": "Bash",
|
|
72
|
+
"exec_command": "Bash",
|
|
73
|
+
"container.exec": "Bash",
|
|
74
|
+
"local_shell": "Bash",
|
|
75
|
+
"grep_files": "Grep",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Route a PreToolUse event. Returns normalized decision object or null for passthrough.
|
|
80
|
+
*/
|
|
81
|
+
export function routePreToolUse(toolName, toolInput, projectDir) {
|
|
82
|
+
// Normalize platform-specific tool name to canonical
|
|
83
|
+
const canonical = TOOL_ALIASES[toolName] ?? toolName;
|
|
84
|
+
|
|
85
|
+
// ─── Bash: Stage 1 security check, then Stage 2 routing ───
|
|
86
|
+
if (canonical === "Bash") {
|
|
87
|
+
const command = toolInput.command ?? "";
|
|
88
|
+
|
|
89
|
+
// Stage 1: Security check against user's deny/allow patterns.
|
|
90
|
+
// Only act when an explicit pattern matched. When no pattern matches,
|
|
91
|
+
// evaluateCommand returns { decision: "ask" } with no matchedPattern —
|
|
92
|
+
// in that case fall through so other hooks and the platform's native engine can decide.
|
|
93
|
+
if (security) {
|
|
94
|
+
const policies = security.readBashPolicies(projectDir);
|
|
95
|
+
if (policies.length > 0) {
|
|
96
|
+
const result = security.evaluateCommand(command, policies);
|
|
97
|
+
if (result.decision === "deny") {
|
|
98
|
+
return { action: "deny", reason: `Blocked by security policy: matches deny pattern ${result.matchedPattern}` };
|
|
99
|
+
}
|
|
100
|
+
if (result.decision === "ask" && result.matchedPattern) {
|
|
101
|
+
return { action: "ask" };
|
|
102
|
+
}
|
|
103
|
+
// "allow" or no match → fall through to Stage 2
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Stage 2: Context-mode routing (existing behavior)
|
|
108
|
+
|
|
109
|
+
// curl/wget detection: strip quoted content first to avoid false positives
|
|
110
|
+
// like `gh issue edit --body "text with curl in it"` (Issue #63).
|
|
111
|
+
const stripped = stripQuotedContent(command);
|
|
112
|
+
|
|
113
|
+
// curl/wget → replace with echo redirect
|
|
114
|
+
if (/(^|\s|&&|\||\;)(curl|wget)\s/i.test(stripped)) {
|
|
115
|
+
return {
|
|
116
|
+
action: "modify",
|
|
117
|
+
updatedInput: {
|
|
118
|
+
command: 'echo "context-mode: curl/wget blocked. You MUST use mcp__plugin_context-mode_context-mode__ctx_fetch_and_index(url, source) to fetch URLs, or mcp__plugin_context-mode_context-mode__ctx_execute(language, code) to run HTTP calls in sandbox. Do NOT retry with curl/wget."',
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Inline HTTP detection: strip only heredocs (not quotes) so that
|
|
124
|
+
// code passed via -e/-c flags is still visible to the regex, while
|
|
125
|
+
// heredoc content (e.g. cat << EOF ... requests.get ... EOF) is removed.
|
|
126
|
+
// These patterns are specific enough that false positives in quoted
|
|
127
|
+
// text are rare, unlike single-word "curl"/"wget" (Issue #63).
|
|
128
|
+
const noHeredoc = stripHeredocs(command);
|
|
129
|
+
if (
|
|
130
|
+
/fetch\s*\(\s*['"](https?:\/\/|http)/i.test(noHeredoc) ||
|
|
131
|
+
/requests\.(get|post|put)\s*\(/i.test(noHeredoc) ||
|
|
132
|
+
/http\.(get|request)\s*\(/i.test(noHeredoc)
|
|
133
|
+
) {
|
|
134
|
+
return {
|
|
135
|
+
action: "modify",
|
|
136
|
+
updatedInput: {
|
|
137
|
+
command: 'echo "context-mode: Inline HTTP blocked. Use mcp__plugin_context-mode_context-mode__ctx_execute(language, code) to run HTTP calls in sandbox, or mcp__plugin_context-mode_context-mode__ctx_fetch_and_index(url, source) for web pages. Do NOT retry with Bash."',
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build tools (gradle, maven) → redirect to execute sandbox (Issue #38).
|
|
143
|
+
// These produce extremely verbose output that should stay in sandbox.
|
|
144
|
+
if (/(^|\s|&&|\||\;)(\.\/gradlew|gradlew|gradle|\.\/mvnw|mvnw|mvn)\s/i.test(stripped)) {
|
|
145
|
+
const safeCmd = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
146
|
+
return {
|
|
147
|
+
action: "modify",
|
|
148
|
+
updatedInput: {
|
|
149
|
+
command: `echo "context-mode: Build tool redirected to sandbox. Use mcp__plugin_context-mode_context-mode__ctx_execute(language: \\"shell\\", code: \\"${safeCmd}\\") to run this command. Do NOT retry with Bash."`,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// allow all other Bash commands, but inject routing nudge
|
|
155
|
+
return { action: "context", additionalContext: BASH_GUIDANCE };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Read: nudge toward execute_file ───
|
|
159
|
+
if (canonical === "Read") {
|
|
160
|
+
return { action: "context", additionalContext: READ_GUIDANCE };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Grep: nudge toward execute ───
|
|
164
|
+
if (canonical === "Grep") {
|
|
165
|
+
return { action: "context", additionalContext: GREP_GUIDANCE };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── WebFetch: deny + redirect to sandbox ───
|
|
169
|
+
if (canonical === "WebFetch") {
|
|
170
|
+
const url = toolInput.url ?? "";
|
|
171
|
+
return {
|
|
172
|
+
action: "deny",
|
|
173
|
+
reason: `context-mode: WebFetch blocked. Use mcp__plugin_context-mode_context-mode__ctx_fetch_and_index(url: "${url}", source: "...") to fetch this URL in sandbox. Then use mcp__plugin_context-mode_context-mode__ctx_search(queries: [...]) to query results. Do NOT use curl/wget — they are also blocked.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Agent/Task: inject context-mode routing into subagent prompts ───
|
|
178
|
+
if (canonical === "Agent" || canonical === "Task") {
|
|
179
|
+
const subagentType = toolInput.subagent_type ?? "";
|
|
180
|
+
const prompt = toolInput.prompt ?? "";
|
|
181
|
+
|
|
182
|
+
const updatedInput =
|
|
183
|
+
subagentType === "Bash"
|
|
184
|
+
? { ...toolInput, prompt: prompt + ROUTING_BLOCK, subagent_type: "general-purpose" }
|
|
185
|
+
: { ...toolInput, prompt: prompt + ROUTING_BLOCK };
|
|
186
|
+
|
|
187
|
+
return { action: "modify", updatedInput };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── MCP execute: security check for shell commands ───
|
|
191
|
+
// Match both __execute and __ctx_execute (prefixed tool names)
|
|
192
|
+
if (toolName.includes("context-mode") && /__(ctx_)?execute$/.test(toolName)) {
|
|
193
|
+
if (security && toolInput.language === "shell") {
|
|
194
|
+
const code = toolInput.code ?? "";
|
|
195
|
+
const policies = security.readBashPolicies(projectDir);
|
|
196
|
+
if (policies.length > 0) {
|
|
197
|
+
const result = security.evaluateCommand(code, policies);
|
|
198
|
+
if (result.decision === "deny") {
|
|
199
|
+
return { action: "deny", reason: `Blocked by security policy: shell code matches deny pattern ${result.matchedPattern}` };
|
|
200
|
+
}
|
|
201
|
+
if (result.decision === "ask" && result.matchedPattern) {
|
|
202
|
+
return { action: "ask" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── MCP execute_file: check file path + code against deny patterns ───
|
|
210
|
+
if (toolName.includes("context-mode") && /__(ctx_)?execute_file$/.test(toolName)) {
|
|
211
|
+
if (security) {
|
|
212
|
+
// Check file path against Read deny patterns
|
|
213
|
+
const filePath = toolInput.path ?? "";
|
|
214
|
+
const denyGlobs = security.readToolDenyPatterns("Read", projectDir);
|
|
215
|
+
const evalResult = security.evaluateFilePath(filePath, denyGlobs);
|
|
216
|
+
if (evalResult.denied) {
|
|
217
|
+
return { action: "deny", reason: `Blocked by security policy: file path matches Read deny pattern ${evalResult.matchedPattern}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check code parameter against Bash deny patterns (same as execute)
|
|
221
|
+
const lang = toolInput.language ?? "";
|
|
222
|
+
const code = toolInput.code ?? "";
|
|
223
|
+
if (lang === "shell") {
|
|
224
|
+
const policies = security.readBashPolicies(projectDir);
|
|
225
|
+
if (policies.length > 0) {
|
|
226
|
+
const result = security.evaluateCommand(code, policies);
|
|
227
|
+
if (result.decision === "deny") {
|
|
228
|
+
return { action: "deny", reason: `Blocked by security policy: shell code matches deny pattern ${result.matchedPattern}` };
|
|
229
|
+
}
|
|
230
|
+
if (result.decision === "ask" && result.matchedPattern) {
|
|
231
|
+
return { action: "ask" };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── MCP batch_execute: check each command individually ───
|
|
240
|
+
if (toolName.includes("context-mode") && /__(ctx_)?batch_execute$/.test(toolName)) {
|
|
241
|
+
if (security) {
|
|
242
|
+
const commands = toolInput.commands ?? [];
|
|
243
|
+
const policies = security.readBashPolicies(projectDir);
|
|
244
|
+
if (policies.length > 0) {
|
|
245
|
+
for (const entry of commands) {
|
|
246
|
+
const cmd = entry.command ?? "";
|
|
247
|
+
const result = security.evaluateCommand(cmd, policies);
|
|
248
|
+
if (result.decision === "deny") {
|
|
249
|
+
return { action: "deny", reason: `Blocked by security policy: batch command "${entry.label ?? cmd}" matches deny pattern ${result.matchedPattern}` };
|
|
250
|
+
}
|
|
251
|
+
if (result.decision === "ask" && result.matchedPattern) {
|
|
252
|
+
return { action: "ask" };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Unknown tool — pass through
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stdin reader for all hook scripts.
|
|
3
|
+
* Cross-platform (Windows/macOS/Linux) — no bash/jq dependency.
|
|
4
|
+
*
|
|
5
|
+
* Uses event-based flowing mode to avoid two platform bugs:
|
|
6
|
+
* - `for await (process.stdin)` hangs on macOS when piped via spawnSync
|
|
7
|
+
* - `readFileSync(0)` throws EOF/EISDIR on Windows, EAGAIN on Linux
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function readStdin() {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let data = "";
|
|
13
|
+
process.stdin.setEncoding("utf-8");
|
|
14
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
15
|
+
process.stdin.on("end", () => resolve(data));
|
|
16
|
+
process.stdin.on("error", reject);
|
|
17
|
+
process.stdin.resume();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code formatter — converts routing decisions into Claude Code hook output format.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code expects:
|
|
5
|
+
* { hookSpecificOutput: { hookEventName, permissionDecision?, reason?, updatedInput?, additionalContext? } }
|
|
6
|
+
*
|
|
7
|
+
* Decision shape from routing.mjs:
|
|
8
|
+
* - { action: "deny", reason: string }
|
|
9
|
+
* - { action: "ask" }
|
|
10
|
+
* - { action: "modify", updatedInput: object }
|
|
11
|
+
* - { action: "context", additionalContext: string }
|
|
12
|
+
* - null (passthrough)
|
|
13
|
+
*
|
|
14
|
+
* @param {object | null} decision - Normalized decision from routePreToolUse
|
|
15
|
+
* @returns {object | null} Claude Code hook response, or null for passthrough
|
|
16
|
+
*/
|
|
17
|
+
export function formatDecision(decision) {
|
|
18
|
+
if (!decision) return null;
|
|
19
|
+
|
|
20
|
+
switch (decision.action) {
|
|
21
|
+
case "deny":
|
|
22
|
+
return {
|
|
23
|
+
hookSpecificOutput: {
|
|
24
|
+
hookEventName: "PreToolUse",
|
|
25
|
+
permissionDecision: "deny",
|
|
26
|
+
reason: decision.reason ?? "Blocked by context-mode",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
case "ask":
|
|
31
|
+
return {
|
|
32
|
+
hookSpecificOutput: {
|
|
33
|
+
hookEventName: "PreToolUse",
|
|
34
|
+
permissionDecision: "ask",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
case "modify":
|
|
39
|
+
return {
|
|
40
|
+
hookSpecificOutput: {
|
|
41
|
+
hookEventName: "PreToolUse",
|
|
42
|
+
updatedInput: decision.updatedInput,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
case "context":
|
|
47
|
+
return {
|
|
48
|
+
hookSpecificOutput: {
|
|
49
|
+
hookEventName: "PreToolUse",
|
|
50
|
+
additionalContext: decision.additionalContext ?? "",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
default:
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI formatter — converts routing decisions into Gemini CLI hook output format.
|
|
3
|
+
*
|
|
4
|
+
* Gemini CLI expects:
|
|
5
|
+
* { hookSpecificOutput: { decision?, reason?, tool_input?, additionalContext? } }
|
|
6
|
+
*
|
|
7
|
+
* Key differences from Claude Code:
|
|
8
|
+
* - Uses "decision" instead of "permissionDecision"
|
|
9
|
+
* - Uses "tool_input" instead of "updatedInput"
|
|
10
|
+
* - No "ask" concept — Gemini CLI only supports deny/allow
|
|
11
|
+
*
|
|
12
|
+
* Decision shape from routing.mjs:
|
|
13
|
+
* - { action: "deny", reason: string }
|
|
14
|
+
* - { action: "ask" }
|
|
15
|
+
* - { action: "modify", updatedInput: object }
|
|
16
|
+
* - { action: "context", additionalContext: string }
|
|
17
|
+
* - null (passthrough)
|
|
18
|
+
*
|
|
19
|
+
* @param {object | null} decision - Normalized decision from routePreToolUse
|
|
20
|
+
* @returns {object | null} Gemini CLI hook response, or null for passthrough
|
|
21
|
+
*/
|
|
22
|
+
export function formatDecision(decision) {
|
|
23
|
+
if (!decision) return null;
|
|
24
|
+
|
|
25
|
+
switch (decision.action) {
|
|
26
|
+
case "deny":
|
|
27
|
+
return {
|
|
28
|
+
hookSpecificOutput: {
|
|
29
|
+
decision: "deny",
|
|
30
|
+
reason: decision.reason ?? "Blocked by context-mode",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
case "ask":
|
|
35
|
+
// Gemini CLI has no "ask" concept — return null (passthrough)
|
|
36
|
+
return null;
|
|
37
|
+
|
|
38
|
+
case "modify":
|
|
39
|
+
return {
|
|
40
|
+
hookSpecificOutput: {
|
|
41
|
+
tool_input: decision.updatedInput,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
case "context":
|
|
46
|
+
return {
|
|
47
|
+
hookSpecificOutput: {
|
|
48
|
+
additionalContext: decision.additionalContext ?? "",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VS Code Copilot formatter — converts routing decisions into VS Code Copilot hook output format.
|
|
3
|
+
*
|
|
4
|
+
* VS Code Copilot expects a flat structure for deny/ask, nested for modify/context:
|
|
5
|
+
* { permissionDecision: "deny", reason: "..." } — deny
|
|
6
|
+
* { permissionDecision: "ask" } — ask
|
|
7
|
+
* { hookSpecificOutput: { ... }, hookEventName: "PreToolUse" } — modify/context
|
|
8
|
+
*
|
|
9
|
+
* Key differences from Claude Code:
|
|
10
|
+
* - deny/ask are flat (not wrapped in hookSpecificOutput)
|
|
11
|
+
* - modify/context include hookEventName at top level alongside hookSpecificOutput
|
|
12
|
+
*
|
|
13
|
+
* Decision shape from routing.mjs:
|
|
14
|
+
* - { action: "deny", reason: string }
|
|
15
|
+
* - { action: "ask" }
|
|
16
|
+
* - { action: "modify", updatedInput: object }
|
|
17
|
+
* - { action: "context", additionalContext: string }
|
|
18
|
+
* - null (passthrough)
|
|
19
|
+
*
|
|
20
|
+
* @param {object | null} decision - Normalized decision from routePreToolUse
|
|
21
|
+
* @returns {object | null} VS Code Copilot hook response, or null for passthrough
|
|
22
|
+
*/
|
|
23
|
+
export function formatDecision(decision) {
|
|
24
|
+
if (!decision) return null;
|
|
25
|
+
|
|
26
|
+
switch (decision.action) {
|
|
27
|
+
case "deny":
|
|
28
|
+
return {
|
|
29
|
+
permissionDecision: "deny",
|
|
30
|
+
reason: decision.reason ?? "Blocked by context-mode",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
case "ask":
|
|
34
|
+
return {
|
|
35
|
+
permissionDecision: "ask",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
case "modify":
|
|
39
|
+
return {
|
|
40
|
+
hookSpecificOutput: decision.updatedInput,
|
|
41
|
+
hookEventName: "PreToolUse",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
case "context":
|
|
45
|
+
return {
|
|
46
|
+
hookSpecificOutput: {
|
|
47
|
+
additionalContext: decision.additionalContext ?? "",
|
|
48
|
+
},
|
|
49
|
+
hookEventName: "PreToolUse",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI AfterTool hook — session event capture.
|
|
4
|
+
*
|
|
5
|
+
* Captures session events from tool calls (13 categories) and stores
|
|
6
|
+
* them in the per-project SessionDB for later resume snapshot building.
|
|
7
|
+
*
|
|
8
|
+
* Must be fast (<20ms). No network, no LLM, just SQLite writes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readStdin, getSessionId, getSessionDBPath, getProjectDir, GEMINI_OPTS } from "../session-helpers.mjs";
|
|
12
|
+
import { appendFileSync } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PKG_SESSION = join(HOOK_DIR, "..", "..", "build", "session");
|
|
19
|
+
const OPTS = GEMINI_OPTS;
|
|
20
|
+
const DEBUG_LOG = join(homedir(), ".gemini", "context-mode", "aftertool-debug.log");
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readStdin();
|
|
24
|
+
const input = JSON.parse(raw);
|
|
25
|
+
|
|
26
|
+
appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] CALL: ${input.tool_name}\n`);
|
|
27
|
+
|
|
28
|
+
const { extractEvents } = await import(join(PKG_SESSION, "extract.js"));
|
|
29
|
+
const { SessionDB } = await import(join(PKG_SESSION, "db.js"));
|
|
30
|
+
|
|
31
|
+
const dbPath = getSessionDBPath(OPTS);
|
|
32
|
+
const db = new SessionDB({ dbPath });
|
|
33
|
+
const sessionId = getSessionId(input, OPTS);
|
|
34
|
+
|
|
35
|
+
db.ensureSession(sessionId, getProjectDir(OPTS));
|
|
36
|
+
|
|
37
|
+
const events = extractEvents({
|
|
38
|
+
tool_name: input.tool_name,
|
|
39
|
+
tool_input: input.tool_input ?? {},
|
|
40
|
+
tool_response: typeof input.tool_response === "string"
|
|
41
|
+
? input.tool_response
|
|
42
|
+
: JSON.stringify(input.tool_response ?? ""),
|
|
43
|
+
tool_output: input.tool_output,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
db.insertEvent(sessionId, event, "AfterTool");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] OK: ${input.tool_name} → ${events.length} events\n`);
|
|
51
|
+
db.close();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
try {
|
|
54
|
+
appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ERR: ${err?.message || err}\n`);
|
|
55
|
+
} catch { /* silent */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// AfterTool is non-blocking — no stdout output
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI BeforeTool hook for context-mode
|
|
4
|
+
* Thin wrapper — uses shared routing core, no self-heal, no Claude Code-specific logic.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { readStdin } from "../core/stdin.mjs";
|
|
10
|
+
import { routePreToolUse, initSecurity } from "../core/routing.mjs";
|
|
11
|
+
import { formatDecision } from "../core/formatters.mjs";
|
|
12
|
+
|
|
13
|
+
const __hookDir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
await initSecurity(resolve(__hookDir, "..", "..", "build"));
|
|
15
|
+
|
|
16
|
+
const raw = await readStdin();
|
|
17
|
+
const input = JSON.parse(raw);
|
|
18
|
+
const tool = input.tool_name ?? "";
|
|
19
|
+
const toolInput = input.tool_input ?? {};
|
|
20
|
+
|
|
21
|
+
const decision = routePreToolUse(tool, toolInput, process.env.GEMINI_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR);
|
|
22
|
+
const response = formatDecision("gemini-cli", decision);
|
|
23
|
+
if (response !== null) {
|
|
24
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
25
|
+
}
|