context-mode 1.0.153 → 1.0.155

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.
@@ -18,14 +18,50 @@ export const formatters = {
18
18
  permissionDecision: "ask",
19
19
  },
20
20
  }),
21
- modify: (updatedInput) => ({
22
- hookSpecificOutput: {
23
- hookEventName: "PreToolUse",
24
- permissionDecision: "allow",
25
- permissionDecisionReason: "Routed to context-mode sandbox",
26
- updatedInput,
27
- },
28
- }),
21
+ // Tool-aware modify handling for claude-code:
22
+ //
23
+ // - Bash redirect (updatedInput.command): CC v2.1.x ignores
24
+ // `updatedInput.command` substitution under `permissionDecision: "allow"`
25
+ // — original command runs unchanged. Verified via /diagnose Phase 4
26
+ // forced-deny probe: only `permissionDecision: "deny"` is honored for
27
+ // Bash blocking. Emit deny + extract echo payload into
28
+ // `permissionDecisionReason`.
29
+ //
30
+ // - Agent prompt injection (updatedInput.prompt): CC honors
31
+ // allow+updatedInput for Agent tool — modified prompt reaches the
32
+ // subagent. Keep modify shape so subagent routing-block injection works.
33
+ //
34
+ // - Any other shape: pass through as modify and let CC decide.
35
+ //
36
+ // Other adapters (gemini-cli, vscode-copilot, etc.) keep their own modify
37
+ // semantics — their hosts implement updatedInput differently or not at all.
38
+ modify: (updatedInput) => {
39
+ const ui = updatedInput ?? {};
40
+ const isBashCommandRedirect = "command" in ui;
41
+ if (!isBashCommandRedirect) {
42
+ return {
43
+ hookSpecificOutput: {
44
+ hookEventName: "PreToolUse",
45
+ updatedInput: ui,
46
+ },
47
+ };
48
+ }
49
+ // routing.mjs wraps the redirect guidance in `echo "..."` form.
50
+ // Extract the quoted payload as the deny reason. Fall back to a generic
51
+ // ADR-0003 CASE A message if the shape doesn't match.
52
+ const cmd = ui.command ?? "";
53
+ const m = cmd.match(/^echo\s+"(.+)"$/s);
54
+ const reason = m
55
+ ? m[1]
56
+ : "Redirected to ctx_execute / ctx_fetch_and_index. Call ctx_execute(language, code) to fetch and derive your answer in one round trip, or call ctx_fetch_and_index(url, source) when you want to query the response later via ctx_search. Both have full network access. Retry the same call on a transient DNS error (EAI_AGAIN, ETIMEDOUT, ENETUNREACH).";
57
+ return {
58
+ hookSpecificOutput: {
59
+ hookEventName: "PreToolUse",
60
+ permissionDecision: "deny",
61
+ permissionDecisionReason: reason,
62
+ },
63
+ };
64
+ },
29
65
  context: (additionalContext) => ({
30
66
  hookSpecificOutput: {
31
67
  hookEventName: "PreToolUse",
@@ -54,14 +54,50 @@ export function formatDecision(decision) {
54
54
  },
55
55
  };
56
56
 
57
- case "modify":
57
+ case "modify": {
58
58
  if (isHeadless()) return null;
59
+
60
+ // Tool-aware modify handling:
61
+ //
62
+ // - Bash redirect (updatedInput.command): CC v2.1.x ignores
63
+ // `updatedInput.command` substitution under `permissionDecision: "allow"`
64
+ // — original command runs unchanged. Verified via /diagnose Phase 4
65
+ // forced-deny probe: only `permissionDecision: "deny"` is honored for
66
+ // Bash blocking. Emit deny + extract echo payload into
67
+ // `permissionDecisionReason`.
68
+ //
69
+ // - Agent prompt injection (updatedInput.prompt): CC honors allow+updatedInput
70
+ // for the Agent tool — the modified prompt actually reaches the subagent.
71
+ // Keep the original modify shape so subagent routing-block injection works.
72
+ //
73
+ // - Any other shape: pass through as modify (no command, no prompt — let
74
+ // CC decide if the field is honored).
75
+ const ui = decision.updatedInput ?? {};
76
+ const isBashCommandRedirect = "command" in ui;
77
+ if (!isBashCommandRedirect) {
78
+ return {
79
+ hookSpecificOutput: {
80
+ hookEventName: "PreToolUse",
81
+ updatedInput: ui,
82
+ },
83
+ };
84
+ }
85
+ // routing.mjs wraps the redirect guidance in `echo "..."` form.
86
+ // Extract the quoted payload as the deny reason. Fall back to a generic
87
+ // ADR-0003 CASE A message if the shape doesn't match.
88
+ const cmd = ui.command ?? "";
89
+ const m = cmd.match(/^echo\s+"(.+)"$/s);
90
+ const reason = m
91
+ ? m[1]
92
+ : "Redirected to ctx_execute / ctx_fetch_and_index. Call ctx_execute(language, code) to fetch and derive your answer in one round trip, or call ctx_fetch_and_index(url, source) when you want to query the response later via ctx_search. Both have full network access. Retry the same call on a transient DNS error (EAI_AGAIN, ETIMEDOUT, ENETUNREACH).";
59
93
  return {
60
94
  hookSpecificOutput: {
61
95
  hookEventName: "PreToolUse",
62
- updatedInput: decision.updatedInput,
96
+ permissionDecision: "deny",
97
+ permissionDecisionReason: reason,
63
98
  },
64
99
  };
100
+ }
65
101
 
66
102
  case "context":
67
103
  return {
@@ -0,0 +1,207 @@
1
+ // hooks/platform-bridge.mjs — Fire-and-forget event forwarder.
2
+ // Reads ~/.context-mode/platform.json {api_key, platform_url}.
3
+ // POSTs every event to ${platform_url}/events.
4
+ // Privacy: redacts secrets + normalizes $HOME before send.
5
+ // Backward-compat (PRD §5.3 Upgrade Lag): v1 events_url → platform_url; token → api_key.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ const CACHE_TTL_MS = 60_000;
12
+ const FETCH_TIMEOUT_MS = 2_000;
13
+ const MAX_FIELD_LEN = 200;
14
+ const MAX_DEPTH = 4;
15
+
16
+ // Negative-cache sentinel — distinguishes "uninitialized" (null) from
17
+ // "we checked recently and there's no config" (NO_CONFIG). Without this,
18
+ // the unconfigured-user path would hit fs.readFileSync on every event.
19
+ const NO_CONFIG = Symbol("no-config");
20
+
21
+ let _cache = null;
22
+ let _cacheLoadedAt = 0;
23
+ let _warned = false; // dedupe stderr — log first failure only, reset on success
24
+ let _fsLoads = 0; // test-only counter: how many times readConfig hit the FS
25
+
26
+ // === Cross-platform config path (bug-free across Win/Linux/Mac/WSL) ===
27
+ export function configPath() {
28
+ if (process.platform === "win32") {
29
+ const appdata = process.env.APPDATA;
30
+ if (appdata) return path.join(appdata, "context-mode", "platform.json");
31
+ // Fallback if APPDATA unset (rare — Git Bash without env)
32
+ return path.join(os.homedir(), "AppData", "Roaming", "context-mode", "platform.json");
33
+ }
34
+ if (process.env.XDG_CONFIG_HOME) {
35
+ return path.join(process.env.XDG_CONFIG_HOME, "context-mode", "platform.json");
36
+ }
37
+ return path.join(os.homedir(), ".context-mode", "platform.json");
38
+ }
39
+
40
+ function warn(msg) {
41
+ if (_warned) return;
42
+ _warned = true;
43
+ process.stderr.write(`[context-mode] ${msg}\n`);
44
+ }
45
+
46
+ // v1 events_url + token alias kept for Upgrade Lag (PRD §5.3).
47
+ // Stray fields (version, hostname, device_id, endpoints.sessions, routes) silently ignored.
48
+ function normalizeConfig(raw) {
49
+ if (!raw || typeof raw !== "object") return null;
50
+ const api_key = raw.api_key ?? raw.token;
51
+ let platform_url = raw.platform_url;
52
+ if (!platform_url && raw.endpoints?.events) platform_url = String(raw.endpoints.events).replace(/\/events$/, "");
53
+ if (!platform_url && raw.events_url) platform_url = String(raw.events_url).replace(/\/events$/, "");
54
+ if (typeof api_key !== "string" || !api_key.startsWith("ctxm_")) return null;
55
+ if (typeof platform_url !== "string" || !platform_url) return null;
56
+ return { api_key, platform_url: platform_url.replace(/\/$/, "") };
57
+ }
58
+
59
+ function readConfig() {
60
+ const now = Date.now();
61
+ // Cache hit — covers BOTH positive (config object) and negative (NO_CONFIG) results.
62
+ if (_cache !== null && now - _cacheLoadedAt < CACHE_TTL_MS) {
63
+ return _cache === NO_CONFIG ? null : _cache;
64
+ }
65
+ _cacheLoadedAt = now;
66
+ _fsLoads++;
67
+ const cfgPath = configPath();
68
+
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(cfgPath, "utf8");
72
+ } catch (e) {
73
+ if (e.code !== "ENOENT") warn(`cannot read ${cfgPath}: ${e.code || e.message}`);
74
+ _cache = NO_CONFIG;
75
+ return null;
76
+ }
77
+
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch (e) {
82
+ warn(`${cfgPath} is not valid JSON: ${e.message}`);
83
+ _cache = NO_CONFIG;
84
+ return null;
85
+ }
86
+
87
+ const normalized = normalizeConfig(parsed);
88
+ if (!normalized) {
89
+ warn(`${cfgPath} schema invalid — expected {api_key (ctxm_*), platform_url}`);
90
+ _cache = NO_CONFIG;
91
+ return null;
92
+ }
93
+
94
+ _warned = false; // success — re-arm warning for future failures
95
+ _cache = normalized;
96
+ return _cache;
97
+ }
98
+
99
+ // === Gate — cheap boolean for callers that want to skip allocations entirely ===
100
+ // `session-loaders.mjs` uses this BEFORE the per-event forwarding loop so the
101
+ // loop never executes when ~/.context-mode/platform.json is missing. Honors
102
+ // the same 60s TTL as readConfig() — first call hits the FS, subsequent calls
103
+ // within the TTL return the cached decision (positive or negative).
104
+ export function hasPlatformConfig() {
105
+ return readConfig() !== null;
106
+ }
107
+
108
+ // === URL construction (single endpoint per ADR-0006) ===
109
+ export function buildUrl(cfg, _eventType) {
110
+ return `${cfg.platform_url}/events`;
111
+ }
112
+
113
+ // === Privacy: secret + PII redaction ===
114
+ const SECRETS = [
115
+ /\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}\b/g, // GitHub
116
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS
117
+ /\bsk-(?:ant|proj)?-?[A-Za-z0-9_-]{32,}\b/g, // OpenAI/Anthropic
118
+ /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, // JWT
119
+ /\bxox[bpoas]-[A-Za-z0-9-]{10,}\b/g, // Slack
120
+ /\bglpat-[A-Za-z0-9_-]{20,}\b/g, // GitLab
121
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // emails
122
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN-like
123
+ ];
124
+ const HOME = os.homedir();
125
+ const USER_DIR_RE = /(\/Users\/|\/home\/|\\+Users\\+)[^/\\]+/g;
126
+
127
+ function privacyTransform(s) {
128
+ if (typeof s !== "string") return s;
129
+ let out = s.split(HOME).join("<HOME>")
130
+ .replace(USER_DIR_RE, (m) => m.replace(/[^/\\]+$/, "<USER>"));
131
+ for (const re of SECRETS) out = out.replace(re, "[REDACTED]");
132
+ return out;
133
+ }
134
+
135
+ function walk(obj, depth) {
136
+ if (depth > MAX_DEPTH) return "[depth-limited]";
137
+ if (obj == null) return obj;
138
+ if (typeof obj === "string") {
139
+ const cleaned = privacyTransform(obj);
140
+ return cleaned.length > MAX_FIELD_LEN ? cleaned.slice(0, MAX_FIELD_LEN) + "…[truncated]" : cleaned;
141
+ }
142
+ if (Array.isArray(obj)) return obj.slice(0, 50).map((x) => walk(x, depth + 1));
143
+ if (typeof obj === "object") {
144
+ const out = {};
145
+ for (const [k, v] of Object.entries(obj)) out[k] = walk(v, depth + 1);
146
+ return out;
147
+ }
148
+ return obj;
149
+ }
150
+
151
+ export function sanitizeEvent(event) {
152
+ return event && typeof event === "object" ? walk(event, 0) : event;
153
+ }
154
+
155
+ // === Public API ===
156
+ export async function maybeForward(event, platform, opts = {}) {
157
+ const cfg = readConfig();
158
+ if (!cfg) return;
159
+
160
+ const ev = sanitizeEvent(event);
161
+ const ctrl = new AbortController();
162
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
163
+
164
+ try {
165
+ const res = await fetch(buildUrl(cfg, ev.type), {
166
+ method: "POST",
167
+ headers: {
168
+ "Authorization": `Bearer ${cfg.api_key}`,
169
+ "Content-Type": "application/json",
170
+ "X-Source-Platform": platform,
171
+ "X-Schema-Version": "2",
172
+ },
173
+ body: JSON.stringify({
174
+ tool: ev.type,
175
+ category: ev.category,
176
+ error: ev.category === "error" ? 1 : 0,
177
+ ts: opts.ts ?? Math.floor(Date.now() / 1000),
178
+ platform,
179
+ project: opts.project,
180
+ session_category: ev.category,
181
+ session_type: ev.type,
182
+ session_data: typeof ev.data === "string" ? ev.data : undefined,
183
+ }),
184
+ signal: ctrl.signal,
185
+ });
186
+ if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; }
187
+ else if (res.status === 429) {
188
+ process.stderr.write(`[context-mode-platform] rate limited (retry after ${res.headers.get("Retry-After")}s)\n`);
189
+ }
190
+ return { ok: res.ok, status: res.status };
191
+ } catch (e) {
192
+ return { ok: false, status: 0, error: e.message };
193
+ } finally {
194
+ clearTimeout(t);
195
+ }
196
+ }
197
+
198
+ export const _internal = {
199
+ readConfig,
200
+ normalizeConfig,
201
+ buildUrl,
202
+ sanitizeEvent,
203
+ privacyTransform,
204
+ configPath,
205
+ resetState: () => { _cache = null; _cacheLoadedAt = 0; _warned = false; _fsLoads = 0; },
206
+ get fsLoads() { return _fsLoads; },
207
+ };
@@ -10,6 +10,9 @@ import { join } from "node:path";
10
10
  import { pathToFileURL } from "node:url";
