20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 78 | Change denied tool message |
Soften denial messages
Problem Statement
When a tool call is denied, the extension appends a "Hard stop" suffix to the denial reason returned to the agent:
Hard stop: this permission denial is policy-enforced. Do not retry or investigate bypasses; report the block to the user.
This causes two problems:
- The aggressive language makes the LLM interpret the denial as a blanket ban on all similar operations (e.g., all writes), not just the specific call that was denied.
- No denial message identifies
pi-permission-systemas the extension making the decision — the agent has no way to know where the policy lives or which extension blocked it.
Both problems trace to a structural issue: denial message text is scattered across 6 gate files and 2 shared hint functions, with no single point of control.
Goals
- Centralize all denial message formatting into a single "sink" module so message text, tone, and attribution are controlled in one place.
- Attribute every denial to
pi-permission-systemso the agent knows which extension is gatekeeping. - Remove all "Hard stop" / "Do not retry" behavioral instructions.
- Replace with informative, scoped messages that describe what was denied, who denied it (policy rule vs. user at prompt), and why (including any user-supplied reason), without prescribing what the agent should do next.
Non-Goals
- Making the denial message text user-configurable (possible follow-up).
- Changing
applyPermissionGate— it stays unchanged; the runner constructs themessagesit needs from the new formatter. - Changing the "ask" prompt wording (messages shown to the user when asking for approval).
- Moving
formatAskPrompt/formatSkillAskPrompt/formatMissingToolNameReason/formatUnknownToolReason— these are prompt or pre-check messages, not denial messages.
Background
Current architecture (formatting pushed upstream)
Each of the 6 gate functions pre-formats three message strings (denyReason, unavailableReason, userDeniedReason) and embeds them in the GateDescriptor.messages object.
The runner passes those strings to applyPermissionGate, which returns the appropriate one as the block reason.
Neither the runner nor applyPermissionGate has any control over message content — they are dumb pass-throughs.
Gate (6 files) Descriptor Runner applyPermissionGate
────────────── ────────── ────── ───────────────────
Pre-formats 3 → Carries pre- → Passes to → Returns pre-formatted
message strings formatted strings gate function string as block reason
This is why:
- The "Hard stop" text ended up duplicated in 5 places (2 functions + 3 inline strings).
- No gate thought to mention
pi-permission-system— each composes its own text independently. - Changing tone or attribution requires editing every gate.
Denial message sources (current)
| Source | File | What it formats |
|---|---|---|
formatPermissionHardStopHint |
src/permission-prompts.ts |
Tool/bash/MCP "Hard stop" suffix |
formatDenyReason |
src/permission-prompts.ts |
Tool/bash/MCP policy deny |
formatUserDeniedReason |
src/permission-prompts.ts |
Tool/bash/MCP user deny |
formatExternalDirectoryHardStopHint |
src/handlers/gates/external-directory-messages.ts |
External-directory "Hard stop" suffix |
formatExternalDirectoryDenyReason |
src/handlers/gates/external-directory-messages.ts |
External-directory policy deny |
formatExternalDirectoryUserDeniedReason |
src/handlers/gates/external-directory-messages.ts |
External-directory user deny |
formatBashExternalDirectoryDenyReason |
src/handlers/gates/external-directory-messages.ts |
Bash external-directory policy deny |
formatPathDenyReason |
src/handlers/gates/path.ts |
Path policy deny |
Inline in path.ts |
src/handlers/gates/path.ts |
Path user deny |
Inline in bash-path.ts |
src/handlers/gates/bash-path.ts |
Bash-path user deny |
Inline in bash-external-directory.ts |
src/handlers/gates/bash-external-directory.ts |
Bash external-directory user deny |
Inline in skill-read.ts |
src/handlers/gates/skill-read.ts |
Skill-read user deny |
Relevant AGENTS.md constraints
- Keep scope tight; prefer small, reversible changes.
- Prefer explicit configuration over hidden behavior.
- Keep modules focused and composable (one concern per file).
Design Overview
Target architecture (formatting at the sink)
Gate (6 files) Descriptor Runner (the sink)
────────────── ────────── ─────────────────
Builds structured → Carries Calls formatDenialMessage()
DenialContext DenialContext → to produce messages, then
(no message text) (no messages) passes them to applyPermissionGate
Gates provide what happened as structured data.
The runner — the single point where block reasons are finalized — constructs the messages object by calling a centralized formatter.
applyPermissionGate stays unchanged; it still receives messages as before.
DenialContext discriminated union
Each gate surface carries the minimum fields the formatter needs:
type DenialContext =
| {
kind: "tool";
check: PermissionCheckResult;
agentName?: string;
input?: unknown;
}
| {
kind: "path";
toolName: string;
pathValue: string;
agentName?: string;
}
| {
kind: "external_directory";
toolName: string;
pathValue: string;
cwd: string;
agentName?: string;
}
| {
kind: "bash_external_directory";
command: string;
externalPaths: string[];
cwd: string;
agentName?: string;
}
| {
kind: "bash_path";
command: string;
pathValue: string;
agentName?: string;
}
| {
kind: "skill_read";
skillName: string;
readPath: string;
agentName?: string;
};
Centralized formatter
A single module (src/denial-messages.ts) exports three functions:
export const EXTENSION_TAG = "[pi-permission-system]";
export function formatDenyReason(ctx: DenialContext): string;
export function formatUnavailableReason(ctx: DenialContext): string;
export function formatUserDeniedReason(ctx: DenialContext, denialReason?: string): string;
Each function switches on ctx.kind to produce surface-specific text and appends EXTENSION_TAG.
All denial message text lives in this one file.
Example outputs:
Agent 'builder' is not permitted to run 'write' (matched 'write'). [pi-permission-system]
User denied tool 'write'. Reason: too risky. [pi-permission-system]
User denied access to path '/etc/passwd'. [pi-permission-system]
Current agent is not permitted to access path '/etc/passwd' via tool 'read'. [pi-permission-system]
Runner as the glue
In runGateCheck, after resolving the permission state and before calling applyPermissionGate, the runner constructs the messages object:
const messages = {
denyReason: formatDenyReason(descriptor.denialContext),
unavailableReason: formatUnavailableReason(descriptor.denialContext),
userDeniedReason: (decision) =>
formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
};
applyPermissionGate and PermissionGateParams.messages are unchanged.
Lift-and-shift migration
To avoid a big-bang rewrite, the migration is incremental:
- Add
denialContextas an optional field onGateDescriptoralongsidemessages. - Update the runner to construct
messagesfromdenialContextwhen present, falling back todescriptor.messageswhen not. - Migrate each gate to provide
denialContextinstead ofmessages, one family at a time. - Once all gates use
denialContext, make it required and removemessagesfromGateDescriptor.
Result shape
GateOutcome (returned by the runner to the orchestrator) is unchanged: { action: "block"; reason: string }.
PermissionGateParams and applyPermissionGate are unchanged.
The GateDescriptor.messages field is replaced by denialContext — this is the only interface change.
Module-Level Changes
src/denial-messages.ts (NEW)
- Add
DenialContextdiscriminated union type. - Add
EXTENSION_TAGconstant. - Add
formatDenyReason(ctx),formatUnavailableReason(ctx),formatUserDeniedReason(ctx, denialReason?). - All denial message text for all 6 surfaces lives here.
src/handlers/gates/descriptor.ts
- Add
denialContext: DenialContexttoGateDescriptor(optional during migration, required at end). - Remove
messagesfromGateDescriptor(final step).
src/handlers/gates/runner.ts
- Add import of formatter functions from
../../denial-messages. - Add
messagesconstruction fromdescriptor.denialContextbefore passing toapplyPermissionGate. - Remove usage of
descriptor.messages(final step).
src/handlers/gates/tool.ts
- Replace
messagesconstruction withdenialContext: { kind: "tool", check, agentName, input }. - Remove imports of
formatDenyReason,formatUserDeniedReasonfrom../../permission-prompts.
src/handlers/gates/path.ts
- Replace
messagesconstruction withdenialContext: { kind: "path", toolName, pathValue, agentName }. - Remove
formatPathDenyReasonexport (absorbed intodenial-messages.ts).
src/handlers/gates/bash-path.ts
- Replace
messagesconstruction withdenialContext: { kind: "bash_path", command, pathValue: worstToken, agentName }. - Remove import of
formatPathDenyReasonfrom./path.
src/handlers/gates/external-directory.ts
- Replace
messagesconstruction withdenialContext: { kind: "external_directory", toolName, pathValue, cwd, agentName }. - Remove imports of
formatExternalDirectoryDenyReason,formatExternalDirectoryUserDeniedReasonfrom./external-directory-messages.
src/handlers/gates/bash-external-directory.ts
- Replace
messagesconstruction withdenialContext: { kind: "bash_external_directory", command, externalPaths, cwd, agentName }. - Remove imports of
formatBashExternalDirectoryDenyReason,formatExternalDirectoryHardStopHintfrom./external-directory-messages.
src/handlers/gates/skill-read.ts
- Replace
messagesconstruction withdenialContext: { kind: "skill_read", skillName, readPath, agentName }. - Remove imports of
formatSkillPathDenyReasonfrom../../permission-prompts.
src/handlers/gates/external-directory-messages.ts
- Delete entire file (all functions absorbed into
denial-messages.ts).
src/permission-prompts.ts
- Remove
formatPermissionHardStopHint(deleted). - Remove
formatDenyReason(moved todenial-messages.ts). - Remove
formatUserDeniedReason(moved todenial-messages.ts). - Remove
formatSkillPathDenyReason(moved todenial-messages.ts). - Keep
formatMissingToolNameReason,formatUnknownToolReason(pre-check messages, not denial messages). - Keep
formatAskPrompt,formatSkillAskPrompt,formatSkillPathAskPrompt(user-facing prompts, not denial messages).
src/permission-gate.ts
- No change.
Removed-symbol audit
Symbols removed from public module exports:
formatPermissionHardStopHint— internal topermission-prompts.ts, imported intests/permission-prompts.test.ts.formatDenyReason— imported insrc/handlers/gates/tool.ts,tests/permission-prompts.test.ts.formatUserDeniedReason— imported insrc/handlers/gates/tool.ts,tests/permission-prompts.test.ts.formatSkillPathDenyReason— imported insrc/handlers/gates/skill-read.ts,tests/permission-prompts.test.ts.formatPathDenyReason— imported insrc/handlers/gates/bash-path.ts,tests/handlers/gates/path.test.ts(if it exists).formatExternalDirectoryHardStopHint— imported insrc/handlers/gates/bash-external-directory.ts,tests/handlers/external-directory-integration.test.ts,tests/handlers/gates/external-directory-messages.test.ts.formatExternalDirectoryDenyReason— imported insrc/handlers/gates/external-directory.ts,tests/handlers/external-directory-integration.test.ts,tests/handlers/gates/external-directory-messages.test.ts.formatExternalDirectoryUserDeniedReason— imported insrc/handlers/gates/external-directory.ts,tests/handlers/gates/external-directory-messages.test.ts.formatBashExternalDirectoryDenyReason— imported insrc/handlers/gates/bash-external-directory.ts,tests/handlers/gates/external-directory-messages.test.ts.formatBashExternalDirectoryAskPrompt— imported insrc/handlers/gates/bash-external-directory.ts,tests/handlers/gates/external-directory-messages.test.ts. Note: this is an ask-prompt function, not a denial message. Move topermission-prompts.ts(or keep in a reducedexternal-directory-messages.ts) rather than deleting.formatExternalDirectoryAskPrompt— imported insrc/handlers/gates/external-directory.ts,tests/handlers/gates/external-directory-messages.test.ts. Same treatment as above — ask-prompt, not denial message.
All import sites are covered in the gate migration steps.
Test Impact Analysis
New tests
tests/denial-messages.test.ts(NEW) — comprehensive tests forformatDenyReason,formatUnavailableReason,formatUserDeniedReasonacross all 6DenialContextkinds. Every test asserts the presence of[pi-permission-system]and the absence of "Hard stop". This single test file replaces denial-message assertions currently spread across 4 test files.
Tests that must change
tests/permission-prompts.test.ts— remove tests forformatPermissionHardStopHint,formatDenyReason,formatUserDeniedReason,formatSkillPathDenyReason(moved todenial-messages.test.ts). Keep tests forformatAskPrompt,formatSkillAskPrompt,formatMissingToolNameReason,formatUnknownToolReason.tests/handlers/gates/external-directory-messages.test.ts— delete or reduce to only ask-prompt tests (if ask-prompt functions remain in this file).tests/handlers/external-directory-integration.test.ts— replacetoContain("Hard stop")withtoContain("[pi-permission-system]"). Remove import offormatExternalDirectoryHardStopHint.tests/bash-external-directory.test.ts— replacetoContain("Hard stop")withtoContain("[pi-permission-system]").- Gate test files that construct mock
GateDescriptorobjects withmessages— update to usedenialContextinstead.
Tests that stay as-is
- Tests for
applyPermissionGate(interface unchanged). - Tests for permission resolution, wildcard matching, session rules — unrelated to message formatting.
- Tests for ask-prompt formatting functions.
TDD Order
- Red → Green: Create
src/denial-messages.tswithDenialContexttype,EXTENSION_TAG, and the three formatter functions covering all 6 context kinds. Createtests/denial-messages.test.tswith comprehensive tests asserting correct output for each kind, presence of[pi-permission-system], and absence of "Hard stop". Commit:feat: add centralized denial message formatter (#78) - Red → Green: Add optional
denialContexttoGateDescriptor. UpdaterunGateCheckto constructmessagesfromdenialContextwhen present, falling back todescriptor.messages. Add runner tests verifying the formatter path. Commit:refactor: wire runner to construct messages from denialContext (#78) - Red → Green: Migrate tool gate and path gate to
denialContext. RemoveformatDenyReason,formatUserDeniedReason,formatPermissionHardStopHintfrompermission-prompts.ts. RemoveformatPathDenyReasonfrompath.ts. Updatetests/permission-prompts.test.tsto remove migrated tests. Commit:refactor: migrate tool and path gates to denialContext (#78) - Red → Green: Migrate external-directory gate and bash-external-directory gate to
denialContext. Move ask-prompt functions (formatExternalDirectoryAskPrompt,formatBashExternalDirectoryAskPrompt) topermission-prompts.ts. Deleteexternal-directory-messages.ts. Updatetests/handlers/gates/external-directory-messages.test.tsandtests/handlers/external-directory-integration.test.ts. Commit:refactor: migrate external-directory gates to denialContext (#78) - Red → Green: Migrate bash-path gate and skill-read gate to
denialContext. RemoveformatSkillPathDenyReasonfrompermission-prompts.ts. Updatetests/bash-external-directory.test.ts. Commit:refactor: migrate bash-path and skill-read gates to denialContext (#78) - Red → Green: Make
denialContextrequired onGateDescriptor, removemessages. Remove the fallback path in the runner. Update any remaining test fixtures constructing descriptors withmessages. Runpnpm run checkto verify no type errors remain. Commit:refactor!: remove messages from GateDescriptor (#78)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| LLM retries denied operations because messages are less aggressive. | Base messages still clearly state "is not permitted" / "User denied". The skill-read gate has shipped without "Hard stop" with no observed retry loops. The [pi-permission-system] attribution adds clarity the old messages lacked. |
| Large blast radius — 6 gate files, runner, descriptor, 2 deleted modules. | Lift-and-shift migration: denialContext is added alongside messages, gates migrate incrementally, messages is removed only after all gates are migrated. Each step leaves the repo green. |
DenialContext union grows unwieldy as new surfaces are added. |
Each variant is small (3–5 fields). New surfaces add one variant to the union and one branch to each formatter function — no existing code changes. |
Ask-prompt functions in external-directory-messages.ts are collateral. |
They move to permission-prompts.ts where sibling ask-prompt functions already live. Imports update but behavior is unchanged. |
Open Questions
- The skill-read gate currently produces denial messages without "Hard stop" and without extension attribution.
After this change it gains
[pi-permission-system]attribution via the centralized formatter — verify this is desirable (likely yes). - Should
EXTENSION_TAGreference theEXTENSION_IDconstant fromextension-config.tsrather than duplicating the string? Using the existing constant keeps the name in one place, but adds an import dependency from the denial-messages module to the config module.