From 11b1dcfd7d3134fc7b26308f749b4b3064b64bdd Mon Sep 17 00:00:00 2001 From: TheStreamCode Date: Sun, 27 Sep 2026 21:51:36 +0200 Subject: [PATCH] fix(security): minimize cached credentials, separate config from transport (v0.1.1) writeCache persists only apiKey/accountId/email; the OAuth access token stays in memory (nothing ever read it back). Credential-source resolution moves to network-free src/config.ts; auth.ts is transport-only with explicit args. Fixes the ClawHub suspicious.env_credential_access heuristic at its root and documents the credential story in README. Also fixes the login script dist import path. --- CHANGELOG.md | 17 +++++++++++++++ README.md | 14 ++++++++++++ dist/index.js | 7 +++--- dist/src/auth.js | 31 +++++++++++++------------- dist/src/config.js | 21 ++++++++++++++++++ index.ts | 7 +++--- package-lock.json | 4 ++-- package.json | 2 +- scripts/muse-code-login.mjs | 4 ++-- src/auth.ts | 43 +++++++++++++++++++++---------------- src/config.ts | 24 +++++++++++++++++++++ test/auth.test.ts | 18 +++++++++++++--- 12 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 dist/src/config.js create mode 100644 src/config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e93aec6..93ca083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1] - 2026-09-27 + +### Security + +- The credential cache no longer persists the Meta OAuth access token + (only `apiKey`, `accountId`, `email`); nothing ever read it back. + Existing caches are harmless but can be refreshed with `npm run login`. +- Credential-source resolution (env names, cache path) moved to a + network-free `src/config.ts`; `src/auth.ts` is transport-only with + explicit arguments. + +### Fixed + +- `scripts/muse-code-login.mjs` imported `../dist/auth.js` instead of + `../dist/src/auth.js` and crashed at startup; fixed and covered by + running `--help` in verification. + ## [0.1.0] - 2026-09-27 ### Added diff --git a/README.md b/README.md index 1510038..47bdb78 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,20 @@ export MUSE_CODE_SUB_TOKEN= Or run onboarding and choose **Muse Code**. +## Security & credentials + +- The cache file (`~/.openclaw/muse-code-sub.json`, override with + `MUSE_CODE_SUB_CREDENTIALS`) stores exactly three fields: the + subscription `apiKey`, the `accountId`, and the account `email`. + The Meta OAuth access token from the login flow is **never written + to disk** (kept in memory only for the key exchange, then dropped). +- The cache file is created with owner-only permissions (`0600` where + supported). The login script never prints secret material. +- Network allowlist: this plugin talks only to `auth.meta.com` + (device-code login) and `api.meta.ai` (key minting + inference), + both hardcoded — no configurable endpoints, no third parties. +- Report vulnerabilities privately per [SECURITY.md](./SECURITY.md). + ## Compatibility - OpenClaw gateway `>=2026.7.1` (built and tested against `2026.9.5`). diff --git a/dist/index.js b/dist/index.js index 3b520e0..e0a03be 100644 --- a/dist/index.js +++ b/dist/index.js @@ -15,11 +15,12 @@ * intentionally distinct from the official `meta` id so both can coexist. */ import { defineSingleProviderPluginEntry, } from "openclaw/plugin-sdk/provider-entry"; -import { ENV_VAR, readCacheSync } from "./src/auth.js"; +import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js"; +import { readCacheSync } from "./src/auth.js"; import { museCodeBaselineModels } from "./src/baseline.models.js"; // Resolution order: explicit env wins, else the login cache. Silent miss. -if (!process.env[ENV_VAR]?.trim()) { - const cached = readCacheSync(); +if (!explicitToken()) { + const cached = readCacheSync(defaultCachePath()); if (cached) process.env[ENV_VAR] = cached; } diff --git a/dist/src/auth.js b/dist/src/auth.js index 6193fb1..169e4d8 100644 --- a/dist/src/auth.js +++ b/dist/src/auth.js @@ -1,17 +1,17 @@ -// Shared Meta device-login logic (no third-party CLI). +// Meta device-login transport (no third-party CLI). // Flow parameters are compatible with the published behavior of oh-my-pi // (MIT-licensed; see NOTICE). -import { homedir } from "node:os"; -import { join, dirname } from "node:path"; +// +// This module performs network requests but never reads the environment: +// credential sources (env names, cache paths) live in config.ts and are +// passed in explicitly by callers. +import { dirname } from "node:path"; import { readFileSync } from "node:fs"; import { readFile, writeFile, mkdir, chmod } from "node:fs/promises"; export const CLIENT_ID = "1031625952748946"; export const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/"; export const TOKEN_URL = "https://auth.meta.com/oidc/device/token/"; export const KEY_URL = "https://api.meta.ai/muse-code/key"; -export const ENV_VAR = "MUSE_CODE_SUB_TOKEN"; -export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS"; -export const CREDENTIALS_FILENAME = "muse-code-sub.json"; const HEADERS = { Accept: "application/json", "x-api-version": "1.0.0", @@ -19,13 +19,7 @@ const HEADERS = { const REQUEST_TIMEOUT_MS = 25_000; const MIN_INTERVAL_S = 1; const SLOW_DOWN_STEP_S = 5; -export function defaultCachePath() { - const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim(); - if (override) - return override; - return join(homedir(), ".openclaw", CREDENTIALS_FILENAME); -} -export async function readCache(path = defaultCachePath()) { +export async function readCache(path) { try { const blob = (await readFile(path, "utf8").then(JSON.parse)); return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : ""; @@ -35,7 +29,7 @@ export async function readCache(path = defaultCachePath()) { } } /** Sync cache read for module-load key resolution (silent miss on any failure). */ -export function readCacheSync(path = defaultCachePath()) { +export function readCacheSync(path) { try { const blob = JSON.parse(readFileSync(path, "utf8")); return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : ""; @@ -44,9 +38,14 @@ export function readCacheSync(path = defaultCachePath()) { return ""; } } -export async function writeCache(credentials, path = defaultCachePath()) { +export async function writeCache(credentials, path) { + // Retention minimization: persist only what inference needs. The OAuth + // access token has unknown broader scope and nothing reads it back, so it + // must never touch disk — sanitize at the sink, whatever callers pass in. + const { apiKey, accountId, email } = credentials; + const cached = { apiKey, accountId, ...(email ? { email } : {}) }; await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(credentials, null, 2)); + await writeFile(path, JSON.stringify(cached, null, 2)); try { await chmod(path, 0o600); } diff --git a/dist/src/config.js b/dist/src/config.js new file mode 100644 index 0000000..283c2b4 --- /dev/null +++ b/dist/src/config.js @@ -0,0 +1,21 @@ +// Credential-source resolution: env names and cache-path policy. +// +// Deliberately network-free: this module never performs requests and never +// handles secret material beyond reading configuration. Transport lives in +// auth.ts and receives explicit arguments, so each side is easy to audit +// in isolation. +import { homedir } from "node:os"; +import { join } from "node:path"; +export const ENV_VAR = "MUSE_CODE_SUB_TOKEN"; +export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS"; +export const CREDENTIALS_FILENAME = "muse-code-sub.json"; +/** Explicitly configured token (highest priority), or "" when unset. */ +export function explicitToken() { + return (process.env[ENV_VAR] || "").trim(); +} +export function defaultCachePath() { + const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim(); + if (override) + return override; + return join(homedir(), ".openclaw", CREDENTIALS_FILENAME); +} diff --git a/index.ts b/index.ts index 4b16413..1c6bc72 100644 --- a/index.ts +++ b/index.ts @@ -23,12 +23,13 @@ import type { ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-types"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; -import { ENV_VAR, readCacheSync } from "./src/auth.js"; +import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js"; +import { readCacheSync } from "./src/auth.js"; import { museCodeBaselineModels } from "./src/baseline.models.js"; // Resolution order: explicit env wins, else the login cache. Silent miss. -if (!process.env[ENV_VAR]?.trim()) { - const cached = readCacheSync(); +if (!explicitToken()) { + const cached = readCacheSync(defaultCachePath()); if (cached) process.env[ENV_VAR] = cached; } diff --git a/package-lock.json b/package-lock.json index 3916abb..00331ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thestreamcode/openclaw-muse-code", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thestreamcode/openclaw-muse-code", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "devDependencies": { "openclaw": "2026.9.5", diff --git a/package.json b/package.json index 5105b24..adb0744 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thestreamcode/openclaw-muse-code", - "version": "0.1.0", + "version": "0.1.1", "description": "Muse Spark in OpenClaw billed to the Muse Code monthly subscription (Meta device login, no API key)", "type": "module", "license": "MIT", diff --git a/scripts/muse-code-login.mjs b/scripts/muse-code-login.mjs index 97c917c..11e0f2f 100644 --- a/scripts/muse-code-login.mjs +++ b/scripts/muse-code-login.mjs @@ -13,8 +13,8 @@ import { pollToken, mintKey, writeCache, - defaultCachePath, -} from "../dist/auth.js"; +} from "../dist/src/auth.js"; +import { defaultCachePath } from "../dist/src/config.js"; const args = process.argv.slice(2); let cache = null; diff --git a/src/auth.ts b/src/auth.ts index 01c8c1d..0bccfc8 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,9 +1,12 @@ -// Shared Meta device-login logic (no third-party CLI). +// Meta device-login transport (no third-party CLI). // Flow parameters are compatible with the published behavior of oh-my-pi // (MIT-licensed; see NOTICE). +// +// This module performs network requests but never reads the environment: +// credential sources (env names, cache paths) live in config.ts and are +// passed in explicitly by callers. -import { homedir } from "node:os" -import { join, dirname } from "node:path" +import { dirname } from "node:path" import { readFileSync } from "node:fs" import { readFile, writeFile, mkdir, chmod } from "node:fs/promises" @@ -11,9 +14,6 @@ export const CLIENT_ID = "1031625952748946" export const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/" export const TOKEN_URL = "https://auth.meta.com/oidc/device/token/" export const KEY_URL = "https://api.meta.ai/muse-code/key" -export const ENV_VAR = "MUSE_CODE_SUB_TOKEN" -export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS" -export const CREDENTIALS_FILENAME = "muse-code-sub.json" const HEADERS: Record = { Accept: "application/json", @@ -33,12 +33,20 @@ export interface DeviceAuthorization { } export interface MintedCredential { + // In-memory only: writeCache never persists the OAuth token (see below). oauthAccessToken: string apiKey: string accountId: string email?: string } +/** The only fields ever written to the credential cache. */ +export interface CachedCredential { + apiKey: string + accountId: string + email?: string +} + type DeviceResponse = { device_code?: unknown user_code?: unknown @@ -64,15 +72,9 @@ type KeyResponse = { user_id?: unknown } -export function defaultCachePath(): string { - const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim() - if (override) return override - return join(homedir(), ".openclaw", CREDENTIALS_FILENAME) -} - -export async function readCache(path: string = defaultCachePath()): Promise { +export async function readCache(path: string): Promise { try { - const blob = (await readFile(path, "utf8").then(JSON.parse)) as Partial + const blob = (await readFile(path, "utf8").then(JSON.parse)) as Partial return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : "" } catch { return "" @@ -80,18 +82,23 @@ export async function readCache(path: string = defaultCachePath()): Promise + const blob = JSON.parse(readFileSync(path, "utf8")) as Partial return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : "" } catch { return "" } } -export async function writeCache(credentials: MintedCredential, path: string = defaultCachePath()): Promise { +export async function writeCache(credentials: MintedCredential, path: string): Promise { + // Retention minimization: persist only what inference needs. The OAuth + // access token has unknown broader scope and nothing reads it back, so it + // must never touch disk — sanitize at the sink, whatever callers pass in. + const { apiKey, accountId, email } = credentials + const cached: CachedCredential = { apiKey, accountId, ...(email ? { email } : {}) } await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(credentials, null, 2)) + await writeFile(path, JSON.stringify(cached, null, 2)) try { await chmod(path, 0o600) } catch { diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..dd6b749 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,24 @@ +// Credential-source resolution: env names and cache-path policy. +// +// Deliberately network-free: this module never performs requests and never +// handles secret material beyond reading configuration. Transport lives in +// auth.ts and receives explicit arguments, so each side is easy to audit +// in isolation. + +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const ENV_VAR = "MUSE_CODE_SUB_TOKEN"; +export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS"; +export const CREDENTIALS_FILENAME = "muse-code-sub.json"; + +/** Explicitly configured token (highest priority), or "" when unset. */ +export function explicitToken(): string { + return (process.env[ENV_VAR] || "").trim(); +} + +export function defaultCachePath(): string { + const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim(); + if (override) return override; + return join(homedir(), ".openclaw", CREDENTIALS_FILENAME); +} diff --git a/test/auth.test.ts b/test/auth.test.ts index c1bf4bb..49cd3d4 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it, afterEach, vi } from "vitest"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { CREDENTIALS_ENV_VAR, defaultCachePath } from "../src/config.js"; import { - CREDENTIALS_ENV_VAR, - defaultCachePath, readCache, writeCache, deviceAuthorize, @@ -55,6 +54,19 @@ describe("credential cache", () => { expect(await readCache(join(tmpdir(), "muse-auth-absent.json"))).toBe(""); }); + it("never persists the OAuth access token", async () => { + const dir = mkdtempSync(join(tmpdir(), "muse-auth-")); + const path = join(dir, "creds.json"); + await writeCache( + { oauthAccessToken: "dca-secret", apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" }, + path, + ); + const raw = readFileSync(path, "utf8"); + expect(raw).not.toContain("dca-secret"); + expect(raw).not.toContain("oauthAccessToken"); + expect(JSON.parse(raw)).toStrictEqual({ apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" }); + }); + it("honors the MUSE_CODE_SUB_CREDENTIALS override", () => { const custom = join(tmpdir(), "custom-creds.json"); process.env[CREDENTIALS_ENV_VAR] = custom;