Dev.to AI 🤖 Ai 👁 0 📖 3 min read

Writing a TokenCap Plugin, and What the Sandbox Stops It From Doing

TokenCap 3 added a local plugin runtime. The contract is small on purpose: two hooks, named tools, and one capability, which is reading and writing files in the plugin's own folder. This post walks through a plugin and t

TokenCap 3 added a local plugin runtime. The contract is small on purpose: two hooks, named tools, and one capability, which is reading and writing files in the plugin's own folder. This post walks through a plugin and then through each limit, with how it is enforced in src/plugins/runtime.js.

Registering a plugin

Plugins are opt-in per project, from that project's own package.json:

{
  "tokencapPlugins": ["./tools/tokencap-plugin.js"]
}

Each entry must be a workspace-relative path that starts with ./ and points to a file inside the workspace. Package names and remote modules are refused.

A plugin file

// tools/tokencap-plugin.js
module.exports = {
  name: "build-log",
  version: "1.0.0",
  hooks: {
    afterBuild(ctx) {
      ctx.artifacts.write("last-build.json", JSON.stringify(ctx.build));
      return { logged: true };
    },
    beforeRedact(ctx) {
      return ctx.content.replace(/internal\.corp\.example/g, "[internal-host]");
    },
  },
  tools: [
    {
      name: "last-build",
      handler(ctx) {
        return ctx.artifacts.read("last-build.json");
      },
    },
  ],
};

The runtime validates the shape when it loads the file. The name must be 1 to 64 characters of letters, digits, - or _, starting with a letter or digit. A version is required. Any hook other than afterBuild or beforeRedact is rejected, and two plugins with the same name or the same tool name are refused.

The two hooks

afterBuild runs after tokencap make. ctx.build holds outputPath, fileCount and estimatedTokens. When any plugin hook ran, the build prints one summary line:

Plugins:                     1 hook(s), 0 error(s)

beforeRedact receives ctx.content and ctx.filePath and runs before TokenCap's own redaction. It is called synchronously, so return the new string (or { content }) directly. If a plugin throws or returns an error, its change is skipped and core redaction runs on the original content. The core engine stays authoritative.

Tools

tokencap analyze plugins list
tokencap analyze plugins run last-build --text "input"

list prints Plugins: 1 loaded, 0 error(s). run passes --text to the handler as ctx.input.text. Add --json to either command for the full result.

What the sandbox enforces

A separate realm. Each plugin gets its own V8 context with a null-prototype global, so this.constructor cannot resolve to the host's Object. Code generation from strings and WebAssembly code generation are both turned off. Host callbacks stay in a closure the plugin cannot reach. Payloads go in as JSON strings and come back as JSON, and the payload object a hook receives is frozen. The test suite tries six constructor paths back to the host realm, and all six come back blocked.

No require, no process, no network. Inside a plugin, typeof require and typeof process are both "undefined". The only I/O is ctx.artifacts.read, write and list.

One folder. Every artifact path resolves under .tokencap/plugins/<name>/. Empty paths, .. and absolute paths are rejected. Each existing path component from .tokencap down is checked with lstat, and a symlink or junction anywhere on that path is an error. The source names the case it is written for: a committed .tokencap/plugins/<name> link pointing at ~/.ssh. The check runs before and after the directory is created, and the file is opened with O_NOFOLLOW where the platform supports it.

A clock. Each hook or tool call has 1 second, and loading a plugin has 150 ms. Promise callbacks run inside the timed call, so an async hook cannot slip past the limit; a call still pending after it is reported as not completed. A plugin that overruns is disabled until its file changes or the process restarts.

That last rule is a 3.1 fix. Before it, a looping hook could hang make with the build lock held.

The limit we state up front

The runtime's own header says it plainly: node:vm is not a security boundary, and plugins are trusted code. The rules above close the known host-object escapes and stop the common accidents: a path that walks out of its folder, a committed symlink, an infinite loop. They are not designed to contain a determined attacker running code on your machine.

So the usage docs end the plugin section the same way this post does: install plugins you trust.

Plugin docs at tokencap.vansharora.app

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.