Skip to content
7 min read · 1,459 words

Executable capability

This is a featured battery

Capabilities belong to the deployer. A plugin host does not trust a plugin to call its own permission check, nor does a plugin get to decide its own permissions by writing allowed-tools in a manifest.

A tool and a script are not the same risk, and a skill can supply three different kinds of executable capability:

  • A module tool is a live Tool a source produced by importing a package — it runs in your process, on your heap.
  • An isolated tool is JavaScript source the skill carries, evaluated inside a configured guest runtime.
  • A script is a file the skill ships, run in a child process with a materialized per-skill directory and a derived policy.

They are ordered by containment, and that ordering is the whole point: a module tool is bounded by the wrapper but not confined; an isolated tool cannot escape its guest; a script cannot escape its policy. Code you wrote belongs in the first. Code you downloaded belongs in the other two. (The source and the API call these tiers 1, 2, and 3, in that order.)

Module tools

A skill can ship executable tools, not just prose: a SkillDescriptor may carry live Tool instances that a source produced by importing a module. That is genuinely useful and genuinely dangerous. It is third-party code, and once loaded, the model can call it on any dispatch.

The rule is flat: every imported tool is rebuilt before it is ever registered. Not inspected, not validated against a policy flag, not trusted because it declares itself trustworthy — rebuilt, unconditionally, into a new wrapper the host owns. The original is never mutated and never reached by the model.

runToolGate is a convention a handler is supposed to call itself. A plugin author is precisely the party who will not — through malice, or far more often through simply not knowing they were meant to. A plugin host that trusts a plugin to run the host's own permission check has not implemented a permission check, so wrapping is neither optional nor configurable. The wrapper takes over the gate, error containment, response validation, artifact binding, provenance metadata (meta.skill and meta.skillVersion), and forcing trusted: false.

Bounded, not safe

None of this makes a tool safe. It makes it bounded. An imported tool is still a function running in your process, on your heap, reaching whatever the rest of your process can reach — the wrapper governs how it is called and what it may return, not what it can touch once it is running. The wrapper is the host boundary, not a sandbox. For code you genuinely do not trust, the containment story is an isolated guest or a child-process script.

The wrapper boundary

A source can return live tools from descriptor(ref). That is intentionally a host-side seam: bytes and markdown cannot create a handler. Once the descriptor crosses into the manager, every imported tool is rebuilt.

ts
import { createSkillManager } from '@nhtio/adk/batteries/skills'
import { Tool } from '@nhtio/adk/common'
import { validator } from '@nhtio/validation'

// In normal use createSkillManager performs the import and wrapping. A hostile
// tool that throws is exposed to the model as a skill-tool failure.
const source = /* a SkillSource whose descriptor returns { ...descriptor, tools: [hostile] } */ null as never
const hostile = new Tool({
  name: 'reports',
  description: 'Reports',
  inputSchema: validator.object({}).unknown(false),
  trusted: true,
  handler: () => { throw new Error('secret-error') },
})
const manager = await createSkillManager({
  sources: [source],
  gate: async () => undefined,
})
// After loading and calling the tool, the model receives E_SKILL_TOOL_FAILED;
// trusted is forced to false, and a returned SpooledArtifact would instead be
// rejected as E_SKILL_TOOL_BAD_RESPONSE.

What the wrapper forces

The wrapper runs runToolGate before the original executor. A thrown error becomes E_SKILL_TOOL_FAILED; a bare string is not treated as a recoverable Error. Valid responses are strings, Uint8Array, Media, or arrays of Media. undefined, objects, functions, and prebuilt SpooledArtifact values are rejected as E_SKILL_TOOL_BAD_RESPONSE.

The manager call above is the minimal host boundary. In a host that supplies an artifact policy, the same call can make the binding explicit:

ts
const manager = await createSkillManager({
  sources: [source],
  gate,
  artifactKinds: { report: () => ReportArtifact },
  artifactBindings: { reports: { reports: 'report' } },
})

That configuration does not make the plugin trusted: it only chooses how an otherwise valid response is spooled (see Tool results). Setting trusted: true on the incoming tool is ignored and produces an untrusted wrapper; returning an object or an already-built artifact produces E_SKILL_TOOL_BAD_RESPONSE. A thrown handler produces E_SKILL_TOOL_FAILED, while a name collision produces E_SKILL_TOOL_COLLISION before registration.

Wrappers are untrusted by construction: trusted is false even if the supplied tool claimed true. The manager also preflights names against existing tools, reserved artifact readers, duplicate declarations (E_SKILL_TOOL_DUPLICATE), and the provider-safe name pattern. A collision prevents registration rather than allowing a plugin to replace a host capability.

The wrapper does not race an in-process handler against ctx.abortSignal. A handler that never settles hangs the turn. If cancellation matters, use an isolated guest or a script.

Isolated JavaScript

A SkillIsolationConfig supplies guest globals, modules, limits, and either resolveGuest or the explicit unsafe in-process opt-in. The isolated source (SkillIsolatedTool) is a complete async arrow expression (async (args) => { ... }) and receives validated JSON arguments. The guest gets only declared capabilities; there is no ambient fetch, process, require, Date.now, or Math.random in the default compartment.

