From 4bc84bdb2e496fe50017cf6bfe253b0ba34984e5 Mon Sep 17 00:00:00 2001 From: "will.anderson" Date: Fri, 21 Aug 2026 19:13:51 -0500 Subject: [PATCH] feat(opencode): add keychain-backed credential broker tool --- packages/opencode/src/tool/credential.ts | 87 +++++++++++++++++++++++ packages/opencode/src/tool/credential.txt | 22 ++++++ packages/opencode/src/tool/registry.ts | 3 + 3 files changed, 112 insertions(+) create mode 100644 packages/opencode/src/tool/credential.ts create mode 100644 packages/opencode/src/tool/credential.txt diff --git a/packages/opencode/src/tool/credential.ts b/packages/opencode/src/tool/credential.ts new file mode 100644 index 0000000000..3ff10ef611 --- /dev/null +++ b/packages/opencode/src/tool/credential.ts @@ -0,0 +1,87 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION from "./credential.txt" + +const KEYCHAIN_SERVICE = "neuron" +const GITEA_BASE = "https://git.neuralplatform.ai/api/v1/" +const MAX_RESPONSE_SIZE = 20_000 + +const redact = (text: string, secret: string) => (secret ? text.split(secret).join("[redacted]") : text) + +const readSecret = (account: string) => + Effect.tryPromise({ + try: async () => { + const result = await Bun.$`security find-generic-password -s ${KEYCHAIN_SERVICE} -a ${account} -w`.quiet().nothrow() + if (result.exitCode !== 0) throw new Error(`no keychain credential "${account}" under service "${KEYCHAIN_SERVICE}"`) + return result.stdout.toString().trim() + }, + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + +const Parameters = Schema.Struct({ + action: Schema.Literals(["gitea-api", "check"]).annotate({ + description: "gitea-api performs an authenticated Gitea API call; check only verifies a credential exists", + }), + account: Schema.optional(Schema.String).annotate({ + description: 'Keychain account name. Defaults to "gitea-token".', + default: "gitea-token", + }), + path: Schema.optional(Schema.String).annotate({ + description: "API path after /api/v1/, e.g. user/keys. Required for gitea-api.", + }), + method: Schema.optional(Schema.Literals(["GET", "POST", "PATCH", "PUT", "DELETE"])).annotate({ + description: "HTTP method for gitea-api. Defaults to GET.", + default: "GET", + }), + body: Schema.optional(Schema.String).annotate({ + description: "JSON request body for gitea-api.", + }), +}) + +export const CredentialTool = Tool.define( + "credential", + Effect.gen(function* () { + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, _ctx: Tool.Context) => + Effect.gen(function* () { + const account = params.account ?? "gitea-token" + + if (params.action === "check") { + yield* readSecret(account) + return { + title: `credential check: ${account}`, + metadata: {}, + output: `Credential "${account}" exists in the Keychain. Value withheld by design.`, + } + } + + const path = params.path + if (!path) throw new Error("path is required for gitea-api") + + const secret = yield* readSecret(account) + const response = yield* Effect.tryPromise({ + try: () => + fetch(new URL(path, GITEA_BASE), { + method: params.method ?? "GET", + headers: { + Authorization: `token ${secret}`, + ...(params.body ? { "Content-Type": "application/json" } : {}), + }, + body: params.body, + }), + catch: (error) => new Error(error instanceof Error ? error.message : String(error)), + }).pipe(Effect.orDie) + const text = yield* Effect.promise(() => response.text()) + const safeBody = redact(text.length > MAX_RESPONSE_SIZE ? `${text.slice(0, MAX_RESPONSE_SIZE)}\n...[truncated]` : text, secret) + const safeHeaders = JSON.stringify(Object.fromEntries(response.headers)).replace(/\s+/g, " ").slice(0, 500) + return { + title: `gitea ${params.method ?? "GET"} /${path}`, + metadata: {}, + output: `HTTP ${response.status}\nheaders: ${redact(safeHeaders, secret)}\n${safeBody}`, + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/credential.txt b/packages/opencode/src/tool/credential.txt new file mode 100644 index 0000000000..cc37a19505 --- /dev/null +++ b/packages/opencode/src/tool/credential.txt @@ -0,0 +1,22 @@ +Broker credentials without ever seeing them. + +Secrets live in the macOS Keychain under service name "neuron", one per +account name. The user stores them once, outside of any chat transcript: + + security add-generic-password -s neuron -a gitea-token -w + +This tool reads a secret from the Keychain, uses it to perform the requested +operation, and returns only results — the secret itself is never printed, +returned, or logged. If an output would contain the secret it is replaced +with [redacted]. + +Actions: + +- `gitea-api`: call the Gitea API at git.neuralplatform.ai with the stored + `gitea-token` credential. Provide `path` (after /api/v1/), optional HTTP + `method` (default GET) and JSON `body`. Returns status and response body. +- `check`: verify that a named Keychain credential exists. Never returns the + value. + +If a required credential is missing, tell the user to store it with the +security command above rather than pasting secrets into the conversation. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 2dce0945b2..239455afa3 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -18,6 +18,7 @@ import { TaskTool } from "./task" import { Database } from "@opencode-ai/core/database/database" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" +import { CredentialTool } from "./credential" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" @@ -112,6 +113,7 @@ const layer = Layer.effect( const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool + const credential = yield* CredentialTool const shell = yield* ShellTool const globtool = yield* GlobTool const lstool = yield* LsTool @@ -231,6 +233,7 @@ const layer = Layer.effect( write: Tool.init(writetool), task: Tool.init(task), fetch: Tool.init(webfetch), + credential: Tool.init(credential), todo: Tool.init(todo), search: Tool.init(websearch), skill: Tool.init(skilltool),