# OpenLife — toolset enforcement (Stories 10.1 + 10.2)

## What it is

A thin kernel-style enforcement layer that decides, at runtime, whether
the current Profile permits a given toolset category before the executor
spawns a subprocess, calls a CLI, or makes a delegation.

It builds on `ToolsetRegistry` (Story 5.3) and adds a single guard module:
`src/orchestrator/toolset/ToolsetGuard.ts`. The guard exposes:

```ts
isToolsetAllowed(toolset: ToolsetCategory): boolean   // never throws
assertToolsetAllowed(toolset: ToolsetCategory, hint?: string): void  // throws ToolsetBlockedError
```

`ToolsetBlockedError` has a stable error code: `toolset_blocked:<category>`.
Callers can match on `.code` instead of substring-matching `.message`.

## Opt-in by environment flag

The guard is gated by `OPENLIFE_TOOLSET_ENFORCEMENT`. The decision matrix:

| `OPENLIFE_TOOLSET_ENFORCEMENT` | Behavior |
|---|---|
| unset / empty / anything other than `on` | Enforcement OFF. Both `isToolsetAllowed` and `assertToolsetAllowed` short-circuit before consulting the registry. **This is the v1.4 default.** |
| `on` (case-insensitive) | Enforcement ON. Both functions consult `ToolsetRegistry.isAllowed(...)` against the active Profile. |

The `=on` default OFF stance for v1.4 is a **locked decision** from the
plan: enforcement soaks for one milestone. **v1.5 flips the default to
ON.** Plan ahead — anything that relies on profiles with restrictive
`toolsetAllowed` will become enforceable in v1.5.

## The five hook points

The guard is wired at exactly five places — the spots that historically
have been the easiest way to bypass governance:

| Site | Toolset | Why |
|---|---|---|
| `TaskExecutor.executeWithCodex` | `delegation` | Delegating cognition to the `codex` CLI. |
| `TaskExecutor.executeWithGemini` | `delegation` | Same, for `gemini`. |
| `TaskExecutor.runShellCommand` | `terminal` | The underlying `/bin/bash` spawn the two executors fan out to. |
| `Brain.thinkWithOpenAICLI` | `delegation` | `Brain` shell-out to the `codex` CLI. |
| `Brain.thinkWithGeminiCLI` | `delegation` | Same, for `gemini`. |

Note that `delegation` and `terminal` are orthogonal axes: a profile can
permit local shell tools (`terminal`) while forbidding delegation to
external cognitive CLIs (`delegation`). Both guards are checked
independently.

## Configuring a profile

`ToolsetRegistry` reads `profile.toolsetAllowed`:

| `toolsetAllowed` value | Meaning |
|---|---|
| `['*']` | Wildcard — every one of the 15 categories is allowed. |
| `['file', 'web', 'memory']` | Only those three. |
| `[]` | Locked-down — nothing allowed. |

The 15 categories are `file`, `terminal`, `web`, `browser`, `memory`,
`skills`, `workflows`, `squads`, `gateway`, `cron`, `delegation`, `mcp`,
`vision`, `tts`, `image_gen`.

Example: a profile that lets the operator run local shell tools but
forbids any LLM CLI shellout:

```bash
openlife profile create research-only \
  --toolset-allowed file,terminal,memory,skills,workflows,squads,web
openlife profile use research-only

OPENLIFE_TOOLSET_ENFORCEMENT=on openlife task run "do x"
#  → if `do x` routes through TaskExecutor.executeWithCodex, throws
#    `toolset_blocked:delegation`
```

## Catching the error

Callers can catch and inspect:

```ts
try {
  assertToolsetAllowed('terminal', 'MyCaller.run');
} catch (err) {
  if (err instanceof ToolsetBlockedError) {
    console.log(err.code);     // "toolset_blocked:terminal"
    console.log(err.toolset);  // "terminal"
    console.log(err.message);  // "toolset_blocked:terminal (MyCaller.run)"
  }
}
```

The error message ends with the `hint` parameter when provided, so the
operator can trace which call site triggered the block.

## Diagnosing during the soak window

Because v1.4 keeps the default OFF, the easiest way to see how a profile
would behave under v1.5's default-ON is to run the relevant CLI command
with the flag turned on once:

```bash
OPENLIFE_TOOLSET_ENFORCEMENT=on openlife <command>
```

If anything fails with `toolset_blocked:*` you have until the v1.5 cutover
to either widen the profile's `toolsetAllowed`, move the call site
behind a different toolset axis, or accept that the operation should
indeed be blocked.

## How it's tested

`src/test_toolset_enforcement.ts` covers four scenarios:

1. Flag unset — every `isToolsetAllowed` returns `true`, `assertToolsetAllowed` never throws.
2. Flag ON + `toolsetAllowed: ['*']` — same as above.
3. Flag ON + `toolsetAllowed: ['memory']` — `terminal` and `delegation` return `false` / throw `ToolsetBlockedError` with the stable code.
4. The error class is `instanceof ToolsetBlockedError` in every blocked case, so callers can pattern-match on the class.

CI runs this test in the `test:all` chain.
