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

@openparachute/agent

Package Overview
Dependencies
Maintainers
1
Versions
36
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-rc.15
to
0.2.3-rc.16
+1
-1
package.json
{
"name": "@openparachute/agent",
"version": "0.2.3-rc.15",
"version": "0.2.3-rc.16",
"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",

@@ -79,3 +79,3 @@ /**

import { resolveClaudeCredential, resolveChannelEnv } from "../credentials.ts";
import { resolveInjectedGrants, type GrantsClient } from "../grants.ts";
import { resolveInjectedGrantsUnion, type GrantsClient } from "../grants.ts";
import { parseStreamJsonStream } from "./stream-json.ts";

@@ -181,9 +181,13 @@ import { composeSystemPrompt } from "./types.ts";

/**
* The agent NAME used to key the agent's grants on the hub (`GET
* /admin/grants?agent=<name>`). Defaults to `spec.name` when absent. The grants are
* keyed by the agent name (= the def's name), which equals `spec.name` for a
* vault-native agent. Threaded explicitly so a future channel/agent-name split
* doesn't silently fetch the wrong agent's grants.
* The LEGACY-def grant KEY — the hub grant-holder string for the def's OWN grants
* (`GET /admin/grants?agent=<key>`). Defaults to `spec.name` when absent (= the def's
* `metadata.name`, the legacy continuity key — the 4 live def agents inject exactly
* `listGrants(spec.name)`). Renamed from `grantsAgentName` (threads-only Phase B′ —
* the locked lexicon drops the "agent" framing). It is the DORMANT per-thread override
* escape hatch: the PRIMARY grant path is the loadout-pack UNION (see `deliver` /
* resolveInjectedGrantsUnion), NOT this single key. Do NOT set it unless a thread
* genuinely needs creds keyed off something other than its def name (the rare per-thread
* override + the migration seam) — else you'd fetch the wrong source's grants.
*/
grantsAgentName?: string;
grantsKey?: string;
/**

@@ -451,2 +455,3 @@ * The subprocess spawner — runs the sandbox-wrapped `claude -p`. Tests inject a

subject?: string,
packKeys?: string[],
): Promise<DeliverResult> {

@@ -534,18 +539,33 @@ const spec = handle.spec;

if (this.deps.grants) {
// The grants are keyed on the hub by the AGENT name, which for vault-native defs
// is `spec.name`. `grantsAgentName` is an explicit override reserved for a future
// channel-name≠agent-name split; today no caller sets it, so it falls through to
// `spec.name` — do NOT set it unless that split lands (else you'd fetch the wrong
// agent's grants).
const agentName = this.deps.grantsAgentName ?? spec.name;
// GRANT INJECTION = UNION OF SOURCES (threads-only Phase B′ — §4). The injected
// grants for a turn are the union of:
// 1. the LEGACY def key — `grantsKey ?? spec.name` (UNCHANGED): a
// `#agent/definition` keeps its grants keyed by its `metadata.name`. For the 4
// live def agents (uni/steward/uni-evolve/eco-civilization) this is the only
// source. `grantsKey` (was `grantsAgentName`) is the dormant per-thread override
// escape hatch (§"Activate the dormant hook") — today no caller sets it, so it
// falls through to `spec.name`. Do NOT make it the primary path; the primary is
// this union.
// 2. each loaded PACK's path key (`packKeys`) — the slugged PATH (packPathKey) of
// every loaded note in the thread's loadout that is a `#pack` declaring `wants:`.
// The SECURITY GATE (a non-pack note's `wants:` is ignored) is enforced UPSTREAM
// where packKeys is built (the transport's readThreadPackKeys), so a plain
// content note's `wants:` never reaches here. Empty packKeys (every current
// agent — no thread loads a `#pack` with `wants:` today) → the union is exactly
// `[spec.name]` → BYTE-IDENTICAL to the legacy single-source path. (legacy continuity)
const legacyKey = this.deps.grantsKey ?? spec.name;
const sources = [legacyKey, ...(packKeys ?? [])];
try {
const injected = await resolveInjectedGrants(this.deps.grants, agentName);
const injected = await resolveInjectedGrantsUnion(this.deps.grants, sources);
grantMcpEntries = injected.mcpEntries;
grantEnv = injected.env;
} catch (err) {
// A failed grant LIST aborts only the cross-resource injection — the turn
// still runs with own-vault. (A revoked-mid-list / hub blip class.)
// resolveInjectedGrantsUnion is per-source best-effort (each source's list failure
// is logged + skipped inside it), so this catch is defense-in-depth — a surprise
// throw aborts only the cross-resource injection; the turn still runs with own-vault.
console.warn(
`parachute-agent: resolving grants for "${agentName}" failed (running this turn ` +
`WITHOUT cross-resource grants — own-vault unaffected): ${(err as Error).message}`,
`parachute-agent: resolving grants for "${legacyKey}"` +
(packKeys && packKeys.length > 0 ? ` (+${packKeys.length} pack source(s))` : "") +
` failed (running this turn WITHOUT cross-resource grants — own-vault unaffected): ` +
`${(err as Error).message}`,
);

@@ -552,0 +572,0 @@ }

@@ -364,2 +364,13 @@ /**

* the workspace IDENTITY.
*
* `packKeys` (optional, threads-only Phase B′ — DESIGN-2026-06-29-threads-only.md §4) is the
* list of hub grant-holder KEYS for the loaded PACKS in this thread's loadout — the slugged
* PATH (`packPathKey`) of every loaded note that is a `#pack` declaring `wants:`. The
* programmatic backend UNIONS each pack's APPROVED grants with the def's own (`spec.name`),
* so a thread that loads a pack gains that pack's capabilities. This is SEPARATE from
* `loadout` (prompt content) by design: the SECURITY GATE — a non-pack note's `wants:` is
* IGNORED — is enforced where `packKeys` is built (the transport reads the loaded notes'
* METADATA, never folding it into the prompt). ADDITIVE — omitted/empty (every current agent;
* no thread loads a `#pack` with `wants:` today) → the grant source set is exactly
* `[spec.name]`, BYTE-IDENTICAL to the legacy single-source injection (legacy continuity).
*/

@@ -375,2 +386,3 @@ deliver(

subject?: string,
packKeys?: string[],
): Promise<DeliverResult>;

@@ -377,0 +389,0 @@

@@ -75,2 +75,10 @@ /**

/**
* The bare PACK tag the pack-watch triggers filter on (threads-only Phase B′ —
* DESIGN-2026-06-29-threads-only.md §4). A `#pack` note save fires the pack webhook so the
* daemon reconciles that pack's `wants:` under its path key (register + prune-its-own-partition).
* This is the PER-PACK trigger — analogous to the def-watch triggers, NOT fired on every load
* (load is per-turn). Mirrors {@link PACK_TAG} in grants.ts.
*/
export const PACK_TAG = "pack";
/**
* The inbound trigger's `when` predicate. SOURCED from the existing

@@ -136,2 +144,11 @@ * {@link AGENT_VAULT_TRIGGER_TEMPLATE} (`src/transports/vault.ts`) — the

/**
* The pack-watch trigger name for a (vault, kind) (threads-only Phase B′). Stable so the
* POST upserts in place. ONE pair (create + edit) per def-vault — they fire the pack webhook
* for ANY `#pack` note in the vault, which reconciles that pack's `wants:` under its path key.
*/
export function packWatchTriggerName(vault: string, kind: "create" | "edit"): string {
return `conn_packs-${kind}-${vault}`;
}
/** Build the webhook URL `<hub-origin>/agent/api/vault/<endpoint>`. */

@@ -155,2 +172,3 @@ function buildWebhook(hubOrigin: string, endpoint: string): string {

const inboundWebhook = buildWebhook(hubOrigin, "/api/vault/inbound");
const packWebhook = buildWebhook(hubOrigin, "/api/vault/pack");
const auth = { bearer: webhookBearer };

@@ -186,2 +204,23 @@ return [

},
// PACK-watch CREATE — a new `#pack` note reconciles its `wants:` under its path key
// (threads-only Phase B′ §4). This is the PER-PACK reconcile trigger, analogous to the
// def-watch triggers — fired on SAVE, NOT on every load (load is per-turn). The webhook
// registers the pack's wants + prunes ONLY that pack's own grant partition.
{
name: packWatchTriggerName(vault, "create"),
events: ["created"],
when: { tags: [PACK_TAG] },
action: { webhook: packWebhook, send: "json", auth },
},
// PACK-watch EDIT — an edited `#pack` note re-reconciles (a removed `wants:` prunes that
// pack's partition to []). The vault has no `deleted` trigger event (validator allows only
// created/updated), so deletion is handled out-of-band; the create+edit pair covers
// save + drop-wants, which is the live reconcile surface. (Each pack is its OWN partition,
// so a reconcile can never touch a def's grants or another pack's — §4.)
{
name: packWatchTriggerName(vault, "edit"),
events: ["updated"],
when: { tags: [PACK_TAG] },
action: { webhook: packWebhook, send: "json", auth },
},
];

@@ -311,3 +350,3 @@ }

