Sign In

jamgate

Package Overview
Dependencies
Maintainers
1
Versions
38
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

jamgate - npm Package Compare versions

Comparing version
0.15.1
to
0.16.0
+25
-6
dist/backup/cli.js

@@ -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 @@ }

@@ -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";
{
"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 @@ },

@@ -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).