+25
-6
@@ -20,2 +20,3 @@ // Terminal front-end for `jamgate export` and `jamgate import` (D-033). | ||
| import { SHORT_LIFESPAN_MS } from "../gate/lifespan.js"; | ||
| import { detectSecret } from "../gate/secrets.js"; | ||
| import { ImportValidationError, parseImportFile } from "./parse.js"; | ||
@@ -206,2 +207,14 @@ import { isVendor, loadVendorSources, parseVendorExport, VendorImportError, VENDORS, } from "./vendor.js"; | ||
| * conversation logs were deliberately left alone. */ | ||
| /** A one-line, non-disclosing description of a record for CLI output (D-076): subject plus a | ||
| * short preview, and no preview at all when the text looks like it holds a credential. Import | ||
| * output is read by humans and captured by terminals and CI logs; it is not a place to reprint | ||
| * whatever is in the store. The import that REDACTED a password printed that password, and the | ||
| * medical record around it, because these lines echoed both texts in full. */ | ||
| function describeForLog(m) { | ||
| const subject = m.subject ? `"${m.subject}"` : "(no subject)"; | ||
| if (detectSecret(m.text)) | ||
| return `${subject} [content withheld: looks like a credential]`; | ||
| const preview = m.text.length > 60 ? `${m.text.slice(0, 57)}…` : m.text; | ||
| return `${subject} (${m.text.length} chars: ${JSON.stringify(preview)})`; | ||
| } | ||
| function vendorSummary(vendor, result) { | ||
@@ -232,4 +245,6 @@ const lines = [` source: ${vendor} export — read ${result.readFiles.join(", ")}`]; | ||
| if (o.action === "conflict") { | ||
| const trusted = (o.conflictsWith ?? []).map((m) => `"${m.text}" (${m.source})`).join(", "); | ||
| lines.push(` ⚠ conflict "${o.memory.text}" — subject "${o.memory.subject}" already held by a ` + | ||
| const trusted = (o.conflictsWith ?? []) | ||
| .map((m) => `${m.subject ?? "(no subject)"} (${m.source})`) | ||
| .join(", "); | ||
| lines.push(` ⚠ conflict ${describeForLog(o.memory)} — subject already held by a ` + | ||
| `more-trusted memory: ${trusted}. Not imported.`); | ||
@@ -239,9 +254,13 @@ } | ||
| const near = (o.possibleDuplicates ?? []) | ||
| .map((d) => `"${d.memory.text}" (~${d.similarity.toFixed(2)})`) | ||
| .map((d) => `${d.memory.subject ?? "(no subject)"} (~${d.similarity.toFixed(2)})`) | ||
| .join(", "); | ||
| lines.push(` ≈ near-dup "${o.memory.text}" looks like an existing memory: ${near}. Not imported.`); | ||
| lines.push(` ≈ near-dup ${describeForLog(o.memory)} looks like an existing memory: ${near}. Not imported.`); | ||
| } | ||
| else if (o.action === "superseded") { | ||
| const old = (o.retired ?? []).map((m) => `"${m.text}"`).join(", "); | ||
| lines.push(` ↻ superseded "${o.memory.text}" retired ${old}`); | ||
| // Print the SUBJECT and a short preview, never the whole record (D-076). This line used | ||
| // to echo both texts in full, which turned a routine import into a disclosure: running it | ||
| // to REDACT a credential printed the credential, and the medical record it lived in, onto | ||
| // the terminal and into whatever captured that output. | ||
| const old = (o.retired ?? []).map((m) => m.subject ?? "(no subject)").join(", "); | ||
| lines.push(` ↻ superseded ${describeForLog(o.memory)} — retired ${old || "(nothing)"}`); | ||
| } | ||
@@ -248,0 +267,0 @@ } |
@@ -9,2 +9,3 @@ // The shared save pipeline (D-049). | ||
| import { prefilter } from "./prefilter.js"; | ||
| import { detectSecret } from "./secrets.js"; | ||
| import { deriveSubject } from "./subject.js"; | ||
@@ -219,3 +220,6 @@ import { triage } from "./ambiguity.js"; | ||
| client: m.client?.name, | ||
| text: m.text, | ||
| // A deletion must not re-publish what it is deleting (D-076). D-042 already redacts a | ||
| // credential the prefilter refuses; the DELETE path wrote the record's full text into | ||
| // the same log, so removing a secret from the store copied it into the audit trail. | ||
| text: detectSecret(m.text) ? `[redacted: ${m.text.length} characters]` : m.text, | ||
| }, gateLog); | ||
@@ -222,0 +226,0 @@ } |
+80
-0
@@ -79,2 +79,75 @@ // Credential detection (gate layer 1, D-042). | ||
| const ASSIGNMENT = /\b(password|passwd|pwd|passphrase|api[ _-]?key|secret[ _-]?key|access[ _-]?token|auth[ _-]?token|client[ _-]?secret)\b(?:\s+(?:for|to|of|on)\b[^.;:!?]{0,60}?)?\s*(?::|=|==>|->|\bis\b|\bwas\b)\s*["'`]?(\S{6,})/i; | ||
| /** | ||
| * A credential stated by JUXTAPOSITION: the keyword, then the value, with NO separator at all | ||
| * (D-076). | ||
| * | ||
| * {@link ASSIGNMENT} requires a `:`/`=`/`is`/`was` between the keyword and the value, and that | ||
| * is how most people write one. It is not how they all write one. A real save reached the | ||
| * maintainer's production store reading "… PDF password 293083." — keyword, space, value, full | ||
| * stop — and walked through every layer: no separator for ASSIGNMENT to anchor on, and a | ||
| * six-digit value is far below the entropy rule's 20-character, three-character-class floor. | ||
| * The gate stored a live document password in a memory that syncs to every one of his agents. | ||
| * | ||
| * The value must not look like an ordinary word, or "password manager", "password policy" and | ||
| * "password rotates monthly" would all be refused and cost the user real memories. Requiring a | ||
| * digit or a symbol in the value is what separates "password 293083" from "password manager" | ||
| * while staying blind to nothing that matters: a credential with neither a digit nor a symbol | ||
| * anywhere in it is not a credential anyone sets. | ||
| */ | ||
| const JUXTAPOSED_KEYWORD = /\b(password|passwd|pwd|passphrase|pin code|access code)\b/gi; | ||
| /** | ||
| * Words that, immediately after the keyword, move the head noun off the credential itself. | ||
| * "password MANAGER is 1Password" is a fact about a product; "password POLICY requires 12 | ||
| * characters" is a fact about a rule. Neither states a secret, and both must survive — a | ||
| * credential layer that eats ordinary memories gets switched off, which protects nobody. | ||
| */ | ||
| const HEAD_REDIRECTS = new Set([ | ||
| "manager", "managers", "policy", "policies", "reset", "resets", "rotation", "hygiene", | ||
| "requirement", "requirements", "field", "fields", "prompt", "prompts", "box", "strength", | ||
| "protected", "protection", "recovery", "hint", "hints", "change", "changes", "expiry", | ||
| ]); | ||
| /** How many tokens after the keyword may still be introducing its value. */ | ||
| const JUXTAPOSED_WINDOW = 8; | ||
| /** Is this token credential-shaped rather than an ordinary word? */ | ||
| function looksLikeCredentialValue(v) { | ||
| if (v.length < 4 || v.length > 64) | ||
| return false; | ||
| // A plain alphabetic word ("manager", "yesterday", "required") is never a credential here. | ||
| if (/^[A-Za-z]+$/.test(v)) | ||
| return false; | ||
| // A bare small number is a count, not a secret: "requires 12 characters", "500 euros". | ||
| if (/^[0-9]{1,3}$/.test(v)) | ||
| return false; | ||
| return /[0-9]/.test(v) || /[^A-Za-z0-9]/.test(v); | ||
| } | ||
| /** | ||
| * A credential stated by JUXTAPOSITION: the keyword, then the value, with no separator at all | ||
| * (D-076). | ||
| * | ||
| * {@link ASSIGNMENT} requires a `:`/`=`/`is`/`was` between keyword and value, and that is how | ||
| * most people write one. It is not how they all write one. A real save reached the maintainer's | ||
| * production store reading "… PDF password <six digits>." — keyword, space, value, full stop — | ||
| * and walked through every layer: no separator for ASSIGNMENT to anchor on, and a six-digit | ||
| * value sits far below the entropy rule's 20-character, three-class floor. The gate stored a | ||
| * live document password in a memory that syncs to every one of his agents. | ||
| */ | ||
| function detectJuxtaposedCredential(text) { | ||
| JUXTAPOSED_KEYWORD.lastIndex = 0; | ||
| for (const m of text.matchAll(JUXTAPOSED_KEYWORD)) { | ||
| const after = text.slice((m.index ?? 0) + m[0].length); | ||
| // A sentence boundary ends the keyword's reach. | ||
| const clause = after.split(/[.;:!?\n]/, 1)[0] ?? ""; | ||
| const tokens = clause.split(/\s+/).filter(Boolean).slice(0, JUXTAPOSED_WINDOW); | ||
| if (tokens.length === 0) | ||
| continue; | ||
| if (HEAD_REDIRECTS.has(tokens[0].toLowerCase().replace(/[^a-z]/g, ""))) | ||
| continue; | ||
| for (const raw of tokens) { | ||
| const tok = raw.replace(/^["'`(\[]+/, "").replace(/["'`)\].,]+$/, ""); | ||
| if (looksLikeCredentialValue(tok)) | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| /** Minimum length for the entropy rule. Below this a token is too short to be a modern | ||
@@ -153,2 +226,9 @@ * credential and too likely to be an ordinary word or code identifier. */ | ||
| } | ||
| // Keyword followed by a credential-shaped value, with no separator (D-076). Every candidate | ||
| // between the keyword and the end of the clause is tested, not just the first: "the PDF | ||
| // password for the lab result 293083" puts three ordinary words before the value, and | ||
| // checking only the nearest token would clear the whole sentence on the word "the". | ||
| if (detectJuxtaposedCredential(text)) { | ||
| return { label: "a password stated next to its value" }; | ||
| } | ||
| if (CREDENTIAL_KEYWORDS.test(text)) { | ||
@@ -155,0 +235,0 @@ for (const token of candidateTokens(text)) { |
@@ -401,4 +401,38 @@ import { promises as fs } from "node:fs"; | ||
| const existing = memories.find((m) => live(m) && memScope(m) === scope && m.text.trim().toLowerCase() === norm); | ||
| if (existing) | ||
| return { action: "duplicate", memory: existing }; | ||
| if (existing) { | ||
| // SAME TEXT, DIFFERENT TYPE, IS A CORRECTION — NOT A DUPLICATE (D-076). | ||
| // | ||
| // `type` sets the lifespan, so re-saving the same fact under a different type is the one | ||
| // and only way a caller can repair a mis-typed memory. This check used to run first and | ||
| // compare text alone, so it refused precisely that repair: a durable fact filed as | ||
| // `state` with a 2-day TTL, re-saved seconds later as `identity`, came back "Already | ||
| // known (no duplicate added)" and kept its expiry. The user got it stored only by | ||
| // rewording the text until it slipped past this line, which is not a workflow anyone | ||
| // should have to discover. D-072 made the mis-type visible and then this refused the fix. | ||
| // | ||
| // Deliberately narrow: identical text AND identical type is still a duplicate, so | ||
| // repeated identical saves cannot churn the store. Only a real change of lifespan class | ||
| // gets through. | ||
| // BOTH types must be present and different. "Untyped → typed" and "typed → untyped" are | ||
| // ambiguous: a bulk vendor import carries types the source never had, and treating that | ||
| // as a correction would churn every already-known record on every re-import. The incident | ||
| // this fixes was `state` → `identity`, both stated deliberately by the caller. | ||
| const isTypeCorrection = candidate.type !== undefined && | ||
| existing.type !== undefined && | ||
| existing.type !== candidate.type; | ||
| if (!isTypeCorrection) | ||
| return { action: "duplicate", memory: existing }; | ||
| // The trust ladder still applies (RULES §2.3): a lower-trust source may not silently | ||
| // rewrite a higher-trust record's lifespan — that is a conflict for the user to resolve. | ||
| if (TRUST[candidate.source] < TRUST[existing.source]) { | ||
| return { action: "conflict", memory: candidate, conflictsWith: [existing] }; | ||
| } | ||
| candidate.subject = normalizeSubject(candidate.subject) ?? existing.subject; | ||
| existing.status = "superseded"; | ||
| existing.supersededBy = candidate.id; | ||
| existing.supersededAt = now; | ||
| existing.updatedAt = now; | ||
| memories.push(candidate); | ||
| return { action: "superseded", memory: candidate, retired: [existing] }; | ||
| } | ||
| // Same treatment for the subject as for the scope above (D-052): canonicalize once, stamp | ||
@@ -405,0 +439,0 @@ // it back, then compare canonical-to-canonical so a legacy record's separator spelling |
+1
-1
@@ -9,2 +9,2 @@ /** | ||
| */ | ||
| export const VERSION = "0.15.1"; | ||
| export const VERSION = "0.16.0"; |
+3
-3
| { | ||
| "name": "jamgate", | ||
| "version": "0.15.1", | ||
| "version": "0.16.0", | ||
| "mcpName": "io.github.amirj4m/jamgate", | ||
| "description": "A neutral, cross-agent memory quality gate for AI agents, delivered as an MCP server — a gate, not a store.", | ||
| "description": "A neutral, cross-agent memory quality gate for AI agents, delivered as an MCP server \u2014 a gate, not a store.", | ||
| "keywords": [ | ||
@@ -42,3 +42,3 @@ "mcp", | ||
| "pretest": "tsc && tsc -p tsconfig.test.json", | ||
| "test": "node --test dist-test/test/*.test.js", | ||
| "test": "JAMGATE_GATE_LOG=off node --test dist-test/test/*.test.js", | ||
| "prepublishOnly": "npm run build" | ||
@@ -45,0 +45,0 @@ }, |
+1
-1
@@ -1008,3 +1008,3 @@ # Jamgate | ||
| 600 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full | ||
| 619 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full | ||
| history is in [`CHANGELOG.md`](./CHANGELOG.md), and every non-obvious decision, including the | ||
@@ -1011,0 +1011,0 @@ ones I got wrong and reversed, is written up in [`DECISIONS.md`](./DECISIONS.md). |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
422854
2.1%7070
1.98%