🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@openparachute/agent

Package Overview
Dependencies
Maintainers
1
Versions
34
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@openparachute/agent - npm Package Compare versions

Comparing version
0.2.3
to
0.2.4-rc.1
+1
-1
package.json
{
"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/

@@ -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