You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/20 14:21:39 UTC

(skywalking-ai-sessionizer) 09/23: Let a runtime that is not Claude Code drive the change recorder

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch langchain-harness
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git

commit 02a594f41ef4a4eec1800afccde58ea67f92f4a1
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Sep 20 19:26:28 2026 +0800

    Let a runtime that is not Claude Code drive the change recorder
    
    The program records what a tool call changed, which is nobody's idea in
    particular, but three things made it Claude Code's alone. Each was a wall found
    by driving it from somewhere else, not by reading it.
    
    A tool with no shell command was classified read-only and skipped. The
    classifier returns true for an empty string, which is vacuously right and
    practically wrong: every tool of every runtime whose tools take arguments
    rather than a command line was being skipped. No command is not a read-only
    command.
    
    Its name could not be listed. A runtime with a fixed tool set can name its
    shells, which is what the Claude Code entry does; a runtime whose tools the
    application defines cannot, because nobody knows the names in advance. So an
    entry is now an exact name, a regular expression when it begins "re:", or "*"
    for every tool — with an exclusion list in the same three forms, which is what
    makes the wildcard usable: the read-only classifier only understands shell
    commands, so for any other runtime the exclusions are the only way to keep a
    scan off a tool that reads. A regular expression that does not compile matches
    nothing rather than everything, because a setting someone got wrong should
    observe too little and be noticed.
    
    And its data directory could only be named by CLAUDE_PLUGIN_DATA. ASZ_CHANGES_
    DATA comes first now; the other still works, because Claude Code sets it for
    every hook and a plugin installed there should need no configuration at all.
    
    The events have neutral names too — tool.begin, tool.end, tool.failed — meaning
    exactly what Claude Code's do and taking the same path. Another runtime should
    not have to send "PreToolUse" to say a tool began.
---
 plugins/claude-code/internal/hook/hook.go         |  33 ++++-
 plugins/claude-code/internal/readonly/readonly.go |   7 +
 plugins/claude-code/internal/settings/settings.go |  38 +++++-
 plugins/claude-code/main.go                       |  32 +++--
 plugins/claude-code/neutral_test.go               | 148 ++++++++++++++++++++++
 5 files changed, 247 insertions(+), 11 deletions(-)

diff --git a/plugins/claude-code/internal/hook/hook.go b/plugins/claude-code/internal/hook/hook.go
index 2da704b..3d7b3fa 100644
--- a/plugins/claude-code/internal/hook/hook.go
+++ b/plugins/claude-code/internal/hook/hook.go
@@ -30,7 +30,11 @@ import (
 	"io"
 )
 