`parachute-agent: def-vault "${r.vault}" — auto-registered ${r.registered.length} trigger(s) ` +
`(def-watch create/edit + inbound, bare-keyed).`,
`(def-watch create/edit + inbound + pack-watch create/edit, bare-keyed).`,
);

@@ -314,0 +353,0 @@ } else {

@@ -659,2 +659,132 @@ /**

/**
* UNION an agent's injected grants across MULTIPLE grant-source keys (threads-only
* Phase B′ — DESIGN-2026-06-29-threads-only.md §4). The injected grants for a turn are
* the union of `resolveInjectedGrants(client, key)` over `[spec.name, ...packKeys]`:
*
* - `spec.name` — the LEGACY def path (UNCHANGED): a `#agent/definition` keeps its
* grants keyed by its `metadata.name`. For the 4 live def agents (uni, steward, …)
* this is the ONLY key, so with no loaded packs the union has one source and the
* result is byte-identical to {@link resolveInjectedGrants}(client, spec.name).
* - each `packKey` — a slugged pack PATH ({@link packPathKey}) for every loaded note
* that is a `#pack` declaring `wants:`. Loading a pack into a thread unions that
* pack's path-keyed APPROVED grants in.
*
* 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 pack's.
*
* Keys are deduped + processed in order; a duplicate key (e.g. the same pack twice in
* a loadout, or a pack key that happens to equal `spec.name`) is fetched ONCE. A single
* source's grant-LIST failure is logged + SKIPPED (its grants are simply absent this
* turn — the others still inject), so a flaky hub on one pack never sinks the whole
* turn's injection. Material is fetched FRESH per source (never cached), same as the
* single-source path.
*/
export async function resolveInjectedGrantsUnion(
client: GrantsClient,
keys: string[],
): Promise<InjectedGrants> {
const mcpByName = new Map<string, InjectedMcpEntry>();
const env: Record<string, string> = {};
const seenKeys = new Set<string>();
for (const key of keys) {
if (!key || seenKeys.has(key)) continue;
seenKeys.add(key);
let injected: InjectedGrants;
try {
injected = await resolveInjectedGrants(client, key);
} catch (err) {
// A single source's grant LIST failing must not sink the others — that source's
// grants are simply absent this turn (its own-vault is unaffected; another
// source's grants still inject). Never log a token (there is none in the error).
console.warn(
`parachute-agent: resolving grants for source "${key}" failed ` +
`(skipping this source's grants for this turn): ${(err as Error).message}`,
);
continue;
}
for (const entry of injected.mcpEntries) {
if (!mcpByName.has(entry.name)) mcpByName.set(entry.name, entry);
}
for (const [varName, value] of Object.entries(injected.env)) {
if (!(varName in env)) env[varName] = value;
}
}
return { mcpEntries: [...mcpByName.values()], env };
}
/**
* The hub grant-holder KEY for a PACK (threads-only Phase B′ §4) — the slugged note
* PATH, mirroring the slug discipline the hub's `grantId` uses (`/`→`-`, all non-slug
* chars collapse). Prefixed `pack--` so it reads UNAMBIGUOUSLY as a pack source and is
* practically partitioned from def keys: a def `metadata.name` is an operator-authored
* `[a-zA-Z0-9_-]+` slug (the live cast — `uni`, `steward`, … — have no `--`), so the
* `pack--` prefix keeps the two namespaces apart in normal operation. (The def-name regex
* technically accepts `--`, so a deliberately-crafted def named `pack--<x>` could share a
* pack's partition — but that's an operator-trust corner, not an exploitable boundary: an
* operator who can author such a def can author the pack too.) The hub keys
* `grantId(agent, spec) = ${agent}--${connectionKey}` on this opaque holder string;
* each pack is therefore its OWN prune partition on the hub (`listGrantsForAgent`),
* so a pack's reconcile can never touch a def's grants or another pack's.
*
* Examples: `Uni/Packs/github` → `pack--Uni-Packs-github`; `Packs/Read.ai` → `pack--Packs-Read-ai`.
*/
export function packPathKey(notePath: string): string {
// Mirror sandbox/types.ts `slug`: collapse every non-`[a-zA-Z0-9_-]` char to `-`.
const slugged = notePath.replace(/[^a-zA-Z0-9_-]/g, "-");
return `pack--${slugged}`;
}
/** The bare tag (post-canonicalization) that marks a note as a PACK. */
export const PACK_TAG = "pack";
/**
* Is this loaded note a PACK that declares `wants:`? — the SECURITY GATE (threads-only
* Phase B′ §"Security refinement — wants only from PACKS"). A note's `wants:` is honored
* ONLY if the note carries the `#pack`/`pack` tag; a plain content note's `wants:` is
* IGNORED (inert) even if present, so loading arbitrary notes for context can NEVER add
* capabilities. PURE — no I/O. Tag matching tolerates a stray leading `#` (pre-/post-
* canonicalization) so a `#pack`-authored note is recognized either way.
*
* Returns the parsed `wants:` connection specs when the note is a pack WITH a non-empty,
* cleanly-parsing `wants:`; otherwise `null` (not a pack, no `wants:`, or a parse error —
* a parse error is surfaced via {@link packWantsOrError}, this fast-path just returns null).
*/
export function packWants(note: {
tags?: unknown;
metadata?: Record<string, unknown>;
}): ConnectionSpec[] | null {
if (!isPackNote(note)) return null;
const raw = note.metadata?.wants;
if (raw === undefined || raw === null) return null;
let specs: ConnectionSpec[];
try {
specs = parseWants(raw);
} catch {
return null; // parse error — the reconcile path stamps status:error; injection ignores it.
}
return specs.length > 0 ? specs : null;
}
/**
* Does this note carry the `#pack` tag? Tolerates the `tags` field being an array of
* strings (the vault REST shape) or a comma/space-joined string, and a stray leading
* `#` on the tag value. PURE.
*/
export function isPackNote(note: { tags?: unknown }): boolean {
const tags = note.tags;
let list: string[];
if (Array.isArray(tags)) {
list = tags.map((t) => (typeof t === "string" ? t : String(t)));
} else if (typeof tags === "string") {
list = tags.split(/[,\s]+/);
} else {
return false;
}
return list.some((t) => t.trim().replace(/^#/, "") === PACK_TAG);
}
/** MCP entry key for a GRANTED vault — namespaced so it never collides with the

@@ -661,0 +791,0 @@ * agent's OWN def-vault entry (`parachute-vault-<name>`). */

@@ -280,2 +280,20 @@ /**

/**
* Optional: read the thread's loaded-PACK grant KEYS (threads-only Phase B′ —
* DESIGN-2026-06-29-threads-only.md §4). Resolves the same `metadata.loadout` paths as
* {@link readThreadLoadout}, but reads each note's METADATA (not its content) to find the
* loaded notes that are PACKS — i.e. carry the `#pack` tag AND declare a non-empty `wants:`.
* Returns each such pack's hub grant-holder key (the slugged PATH, `packPathKey`). This is
* the SECURITY GATE: a plain content note's `wants:` is IGNORED (only `#pack` notes
* contribute), and the metadata read is DELIBERATELY SEPARATE from the content-only prompt
* read (loading a note for context never adds capabilities). The backend unions these with
* the def's own `spec.name` grants. `subject` resolves the subject-scoped thread note.
* Absent `metadata.loadout` / no pack with `wants:` → an empty array (every current thread).
* Only a durable transport (the VaultTransport) implements it; others omit it.
*/
readThreadPackKeys?(
channel: string,
name: string,
subject?: string,
): Promise<string[]>;
/**
* Optional: write an agent-to-agent CALLBACK as an INBOUND note on THIS channel (the

@@ -282,0 +300,0 @@ * "reply_to" substrate). A recipient agent's drain, on turn completion, calls this on the

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display