The caller's ctx.abortSignal is passed to guest creation. Evaluation has its separate timeout, and the battery calls kill() on timeout.

The default in-process compartment's kill() is a no-op, so real cancellation requires a real worker or child runtime via resolveGuest. Otherwise, manager construction refuses the configuration unless unsafe.isolatedToolsInProcess is set explicitly.

Scripts

Configure SkillScriptConfig to enable tier-3 scripts. The following is the shape used by the passing manager integration test: the host supplies the child-process handle, workspace, policy translator, interpreter allow-list, and hard ceilings.

ts
import { createSkillManager } from '@nhtio/adk/batteries/skills'

const manager = await createSkillManager({
  sources: [source],
  gate,
  scripts: {
    handle: sandboxHandle,
    workspace: skillWorkspace,
    policy: sessionPolicy,
    translator: policyTranslator,
    interpreters: { sh: ['/bin/sh'] },
    interpreterReadPaths: [],
    maxTimeoutSeconds: 3,
    defaultTimeoutSeconds: 1,
    maxOutputBytes: 1_000,
    hostEnvIsolated: true,
  },
})

hostEnvIsolated: true is not cosmetic: without it, construction rejects the configuration as invalid. interpreterReadPaths is deliberately explicit, even when empty, so the host can review what the interpreter may read. The workspace and handle are host-owned resources and are disposed when the skill leaves the loaded set.

A descriptor's scripts entries declare name, source-relative path, description, interpreter, and optional typed params (SkillScriptParam). The battery builds argv; there is no free-form argv and values are never shell-interpolated. Optional positional parameters are rejected; flags are rendered as --flag value, and boolean flags are emitted only when true.

Generated tools use the naming convention run_<skill>_<script>, must match the provider-safe name pattern, and are limited to 64 characters. The script path is normalized and rejects absolute paths, traversal, and unsafe sandbox paths. Files are materialized through SkillWorkspace; the returned root is authoritative and disposed when the skill leaves the loaded set.

The tool requires hostEnvIsolated: true, passes env: {}, checks that the derived policy is a subset of the session policy, caps captured output at maxOutputBytes (spooled according to Tool results), and applies timeout_seconds between 1 and maxTimeoutSeconds. Timeout is an execution control, not a resource governor.

By default a nonzero exit code is a failure: the script's output is spooled as usual, but the tool throws E_SKILL_SCRIPT_FAILED (carrying the exit code and the retrievable id) rather than returning a success string, so a host can detect a broken or rejected script without parsing prose. Set failOnNonzeroExit: false for skills whose scripts use exit codes as ordinary signalling; the tool then returns the acknowledgement string — exit code, byte count, truncation flag, retrievable id — for any completed run.

Failure boundaries

The gate runs before any handler or process execution. An explicit permission refusal is surfaced through the gate's normal denial path; a broken or unavailable gate never masquerades as a permission refusal. Handler and child-process failures follow distinct, documented error chains so the host can audit or retry failures without granting a plugin a way to disguise them:

  • Module tools (in-process): The gate runs before the handler. An explicit permission refusal is surfaced through the gate's normal denial path. A thrown handler error is caught and wrapped as E_SKILL_TOOL_FAILED. An invalid output shape (objects, functions, undefined, or prebuilt SpooledArtifact values) produces E_SKILL_TOOL_BAD_RESPONSE. Name collisions with existing host tools or reserved readers fail preflight with E_SKILL_TOOL_COLLISION, while duplicate declarations within the same skill fail with E_SKILL_TOOL_DUPLICATE.
  • Scripts (child-process): A path containing traversal, leading separators, or an absolute path produces E_SKILL_SOURCE_PATH_REJECTED. An interpreter absent from interpreters cannot be selected. An explicit policy refusal produces E_SKILL_SCRIPT_DENIED, whereas an unavailable or broken gate produces E_SKILL_SCRIPT_GATE_UNAVAILABLE (503, never misrouted as a 403 refusal). If policy derivation attempts to widen permissions beyond the session boundary, execution fails with E_SKILL_SCRIPT_POLICY_WIDENED. Exceeding a deadline produces E_SKILL_SCRIPT_TIMEOUT. A nonzero exit code produces E_SKILL_SCRIPT_FAILED unless failOnNonzeroExit: false is configured. Materialization and disposal failures use E_SKILL_WORKSPACE_FAILED.

These two error hierarchies are intentionally separate: E_SKILL_TOOL_FAILED describes an imported module tool's thrown exception, while the script exceptions isolate workspace, path, interpreter, and child-process policy boundaries. Neither is silently converted into a successful tool result.

What is not enforced

No memory, CPU, or process-count limit is supplied by this battery. The model-supplied timeout is bounded for scripts and isolated evaluation, but it is not a general scheduler. In-process module handlers are not cancellable: one that never settles hangs the turn. A permissive script policy is available only when explicitly supplied as unsafe.scriptsPermissivePolicy; allowed-tools is carried nowhere as an enforcement rule.

For the body lifecycle and projection channels, see Context channels.