feat(permission): a tighten-only extension's ask is a fresh human approval of the one call

This commit is contained in:
esengine committed 2026-10-18 00:35:39 -07:00
1 parent 2234a3d68b
commit ec49147dee
12 files changed
+179 -5

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
- Protocol ID: `reasonix.extension.v2`
- Protocol major: `2`
- Schema: `internal/ext/extension/protocol/schema.generated.json`
- Schema hash: `sha256:3c01970cdea893303c49150fb2717f592178fb3974a17c91a7848712e516930d`
- Schema hash: `sha256:d46a970c1b1ac3a03cbdc9a32e6457e48f02c25a2679a0e3bbc8a727557437e7`
Within major v2 only optional fields, new enum values, and new methods may
be added; existing required fields, directions, limits, error reasons, and
@@ -280,3 +280,70 @@ func TestEffectAModThatCrashesOrHangsDoesNotFailTheTurn(t *testing.T) {
})
}
}
// A mod's ask puts the call to the person even in a posture that approves
// everything, and the answer covers that one call. YOLO does not answer it.
func TestEffectAModsAskIsAFreshHumanApprovalThatYoloDoesNotAnswer(t *testing.T) {
for _, approve := range []bool{true, false} {
name := "declined"
if approve {
name = "approved"
}
t.Run(name, func(t *testing.T) {
_, workspace, prov := installMod(t, `
export function register(on) {
on('tool.check', async ($, e, next) => (e.input.command.includes('ASKME') ? { decision: 'ask' } : next(e)));
}`)
setModsSwitch(t, true)
prov.command = "touch asked-" + name + ".txt # ASKME"
var mu sync.Mutex
var cards []event.Approval
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
mu.Lock()
cards = append(cards, e.Approval)
mu.Unlock()
}
})
res, err := BuildRuntime(t.Context(), Options{Sink: sink})
if err != nil {
t.Fatal(err)
}
defer res.Controller.Close()
res.Controller.EnableInteractiveApproval()
res.Controller.SetToolApprovalMode(control.ToolApprovalYolo)
done := make(chan error, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
done <- res.Controller.Run(ctx, "run it")
}()
var card event.Approval
waitFor(t, "the approval card", func() bool {
mu.Lock()
defer mu.Unlock()
for _, c := range cards {
if c.Tool == "mod_check" {
card = c
return true
}
}
return false
})
if !strings.Contains(card.Subject, "bash") {
t.Fatalf("the card does not show the call: %+v", card)
}
if _, err := os.Stat(filepath.Join(workspace, "asked-"+name+".txt")); err == nil {
t.Fatal("the call ran before the person answered")
}
res.Controller.Approve(card.ID, approve, false, false)
if err := <-done; err != nil {
t.Fatalf("Run: %v", err)
}
_, statErr := os.Stat(filepath.Join(workspace, "asked-"+name+".txt"))
if (statErr == nil) != approve {
t.Fatalf("approve=%t but the file exists=%t; results:\n%s", approve, statErr == nil, strings.Join(prov.toolResults(), "\n"))
}
})
}
}
@@ -67,6 +67,9 @@ type Result struct {
Blocked bool
// BlockReason is the extension's user-visible reason, credential-redacted.
BlockReason string
// Ask is set when a tighten-only runtime put the call to the user; the caller
// asks even where the host allowed it. It is never set by an allow or a deny.
Ask bool
// Permission carries the terminal allow/deny ruling at permission.decision;
// nil means every interceptor continued and the host decision stands. The
// CALLER combines it with the host verdict (host deny + extension allow →
@@ -280,6 +283,15 @@ func (d *Dispatcher) Intercept(ctx context.Context, point extension.InterceptorP
return nil, fmt.Errorf("dispatch: marshal %s replacement: %w", point, err)
}
result.Applied = append(result.Applied, pluginID)
case protocol.DecisionAsk:
if point != extension.PointPermissionDecision || !d.tightenOnly[pluginID] {
err := fmt.Errorf("decision %q is only legal for a tighten-only runtime at %s", answer.Decision, extension.PointPermissionDecision)
if err := d.violation(pluginID, point, err); err != nil {
return nil, err
}
continue
}
result.Ask = true
case protocol.DecisionAllow, protocol.DecisionDeny:
if point != extension.PointPermissionDecision {
err := fmt.Errorf("decision %q is only legal at %s", answer.Decision, extension.PointPermissionDecision)
@@ -97,3 +97,38 @@ func TestARequiredTightenOnlyRuntimeThatLoosensFailsTheOperationWithATypedCause(
t.Fatalf("err = %v, want a ViolationError wrapping ErrLoosensDecision", err)
}
}
func TestAnAskFromATightenOnlyRuntimeIsRecordedAndNotMistakenForAnAllow(t *testing.T) {
point := extension.PointPermissionDecision
d := tightenOnlyDispatcher("mod-x", protocol.InterceptResult{Decision: protocol.DecisionAsk}, &warnRecorder{})
result, err := d.Intercept(context.Background(), point, &PermissionPayload{Name: "bash", HostDecision: "allow"})
if err != nil || !result.Ask || result.Permission != nil {
t.Fatalf("result = %+v, %v; want Ask set and no allow/deny", result, err)
}
}
// ask is the tighten-only runtimes' own ruling; another runtime, or any other
// point, answering it breaks the dispatch rules.
func TestAnAskFromAnyoneElseOrAnywhereElseIsAViolation(t *testing.T) {
ask := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
return protocol.InterceptResult{Decision: protocol.DecisionAsk}, nil
}}
for name, tc := range map[string]struct {
point extension.InterceptorPoint
tight map[string]bool
pay any
}{
"not tighten-only": {extension.PointPermissionDecision, nil, &PermissionPayload{Name: "bash", HostDecision: "allow"}},
"other point": {extension.PointToolBefore, map[string]bool{"mod-x": true}, &ToolBeforePayload{Name: "bash", Arguments: "{}"}},
} {
t.Run(name, func(t *testing.T) {
warns := &warnRecorder{}
d := New(map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("mod-x", tc.point, 0)}}, nil,
func(string) Client { return ask }, nil, Options{Warn: warns.warn, TightenOnly: tc.tight})
result, err := d.Intercept(context.Background(), tc.point, tc.pay)
if err != nil || result.Ask || warns.count() != 1 {
t.Fatalf("result = %+v, %v, warnings %d; want the ask refused with one warning", result, err, warns.count())
}
})
}
}
+3
View File
@@ -88,6 +88,9 @@ const (
DecisionReplace InterceptDecision = "replace"
DecisionAllow InterceptDecision = "allow"
DecisionDeny InterceptDecision = "deny"
// DecisionAsk, legal only at permission.decision and only for a runtime that
// may merely tighten, puts the call to the user even where the host allowed it.
DecisionAsk InterceptDecision = "ask"
)
// UIHostKind identifies which host UI surface family renders extension UI.
@@ -1 +1 @@
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"toolSpecs":{"items":{"$ref":"#/$defs/ToolSpec"},"type":"array"},"tools":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"decision":{"enum":["allow","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"}Line truncated
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"toolSpecs":{"items":{"$ref":"#/$defs/ToolSpec"},"type":"array"},"tools":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"decision":{"enum":["allow","ask","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"aLine truncated
@@ -5,4 +5,4 @@ package protocol
// GeneratedSchemaHash is the SHA-256 of schema.generated.json. Handshake
// comparisons use this constant; protocol tests independently recompute it
// from CanonicalSchemaBytes to reject stale generated artifacts.
const GeneratedSchemaHash = "sha256:3c01970cdea893303c49150fb2717f592178fb3974a17c91a7848712e516930d"
const GeneratedSchemaHash = "sha256:d46a970c1b1ac3a03cbdc9a32e6457e48f02c25a2679a0e3bbc8a727557437e7"
+1 -1
View File
@@ -32,7 +32,7 @@ var enumTypes = map[reflect.Type][]string{
reflect.TypeFor[Direction](): values(DirectionHostToExtensionRequest, DirectionExtensionToHostRequest, DirectionHostToExtensionNotification, DirectionExtensionToHostNotification),
reflect.TypeFor[OperationClass](): values(ClassLifecycle, ClassIntercept, ClassObservation, ClassProvider, ClassUI, ClassContent),
reflect.TypeFor[InterceptEvent](): interceptEventValues(),
reflect.TypeFor[InterceptDecision](): values(DecisionContinue, DecisionBlock, DecisionReplace, DecisionAllow, DecisionDeny),
reflect.TypeFor[InterceptDecision](): values(DecisionContinue, DecisionBlock, DecisionReplace, DecisionAllow, DecisionDeny, DecisionAsk),
reflect.TypeFor[UIHostKind](): values(UIHostTUI, UIHostDesktop, UIHostACP, UIHostHeadless),
reflect.TypeFor[UISurfaceKind](): values(UISurfaceStatus, UISurfaceCard, UISurfaceForm, UISurfaceNotification, UISurfacePanel, UISurfaceView),
reflect.TypeFor[UINodeKind](): values(UINodeText, UINodeMarkdown, UINodeRow, UINodeStack, UINodeKV, UINodeMeter, UINodePip, UINodeButton, UINodeDivider),
+28
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
@@ -12,6 +13,7 @@ import (
"reasonix/internal/ext/extension"
"reasonix/internal/ext/extension/dispatch"
"reasonix/internal/ext/extension/providerconv"
"reasonix/internal/safety/permission"
)
// Extension Protocol v2 agent-side wiring. The agent consults the
@@ -349,6 +351,32 @@ func (a *Agent) interceptExtensionPermission(ctx context.Context, plan *toolCall
case result.Permission != nil && *result.Permission:
*allow = true
}
if result.Ask && *allow {
return a.confirmAskedByExtension(ctx, plan)
}
return toolOutcome{}, false
}
// confirmAskedByExtension puts a call the host allowed to the user because a
// tighten-only extension asked. A gate that cannot ask refuses: an ask is never
// quietly dropped.
func (a *Agent) confirmAskedByExtension(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
refuse := func(msg string) (toolOutcome, bool) {
return toolOutcome{output: "blocked: " + msg, blocked: true, errMsg: "blocked by extension permission policy"}, true
}
c, ok := a.svc.gate.(interface {
Confirm(context.Context, string, json.RawMessage, string) (permission.Verdict, error)
})
if !ok {
return refuse("an extension required approval for this call and this session has no way to ask")
}
v, err := c.Confirm(ctx, plan.permName, plan.permArgs, "an extension")
if err != nil {
return refuse(err.Error())
}
if !v.Allow {
return refuse(v.Reason)
}
return toolOutcome{}, false
}
+28
View File
@@ -159,6 +159,34 @@ func (g *Gate) denial(ruleReason string) Verdict {
}
}
// ConfirmTool names the approval a stricter party opens on a call the policy
// allowed. It is a fresh decision: a person answers it, never a posture such as
// YOLO or auto, and the answer covers this one call.
const ConfirmTool = "mod_check"
// Confirm puts a call the policy allowed to the user anyway, on behalf of who
// (a Claude Code mod that may only tighten). It cannot loosen anything: the
// verdict is Allow only when a person approved, and a gate with nobody to ask
// refuses.
func (g *Gate) Confirm(ctx context.Context, toolName string, args json.RawMessage, who string) (Verdict, error) {
if g.Approver == nil {
return Verdict{Reason: who + " asked for approval of this call and nobody can give it, so it was refused.", Code: RefusalUnattended}, nil
}
subject := who + " asks you to approve: " + toolName + " " + Subject(args)
allow, _, reason, err := g.approve(ctx, ConfirmTool, subject, args, who+" requires approval for this call")
if err != nil {
return Verdict{Reason: "approval aborted"}, err
}
if !allow {
v := Verdict{Reason: "the user declined this tool call — do not retry it; ask how they would like to proceed or choose another approach.", Code: RefusalDeclined}
if reason != "" {
v.Reason = reason
}
return v, nil
}
return Verdict{Allow: true}, nil
}
// Checker is any gate a tool call is put to.
type Checker interface {
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error)
+1 -1
View File
@@ -633,7 +633,7 @@ func ParseToolApprovalMode(mode string) (string, bool) {
// narrow operation in interactive or headless mode.
func RequiresFreshHumanApprovalTool(tool string) bool {
switch tool {
case planApprovalTool, memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool, MCPScopeUpgradeApprovalTool, SettingApprovalTool, SettingElevatedApprovalTool:
case planApprovalTool, memoryRememberTool, memoryForgetTool, permission.ConfirmTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool, NetworkEgressApprovalTool, PaidSearchApprovalTool, MCPSignInApprovalTool, MCPScopeUpgradeApprovalTool, SettingApprovalTool, SettingElevatedApprovalTool:
return true
default:
return false
+1
View File
@@ -276,6 +276,7 @@ const (
DecisionReplace InterceptDecision = "replace"
DecisionAllow InterceptDecision = "allow"
DecisionDeny InterceptDecision = "deny"
DecisionAsk InterceptDecision = "ask"
)
// ProviderCatalogParams is a generated Extension Protocol v2 wire DTO.