@openparachute/agent
Advanced tools
+1
-1
| { | ||
| "name": "@openparachute/agent", | ||
| "version": "0.2.3", | ||
| "version": "0.2.4-rc.1", | ||
| "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.", | ||
@@ -5,0 +5,0 @@ "license": "AGPL-3.0", |
@@ -57,3 +57,3 @@ /** | ||
| import { mkdirSync, writeFileSync } from "node:fs"; | ||
| import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
@@ -80,3 +80,9 @@ import type { AgentSpec } from "../sandbox/types.ts"; | ||
| import { resolveClaudeCredential, resolveChannelEnv } from "../credentials.ts"; | ||
| import { resolveInjectedGrantsUnion, type GrantsClient } from "../grants.ts"; | ||
| import { | ||
| buildGitAskpassScript, | ||
| buildSurfaceGitEnv, | ||
| resolveInjectedGrantsUnion, | ||
| type GitCredential, | ||
| type GrantsClient, | ||
| } from "../grants.ts"; | ||
| import { parseStreamJsonStream } from "./stream-json.ts"; | ||
@@ -539,2 +545,3 @@ import { composeSystemPrompt } from "./types.ts"; | ||
| let grantEnv: Record<string, string> = {}; | ||
| let grantGitCredentials: GitCredential[] = []; | ||
| if (this.deps.grants) { | ||
@@ -563,2 +570,3 @@ // GRANT INJECTION = UNION OF SOURCES (roles as the capability layer — | ||
| grantEnv = injected.env; | ||
| grantGitCredentials = injected.gitCredentials; | ||
| } catch (err) { | ||
@@ -593,2 +601,23 @@ // resolveInjectedGrantsUnion is per-source best-effort (each source's list failure | ||
| // ── SURFACE GIT GRANTS (Phase 2 §6a step 4) ──────────────────────────────────── | ||
| // A surface grant carries a scoped `surface:<name>:write` token + the git remote. | ||
| // We wire it into `git` via a per-spawn GIT_ASKPASS script + env vars — so the | ||
| // agent can `git clone`/`git push` the surface's hub-hosted repo WITHOUT the token | ||
| // ever landing in `.git/config` or a URL. Written into the PRIVATE session | ||
| // workspace (like `.mcp.json`), 0700 (private + EXECUTABLE — git EXECs the askpass, | ||
| // so it needs the exec bit; the token inside makes it private). The clone itself is | ||
| // the AGENT's job in its cwd (clone-per-turn — the daemon pre-clones nothing), fed | ||
| // the remote via `PARACHUTE_SURFACE_<NAME>_REMOTE`. Absent surface grants → no git | ||
| // wiring at all (byte-identical to today). The token is fetched fresh per turn (via | ||
| // the grant material above), so a revoked surface grant stops working next turn. | ||
| let surfaceGitEnv: Record<string, string> = {}; | ||
| if (grantGitCredentials.length > 0) { | ||
| const askpassPath = join(workspace, "git-askpass.sh"); | ||
| writeFileSync(askpassPath, buildGitAskpassScript(grantGitCredentials), { mode: 0o700 }); | ||
| // `mode` on writeFileSync only applies at CREATE; chmod unconditionally so an | ||
| // existing askpass from a prior turn is (re)tightened to 0700 (private + +x). | ||
| chmodSync(askpassPath, 0o700); | ||
| surfaceGitEnv = buildSurfaceGitEnv(grantGitCredentials, askpassPath); | ||
| } | ||
| // ── INBOUND FILE ATTACHMENTS (Phase 1) ───────────────────────────────────────── | ||
@@ -733,10 +762,17 @@ // Stage each attached file into the agent's PRIVATE session workspace (under a SAFE | ||
| // Merge the granted-service env (GITHUB_TOKEN, …) with the operator-scoped | ||
| // per-channel env. The per-channel store wins on a key collision (it's the | ||
| // explicit operator override); both go in at the SAME (lowest) precedence layer of | ||
| // buildAgentChildEnv — which then applies its denylist (ANTHROPIC_API_KEY / | ||
| // CLAUDE_API_KEY / CLAUDE_CODE_OAUTH_TOKEN can NEVER be set from either source) and | ||
| // sets CLAUDE_CODE_OAUTH_TOKEN LAST, so a granted var can never clobber the | ||
| // session's managed auth or the subscription-billing guarantee. | ||
| const mergedChannelEnv: Record<string, string> = { ...grantEnv, ...channelEnv }; | ||
| // Merge the granted-service env (GITHUB_TOKEN, …) + the surface git env | ||
| // (GIT_ASKPASS / GIT_TERMINAL_PROMPT / PARACHUTE_SURFACE_*_REMOTE) with the | ||
| // operator-scoped per-channel env. The per-channel store wins on a key collision | ||
| // (it's the explicit operator override); all three go in at the SAME (lowest) | ||
| // precedence layer of buildAgentChildEnv — which then applies its denylist | ||
| // (ANTHROPIC_API_KEY / CLAUDE_API_KEY / CLAUDE_CODE_OAUTH_TOKEN can NEVER be set | ||
| // from any source) and sets CLAUDE_CODE_OAUTH_TOKEN LAST, so a granted var can | ||
| // never clobber the session's managed auth or the subscription-billing guarantee. | ||
| // None of the surface git vars are denylisted or in SANDBOX_ENV_ALLOWLIST, so they | ||
| // survive the scrub + the sandbox env merge intact. | ||
| const mergedChannelEnv: Record<string, string> = { | ||
| ...grantEnv, | ||
| ...surfaceGitEnv, | ||
| ...channelEnv, | ||
| }; | ||
@@ -743,0 +779,0 @@ // Layer the scrubbed agent env UNDER the sandbox wrapper's env; the HOME/config/ |
+225
-9
@@ -57,10 +57,10 @@ /** | ||
| export interface ConnectionSpec { | ||
| /** Resource kind. `vault`/`service` are wired in 4b-1; `mcp` is parsed-but-deferred. */ | ||
| kind: "vault" | "service" | "mcp"; | ||
| /** Resource kind. `vault`/`service`/`surface` are wired; `mcp` is parsed-but-deferred. */ | ||
| kind: "vault" | "service" | "surface" | "mcp"; | ||
| /** | ||
| * The resource target — a vault name (`research`), a service name (`github`), | ||
| * or, for `kind:"mcp"`, the remote MCP https URL. | ||
| * a surface name (`gitcoin-brain`), or, for `kind:"mcp"`, the remote MCP https URL. | ||
| */ | ||
| target: string; | ||
| /** Vault access verb. Vault-only. */ | ||
| /** Access verb. Vault + surface (`surface:<name>:<verb>` — write ⊇ read at the git endpoint). */ | ||
| access?: "read" | "write"; | ||
@@ -88,3 +88,11 @@ /** Vault tag-scope (one or more `#tag`). Vault-only. */ | ||
| * service → `<inject-joined>:<target>` e.g. `env+mcp:github` | ||
| * surface → `surface:<target>:<access>` | ||
| * mcp → `mcp:<url>` | ||
| * | ||
| * NOTE: this key is used ONLY for the agent's OWN status resolution | ||
| * ({@link resolveConnectionStatus}) — where BOTH sides (the declared spec + the | ||
| * hub's echoed-back `connection`) run through THIS function, so it's internally | ||
| * consistent. It is DELIBERATELY NOT sent to the hub for reconcile: the agent | ||
| * sends SPECS and the hub re-derives keys with its own `connectionKey` (the | ||
| * cross-repo divergence lesson — agent#96/hub#674). | ||
| */ | ||
@@ -100,2 +108,5 @@ export function connectionKey(c: ConnectionSpec): string { | ||
| } | ||
| if (c.kind === "surface") { | ||
| return `surface:${c.target}:${c.access ?? "read"}`; | ||
| } | ||
| return `mcp:${c.target}`; | ||
@@ -108,2 +119,9 @@ } | ||
| const SERVICE_NAME_SLUG = /^[a-zA-Z0-9_-]+$/; | ||
| /** | ||
| * A surface name slug — the `<name>` segment in `surface:<name>:<verb>`. MATCHES | ||
| * the hub's `SURFACE_NAME_RE` (git-registry.ts) so a name this parser accepts is | ||
| * one the hub's PUT/registry + git-transport URL parser also accept (no slashes | ||
| * or dots → no path traversal in the git endpoint). Bounded length. | ||
| */ | ||
| const SURFACE_NAME_SLUG = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; | ||
@@ -203,2 +221,4 @@ /** | ||
| return parseVaultWant(entry, rest); | ||
| case "surface": | ||
| return parseSurfaceWant(entry, rest); | ||
| case "env": | ||
@@ -214,3 +234,3 @@ return parseServiceWant(entry, rest, "env"); | ||
| `wants: "${entry}" has unknown kind "${prefix}" — expected one of ` + | ||
| `vault | env | mcp.`, | ||
| `vault | surface | env | mcp.`, | ||
| ); | ||
@@ -259,2 +279,39 @@ } | ||
| /** | ||
| * Parse `surface:<name>:<read|write>` — a grant to a surface's hub-hosted git repo | ||
| * (Phase 2 §6a). Mirrors {@link parseVaultWant}'s `<name>:<verb>` split, minus the | ||
| * tag suffix (surfaces have no tag-scope). The verb is REQUIRED + explicit (like | ||
| * vault): `write` = clone+push, `read` = clone only. write ⊇ read at the git | ||
| * endpoint, so a `write` grant needs no separate read grant to clone. | ||
| * | ||
| * The name is NORMALIZED to lowercase (the canonical form): the hub lowercases it too | ||
| * (admin-agent-grants.ts parseConnectionSpec) so both repos' keys agree, and the hub's | ||
| * `grantId` slug + minted `surface:<name>:<verb>` scope + `/git/<name>` remote are all | ||
| * lowercase — surfaces are lowercase-kebab by convention (surface-host registers them | ||
| * lowercase). Validated case-permissively (SURFACE_NAME_SLUG), then lowercased. | ||
| */ | ||
| function parseSurfaceWant(entry: string, rest: string): ConnectionSpec { | ||
| const colon = rest.indexOf(":"); | ||
| if (colon < 0) { | ||
| throw new WantsParseError( | ||
| `wants: "${entry}" is malformed — a surface connection needs a verb: ` + | ||
| `"surface:<name>:<read|write>".`, | ||
| ); | ||
| } | ||
| const name = rest.slice(0, colon); | ||
| const verb = rest.slice(colon + 1).trim(); | ||
| if (!SURFACE_NAME_SLUG.test(name)) { | ||
| throw new WantsParseError( | ||
| `wants: "${entry}" — surface name "${name}" must be a slug ` + | ||
| `(alphanumeric, dash, underscore; no slashes or dots).`, | ||
| ); | ||
| } | ||
| if (verb !== "read" && verb !== "write") { | ||
| throw new WantsParseError( | ||
| `wants: "${entry}" — surface access must be "read" or "write" (got "${verb}").`, | ||
| ); | ||
| } | ||
| return { kind: "surface", target: name.toLowerCase(), access: verb }; | ||
| } | ||
| /** Parse `env:<service>` / `mcp:<service>` into one service connection. */ | ||
@@ -327,2 +384,3 @@ function parseServiceWant(entry: string, service: string, mode: "env" | "mcp"): ConnectionSpec { | ||
| | { kind: "service"; token: string; inject: ("env" | "mcp")[] } | ||
| | { kind: "surface"; token: string; remoteUrl: string } | ||
| | { kind: "mcp"; token: string; mcpUrl: string }; | ||
@@ -550,2 +608,16 @@ | ||
| /** | ||
| * One granted git credential (a surface grant) — the git remote the agent | ||
| * clones/pushes + the scoped hub token that authenticates it. The token is | ||
| * injected into `git` via a per-spawn 0700 GIT_ASKPASS (private + executable — git | ||
| * execs it; never `.git/config`), so it never persists on disk beyond the ephemeral | ||
| * workspace (design §6a step 4). | ||
| */ | ||
| export interface GitCredential { | ||
| /** The git remote — `<hubOrigin>/git/<name>` (the hub git-transport endpoint). */ | ||
| remoteUrl: string; | ||
| /** The scoped `surface:<name>:<verb>` JWT (Basic `x-access-token:<jwt>` / Bearer). */ | ||
| token: string; | ||
| } | ||
| /** The result of resolving an agent's approved grants into spawn-injectable bits. */ | ||
@@ -557,2 +629,9 @@ export interface InjectedGrants { | ||
| env: Record<string, string>; | ||
| /** | ||
| * Granted git credentials (surface grants) — the backend wires each into a | ||
| * per-spawn GIT_ASKPASS + a `PARACHUTE_SURFACE_<NAME>_REMOTE` env var so the | ||
| * agent can `git clone`/`git push` the surface's repo. Empty for an agent with | ||
| * no surface grants (today's behavior — no git wiring at all). | ||
| */ | ||
| gitCredentials: GitCredential[]; | ||
| } | ||
@@ -572,2 +651,6 @@ | ||
| * inject, keeping the env one). | ||
| * - surface material (`{token, remoteUrl}`) → a {@link GitCredential} (the | ||
| * agent clones/pushes the surface's hub-hosted git repo). NOT an MCP entry + | ||
| * NOT an env var — the backend wires it into a per-spawn GIT_ASKPASS + a | ||
| * remote-URL env var. | ||
| * - mcp material (`{token, mcpUrl}`, 4b-2) → an MCP server entry (the agent | ||
@@ -592,2 +675,3 @@ * reaches the remote MCP / OAuth resource). An UNAPPROVED mcp grant has no | ||
| const env: Record<string, string> = {}; | ||
| const gitCredentials: GitCredential[] = []; | ||
@@ -632,2 +716,11 @@ const grants = await client.listGrants(agent); // throws → caller spawns without grants | ||
| if (material.kind === "surface") { | ||
| // Surface git grant (Phase 2 §6a): the material carries the scoped token + | ||
| // the git remote. The backend wires it into a per-spawn GIT_ASKPASS + a | ||
| // `PARACHUTE_SURFACE_<NAME>_REMOTE` env var — NEVER an MCP entry + NEVER | ||
| // `.git/config`. Just carry it through as a git credential. | ||
| gitCredentials.push({ remoteUrl: material.remoteUrl, token: material.token }); | ||
| continue; | ||
| } | ||
| if (material.kind === "service") { | ||
@@ -669,3 +762,3 @@ // service material — inject env and/or mcp per the material's `inject` list. | ||
| return { mcpEntries, env }; | ||
| return { mcpEntries, env, gitCredentials }; | ||
| } | ||
@@ -687,4 +780,5 @@ | ||
| * Dedupe rule: MCP entries are deduped by their entry `name` (first source wins); | ||
| * env vars are deduped by var name (first source wins). The legacy `spec.name` source | ||
| * is conventionally first, so a def's own grant wins a name collision with a role's. | ||
| * env vars are deduped by var name (first source wins); git credentials are deduped | ||
| * by `remoteUrl` (first source wins). The legacy `spec.name` source is conventionally | ||
| * first, so a def's own grant wins a collision with a role's. | ||
| * | ||
@@ -704,2 +798,3 @@ * Keys are deduped + processed in order; a duplicate key (e.g. the same role twice in the | ||
| const env: Record<string, string> = {}; | ||
| const gitByRemote = new Map<string, GitCredential>(); | ||
| const seenKeys = new Set<string>(); | ||
@@ -729,8 +824,129 @@ | ||
| } | ||
| for (const cred of injected.gitCredentials) { | ||
| if (!gitByRemote.has(cred.remoteUrl)) gitByRemote.set(cred.remoteUrl, cred); | ||
| } | ||
| } | ||
| return { mcpEntries: [...mcpByName.values()], env }; | ||
| return { | ||
| mcpEntries: [...mcpByName.values()], | ||
| env, | ||
| gitCredentials: [...gitByRemote.values()], | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Surface git injection — the GIT_ASKPASS credential channel (design §6a step 4) | ||
| // --------------------------------------------------------------------------- | ||
| /** POSIX single-quote-escape (for embedding a value inside `'…'` in the script). */ | ||
| function shSingleQuoteBody(s: string): string { | ||
| return s.replace(/'/g, `'\\''`); | ||
| } | ||
| /** The git URL path (`/git/<name>`) of a surface remote — used to disambiguate | ||
| * tokens when an agent holds grants to MULTIPLE surfaces on the one hub host. */ | ||
| function gitRemotePath(remoteUrl: string): string { | ||
| try { | ||
| return new URL(remoteUrl).pathname; | ||
| } catch { | ||
| return remoteUrl; | ||
| } | ||
| } | ||
| /** The surface name (last `/git/<name>` segment) of a remote, or null. */ | ||
| export function surfaceNameFromRemoteUrl(remoteUrl: string): string | null { | ||
| const segs = gitRemotePath(remoteUrl).split("/").filter((s) => s.length > 0); | ||
| const last = segs[segs.length - 1]; | ||
| return last && last.length > 0 ? last : null; | ||
| } | ||
| /** The `PARACHUTE_SURFACE_<NAME>_REMOTE` env var name for a surface remote. */ | ||
| export function surfaceRemoteEnvVar(remoteUrl: string): string | null { | ||
| const name = surfaceNameFromRemoteUrl(remoteUrl); | ||
| if (!name) return null; | ||
| return `PARACHUTE_SURFACE_${name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_REMOTE`; | ||
| } | ||
| /** | ||
| * Build the per-spawn GIT_ASKPASS script that feeds a surface-scoped hub token to | ||
| * `git` on demand — so the token authenticates `git clone`/`git push` WITHOUT ever | ||
| * landing in `.git/config` or a URL (design §6a step 4). Git invokes the askpass as | ||
| * `askpass "<prompt>"`; the script answers on stdout: | ||
| * - a `Username` prompt → the sentinel `x-access-token` (the hub accepts Basic | ||
| * `x-access-token:<jwt>`, GitHub's compat form — see git-transport.ts extractToken); | ||
| * - a `Password` prompt → the surface's token. | ||
| * | ||
| * SINGLE surface (the common case): any password prompt echoes the one token — | ||
| * host-keyed, unambiguous. MULTIPLE surfaces share the ONE hub host, so the caller | ||
| * sets `credential.useHttpPath=true` (see {@link buildSurfaceGitEnv}) which puts the | ||
| * repo PATH in the prompt; this script then path-matches `…/git/<name>…` to return | ||
| * the right token (falling back to the first token if no path matches — a graceful, | ||
| * debuggable 403 rather than a wrong-surface push, never a security hole). | ||
| * | ||
| * PURE + testable. The token is single-quoted (a JWT is `[A-Za-z0-9._-]` — never a | ||
| * quote — but we escape defensively). Empty `creds` → a no-op script (never wired). | ||
| */ | ||
| export function buildGitAskpassScript(creds: GitCredential[]): string { | ||
| const head = [ | ||
| "#!/bin/sh", | ||
| "# Parachute surface git credentials — GIT_ASKPASS (per-spawn, 0700, ephemeral).", | ||
| "# The surface-scoped hub token is echoed on demand only — it NEVER lands in", | ||
| "# .git/config or a remote URL. Git invokes: askpass \"<prompt>\".", | ||
| 'case "$1" in', | ||
| " Username*|username*) printf %s 'x-access-token' ;;", | ||
| " *)", | ||
| ]; | ||
| const tail = ["esac", ""]; | ||
| if (creds.length <= 1) { | ||
| const tok = creds[0]?.token ?? ""; | ||
| return [...head, ` printf %s '${shSingleQuoteBody(tok)}' ;;`, ...tail].join("\n"); | ||
| } | ||
| // Multi-surface: match the repo path in the prompt (needs credential.useHttpPath). | ||
| const inner = [' case "$1" in']; | ||
| for (const c of creds) { | ||
| const path = gitRemotePath(c.remoteUrl); | ||
| inner.push(` *'${shSingleQuoteBody(path)}'*) printf %s '${shSingleQuoteBody(c.token)}' ;;`); | ||
| } | ||
| inner.push(` *) printf %s '${shSingleQuoteBody(creds[0]!.token)}' ;;`); | ||
| inner.push(" esac", " ;;"); | ||
| return [...head, ...inner, ...tail].join("\n"); | ||
| } | ||
| /** | ||
| * Build the env vars that wire a surface-grant holder's `git` to the per-spawn | ||
| * askpass (design §6a step 4). Returns `{}` for no surface grants (no git wiring — | ||
| * today's behavior). For ≥1: | ||
| * - `GIT_ASKPASS` → the askpass script path; `GIT_TERMINAL_PROMPT=0` (fail closed, | ||
| * never block on an interactive prompt); | ||
| * - `PARACHUTE_SURFACE_<NAME>_REMOTE` per surface → the clone/push URL, so the | ||
| * agent DISCOVERS where to clone (the clone-per-turn model — the agent runs the | ||
| * clone itself in its cwd; the daemon pre-clones nothing); | ||
| * - for ≥2 surfaces (same hub host, distinct scoped tokens) → `credential.useHttpPath` | ||
| * via GIT_CONFIG_* so git includes the repo PATH in the askpass prompt and the | ||
| * script can return the RIGHT token per surface. None of these are secrets (the | ||
| * token lives only in the askpass file) and none collide with the Claude-auth | ||
| * denylist, so they ride the ordinary child-env path. | ||
| */ | ||
| export function buildSurfaceGitEnv( | ||
| creds: GitCredential[], | ||
| askpassPath: string, | ||
| ): Record<string, string> { | ||
| if (creds.length === 0) return {}; | ||
| const env: Record<string, string> = { | ||
| GIT_ASKPASS: askpassPath, | ||
| GIT_TERMINAL_PROMPT: "0", | ||
| }; | ||
| for (const c of creds) { | ||
| const varName = surfaceRemoteEnvVar(c.remoteUrl); | ||
| if (varName) env[varName] = c.remoteUrl; | ||
| } | ||
| if (creds.length >= 2) { | ||
| env.GIT_CONFIG_COUNT = "1"; | ||
| env.GIT_CONFIG_KEY_0 = "credential.useHttpPath"; | ||
| env.GIT_CONFIG_VALUE_0 = "true"; | ||
| } | ||
| return env; | ||
| } | ||
| /** | ||
| * The hub grant-holder KEY for a ROLE (roles as the capability layer — | ||
@@ -737,0 +953,0 @@ * DESIGN-2026-06-29-threads-roles-context.md) — the slugged note PATH, mirroring the slug |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1534243
0.81%24266
1%