@aixle/insights 0.2.4-staging → 0.2.5-staging

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/README.md CHANGED
@@ -162,6 +162,12 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
162
162
  }
163
163
  ```
164
164
 
165
+ The file is optional — an absent `config.json` is the normal case and is silent. A file that is
166
+ present but unusable is ignored entirely (every override falls back to its default) and a
167
+ `config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
168
+ isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
169
+ Nothing is printed to the terminal, so check the log if an override appears to have no effect.
170
+
165
171
  ## Security
166
172
 
167
173
  `@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
@@ -188,6 +194,22 @@ The runtime gate exists because `init`'s two gates only run once, at login time.
188
194
 
189
195
  The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
190
196
 
197
+ ### Local store integrity
198
+
199
+ Transport security covers data in flight. The other half of the threat model is what the package
200
+ reads back off the local machine: credentials (keychain or file), `config.json`, and state files are
201
+ all attacker-writable if the account is compromised, so none of them is trusted on read.
202
+
203
+ Each is validated every time it is loaded. A payload that fails to parse, or that parses but does
204
+ not match the expected shape, is **rejected** and the caller falls back to its documented default —
205
+ no credentials, no config overrides, fresh state. Every rejection is recorded in `mcp.log`, so a
206
+ corrupted or tampered store is distinguishable from one that was never created; before this, both
207
+ were silent and looked identical to a fresh install. See
208
+ [Diagnostics](#diagnostics) for the event names.
209
+
210
+ Log fields carry only the file path (or the keychain service name) and a short machine-readable
211
+ reason. File contents, keychain payloads, and tokens are never logged.
212
+
191
213
  ## Cursor hook forwarder (opt-in)
192
214
 
193
215
  `aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
@@ -225,6 +247,35 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
225
247
 
226
248
  `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
227
249
 
250
+ ### Local-store integrity events
251
+
252
+ These four are the only signal that a local store was present but unusable — `health` and
253
+ `aixle_insights_status` do **not** report them, so `mcp.log` is the sole surface:
254
+
255
+ | Event | Fires when |
256
+ |---|---|
257
+ | `credentials_parse_failed` | `credentials.json` exists but was rejected |
258
+ | `credentials_keytar_parse_failed` | the OS keychain entry exists but was rejected |
259
+ | `config_parse_failed` | `config.json` exists but was rejected |
260
+ | `state_parse_failed` | a state file exists but was rejected |
261
+
262
+ Each carries a `reason` distinguishing the two failure modes:
263
+
264
+ - `invalid_json` — the payload did not parse at all.
265
+ - `invalid_shape` — it parsed, but validation rejected it: credentials with no usable token, a
266
+ `config.json` that is a JSON array, a state file missing `version` / `sessions`, and so on.
267
+
268
+ Three properties are worth relying on:
269
+
270
+ - An **absent** file never warns. That is the everyday case (most users never create a
271
+ `config.json`, and every machine starts with no state file), so a warning always means something
272
+ is actually there and wrong.
273
+ - A **missing or disabled OS keychain** never warns either — falling back to the file is expected
274
+ on headless Linux, CI, and containers, not an error.
275
+ - All four are written to the log **only**, never mirrored to stderr, because stray output on the
276
+ stdio transport corrupts the MCP protocol. Emitting a warning never changes the fallback the
277
+ caller returns.
278
+
228
279
  ## Troubleshooting
229
280
 
230
281
  | Symptom | Most likely cause | Fix |
@@ -236,6 +287,9 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
236
287
  | `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
237
288
  | `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
238
289
  | `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