-// Events this plugin acts on.
+// Events this plugin acts on, in its own vocabulary.
+//
+// The names are Claude Code's, because that is the runtime this was written
+// for and changing them would break every installed manifest for nothing. What
+// matters is that they are no longer the ONLY names: see neutral below.
 const (
 	PreToolUse         = "PreToolUse"
 	PostToolUse        = "PostToolUse"
@@ -39,6 +43,30 @@ const (
 	SessionEnd         = "SessionEnd"
 )
 
+// neutral maps a runtime-neutral event name onto the one this acts on.
+//
+// This program records what a tool call changed, which is not a Claude Code
+// idea. Another runtime's integration should not have to send "PreToolUse" to
+// say a tool began, so it can say so plainly instead. The two vocabularies
+// mean the same thing and take the same path.
+var neutral = map[string]string{
+	"tool.begin":    PreToolUse,
+	"tool.end":      PostToolUse,
+	"tool.failed":   PostToolUseFailure,
+	"session.begin": SessionStart,
+	"session.end":   SessionEnd,
+}
+
+// Neutral returns the neutral name for an event, for a caller writing one.
+func Neutral(event string) string {
+	for name, mapped := range neutral {
+		if mapped == event {
+			return name
+		}
+	}
+	return event
+}
+
 // Input is one hook invocation.
 type Input struct {
 	Event     string `json:"hook_event_name"`
@@ -68,6 +96,9 @@ func Read(r io.Reader) (*Input, error) {
 	if in.Event == "" {
 		return nil, fmt.Errorf("hook: input names no event")
 	}
+	if mapped, ok := neutral[in.Event]; ok {
+		in.Event = mapped
+	}
 	return &in, nil
 }
 
diff --git a/plugins/claude-code/internal/readonly/readonly.go b/plugins/claude-code/internal/readonly/readonly.go
index cd63c56..1ed8631 100644
--- a/plugins/claude-code/internal/readonly/readonly.go
+++ b/plugins/claude-code/internal/readonly/readonly.go
@@ -88,6 +88,13 @@ var dupIn = regexp.MustCompile(`^<&(\d+|-)`)
 
 // IsReadOnly reports whether the command cannot change the working tree.
 func IsReadOnly(cmd string) bool {
+	// No command at all is not a read-only command: it is a tool that is not
+	// a shell. Returning true here would skip the scan for every tool of
+	// every runtime whose tools take arguments rather than a command line,
+	// which is all of them but this one.
+	if strings.TrimSpace(cmd) == "" {
+		return false
+	}
 	segs, redirect, subst := split(cmd)
 	if redirect || subst {
 		return false
diff --git a/plugins/claude-code/internal/settings/settings.go b/plugins/claude-code/internal/settings/settings.go
index 07810af..df73dc5 100644
--- a/plugins/claude-code/internal/settings/settings.go
+++ b/plugins/claude-code/internal/settings/settings.go
@@ -25,6 +25,8 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"regexp"
+	"strings"
 	"time"
 
 	"gopkg.in/yaml.v3"
@@ -73,8 +75,20 @@ type ReadOnly struct {
 func (r ReadOnly) On() bool { return r.Enabled == nil || *r.Enabled }
 
 // Tools names the tools observed with a scan.
+//
+// A runtime with a fixed tool set can be listed by name, which is what Claude
+// Code's entry does. A runtime whose tools the application defines cannot be:
+// nobody knows their names in advance. So an entry takes three forms, and the
+// exclusions take the same three.
 type Tools struct {
+	// Scope names the tools observed. An entry is an exact name, a regular
+	// expression when it begins "re:", or "*" alone for every tool.
 	Scope []string `yaml:"scope"`
+	// Exclude names tools never observed, in the same three forms, applied
+	// after Scope. It is what makes "*" usable: the read-only classifier
+	// only understands shell commands, so for any other runtime this is the
+	// only way to keep a scan off a tool that reads.
+	Exclude []string `yaml:"exclude"`
 }
 
 // Retention is how long things are kept.
@@ -143,8 +157,28 @@ func Load(dataDir string) (*Settings, error) {
 
 // IsScopeTool reports whether a tool is observed with a scan.
 func (s *Settings) IsScopeTool(name string) bool {
-	for _, t := range s.Tools.Scope {
-		if t == name {
+	if !matchesAny(s.Tools.Scope, name) {
+		return false
+	}
+	return !matchesAny(s.Tools.Exclude, name)
+}
+
+// matchesAny reads the three entry forms.
+//
+// A regular expression that does not compile matches nothing rather than
+// everything. A setting someone got wrong should observe too little and be
+// noticed, not observe everything and be expensive.
+func matchesAny(entries []string, name string) bool {
+	for _, entry := range entries {
+		switch {
+		case entry == "*":
+			return true
+		case strings.HasPrefix(entry, "re:"):
+			re, err := regexp.Compile(entry[len("re:"):])
+			if err == nil && re.MatchString(name) {
+				return true
+			}
+		case entry == name:
 			return true
 		}
 	}
diff --git a/plugins/claude-code/main.go b/plugins/claude-code/main.go
index 3b8d9a4..5b1471d 100644
--- a/plugins/claude-code/main.go
+++ b/plugins/claude-code/main.go
@@ -57,6 +57,21 @@ import (
 	"github.com/apache/skywalking-ai-sessionizer/plugins/claude-code/internal/settings"
 )
 
+// dataDir is where this keeps its snapshots and its output.
+//
+// ASZ_CHANGES_DATA comes first because this program is not Claude Code's: it
+// records what a tool call changed for whatever runtime drives it, and a
+// runtime-neutral program reading a runtime's variable would be asking every
+// other one to pretend. CLAUDE_PLUGIN_DATA still works, because Claude Code
+// sets it for every hook and a plugin installed there should need no
+// configuration at all.
+func dataDir() string {
+	if dir := os.Getenv("ASZ_CHANGES_DATA"); dir != "" {
+		return dir
+	}
+	return os.Getenv("CLAUDE_PLUGIN_DATA")
+}
+
 // version is set at build time from the tag or the commit.
 var version = "dev"
 
@@ -71,14 +86,14 @@ Usage:
 With no command, it runs as hook when standard input is a pipe, a socket, a
 file, or anything else that is not a terminal or another character device.
 
-The plugin's data directory is CLAUDE_PLUGIN_DATA, which Claude Code sets for
-every hook. status and prune take it from the environment too.
+The data directory is ASZ_CHANGES_DATA, or CLAUDE_PLUGIN_DATA, which Claude
+Code sets for every hook. status and prune take it from the environment too.
 `
 
 func main() {
 	switch subcommand(os.Args[1:], os.Stdin) {
 	case "hook":
-		os.Exit(runHook(os.Stdin, os.Getenv("CLAUDE_PLUGIN_DATA"), os.Getenv("CLAUDE_PROJECT_DIR"), time.Now()))
+		os.Exit(runHook(os.Stdin, dataDir(), os.Getenv("CLAUDE_PROJECT_DIR"), time.Now()))
 	case "version":
 		fmt.Printf("asz-claude-plugin %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
 	case "status":
@@ -582,10 +597,11 @@ func (p *plugin) maintain() {
 
 // status prints what the plugin would run under.
 func status() error {
-	dataDir := os.Getenv("CLAUDE_PLUGIN_DATA")
-	if dataDir == "" {
-		return errors.New("CLAUDE_PLUGIN_DATA is not set; run under Claude Code, or set it to the plugin's data directory")
+	dir := dataDir()
+	if dir == "" {
+		return errors.New("no data directory: set ASZ_CHANGES_DATA, or run under Claude Code, which sets CLAUDE_PLUGIN_DATA")
 	}
+	dataDir := dir
 	st, err := settings.Load(dataDir)
 	if err != nil {
 		return err
@@ -616,9 +632,9 @@ func orDefault(s, d string) string {
 
 // pruneNow runs the retention rules from the command line.
 func pruneNow() error {
-	dataDir := os.Getenv("CLAUDE_PLUGIN_DATA")
+	dataDir := dataDir()
 	if dataDir == "" {
-		return errors.New("CLAUDE_PLUGIN_DATA is not set")
+		return errors.New("no data directory: set ASZ_CHANGES_DATA, or run under Claude Code, which sets CLAUDE_PLUGIN_DATA")
 	}
 	st, err := settings.Load(dataDir)
 	if err != nil {
diff --git a/plugins/claude-code/neutral_test.go b/plugins/claude-code/neutral_test.go
new file mode 100644
index 0000000..db7b3a6
--- /dev/null
+++ b/plugins/claude-code/neutral_test.go
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/apache/skywalking-ai-sessionizer/pkg/changes"
+	"github.com/apache/skywalking-ai-sessionizer/plugins/claude-code/internal/hook"
+)
+
+// TestAnotherRuntimeCanDriveThis carries one tool call in the neutral
+// vocabulary, from a runtime that is not Claude Code: a tool with no shell
+// command, a name nobody could have listed in advance, and no
+// CLAUDE_PLUGIN_DATA anywhere.
+//
+// Each of the three things it needs was a wall before: a tool with no command
+// was classified read-only and skipped, its name was not in the scope list and
+// could not be, and the data directory could only be named by a Claude Code
+// variable.
+func TestAnotherRuntimeCanDriveThis(t *testing.T) {
+	data, work := t.TempDir(), t.TempDir()
+	if err := os.WriteFile(filepath.Join(data, "settings.yaml"),
+		[]byte("tools:\n  scope: [\"*\"]\n  exclude: [\"re:^(read|get|list)_\"]\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(work, "notes.txt"), []byte("before\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("ASZ_CHANGES_DATA", data)
+
+	event := func(name, tool string) string {
+		payload, _ := json.Marshal(map[string]any{
+			"hook_event_name": name, "session_id": "ls-a-thread", "cwd": work,
+			"tool_name": tool, "tool_use_id": "call_write_report_5",
+			"tool_input": map[string]any{"cluster": "prod-1"},
+		})
+		return string(payload)
+	}
+	now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
+	if code := runHook(strings.NewReader(event("tool.begin", "write_report")), data, "", now); code != 0 {
+		t.Fatalf("tool.begin exited %d", code)
+	}
+	if err := os.WriteFile(filepath.Join(work, "findings.md"), []byte("# found\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if code := runHook(strings.NewReader(event("tool.end", "write_report")), data,
+		"", now.Add(time.Second)); code != 0 {
+		t.Fatalf("tool.end exited %d", code)
+	}
+
+	record := lastRecord(t, data)
+	if record.Basis != "tool_window" {
+		t.Fatalf("basis %q: a tool with no shell command was not observed", record.Basis)
+	}
+	if record.Tool != "call_write_report_5" {
+		t.Errorf("the record names %q, not the call it answers", record.Tool)
+	}
+	if record.ChangedFiles == nil || *record.ChangedFiles == 0 {
+		t.Fatal("nothing was recorded as changed")
+	}
+	var added bool
+	for _, c := range record.Changes {
+		if strings.HasSuffix(c.Path, "findings.md") {
+			added = true
+		}
+	}
+	if !added {
+		t.Error("the file the tool wrote is not in the record")
+	}
+
+	// And a tool the exclusions name is left alone, which is what makes the
+	// wildcard usable for a runtime whose tools nobody can list.
+	if code := runHook(strings.NewReader(event("tool.begin", "read_status")), data,
+		"", now.Add(2*time.Second)); code != 0 {
+		t.Fatalf("excluded tool.begin exited %d", code)
+	}
+	if err := os.WriteFile(filepath.Join(work, "ignored.txt"), []byte("x\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if code := runHook(strings.NewReader(event("tool.end", "read_status")), data,
+		"", now.Add(3*time.Second)); code != 0 {
+		t.Fatalf("excluded tool.end exited %d", code)
+	}
+	if got := lastRecord(t, data); got.Tool == "call_write_report_5" {
+		return // nothing new was written, which is the point
+	} else if got.Basis == "tool_window" && got.ToolName == "read_status" {
+		t.Error("an excluded tool was scanned")
+	}
+}
+
+// TestNeutralNamesAreTheSameEvents keeps the two vocabularies in step.
+func TestNeutralNamesAreTheSameEvents(t *testing.T) {
+	for neutral, want := range map[string]string{
+		"tool.begin": hook.PreToolUse, "tool.end": hook.PostToolUse,
+		"tool.failed":   hook.PostToolUseFailure,
+		"session.begin": hook.SessionStart, "session.end": hook.SessionEnd,
+	} {
+		in, err := hook.Read(strings.NewReader(`{"hook_event_name":"` + neutral + `"}`))
+		if err != nil {
+			t.Fatalf("%s: %v", neutral, err)
+		}
+		if in.Event != want {
+			t.Errorf("%s read as %q, want %q", neutral, in.Event, want)
+		}
+		if got := hook.Neutral(want); got != neutral {
+			t.Errorf("%s renders back as %q", want, got)
+		}
+	}
+}
+
+func lastRecord(t *testing.T, data string) changes.Record {
+	t.Helper()
+	matches, err := filepath.Glob(filepath.Join(data, "output", "*", "*.jsonl"))
+	if err != nil || len(matches) == 0 {
+		t.Fatalf("no output under %s: %v", data, err)
+	}
+	body, err := os.ReadFile(matches[0])
+	if err != nil {
+		t.Fatal(err)
+	}
+	lines := strings.Split(strings.TrimSpace(string(body)), "\n")
+	var record changes.Record
+	if err := json.Unmarshal([]byte(lines[len(lines)-1]), &record); err != nil {
+		t.Fatal(err)
+	}
+	return record
+}