The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
@@ -0,0 +1,172 @@
|
||||
// Persistent memory, after Hermes Agent (tools/memory_tool.py, tools/memory_tool_store.py, MIT,
|
||||
// © Nous Research): two small files in ~/.config/lembas/memory/, MEMORY.md (the agent's notes)
|
||||
// and USER.md (who the user is), entries separated by a line holding only "§". Both go into the
|
||||
// system prompt as a snapshot taken when a session starts; writes during the session reach the
|
||||
// file but not the prompt, so the prompt stays the same (and cacheable) all session.
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { paths } from "../config/paths.ts"
|
||||
import { threatMessage } from "./threats.ts"
|
||||
|
||||
export type Target = "memory" | "user"
|
||||
export const DELIMITER = "\n§\n"
|
||||
export const DEFAULT_LIMITS: Record<Target, number> = { memory: 2200, user: 1375 }
|
||||
/** One fact per entry, and a short one (LLeMbas's MAX_MEMORY_CHARS): a long entry is several facts,
|
||||
* or a note that belongs in a skill or the project's records. */
|
||||
export const MAX_ENTRY = 400
|
||||
const FILES: Record<Target, string> = { memory: "MEMORY.md", user: "USER.md" }
|
||||
const HEADERS: Record<Target, string> = { memory: "MEMORY (your personal notes)", user: "USER PROFILE (who the user is)" }
|
||||
|
||||
export interface Op {
|
||||
action: "add" | "replace" | "remove"
|
||||
content?: string
|
||||
old_text?: string
|
||||
}
|
||||
|
||||
export interface Outcome {
|
||||
ok: boolean
|
||||
message: string
|
||||
/** On a failure the model can fix by consolidating: what is there now. */
|
||||
entries?: string[]
|
||||
usage: string
|
||||
}
|
||||
|
||||
export const memoryDir = () => join(paths.config, "memory")
|
||||
|
||||
export function parseEntries(raw: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
return raw
|
||||
.replace(/^/, "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split(/\n[ \t]*§[ \t]*\n/)
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e && e !== "§" && !seen.has(e) && seen.add(e))
|
||||
}
|
||||
|
||||
/** An entry `old` identifies: an exact match first, else the one entry containing it. */
|
||||
export function findEntry(entries: string[], old: string): { index?: number; ambiguous?: boolean } {
|
||||
const exact = entries.findIndex((e) => e === old)
|
||||
if (exact >= 0) return { index: exact }
|
||||
const hits = entries.map((e, i) => (e.includes(old) ? i : -1)).filter((i) => i >= 0)
|
||||
if (hits.length > 1) return { ambiguous: true }
|
||||
return hits.length ? { index: hits[0] } : {}
|
||||
}
|
||||
|
||||
export class MemoryStore {
|
||||
constructor(
|
||||
private limits: Record<Target, number> = DEFAULT_LIMITS,
|
||||
private dir = memoryDir(),
|
||||
/** Headings in the system prompt, where they are not the global files' (a project's memory). */
|
||||
private headers: Partial<Record<Target, string>> = {},
|
||||
) {}
|
||||
|
||||
file(t: Target) {
|
||||
return join(this.dir, FILES[t])
|
||||
}
|
||||
|
||||
entries(t: Target): string[] {
|
||||
const f = this.file(t)
|
||||
return existsSync(f) ? parseEntries(readFileSync(f, "utf8")) : []
|
||||
}
|
||||
|
||||
private size = (entries: string[]) => entries.join(DELIMITER).length
|
||||
|
||||
usage(t: Target, entries = this.entries(t)): string {
|
||||
const n = this.size(entries)
|
||||
const limit = this.limits[t]
|
||||
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
|
||||
}
|
||||
|
||||
private save(t: Target, entries: string[]) {
|
||||
const f = this.file(t)
|
||||
const tmp = `${f}.${process.pid}.tmp`
|
||||
writeFileSync(tmp, entries.length ? entries.join(DELIMITER) + "\n" : "")
|
||||
renameSync(tmp, f)
|
||||
}
|
||||
|
||||
/** Two sessions share these files: a change is read, made and written under a lock file, so one
|
||||
* cannot write over the other's. A lock older than 10 s is somebody who died holding it. */
|
||||
private locked<T>(t: Target, fn: () => T): T {
|
||||
mkdirSync(this.dir, { recursive: true })
|
||||
const lock = `${this.file(t)}.lock`
|
||||
const until = Date.now() + 5000
|
||||
let fd: number | undefined
|
||||
while (fd === undefined) {
|
||||
try {
|
||||
fd = openSync(lock, "wx")
|
||||
} catch {
|
||||
try {
|
||||
if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, { force: true })
|
||||
} catch {}
|
||||
if (Date.now() > until) break
|
||||
Bun.sleepSync(20)
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
closeSync(fd)
|
||||
rmSync(lock, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply operations all together; the size limit is checked on the result only, so one call can
|
||||
* make room and add. Nothing is written unless every operation succeeds. */
|
||||
apply(t: Target, ops: Op[]): Outcome {
|
||||
return this.locked(t, () => this.applyNow(t, ops))
|
||||
}
|
||||
|
||||
private applyNow(t: Target, ops: Op[]): Outcome {
|
||||
const before = this.entries(t)
|
||||
const work = [...before]
|
||||
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: this.usage(t, before), ...(show ? { entries: before } : {}) })
|
||||
for (const [i, op] of ops.entries()) {
|
||||
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
|
||||
const content = op.content?.trim() ?? ""
|
||||
if (op.action !== "remove") {
|
||||
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
|
||||
const threat = threatMessage(content, "The entry")
|
||||
if (threat) return fail(`${at}${threat}`)
|
||||
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
|
||||
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
|
||||
}
|
||||
if (op.action === "add") {
|
||||
if (!work.includes(content)) work.push(content)
|
||||
continue
|
||||
}
|
||||
if (!op.old_text?.trim())
|
||||
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
|
||||
const hit = findEntry(work, op.old_text.trim())
|
||||
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
|
||||
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
|
||||
if (op.action === "remove") work.splice(hit.index, 1)
|
||||
else work[hit.index] = content
|
||||
}
|
||||
const limit = this.limits[t]
|
||||
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
|
||||
// still goes through, so "forget X" always works.
|
||||
if (this.size(work) > limit && this.size(work) > this.size(before))
|
||||
return fail(
|
||||
`${t === "user" ? "The user profile" : "Memory"} would be ${this.size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
|
||||
true,
|
||||
)
|
||||
const unique = [...new Set(work)]
|
||||
if (unique.join("\u0000") !== before.join("\u0000")) this.save(t, unique)
|
||||
return { ok: true, message: ops.length > 1 ? `Applied ${ops.length} operations.` : ops[0]!.action === "add" && before.includes(ops[0]!.content!.trim()) ? "That entry already exists; nothing added." : `Entry ${ops[0]!.action === "add" ? "added" : ops[0]!.action === "replace" ? "replaced" : "removed"}.`, usage: this.usage(t, unique) }
|
||||
}
|
||||
|
||||
/** The system-prompt block for one target, or "" when it is empty. */
|
||||
block(t: Target): string {
|
||||
const entries = this.entries(t)
|
||||
if (!entries.length) return ""
|
||||
const bar = "═".repeat(46)
|
||||
return `${bar}\n${this.headers[t] ?? HEADERS[t]} [${this.usage(t, entries)}]\n${bar}\n${entries.join(DELIMITER)}`
|
||||
}
|
||||
|
||||
/** Both blocks, as they stand now: taken once per session. */
|
||||
snapshot(): string {
|
||||
return [this.block("user"), this.block("memory")].filter(Boolean).join("\n\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Prompt-injection and exfiltration patterns for text that ends up in the system prompt: memory
|
||||
// entries and skills. Ported from Hermes Agent (tools/threat_patterns.py, MIT, © Nous Research).
|
||||
// Patterns anchor on attack vocabulary, not bossy English: "you must" is normal in an AGENTS.md.
|
||||
|
||||
const F = String.raw`(?:\w+\s+){0,8}` // bounded filler between key words
|
||||
const SECRET_VAR = String.raw`\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b`
|
||||
const MODIFY = String.raw`(update|modify|edit|write|change|append|add\s+to)\s+[^\n]{0,2048}`
|
||||
|
||||
/** "all": everywhere. "context": text not written by the user (files, tool results). "strict":
|
||||
* writes the user can resolve (memory, skills). Inclusion is cumulative: strict checks all. */
|
||||
export type Scope = "all" | "context" | "strict"
|
||||
|
||||
const PATTERNS: [string, string, Scope][] = [
|
||||
[String.raw`ignore\s+${F}(previous|all|above|prior)\s+${F}instructions`, "prompt_injection", "all"],
|
||||
[String.raw`system\s+prompt\s+override`, "sys_prompt_override", "all"],
|
||||
[String.raw`disregard\s+${F}(your|all|any)\s+${F}(instructions|rules|guidelines)`, "disregard_rules", "all"],
|
||||
[String.raw`act\s+as\s+(if|though)\s+${F}you\s+${F}(have\s+no|don't\s+have)\s+${F}(restrictions|limits|rules)`, "bypass_restrictions", "all"],
|
||||
[String.raw`<!--[^>]{0,512}(?:ignore|override|system|secret|hidden)[^>]{0,512}-->`, "html_comment_injection", "all"],
|
||||
[String.raw`<\s*div\s+style\s*=\s*["'][^>]{0,2048}display\s*:\s*none`, "hidden_div", "all"],
|
||||
[String.raw`translate\s+[^\n]{0,512}\s+into\s+\w+(?:[\s-]+\w+){0,2}\s+and\s+(execute|run|eval)\b`, "translate_execute", "all"],
|
||||
[String.raw`do\s+not\s+${F}tell\s+${F}the\s+user`, "deception_hide", "all"],
|
||||
[String.raw`you\s+are\s+${F}now\s+(?:a|an|the)\s+`, "role_hijack", "context"],
|
||||
[String.raw`pretend\s+${F}(you\s+are|to\s+be)\s+`, "role_pretend", "context"],
|
||||
[String.raw`output\s+${F}(system|initial)\s+prompt`, "leak_system_prompt", "context"],
|
||||
[String.raw`(respond|answer|reply)\s+without\s+${F}(restrictions|limitations|filters|safety)`, "remove_filters", "context"],
|
||||
[String.raw`you\s+have\s+been\s+${F}(updated|upgraded|patched)\s+to`, "fake_update", "context"],
|
||||
[String.raw`\bname\s+yourself\s+\w+`, "identity_override", "context"],
|
||||
[String.raw`register\s+(as\s+)?a?\s*node`, "c2_node_registration", "context"],
|
||||
[String.raw`(heartbeat|beacon|check[\s\-]?in)\s+(to|with)\s+`, "c2_heartbeat", "context"],
|
||||
[String.raw`pull\s+(down\s+)?(?:new\s+)?task(?:ing|s)?\b`, "c2_task_pull", "context"],
|
||||
[String.raw`connect\s+to\s+the\s+network\b`, "c2_network_connect", "context"],
|
||||
[String.raw`you\s+must\s+(?:\w+\s+){0,3}(register|connect|report|beacon)\b`, "forced_action", "context"],
|
||||
[String.raw`only\s+use\s+one[\s\-]?liners?\b`, "anti_forensic_oneliner", "context"],
|
||||
[String.raw`never\s+${F}(?:create|write)\s+${F}(?:script|file)\s+${F}disk`, "anti_forensic_disk", "context"],
|
||||
[String.raw`unset\s+\w*(?:CLAUDE|CODEX|HERMES|LEMBAS|AGENT|OPENAI|ANTHROPIC)\w*`, "env_var_unset_agent", "context"],
|
||||
[String.raw`\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b`, "known_c2_framework", "context"],
|
||||
[String.raw`\bc2\s+(?:server|channel|infrastructure|beacon)\b`, "c2_explicit", "context"],
|
||||
[String.raw`\bcommand\s+and\s+control\b`, "c2_explicit_long", "context"],
|
||||
[String.raw`curl\s+[^\n]{0,2048}${SECRET_VAR}`, "exfil_curl", "all"],
|
||||
[String.raw`wget\s+[^\n]{0,2048}${SECRET_VAR}`, "exfil_wget", "all"],
|
||||
[String.raw`cat\s+[^\n]{0,2048}(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)`, "read_secrets", "all"],
|
||||
[String.raw`(send|post|upload|transmit)\s+[^\n]{0,2048}\s+(to|at)\s+https?://`, "send_to_url", "strict"],
|
||||
[String.raw`(include|output|print|share)\s+${F}(conversation|chat\s+history|previous\s+messages|full\s+context|entire\s+context)`, "context_exfil", "strict"],
|
||||
[String.raw`authorized_keys`, "ssh_backdoor", "strict"],
|
||||
[String.raw`(?:\b(?:echo|cat|cp|mv|dd|tee|install|printf|rsync|scp|ln|append|add|write|sed|chmod|chown|truncate|rm|touch|curl|wget|git)\b|\bopen\s*\(|>>?)[^\n]{0,512}(?:\$HOME/\.ssh|~/\.ssh)`, "ssh_access", "strict"],
|
||||
[String.raw`${MODIFY}(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)`, "agent_config_mod", "strict"],
|
||||
[String.raw`${MODIFY}(?:lembas/)?(connections\.yaml|config\.yaml|SOUL\.md)`, "lembas_config_mod", "strict"],
|
||||
]
|
||||
|
||||
const INCLUDES: Record<Scope, Scope[]> = { all: ["all"], context: ["all", "context"], strict: ["all", "context", "strict"] }
|
||||
// Python's \w (what Hermes wrote these for) is Unicode; JavaScript's is ASCII, so "ignore všetky
|
||||
// previous instructions" slipped through. A letter is any letter.
|
||||
const COMPILED = PATTERNS.map(([re, id, scope]) => ({ re: new RegExp(re.replaceAll(String.raw`\w`, String.raw`[\p{L}\p{N}_]`), "iu"), id, scope }))
|
||||
// A value that names an environment variable (MY_APP_PASSWORD) says where a secret lives; it is
|
||||
// not one. Hermes does this with a case-sensitive inline group, which JavaScript lacks.
|
||||
const SECRET = /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["']([A-Za-z0-9+/=_-]{20,})/gi
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/
|
||||
|
||||
export const INVISIBLE = new Set("")
|
||||
// As much as anything scanned may hold (a skill file is up to 100,000 characters).
|
||||
const MAX_SCAN = 131_072
|
||||
|
||||
/** The ids of every pattern `text` matches in `scope`. */
|
||||
export function scanThreats(text: string, scope: Scope = "context"): string[] {
|
||||
if (!text) return []
|
||||
text = text.slice(0, MAX_SCAN)
|
||||
// Invisible characters on the raw text: NFKC can remove them.
|
||||
const found = [...new Set(text)].filter((c) => INVISIBLE.has(c)).map((c) => `invisible_unicode_U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`)
|
||||
const norm = text.normalize("NFKC")
|
||||
const scopes = INCLUDES[scope]
|
||||
for (const p of COMPILED) if (scopes.includes(p.scope) && p.re.test(norm)) found.push(p.id)
|
||||
if (scope === "strict") for (const m of norm.matchAll(SECRET)) if (!ENV_NAME.test(m[1]!)) found.push("hardcoded_secret")
|
||||
return [...new Set(found)]
|
||||
}
|
||||
|
||||
/** A refusal for the first threat found, or undefined. */
|
||||
export function threatMessage(text: string, what = "Content"): string | undefined {
|
||||
const [first] = scanThreats(text, "strict")
|
||||
if (!first) return undefined
|
||||
if (first.startsWith("invisible_unicode_")) return `Blocked: ${what.toLowerCase()} contains the invisible character ${first.slice(18)} (a common injection carrier).`
|
||||
return `Blocked: ${what.toLowerCase()} matches the threat pattern "${first}". It goes into the system prompt of every session, so it must not carry instructions aimed at the agent or anything that leaks secrets.`
|
||||
}
|
||||
Reference in new issue
Block a user