11
11
  import { existsSync } from "node:fs";
12
12
 
13
+ import { hasPlatformConfig, maybeForward } from "./platform-bridge.mjs";
14
+ import { detectPlatformFromEnv } from "./core/platform-detect.mjs";
15
+
13
16
  export function createSessionLoaders(hookDir) {
14
17
  // Auto-detect bundle directory: bundles live in hooks/ root, not platform subdirs.
15
18
  // If hookDir itself has bundles, use it; otherwise go up one level.
@@ -96,5 +99,17 @@ export function attributeAndInsertEvents(db, sessionId, events, input, projectDi
96
99
  db.insertEvent(sessionId, events[i], hookName, attributions[i], bytesList?.[i]);
97
100
  }
98
101
  }
102
+
103
+ // PRD-context-as-a-service §5.2 — Forwarder injection.
104
+ // Gated: the per-event loop never runs when ~/.context-mode/platform.json
105
+ // is missing. hasPlatformConfig() is a single cached probe (60s TTL), so
106
+ // the unconfigured-user path costs at most one syscall per minute.
107
+ if (hasPlatformConfig()) {
108
+ const platform = detectPlatformFromEnv();
109
+ for (let i = 0; i < events.length; i++) {
110
+ maybeForward({ ...events[i], session_id: sessionId, ...attributions[i] }, platform);
111
+ }
112
+ }
113
+
99
114
  return attributions;
100
115
  }
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.153",
6
+ "version": "1.0.155",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.153",
3
+ "version": "1.0.155",
4
4
  "type": "module",
5
5
  "description": "MCP plugin that saves 98% of your context window. Works with Claude Code, Gemini CLI, VS Code Copilot, OpenCode, and Codex CLI. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
6
6
  "author": "Mert Koseoğlu",