bounded
v3.3.0
Published
Bounded: guardrails for AI coding agents. Typed packs and extension points, the protected-paths pack, and hooks for Claude Code and pi, with the bounded command to set a project up
Readme
bounded
Guardrails for AI coding agents. Bounded judges every tool call an agent
makes (Claude Code or pi), before it runs, against the packs a project
selects. A refusal names the rule, the pack that contributed it, and what to
do instead. Every decision is recorded in .bounded/log.jsonl. The
protected-paths pack refuses reads, listings and writes of the paths a project
protects, including those a shell command names, and puts back what a shell
command changed in them.
It runs on Node 22.18 or later, with npm, pnpm, yarn or bun.
Start
In your project (it needs a package.json):
npx bounded initThis adds bounded as a devDependency. It writes bounded.config.ts,
selecting the protected-paths pack, which brings in the core, with default rules that keep agents
off this configuration, .bounded/, Claude Code's settings, pi's loader,
Bounded's installed code and git's hooks and config. It installs the hooks of the agent hosts the
project uses (.claude/, .pi/, or --host claude-code, --host pi).
Restart (or start) the hosts' sessions afterwards, as init says, so they
load the hooks. After bounded update, the CLI says, per host, whether
sessions must restart: Claude Code's only when its settings changed, pi's
every time.
Then add your own rules beside the defaults:
// bounded.config.ts
import { contribution, defineConfig } from "bounded/domain";
import { protectedPathsPack } from "bounded/protected-paths";
export default defineConfig({
packs: [protectedPathsPack],
contributes: [
contribution(protectedPathsPack.points.protectedPaths, [
// ...init's default rules...
{ match: "secrets/**", deny: ["read", "create", "modify", "delete"], why: "secrets are kept by people", redirect: "Ask a maintainer for the value you need" },
]),
],
});Run npx bounded update to upgrade Bounded and refresh the hooks.
What it is not
Bounded discourages agents and keeps a record; it is not a security boundary. An agent running as your user can always get round it. For enforcement, pair it with your host's operating-system sandbox, such as Claude Code's sandbox mode. Known ways round it:
- drift does not watch
node_modulesor.git, so what a command changes there is not put back; - commands the protected-paths pack does not recognise as writing, such as
sed -i,perl -i,node -eandpython -c; git config …andgit -c core.hooksPath=…, which the protected-paths pack sees as reads, so they get past the rules on git's hooks and config;- paths the protected-paths pack cannot resolve (variables, globs), which it allows;
- user-level settings outside the project, such as
~/.claude/settings.json; - writes delayed into the background, after the tool call is judged;
- drift's snapshots, kept in a state directory the user (and so the agent) can write.
Export paths
A configuration imports bounded/domain and the packs it selects, such as
bounded/protected-paths and bounded/prereqs. The adapter export paths,
bounded/adapters, bounded/protected-paths/adapters and
bounded/prereqs/adapters, are internal: they serve the hooks for Claude
Code and pi that this package carries and its bounded command, and may
change in any release.
3.1.0 deliberately breaks what 3.0.0 published, in a
minor release because 3.0.0 was about an hour old with no users: it replaced
3.0.0's bounded/adapters/{file-system,in-memory,system} and
bounded/path-gate/adapters/{file-system,in-memory,tree-sitter}, removed
their in-memory doubles and pathGateInMemory, and made the clock and
decision-id ports give DecisionTime and DecisionId (the repository's
ADR 2026-017). 3.2.0, also a minor release by the maintainer's choice,
deliberately breaks the types 3.0.0 and 3.1.0 published: a selection now
brings in every pack its listed packs depend on, so Config.selectedPacks
is Config.listedPacks, compose-packs' input field selectedPackIds is
listedPackIds, and SelectedPacks.packs holds every selected pack, listed
or brought in, beside the new listedPacks (the repository's ADR
2026-018). It also reads shell commands once, in the core's judge (the
repository's ADR 2026-020): openProject requires a shellCommandReader,
pathGatePortProvisions() gives two provisions, and bounded/path-gate
no longer exports PathKind, PathKinds, ShellCheck, ShellParser,
pathKindsPort or shellParserPort (nor its adapters
TreeSitterShellParser and FileSystemPathKinds). 3.3.0 renames the path
gate the protected-paths pack (the repository's ADR 2026-021):
bounded/path-gate and bounded/path-gate/adapters are now
bounded/protected-paths and bounded/protected-paths/adapters, and
pathGate and pathGatePortProvisions are protectedPathsPack and
protectedPathsPortProvisions; the old names are removed, with nothing kept
for them. 3.3.0 also renames the guard log the Bounded log (the repository's
ADR 2026-022), breaking what 3.2.0 published, with no aliases:
bounded/application no longer exports GuardLog and ProjectGuardLogs
(now BoundedLog and ProjectBoundedLogs), openProject's guardLog
option is boundedLog, bounded/adapters no longer exports
FileSystemGuardLog and FileSystemProjectGuardLogs (now
FileSystemBoundedLog and FileSystemProjectBoundedLogs), and decisions
go to .bounded/log.jsonl; an existing .bounded/guard-log.jsonl is left
as it was. 3.3.0 also corrects how shell commands are read (the
repository's ADR 2026-020, corrected): the host adapter reads each one, not
the core's judge, breaking what 3.2.0 published, with no aliases:
bounded/application no longer exports ShellCommandReader (now
bounded/shell-command-reader's), openProject and OpenProjectHandler
take no shellCommandReader, JudgeEventHandler takes no shellCommandReader,
projectRoot or readWithinMs and has no DEFAULT_READ_WITHIN_MS,
ExecuteEffect.reading is never null and ExecuteEffectJSON.reading is
required, bounded/hosts/pi's translate gives the call before its
readings, and its piExtension (ExtensionOptions) requires a
readShellCommand. A host that sends an execute effect without a reading, as every
3.2.0 host did, has each such call refused as an event that cannot be read
and recorded as invalid; a tool result's execute effects must now carry a
reading too.
A host other than the two this package carries opens a project with the ports its packs need, and reads each shell command with the reader bounded publishes, putting the reading on the execute effect it builds:
import { openProject } from "bounded/open-project";
import { protectedPathsPortProvisions } from "bounded/protected-paths/adapters";
import { prereqsPortProvisions } from "bounded/prereqs/adapters";
import { openShellCommandReading } from "bounded/shell-command-reader";
const project = await openProject(root, { ports: [...protectedPathsPortProvisions(), ...prereqsPortProvisions()] });
const shellCommandReading = openShellCommandReading();
void shellCommandReading.prepare();
const reading = await shellCommandReading.read({ projectRoot: root, command, cwd: null });
const verdict = await project.judge({ kind: "tool-use", role: null, tool: "shell", effects: [{ kind: "execute", command, cwd: null, reading }] });An execute effect without a reading is refused; one whose command could not
be read carries why, and the protected-paths pack refuses it. A host with a
shell of its own may write its own reader of the ShellCommandReader port
(bounded/shell-command-reader, which gives ReadShellCommandHandler to
read with it); its tests run the suite every reader runs,
bounded/testing/shell-command-reader-conformance (under bun's test
runner).
More
The design, the configuration, the packs and the decisions behind them are in the repository: https://github.com/bounded-dev/bounded.
MIT licence.
