mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-24 08:23:31 +00:00
Harden the invocation-descriptor contract before the investigation profile
Four classifier ratchets from review of 67c2534c0:
The classification vocabulary is closed: descriptor validation rejects
any class outside the known workflow kinds and mutation targets (an
empty Mutation no longer registers), and InvocationPolicy.Allows
independently denies unknown mutation targets outright, so a class
that somehow bypassed validation still cannot execute. Descriptor
lookups and registration store deep copies, so callers can never
mutate the canonical table through shared case maps or static class
pointers. Registration rejects descriptor overrides for canonical tool
names; overrides exist only for genuinely non-canonical extension and
test names.
Projected governance now derives its action mode from mutation
targets, not workflow kinds, and recomputes it even when no enum value
was filtered; a projection whose remaining invocations mutate nothing
downgrades to scope-only approval metadata (registered scope-only
summaries are preserved). Docker check_updates reclassifies from
{write,none} to {read,none}: it queues a read-only scan, and the write
kind was driving the FSM into verification and making the read-only
Docker projection report mixed. Discovery consequently projects as
mode=read in governance manifests, which is the honest mutation-derived
mode. The pulse_file_edit governance summary no longer claims to read
files. The contract prose also names pulse_read's execution-intent
classifier as mandatory second-stage enforcement for exec, not merely
defense in depth: the static read/none descriptor cannot prove
arbitrary command text safe.
Proofs: open-vocabulary rejection, unknown-target policy denial,
descriptor copy isolation, canonical-override rejection, and the
read-only Docker read/scope-only projection.
This commit is contained in:
parent
1685358872
commit
3073a50614
10 changed files with 233 additions and 55 deletions
|
|
@ -272,7 +272,7 @@ call when building tool-result turns.
|
|||
13. `internal/agentcapabilities/events.go` shared with `api-contracts`: the Pulse Intelligence event vocabulary is both the canonical API SSE event contract and the AI runtime adapter notification contract for Assistant and external-agent surfaces.
|
||||
14. `internal/agentcapabilities/governance_prompt.go` shared with `api-contracts`: the Pulse Intelligence surface-affordance-resolved model-facing operating-instruction, tool-governance prompt, reusable provider-tool governance description, Assistant-native offered-tool filtering, and Assistant-native interactive question-tool governance projections are both the Assistant system-prompt governance section and the shared API/agent vocabulary for action mode, approval posture, MCP affordance advertisement, and non-registry interaction-tool boundaries.
|
||||
15. `internal/agentcapabilities/http.go` shared with `api-contracts`: the Pulse Intelligence agent HTTP substrate is both the API capabilities invocation contract and the shared AI runtime adapter execution primitive for MCP and reference agent clients.
|
||||
16. `internal/agentcapabilities/invocation.go` shared with `api-contracts`: the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, workflow kind plus mutation target, fail-closed classification) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement.
|
||||
16. `internal/agentcapabilities/invocation.go` shared with `api-contracts`: the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, closed workflow-kind and mutation-target vocabularies, deep-copied lookups, fail-closed classification with unknown targets denied at policy evaluation) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement; canonical tool names cannot carry descriptor overrides.
|
||||
17. `internal/agentcapabilities/manifest.go` shared with `api-contracts`: the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
|
||||
18. `internal/agentcapabilities/markdown.go` shared with `api-contracts`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
|
||||
19. `internal/agentcapabilities/mcp.go` shared with `api-contracts`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
|
||||
|
|
@ -4601,8 +4601,26 @@ invocation, and recomputes the offered governance action mode, so the
|
|||
offered schema and the enforcement boundary can never disagree.
|
||||
Control-level blocks keep returning the operator guidance message;
|
||||
policy blocks return the shared invocation-blocked result.
|
||||
Handler-level checks (the file-edit read-only guard and pulse_read's
|
||||
structural execution-intent classifier) remain defense in depth. The
|
||||
The classification vocabulary is closed: descriptor validation rejects
|
||||
any class outside the known kinds and mutation targets, and
|
||||
`InvocationPolicy.Allows` independently denies unknown mutation targets
|
||||
outright, so an unvalidated class still cannot execute. Descriptor
|
||||
lookups and registration store deep copies, so callers can never mutate
|
||||
the canonical table through shared case maps or static class pointers,
|
||||
and registration rejects descriptor overrides for canonical tool names
|
||||
(overrides exist only for genuinely non-canonical extension/test
|
||||
names). The projected governance action mode derives from mutation
|
||||
targets, not workflow kinds, and is recomputed even when no enum value
|
||||
was removed; a projection whose remaining invocations mutate nothing
|
||||
downgrades to scope-only approval metadata. Docker `check_updates`
|
||||
classifies read/none: it queues a read-only scan and must not drive
|
||||
verification workflow or make a read-only Docker projection look mixed.
|
||||
Handler-level checks remain defense in depth, and pulse_read's
|
||||
structural execution-intent classifier
|
||||
(`ClassifyExecutionIntent` rejecting write-or-unknown command text
|
||||
before dispatch) is mandatory second-stage enforcement for exec, not
|
||||
merely defense in depth: the static read/none descriptor alone cannot
|
||||
prove arbitrary command text safe. The
|
||||
deny restriction is deliberately separate from autonomous mode:
|
||||
suppressing interactive questions grants no mutation authority.
|
||||
`pulse_file_edit` is write-only (append/write); file inspection routes
|
||||
|
|
|
|||
|
|
@ -1180,7 +1180,7 @@ payload shape change when the portal presents compact client rows.
|
|||
39. `internal/agentcapabilities/events.go` shared with `ai-runtime`: the Pulse Intelligence event vocabulary is both the canonical API SSE event contract and the AI runtime adapter notification contract for Assistant and external-agent surfaces.
|
||||
40. `internal/agentcapabilities/governance_prompt.go` shared with `ai-runtime`: the Pulse Intelligence surface-affordance-resolved model-facing operating-instruction, tool-governance prompt, reusable provider-tool governance description, Assistant-native offered-tool filtering, and Assistant-native interactive question-tool governance projections are both the Assistant system-prompt governance section and the shared API/agent vocabulary for action mode, approval posture, MCP affordance advertisement, and non-registry interaction-tool boundaries.
|
||||
41. `internal/agentcapabilities/http.go` shared with `ai-runtime`: the Pulse Intelligence agent HTTP substrate is both the API capabilities invocation contract and the shared AI runtime adapter execution primitive for MCP and reference agent clients.
|
||||
42. `internal/agentcapabilities/invocation.go` shared with `ai-runtime`: the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, workflow kind plus mutation target, fail-closed classification) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement.
|
||||
42. `internal/agentcapabilities/invocation.go` shared with `ai-runtime`: the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, closed workflow-kind and mutation-target vocabularies, deep-copied lookups, fail-closed classification with unknown targets denied at policy evaluation) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement; canonical tool names cannot carry descriptor overrides.
|
||||
43. `internal/agentcapabilities/manifest.go` shared with `ai-runtime`: the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
|
||||
44. `internal/agentcapabilities/markdown.go` shared with `ai-runtime`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
|
||||
45. `internal/agentcapabilities/mcp.go` shared with `ai-runtime`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@
|
|||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/invocation.go",
|
||||
"rationale": "the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, workflow kind plus mutation target, fail-closed classification) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement",
|
||||
"rationale": "the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, closed workflow-kind and mutation-target vocabularies, deep-copied lookups, fail-closed classification with unknown targets denied at policy evaluation) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement; canonical tool names cannot carry descriptor overrides",
|
||||
"subsystems": [
|
||||
"ai-runtime",
|
||||
"api-contracts"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,23 @@ type InvocationClass struct {
|
|||
Mutation MutationTarget
|
||||
}
|
||||
|
||||
// Valid reports whether the class uses the closed kind and mutation
|
||||
// vocabularies. Descriptor validation rejects anything else, so an
|
||||
// unclassifiable or typo'd class can never register.
|
||||
func (c InvocationClass) Valid() bool {
|
||||
switch c.Kind {
|
||||
case ToolCallKindResolve, ToolCallKindRead, ToolCallKindWrite, ToolCallKindUserInput:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
switch c.Mutation {
|
||||
case MutationNone, MutationPulseState, MutationInfrastructure:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FailClosedInvocationClass is what missing, malformed, or unknown
|
||||
// invocations classify as: a newly introduced or fabricated subaction can
|
||||
// never bypass governed-mutation checks by being unclassified.
|
||||
|
|
@ -85,6 +102,9 @@ func (d InvocationDescriptor) Validate(toolName string, enumValues []string) err
|
|||
if d.Discriminator != "" || len(d.Cases) != 0 {
|
||||
return fmt.Errorf("tool %q invocation descriptor must be static or discriminator-based, not both", toolName)
|
||||
}
|
||||
if !d.Static.Valid() {
|
||||
return fmt.Errorf("tool %q static invocation class uses an unknown kind or mutation target", toolName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Discriminator == "" {
|
||||
|
|
@ -103,10 +123,13 @@ func (d InvocationDescriptor) Validate(toolName string, enumValues []string) err
|
|||
missing = append(missing, v)
|
||||
}
|
||||
}
|
||||
for v := range d.Cases {
|
||||
for v, class := range d.Cases {
|
||||
if !want[v] {
|
||||
extra = append(extra, v)
|
||||
}
|
||||
if !class.Valid() {
|
||||
return fmt.Errorf("tool %q invocation case %q uses an unknown kind or mutation target", toolName, v)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
|
|
@ -185,8 +208,10 @@ var registryInvocationDescriptors = map[string]InvocationDescriptor{
|
|||
"tasks": {Kind: ToolCallKindRead, Mutation: MutationNone},
|
||||
"swarm": {Kind: ToolCallKindRead, Mutation: MutationNone},
|
||||
// check_updates queues a read-only scan command on the
|
||||
// agent; it changes nothing on the container estate.
|
||||
"check_updates": {Kind: ToolCallKindWrite, Mutation: MutationNone},
|
||||
// agent; it changes nothing on the container estate, so it
|
||||
// is read for workflow purposes too (write would drive the
|
||||
// FSM into verification for a non-mutating refresh).
|
||||
"check_updates": {Kind: ToolCallKindRead, Mutation: MutationNone},
|
||||
"control": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
|
||||
"update": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
|
||||
},
|
||||
|
|
@ -205,11 +230,32 @@ var registryInvocationDescriptors = map[string]InvocationDescriptor{
|
|||
PatrolResolveFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
|
||||
}
|
||||
|
||||
// InvocationDescriptorFor returns the canonical invocation descriptor for
|
||||
// a registry tool name.
|
||||
// Clone returns a deep copy of the descriptor so callers can never
|
||||
// mutate the canonical table through shared case maps or the static
|
||||
// class pointer.
|
||||
func (d InvocationDescriptor) Clone() InvocationDescriptor {
|
||||
clone := InvocationDescriptor{Discriminator: d.Discriminator}
|
||||
if d.Static != nil {
|
||||
static := *d.Static
|
||||
clone.Static = &static
|
||||
}
|
||||
if d.Cases != nil {
|
||||
clone.Cases = make(map[string]InvocationClass, len(d.Cases))
|
||||
for value, class := range d.Cases {
|
||||
clone.Cases[value] = class
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// InvocationDescriptorFor returns a deep copy of the canonical invocation
|
||||
// descriptor for a registry tool name.
|
||||
func InvocationDescriptorFor(toolName string) (InvocationDescriptor, bool) {
|
||||
d, ok := registryInvocationDescriptors[strings.TrimSpace(toolName)]
|
||||
return d, ok
|
||||
if !ok {
|
||||
return InvocationDescriptor{}, false
|
||||
}
|
||||
return d.Clone(), true
|
||||
}
|
||||
|
||||
// ClassifyRegisteredInvocation classifies a concrete invocation of a
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func TestCanonicalDescriptorsPinSafetyCriticalClassifications(t *testing.T) {
|
|||
assertClass(PulseDockerToolName, map[string]interface{}{"action": "update"},
|
||||
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
|
||||
assertClass(PulseDockerToolName, map[string]interface{}{"action": "check_updates"},
|
||||
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationNone})
|
||||
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
|
||||
assertClass(PulseAlertsToolName, map[string]interface{}{"action": "resolve"},
|
||||
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
|
||||
assertClass(PulseControlToolName, nil,
|
||||
|
|
@ -91,3 +91,43 @@ func TestCanonicalDescriptorsPinSafetyCriticalClassifications(t *testing.T) {
|
|||
assertClass(PulseReadToolName, map[string]interface{}{"action": "exec"},
|
||||
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
|
||||
}
|
||||
|
||||
func TestInvocationClassValidationRejectsOpenVocabulary(t *testing.T) {
|
||||
missingMutation := InvocationDescriptor{Static: &InvocationClass{Kind: ToolCallKindWrite}}
|
||||
if err := missingMutation.Validate("demo", nil); err == nil {
|
||||
t.Fatal("static class without a mutation target must fail validation")
|
||||
}
|
||||
unknownKind := InvocationDescriptor{Static: &InvocationClass{Kind: ToolCallKind(99), Mutation: MutationNone}}
|
||||
if err := unknownKind.Validate("demo", nil); err == nil {
|
||||
t.Fatal("static class with an unknown kind must fail validation")
|
||||
}
|
||||
badCase := InvocationDescriptor{
|
||||
Discriminator: "action",
|
||||
Cases: map[string]InvocationClass{
|
||||
"list": {Kind: ToolCallKindRead, Mutation: MutationTarget("estate")},
|
||||
},
|
||||
}
|
||||
if err := badCase.Validate("demo", []string{"list"}); err == nil {
|
||||
t.Fatal("case with an unknown mutation target must fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvocationDescriptorForReturnsIsolatedCopies(t *testing.T) {
|
||||
first, ok := InvocationDescriptorFor(PulseDockerToolName)
|
||||
if !ok {
|
||||
t.Fatal("docker descriptor missing")
|
||||
}
|
||||
first.Cases["update"] = InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}
|
||||
|
||||
second, _ := InvocationDescriptorFor(PulseDockerToolName)
|
||||
if got := second.Cases["update"]; got.Mutation != MutationInfrastructure {
|
||||
t.Fatalf("mutating a returned descriptor leaked into the canonical table: %#v", got)
|
||||
}
|
||||
|
||||
static, _ := InvocationDescriptorFor(PulseControlToolName)
|
||||
static.Static.Mutation = MutationNone
|
||||
refetched, _ := InvocationDescriptorFor(PulseControlToolName)
|
||||
if refetched.Static.Mutation != MutationInfrastructure {
|
||||
t.Fatalf("mutating a returned static class leaked into the canonical table: %#v", refetched.Static)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,8 +388,11 @@ func TestBuildToolGovernancePromptSection_FallbackDiscoveryMatchesRunContract(t
|
|||
|
||||
prompt := svc.buildToolGovernancePromptSection()
|
||||
|
||||
if !strings.Contains(prompt, "pulse_discovery: mode=mixed") {
|
||||
t.Fatalf("expected fallback governance to classify pulse_discovery as mixed, got %q", prompt)
|
||||
// Discovery's run subaction is read-only evidence collection that
|
||||
// updates only the discovery cache, so the mutation-derived action
|
||||
// mode is read (the pre-descriptor manifest declared it mixed).
|
||||
if !strings.Contains(prompt, "pulse_discovery: mode=read") {
|
||||
t.Fatalf("expected fallback governance to classify pulse_discovery as read, got %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "run uses read-only evidence collection and updates the discovery cache") {
|
||||
t.Fatalf("expected fallback governance to describe discovery refresh behavior, got %q", prompt)
|
||||
|
|
@ -448,7 +451,7 @@ func TestBuildToolGovernancePromptSection_OfferedToolsUseCanonicalFallback(t *te
|
|||
|
||||
prompt := svc.buildToolGovernancePromptSectionForOfferedTools([]providers.Tool{{Name: "pulse_discovery"}})
|
||||
|
||||
if !strings.Contains(prompt, "pulse_discovery: mode=mixed; approval=scope_only (no approval required; run uses read-only evidence collection and updates the discovery cache)") {
|
||||
if !strings.Contains(prompt, "pulse_discovery: mode=read; approval=scope_only (no approval required; run uses read-only evidence collection and updates the discovery cache)") {
|
||||
t.Fatalf("expected offered fallback prompt to use canonical discovery governance, got %q", prompt)
|
||||
}
|
||||
if strings.Contains(prompt, "pulse_control:") {
|
||||
|
|
|
|||
|
|
@ -190,13 +190,18 @@ func TestPulseToolExecutor_GetReadStatePrefersUnifiedResourceProvider(t *testing
|
|||
func TestToolRegistry_ListTools(t *testing.T) {
|
||||
registry := NewToolRegistry()
|
||||
registry.Register(RegisteredTool{
|
||||
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
|
||||
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
||||
Definition: Tool{Name: "read"},
|
||||
})
|
||||
registry.Register(RegisteredTool{
|
||||
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
|
||||
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationInfrastructure),
|
||||
Definition: Tool{Name: "control"},
|
||||
RequireControl: true,
|
||||
Governance: ToolGovernance{
|
||||
ActionMode: ToolActionWrite,
|
||||
ApprovalPolicy: ToolApprovalActionPlan,
|
||||
ApprovalSummary: "hidden in read-only mode; approval required in controlled mode",
|
||||
},
|
||||
})
|
||||
|
||||
readOnly := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
|
||||
|
|
|
|||
|
|
@ -147,3 +147,41 @@ func TestExecutorClonesKeepRequestPoliciesIsolated(t *testing.T) {
|
|||
// And the restriction survives further cloning of the restricted clone.
|
||||
assert.True(t, clone.Clone().invocationPolicy().DenyInfrastructureMutations)
|
||||
}
|
||||
|
||||
func TestInvocationPolicyDeniesUnknownMutationTargets(t *testing.T) {
|
||||
policy := InvocationPolicy{ControlLevel: ControlLevelAutonomous}
|
||||
assert.False(t, policy.Allows(agentcapabilities.InvocationClass{Kind: agentcapabilities.ToolCallKindRead}),
|
||||
"an empty mutation target must be denied outright")
|
||||
assert.False(t, policy.Allows(agentcapabilities.InvocationClass{
|
||||
Kind: agentcapabilities.ToolCallKindRead, Mutation: agentcapabilities.MutationTarget("estate"),
|
||||
}), "an unknown mutation target must be denied outright")
|
||||
}
|
||||
|
||||
func TestRegisterRejectsOverridesForCanonicalToolNames(t *testing.T) {
|
||||
registry := NewToolRegistry()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("overriding a canonical tool's descriptor must panic")
|
||||
}
|
||||
}()
|
||||
registry.Register(RegisteredTool{
|
||||
Definition: Tool{Name: agentcapabilities.PulseControlToolName},
|
||||
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadOnlyDockerProjectsAsReadScopeOnly(t *testing.T) {
|
||||
exec := newInvocationPolicyExecutor(t)
|
||||
exec.SetControlLevel(ControlLevelReadOnly)
|
||||
for _, descriptor := range exec.registry.ListToolGovernance(exec.invocationPolicy()) {
|
||||
if descriptor.Name != agentcapabilities.PulseDockerToolName {
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, agentcapabilities.ActionModeRead, descriptor.ActionMode,
|
||||
"read-only Docker projection must report read, not mixed")
|
||||
assert.Equal(t, agentcapabilities.ApprovalPolicyScopeOnly, descriptor.ApprovalPolicy,
|
||||
"a projection with no mutating subactions must carry scope-only approval")
|
||||
return
|
||||
}
|
||||
t.Fatal("pulse_docker missing from read-only governance projection")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,15 +102,21 @@ type InvocationPolicy struct {
|
|||
// Infrastructure mutations require a control level that allows control
|
||||
// tools and are always blocked under the deny restriction; pulse-state
|
||||
// and non-mutating invocations are not control-gated here (handlers keep
|
||||
// their own defense-in-depth checks).
|
||||
// their own defense-in-depth checks). Unknown mutation targets are
|
||||
// denied outright, independent of registration validation, so a class
|
||||
// that somehow bypasses Validate still cannot execute.
|
||||
func (p InvocationPolicy) Allows(class agentcapabilities.InvocationClass) bool {
|
||||
if class.Mutation != agentcapabilities.MutationInfrastructure {
|
||||
switch class.Mutation {
|
||||
case agentcapabilities.MutationNone, agentcapabilities.MutationPulseState:
|
||||
return true
|
||||
}
|
||||
if p.DenyInfrastructureMutations {
|
||||
case agentcapabilities.MutationInfrastructure:
|
||||
if p.DenyInfrastructureMutations {
|
||||
return false
|
||||
}
|
||||
return agentcapabilities.ControlLevelAllowsControlTools(p.ControlLevel)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return agentcapabilities.ControlLevelAllowsControlTools(p.ControlLevel)
|
||||
}
|
||||
|
||||
// Register adds a tool to the registry. Every registered tool must have a
|
||||
|
|
@ -124,15 +130,19 @@ func (r *ToolRegistry) Register(tool RegisteredTool) {
|
|||
|
||||
tool.Definition = tool.Definition.NormalizeCollections()
|
||||
name := tool.Definition.Name
|
||||
descriptor := agentcapabilities.InvocationDescriptor{}
|
||||
if tool.Invocation != nil {
|
||||
descriptor = *tool.Invocation
|
||||
} else {
|
||||
canonical, ok := agentcapabilities.InvocationDescriptorFor(name)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("tool %q has no canonical invocation descriptor; declare one in agentcapabilities/invocation.go", name))
|
||||
}
|
||||
canonical, isCanonical := agentcapabilities.InvocationDescriptorFor(name)
|
||||
var descriptor agentcapabilities.InvocationDescriptor
|
||||
switch {
|
||||
case isCanonical && tool.Invocation != nil:
|
||||
// A canonical tool name must classify through the shared table;
|
||||
// an override could silently relax its safety classification.
|
||||
panic(fmt.Sprintf("tool %q is canonical; its invocation descriptor comes from agentcapabilities/invocation.go and cannot be overridden", name))
|
||||
case isCanonical:
|
||||
descriptor = canonical
|
||||
case tool.Invocation != nil:
|
||||
descriptor = tool.Invocation.Clone()
|
||||
default:
|
||||
panic(fmt.Sprintf("tool %q has no canonical invocation descriptor; declare one in agentcapabilities/invocation.go", name))
|
||||
}
|
||||
if err := descriptor.Validate(name, discriminatorEnum(tool.Definition, descriptor.Discriminator)); err != nil {
|
||||
panic(err.Error())
|
||||
|
|
@ -234,7 +244,7 @@ func projectToolForPolicy(tool RegisteredTool, policy InvocationPolicy) (Registe
|
|||
if !policy.Allows(*descriptor.Static) {
|
||||
return RegisteredTool{}, false
|
||||
}
|
||||
return tool, true
|
||||
return applyProjectedGovernance(tool, []agentcapabilities.InvocationClass{*descriptor.Static}), true
|
||||
}
|
||||
|
||||
property, ok := tool.Definition.InputSchema.Properties[descriptor.Discriminator]
|
||||
|
|
@ -242,44 +252,62 @@ func projectToolForPolicy(tool RegisteredTool, policy InvocationPolicy) (Registe
|
|||
return RegisteredTool{}, false
|
||||
}
|
||||
allowed := make([]string, 0, len(property.Enum))
|
||||
sawWrite := false
|
||||
sawRead := false
|
||||
classes := make([]agentcapabilities.InvocationClass, 0, len(property.Enum))
|
||||
for _, value := range property.Enum {
|
||||
class := descriptor.Classify(map[string]interface{}{descriptor.Discriminator: value})
|
||||
if !policy.Allows(class) {
|
||||
continue
|
||||
}
|
||||
allowed = append(allowed, value)
|
||||
if class.Kind == agentcapabilities.ToolCallKindWrite {
|
||||
sawWrite = true
|
||||
} else {
|
||||
sawRead = true
|
||||
}
|
||||
classes = append(classes, class)
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return RegisteredTool{}, false
|
||||
}
|
||||
if len(allowed) == len(property.Enum) {
|
||||
return tool, true
|
||||
}
|
||||
|
||||
projected := tool
|
||||
projected.Definition.InputSchema.Properties = make(map[string]PropertySchema, len(tool.Definition.InputSchema.Properties))
|
||||
for key, value := range tool.Definition.InputSchema.Properties {
|
||||
projected.Definition.InputSchema.Properties[key] = value
|
||||
if len(allowed) != len(property.Enum) {
|
||||
projected.Definition.InputSchema.Properties = make(map[string]PropertySchema, len(tool.Definition.InputSchema.Properties))
|
||||
for key, value := range tool.Definition.InputSchema.Properties {
|
||||
projected.Definition.InputSchema.Properties[key] = value
|
||||
}
|
||||
property.Enum = allowed
|
||||
projected.Definition.InputSchema.Properties[descriptor.Discriminator] = property
|
||||
}
|
||||
property.Enum = allowed
|
||||
projected.Definition.InputSchema.Properties[descriptor.Discriminator] = property
|
||||
return applyProjectedGovernance(projected, classes), true
|
||||
}
|
||||
|
||||
switch {
|
||||
case sawWrite && sawRead:
|
||||
projected.Governance.ActionMode = agentcapabilities.ActionModeMixed
|
||||
case sawWrite:
|
||||
projected.Governance.ActionMode = agentcapabilities.ActionModeWrite
|
||||
default:
|
||||
projected.Governance.ActionMode = agentcapabilities.ActionModeRead
|
||||
// applyProjectedGovernance recomputes the offered governance from the
|
||||
// mutation targets the policy actually permits: the action mode reflects
|
||||
// what the offered invocations can change (not their workflow kind), and
|
||||
// a tool whose remaining invocations mutate nothing carries scope-only
|
||||
// approval metadata instead of a stale action-plan requirement.
|
||||
func applyProjectedGovernance(tool RegisteredTool, classes []agentcapabilities.InvocationClass) RegisteredTool {
|
||||
sawMutating := false
|
||||
sawNonMutating := false
|
||||
for _, class := range classes {
|
||||
if class.Mutation == agentcapabilities.MutationNone {
|
||||
sawNonMutating = true
|
||||
} else {
|
||||
sawMutating = true
|
||||
}
|
||||
}
|
||||
return projected, true
|
||||
switch {
|
||||
case sawMutating && sawNonMutating:
|
||||
tool.Governance.ActionMode = agentcapabilities.ActionModeMixed
|
||||
case sawMutating:
|
||||
tool.Governance.ActionMode = agentcapabilities.ActionModeWrite
|
||||
default:
|
||||
tool.Governance.ActionMode = agentcapabilities.ActionModeRead
|
||||
if tool.Governance.ApprovalPolicy != ToolApprovalScopeOnly {
|
||||
// Downgrading from action-plan approval: the registered
|
||||
// approval summary no longer applies, so clear it and let
|
||||
// the shared normalization supply the scope-only default.
|
||||
tool.Governance.ApprovalPolicy = ToolApprovalScopeOnly
|
||||
tool.Governance.ApprovalSummary = ""
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
// allNames returns the canonical list of registered tool names in
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ Examples:
|
|||
ActionMode: ToolActionWrite,
|
||||
ApprovalPolicy: ToolApprovalActionPlan,
|
||||
ApprovalSummary: "hidden in read-only mode; approval required in controlled mode",
|
||||
Summary: "Reads or changes files through the governed file-edit path; read-only file inspection is exposed through the read-only tool surface.",
|
||||
Summary: "Changes files through the governed file-edit path (append/write only); file inspection routes through the read-only pulse_read tool.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue