@compr/opscontext-mcp 2.9.1 → 2.11.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.
@@ -0,0 +1,7 @@
1
+ export declare function ceHome(): string;
2
+ /** Makes `dir` private to this user (0700), creating it if needed. Never throws. */
3
+ export declare function securePrivateDir(dir: string, create: boolean): void;
4
+ /** The CE home (created if missing) and, when CONTEXTENGINE_HOME points elsewhere, the default one
5
+ * if it exists: the receiver's secret always lives in ~/.contextengine. */
6
+ export declare function secureCeHome(): void;
7
+ //# sourceMappingURL=ce-home.d.ts.map
@@ -0,0 +1,45 @@
1
+ // [LOCKED] [CE-HOME-IS-PRIVATE] - 2026-09-25
2
+ // [NEVER] leave ~/.contextengine (or CONTEXTENGINE_HOME) readable by other accounts, and never
3
+ // chmod a folder this user does not own.
4
+ // WHY: every file in it was created with the default modes: the folder 0755, and audit.log with
5
+ // its 59 archived segments, learnings.json and 20 backups, sessions, the shared index (which
6
+ // holds dotenv, shell history and crontab chunks), license.json and the daemon log all 0644.
7
+ // Only extension-secret and keys/ were private. On a Mac whose home folder is 0755, or a
8
+ // shared Linux box, any other account could read the lot (E2E_REVIEW_2026-09 A7-1, A4-3).
9
+ // FIX: both entry points (the MCP server's main() and the CLI's dispatcher) call secureCeHome()
10
+ // first: the folder is created 0700, or set to 0700 when it has any group or other bit. A
11
+ // private folder shields every file inside, whatever its own mode, so no writer has to
12
+ // remember a mode. A folder owned by someone else is left alone.
13
+ import { chmodSync, existsSync, mkdirSync, statSync } from "fs";
14
+ import { join } from "path";
15
+ import { homedir } from "os";
16
+ export function ceHome() {
17
+ return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
18
+ }
19
+ /** Makes `dir` private to this user (0700), creating it if needed. Never throws. */
20
+ export function securePrivateDir(dir, create) {
21
+ try {
22
+ if (!existsSync(dir)) {
23
+ if (!create)
24
+ return;
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ }
27
+ const st = statSync(dir);
28
+ if (typeof process.getuid === "function" && st.uid !== process.getuid())
29
+ return;
30
+ if ((st.mode & 0o077) !== 0)
31
+ chmodSync(dir, 0o700);
32
+ }
33
+ catch {
34
+ /* permissions are hardening, never a reason to stop */
35
+ }
36
+ }
37
+ /** The CE home (created if missing) and, when CONTEXTENGINE_HOME points elsewhere, the default one
38
+ * if it exists: the receiver's secret always lives in ~/.contextengine. */
39
+ export function secureCeHome() {
40
+ securePrivateDir(ceHome(), true);
41
+ const fallback = join(homedir(), ".contextengine");
42
+ if (fallback !== ceHome())
43
+ securePrivateDir(fallback, false);
44
+ }
45
+ //# sourceMappingURL=ce-home.js.map
@@ -44,6 +44,7 @@ export const KNOWN_COMMANDS = [
44
44
  "import-learnings",
45
45
  "init",
46
46
  "init-extension-secret",
47
+ "trust",
47
48
  "install-autostart",
48
49
  "install-claude-hook",
49
50
  "install-skill",
package/dist/cli.js CHANGED
@@ -15,11 +15,11 @@
15
15
  * contextengine cost Multi-agent token/cost/capacity report from transcripts
16
16
  * contextengine help Show this message
17
17
  */
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, mkdtempSync } from "fs";
19
19
  import { join, basename, resolve } from "path";
20
20
  import { createInterface } from "readline";
21
21
  import { tmpdir, homedir } from "os";
22
- import { execSync } from "child_process";
22
+ import { execSync, execFileSync } from "child_process";
23
23
  function detectProject(dir) {
24
24
  const name = basename(dir);
25
25
  const result = {
@@ -649,14 +649,18 @@ import { scanCodeDir } from "./code-chunker.js";
649
649
  import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
650
650
  import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
651
651
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
652
- import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
652
+ import { activate, deactivate, getActivationStatus, gateCheckFresh, } from "./activation.js";
653
653
  import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
654
- import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, } from "./audit.js";
655
- import { redactPayload } from "./secret-shapes.js";
654
+ import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, recordVerifyState, acquireVerifyLock, } from "./audit.js";
655
+ import { redactPayload, redactChunk } from "./secret-shapes.js";
656
656
  import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
657
657
  import { buildCostReport } from "./cost-report.js";
