From e494595d52d8ddeae88aa13cbdb3a97bac71902a Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 17:17:12 +0200 Subject: [PATCH 1/7] chore(lint): add .oxlintrc.json with type-aware rules Formalize the lint configuration that was previously running on oxlint defaults via the --type-aware flag. The config: - Activates type-aware rules (no-floating-promises, no-misused-promises, await-thenable, no-base-to-string, restrict-template-expressions). - Formalizes the repo no-explicit-any policy as a warning. - Disables noisy rules in test files where type strictness is less valuable (unbound-method, no-floating-promises, any). - Keeps correctness category as warnings, not errors, to preserve a green baseline while surfacing 364 items of technical debt for incremental cleanup. --- .oxlintrc.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .oxlintrc.json diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..0f570074 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,42 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "warn" + }, + "rules": { + "no-explicit-any": "warn", + "typescript/no-floating-promises": "warn", + "typescript/no-misused-promises": "warn", + "typescript/await-thenable": "warn", + "typescript/no-base-to-string": "warn", + "typescript/restrict-template-expressions": "warn", + "typescript/no-misused-spread": "warn", + "typescript/no-this-alias": "warn", + "no-unused-vars": "warn", + "no-unassigned-vars": "warn", + "no-dupe-keys": "warn", + "no-unused-expressions": "warn", + "no-misleading-character-class": "warn", + "no-control-regex": "warn", + "no-new-array": "off" + }, + "overrides": [ + { + "files": [ + "**/*.test.ts", + "**/*.e2e.test.ts", + "**/*.live.test.ts", + "**/test-helpers/**", + "**/test-utils/**" + ], + "rules": { + "typescript/no-floating-promises": "off", + "typescript/no-misused-promises": "off", + "typescript/unbound-method": "off", + "no-explicit-any": "off", + "typescript/no-base-to-string": "off", + "typescript/restrict-template-expressions": "off" + } + } + ] +} From 010ffea414c534589e37049849799701e71a42e2 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 17:19:51 +0200 Subject: [PATCH 2/7] docs(security): document dependency supply-chain policy Add a Dependency Supply Chain section to SECURITY.md covering the controls the project already enforces via package.json: - The 48-hour release age floor (minimumReleaseAge: 2880). - The rationale requirement for every pnpm.overrides entry, tied to a CVE or regression. - The hard rule that patchedDependencies must use exact versions. - Lockfile discipline: frozen-lockfile in CI, deps: commits for dependency changes, reviewed transitive bumps. - The onlyBuiltDependencies allowlist limiting native build scripts. This formalizes existing practice as written policy so maintainers and contributors have a single source of truth for dependency trust decisions. --- SECURITY.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index 784bdf96..fb315a71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -123,6 +123,56 @@ docker run --read-only --cap-drop=ALL \ mayros/mayros:latest ``` +## Dependency Supply Chain + +Mayros treats its dependency tree as part of its trust boundary. A malicious or +broken transitive package reaches users the moment it enters the lockfile, so the +project enforces the following controls in `package.json`: + +### Release age floor + +`pnpm.minimumReleaseAge: 2880` (48 hours) gates every resolution. A package +published less than 48 hours ago is not eligible for install, which buffers +against compromised or yanked releases that get quarantined shortly after +publish. + +### Forced security overrides + +The `pnpm.overrides` map forces specific versions of transitive dependencies +known to carry security regressions. Each override exists because a transitive +in the tree resolved to a vulnerable version and the override is the most +durable way to pin the patched floor without waiting for upstreams to bump. + +When adding or changing an override, record why in the commit message: the CVE +or GHSA it addresses (or the regression it prevents). Overrides without a +recorded rationale are rejected in review. + +### Patched dependencies + +Any dependency declared in `pnpm.patchedDependencies` must use an exact version +(no `^` or `~`). This is a hard rule: a range on a patched package lets a future +resolve drift past the patch, silently reverting the fix. + +### Lockfile discipline + +The npm/pnpm lockfile pins the full transitive tree by content hash. Treat the +lockfile as a reviewed artifact, not an auto-generated one: + +- A change to `pnpm-lock.yaml` without a corresponding dependency change in + `package.json` is suspicious and must be explained in the commit. +- Dependabot/Renovate PRs that bump a transitive without a recorded CVE/fix are + held until the change is understood. +- `pnpm install --frozen-lockfile` is the canonical install command in CI; + `pnpm install` (which can mutate the lockfile) is reserved for local + dependency changes that are committed alongside a `deps:` commit. + +### Native build scripts + +`pnpm.onlyBuiltDependencies` enumerates the packages allowed to run native +build scripts (node-gyp, prebuilt binaries). Packages not in this list cannot +execute install scripts, which limits the blast radius of a compromised native +dependency. + ## Security Scanning This project uses `detect-secrets` for automated secret detection in CI/CD. From d26e0218a957e6dfbc71e6e9d6d124984e607214 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 17:21:29 +0200 Subject: [PATCH 3/7] =?UTF-8?q?docs(security):=20add=20trust=20model=20?= =?UTF-8?q?=E2=80=94=20OS=20isolation=20as=20the=20load-bearing=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Trust Model section that states the project's governing security principle: operating-system level isolation is the only load-bearing boundary, and every in-process defense (approval gate, output redaction, scanners, rate limiters, the WASM sandbox) is a heuristic that reduces attack surface but does not contain a determined adversary with in-process code execution. The section classifies real boundaries (separate users, containers, VMs, namespaces, seccomp, Landlock) against in-process defenses, and gives concrete implications for operators running untrusted skills and plugins. This sharpens the existing Plugin Trust Boundary section, which now reads as a specific case of the general model. --- SECURITY.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index fb315a71..74786ce3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,6 +57,48 @@ Mayros security guidance assumes: - Anyone who can modify `~/.mayros` state/config (including `mayros.json`) is effectively a trusted operator. - A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. +## Trust Model + +The single load-bearing security boundary in Mayros is **operating-system level +isolation**. Everything else is a heuristic or a convenience, not a containment +boundary. + +This distinction matters because it is easy to over-trust in-process defenses. +The rule below governs how each layer should be treated: + +### What is and is not a boundary + +- **Real boundaries** — mechanisms the OS or kernel enforces against a + malicious process running inside them: + - Separate OS users, containers, VMs, namespaces, seccomp, Landlock. + - A sandboxed Docker container with `--cap-drop=ALL --read-only`. + - A separate host entirely (the strongest option). +- **In-process defenses** — useful, but **not containment**. A sufficiently + resourced attacker who has code execution inside the process can bypass them: + - The approval gate and interactive permission prompts. + - Output redaction and secret masking (`src/security/output-masking.ts`). + - The static skill scanner and bash command blocklist. + - Rate limiters, query/write caps, and the enrichment timeout. + - The WASM skill sandbox (it narrows the attack surface of untrusted skill + code, but the host process can still be reached via bugs in the runtime). + +Treat the in-process defenses as **defense in depth and attack-surface +reduction**, never as the thing that stops a determined adversary. The thing +that stops a determined adversary is running the untrusted workload under a +real OS boundary. + +### Practical implications + +- For fully untrusted skills, plugins, or tool output, run the Gateway (or the + session) inside a container or separate OS user. Do not rely on the approval + gate alone. +- The default loopback-only bind is a real boundary against remote network + attackers; keep it unless you have a replacement boundary (Tailscale, SSH + tunnel, firewall). +- When reviewing a security finding, classify it by which boundary it crosses. + A bug that lets a plugin read another plugin's memory is contained by the OS + user; a bug that lets a remotely-reached endpoint execute code is not. + ## Plugin Trust Boundary Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code. From f2da992691d031f5499a7bb36124ea8062b2f092 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 17:25:16 +0200 Subject: [PATCH 4/7] ci: enforce lockfile change policy Add a CI step on pull requests that fails when pnpm-lock.yaml changes without a deps-scoped commit or a corresponding manifest change (package.json, pnpm-workspace.yaml). This implements the lockfile discipline documented in SECURITY.md as an automated gate rather than relying on review alone. The check is implemented as scripts/check-lockfile-policy.mjs so it can also run locally (node scripts/check-lockfile-policy.mjs --base ) before pushing. On CI it only runs on pull_request events, where the base ref is well-defined. --- .github/workflows/ci.yml | 6 ++ scripts/check-lockfile-policy.mjs | 134 ++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 scripts/check-lockfile-policy.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a856ab70..8a769815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,8 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + # Full history so the lockfile policy check can read commit messages. + fetch-depth: 0 submodules: false - name: Setup Node environment @@ -114,6 +116,10 @@ jobs: - name: Check types, lint, and format run: pnpm check + - name: Enforce lockfile change policy + if: github.event_name == 'pull_request' + run: node scripts/check-lockfile-policy.mjs --base "origin/${{ github.base_ref }}" --head "${{ github.event.pull_request.head.sha }}" + build-artifacts: needs: [docs-scope, changed-scope, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') diff --git a/scripts/check-lockfile-policy.mjs b/scripts/check-lockfile-policy.mjs new file mode 100644 index 00000000..59e95005 --- /dev/null +++ b/scripts/check-lockfile-policy.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Lockfile change policy check. + * + * Enforces the dependency supply-chain policy documented in SECURITY.md: + * a change to pnpm-lock.yaml should arrive alongside a dependency change in + * package.json, and the commit carrying it should be scoped as `deps:`. + * + * The check compares the lockfile against a base. On CI it reads the base + * from the GITHUB_BASE_REF / commit range; locally it can be invoked with + * --base to run the same gate before pushing. + * + * Exit codes: + * 0 — policy satisfied (or lockfile unchanged) + * 1 — policy violated (lockfile changed without a deps-scoped commit) + * 2 — unable to determine the range (soft-fail with a warning) + */ +import { execSync } from "node:child_process"; +import process from "node:process"; + +const LOCKFILE = "pnpm-lock.yaml"; +const DEPS_SCOPE = /^deps:/; +const DEPS_FILES = new Set(["package.json", "pnpm-workspace.yaml"]); + +function parseArgs(argv) { + const args = { base: null, head: null }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--base") args.base = argv[++i]; + else if (a === "--head") args.head = argv[++i]; + else if (a === "-h" || a === "--help") { + process.stdout.write( + "Usage: check-lockfile-policy.mjs [--base ] [--head ]\n" + + " Checks that pnpm-lock.yaml changes arrive in deps-scoped commits.\n", + ); + process.exit(0); + } + } + return args; +} + +function run(cmd) { + return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function tryRun(cmd) { + try { + return run(cmd); + } catch { + return null; + } +} + +function resolveRange(args) { + const head = args.head || process.env.GITHUB_SHA || "HEAD"; + let base = args.base; + if (!base) { + if (process.env.GITHUB_BASE_REF) { + base = `origin/${process.env.GITHUB_BASE_REF}`; + } else if (process.env.GITHUB_EVENT_BEFORE) { + base = process.env.GITHUB_EVENT_BEFORE; + } else { + // local default: compare against the default branch tip + base = + tryRun("git rev-parse --verify origin/main >/dev/null 2>&1 && echo origin/main") || null; + } + } + return { base, head }; +} + +function changedFiles(base, head) { + const out = tryRun(`git diff --name-only ${base}..${head}`); + if (!out) return null; + return new Set(out.split(/\r?\n/).filter(Boolean)); +} + +function commitSubjects(base, head) { + const out = tryRun(`git log --format=%s ${base}..${head}`); + if (!out) return []; + return out.split(/\r?\n/).filter(Boolean); +} + +function main() { + const args = parseArgs(process.argv); + const { base, head } = resolveRange(args); + + if (!base) { + console.warn("[lockfile-policy] could not determine a base ref; skipping."); + process.exit(2); + } + + // Make sure the base is locally resolvable (CI shallow clones). + const resolvedBase = tryRun(`git rev-parse --verify "${base}^{commit}"`); + if (!resolvedBase) { + const fetched = tryRun(`git fetch --no-tags --depth=1 origin ${base}`); + if (!fetched === false) { + console.warn(`[lockfile-policy] base ref ${base} not resolvable; skipping.`); + process.exit(2); + } + } + + const files = changedFiles(base, head); + if (!files) { + console.warn("[lockfile-policy] could not compute diff; skipping."); + process.exit(2); + } + + if (!files.has(LOCKFILE)) { + console.log(`[lockfile-policy] ${LOCKFILE} unchanged — policy satisfied.`); + process.exit(0); + } + + // Lockfile changed. Require either a deps-scoped commit or a change to a + // recognized dependency manifest in the same range. + const subjects = commitSubjects(base, head); + const hasDepsScope = subjects.some((s) => DEPS_SCOPE.test(s)); + const hasManifestChange = [...files].some((f) => DEPS_FILES.has(f)); + + if (hasDepsScope || hasManifestChange) { + console.log(`[lockfile-policy] ${LOCKFILE} changed with deps-scoped commit — OK.`); + process.exit(0); + } + + console.error( + `[lockfile-policy] ${LOCKFILE} changed but no commit in range is scoped "deps:" ` + + `and no manifest (${[...DEPS_FILES].join(", ")}) changed.\n` + + `Commit subjects in range:\n` + + subjects.map((s) => ` - ${s}`).join("\n") + + '\nEither re-scope the commit to start with "deps:" or explain the lockfile drift.', + ); + process.exit(1); +} + +main(); From f49188b9d4abf1e2133d4dc5a51cb1d4979c8999 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 17:40:32 +0200 Subject: [PATCH 5/7] refactor: replace all non-test as-any casts with proper types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove every `as any` escape hatch in production code (the 20 occurrences flagged by no-explicit-any once the rule was formalized). Each replacement uses a concrete type: - mattermost/channel.ts: params is already Record, so the casts were unnecessary — access the fields directly with typeof narrowing. - agent-mesh/index.ts: model the on-disk mayros.json and auth-profiles shapes with structural types instead of Record; forward the heterogeneous Mamoru gateway handlers through a typed callable. - feishu/bot.ts: narrow the Lark SDK response with a minimal FeishuUserResponse type; widen the logger signature to unknown[]. - feishu/docx.ts: introduce a FeishuBlock structural type for the document blocks used by sortBlocksByFirstLevel, with a type-guard filter. - mcp-server/index.ts: type registerCli's program as Command and the dashboard respond callback with its structural signature. Also extend the oxlint test override to *.test-helpers.ts so test fixtures are not held to production type strictness. --- .oxlintrc.json | 1 + extensions/agent-mesh/index.ts | 41 +++++++++----- extensions/feishu/src/bot.ts | 15 ++++-- extensions/feishu/src/docx.ts | 11 ++-- extensions/mattermost/src/channel.ts | 12 ++--- extensions/mcp-server/index.ts | 81 +++++++++++++++------------- 6 files changed, 99 insertions(+), 62 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f570074..cda82e50 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -26,6 +26,7 @@ "**/*.test.ts", "**/*.e2e.test.ts", "**/*.live.test.ts", + "**/*.test-helpers.ts", "**/test-helpers/**", "**/test-utils/**" ], diff --git a/extensions/agent-mesh/index.ts b/extensions/agent-mesh/index.ts index 24ba5bf6..76eb5f12 100644 --- a/extensions/agent-mesh/index.ts +++ b/extensions/agent-mesh/index.ts @@ -2222,7 +2222,12 @@ const agentMeshPlugin = { for (const [name, handler] of Object.entries(methods)) { api.registerGatewayMethod(name, async ({ params, respond }) => { try { - const result = await handler((params ?? {}) as any); + // Handlers in the Mamoru gateway map are heterogeneous (each + // declares its own params shape). The runtime contract is that + // params arrive validated by the gateway dispatcher, so we forward + // the resolved object typed as the handler's first parameter. + const fn = handler as (input: Record) => Promise; + const result = await fn((params ?? {}) as Record); respond(true, result); } catch (err) { respond(false, { error: err instanceof Error ? err.message : String(err) }); @@ -2277,21 +2282,28 @@ const agentMeshPlugin = { const nodeFsP = await import("node:fs/promises"); const home = process.env.HOME ?? ""; - // Update mayros.json with model + ollama auth profile + // Update mayros.json with model + ollama auth profile. The config is + // read from disk and mutated, so we model the shape we touch rather + // than widening to Record. + type MayrosJsonFile = { + agents?: { defaults?: { model?: { primary?: string; [k: string]: unknown } } }; + auth?: { profiles?: Record }; + [k: string]: unknown; + }; const configPath = nodePath.join(home, ".mayros", "mayros.json"); - let cfg: Record = {}; + let cfg: MayrosJsonFile = {}; try { - cfg = JSON.parse(await nodeFsP.readFile(configPath, "utf8")); + cfg = JSON.parse(await nodeFsP.readFile(configPath, "utf8")) as MayrosJsonFile; } catch { /* new */ } - if (!cfg.agents) cfg.agents = {}; - if (!cfg.agents.defaults) cfg.agents.defaults = {}; - if (!cfg.agents.defaults.model) cfg.agents.defaults.model = {}; + cfg.agents ??= {}; + cfg.agents.defaults ??= {}; + cfg.agents.defaults.model ??= {}; cfg.agents.defaults.model.primary = p.model; if (p.provider === "local" && p.model.startsWith("ollama/")) { - if (!cfg.auth) cfg.auth = {}; - if (!cfg.auth.profiles) cfg.auth.profiles = {}; + cfg.auth ??= {}; + cfg.auth.profiles ??= {}; cfg.auth.profiles["ollama"] = { provider: "ollama", mode: "api_key" }; } await nodeFsP.writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n"); @@ -2301,13 +2313,18 @@ const agentMeshPlugin = { const agentDir = nodePath.join(home, ".mayros", "agents", "main", "agent"); await nodeFsP.mkdir(agentDir, { recursive: true }); const storePath = nodePath.join(agentDir, "auth-profiles.json"); - let store: Record = { version: 2, profiles: {} }; + type AuthProfileStore = { + version?: number; + profiles?: Record; + [k: string]: unknown; + }; + let store: AuthProfileStore = { version: 2, profiles: {} }; try { - store = JSON.parse(await nodeFsP.readFile(storePath, "utf8")); + store = JSON.parse(await nodeFsP.readFile(storePath, "utf8")) as AuthProfileStore; } catch { /* new */ } - if (!store.profiles) store.profiles = {}; + store.profiles ??= {}; store.profiles["ollama"] = { provider: "ollama", type: "api_key", key: "ollama-local" }; await nodeFsP.writeFile(storePath, JSON.stringify(store, null, 2)); } diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 95af28eb..83215694 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -86,7 +86,7 @@ type SenderNameResult = { async function resolveFeishuSenderName(params: { account: ResolvedFeishuAccount; senderOpenId: string; - log: (...args: any[]) => void; + log: (...args: unknown[]) => void; }): Promise { const { account, senderOpenId, log } = params; if (!account.configured) return {}; @@ -100,10 +100,19 @@ async function resolveFeishuSenderName(params: { const client = createFeishuClient(account); // contact/v3/users/:user_id?user_id_type=open_id - const res: any = await client.contact.user.get({ + // The Feishu SDK returns an untyped response envelope; narrow the fields + // we actually read instead of widening the whole value to `any`. + type FeishuUser = { + name?: string; + display_name?: string; + nickname?: string; + en_name?: string; + }; + type FeishuUserResponse = { data?: { user?: FeishuUser } }; + const res = (await client.contact.user.get({ path: { user_id: senderOpenId }, params: { user_id_type: "open_id" }, - }); + })) as FeishuUserResponse; const name: string | undefined = res?.data?.user?.name || diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 52f90dbb..f4f5d048 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -93,11 +93,16 @@ async function convertMarkdown(client: Lark.Client, markdown: string) { }; } -function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] { +/** Minimal structural shape of a Feishu/Lark document block used for sorting. */ +type FeishuBlock = { block_id?: string; [key: string]: unknown }; + +function sortBlocksByFirstLevel(blocks: FeishuBlock[], firstLevelIds: string[]): FeishuBlock[] { if (!firstLevelIds || firstLevelIds.length === 0) return blocks; - const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean); + const sorted = firstLevelIds + .map((id) => blocks.find((b) => b.block_id === id)) + .filter((b): b is FeishuBlock => Boolean(b)); const sortedIds = new Set(firstLevelIds); - const remaining = blocks.filter((b) => !sortedIds.has(b.block_id)); + const remaining = blocks.filter((b) => !sortedIds.has(b.block_id ?? "")); return [...sorted, ...remaining]; } diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index ed979461..37ce4591 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -67,23 +67,23 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { } const postIdRaw = - typeof (params as any)?.messageId === "string" - ? (params as any).messageId - : typeof (params as any)?.postId === "string" - ? (params as any).postId + typeof params.messageId === "string" + ? params.messageId + : typeof params.postId === "string" + ? params.postId : ""; const postId = postIdRaw.trim(); if (!postId) { throw new Error("Mattermost react requires messageId (post id)"); } - const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : ""; + const emojiRaw = typeof params.emoji === "string" ? params.emoji : ""; const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, ""); if (!emojiName) { throw new Error("Mattermost react requires emoji"); } - const remove = (params as any)?.remove === true; + const remove = params.remove === true; if (remove) { const result = await removeMattermostReaction({ cfg, diff --git a/extensions/mcp-server/index.ts b/extensions/mcp-server/index.ts index 65efa511..299f8016 100644 --- a/extensions/mcp-server/index.ts +++ b/extensions/mcp-server/index.ts @@ -15,6 +15,7 @@ // @ts-expect-error — dist/index.js has no declaration file; types resolved via source paths import type { MayrosPluginApi, MayrosPluginToolContext } from "@apilium/mayros"; +import type { Command } from "commander"; import { mcpServerConfigSchema, type McpServerConfig } from "./config.js"; import { McpServer, type McpServerOptions } from "./server.js"; import type { AdaptableTool } from "./tool-adapter.js"; @@ -142,7 +143,7 @@ const mcpServerPlugin = { // ── Register CLI ──────────────────────────────────────────────── - api.registerCli(({ program }: { program: any }) => { + api.registerCli(({ program }: { program: Command }) => { const serve = program .command("serve") .description("Start MCP server to expose Mayros tools, resources, and prompts"); @@ -303,45 +304,49 @@ const mcpServerPlugin = { // ── Register gateway method (MCP Dashboard) ──────────────────── - api.registerGatewayMethod("mcp.dashboard", async ({ respond }: { respond: any }) => { - // Cortex health check with 3s timeout — always runs - let cortexHealth: { status: "online" | "offline"; latencyMs: number }; - try { - const cortexPort = cfg.cortex?.port ?? 19090; - const cortexUrl = `http://127.0.0.1:${cortexPort}/api/v1/health`; - const start = Date.now(); - const cortexRes = await fetch(cortexUrl, { - signal: AbortSignal.timeout(3000), - }); - const latencyMs = Date.now() - start; - cortexHealth = { - status: cortexRes.ok ? "online" : "offline", - latencyMs, - }; - } catch { - cortexHealth = { status: "offline", latencyMs: 0 }; - } + type DashboardRespond = (ok: boolean, payload?: unknown) => void; + api.registerGatewayMethod( + "mcp.dashboard", + async ({ respond }: { respond: DashboardRespond }) => { + // Cortex health check with 3s timeout — always runs + let cortexHealth: { status: "online" | "offline"; latencyMs: number }; + try { + const cortexPort = cfg.cortex?.port ?? 19090; + const cortexUrl = `http://127.0.0.1:${cortexPort}/api/v1/health`; + const start = Date.now(); + const cortexRes = await fetch(cortexUrl, { + signal: AbortSignal.timeout(3000), + }); + const latencyMs = Date.now() - start; + cortexHealth = { + status: cortexRes.ok ? "online" : "offline", + latencyMs, + }; + } catch { + cortexHealth = { status: "offline", latencyMs: 0 }; + } - if (!server) { - respond(true, { - status: { - running: false, - transport: cfg.transport ?? "http", - toolCount: 0, - initialized: false, - uptimeMs: 0, - sseSessionCount: 0, - }, - metrics: null, - cortexHealth, - }); - return; - } + if (!server) { + respond(true, { + status: { + running: false, + transport: cfg.transport ?? "http", + toolCount: 0, + initialized: false, + uptimeMs: 0, + sseSessionCount: 0, + }, + metrics: null, + cortexHealth, + }); + return; + } - const status = server.status(); - const metrics = server.getMetrics(); - respond(true, { status, metrics, cortexHealth }); - }); + const status = server.status(); + const metrics = server.getMetrics(); + respond(true, { status, metrics, cortexHealth }); + }, + ); // ── Register service lifecycle ────────────────────────────────── From 9212ff3e75c4ea17bc75ef25d0f4ef556ad6c186 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 17:55:02 +0200 Subject: [PATCH 6/7] feat(agents): prompt cache discipline + system-prompt drift detection Formalize the project's prompt-cache invariant and add a drift signal to the cache trace so cache-breaking changes become visible. AGENTS.md: - Add a Prompt Cache Discipline section stating the governing principle: the system prompt must stay stable across runs that have no reason to change it, slash commands that mutate state invalidate only on the next turn, and hooks must be deterministic. This makes the contract explicit for contributors. cache-trace.ts: - Track the last system digest per session key in a module-level map. - When wrapStreamFn sees a different system digest for the same session on a consecutive call, emit a new cache:system-drift event carrying both the current and previous digests. This turns silent cache invalidations (which multiply token cost) into a diagnosable signal. - Propagate previousSystemDigest through the event pipeline. cache-trace.test.ts: - Unit tests covering first-call (no drift), drift on change, stability across repeated calls, and independent per-session tracking. --- AGENTS.md | 10 +++ src/agents/cache-trace.test.ts | 118 +++++++++++++++++++++++++++++++++ src/agents/cache-trace.ts | 39 ++++++++++- 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/agents/cache-trace.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7a543e1d..6f603a8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,16 @@ - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Naming: use **Mayros** for product/app/docs headings; use `mayros` for CLI command, package/binary, paths, and config keys. +## Prompt Cache Discipline + +Per-conversation prompt caching is sacred. A long-lived session reuses a cached prefix on every turn; anything that mutates past context, swaps toolsets, or rebuilds the system prompt with volatile content invalidates that cache and multiplies token cost. The project enforces caching via the `cacheRetention` stream option (handled by the provider layer), and the cost of an invalidation is real and silent. + +- The system prompt is rebuilt on every run. Its content must be **stable across runs that have no reason to change it**. Do not add volatile data (timestamps that tick every second, random IDs, unordered maps) to the system prompt or to context files that feed it. The `Runtime:` line is the sanctioned place for run-scoped info and is designed to change only when the operator changes model, channel, or thinking level. +- Slash commands that mutate prompt-relevant state (`/model`, `/think`, `/reasoning`, `/elevated`, `/fast`, `/new`) are expected to invalidate the cache on the **next** turn by design. That is the only acceptable invalidation. A change that invalidates the cache mid-turn, or on every turn regardless of whether the operator touched anything, is a bug. +- When adding context to the prompt (new sections, hook output, injected memory), prefer **append-only** structure. Reordering existing sections or inserting in the middle invalidates everything after the insertion point. +- Hooks (`before_prompt_build`, `before_agent_start`) that override `systemPrompt` or `prependContext` must be deterministic for a given input. A hook that returns different text on consecutive calls with the same inputs breaks the cache and is a high-priority bug. +- The `diagnostics.cacheTrace` subsystem records a SHA-256 of the system prompt and messages at each pipeline stage. When investigating cache regressions, compare `systemDigest` across consecutive turns of a session where nothing should have changed — a drift there is the signature of a cache-breaking change. + ## Release Channels (Naming) - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts new file mode 100644 index 00000000..c6edcaab --- /dev/null +++ b/src/agents/cache-trace.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { MayrosConfig } from "../config/config.js"; +import { createCacheTrace } from "./cache-trace.js"; + +/** + * cache-trace drift detection unit tests. + * + * These cover the system-prompt drift signal added to wrapStreamFn: when the + * same session sees a different system digest between consecutive stream calls, + * a `cache:system-drift` event is emitted so cache-breaking changes become + * visible instead of silently inflating token cost. + */ + +type TraceLine = { stage: string; systemDigest?: string; previousSystemDigest?: string }; + +function makeTrace(sessionKey: string, lines: TraceLine[]) { + return createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + includeSystem: true, + }, + }, + } as MayrosConfig, + env: {}, + sessionKey, + writer: { + filePath: "memory", + write: (line: string) => { + const parsed = JSON.parse(line) as TraceLine; + lines.push(parsed); + }, + }, + }); +} + +// Minimal StreamFn stub: returns an empty async iterable and records nothing. +function noopStreamFn() { + return (async function* () { + /* empty */ + })(); +} + +// The wrapper invokes streamFn(model, context, options). Build a minimal model +// stub and a typed context so tsgo accepts the call without constructing a full +// Model (which carries many fields irrelevant to drift detection). +type StreamCallContext = { system?: unknown; messages: unknown[] }; +function callWrapped( + wrapped: ReturnType>["wrapStreamFn"]> | undefined, + system: unknown, +) { + const model = { id: "m", provider: "anthropic" } as unknown as Parameters< + NonNullable + >[0]; + const context = { system, messages: [] } as StreamCallContext as Parameters< + NonNullable + >[1]; + wrapped?.(model, context, {}); +} + +describe("cache-trace drift detection", () => { + it("does not emit a drift event on the first stream call for a session", () => { + const lines: TraceLine[] = []; + const trace = makeTrace("sess-first", lines); + const wrapped = trace?.wrapStreamFn(noopStreamFn as never); + + callWrapped(wrapped, "system-A"); + + const drifts = lines.filter((l) => l.stage === "cache:system-drift"); + expect(drifts).toHaveLength(0); + }); + + it("emits a drift event when the system prompt changes between two calls of the same session", () => { + const lines: TraceLine[] = []; + const trace = makeTrace("sess-drift", lines); + const wrapped = trace?.wrapStreamFn(noopStreamFn as never); + + callWrapped(wrapped, "system-A"); + callWrapped(wrapped, "system-B"); + + const drifts = lines.filter((l) => l.stage === "cache:system-drift"); + expect(drifts).toHaveLength(1); + expect(drifts[0]?.previousSystemDigest).toBeDefined(); + expect(drifts[0]?.systemDigest).toBeDefined(); + expect(drifts[0]?.previousSystemDigest).not.toBe(drifts[0]?.systemDigest); + }); + + it("does not emit a drift event when the system prompt stays stable across calls", () => { + const lines: TraceLine[] = []; + const trace = makeTrace("sess-stable", lines); + const wrapped = trace?.wrapStreamFn(noopStreamFn as never); + + callWrapped(wrapped, "same-system"); + callWrapped(wrapped, "same-system"); + callWrapped(wrapped, "same-system"); + + const drifts = lines.filter((l) => l.stage === "cache:system-drift"); + expect(drifts).toHaveLength(0); + }); + + it("tracks sessions independently", () => { + const linesA: TraceLine[] = []; + const linesB: TraceLine[] = []; + const traceA = makeTrace("sess-a", linesA); + const traceB = makeTrace("sess-b", linesB); + + // Session A and B both start with the same system, then A changes. + callWrapped(traceA?.wrapStreamFn(noopStreamFn as never), "shared-system"); + callWrapped(traceB?.wrapStreamFn(noopStreamFn as never), "shared-system"); + callWrapped(traceA?.wrapStreamFn(noopStreamFn as never), "changed-system"); + + const driftsA = linesA.filter((l) => l.stage === "cache:system-drift"); + const driftsB = linesB.filter((l) => l.stage === "cache:system-drift"); + expect(driftsA).toHaveLength(1); + expect(driftsB).toHaveLength(0); + }); +}); diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index cc9a6786..9da1fc3d 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -15,6 +15,7 @@ export type CacheTraceStage = | "prompt:before" | "prompt:images" | "stream:context" + | "cache:system-drift" | "session:after"; export type CacheTraceEvent = { @@ -38,6 +39,8 @@ export type CacheTraceEvent = { messageFingerprints?: string[]; messagesDigest?: string; systemDigest?: string; + /** Previous system digest for the same session, when a drift is detected. */ + previousSystemDigest?: string; note?: string; error?: string; }; @@ -74,6 +77,14 @@ type CacheTraceWriter = QueuedFileWriter; const writers = new Map(); +/** + * Last system digest seen per session key, so we can detect when the system + * prompt drifts between consecutive turns of the same session. A drift with no + * operator-triggered change (model, thinking, channel) is the signature of a + * cache-breaking bug. Keyed by sessionKey (or sessionId as fallback). + */ +const lastSystemDigestBySession = new Map(); + function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig { const env = params.env ?? process.env; const config = params.cfg?.diagnostics?.cacheTrace; @@ -222,6 +233,9 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { if (payload.error) { event.error = payload.error; } + if (payload.previousSystemDigest !== undefined) { + event.previousSystemDigest = payload.previousSystemDigest; + } const line = safeJsonStringify(event); if (!line) { @@ -232,16 +246,37 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { const wrapStreamFn: CacheTrace["wrapStreamFn"] = (streamFn) => { const wrapped: StreamFn = (model, context, options) => { + const system = (context as { system?: unknown }).system; + const messages = (context as { messages?: AgentMessage[] }).messages ?? []; recordStage("stream:context", { model: { id: model?.id, provider: model?.provider, api: model?.api, }, - system: (context as { system?: unknown }).system, - messages: (context as { messages?: AgentMessage[] }).messages ?? [], + system, + messages, options: (options ?? {}) as Record, }); + + // Detect system-prompt drift across turns of the same session. A + // different digest with no operator-triggered change (model/thinking) + // is the signature of a cache-breaking bug, per the Prompt Cache + // Discipline policy in AGENTS.md. + if (system !== undefined && params.sessionKey) { + const currentDigest = digest(system); + const previous = lastSystemDigestBySession.get(params.sessionKey); + if (previous !== undefined && previous !== currentDigest) { + recordStage("cache:system-drift", { + system, + systemDigest: currentDigest, + previousSystemDigest: previous, + note: "system prompt changed between turns without a session restart; investigate if no operator change triggered this", + }); + } + lastSystemDigestBySession.set(params.sessionKey, currentDigest); + } + return streamFn(model, context, options); }; return wrapped; From 499cd764228cba8526225fe0f9f62bc0e4fe71f6 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 18:02:58 +0200 Subject: [PATCH 7/7] docs: TUI redesign audit and roadmap Add docs/platforms/tui-redesign.md capturing the current state of the terminal UI, the gaps found during an internal audit, and a prioritized P0/P1/P2 roadmap for the next redesign pass. The audit covers rendering, input, slash commands, themes, vim mode, accessibility, and platform coverage. It documents the architectural constraints (shared terminal UI dependency as rendering floor, mandatory gateway, mutable state, two parallel TUI implementations, two palette systems) that bound the redesign, and calls out two P0 items that block trustworthy feature work: verifying the vim bridge is actually wired, and bringing the accessibility TUI to feature parity. --- docs/platforms/tui-redesign.md | 109 +++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/platforms/tui-redesign.md diff --git a/docs/platforms/tui-redesign.md b/docs/platforms/tui-redesign.md new file mode 100644 index 00000000..edc609c2 --- /dev/null +++ b/docs/platforms/tui-redesign.md @@ -0,0 +1,109 @@ +--- +title: TUI Redesign Roadmap +description: Audit of the current terminal UI and prioritized plan for reaching a polished, best-in-class experience. +--- + +# TUI Redesign Roadmap + +This document captures the current state of the Mayros terminal UI (`mayros code` / `mayros tui`), the gaps identified during an internal audit, and a prioritized roadmap for the next redesign pass. It is a planning artifact; implementation is tracked separately. + +## Current state at a glance + +The TUI is a gateway client: it renders in the terminal and talks to a running `mayros gateway` daemon over WebSocket. The rendering, input, and component layer comes from the shared terminal UI dependency; Mayros adds the slash-command surface, themes, vim mode, mouse scrolling, accessibility fallback, and the gateway chat client. + +| Area | Maturity | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Rendering and streaming | Mature. Event-driven render loop, full assistant-message re-render on each delta. | +| Slash commands | Mature in count (40+ built-in), uneven in depth. Core session commands are first-class; ecosystem commands are prompt-injection shims. | +| Themes | Mature. Ten palettes with 22 color tokens each, live hot-swap. | +| Vim mode | Basic. Custom implementation; operators partially stubbed, no visual or command-line mode. | +| Accessibility | Split. A linear text-mode TUI exists but is a separate, feature-poor implementation. | +| Platform coverage | Gaps. Clipboard image paste and `/copy` work on macOS and Linux only; Windows and Wayland are unsupported. | +| Architecture | Workable but strained. Two monolithic files (`tui.ts`, `tui-command-handlers.ts`), a mutable god-object state, and two parallel palette systems. | + +## Architectural constraints + +These shape what a redesign can and cannot do without larger upstream work. + +- **The terminal UI dependency is the rendering floor.** Layout capabilities (vertical-stack containers, select-list overlays) bound what Mayros can render. The tightest couplings are a `CustomEditor` that subclasses the shared editor and a `ChatLog` that subclasses the shared container. Refactoring either to composition requires an adapter layer or upstream changes. +- **The gateway is mandatory.** The TUI cannot run without a reachable gateway daemon. There is no in-process fallback, which affects offline use and dev ergonomics. +- **Theme hot-swap relies on singleton mutation.** Active theme objects are mutated in place so existing references stay live. Any snapshotting or caching of theme values would break live switching. +- **Mutable state object.** The TUI threads a single state object through every handler with manual render requests. There is no reactive store, although the signal libraries used elsewhere in the repo are available as a foundation. +- **Two parallel TUI implementations.** The graphical TUI and the accessibility TUI share almost no code. Every new feature currently needs to be implemented twice or skipped in one of them. +- **Two palette systems.** Terminal output (onboarding, prompts, CLI tables) and the TUI use separate palette modules with different accent colors. Visual consistency across the CLI experience requires unifying them. + +## Gaps by category + +### Rendering + +- No inline image rendering despite the shared terminal dependency shipping the capability. Image content blocks currently render as text placeholders. +- The diff renderer hardcodes colors instead of consuming the active theme, so diffs look wrong under light or high-contrast themes. +- Only vertical-stack layout; no split panes or resizable regions beyond the select-list overlay. +- Full assistant-text re-render on every streaming delta; long messages may degrade. + +### Input + +- Vim operator-plus-motion combinations are a simplified stub (non-linewise motions collapse to single-character operations). No visual mode, no command-line mode, no search, no repeat, no registers beyond a single yank buffer. +- The vim bridge does not appear to be wired from the TUI side; motions may silently no-op if the bridge is never connected. This needs verification before any vim work. +- Ctrl+C does not abort the active run; only Escape does. This is surprising and should align with user expectations. +- A cross-session input history store exists in code but is not connected to the main loop. + +### Commands + +- Ecosystem commands (`/team`, `/tasks`, `/workflow`, `/rules`, `/mailbox`, `/sync`, `/trace`, `/kg`, `/plan`) inject a prompt that asks the model to call a tool, rather than being first-class features. Results depend on the model. +- Several commands lack argument autocomplete. +- The theme preset list is duplicated in two files and can drift. +- The README command list is stale relative to the implementation. + +### Themes + +- The terminal palette and the TUI palette are separate modules with different accent colors. +- No user-defined custom themes. +- No colorblind-optimized palettes beyond the existing high-contrast option. +- No explicit `NO_COLOR` or `FORCE_COLOR` handling in the TUI. + +### Accessibility + +- The accessibility TUI is a stripped-down parallel implementation: no slash commands beyond exit, no session switching, no abort, no model selection. It excludes screen-reader users from most functionality. +- No screen-reader announcements in the graphical TUI. +- No monochrome or no-color mode in the graphical TUI. + +### Robustness and platform + +- Clipboard image paste and `/copy` support macOS and Linux (xclip) only. Windows and Wayland are unsupported. +- Several silent `catch` blocks swallow errors without logging. +- Disconnect handling shows hints but does not auto-reconnect with backoff. +- Window resize handling is delegated entirely to the shared terminal dependency and is not verified end-to-end in Mayros. + +## Roadmap + +Priorities are assigned as P0 (correctness or blocker), P1 (high-value polish), and P2 (nice to have). + +### P0 — Correctness and blockers + +1. **Verify the vim bridge is connected.** If the editor bridge is never set from the TUI side, every vim motion that relies on it silently no-ops. Confirm or fix before any further vim work. +2. **Accessibility TUI feature parity.** Either linearize the graphical renderer for screen readers or bring slash commands, session switching, and abort into the accessibility TUI. The current split excludes users from most functionality. + +### P1 — High-value polish + +3. **Inline image rendering.** Use the shared terminal image component to render image content blocks inline instead of text placeholders. +4. **Unify the palettes.** Merge the terminal palette and the TUI palette into one source of truth; make the diff renderer and every component consume the active theme. +5. **Complete vim mode.** Implement real operator-plus-motion semantics and add visual mode, or adopt a proven keymap pattern. +6. **Windows and Wayland clipboard support.** Add the platform-native clipboard commands for `/copy` and image paste. +7. **Make Ctrl+C abort the active run** with a double-press-to-exit fallback, keeping Escape as a secondary abort. +8. **Refresh the README** to reflect the actual theme count and command list. + +### P2 — Nice to have + +9. Terminal color-scheme auto-detection to pick dark or light theme automatically. +10. Wire the cross-session input history store into the main loop, or remove the dead code. +11. User-defined custom themes loaded from a config directory. +12. Argument autocomplete for every command; deduplicate the theme preset list. +13. Colorblind-optimized palettes and a true monochrome mode. +14. Incremental streaming render for long assistant messages. +15. Replace silent error swallowing with structured logging behind a debug flag. +16. Split the two monolithic TUI files to bring them under the file-size guideline. + +## Next steps + +The first implementation pass should focus on the P0 items, since both block a trustworthy feature surface: vim cannot be extended on top of an unwired bridge, and the accessibility TUI cannot ship as the screen-reader path while it lacks core commands. The P1 items are the bulk of the visible polish work and can be sequenced independently once the P0 verifications land.