@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.
- package/CHANGELOG.md +170 -1
- package/dist/activation.d.ts +23 -1
- package/dist/activation.js +112 -21
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +6 -5
- package/dist/audit.d.ts +70 -3
- package/dist/audit.js +664 -82
- package/dist/ce-home.d.ts +7 -0
- package/dist/ce-home.js +45 -0
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +128 -23
- package/dist/collectors.d.ts +3 -0
- package/dist/collectors.js +37 -6
- package/dist/community-export.js +19 -22
- package/dist/community-sync.d.ts +3 -0
- package/dist/community-sync.js +41 -3
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -3
- package/dist/firewall.js +7 -3
- package/dist/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- package/dist/framing.d.ts +2 -0
- package/dist/framing.js +13 -0
- package/dist/http-server.d.ts +34 -5
- package/dist/http-server.js +246 -57
- package/dist/index.js +105 -35
- package/dist/install-autostart.js +38 -17
- package/dist/install-claude-hook.d.ts +1 -0
- package/dist/install-claude-hook.js +135 -27
- package/dist/learnings.d.ts +1 -0
- package/dist/learnings.js +18 -1
- package/dist/license-sig.d.ts +9 -2
- package/dist/license-sig.js +35 -7
- package/dist/secret-shapes.d.ts +19 -0
- package/dist/secret-shapes.js +30 -2
- package/dist/server-registry.d.ts +8 -0
- package/dist/server-registry.js +38 -2
- package/dist/trusted-projects.d.ts +10 -0
- package/dist/trusted-projects.js +77 -0
- package/package.json +2 -2
|
@@ -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
|
package/dist/ce-home.js
ADDED
|
@@ -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
|
package/dist/cli-commands.js
CHANGED
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1736
|
-
|
|
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(`
|
|
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);
|
package/dist/collectors.d.ts
CHANGED
|
@@ -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
|
package/dist/collectors.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
package/dist/community-export.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/community-sync.d.ts
CHANGED
|
@@ -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";
|
package/dist/community-sync.js
CHANGED
|
@@ -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;
|