mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
---
|
||||
issue: 385
|
||||
issue_title: "pkg:pi-permission-system — Respect pi default active tool set instead of activating all non-denied tools"
|
||||
---
|
||||
|
||||
# Respect pi's default active tool set in `before_agent_start`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`AgentPrepHandler.handle()` (`src/handlers/before-agent-start.ts`) builds the active tool set from `pi.getAllTools()` — _every_ registered tool — and re-activates all of them except those explicitly denied.
|
||||
Pi ships `find`, `grep`, and `ls` as built-in tools that are **off by default** (pi's default active set is `read`, `bash`, `edit`, `write`).
|
||||
Because the handler starts from the full registry, it silently turns these off-by-default tools _on_ in every session, overriding pi's own activation decision.
|
||||
|
||||
This produces two downstream symptoms:
|
||||
|
||||
1. The main session always sees `find`, `grep`, `ls`, defeating pi's default of keeping them inactive.
|
||||
2. The only workaround — denying them globally (`"find": "deny"`) — also strips them from subagents that explicitly request them via `tools:` frontmatter, since `before_agent_start` fires for subagent sessions too.
|
||||
|
||||
The fix is to start from `pi.getActiveTools()` (pi's _already-active_ set for the current session) instead of `pi.getAllTools()`.
|
||||
The permission system then only ever **removes** tools (deny) — it never grants a tool pi left off.
|
||||
This is the restrict-only contract the extension is meant to honor.
|
||||
|
||||
Ben Tang (@0xbentang) reported this issue and submitted reference PR [#386], which is the basis for this plan.
|
||||
This plan adopts that approach and improves on it in two places: the `getActive()` type fidelity and an explicit regression test (see Design Overview).
|
||||
The implementation commits should credit Ben with a `Co-authored-by: Ben Tang <bentang@fastmail.com>` trailer (see TDD Order).
|
||||
|
||||
## Goals
|
||||
|
||||
- Add `getActive()` to the `ToolRegistry` interface, wired to `pi.getActiveTools()` in `index.ts`.
|
||||
- Change `AgentPrepHandler.handle()` to compute the allowed set from `getActive()` instead of `getAll()`.
|
||||
- Preserve `getAll()` for `PermissionGateHandler` tool-call validation — registration checks must still see the full registry.
|
||||
- Type `getActive(): string[]` to match the real `pi.getActiveTools(): string[]` contract (PR #386 typed it `unknown[]`); update mocks/fakes to return `string[]` for fidelity.
|
||||
- Add a regression test proving off-by-default tools (`find`/`grep`/`ls`) present in the registry but absent from the active set are **not** activated.
|
||||
- **Breaking change.**
|
||||
On upgrade, the main session's effective tool set changes without a user edit: tools pi leaves off (e.g. `find`, `grep`, `ls`) are no longer auto-activated by the permission system.
|
||||
Users who want them active should launch pi with the `--tools` CLI flag (there is no persistent config-file key for the active set).
|
||||
Ship as `fix!:` with a `BREAKING CHANGE:` footer.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No escape hatch to re-grant pi's off-by-default tools through permission config.
|
||||
The extension stays restrict-only; activating tools is pi's job — done at launch via the `--tools` / `-t` CLI flag (e.g. `pi --tools read,bash,edit,write,grep,find,ls`) or programmatically via `createAgentSession({ tools: [...] })`.
|
||||
There is no persistent settings-file key for the active tool set.
|
||||
- No change to `PermissionGateHandler` / `validateRequestedTool` — tool-call validation continues to use `getAll()` (the full registry), which is correct: a denied-but-registered tool should still validate as "registered" and then be gated, not rejected as "unknown".
|
||||
- No split of the `ToolRegistry` interface (see Design Overview — track-and-watch, not now).
|
||||
- No change to skill or prompt sanitization logic in the handler.
|
||||
|
||||
## Background
|
||||
|
||||
Relevant modules:
|
||||
|
||||
- `src/handlers/before-agent-start.ts` — `AgentPrepHandler.handle()` is the only Phase-1 (tool-filtering) consumer.
|
||||
It currently calls `this.toolRegistry.getAll()`, maps each entry through `getToolNameFromValue`, drops denied tools via `shouldExposeTool`, then `setActive(allowedTools)` (guarded by the `activeToolsGate` cache).
|
||||
- `src/tool-registry.ts` — the narrow `ToolRegistry` interface (`getAll(): unknown[]`, `setActive(names: string[]): void`) plus `getToolNameFromValue` and the registration-check helpers used by `PermissionGateHandler`.
|
||||
- `src/index.ts` (~line 153) — composition root wires `toolRegistry = { getAll: () => pi.getAllTools(), setActive: (n) => pi.setActiveTools(n) }`.
|
||||
- `src/handlers/permission-gate-handler.ts` — Phase-2 (`tool_call`) consumer; calls `this.toolRegistry.getAll()` for `validateRequestedTool`.
|
||||
**Stays on `getAll()`.**
|
||||
|
||||
Pi SDK contract (`@earendil-works/pi-coding-agent`, `core/extensions/types.d.ts`):
|
||||
|
||||
- `getActiveTools(): string[]` — currently active tool **names**.
|
||||
- `getAllTools(): ToolInfo[]` — all configured tools as objects (`{ name, description, parameters, promptGuidelines, ... }`).
|
||||
- `setActiveTools(toolNames: string[]): void`.
|
||||
|
||||
Note the shape asymmetry: `getActiveTools` returns `string[]`, `getAllTools` returns `ToolInfo[]`.
|
||||
`getToolNameFromValue` already handles both a bare string (via `getNonEmptyString`) and an object (via `.name`/`.toolName`/`.tool`), so the handler loop works unchanged regardless of source.
|
||||
|
||||
AGENTS.md / skill constraints that apply:
|
||||
|
||||
- `@typescript-eslint/require-await` is enabled for `src/` — the handler is already `async` with an `eslint-disable` line; unchanged.
|
||||
- Keep schema/example/docs/types aligned — this change touches no config field, but it does touch `docs/configuration.md` Pi-integration-hooks behavior wording (clarify restrict-only).
|
||||
- The handler fires for subagent sessions too; `getActiveTools()` returns the subagent's own active set there, so deny still correctly prunes a subagent's requested tools — behavior unchanged on that axis.
|
||||
|
||||
## Design Overview
|
||||
|
||||
### Decision model
|
||||
|
||||
`before_agent_start` recomputes the active set on every fire.
|
||||
Switching the base set from "all registered" to "currently active" makes the operation purely subtractive:
|
||||
|
||||
```text
|
||||
allowed = getActive() minus { tools denied for this agent }
|
||||
setActive(allowed)
|
||||
```
|
||||
|
||||
Idempotence check (no oscillation across repeated fires):
|
||||
|
||||
- Fire 1: active = `[read, bash, edit, write]`, deny `bash` → allowed = `[read, edit, write]` → `setActive([read, edit, write])`.
|
||||
- Fire 2: `getActive()` now returns `[read, edit, write]` (what we just set); `bash` already absent → allowed = `[read, edit, write]`.
|
||||
Stable; the `activeToolsGate` cache short-circuits the redundant `setActive`.
|
||||
|
||||
Because the set only shrinks toward a fixed point, there is no risk of the handler re-adding or thrashing tools across fires.
|
||||
|
||||
### Type shape
|
||||
|
||||
`pi.getActiveTools()` returns `string[]`, so the interface reflects that directly:
|
||||
|
||||
```typescript
|
||||
export interface ToolRegistry {
|
||||
getAll(): unknown[]; // ToolInfo[] from pi.getAllTools() — kept defensively wide
|
||||
getActive(): string[]; // names from pi.getActiveTools()
|
||||
setActive(names: string[]): void;
|
||||
}
|
||||
```
|
||||
|
||||
The handler change is a single line — `getAll()` → `getActive()`.
|
||||
The loop keeps `getToolNameFromValue(tool)`: it accepts a bare string and returns it (filtering empties), so the defensive normalization stays in place and the diff stays minimal.
|
||||
|
||||
### Consumer call site (composition root, `index.ts`)
|
||||
|
||||
```typescript
|
||||
const toolRegistry = {
|
||||
getAll: () => pi.getAllTools(),
|
||||
getActive: () => pi.getActiveTools(), // string[]
|
||||
setActive: (names: string[]) => pi.setActiveTools(names),
|
||||
};
|
||||
```
|
||||
|
||||
`AgentPrepHandler` reads `getActive` + `setActive`; `PermissionGateHandler` reads `getAll`.
|
||||
No reach-through, no output arguments, no mutation of the registry — Tell-Don't-Ask and LoD hold.
|
||||
|
||||
### Design-review note (shared interface gaining a field)
|
||||
|
||||
After this change the two consumers use disjoint slices of `ToolRegistry`: `AgentPrepHandler` → `{ getActive, setActive }`, `PermissionGateHandler` → `{ getAll }`.
|
||||
That is a latent ISP seam (the interface could split into an active-tool controller vs. a registry reader).
|
||||
Splitting a 3-method interface now is premature — **track-and-watch**.
|
||||
Record it; revisit only if the interface grows or a third consumer appears.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Empty active set.**
|
||||
If `getActiveTools()` returns `[]` (e.g. a session with no tools yet), `setActive([])` is a no-op-equivalent — same as today when all tools are denied.
|
||||
- **Subagent session.**
|
||||
`getActiveTools()` returns the subagent's frontmatter-driven set; deny still prunes it.
|
||||
Behavior on that axis is unchanged (this is the point — global deny still blocks a subagent, but you no longer _need_ to deny `find`/`grep`/`ls`, so the symptom disappears).
|
||||
- **Lifecycle timing risk** — see Risks: must confirm `getActiveTools()` is already populated when `before_agent_start` fires.
|
||||
|
||||
## Module-Level Changes
|
||||
|
||||
- `src/tool-registry.ts` — add `getActive(): string[]` to the `ToolRegistry` interface.
|
||||
No other code in this file changes (`getToolNameFromValue` and the registration helpers are untouched).
|
||||
- `src/index.ts` — add `getActive: () => pi.getActiveTools()` to the `toolRegistry` literal (~line 154).
|
||||
- `src/handlers/before-agent-start.ts` — replace `const allTools = this.toolRegistry.getAll();` with `this.toolRegistry.getActive();`.
|
||||
Update the constructor JSDoc that lists deps as "getAll + setActive" → "getActive + setActive". (`PermissionGateHandler`'s JSDoc still says "getAll + setActive" and stays accurate — it uses getAll.)
|
||||
- `docs/configuration.md` — clarify the `before_agent_start` row / "Additional behaviors" list: the permission system filters pi's **already-active** tool set (restrict-only) and does not activate tools pi leaves off by default.
|
||||
|
||||
Test files (fixtures + specs):
|
||||
|
||||
- `test/handlers/before-agent-start.test.ts` — add `getActive` to the local `makeToolRegistry`; migrate the three `getAll`-based active-set assertions to `getActive` returning `string[]`; add the regression test.
|
||||
- `test/helpers/handler-fixtures.ts` — add `getActive: vi.fn().mockReturnValue(["read", "bash"])` to the shared `makeToolRegistry`.
|
||||
- `test/handlers/external-directory-session-dedup.test.ts` — add `getActive` to the two inline tool-registry stubs (return `string[]`).
|
||||
- `test/handlers/tool-call.test.ts` — add `getActive` to the two stubs (these exercise `PermissionGateHandler`, which uses `getAll`; `getActive` is added only to satisfy the interface — return `string[]`).
|
||||
- `test/helpers/make-fake-pi.ts` — add `getActiveTools(): string[]` returning `toolNames` (bare names, matching the real SDK shape) and add it to the `FakePi` interface.
|
||||
- `test/permission-events.test.ts`, `test/session-start.test.ts` — add `getActiveTools` to the fake `ExtensionAPI` objects (composition-root wiring tests).
|
||||
|
||||
Grep confirmation performed: `getAll` / `getActive` / `setActive` consumers are exactly the two handlers above; no `.pi/skills/package-*/SKILL.md` references the `ToolRegistry` method names; `docs/architecture/architecture.md:504` describes `AgentPrepHandler`'s deps generically (`toolRegistry`) without enumerating method names, so no architecture-doc edit is required.
|
||||
|
||||
## Test Impact Analysis
|
||||
|
||||
This is a one-line behavior change on a shared seam, not an extraction, so the test surface is mostly fixture plumbing.
|
||||
|
||||
1. **New tests enabled:** a focused regression test that was previously impossible to express because the handler ignored the active/registered distinction — given `getActive() = [read, bash, edit, write]` and `getAll() = [...those, find, grep, ls]` (all permission-allowed), assert `setActive` is called with exactly `[read, bash, edit, write]`.
|
||||
Under the old code (using `getAll`) this would have activated all seven; it is the canonical guard for #385.
|
||||
2. **Redundant/changed tests:** the three existing assertions ("filters out denied tools", "includes allowed and ask tools", "calls setActive once") move their mock from `getAll` to `getActive` and switch the returned shape to `string[]`.
|
||||
They are not redundant — they still verify deny-filtering, allow/ask inclusion, and cache dedup — but they now exercise the correct source.
|
||||
3. **Tests that stay as-is:** `shouldExposeTool` pure-helper tests (unaffected), prompt-sanitization tests, skill-entry tests, and all `PermissionGateHandler` / `validateRequestedTool` tests (still validate against `getAll`).
|
||||
|
||||
## TDD Order
|
||||
|
||||
1. **Interface + composition-root wiring.**
|
||||
Red: add `getActive` to `ToolRegistry`; the type checker / composition-root tests fail until `index.ts` and every fake/fixture implement it.
|
||||
Green: add `getActive: () => pi.getActiveTools()` to `index.ts`; add `getActiveTools` to `make-fake-pi.ts`, `handler-fixtures.ts` `makeToolRegistry`, and the two composition-root fake APIs (`permission-events.test.ts`, `session-start.test.ts`).
|
||||
Because adding a method to the interface breaks every implementer at the type level in one commit, fold the interface addition, the `index.ts` wiring, and all fixture/fake updates into this single step.
|
||||
Commit: `feat: add getActive to ToolRegistry wired to pi.getActiveTools (#385)`.
|
||||
|
||||
2. **Regression test for off-by-default tools (red→green).**
|
||||
Red: in `before-agent-start.test.ts`, add the test — `getActive` returns `[read, bash, edit, write]`, all permission-allowed — asserting `setActive` is called with exactly `[read, bash, edit, write]`.
|
||||
With the handler still on `getAll`, this fails (or is wired to `getAll` returning the superset and fails by including `find`/`grep`/`ls`).
|
||||
Green: change `AgentPrepHandler.handle()` to call `getActive()`; update the three existing active-set assertions to use `getActive` returning `string[]`; add `getActive` to the inline stubs in `external-directory-session-dedup.test.ts` and `tool-call.test.ts`.
|
||||
Commit: `fix!: respect pi's default active tool set in before_agent_start (#385)` with a `BREAKING CHANGE:` footer noting that the permission system no longer auto-activates pi's off-by-default tools (`find`, `grep`, `ls`) in the main session; users wanting them active should launch pi with `--tools read,bash,edit,write,grep,find,ls` (no persistent config-file key exists for the active set).
|
||||
|
||||
3. **Docs.**
|
||||
Update `docs/configuration.md` to clarify the restrict-only filtering behavior.
|
||||
Commit: `docs: clarify before_agent_start filters pi's active tool set (#385)`.
|
||||
|
||||
Steps 1 and 2 could be merged (the interface addition and handler switch are tightly coupled), but keeping the regression test and behavior flip in their own `fix!:` commit isolates the breaking change for the changelog.
|
||||
Prefer the split.
|
||||
|
||||
Add a `Co-authored-by:` trailer to the implementation commits (steps 1 and 2 at minimum) to credit the reporter and reference-PR author:
|
||||
|
||||
```text
|
||||
Co-authored-by: Ben Tang <bentang@fastmail.com>
|
||||
```
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- **Lifecycle timing — is `getActiveTools()` populated when `before_agent_start` fires?**
|
||||
If pi initializes its default active set _after_ this event, `getActiveTools()` could return an empty or unexpected set, breaking tool exposure entirely.
|
||||
Mitigation: verify against the `pi-extension-lifecycle` skill and/or a live smoke test that `getActiveTools()` returns pi's defaults (`read`, `bash`, `edit`, `write`) at `before_agent_start` time before relying on it.
|
||||
The reference PR #386 was authored by the issue reporter, suggesting they validated this empirically — confirm during TDD.
|
||||
- **Test fidelity drift.**
|
||||
PR #386's mocks return objects (`{ name: "write" }`) for `getActive`, but the real API returns `string[]`.
|
||||
Mitigation: this plan types `getActive(): string[]` and returns bare strings from every mock/fake so tests match the real contract.
|
||||
- **Breaking-change surprise for existing users.**
|
||||
Users relying on `find`/`grep`/`ls` being active in the main session lose them on upgrade.
|
||||
Mitigation: prominent `BREAKING CHANGE:` footer + changelog (release-please) pointing to the `--tools` CLI flag as the supported way to enable them.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Does pi guarantee `getActiveTools()` reflects the user's configured `activeTools` (not just the built-in default four) at `before_agent_start`?
|
||||
If a user configured extra active tools at the pi level, the fix should preserve them — confirm during the lifecycle-timing check above.
|
||||
Defer until TDD step 2's smoke test.
|
||||
|
||||
[#386]: https://github.com/gotgenes/pi-packages/pull/386
|
||||
Reference in New Issue
Block a user