290
+ | `health` reports `authenticated: false` right after a successful `init`, and `credentials_parse_failed` or `credentials_keytar_parse_failed` is in the log | The credential store exists but was rejected, so it is treated as absent. The `reason` field says whether it failed to parse (`invalid_json`) or parsed into the wrong shape (`invalid_shape`). | Re-run `init`. If you hand-edited `credentials.json` for local testing, remember the keychain is read **first** — see [State + credentials](#state--credentials). |
291
+ | A `config.json` override has no effect, and `config_parse_failed` is in the log | The file is malformed, or is valid JSON that is not an object — a top-level array is the usual mistake. | Fix it to match the shape under [Environment](#environment); until it parses, every override is ignored. |
292
+ | Sync re-sends history that was already delivered, and `state_parse_failed` is in the log | A state file was present but rejected, so sync fell back to fresh state and lost its dedup checkpoints. | This is recovery, not a loop — the next successful cycle writes valid state. Ingest upserts by session, so duplicates are absorbed. Worth investigating what wrote the bad file. |
239
293
  | `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
240
294
  | Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
241
295
 
@@ -4,6 +4,7 @@ import { userInfo } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { getAppDir } from "../state.js";
6
6
  import { mcpLog } from "../log.js";
7
+ import { describeReadFailure } from "../lib/parse-error.js";
7
8
  export const KEYTAR_SERVICE = "aixle-insights";
8
9
  const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
9
10
  function credentialsPath(appDir) {
@@ -62,15 +63,23 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
62
63
  const filePath = credentialsPath(appDir);
63
64
  if (!existsSync(filePath))
64
65
  return null;
66
+ let raw;
65
67
  try {
66
- const raw = JSON.parse(readFileSync(filePath, "utf-8"));
67
- return normalizeLoadedCredentials(raw);
68
+ raw = JSON.parse(readFileSync(filePath, "utf-8"));
68
69
  }
69
70
  catch (err) {
70
- // File exists (checked above) but failed to parse/normalize — distinguishes tampering from "never created".
71
- mcpLog.warn("credentials_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
71
+ // File exists (checked above) but is not readable/valid JSON — distinguishes tampering from "never created".
72
+ mcpLog.warn("credentials_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
72
73
  return null;
73
74
  }
75
+ const normalized = normalizeLoadedCredentials(raw);
76
+ if (normalized === null) {
77
+ // Valid JSON, but not a credential shape we accept. `normalizeLoadedCredentials` signals
78
+ // rejection by returning null and never throws, so this cannot surface in the catch
79
+ // above — without this branch a plausible-looking replacement file stays silent. (DB90DV-699)
80
+ mcpLog.warn("credentials_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
81
+ }
82
+ return normalized;
74
83
  }
75
84
  async function tryKeytarGet() {
76
85
  if (keytarDisabled())
@@ -86,15 +95,22 @@ async function tryKeytarGet() {
86
95
  }
87
96
  if (!raw)
88
97
  return null;
98
+ let parsed;
89
99
  try {
90
- const parsed = JSON.parse(raw);
91
- return normalizeLoadedCredentials(parsed);
100
+ parsed = JSON.parse(raw);
92
101
  }
93
102
  catch (err) {
94
- // Keychain entry exists (checked above) but failed to parse/normalize — distinguishes tampering from "no entry".
95
- mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
103
+ // Keychain entry exists (checked above) but is not valid JSON — distinguishes tampering from "no entry".
104
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, ...describeReadFailure(err) }, false);
96
105
  return null;
97
106
  }
107
+ const normalized = normalizeLoadedCredentials(parsed);
108
+ if (normalized === null) {
109
+ // Same shape-rejection hole as the file path above. Fields stay service-only — never the
110
+ // keychain payload. (DB90DV-699)
111
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, reason: "invalid_shape" }, false);
112
+ }
113
+ return normalized;
98
114
  }
99
115
  async function tryKeytarSet(payload) {
100
116
  if (keytarDisabled())
@@ -12,8 +12,8 @@ export interface BaseConfig {
12
12
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
13
13
  * malformed files — callers fall back to env vars / CLI flags / defaults.
14
14
  *
15
- * @param configDir Directory containing `config.json`, typically the
16
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
15
+ * @param configDir Directory containing `config.json`, typically the app home directory
16
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
17
17
  * @param parsePricing Optional callback that extracts a connector-specific
18
18
  * pricing shape from the raw parsed JSON. Returns
19
19
  * `undefined` when the pricing block is missing or invalid.
@@ -1,12 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { mcpLog } from "../log.js";
4
+ import { describeReadFailure } from "./parse-error.js";
4
5
  /**
5
6
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
6
7
  * malformed files — callers fall back to env vars / CLI flags / defaults.
7
8
  *
8
- * @param configDir Directory containing `config.json`, typically the
9
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
9
+ * @param configDir Directory containing `config.json`, typically the app home directory
10
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
10
11
  * @param parsePricing Optional callback that extracts a connector-specific
11
12
  * pricing shape from the raw parsed JSON. Returns
12
13
  * `undefined` when the pricing block is missing or invalid.
@@ -16,29 +17,36 @@ import { mcpLog } from "../log.js";
16
17
  */
17
18
  export function loadBaseConfig(configDir, parsePricing) {
18
19
  const configPath = join(configDir, "config.json");
20
+ let parsed;
19
21
  try {
20
- const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
21
- if (typeof parsed === "object" && parsed !== null) {
22
- const obj = parsed;
23
- const result = {
24
- token: typeof obj.token === "string" ? obj.token : undefined,
25
- host: typeof obj.host === "string" ? obj.host : undefined,
26
- project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
27
- };
28
- if (parsePricing) {
29
- const pricing = parsePricing(obj);
30
- if (pricing !== undefined)
31
- result.pricing = pricing;
32
- }
33
- return result;
34
- }
22
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
35
23
  }
36
24
  catch (err) {
37
25
  const code = err?.code;
38
26
  if (code !== "ENOENT") {
39
- // Config file exists but failed to parse — distinguishes tampering from "never created".
40
- mcpLog.warn("config_parse_failed", { path: configPath, error: err instanceof Error ? err.message : String(err) }, false);
27
+ // Config file exists but is not valid JSON — distinguishes tampering from "never created".
28
+ // ENOENT stays silent: this file is optional and most users never create it.
29
+ mcpLog.warn("config_parse_failed", { path: configPath, ...describeReadFailure(err) }, false);
41
30
  }
31
+ return {};
32
+ }
33
+ // Valid JSON, but not a config object. Arrays are rejected explicitly because
34
+ // `typeof [] === "object"` would otherwise let them reach the happy path and be handed
35
+ // to `parsePricing`. Previously every non-object fell through silently. (DB90DV-699)
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37
+ mcpLog.warn("config_parse_failed", { path: configPath, reason: "invalid_shape" }, false);
38
+ return {};
39
+ }
40
+ const obj = parsed;
41
+ const result = {
42
+ token: typeof obj.token === "string" ? obj.token : undefined,
43
+ host: typeof obj.host === "string" ? obj.host : undefined,
44
+ project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
45
+ };
46
+ if (parsePricing) {
47
+ const pricing = parsePricing(obj);
48
+ if (pricing !== undefined)
49
+ result.pricing = pricing;
42
50
  }
43
- return {};
51
+ return result;
44
52
  }
@@ -0,0 +1,21 @@
1
+ export type ReadFailureReason = "invalid_json" | "unreadable";
2
+ /**
3
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
4
+ * payload) into a log-safe reason + error string.
5
+ *
6
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
7
+ * short enough input, the entirety) of the unparsed content — e.g.
8
+ * `JSON.parse("example_local_fixture_1234567890")` produces
9
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
10
+ * leak exactly the secret content the parse-failure events exist to describe without
11
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
12
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
13
+ * the error name, never `.message`.
14
+ *
15
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
16
+ * errno `code`, which never contains file content and is more actionable than a bare name.
17
+ */
18
+ export declare function describeReadFailure(err: unknown): {
19
+ reason: ReadFailureReason;
20
+ error: string;
21
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
3
+ * payload) into a log-safe reason + error string.
4
+ *
5
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
6
+ * short enough input, the entirety) of the unparsed content — e.g.
7
+ * `JSON.parse("example_local_fixture_1234567890")` produces
8
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
9
+ * leak exactly the secret content the parse-failure events exist to describe without
10
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
11
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
12
+ * the error name, never `.message`.
13
+ *
14
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
15
+ * errno `code`, which never contains file content and is more actionable than a bare name.
16
+ */
17
+ export function describeReadFailure(err) {
18
+ if (err instanceof SyntaxError) {
19
+ return { reason: "invalid_json", error: "SyntaxError" };
20
+ }
21
+ const code = err?.code;
22
+ if (code)
23
+ return { reason: "unreadable", error: code };
24
+ return { reason: "unreadable", error: err instanceof Error ? err.name : "unknown_error" };
25
+ }
package/dist/state.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { mcpLog } from "./log.js";
6
+ import { describeReadFailure } from "./lib/parse-error.js";
6
7
  export function getAppDir() {
7
8
  const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
8
9
  if (override && override.length > 0)
@@ -90,48 +91,50 @@ export function migrateLegacyState(dir, host, token) {
90
91
  }
91
92
  export function readState(dir, host, token) {
92
93
  const filePath = stateFilePath(dir ?? getAppDir(), host, token);
94
+ let parsed;
93
95
  try {
94
- const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
95
- if (typeof parsed === "object" && parsed !== null) {
96
- const p = parsed;
97
- if (typeof p.version === "number" &&
98
- typeof p.sessions === "object" &&
99
- p.sessions !== null) {
100
- const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
101
- ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
102
- : undefined;
103
- const out = {
104
- version: p.version,
105
- sessions: p.sessions,
106
- };
107
- if (lastRecentCommitHashes !== undefined) {
108
- out.lastRecentCommitHashes = lastRecentCommitHashes;
109
- }
110
- if ("mcp_operator" in p) {
111
- const mcp = parseMcpOperator(p.mcp_operator);
112
- if (mcp)
113
- out.mcp_operator = mcp;
114
- }
115
- if ("rate_limited_until" in p) {
116
- if (typeof p.rate_limited_until === "string") {
117
- out.rate_limited_until = p.rate_limited_until;
118
- }
119
- else if (p.rate_limited_until === null) {
120
- out.rate_limited_until = null;
121
- }
122
- }
123
- return out;
124
- }
125
- }
96
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
126
97
  }
127
98
  catch (err) {
128
99
  const code = err?.code;
129
100
  if (code !== "ENOENT") {
130
- // State file exists but failed to parse — distinguishes tampering from "never created".
131
- mcpLog.warn("state_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
101
+ // State file exists but is not valid JSON — distinguishes tampering from "never created".
102
+ // ENOENT stays silent: that is the normal first-run case.
103
+ mcpLog.warn("state_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
104
+ }
105
+ return { version: 1, sessions: {} };
106
+ }
107
+ const p = typeof parsed === "object" && parsed !== null ? parsed : null;
108
+ if (p === null || typeof p.version !== "number" || typeof p.sessions !== "object" || p.sessions === null) {
109
+ // Valid JSON, wrong shape. This fallback discards every dedup checkpoint and causes a full
110
+ // re-send, so it is the most consequential of the four to have been silent. (DB90DV-699)
111
+ mcpLog.warn("state_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
112
+ return { version: 1, sessions: {} };
113
+ }
114
+ const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
115
+ ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
116
+ : undefined;
117
+ const out = {
118
+ version: p.version,
119
+ sessions: p.sessions,
120
+ };
121
+ if (lastRecentCommitHashes !== undefined) {
122
+ out.lastRecentCommitHashes = lastRecentCommitHashes;
123
+ }
124
+ if ("mcp_operator" in p) {
125
+ const mcp = parseMcpOperator(p.mcp_operator);
126
+ if (mcp)
127
+ out.mcp_operator = mcp;
128
+ }
129
+ if ("rate_limited_until" in p) {
130
+ if (typeof p.rate_limited_until === "string") {
131
+ out.rate_limited_until = p.rate_limited_until;
132
+ }
133
+ else if (p.rate_limited_until === null) {
134
+ out.rate_limited_until = null;
132
135
  }
133
136
  }
134
- return { version: 1, sessions: {} };
137
+ return out;
135
138
  }
136
139
  /** Atomic write: write to a temp file then rename over the target. */
137
140
  export function writeState(state, dir, host, token) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.2.4-staging",
3
+ "version": "0.2.5-staging",
4
4
  "description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
5
5
  "type": "module",
6
6
  "bin": {