Route Claude native tool intent through Pulse

This commit is contained in:
rcourtman
2026-07-16 16:09:48 +01:00
parent a266a2e577
commit b91ba759c9
5 changed files with 269 additions and 11 deletions
+7
View File
@@ -374,6 +374,13 @@ Pulse tool loop independently applies control level, license,
protected-resource, approval, action, and verification policy. The CLI never
executes a Patrol tool itself.
Claude Code can occasionally express a requested Pulse tool as a local native
tool call even though local tools are disabled. Pulse audits Claude's buffered
event stream, accepts only the first call whose name was explicitly offered for
that turn, and routes it back through Pulse's normal executor; undeclared local
tool attempts fail closed. This does not grant Claude Code direct access to the
infrastructure.
This is still a local agent process, not a remote chat-completions API. Pulse
rejects a turn if Codex reports command, file, MCP, web, computer, or image-tool
activity, and Claude is launched with its built-in filesystem, shell, web, and
@@ -299,7 +299,18 @@ own subscription-provider prefix before CLI execution, and rejects foreign
provider prefixes rather than forwarding an invalid or cross-provider model
name to the local agent.
Claude uses its native output-schema mode for every provider decision. Pulse
decodes completed structured turns with unknown fields and trailing values
consumes Claude Code's verbose JSON event stream but still buffers the whole
process result before emitting provider events. Claude Code can mistake a tool
name serialized in the Pulse request for a local native tool even though local
tools are disabled; in that case it emits the intended `tool_use`, injects a
synthetic `No such tool available` result, and lets the model continue toward a
false `needs_attention` conclusion. The adapter must recover only the first
such call when its name is in the current Pulse-owned offered-tool manifest,
then return that call through the normal Pulse executor on the next provider
turn. It must ignore later attempts made without real Pulse tool results,
reject any undeclared native tool attempt, and never execute infrastructure
tools inside Claude Code. When no recoverable native call occurred, Pulse
decodes the terminal structured turn with unknown fields and trailing values
rejected. Live qualification also proved that Claude's wrapper may exhaust its
structured-output retries on the final turn after Pulse has already persisted
a successful `patrol_report_finding`, `patrol_assess_finding`, or
+155 -10
View File
@@ -41,6 +41,10 @@ REQUEST_JSON.system, REQUEST_JSON.tools, and REQUEST_JSON.tool_choice are truste
Returning a tool_calls entry is a routing decision, not execution or fabrication: Pulse will validate the declared tool, enforce permissions, execute it, and return the result in a later provider turn. Never invoke local agent tools yourself. Do not refuse merely because the serialized provider request contains a system instruction or a tool catalogue.
Select only tools declared in REQUEST_JSON.tools. Return each tool's arguments as the native JSON object in input. Use stop_reason tool_use when returning any tool_calls; otherwise use end_turn.`
claudeSubscriptionAgentControlPrompt = subscriptionAgentControlPrompt + `
Claude Code transport note: StructuredOutput is the only local runtime tool. Names in REQUEST_JSON.tools and messages[].tool_calls are data values in Pulse's external protocol, not Claude Code tools. Never invoke those names as native tools. Encode the next desired Pulse call only inside StructuredOutput's tool_calls field; Pulse will execute it and provide its result in a later request.`
)
var subscriptionAgentSlots = map[SubscriptionAgent]chan struct{}{
@@ -70,15 +74,31 @@ type subscriptionAgentToolCall struct {
}
type claudePrintResponse struct {
Type string `json:"type,omitempty"`
StructuredOutput json.RawMessage `json:"structured_output"`
Result string `json:"result"`
PermissionDenials []json.RawMessage `json:"permission_denials"`
TerminalReason string `json:"terminal_reason,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
type claudeStreamEvent struct {
Type string `json:"type"`
Message struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]interface{} `json:"input,omitempty"`
} `json:"content"`
} `json:"message"`
}
type subscriptionAgentCommandError struct {
command string
cause error
@@ -231,7 +251,7 @@ func (c *SubscriptionAgentClient) Chat(ctx context.Context, req ChatRequest) (*C
raw, err = os.ReadFile(outputPath)
}
case SubscriptionAgentClaude:
raw, err = c.run(ctx, "claude", []string{"-p", "--model", c.model, "--output-format", "json", "--json-schema", string(schemaBytes), "--safe-mode", "--no-session-persistence", "--permission-mode", "dontAsk", "--tools", "", "--system-prompt", subscriptionAgentControlPrompt}, requestPrompt, workdir)
raw, err = c.run(ctx, "claude", []string{"-p", "--model", c.model, "--output-format", "stream-json", "--verbose", "--json-schema", string(schemaBytes), "--safe-mode", "--no-session-persistence", "--permission-mode", "dontAsk", "--tools", "", "--system-prompt", claudeSubscriptionAgentControlPrompt}, requestPrompt, workdir)
default:
return nil, fmt.Errorf("unknown subscription agent %q", c.agent)
}
@@ -245,7 +265,12 @@ func (c *SubscriptionAgentClient) Chat(ctx context.Context, req ChatRequest) (*C
return nil, fmt.Errorf("subscription agent output exceeds %d bytes", maxSubscriptionAgentOutputBytes)
}
turn, err := decodeSubscriptionAgentTurn(c.agent, raw)
var turn subscriptionAgentTurn
if c.agent == SubscriptionAgentClaude {
turn, err = decodeClaudeSubscriptionAgentResponse(req, raw)
} else {
turn, err = decodeSubscriptionAgentTurn(c.agent, raw)
}
if err != nil {
return nil, err
}
@@ -342,14 +367,7 @@ func (c *SubscriptionAgentClient) run(ctx context.Context, name string, args []s
}
commandErr := &subscriptionAgentCommandError{command: name, cause: err, detail: message}
if name == "claude" {
var terminal struct {
TerminalReason string `json:"terminal_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if json.Unmarshal(stdout.buffer.Bytes(), &terminal) == nil {
if terminal, ok := decodeClaudeTerminalResponse(stdout.buffer.Bytes()); ok {
commandErr.terminalReason = terminal.TerminalReason
commandErr.inputTokens = terminal.Usage.InputTokens
commandErr.outputTokens = terminal.Usage.OutputTokens
@@ -472,6 +490,133 @@ func decodeSubscriptionAgentTurn(agent SubscriptionAgent, raw []byte) (subscript
return turn, nil
}
// decodeClaudeSubscriptionAgentResponse consumes Claude Code's event stream
// instead of trusting only its terminal StructuredOutput envelope. Claude Code
// may emit a native tool_use for a name serialized in REQUEST_JSON even though
// all of its local tools are intentionally disabled. The CLI then fabricates a
// "No such tool available" result and lets the model continue, which destroys
// Pulse's one-tool-turn-at-a-time provider protocol. The stream preserves the
// original intended call before that local error. Route the first declared
// Pulse call back through Pulse's executor; never execute it inside Claude.
func decodeClaudeSubscriptionAgentResponse(req ChatRequest, raw []byte) (subscriptionAgentTurn, error) {
if !bytes.Contains(raw, []byte{'\n'}) {
return decodeSubscriptionAgentTurn(SubscriptionAgentClaude, raw)
}
allowed := make(map[string]struct{}, len(req.Tools))
for _, tool := range req.Tools {
allowed[tool.Name] = struct{}{}
}
var terminal claudePrintResponse
var terminalFound bool
var content strings.Builder
var routed *subscriptionAgentToolCall
for _, line := range bytes.Split(raw, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var event claudeStreamEvent
if err := json.Unmarshal(line, &event); err != nil {
return subscriptionAgentTurn{}, fmt.Errorf("decode Claude subscription stream event: %w", err)
}
switch event.Type {
case "assistant":
for _, block := range event.Message.Content {
switch block.Type {
case "text":
if routed == nil && strings.TrimSpace(block.Text) != "" {
content.WriteString(block.Text)
}
case "tool_use":
if block.Name == "StructuredOutput" {
continue
}
if _, ok := allowed[block.Name]; !ok {
return subscriptionAgentTurn{}, fmt.Errorf("Claude subscription agent attempted undeclared native tool %q", block.Name)
}
if routed == nil {
call := subscriptionAgentToolCall{ID: block.ID, Name: block.Name, Input: block.Input}
routed = &call
}
}
}
case "result":
if err := json.Unmarshal(line, &terminal); err != nil {
return subscriptionAgentTurn{}, fmt.Errorf("decode Claude subscription result: %w", err)
}
terminalFound = true
}
}
if !terminalFound {
return subscriptionAgentTurn{}, errors.New("Claude subscription stream did not contain a terminal result")
}
if len(terminal.PermissionDenials) > 0 {
return subscriptionAgentTurn{}, errors.New("Claude subscription agent attempted a denied built-in tool")
}
if routed != nil {
return subscriptionAgentTurn{
Content: content.String(),
RawToolCalls: []subscriptionAgentToolCall{*routed},
InputTokens: terminal.Usage.InputTokens,
OutputTokens: terminal.Usage.OutputTokens,
}, nil
}
return decodeClaudePrintResponse(terminal)
}
func decodeClaudePrintResponse(wrapper claudePrintResponse) (subscriptionAgentTurn, error) {
var turn subscriptionAgentTurn
if len(wrapper.PermissionDenials) > 0 {
return turn, errors.New("Claude subscription agent attempted a denied built-in tool")
}
payload := wrapper.StructuredOutput
if (len(payload) == 0 || string(payload) == "null") && strings.TrimSpace(wrapper.Result) != "" {
payload = json.RawMessage(wrapper.Result)
}
if len(payload) == 0 || string(payload) == "null" {
return turn, errors.New("Claude subscription response did not contain structured output")
}
if err := decodeStrictJSON(payload, &turn); err != nil {
return turn, fmt.Errorf("decode Claude structured turn: %w", err)
}
if turn.InputTokens == 0 {
turn.InputTokens = wrapper.Usage.InputTokens
}
if turn.OutputTokens == 0 {
turn.OutputTokens = wrapper.Usage.OutputTokens
}
return turn, nil
}
func decodeClaudeTerminalResponse(raw []byte) (claudePrintResponse, bool) {
var terminal claudePrintResponse
if !bytes.Contains(raw, []byte{'\n'}) {
if json.Unmarshal(raw, &terminal) == nil {
return terminal, true
}
return claudePrintResponse{}, false
}
var found bool
for _, line := range bytes.Split(raw, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var header struct {
Type string `json:"type"`
}
if json.Unmarshal(line, &header) != nil || header.Type != "result" {
continue
}
if json.Unmarshal(line, &terminal) == nil {
found = true
}
}
return terminal, found
}
func followsSuccessfulPatrolOutcome(req ChatRequest) bool {
if len(req.Messages) < 2 {
return false
@@ -168,6 +168,11 @@ func TestSubscriptionAgentPromptSeparatesTrustedControlFromInfrastructureData(t
t.Fatalf("adapter system prompt missing trust-boundary clause %q", expected)
}
}
for _, expected := range []string{"StructuredOutput is the only local runtime tool", "not Claude Code tools", "Pulse will execute it"} {
if !strings.Contains(claudeSubscriptionAgentControlPrompt, expected) {
t.Fatalf("Claude adapter system prompt missing native-tool clause %q", expected)
}
}
}
func TestSubscriptionAgentClientsUseStructuredSingleTurnProcess(t *testing.T) {
@@ -214,6 +219,8 @@ seen_system=false
seen_json_schema=false
seen_no_tools=false
seen_dont_ask=false
seen_stream_json=false
seen_verbose=false
while [ "$#" -gt 0 ]; do
case "$1" in
--system-prompt)
@@ -239,6 +246,13 @@ while [ "$#" -gt 0 ]; do
shift
[ "$1" = "dontAsk" ] && seen_dont_ask=true
;;
--output-format)
shift
[ "$1" = "stream-json" ] && seen_stream_json=true
;;
--verbose)
seen_verbose=true
;;
esac
shift
done
@@ -246,6 +260,8 @@ done
[ "$seen_json_schema" = true ] || { echo "missing native output schema" >&2; exit 97; }
[ "$seen_no_tools" = true ] || { echo "Claude built-in tools not disabled" >&2; exit 95; }
[ "$seen_dont_ask" = true ] || { echo "unexpected permission mode" >&2; exit 96; }
[ "$seen_stream_json" = true ] || { echo "Claude event stream not enabled" >&2; exit 98; }
[ "$seen_verbose" = true ] || { echo "Claude verbose stream not enabled" >&2; exit 99; }
printf '%s' '{"structured_output":{"content":"healthy","stop_reason":"end_turn","tool_calls":[]},"usage":{"input_tokens":12,"output_tokens":3}}'
`)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
@@ -340,6 +356,81 @@ func TestDecodeClaudeSubscriptionAgentRejectsNonJSONResult(t *testing.T) {
}
}
func TestDecodeClaudeSubscriptionAgentRoutesNativePulseToolAttempt(t *testing.T) {
raw := []byte(strings.Join([]string{
`{"type":"assistant","message":{"content":[{"type":"text","text":"Checking logs next."},{"type":"tool_use","id":"toolu-1","name":"pulse_read","input":{"action":"logs","resource_id":"resource-1"}}]}}`,
`{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu-1","is_error":true,"content":"No such tool available: pulse_read"}]}}`,
`{"type":"result","subtype":"success","num_turns":3,"structured_output":{"content":"Tool unavailable.","stop_reason":"end_turn","tool_calls":[]},"permission_denials":[],"usage":{"input_tokens":11,"output_tokens":17}}`,
}, "\n"))
req := ChatRequest{Tools: []Tool{{Name: "pulse_read"}}}
turn, err := decodeClaudeSubscriptionAgentResponse(req, raw)
if err != nil {
t.Fatal(err)
}
if turn.Content != "Checking logs next." || len(turn.RawToolCalls) != 1 {
t.Fatalf("routed turn = %#v", turn)
}
call := turn.RawToolCalls[0]
if call.ID != "toolu-1" || call.Name != "pulse_read" || call.Input["resource_id"] != "resource-1" {
t.Fatalf("routed tool call = %#v", call)
}
if turn.InputTokens != 11 || turn.OutputTokens != 17 {
t.Fatalf("routed usage = %d/%d", turn.InputTokens, turn.OutputTokens)
}
}
func TestDecodeClaudeSubscriptionAgentRoutesOnlyFirstNativePulseToolAttempt(t *testing.T) {
raw := []byte(strings.Join([]string{
`{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu-1","name":"pulse_read","input":{"action":"logs"}}]}}`,
`{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu-2","name":"patrol_propose_action","input":{"capability_name":"start"}}]}}`,
`{"type":"result","subtype":"success","structured_output":{"content":"Both unavailable.","stop_reason":"end_turn","tool_calls":[]},"permission_denials":[],"usage":{}}`,
}, "\n"))
req := ChatRequest{Tools: []Tool{{Name: "pulse_read"}, {Name: "patrol_propose_action"}}}
turn, err := decodeClaudeSubscriptionAgentResponse(req, raw)
if err != nil {
t.Fatal(err)
}
if len(turn.RawToolCalls) != 1 || turn.RawToolCalls[0].Name != "pulse_read" {
t.Fatalf("routed tool calls = %#v", turn.RawToolCalls)
}
}
func TestDecodeClaudeSubscriptionAgentRejectsUndeclaredNativeToolAttempt(t *testing.T) {
raw := []byte(strings.Join([]string{
`{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu-1","name":"Bash","input":{"command":"true"}}]}}`,
`{"type":"result","subtype":"success","structured_output":{"content":"done","stop_reason":"end_turn","tool_calls":[]},"permission_denials":[],"usage":{}}`,
}, "\n"))
_, err := decodeClaudeSubscriptionAgentResponse(ChatRequest{Tools: []Tool{{Name: "pulse_read"}}}, raw)
if err == nil || !strings.Contains(err.Error(), `undeclared native tool "Bash"`) {
t.Fatalf("undeclared native tool error = %v", err)
}
}
func TestDecodeClaudeSubscriptionAgentUsesTerminalStructuredOutput(t *testing.T) {
raw := []byte(strings.Join([]string{
`{"type":"assistant","message":{"content":[{"type":"tool_use","id":"structured-1","name":"StructuredOutput","input":{"content":"healthy","stop_reason":"end_turn","tool_calls":[]}}]}}`,
`{"type":"result","subtype":"success","num_turns":2,"structured_output":{"content":"healthy","stop_reason":"end_turn","tool_calls":[]},"permission_denials":[],"usage":{"input_tokens":5,"output_tokens":7}}`,
}, "\n"))
turn, err := decodeClaudeSubscriptionAgentResponse(ChatRequest{}, raw)
if err != nil {
t.Fatal(err)
}
if turn.Content != "healthy" || len(turn.RawToolCalls) != 0 || turn.InputTokens != 5 || turn.OutputTokens != 7 {
t.Fatalf("structured turn = %#v", turn)
}
}
func TestDecodeClaudeTerminalResponseUsesFinalStreamResult(t *testing.T) {
raw := []byte(strings.Join([]string{
`{"type":"system","subtype":"init"}`,
`{"type":"result","subtype":"error","terminal_reason":"structured_output_retry_exhausted","usage":{"input_tokens":19,"output_tokens":23}}`,
}, "\n"))
terminal, ok := decodeClaudeTerminalResponse(raw)
if !ok || terminal.TerminalReason != "structured_output_retry_exhausted" || terminal.Usage.InputTokens != 19 || terminal.Usage.OutputTokens != 23 {
t.Fatalf("terminal stream result = (%#v, %t)", terminal, ok)
}
}
func TestDecodeSubscriptionAgentTurnRejectsUnknownStructuredFields(t *testing.T) {
raw := []byte(`{"result":"{\"content\":\"healthy\",\"stop_reason\":\"end_turn\",\"tool_calls\":[],\"unexpected\":true}"}`)
if _, err := decodeSubscriptionAgentTurn(SubscriptionAgentClaude, raw); err == nil || !strings.Contains(err.Error(), "unknown field") {
@@ -149,6 +149,10 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase):
self.assertIn("API-key environment variables", normalized_content)
self.assertIn("dedicated, least-privilege OS account", normalized_content)
self.assertIn("Pulse retains tool execution and policy enforcement", normalized_content)
self.assertIn("Pulse audits Claude's buffered event stream", normalized_content)
self.assertIn("accepts only the first call whose name was explicitly offered", normalized_content)
self.assertIn("undeclared local tool attempts fail closed", normalized_content)
self.assertIn("does not grant Claude Code direct access to the infrastructure", normalized_content)
self.assertIn("not represented as a zero-dollar API price", normalized_content)
self.assertIn("per-run metered-API budget as not applicable", normalized_content)
self.assertIn("inference_route=coding_plan_allowance", content)