658
658
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
659
659
  import { safeAppend } from "./audit.js";
660
+ import { checkCaptureEvent, prepareCapturedPayload } from "./http-server.js";
661
+ import { secureCeHome } from "./ce-home.js";
662
+ import { QUOTED_TEXT_NOTE } from "./framing.js";
663
+ import { trustProjects, untrustProjects, listTrusted } from "./trusted-projects.js";
660
664
  import { listServers, formatServers } from "./server-registry.js";
661
665
  import { computeFleetHealth, formatFleetHealth } from "./fleet-health.js";
662
666
  import { ciStatusForHead, formatCiStatus } from "./ci-status.js";
@@ -713,7 +717,9 @@ async function initEngine() {
713
717
  const localChunks = chunks;
714
718
  chunks = mergeWithDedup(localChunks, communityChunks);
715
719
  }
716
- return { sources, chunks };
720
+ // [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: the CLI builds its own index; every chunk it can
721
+ // print goes through the same redaction as the MCP server's.
722
+ return { sources, chunks: chunks.map(redactChunk) };
717
723
  }
718
724
  // ---------------------------------------------------------------------------
719
725
  // CLI Subcommands
@@ -725,7 +731,7 @@ async function cliSearch(query, topK) {
725
731
  console.log(`No results found for: "${query}"`);
726
732
  return;
727
733
  }
728
- console.log(`\n🔍 Search: "${query}" | ${results.length} results (keyword/BM25)\n`);
734
+ console.log(`\n🔍 Search: "${query}" | ${results.length} results (keyword/BM25)\n${QUOTED_TEXT_NOTE}\n`);
729
735
  for (let i = 0; i < results.length; i++) {
730
736
  const r = results[i];
731
737
  console.log(`--- Result ${i + 1} (score: ${r.score.toFixed(3)}) ---`);
@@ -753,7 +759,7 @@ async function cliListSources() {
753
759
  console.log("");
754
760
  }
755
761
  async function cliListProjects() {
756
- const gate = gateCheck("list_projects");
762
+ const gate = await gateCheckFresh("list_projects");
757
763
  if (gate) {
758
764
  console.error(gate);
759
765
  process.exit(1);
@@ -872,7 +878,7 @@ async function cliDeleteLearning(id) {
872
878
  * the scan.
873
879
  */
874
880
  async function cliScore(project, html = false, save = true, all = false) {
875
- const gate = gateCheck("score_project");
881
+ const gate = await gateCheckFresh("score_project");
876
882
  if (gate) {
877
883
  console.error(gate);
878
884
  process.exit(1);
@@ -963,13 +969,15 @@ async function cliScore(project, html = false, save = true, all = false) {
963
969
  }
964
970
  if (html) {
965
971
  const htmlContent = generateScoreHTML(scores);
966
- const tmpPath = join(tmpdir(), "contextengine-score.html");
967
- writeFileSync(tmpPath, htmlContent, "utf-8");
972
+ // A private folder of its own, not a fixed name in a shared /tmp, and the path handed to the
973
+ // opener as an argument, never pasted into a shell string (E2E_REVIEW_2026-09 A1-1).
974
+ const tmpPath = join(mkdtempSync(join(tmpdir(), "contextengine-score-")), "score.html");
975
+ writeFileSync(tmpPath, htmlContent, { encoding: "utf-8", mode: 0o600 });
968
976
  console.log(`\n📊 HTML report written to: ${tmpPath}`);
969
977
  // Open in default browser
970
- const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
978
+ const [openCmd, ...openArgs] = process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", '""'] : ["xdg-open"];
971
979
  try {
972
- execSync(`${openCmd} "${tmpPath}"`);
980
+ execFileSync(openCmd, [...openArgs, tmpPath], { stdio: "ignore" });
973
981
  console.log("🌐 Opened in browser\n");
974
982
  }
975
983
  catch {
@@ -991,7 +999,7 @@ async function cliScore(project, html = false, save = true, all = false) {
991
999
  }
992
1000
  }
993
1001
  async function cliAudit() {
994
- const gate = gateCheck("run_audit");
1002
+ const gate = await gateCheckFresh("run_audit");
995
1003
  if (gate) {
996
1004
  console.error(gate);
997
1005
  process.exit(1);
@@ -1728,12 +1736,31 @@ safeAppend), visible via 'contextengine audit-verify' and consumed by the
1728
1736
  console.error(`Bad payload JSON: ${e instanceof Error ? e.message : String(e)}`);
1729
1737
  process.exit(1);
1730
1738
  }
1731
- // Cast — the audit module accepts any string for the event field; the
1732
- // AuditEvent union is documentation, not enforcement. Validation of
1733
- // "what's a valid event kind" is the caller's responsibility (the HTTP
1734
- // server enforces a prefix allow-list; this CLI is trusted).
1735
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1736
- safeAppend(eventKind, payload, actor);
1739
+ // [LOCKED] [EMIT-EVENT-GOES-THROUGH-THE-DOOR] - 2026-09-25
1740
+ // [NEVER] append a captured event here without the receiver's kind check and
1741
+ // prepareCapturedPayload().
1742
+ // WHY: this command was "trusted" and wrote anything: in a sandbox it kept a mysql password and
1743
+ // a prompt's full text, and wrote an audit.redact acknowledgement, the record that turns
1744
+ // "altered" into "redacted" in the verifier. The VS Code extension emits through it, so
1745
+ // [CAPTURE-IS-REDACTED-AT-THE-DOOR] and [PROMPT-TEXT-IS-NOT-KEPT] had a side entrance
1746
+ // (E2E_REVIEW_2026-09 A2-6).
1747
+ // FIX: the same checkCaptureEvent() and prepareCapturedPayload() as POST /events; "cli" stays a
1748
+ // valid actor here, "system" does not.
1749
+ const refused = checkCaptureEvent(eventKind, actor, { allowCliActor: true });
1750
+ if (refused) {
1751
+ console.error(`❌ ${refused}`);
1752
+ process.exit(1);
1753
+ }
1754
+ // [LOCKED] [RECEIVER-SAYS-WHAT-WAS-WRITTEN] - 2026-09-27
1755
+ // [NEVER] report "Appended" or exit 0 for an entry the audit log refused.
1756
+ // WHY: on a stuck log (a record cut short by a full disk) this printed "Appended vscode.tool_call to
1757
+ // audit log." and exited 0 while nothing landed (E2E_REVIEW_2026-09 B1-1); the VS Code
1758
+ // extension emits through this command and believed it.
1759
+ // FIX: safeAppend() returns whether it wrote; a refusal exits 1. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
1760
+ if (!safeAppend(eventKind, prepareCapturedPayload(payload, eventKind), actor)) {
1761
+ console.error(`❌ Not recorded: the audit log refused ${eventKind} (reason above). The refusal is counted and will be noted on the chain.`);
1762
+ process.exit(1);
1763
+ }
1737
1764
  console.log(`✅ Appended ${eventKind} to audit log.`);
1738
1765
  }
1739
1766
  async function cliWatch(args) {
@@ -1824,6 +1851,33 @@ this CLI is for terminal users, CI, and cron.`);
1824
1851
  // helps catch terminal closes.
1825
1852
  process.stdin.resume();
1826
1853
  }
1854
+ /** `contextengine trust`: which projects are auto-imported. [LOCK] [AUTO-IMPORT-ONLY-FROM-TRUSTED-PROJECTS] */
1855
+ function cliTrust(args) {
1856
+ if (args.includes("-h") || args.includes("--help")) {
1857
+ console.log(`Usage: contextengine trust <project>... mark projects as yours
1858
+ contextengine trust --list show them
1859
+ contextengine trust --remove <project>... unmark them
1860
+
1861
+ Learnings are imported automatically only from projects marked as yours. A project seen for the
1862
+ first time (a repository you downloaded, for instance) stays searchable but its learnings are not
1863
+ saved until you mark it. The list starts with every project that already had learnings.`);
1864
+ return;
1865
+ }
1866
+ const names = args.filter((a) => !a.startsWith("-"));
1867
+ let list;
1868
+ if (args.includes("--remove")) {
1869
+ list = untrustProjects(names);
1870
+ console.log(`Removed ${names.length} project(s).`);
1871
+ }
1872
+ else if (names.length > 0) {
1873
+ list = trustProjects(names, () => [...new Set(listLearnings().map((l) => l.project).filter((p) => !!p))]);
1874
+ console.log(`Marked as yours: ${names.join(", ")}. Their learnings are imported at the next reindex.`);
1875
+ }
1876
+ else {
1877
+ list = listTrusted();
1878
+ }
1879
+ console.log(`${list.length} trusted project(s)${list.length ? ": " + list.join(", ") : " (none yet: the list is created at the first reindex)"}`);
1880
+ }
1827
1881
  async function cliInitExtensionSecret(args) {
1828
1882
  const force = args.includes("--force") || args.includes("-f");
1829
1883
  const help = args.includes("-h") || args.includes("--help");
@@ -2084,11 +2138,35 @@ function cliAuditRedactAck(args) {
2084
2138
  console.log(` audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${(after.redactedIndices ?? []).length} redacted, ${(after.tamperedIndices ?? []).length} altered.`);
2085
2139
  }
2086
2140
  async function cliAuditVerify() {
2141
+ // [LOCK] [HEALTH-SEES-THE-CHAIN]: every full check leaves its result for fleet health. The
2142
+ // daily one (--scheduled, started by the indexing server) runs one at a time and prints nothing.
2143
+ const scheduled = process.argv.includes("--scheduled");
2144
+ let releaseVerifyLock = null;
2145
+ if (scheduled) {
2146
+ releaseVerifyLock = acquireVerifyLock();
2147
+ if (!releaseVerifyLock)
2148
+ return; // another check is running
2149
+ }
2150
+ const t0 = Date.now();
2087
2151
  const report = verifyChain();
2152
+ recordVerifyState(report, Date.now() - t0, scheduled ? "scheduled" : "cli");
2153
+ if (scheduled) {
2154
+ releaseVerifyLock?.();
2155
+ return;
2156
+ }
2088
2157
  const forks = report.forkIndices ?? [];
2089
2158
  const redacted = report.redactedIndices ?? [];
2159
+ const dups = report.duplicateIndices ?? [];
2160
+ // [LOCK] [VERIFY-FORK-IS-NOT-TAMPER]: a second copy of a record is counted once and named as a copy.
2161
+ const copiesNote = () => {
2162
+ if (dups.length === 0)
2163
+ return;
2164
+ console.log(`\n⚠️ ${dups.length} record(s) appear twice (a second copy of a record already in the log).`);
2165
+ console.log(` Counted once. Content intact, nothing missing. Usually a log trim that was interrupted or`);
2166
+ console.log(` ran while entries arrived. At: ${dups.slice(0, 8).join(", ")}${dups.length > 8 ? `, … (+${dups.length - 8} more)` : ""}`);
2167
+ };
2090
2168
  if (report.ok) {
2091
- console.log(`✅ Audit chain verified — ${report.total} record(s).`);
2169
+ console.log(`✅ Audit chain verified — ${report.total - dups.length} record(s).`);
2092
2170
  console.log(redacted.length === 0
2093
2171
  ? ` No record was altered, and no history is missing.`
2094
2172
  : ` No history is missing. ${redacted.length} record(s) redacted and acknowledged on the chain (indices ${redacted.slice(0, 8).join(", ")}${redacted.length > 8 ? ", …" : ""}), 0 altered.`);
@@ -2101,6 +2179,7 @@ async function cliAuditVerify() {
2101
2179
  console.log(` property holds. Do NOT rewrite the log to linearise it — that would destroy`);
2102
2180
  console.log(` the evidence it exists to provide.`);
2103
2181
  }
2182
+ copiesNote();
2104
2183
  return;
2105
2184
  }
2106
2185
  console.error(`❌ Audit chain FAILED — ${report.total} record(s) checked.`);
@@ -2110,12 +2189,24 @@ async function cliAuditVerify() {
2110
2189
  console.error(`\n Altered records (content does not match its own hash):`);
2111
2190
  console.error(` ${t.slice(0, 10).join(", ")}${t.length > 10 ? `, … (+${t.length - 10} more)` : ""}`);
2112
2191
  console.error(` This is tampering: the record's bytes were changed after it was written.`);
2113
- console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain:`);
2114
- console.error(` contextengine audit-redact-ack --index ${t.slice(0, 3).join(",")} --reason "<what was removed and why>"`);
2192
+ console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain`);
2193
+ console.error(` (every altered index is listed, so the command can be pasted as it is):`);
2194
+ console.error(` contextengine audit-redact-ack --index ${t.join(",")} --reason "<what was removed and why>"`);
2115
2195
  }
2116
2196
  if (redacted.length > 0) {
2117
2197
  console.error(`\n Also ${redacted.length} redacted record(s), acknowledged on the chain, not counted above.`);
2118
2198
  }
2199
+ // [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE]: say where, and that the rest was checked.
2200
+ const unreadable = report.unreadable ?? [];
2201
+ if (unreadable.length > 0) {
2202
+ console.error(`\n Lines that are not records (every other record was checked):`);
2203
+ for (const u of unreadable.slice(0, 10))
2204
+ console.error(` ${u.file} line ${u.line}`);
2205
+ if (unreadable.length > 10)
2206
+ console.error(` … (+${unreadable.length - 10} more)`);
2207
+ console.error(` A last record cut short by a full disk is set aside by the next append on its own;`);
2208
+ console.error(` a line like these is damage: keep a copy of the file before touching it.`);
2209
+ }
2119
2210
  if ((report.orphanIndices ?? []).length > 0) {
2120
2211
  const o = report.orphanIndices;
2121
2212
  console.error(`\n Orphaned records (parent hash absent from the log):`);
@@ -2127,6 +2218,9 @@ async function cliAuditVerify() {
2127
2218
  if (forks.length > 0) {
2128
2219
  console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
2129
2220
  }
2221
+ if (dups.length > 0) {
2222
+ console.error(`\n (Also ${dups.length} second copies of records, counted once, benign.)`);
2223
+ }
2130
2224
  console.error(`\nFor compliance-graded evidence, treat the affected records as unverified.`);
2131
2225
  process.exit(2);
2132
2226
  }
@@ -2284,6 +2378,9 @@ async function cliEndSession() {
2284
2378
  checks.push(`- ⛔ Auto-import write refused: ${autoImport.refused}`);
2285
2379
  failCount++;
2286
2380
  }
2381
+ if (autoImport.untrusted.length > 0) {
2382
+ checks.push(`- ⏸ Not imported, project not marked as yours: ${autoImport.untrusted.join(", ")} (contextengine trust <project>)\n`);
2383
+ }
2287
2384
  // --- Check 3: Learnings Store ---
2288
2385
  checks.push("## 3. Learnings Store\n");
2289
2386
  const stats = learningsStats();
@@ -2575,6 +2672,9 @@ function readPackageVersion() {
2575
2672
  // Main — route to init, CLI subcommand, or MCP server
2576
2673
  // ---------------------------------------------------------------------------
2577
2674
  const command = process.argv[2];
2675
+ // [LOCK] [CE-HOME-IS-PRIVATE]: before any command writes into it (not for a version or help line).
2676
+ if (command && !["--version", "-v", "--help", "-h", "help"].includes(command))
2677
+ secureCeHome();
2578
2678
  if (command === "init") {
2579
2679
  runInit().catch((err) => {
2580
2680
  console.error("Error:", err);
@@ -2635,6 +2735,8 @@ Usage:
2635
2735
  Author + validate the declarative .contextengine/policy.json
2636
2736
  contextengine init-extension-secret [--force]
2637
2737
  Generate ~/.contextengine/extension-secret for the browser ext
2738
+ contextengine trust [<project>...] [--list] [--remove <project>...]
2739
+ Mark projects as yours: only those are auto-imported into learnings
2638
2740
  contextengine install-autostart [--force]
2639
2741
  Install macOS LaunchAgent so MCP server auto-starts at login
2640
2742
  (uninstall-autostart / autostart-status — companion commands)
@@ -2895,6 +2997,9 @@ else if (command === "init-extension-secret") {
2895
2997
  process.exit(1);
2896
2998
  });
2897
2999
  }
3000
+ else if (command === "trust") {
3001
+ cliTrust(process.argv.slice(3));
3002
+ }
2898
3003
  else if (command === "install-autostart") {
2899
3004
  import("./install-autostart.js").then((m) => m.cliInstallAutostart(process.argv.slice(3))).catch((err) => {
2900
3005
  console.error("Error:", err instanceof Error ? err.message : err);
@@ -1,4 +1,7 @@
1
1
  import type { Chunk } from "./ingest.js";
2
+ /** The last `count` lines of a file, read without a shell, from at most its last 256 KB. */
3
+ export declare function readLastLines(path: string, count: number): string;
4
+ export declare function redactSensitive(content: string): string;
2
5
  /**
3
6
  * Collect recent git history for a project directory.
4
7
  * Produces chunks with commit messages, authors, dates — gives AI
@@ -1,5 +1,5 @@
1
1
  import { execSync } from "child_process";
2
- import { readFileSync, existsSync, readdirSync, statSync } from "fs";
2
+ import { readFileSync, existsSync, readdirSync, statSync, openSync, fstatSync, readSync, closeSync } from "fs";
3
3
  import { resolve, join } from "path";
4
4
  import { homedir } from "os";
5
5
  /**
@@ -34,13 +34,43 @@ function exec(cmd, cwd) {
34
34
  return "";
35
35
  }
36
36
  }
37
+ /** The last `count` lines of a file, read without a shell, from at most its last 256 KB. */
38
+ export function readLastLines(path, count) {
39
+ const fd = openSync(path, "r");
40
+ try {
41
+ const size = fstatSync(fd).size;
42
+ const len = Math.min(size, 256 * 1024);
43
+ const buf = Buffer.alloc(len);
44
+ readSync(fd, buf, 0, len, size - len);
45
+ const lines = buf.toString("utf-8").split("\n");
46
+ if (len < size)
47
+ lines.shift(); // the first line was cut by the window
48
+ if (lines.length > 0 && lines[lines.length - 1] === "")
49
+ lines.pop();
50
+ return lines.slice(-count).join("\n");
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ }
37
56
  /** Check if a command exists */
38
57
  function commandExists(cmd) {
39
58
  return exec(`command -v ${cmd}`) !== "";
40
59
  }
41
- /** Redact sensitive values in .env content */
42
- function redactSensitive(content) {
43
- const sensitivePatterns = /^(.*(?:PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH|PRIVATE|API_KEY|DB_PASSWORD|MAIL_PASSWORD|JWT_SECRET|APP_KEY|ENCRYPT)[^=]*=\s*).+$/gim;
60
+ // [LOCKED] [ENV-MASK-IS-LINE-BOUND] - 2026-09-25
61
+ // [NEVER] let this pattern cross a newline, or look for the secret word anywhere but in the name
62
+ // (the part before the first "=").
63
+ // WHY: the old pattern `^(.*(?:...KEY|TOKEN...)[^=]*=\s*).+$` let `[^=]*` and `\s*` run over line
64
+ // ends. A value that itself contained "key", "token", "auth" or "secret" (case-insensitive)
65
+ // matched there, the match ran on to the next line's "=", and that next line was masked
66
+ // while the real value stayed in clear. Two FLASK_SECRET_KEY values sat unmasked in the shared
67
+ // index this way; `SMTP_PASS` was never masked at all (E2E_REVIEW_2026-09 A6-3). An agent's
68
+ // screen hid the bug: the output guard redacted the value on display, not the tool.
69
+ // FIX: the name is `[^=\r\n]*` on both sides of the secret word, spaces after "=" are `[ \t]*`, and
70
+ // PASS joins the word list (it also covers PASSWORD). Over-masking a config value in search
71
+ // costs nothing; under-masking hands a password to every agent.
72
+ export function redactSensitive(content) {
73
+ const sensitivePatterns = /^([^=\r\n]*(?:PASS|SECRET|KEY|TOKEN|CREDENTIAL|AUTH|PRIVATE|ENCRYPT)[^=\r\n]*=[ \t]*)\S[^\r\n]*$/gim;
44
74
  return content.replace(sensitivePatterns, "$1[REDACTED]");
45
75
  }
46
76
  // ---------------------------------------------------------------------------
@@ -297,8 +327,9 @@ export function collectShellHistory(sourceName) {
297
327
  if (!existsSync(histFile))
298
328
  return [];
299
329
  try {
300
- // Read last 200 lines (most recent commands)
301
- const raw = exec(`tail -200 ${histFile}`);
330
+ // Read last 200 lines (most recent commands). Read directly: the path comes from HOME, and a
331
+ // home folder with a space or a `$(` broke, or ran, the old `tail -200 ${histFile}` (E2E A1-1).
332
+ const raw = readLastLines(histFile, 200);
302
333
  if (!raw)
303
334
  return [];
304
335
  // Parse zsh extended history format: : timestamp:0;command
@@ -18,10 +18,10 @@
18
18
  // SEE ALSO: chrome-extension/src/content/shared/redact.ts (the same patterns,
19
19
  // independently mirrored — keep both in sync; if you patch one, patch the
20
20
  // other in the same commit).
21
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
21
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs";
22
22
  import { dirname } from "path";
23
23
  import { createHash } from "crypto";
24
- import { execSync, spawnSync } from "child_process";
24
+ import { spawnSync } from "child_process";
25
25
  import { tmpdir } from "os";
26
26
  import { join } from "path";
27
27
  import { listLearnings } from "./learnings.js";
@@ -359,32 +359,29 @@ export async function reviewLoop(rules) {
359
359
  if (!process.stdin.isTTY)
360
360
  return rules;
361
361
  const editor = process.env.EDITOR || "vi";
362
- const tmpPath = join(tmpdir(), `opscontext-export-review-${process.pid}.json`);
363
- writeFileSync(tmpPath, JSON.stringify(rules, null, 2), "utf-8");
364
- const result = spawnSync(editor, [tmpPath], { stdio: "inherit" });
365
- if (result.status !== 0) {
366
- // Editor exited non-zero — keep the original list rather than risk a
367
- // half-edited file.
368
- return rules;
369
- }
362
+ // A private folder, the file readable by the user only (it holds the learnings being exported),
363
+ // removed on every path. E2E_REVIEW_2026-09 A1-1: the old cleanup pasted the path into
364
+ // `rm -f "..."`, where a TMPDIR holding `$(` would run, and a failed edit left the file behind.
365
+ const dir = mkdtempSync(join(tmpdir(), "opscontext-export-review-"));
366
+ const tmpPath = join(dir, "review.json");
370
367
  try {
371
- const edited = readFileSync(tmpPath, "utf-8");
372
- const parsed = JSON.parse(edited);
373
- if (Array.isArray(parsed))
374
- return parsed;
375
- return rules;
368
+ writeFileSync(tmpPath, JSON.stringify(rules, null, 2), { encoding: "utf-8", mode: 0o600 });
369
+ // EDITOR is a shell command by convention ("code --wait"), as git treats it; the file path goes
370
+ // in as "$1", an argument, never as text in the command.
371
+ const result = spawnSync("/bin/sh", ["-c", `${editor} "$1"`, "opscontext-editor", tmpPath], { stdio: "inherit" });
372
+ if (result.status !== 0) {
373
+ // Editor exited non-zero — keep the original list rather than risk a
374
+ // half-edited file.
375
+ return rules;
376
+ }
377
+ const parsed = JSON.parse(readFileSync(tmpPath, "utf-8"));
378
+ return Array.isArray(parsed) ? parsed : rules;
376
379
  }
377
380
  catch {
378
381
  return rules;
379
382
  }
380
383
  finally {
381
- try {
382
- // Best-effort cleanup; not a correctness issue if it lingers in $TMPDIR.
383
- execSync(`rm -f ${JSON.stringify(tmpPath)}`);
384
- }
385
- catch {
386
- /* ignore */
387
- }
384
+ rmSync(dir, { recursive: true, force: true });
388
385
  }
389
386
  }
390
387
  // ---------------------------------------------------------------------------
@@ -24,6 +24,9 @@
24
24
  import * as https from "https";
25
25
  import { Chunk } from "./ingest.js";
26
26
  export declare const TIER_A_URL = "https://raw.githubusercontent.com/FASTPROD/opscontext-community-rules/main/rules.json";
27
+ /** Ed25519 signature of rules.json's exact bytes, base64, by the pinned licence key.
28
+ * [LOCK] [COMMUNITY-TIER-A-IS-SIGNED] */
29
+ export declare const TIER_A_SIG_URL = "https://raw.githubusercontent.com/FASTPROD/opscontext-community-rules/main/rules.json.sig";
27
30
  export declare const TIER_B_URL = "https://api.compr.ch/contextengine/community-rules/fetch";
28
31
  export declare const STORE_PATH: string;
29
32
  export type CommunitySource = "tier-A-public" | "tier-B-pro";
@@ -28,11 +28,19 @@ import { createHash } from "crypto";
28
28
  import * as https from "https";
29
29
  import { URL } from "url";
30
30
  import { safeAppend } from "./audit.js";
31
- import { verifyLicenseSignature, } from "./license-sig.js";
31
+ import { verifyLicenseSignature, verifyDetachedSignature, } from "./license-sig.js";
32
32
  // ---------------------------------------------------------------------------
33
33
  // Constants
34
34
  // ---------------------------------------------------------------------------
35
35
  export const TIER_A_URL = "https://raw.githubusercontent.com/FASTPROD/opscontext-community-rules/main/rules.json";
36
+ /** Ed25519 signature of rules.json's exact bytes, base64, by the pinned licence key.
37
+ * [LOCK] [COMMUNITY-TIER-A-IS-SIGNED] */
38
+ export const TIER_A_SIG_URL = `${TIER_A_URL}.sig`;
39
+ // Caps on what any tier can put into every user's search results. [LOCK] [COMMUNITY-TIER-A-IS-SIGNED]
40
+ const MAX_RULES_PER_SOURCE = 500;
41
+ const MAX_RULE_CHARS = 500;
42
+ const MAX_CONTEXT_CHARS = 2000;
43
+ const MAX_TAGS = 10;
36
44
  export const TIER_B_URL = "https://api.compr.ch/contextengine/community-rules/fetch";
37
45
  export const STORE_PATH = join(homedir(), ".contextengine", "community-learnings.json");
38
46
  const STORE_DIR = join(homedir(), ".contextengine");
@@ -156,16 +164,20 @@ function normalizeIncoming(source, rawRules) {
156
164
  const now = new Date().toISOString();
157
165
  const out = [];
158
166
  for (const r of rawRules) {
167
+ if (out.length >= MAX_RULES_PER_SOURCE)
168
+ break;
159
169
  if (!isValidRuleShape(r))
160
170
  continue;
161
171
  const rec = r;
172
+ if (rec.rule.length > MAX_RULE_CHARS)
173
+ continue;
162
174
  out.push({
163
175
  id: rec.id,
164
176
  source,
165
177
  category: rec.category,
166
178
  rule: rec.rule,
167
- context: rec.context,
168
- tags: rec.tags,
179
+ context: rec.context.slice(0, MAX_CONTEXT_CHARS),
180
+ tags: rec.tags.slice(0, MAX_TAGS).map((t) => t.slice(0, 40)),
169
181
  project_cluster: typeof rec.project_cluster === "string"
170
182
  ? rec.project_cluster
171
183
  : undefined,
@@ -222,6 +234,32 @@ export async function syncTierA(opts = {}) {
222
234
  });
223
235
  return { fetched: 0, cached: true };
224
236
  }
237
+ // [LOCKED] [COMMUNITY-TIER-A-IS-SIGNED] - 2026-09-25
238
+ // [NEVER] accept tier A rules without a valid signature, or drop the caps in normalizeIncoming().
239
+ // WHY: tier A was plain JSON from a public GitHub repository, verified by nothing but TLS: whoever
240
+ // could write to that repository wrote into every user's search results. In a sandbox 5,001
241
+ // unsigned rules were accepted, one of 200 KB (E2E_REVIEW_2026-09 A6-4). Tier B was already
242
+ // signed and bound to the request ([COMMUNITY-SYNC-REPLAY-GUARD]).
243
+ // FIX: rules.json must come with rules.json.sig, an Ed25519 signature of its exact bytes by the
244
+ // pinned licence key (server/scripts/sign-community-rules.mjs, run where the private key
245
+ // lives); unsigned or mismatched rules are discarded and the cache kept. Every tier is capped
246
+ // (count, rule and context length, tags). The repository did not exist on 2026-09-25, so
247
+ // nothing was cut off.
248
+ let sigRes;
249
+ try {
250
+ sigRes = await http(TIER_A_SIG_URL, { method: "GET", headers: { "User-Agent": headers["User-Agent"], Accept: "text/plain" } });
251
+ }
252
+ catch (e) {
253
+ process.stderr.write(`[community-sync] Tier A signature fetch failed (${e instanceof Error ? e.message : String(e)}); using cached store.\n`);
254
+ safeAppend("community.sync_error", { tier: "A", reason: "signature_fetch_failed" });
255
+ return { fetched: 0, cached: true };
256
+ }
257
+ if (sigRes.statusCode !== 200 || !verifyDetachedSignature(Buffer.from(res.body, "utf8"), sigRes.body)) {
258
+ const reason = sigRes.statusCode === 200 ? "signature_invalid" : "unsigned";
259
+ process.stderr.write(`[community-sync] Tier A rules discarded (${reason}): they must be signed with the pinned key; using cached store.\n`);
260
+ safeAppend("community.sync_error", { tier: "A", reason });
261
+ return { fetched: 0, cached: true };
262
+ }
225
263
  let parsed;
226
264
  try {
227
265
  parsed = JSON.parse(res.body);
package/dist/config.d.ts CHANGED
@@ -54,6 +54,12 @@ export interface ContextEngineConfig {
54
54
  * Priority: env var > CWD > home dir
55
55
  */
56
56
  export declare function findConfigFile(): string | null;
57
+ /** Where the config came from: the env var, the current folder, or the home folder. A config in
58
+ * the current folder may belong to a downloaded repository. [LOCK] [ADAPTERS-ONLY-FROM-THE-USERS-OWN-CONFIG] */
59
+ export declare function findConfigFileWithOrigin(): {
60
+ path: string;
61
+ origin: "env" | "cwd" | "home";
62
+ } | null;
57
63
  /**
58
64
  * Load knowledge sources.
59
65
  *
package/dist/config.js CHANGED
@@ -29,14 +29,19 @@ const DEFAULT_PATTERNS = [
29
29
  * Priority: env var > CWD > home dir
30
30
  */
31
31
  export function findConfigFile() {
32
+ return findConfigFileWithOrigin()?.path ?? null;
33
+ }
34
+ /** Where the config came from: the env var, the current folder, or the home folder. A config in
35
+ * the current folder may belong to a downloaded repository. [LOCK] [ADAPTERS-ONLY-FROM-THE-USERS-OWN-CONFIG] */
36
+ export function findConfigFileWithOrigin() {
32
37
  const candidates = [];
33
38
  const envPath = process.env.CONTEXTENGINE_CONFIG;
34
39
  if (envPath) {
35
- candidates.push(resolve(envPath));
40
+ candidates.push({ path: resolve(envPath), origin: "env" });
36
41
  }
37
- candidates.push(resolve(process.cwd(), "contextengine.json"), resolve(homedir(), ".contextengine.json"));
42
+ candidates.push({ path: resolve(process.cwd(), "contextengine.json"), origin: "cwd" }, { path: resolve(homedir(), ".contextengine.json"), origin: "home" });
38
43
  for (const c of candidates) {
39
- if (existsSync(c))
44
+ if (existsSync(c.path))
40
45
  return c;
41
46
  }
42
47
  return null;