Compare commits

...
Author SHA1 Message Date
moloch-- 6d51a23cc9 Implement linux detailed version info 2026-03-29 19:11:48 -07:00
moloch-- 3d6eec47be Implement linux detailed version info 2026-03-29 19:11:40 -07:00
moloch-- 464fb7008c Tweak ai.yaml config schema 2026-03-29 18:41:06 -07:00
moloch-- ffa5f89f5f Tweak styling of system prompt 2026-03-29 09:00:15 -07:00
moloch-- f19cbdc957 Give agent encoder/decoder tools 2026-03-29 08:43:14 -07:00
moloch-- 83bfc8d8e6 Add default prompt to ai.yaml config 2026-03-27 21:36:58 -07:00
moloch-- 365bb3596e Add aliases and extensions to agent tools 2026-03-27 20:22:15 -07:00
moloch-- 9ee96c2ce6 Add ctx window usage to tui 2026-03-27 19:43:42 -07:00
moloch-- e776e266d0 Stack toast 2026-03-27 18:46:08 -07:00
moloch-- d080c8a646 Implement more tools for the ai to use 2026-03-27 18:15:46 -07:00
moloch-- cb6402245e Implement more tools for the ai to use 2026-03-27 18:15:37 -07:00
moloch-- e4357277ec Add web search to oai models 2026-03-27 17:33:15 -07:00
moloch-- 5dc2ab1490 Render reasoning text as md 2026-03-27 17:13:39 -07:00
moloch-- 9f034a9956 Implement better reasoning ui 2026-03-27 17:10:24 -07:00
moloch-- f700535997 Implement selectable text 2026-03-27 16:59:08 -07:00
moloch-- 31bca034d8 Remove layout text 2026-03-27 16:48:08 -07:00
moloch-- fac4ad5fb7 Add mouse support to ai tui 2026-03-27 16:40:33 -07:00
moloch-- c128d33514 Add toast notifications to tui 2026-03-27 13:01:34 -07:00
moloch-- 4a37262bcf Implement scroll wheel support for conversation pane 2026-03-27 11:56:58 -07:00
moloch-- a2295112bd Clean up various styling in ai tui 2026-03-27 11:47:39 -07:00
moloch-- 834dff0f6e Add lic header 2026-03-27 08:38:41 -07:00
Joe f4a0431fdd Merge pull request #2231 from BishopFox/fix/pivots-dos
Fix/pivots dos
2026-03-26 21:40:23 -07:00
moloch-- d072b328c0 Fix pivot handler test helper collision 2026-03-26 19:44:18 -07:00
moloch-- 435bbcbd7e Merge master 2026-03-26 18:51:05 -07:00
moloch-- 84dc28e584 Fix nil deref in pivots 2026-03-26 18:50:13 -07:00
Joe 639592cb02 Merge pull request #2230 from BishopFox/fix/tunnel-dos
Fix/tunnel dos
2026-03-26 18:03:16 -07:00
moloch-- 8c7a1a6b40 Fix dos/panic in tunnel closed 2026-03-26 17:40:57 -07:00
moloch-- 06274c4061 Fix dos/panic in tunnel closed 2026-03-26 17:40:46 -07:00
69 changed files with 12653 additions and 891 deletions

No files matched your search

+131 -39
View File
@@ -1,17 +1,40 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/termio"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var openAIProgramTTY = tea.OpenTTY
// AICmd launches the AI conversation TUI.
func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
if con == nil || con.Rpc == nil {
@@ -23,6 +46,8 @@ func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
listenerID, listener := con.CreateEventListener()
defer con.RemoveEventListener(listenerID)
restoreEventNotifications := con.SuppressEventNotifications()
defer restoreEventNotifications()
model := newAIModel(con, buildAIContext(con), listener)
model.showExperimentalWarningModal()
@@ -32,16 +57,61 @@ func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
width, height = w, h
}
program := tea.NewProgram(
model,
opts := []tea.ProgramOption{
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
)
}
ttyOpts, cleanup := configureAIProgramTTY()
if cleanup != nil {
defer cleanup()
}
opts = append(opts, ttyOpts...)
program := tea.NewProgram(model, opts...)
if _, err := program.Run(); err != nil {
con.PrintErrorf("AI TUI error: %s\n", err)
}
}
// Console logging can swap stdout/stderr to pipes so session output can be tee'd
// to disk. Bubble Tea needs a real TTY output to enable terminal features like
// mouse reporting, so bind the AI TUI back to the interactive terminal when
// stdout is no longer the controlling TTY.
func configureAIProgramTTY() ([]tea.ProgramOption, func()) {
stdinTTY := isAITTY(os.Stdin)
stdoutTTY := isAITTY(os.Stdout)
switch {
case !stdinTTY || stdoutTTY:
return nil, nil
case isAITTY(termio.InteractiveInput()) && isAITTY(termio.InteractiveOutput()):
return []tea.ProgramOption{
tea.WithInput(termio.InteractiveInput()),
tea.WithOutput(termio.InteractiveOutput()),
}, nil
default:
inTTY, outTTY, err := openAIProgramTTY()
if err != nil {
return nil, nil
}
return []tea.ProgramOption{
tea.WithInput(inTTY),
tea.WithOutput(outTTY),
}, func() {
_ = inTTY.Close()
if outTTY != inTTY {
_ = outTTY.Close()
}
}
}
}
func isAITTY(file *os.File) bool {
return file != nil && term.IsTerminal(int(file.Fd()))
}
type aiContext struct {
target aiTargetSummary
connection aiConnectionSummary
@@ -67,16 +137,64 @@ type aiConnectionSummary struct {
State string
}
func defaultAITargetSummary() aiTargetSummary {
return aiTargetSummary{
Label: "No active target",
Host: "Select a session or beacon with `use` or ctrl+s",
Mode: "offline preview",
C2: "n/a",
OS: "unknown",
Arch: "unknown",
}
}
func aiSessionTargetSummary(session *clientpb.Session) aiTargetSummary {
if session == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
SessionID: session.ID,
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
Details: []string{
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
fmt.Sprintf("PID: %d", session.PID),
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
},
}
}
func aiBeaconTargetSummary(beacon *clientpb.Beacon) aiTargetSummary {
if beacon == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
BeaconID: beacon.ID,
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
Details: []string{
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
fmt.Sprintf("PID: %d", beacon.PID),
fmt.Sprintf("Remote: %s", fallback(beacon.RemoteAddress, "<unknown>")),
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
},
}
}
func buildAIContext(con *console.SliverClient) aiContext {
ctx := aiContext{
target: aiTargetSummary{
Label: "No active target",
Host: "Select a session or beacon with `use`",
Mode: "offline preview",
C2: "n/a",
OS: "unknown",
Arch: "unknown",
},
target: defaultAITargetSummary(),
connection: aiConnectionSummary{
Profile: "<disconnected>",
Server: "<unknown>",
@@ -99,35 +217,9 @@ func buildAIContext(con *console.SliverClient) aiContext {
session, beacon := con.ActiveTarget.Get()
switch {
case session != nil:
ctx.target = aiTargetSummary{
SessionID: session.ID,
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
Details: []string{
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
fmt.Sprintf("PID: %d", session.PID),
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
},
}
ctx.target = aiSessionTargetSummary(session)
case beacon != nil:
ctx.target = aiTargetSummary{
BeaconID: beacon.ID,
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
Details: []string{
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
},
}
ctx.target = aiBeaconTargetSummary(beacon)
}
}
+18
View File
@@ -1,5 +1,23 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
+83 -11
View File
@@ -1,5 +1,23 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"context"
"net"
@@ -87,24 +105,27 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
Valid: true,
Provider: "openai",
Model: "gpt-test",
SystemPrompt: "Stay concise.",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{},
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-created": {
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
},
}
@@ -134,6 +155,9 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
if request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetTitle() != "New conversation" {
t.Fatalf("unexpected SaveAIConversation request: %+v", request)
}
if request.GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected SaveAIConversation system prompt: %q", request.GetSystemPrompt())
}
}
func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
@@ -161,6 +185,7 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
nil,
"openai",
"gpt-test",
"Stay concise.",
"Explain the workflow.\n\nWith details.",
)()
@@ -183,6 +208,9 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
}
if server.saveConversationReqs[0].GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected conversation system prompt: %q", server.saveConversationReqs[0].GetSystemPrompt())
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
@@ -214,6 +242,7 @@ func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
"openai",
"gpt-test",
"Stay concise.",
"hello",
)()
@@ -294,6 +323,25 @@ func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
}
}
func TestWaitForAIConversationEventCmdReturnsToastMessages(t *testing.T) {
listener := make(chan *clientpb.Event, 1)
listener <- &clientpb.Event{
EventType: consts.ClientToastEvent,
Data: []byte("Session alpha connected"),
Err: "info",
}
msg := waitForAIConversationEventCmd(listener)()
toast, ok := msg.(aiToastMsg)
if !ok {
t.Fatalf("expected aiToastMsg, got %T", msg)
}
if toast.level != "info" || toast.message != "Session alpha connected" {
t.Fatalf("unexpected toast payload: %+v", toast)
}
}
func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
@@ -391,9 +439,13 @@ type aiRPCServer struct {
conversationByID map[string]*clientpb.AIConversation
saveConversationResp *clientpb.AIConversation
saveMessageResp *clientpb.AIConversationMessage
sessionsResp *clientpb.Sessions
beaconsResp *clientpb.Beacons
getProvidersCalls int
getConversationsCalls int
getSessionsCalls int
getBeaconsCalls int
getConversationReqs []*clientpb.AIConversationReq
deleteConversationReqs []*clientpb.AIConversationReq
saveConversationReqs []*clientpb.AIConversation
@@ -424,6 +476,26 @@ func (s *aiRPCServer) GetAIConversations(context.Context, *commonpb.Empty) (*cli
return proto.Clone(s.conversationsResp).(*clientpb.AIConversations), nil
}
func (s *aiRPCServer) GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getSessionsCalls++
if s.sessionsResp == nil {
return &clientpb.Sessions{}, nil
}
return proto.Clone(s.sessionsResp).(*clientpb.Sessions), nil
}
func (s *aiRPCServer) GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getBeaconsCalls++
if s.beaconsResp == nil {
return &clientpb.Beacons{}, nil
}
return proto.Clone(s.beaconsResp).(*clientpb.Beacons), nil
}
func (s *aiRPCServer) GetAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
+2006 -172
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18
View File
@@ -1,5 +1,23 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
+18
View File
@@ -1,5 +1,23 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
+98 -12
View File
@@ -29,6 +29,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/bishopfox/sliver/client/assets"
@@ -99,6 +100,7 @@ type SliverClient struct {
connWg *sync.WaitGroup
connectionHooksApplied bool
logCommandHookApplied bool
suppressedEventNotifs atomic.Int32
// These writers are always safe to use (never error); the underlying stream
// can be swapped on server switch to avoid breaking io.MultiWriter/io.Copy.
@@ -352,8 +354,20 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
switch event.EventType {
case consts.CanaryEvent:
con.PrintEventErrorf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
sessions := con.GetSessionsByName(event.Session.Name)
if con.eventNotificationsSuppressed() {
affected := make([]string, 0, len(sessions))
for _, session := range sessions {
affected = append(affected, strings.Split(session.ID, "-")[0])
}
message := fmt.Sprintf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
if len(affected) > 0 {
message += " Affected sessions: " + strings.Join(affected, ", ")
}
con.emitToastEvent("error", message)
break
}
con.PrintEventErrorf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
for _, session := range sessions {
shortID := strings.Split(session.ID, "-")[0]
con.PrintErrorf("\t🔥 Session %s is affected", shortID)
@@ -361,8 +375,20 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
case consts.WatchtowerEvent:
msg := string(event.Data)
con.PrintEventErrorf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
sessions := con.GetSessionsByName(event.Session.Name)
if con.eventNotificationsSuppressed() {
affected := make([]string, 0, len(sessions))
for _, session := range sessions {
affected = append(affected, strings.Split(session.ID, "-")[0])
}
message := fmt.Sprintf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
if len(affected) > 0 {
message += " Affected sessions: " + strings.Join(affected, ", ")
}
con.emitToastEvent("error", message)
break
}
con.PrintEventErrorf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
for _, session := range sessions {
shortID := strings.Split(session.ID, "-")[0]
con.PrintErrorf("\t🔥 Session %s is affected", shortID)
@@ -370,42 +396,42 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
case consts.JoinedEvent:
if con.Settings.UserConnect {
con.PrintInfof("%s has joined the game", event.Client.Operator.Name)
con.emitConsoleNotification("info", false, "%s has joined the game", event.Client.Operator.Name)
}
case consts.LeftEvent:
if con.Settings.UserConnect {
con.PrintInfof("%s left the game", event.Client.Operator.Name)
con.emitConsoleNotification("info", false, "%s left the game", event.Client.Operator.Name)
}
case consts.JobStoppedEvent:
job := event.Job
con.PrintErrorf("Job #%d stopped (%s/%s)", job.ID, job.Protocol, job.Name)
con.emitConsoleNotification("error", false, "Job #%d stopped (%s/%s)", job.ID, job.Protocol, job.Name)
case consts.SessionOpenedEvent:
session := event.Session
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(session.ID, "-")[0]
con.PrintEventInfof("Session %s %s - %s (%s) - %s/%s - %v",
con.emitConsoleNotification("info", true, "Session %s %s - %s (%s) - %s/%s - %v",
shortID, session.Name, session.RemoteAddress, session.Hostname, session.OS, session.Arch, currentTime)
case consts.SessionUpdateEvent:
session := event.Session
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(session.ID, "-")[0]
con.PrintInfof("Session %s has been updated - %v", shortID, currentTime)
con.emitConsoleNotification("info", false, "Session %s has been updated - %v", shortID, currentTime)
case consts.SessionClosedEvent:
session := event.Session
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(session.ID, "-")[0]
con.PrintEventErrorf("Lost session %s %s - %s (%s) - %s/%s - %v",
con.emitConsoleNotification("error", true, "Lost session %s %s - %s (%s) - %s/%s - %v",
shortID, session.Name, session.RemoteAddress, session.Hostname, session.OS, session.Arch, currentTime)
activeSession := con.ActiveTarget.GetSession()
core.GetTunnels().CloseForSession(session.ID)
core.CloseCursedProcesses(session.ID)
if activeSession != nil && activeSession.ID == session.ID {
con.ActiveTarget.Set(nil, nil)
con.PrintErrorf("Active session disconnected")
con.emitConsoleNotification("error", false, "Active session disconnected")
}
case consts.BeaconRegisteredEvent:
@@ -413,7 +439,7 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
proto.Unmarshal(event.Data, beacon)
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(beacon.ID, "-")[0]
con.PrintEventInfof("Beacon %s %s - %s (%s) - %s/%s - %v",
con.emitConsoleNotification("info", true, "Beacon %s %s - %s (%s) - %s/%s - %v",
shortID, beacon.Name, beacon.RemoteAddress, beacon.Hostname, beacon.OS, beacon.Arch, currentTime)
case consts.BeaconTaskResultEvent:
@@ -433,6 +459,66 @@ func (con *SliverClient) CreateEventListener() (string, <-chan *clientpb.Event)
return listenerID.String(), listener
}
// SuppressEventNotifications redirects console event messages to local listeners instead of stdout.
func (con *SliverClient) SuppressEventNotifications() func() {
if con == nil {
return func() {}
}
con.suppressedEventNotifs.Add(1)
return func() {
if con == nil {
return
}
if con.suppressedEventNotifs.Add(-1) < 0 {
con.suppressedEventNotifs.Store(0)
}
}
}
func (con *SliverClient) eventNotificationsSuppressed() bool {
return con != nil && con.suppressedEventNotifs.Load() > 0
}
func (con *SliverClient) emitToastEvent(level string, message string) {
if con == nil || con.EventListeners == nil || strings.TrimSpace(message) == "" {
return
}
con.triggerEventListeners(&clientpb.Event{
EventType: consts.ClientToastEvent,
Data: []byte(message),
Err: strings.TrimSpace(level),
})
}
func (con *SliverClient) emitConsoleNotification(level string, emphasized bool, format string, args ...any) {
message := fmt.Sprintf(format, args...)
if con.eventNotificationsSuppressed() {
con.emitToastEvent(level, message)
return
}
switch strings.ToLower(strings.TrimSpace(level)) {
case "error", "warn", "warning":
if emphasized {
con.PrintEventErrorf("%s", message)
} else {
con.PrintErrorf("%s", message)
}
case "success":
if emphasized {
con.PrintEventSuccessf("%s", message)
} else {
con.PrintSuccessf("%s", message)
}
default:
if emphasized {
con.PrintEventInfof("%s", message)
} else {
con.PrintInfof("%s", message)
}
}
}
// RemoveEventListener - removes an event listener given its id.
func (con *SliverClient) RemoveEventListener(listenerID string) {
value, ok := con.EventListeners.LoadAndDelete(listenerID)
@@ -477,10 +563,10 @@ func (con *SliverClient) triggerReactions(event *clientpb.Event) {
for _, reaction := range reactions {
for _, line := range reaction.Commands {
con.PrintInfof("%s '%s'", StyleBold.Render("Execute reaction:"), line)
con.emitConsoleNotification("info", false, "%s '%s'", StyleBold.Render("Execute reaction:"), line)
err := con.App.ActiveMenu().RunCommandLine(context.Background(), line)
if err != nil {
con.PrintErrorf("Reaction command error: %s\n", err)
con.emitConsoleNotification("error", false, "Reaction command error: %s", err)
}
}
}
+33
View File
@@ -3,8 +3,11 @@ package console
import (
"bytes"
"os"
"sync"
"testing"
"time"
consts "github.com/bishopfox/sliver/client/constants"
)
func TestDrainStdoutPipeBufferStripsSyncFrames(t *testing.T) {
@@ -102,3 +105,33 @@ func TestSyncOutputWaitsForPipeDrain(t *testing.T) {
t.Fatalf("unexpected final output %q", got.String())
}
}
func TestSuppressEventNotificationsRedirectsMessagesToListeners(t *testing.T) {
con := &SliverClient{
EventListeners: &sync.Map{},
printf: func(string, ...any) (int, error) { return 0, nil },
}
listenerID, listener := con.CreateEventListener()
defer con.RemoveEventListener(listenerID)
restore := con.SuppressEventNotifications()
defer restore()
con.emitConsoleNotification("info", true, "Session %s connected", "alpha")
select {
case event := <-listener:
if event.GetEventType() != consts.ClientToastEvent {
t.Fatalf("expected toast event, got %q", event.GetEventType())
}
if got := string(event.GetData()); got != "Session alpha connected" {
t.Fatalf("unexpected toast payload %q", got)
}
if got := event.GetErr(); got != "info" {
t.Fatalf("unexpected toast level %q", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for redirected event notification")
}
}
+3
View File
@@ -119,6 +119,9 @@ const (
// AIConversationEvent - Fires whenever an AI conversation or message changes.
AIConversationEvent = "ai-conversation"
// ClientToastEvent - Local-only event used to surface console notifications inside TUIs.
ClientToastEvent = "client-toast"
)
// Commands.
+10 -7
View File
@@ -74,6 +74,7 @@ The target fields bind a conversation to the active session or beacon so server-
- `Kind`: chat, reasoning, or tool call
- `Visibility`: context or UI-only
- `IncludeInContext`: whether the block should be replayed into the provider context window
- `State`: in progress, completed, or failed
- `TurnID`
- `ItemID`
@@ -83,7 +84,7 @@ This keeps one ordered transcript table while still distinguishing chat context
### UI-Only Persistence
Reasoning and tool-call items are stored as normal conversation messages, but with `Visibility = UI_ONLY`.
Reasoning, tool-call, and intermediate assistant chat items can be stored as normal conversation messages with `Visibility = UI_ONLY`.
That gives Sliver:
@@ -91,7 +92,7 @@ That gives Sliver:
- transcript restoration after reconnect or TUI restart
- ordered replay of an entire turn, including intermediate items
At the same time, the model context builder filters out any non-context item, so the provider only receives actual user/assistant chat context.
At the same time, the model context builder keys off `IncludeInContext`, so UI visibility and provider visibility are now independent.
### Event Model
@@ -127,9 +128,10 @@ For each new user message:
4. If the runtime supports the Responses API loop, Sliver runs the agentic loop.
5. Reasoning items are persisted as completed UI-only reasoning blocks.
6. Tool calls are persisted as in-progress UI-only tool blocks, executed locally, then updated in place to completed or failed.
7. Tool outputs are fed back into the Responses API with `previous_response_id`.
8. When the model returns final assistant text, that assistant message is persisted as a normal context-visible chat item.
9. The conversation turn state is cleared with `TURN_COMPLETED`.
7. Intermediate assistant chat blocks on tool-use turns are persisted as UI-only chat items with `IncludeInContext = false`.
8. Tool outputs are fed back into the Responses API with `previous_response_id`.
9. When the model returns final assistant text, that assistant message is persisted as a normal context-visible chat item with `IncludeInContext = true`.
10. The conversation turn state is cleared with `TURN_COMPLETED`.
If anything fails:
@@ -157,10 +159,10 @@ Beacon-backed tools wait for task completion on the server before emitting the c
## Context Filtering
Only messages with:
Only chat messages with:
- `Kind = CHAT`
- `Visibility = CONTEXT`
- `IncludeInContext = true`
are included in future provider requests.
@@ -168,6 +170,7 @@ That means these items are excluded from the model context window:
- reasoning blocks
- tool call blocks
- UI-only intermediate assistant chat blocks
- failed system status blocks
This is the main rule that preserves transcript richness without degrading prompt quality.
@@ -1,5 +1,18 @@
Sliver allows an operator to extend the local client console and its features by adding new commands based on third party tools. The easiest way to install an alias or extension is using the [armory](/docs?name=Armory).
## Server AI Access
Aliases and extensions used from the client console still live under `~/.sliver-client/aliases` and `~/.sliver-client/extensions`.
The server-side AI agent uses a separate store under `~/.sliver/ai/aliases` and `~/.sliver/ai/extensions`. To make a package available to the AI agent, copy the unpacked package directory into the matching server-side path so the manifest and payload artifacts are present together. For example:
- `~/.sliver/ai/aliases/Rubeus/alias.json`
- `~/.sliver/ai/aliases/Rubeus/Rubeus.exe`
- `~/.sliver/ai/extensions/nanodump/extension.json`
- `~/.sliver/ai/extensions/nanodump/nanodump.x64.o`
There is currently no dedicated UI for this workflow. The AI agent can search and execute only the packages that have been copied into these server-side directories.
#### Aliases Command Parsing
**⚠️ IMPORTANT:** It's important to understand that all alias commands have certain Sliver shell flags (those that appear in `--help`). Your Sliver shell commands are lexically parsed by the Sliver shell first, and only unnamed positional arguments are passed to the alias code. This means you may need to escape certain arguments in order for them to be correctly parsed.
+4
View File
@@ -4,6 +4,8 @@ The armory downloads packages from `github.com` and `api.github.com` so you'll n
Aliases and extensions are installed on the "sliver client"-side, and thus are not shared among operators in [multiplayer mode](/docs?name=Multi-player+Mode).
The server-side AI agent uses a separate store under `~/.sliver/ai/aliases` and `~/.sliver/ai/extensions`. If you want the AI agent to discover and execute an alias or extension, copy the unpacked package directory (the manifest plus its artifacts) into the matching server-side path. There is currently no dedicated UI or install command for the AI store.
You can also use `armory install all` to install _everything_ if you really want to.
## The Official Armory
@@ -44,6 +46,8 @@ You remove packages installed from the `armory` using the `aliases rm` and `exte
Installed alias and extension files are stored in `~/.sliver-client/` by default if you want to manually remove a package simply delete its corresponding directory and restart the client.
If you also copied a package into the server-side AI store, remove that copy separately from `~/.sliver/ai/aliases` or `~/.sliver/ai/extensions`.
## Private Armories
Sliver has experimental support for self-hosted private armories, but I have not gotten around to testing and writing the documentation for these, so you'll have to read through the source code to figure out how they work for now. I'll eventually update this documentation and release a reference implementation. If you do play around with this, just know the design is subject to change before becoming a real feature.
@@ -6,7 +6,7 @@ BOF support is provided via the [COFF Loader](https://github.com/sliverarmory/CO
The easiest way to install a BOF extension, for example [`nanodump`](https://github.com/sliverarmory/nanodump), is using the [armory](/docs?name=Armory) package manager:
**IMPORTANT:** BOF Extensions are installed per-sliver client, they are not stored on the server. Thus extensions are not shared across operators, each operator must install the extension to use it.
**IMPORTANT:** BOF extensions are still installed per-sliver client for normal operator use, so they are not shared across operators automatically. If you want the server-side AI agent to use a BOF, copy the unpacked BOF directory into `~/.sliver/ai/extensions` on the server as well, and copy any dependency such as `coff-loader` into that same server-side AI store.
```
sliver > armory install nanodump
@@ -114,6 +114,8 @@ Looking at the script we can see the BOF requires a single integer argument. The
Once the manifest is defined load it into your client using `extensions load`, locally loaded extensions do not need to be cryptographically signed. The paths in the manifest should be relative to the manifest file, parent directories are not allowed.
If you want the server-side AI agent to discover and run the same BOF, copy that unpacked directory into `~/.sliver/ai/extensions/<name>/` so the `extension.json` file and `.o` artifact are available on the server.
More details can be found on the [Aliases & Extensions](/docs?name=Aliases+and+Extensions) page.
**IMPORTANT:** For BOF extensions to be properly detected by the Sliver client, the `path` must use the `.o` file extension.
+5 -2
View File
@@ -119,17 +119,20 @@ func pivotStopListenerHandler(envelope *pb.Envelope, connection *transports.Conn
}
func pivotPeerEnvelopeHandler(envelope *pb.Envelope, connection *transports.Connection) {
sent, err := pivots.SendToPeer(envelope)
nextPeerID, sent, err := pivots.SendToPeer(envelope)
if !sent {
// {{if .Config.Debug}}
log.Printf("Send to peer failed, report peer failure upstream ...")
// {{end}}
if nextPeerID == 0 {
return
}
errStr := ""
if err != nil {
errStr = err.Error()
}
data, _ := proto.Marshal(&pb.PivotPeerFailure{
PeerID: pivots.MyPeerID,
PeerID: nextPeerID,
Type: pb.PeerFailureType_SEND_FAILURE,
Err: errStr,
})
+5 -5
View File
@@ -145,14 +145,14 @@ func StopListener(id uint32) {
}
// SendToPeer - Forward an envelope to a peer
func SendToPeer(envelope *pb.Envelope) (bool, error) {
func SendToPeer(envelope *pb.Envelope) (int64, bool, error) {
pivotPeerEnvelope := &pb.PivotPeerEnvelope{}
err := proto.Unmarshal(envelope.Data, pivotPeerEnvelope)
if err != nil {
// {{if .Config.Debug}}
log.Printf("Failed to unmarshal peer envelope: %s", err)
// {{end}}
return false, err
return 0, false, err
}
// {{if .Config.Debug}}
@@ -164,7 +164,7 @@ func SendToPeer(envelope *pb.Envelope) (bool, error) {
// {{if .Config.Debug}}
log.Printf("Failed to find next peer id: %s", err)
// {{end}}
return false, err
return 0, false, err
}
sent := false // Controls iteration of outer loop
@@ -183,9 +183,9 @@ func SendToPeer(envelope *pb.Envelope) (bool, error) {
// {{if .Config.Debug}}
log.Printf("Failed to find peer with id %d", nextPeerID)
// {{end}}
return false, errors.New("peer not found")
return nextPeerID, false, errors.New("peer not found")
}
return sent, nil
return nextPeerID, sent, nil
}
func findNextPeerID(pivotPeerEnvelope *pb.PivotPeerEnvelope) (int64, error) {
@@ -0,0 +1,139 @@
package version
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
"strings"
)
type linuxVersionInfo struct {
Sysname string
Release string
Version string
Machine string
}
func readLinuxOSRelease() string {
paths := []string{"/etc/os-release", "/usr/lib/os-release"}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
if osRelease := buildLinuxOSRelease(parseLinuxOSRelease(string(data))); osRelease != "" {
return osRelease
}
}
return ""
}
func parseLinuxOSRelease(content string) map[string]string {
values := make(map[string]string)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok || key == "" {
continue
}
key = strings.TrimSpace(key)
value = unescapeLinuxOSReleaseValue(unquoteLinuxOSReleaseValue(strings.TrimSpace(value)))
values[key] = value
}
return values
}
func unquoteLinuxOSReleaseValue(s string) string {
if len(s) < 2 {
return s
}
if (s[0] == '"' || s[0] == '\'') && s[0] == s[len(s)-1] {
return s[1 : len(s)-1]
}
return s
}
func unescapeLinuxOSReleaseValue(s string) string {
return strings.NewReplacer(
`\$`, `$`,
`\"`, `"`,
`\'`, `'`,
`\\`, `\`,
"\\`", "`",
).Replace(s)
}
func buildLinuxOSRelease(values map[string]string) string {
name := values["NAME"]
version := values["VERSION"]
if version == "" {
version = values["VERSION_ID"]
}
if name != "" && version != "" {
return fmt.Sprintf("%s %s", name, version)
}
if values["PRETTY_NAME"] != "" {
return values["PRETTY_NAME"]
}
return name
}
func normalizeLinuxArch(arch string) string {
arch = strings.TrimSpace(arch)
if arch == "" {
return ""
}
switch strings.ToLower(arch) {
case "amd64":
return "x86_64"
case "i386", "i486", "i586", "i686", "x86":
return "x86"
default:
return arch
}
}
func formatLinuxDetailedVersion(osRelease string, versionInfo linuxVersionInfo) string {
parts := make([]string, 0, 3)
if osRelease != "" {
parts = append(parts, osRelease)
} else if versionInfo.Sysname != "" {
parts = append(parts, versionInfo.Sysname)
}
kernelParts := make([]string, 0, 2)
if versionInfo.Release != "" {
kernelParts = append(kernelParts, versionInfo.Release)
}
if versionInfo.Version != "" {
kernelParts = append(kernelParts, versionInfo.Version)
}
if len(kernelParts) > 0 {
parts = append(parts, fmt.Sprintf("kernel %s", strings.Join(kernelParts, " ")))
}
if arch := normalizeLinuxArch(versionInfo.Machine); arch != "" {
parts = append(parts, arch)
}
return strings.Join(parts, " ")
}
@@ -0,0 +1,100 @@
package version
import "testing"
func TestParseLinuxOSRelease(t *testing.T) {
content := "# comment\n" +
"NAME=\"Ubuntu\"\n" +
"VERSION=\"24.04.2 LTS (Noble Numbat)\"\n" +
"PRETTY_NAME=\"Ubuntu 24.04.2 LTS\"\n" +
"SPECIAL=\"quoted \\\"value\\\" \\$HOME \\`uname\\`\"\n"
values := parseLinuxOSRelease(content)
if got, want := values["NAME"], "Ubuntu"; got != want {
t.Fatalf("NAME = %q, want %q", got, want)
}
if got, want := values["VERSION"], "24.04.2 LTS (Noble Numbat)"; got != want {
t.Fatalf("VERSION = %q, want %q", got, want)
}
if got, want := values["PRETTY_NAME"], "Ubuntu 24.04.2 LTS"; got != want {
t.Fatalf("PRETTY_NAME = %q, want %q", got, want)
}
if got, want := values["SPECIAL"], "quoted \"value\" $HOME `uname`"; got != want {
t.Fatalf("SPECIAL = %q, want %q", got, want)
}
}
func TestBuildLinuxOSRelease(t *testing.T) {
testCases := []struct {
name string
values map[string]string
want string
}{
{
name: "prefers name and version",
values: map[string]string{
"NAME": "Ubuntu",
"VERSION": "24.04.2 LTS (Noble Numbat)",
"PRETTY_NAME": "Ubuntu 24.04.2 LTS",
},
want: "Ubuntu 24.04.2 LTS (Noble Numbat)",
},
{
name: "falls back to version id",
values: map[string]string{
"NAME": "Alpine Linux",
"VERSION_ID": "3.21.3",
},
want: "Alpine Linux 3.21.3",
},
{
name: "falls back to pretty name",
values: map[string]string{
"PRETTY_NAME": "Fedora Linux 41 (Workstation Edition)",
},
want: "Fedora Linux 41 (Workstation Edition)",
},
{
name: "falls back to name",
values: map[string]string{
"NAME": "Linux",
},
want: "Linux",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
if got := buildLinuxOSRelease(testCase.values); got != testCase.want {
t.Fatalf("buildLinuxOSRelease() = %q, want %q", got, testCase.want)
}
})
}
}
func TestFormatLinuxDetailedVersion(t *testing.T) {
version := formatLinuxDetailedVersion("Ubuntu 24.04.2 LTS (Noble Numbat)", linuxVersionInfo{
Release: "6.8.0-57-generic",
Version: "#59-Ubuntu SMP PREEMPT_DYNAMIC Fri Feb 14 18:29:04 UTC 2025",
Machine: "amd64",
})
want := "Ubuntu 24.04.2 LTS (Noble Numbat) kernel 6.8.0-57-generic #59-Ubuntu SMP PREEMPT_DYNAMIC Fri Feb 14 18:29:04 UTC 2025 x86_64"
if version != want {
t.Fatalf("formatLinuxDetailedVersion() = %q, want %q", version, want)
}
}
func TestFormatLinuxDetailedVersionFallback(t *testing.T) {
version := formatLinuxDetailedVersion("", linuxVersionInfo{
Sysname: "Linux",
Release: "6.12.0",
Machine: "i686",
})
want := "Linux kernel 6.12.0 x86"
if version != want {
t.Fatalf("formatLinuxDetailedVersion() = %q, want %q", version, want)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin && !(windows && (amd64 || 386)) && !(linux && (amd64 || 386))
//go:build !darwin && !linux && !(windows && (amd64 || 386))
package version
+18 -114
View File
@@ -1,4 +1,4 @@
//go:build amd64 || 386
//go:build linux
package version
@@ -21,132 +21,36 @@ package version
*/
import (
"fmt"
"bytes"
//{{if .Config.Debug}}
"log"
//{{end}}
"os"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
func getString(input [65]int8) string {
var buf [65]byte
for i, b := range input {
buf[i] = byte(b)
func linuxNullTerminatedString(input []byte) string {
if index := bytes.IndexByte(input, 0); index >= 0 {
input = input[:index]
}
ver := string(buf[:])
if i := strings.Index(ver, "\x00"); i != -1 {
ver = ver[:i]
}
return ver
}
func readOSRelease() string {
paths := []string{"/etc/os-release", "/usr/lib/os-release"}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
return buildOSRelease(parseOSRelease(string(data)))
}
return ""
}
func parseOSRelease(content string) map[string]string {
values := make(map[string]string)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok || key == "" {
continue
}
key = strings.TrimSpace(key)
value = unescape(unquote(strings.TrimSpace(value)))
values[key] = value
}
return values
}
func unquote(s string) string {
if len(s) < 2 {
return s
}
if (s[0] == '"' || s[0] == '\'') && s[0] == s[len(s)-1] {
return s[1 : len(s)-1]
}
return s
}
func unescape(s string) string {
return strings.NewReplacer(
`\$`, `$`,
`\"`, `"`,
`\'`, `'`,
`\\`, `\`,
"\\`", "`",
).Replace(s)
}
func buildOSRelease(values map[string]string) string {
name := values["NAME"]
version := values["VERSION"]
if version == "" {
version = values["VERSION_ID"]
}
if name != "" && version != "" {
return fmt.Sprintf("%s %s", name, version)
}
return values["PRETTY_NAME"]
}
func normalizeArch(arch string) string {
arch = strings.TrimSpace(arch)
if arch == "" {
return ""
}
switch strings.ToLower(arch) {
case "amd64":
return "x86_64"
case "i386", "i486", "i586", "i686", "x86":
return "x86"
default:
return arch
}
}
func formatDetailedVersion(osRelease, kernel, arch string) string {
parts := make([]string, 0, 3)
if osRelease != "" {
parts = append(parts, osRelease)
}
if kernel != "" {
parts = append(parts, fmt.Sprintf("kernel %s", kernel))
}
if arch != "" {
parts = append(parts, arch)
}
return strings.Join(parts, " ")
return string(input)
}
// GetVersion returns the os version information
func GetVersion() string {
osRelease := readOSRelease()
var uname syscall.Utsname
if err := syscall.Uname(&uname); err != nil {
osRelease := readLinuxOSRelease()
var uname unix.Utsname
if err := unix.Uname(&uname); err != nil {
//{{if .Config.Debug}}
log.Printf("error getting OS version: %v", err)
//{{end}}
return osRelease
}
if osRelease == "" {
osRelease = getString(uname.Sysname)
}
kernel := getString(uname.Release)
arch := normalizeArch(getString(uname.Machine))
return formatDetailedVersion(osRelease, kernel, arch)
return formatLinuxDetailedVersion(osRelease, linuxVersionInfo{
Sysname: linuxNullTerminatedString(uname.Sysname[:]),
Release: linuxNullTerminatedString(uname.Release[:]),
Version: linuxNullTerminatedString(uname.Version[:]),
Machine: linuxNullTerminatedString(uname.Machine[:]),
})
}
+273 -151
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.0
// protoc v7.34.1
// source: clientpb/client.proto
package clientpb
@@ -11659,6 +11659,7 @@ type AIConfigSummary struct {
ThinkingLevel string `protobuf:"bytes,3,opt,name=ThinkingLevel,proto3" json:"ThinkingLevel,omitempty"`
Valid bool `protobuf:"varint,4,opt,name=Valid,proto3" json:"Valid,omitempty"`
Error string `protobuf:"bytes,5,opt,name=Error,proto3" json:"Error,omitempty"`
SystemPrompt string `protobuf:"bytes,6,opt,name=SystemPrompt,proto3" json:"SystemPrompt,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -11728,6 +11729,13 @@ func (x *AIConfigSummary) GetError() string {
return ""
}
func (x *AIConfigSummary) GetSystemPrompt() string {
if x != nil {
return x.SystemPrompt
}
return ""
}
type AIConversationReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
@@ -11824,29 +11832,107 @@ func (x *AIConversations) GetConversations() []*AIConversation {
return nil
}
type AIContextWindowUsage struct {
state protoimpl.MessageState `protogen:"open.v1"`
InputTokens int64 `protobuf:"varint,1,opt,name=InputTokens,proto3" json:"InputTokens,omitempty"`
OutputTokens int64 `protobuf:"varint,2,opt,name=OutputTokens,proto3" json:"OutputTokens,omitempty"`
TotalTokens int64 `protobuf:"varint,3,opt,name=TotalTokens,proto3" json:"TotalTokens,omitempty"`
ContextWindowTokens int64 `protobuf:"varint,4,opt,name=ContextWindowTokens,proto3" json:"ContextWindowTokens,omitempty"`
ContextWindowTokensEstimated bool `protobuf:"varint,5,opt,name=ContextWindowTokensEstimated,proto3" json:"ContextWindowTokensEstimated,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AIContextWindowUsage) Reset() {
*x = AIContextWindowUsage{}
mi := &file_clientpb_client_proto_msgTypes[131]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AIContextWindowUsage) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AIContextWindowUsage) ProtoMessage() {}
func (x *AIContextWindowUsage) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[131]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AIContextWindowUsage.ProtoReflect.Descriptor instead.
func (*AIContextWindowUsage) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{131}
}
func (x *AIContextWindowUsage) GetInputTokens() int64 {
if x != nil {
return x.InputTokens
}
return 0
}
func (x *AIContextWindowUsage) GetOutputTokens() int64 {
if x != nil {
return x.OutputTokens
}
return 0
}
func (x *AIContextWindowUsage) GetTotalTokens() int64 {
if x != nil {
return x.TotalTokens
}
return 0
}
func (x *AIContextWindowUsage) GetContextWindowTokens() int64 {
if x != nil {
return x.ContextWindowTokens
}
return 0
}
func (x *AIContextWindowUsage) GetContextWindowTokensEstimated() bool {
if x != nil {
return x.ContextWindowTokensEstimated
}
return false
}
type AIConversation struct {
state protoimpl.MessageState `protogen:"open.v1"`
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
CreatedAt int64 `protobuf:"varint,2,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"`
UpdatedAt int64 `protobuf:"varint,3,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"`
OperatorName string `protobuf:"bytes,4,opt,name=OperatorName,proto3" json:"OperatorName,omitempty"`
Provider string `protobuf:"bytes,5,opt,name=Provider,proto3" json:"Provider,omitempty"`
Model string `protobuf:"bytes,6,opt,name=Model,proto3" json:"Model,omitempty"`
Title string `protobuf:"bytes,7,opt,name=Title,proto3" json:"Title,omitempty"`
Summary string `protobuf:"bytes,8,opt,name=Summary,proto3" json:"Summary,omitempty"`
SystemPrompt string `protobuf:"bytes,9,opt,name=SystemPrompt,proto3" json:"SystemPrompt,omitempty"`
Messages []*AIConversationMessage `protobuf:"bytes,10,rep,name=Messages,proto3" json:"Messages,omitempty"`
ActiveTurnID string `protobuf:"bytes,11,opt,name=ActiveTurnID,proto3" json:"ActiveTurnID,omitempty"`
TurnState AIConversationTurnState `protobuf:"varint,12,opt,name=TurnState,proto3,enum=clientpb.AIConversationTurnState" json:"TurnState,omitempty"`
TargetSessionID string `protobuf:"bytes,13,opt,name=TargetSessionID,proto3" json:"TargetSessionID,omitempty"`
TargetBeaconID string `protobuf:"bytes,14,opt,name=TargetBeaconID,proto3" json:"TargetBeaconID,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
CreatedAt int64 `protobuf:"varint,2,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"`
UpdatedAt int64 `protobuf:"varint,3,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"`
OperatorName string `protobuf:"bytes,4,opt,name=OperatorName,proto3" json:"OperatorName,omitempty"`
Provider string `protobuf:"bytes,5,opt,name=Provider,proto3" json:"Provider,omitempty"`
Model string `protobuf:"bytes,6,opt,name=Model,proto3" json:"Model,omitempty"`
Title string `protobuf:"bytes,7,opt,name=Title,proto3" json:"Title,omitempty"`
Summary string `protobuf:"bytes,8,opt,name=Summary,proto3" json:"Summary,omitempty"`
SystemPrompt string `protobuf:"bytes,9,opt,name=SystemPrompt,proto3" json:"SystemPrompt,omitempty"`
Messages []*AIConversationMessage `protobuf:"bytes,10,rep,name=Messages,proto3" json:"Messages,omitempty"`
ActiveTurnID string `protobuf:"bytes,11,opt,name=ActiveTurnID,proto3" json:"ActiveTurnID,omitempty"`
TurnState AIConversationTurnState `protobuf:"varint,12,opt,name=TurnState,proto3,enum=clientpb.AIConversationTurnState" json:"TurnState,omitempty"`
TargetSessionID string `protobuf:"bytes,13,opt,name=TargetSessionID,proto3" json:"TargetSessionID,omitempty"`
TargetBeaconID string `protobuf:"bytes,14,opt,name=TargetBeaconID,proto3" json:"TargetBeaconID,omitempty"`
ThinkingLevel string `protobuf:"bytes,15,opt,name=ThinkingLevel,proto3" json:"ThinkingLevel,omitempty"`
ContextWindowUsage *AIContextWindowUsage `protobuf:"bytes,16,opt,name=ContextWindowUsage,proto3" json:"ContextWindowUsage,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AIConversation) Reset() {
*x = AIConversation{}
mi := &file_clientpb_client_proto_msgTypes[131]
mi := &file_clientpb_client_proto_msgTypes[132]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -11858,7 +11944,7 @@ func (x *AIConversation) String() string {
func (*AIConversation) ProtoMessage() {}
func (x *AIConversation) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[131]
mi := &file_clientpb_client_proto_msgTypes[132]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -11871,7 +11957,7 @@ func (x *AIConversation) ProtoReflect() protoreflect.Message {
// Deprecated: Use AIConversation.ProtoReflect.Descriptor instead.
func (*AIConversation) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{131}
return file_clientpb_client_proto_rawDescGZIP(), []int{132}
}
func (x *AIConversation) GetID() string {
@@ -11972,6 +12058,20 @@ func (x *AIConversation) GetTargetBeaconID() string {
return ""
}
func (x *AIConversation) GetThinkingLevel() string {
if x != nil {
return x.ThinkingLevel
}
return ""
}
func (x *AIConversation) GetContextWindowUsage() *AIContextWindowUsage {
if x != nil {
return x.ContextWindowUsage
}
return nil
}
type AIConversationMessages struct {
state protoimpl.MessageState `protogen:"open.v1"`
ConversationID string `protobuf:"bytes,1,opt,name=ConversationID,proto3" json:"ConversationID,omitempty"`
@@ -11982,7 +12082,7 @@ type AIConversationMessages struct {
func (x *AIConversationMessages) Reset() {
*x = AIConversationMessages{}
mi := &file_clientpb_client_proto_msgTypes[132]
mi := &file_clientpb_client_proto_msgTypes[133]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -11994,7 +12094,7 @@ func (x *AIConversationMessages) String() string {
func (*AIConversationMessages) ProtoMessage() {}
func (x *AIConversationMessages) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[132]
mi := &file_clientpb_client_proto_msgTypes[133]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12007,7 +12107,7 @@ func (x *AIConversationMessages) ProtoReflect() protoreflect.Message {
// Deprecated: Use AIConversationMessages.ProtoReflect.Descriptor instead.
func (*AIConversationMessages) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{132}
return file_clientpb_client_proto_rawDescGZIP(), []int{133}
}
func (x *AIConversationMessages) GetConversationID() string {
@@ -12048,13 +12148,14 @@ type AIConversationMessage struct {
ToolArguments string `protobuf:"bytes,20,opt,name=ToolArguments,proto3" json:"ToolArguments,omitempty"`
ToolResult string `protobuf:"bytes,21,opt,name=ToolResult,proto3" json:"ToolResult,omitempty"`
ErrorText string `protobuf:"bytes,22,opt,name=ErrorText,proto3" json:"ErrorText,omitempty"`
IncludeInContext *bool `protobuf:"varint,23,opt,name=IncludeInContext,proto3,oneof" json:"IncludeInContext,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AIConversationMessage) Reset() {
*x = AIConversationMessage{}
mi := &file_clientpb_client_proto_msgTypes[133]
mi := &file_clientpb_client_proto_msgTypes[134]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12066,7 +12167,7 @@ func (x *AIConversationMessage) String() string {
func (*AIConversationMessage) ProtoMessage() {}
func (x *AIConversationMessage) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[133]
mi := &file_clientpb_client_proto_msgTypes[134]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12079,7 +12180,7 @@ func (x *AIConversationMessage) ProtoReflect() protoreflect.Message {
// Deprecated: Use AIConversationMessage.ProtoReflect.Descriptor instead.
func (*AIConversationMessage) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{133}
return file_clientpb_client_proto_rawDescGZIP(), []int{134}
}
func (x *AIConversationMessage) GetID() string {
@@ -12236,6 +12337,13 @@ func (x *AIConversationMessage) GetErrorText() string {
return ""
}
func (x *AIConversationMessage) GetIncludeInContext() bool {
if x != nil && x.IncludeInContext != nil {
return *x.IncludeInContext
}
return false
}
type AIConversationEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
EventType AIConversationEventType `protobuf:"varint,1,opt,name=EventType,proto3,enum=clientpb.AIConversationEventType" json:"EventType,omitempty"`
@@ -12250,7 +12358,7 @@ type AIConversationEvent struct {
func (x *AIConversationEvent) Reset() {
*x = AIConversationEvent{}
mi := &file_clientpb_client_proto_msgTypes[134]
mi := &file_clientpb_client_proto_msgTypes[135]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12262,7 +12370,7 @@ func (x *AIConversationEvent) String() string {
func (*AIConversationEvent) ProtoMessage() {}
func (x *AIConversationEvent) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[134]
mi := &file_clientpb_client_proto_msgTypes[135]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12275,7 +12383,7 @@ func (x *AIConversationEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use AIConversationEvent.ProtoReflect.Descriptor instead.
func (*AIConversationEvent) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{134}
return file_clientpb_client_proto_rawDescGZIP(), []int{135}
}
func (x *AIConversationEvent) GetEventType() AIConversationEventType {
@@ -12330,7 +12438,7 @@ type MonitoringProviders struct {
func (x *MonitoringProviders) Reset() {
*x = MonitoringProviders{}
mi := &file_clientpb_client_proto_msgTypes[135]
mi := &file_clientpb_client_proto_msgTypes[136]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12342,7 +12450,7 @@ func (x *MonitoringProviders) String() string {
func (*MonitoringProviders) ProtoMessage() {}
func (x *MonitoringProviders) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[135]
mi := &file_clientpb_client_proto_msgTypes[136]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12355,7 +12463,7 @@ func (x *MonitoringProviders) ProtoReflect() protoreflect.Message {
// Deprecated: Use MonitoringProviders.ProtoReflect.Descriptor instead.
func (*MonitoringProviders) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{135}
return file_clientpb_client_proto_rawDescGZIP(), []int{136}
}
func (x *MonitoringProviders) GetProviders() []*MonitoringProvider {
@@ -12377,7 +12485,7 @@ type MonitoringProvider struct {
func (x *MonitoringProvider) Reset() {
*x = MonitoringProvider{}
mi := &file_clientpb_client_proto_msgTypes[136]
mi := &file_clientpb_client_proto_msgTypes[137]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12389,7 +12497,7 @@ func (x *MonitoringProvider) String() string {
func (*MonitoringProvider) ProtoMessage() {}
func (x *MonitoringProvider) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[136]
mi := &file_clientpb_client_proto_msgTypes[137]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12402,7 +12510,7 @@ func (x *MonitoringProvider) ProtoReflect() protoreflect.Message {
// Deprecated: Use MonitoringProvider.ProtoReflect.Descriptor instead.
func (*MonitoringProvider) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{136}
return file_clientpb_client_proto_rawDescGZIP(), []int{137}
}
func (x *MonitoringProvider) GetID() string {
@@ -12446,7 +12554,7 @@ type ResourceID struct {
func (x *ResourceID) Reset() {
*x = ResourceID{}
mi := &file_clientpb_client_proto_msgTypes[137]
mi := &file_clientpb_client_proto_msgTypes[138]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12458,7 +12566,7 @@ func (x *ResourceID) String() string {
func (*ResourceID) ProtoMessage() {}
func (x *ResourceID) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[137]
mi := &file_clientpb_client_proto_msgTypes[138]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12471,7 +12579,7 @@ func (x *ResourceID) ProtoReflect() protoreflect.Message {
// Deprecated: Use ResourceID.ProtoReflect.Descriptor instead.
func (*ResourceID) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{137}
return file_clientpb_client_proto_rawDescGZIP(), []int{138}
}
func (x *ResourceID) GetID() string {
@@ -12512,7 +12620,7 @@ type CertificatesReq struct {
func (x *CertificatesReq) Reset() {
*x = CertificatesReq{}
mi := &file_clientpb_client_proto_msgTypes[138]
mi := &file_clientpb_client_proto_msgTypes[139]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12524,7 +12632,7 @@ func (x *CertificatesReq) String() string {
func (*CertificatesReq) ProtoMessage() {}
func (x *CertificatesReq) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[138]
mi := &file_clientpb_client_proto_msgTypes[139]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12537,7 +12645,7 @@ func (x *CertificatesReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertificatesReq.ProtoReflect.Descriptor instead.
func (*CertificatesReq) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{138}
return file_clientpb_client_proto_rawDescGZIP(), []int{139}
}
func (x *CertificatesReq) GetCategoryFilters() uint32 {
@@ -12569,7 +12677,7 @@ type CertificateData struct {
func (x *CertificateData) Reset() {
*x = CertificateData{}
mi := &file_clientpb_client_proto_msgTypes[139]
mi := &file_clientpb_client_proto_msgTypes[140]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12581,7 +12689,7 @@ func (x *CertificateData) String() string {
func (*CertificateData) ProtoMessage() {}
func (x *CertificateData) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[139]
mi := &file_clientpb_client_proto_msgTypes[140]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12594,7 +12702,7 @@ func (x *CertificateData) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertificateData.ProtoReflect.Descriptor instead.
func (*CertificateData) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{139}
return file_clientpb_client_proto_rawDescGZIP(), []int{140}
}
func (x *CertificateData) GetCN() string {
@@ -12655,7 +12763,7 @@ type CertificateInfo struct {
func (x *CertificateInfo) Reset() {
*x = CertificateInfo{}
mi := &file_clientpb_client_proto_msgTypes[140]
mi := &file_clientpb_client_proto_msgTypes[141]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12667,7 +12775,7 @@ func (x *CertificateInfo) String() string {
func (*CertificateInfo) ProtoMessage() {}
func (x *CertificateInfo) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[140]
mi := &file_clientpb_client_proto_msgTypes[141]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12680,7 +12788,7 @@ func (x *CertificateInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertificateInfo.ProtoReflect.Descriptor instead.
func (*CertificateInfo) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{140}
return file_clientpb_client_proto_rawDescGZIP(), []int{141}
}
func (x *CertificateInfo) GetInfo() []*CertificateData {
@@ -12705,7 +12813,7 @@ type CertificateAuthorityData struct {
func (x *CertificateAuthorityData) Reset() {
*x = CertificateAuthorityData{}
mi := &file_clientpb_client_proto_msgTypes[141]
mi := &file_clientpb_client_proto_msgTypes[142]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12717,7 +12825,7 @@ func (x *CertificateAuthorityData) String() string {
func (*CertificateAuthorityData) ProtoMessage() {}
func (x *CertificateAuthorityData) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[141]
mi := &file_clientpb_client_proto_msgTypes[142]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12730,7 +12838,7 @@ func (x *CertificateAuthorityData) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertificateAuthorityData.ProtoReflect.Descriptor instead.
func (*CertificateAuthorityData) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{141}
return file_clientpb_client_proto_rawDescGZIP(), []int{142}
}
func (x *CertificateAuthorityData) GetCN() string {
@@ -12791,7 +12899,7 @@ type CertificateAuthorityInfo struct {
func (x *CertificateAuthorityInfo) Reset() {
*x = CertificateAuthorityInfo{}
mi := &file_clientpb_client_proto_msgTypes[142]
mi := &file_clientpb_client_proto_msgTypes[143]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -12803,7 +12911,7 @@ func (x *CertificateAuthorityInfo) String() string {
func (*CertificateAuthorityInfo) ProtoMessage() {}
func (x *CertificateAuthorityInfo) ProtoReflect() protoreflect.Message {
mi := &file_clientpb_client_proto_msgTypes[142]
mi := &file_clientpb_client_proto_msgTypes[143]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -12816,7 +12924,7 @@ func (x *CertificateAuthorityInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertificateAuthorityInfo.ProtoReflect.Descriptor instead.
func (*CertificateAuthorityInfo) Descriptor() ([]byte, []int) {
return file_clientpb_client_proto_rawDescGZIP(), []int{142}
return file_clientpb_client_proto_rawDescGZIP(), []int{143}
}
func (x *CertificateAuthorityInfo) GetInfo() []*CertificateAuthorityData {
@@ -13801,18 +13909,25 @@ const file_clientpb_client_proto_rawDesc = "" +
"\x04Name\x18\x01 \x01(\tR\x04Name\x12\x1e\n" +
"\n" +
"Configured\x18\x02 \x01(\bR\n" +
"Configured\"\x95\x01\n" +
"Configured\"\xb9\x01\n" +
"\x0fAIConfigSummary\x12\x1a\n" +
"\bProvider\x18\x01 \x01(\tR\bProvider\x12\x14\n" +
"\x05Model\x18\x02 \x01(\tR\x05Model\x12$\n" +
"\rThinkingLevel\x18\x03 \x01(\tR\rThinkingLevel\x12\x14\n" +
"\x05Valid\x18\x04 \x01(\bR\x05Valid\x12\x14\n" +
"\x05Error\x18\x05 \x01(\tR\x05Error\"M\n" +
"\x05Error\x18\x05 \x01(\tR\x05Error\x12\"\n" +
"\fSystemPrompt\x18\x06 \x01(\tR\fSystemPrompt\"M\n" +
"\x11AIConversationReq\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\tR\x02ID\x12(\n" +
"\x0fIncludeMessages\x18\x02 \x01(\bR\x0fIncludeMessages\"Q\n" +
"\x0fAIConversations\x12>\n" +
"\rConversations\x18\x01 \x03(\v2\x18.clientpb.AIConversationR\rConversations\"\xfa\x03\n" +
"\rConversations\x18\x01 \x03(\v2\x18.clientpb.AIConversationR\rConversations\"\xf4\x01\n" +
"\x14AIContextWindowUsage\x12 \n" +
"\vInputTokens\x18\x01 \x01(\x03R\vInputTokens\x12\"\n" +
"\fOutputTokens\x18\x02 \x01(\x03R\fOutputTokens\x12 \n" +
"\vTotalTokens\x18\x03 \x01(\x03R\vTotalTokens\x120\n" +
"\x13ContextWindowTokens\x18\x04 \x01(\x03R\x13ContextWindowTokens\x12B\n" +
"\x1cContextWindowTokensEstimated\x18\x05 \x01(\bR\x1cContextWindowTokensEstimated\"\xf0\x04\n" +
"\x0eAIConversation\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1c\n" +
"\tCreatedAt\x18\x02 \x01(\x03R\tCreatedAt\x12\x1c\n" +
@@ -13828,10 +13943,12 @@ const file_clientpb_client_proto_rawDesc = "" +
"\fActiveTurnID\x18\v \x01(\tR\fActiveTurnID\x12?\n" +
"\tTurnState\x18\f \x01(\x0e2!.clientpb.AIConversationTurnStateR\tTurnState\x12(\n" +
"\x0fTargetSessionID\x18\r \x01(\tR\x0fTargetSessionID\x12&\n" +
"\x0eTargetBeaconID\x18\x0e \x01(\tR\x0eTargetBeaconID\"}\n" +
"\x0eTargetBeaconID\x18\x0e \x01(\tR\x0eTargetBeaconID\x12$\n" +
"\rThinkingLevel\x18\x0f \x01(\tR\rThinkingLevel\x12N\n" +
"\x12ContextWindowUsage\x18\x10 \x01(\v2\x1e.clientpb.AIContextWindowUsageR\x12ContextWindowUsage\"}\n" +
"\x16AIConversationMessages\x12&\n" +
"\x0eConversationID\x18\x01 \x01(\tR\x0eConversationID\x12;\n" +
"\bMessages\x18\x02 \x03(\v2\x1f.clientpb.AIConversationMessageR\bMessages\"\x8d\x06\n" +
"\bMessages\x18\x02 \x03(\v2\x1f.clientpb.AIConversationMessageR\bMessages\"\xd3\x06\n" +
"\x15AIConversationMessage\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\tR\x02ID\x12&\n" +
"\x0eConversationID\x18\x02 \x01(\tR\x0eConversationID\x12\x1c\n" +
@@ -13861,7 +13978,9 @@ const file_clientpb_client_proto_rawDesc = "" +
"\n" +
"ToolResult\x18\x15 \x01(\tR\n" +
"ToolResult\x12\x1c\n" +
"\tErrorText\x18\x16 \x01(\tR\tErrorText\"\xad\x02\n" +
"\tErrorText\x18\x16 \x01(\tR\tErrorText\x12/\n" +
"\x10IncludeInContext\x18\x17 \x01(\bH\x00R\x10IncludeInContext\x88\x01\x01B\x13\n" +
"\x11_IncludeInContext\"\xad\x02\n" +
"\x13AIConversationEvent\x12?\n" +
"\tEventType\x18\x01 \x01(\x0e2!.clientpb.AIConversationEventTypeR\tEventType\x12&\n" +
"\x0eConversationID\x18\x02 \x01(\tR\x0eConversationID\x12\x16\n" +
@@ -14154,7 +14273,7 @@ func file_clientpb_client_proto_rawDescGZIP() []byte {
}
var file_clientpb_client_proto_enumTypes = make([]protoimpl.EnumInfo, 18)
var file_clientpb_client_proto_msgTypes = make([]protoimpl.MessageInfo, 157)
var file_clientpb_client_proto_msgTypes = make([]protoimpl.MessageInfo, 158)
var file_clientpb_client_proto_goTypes = []any{
(OutputFormat)(0), // 0: clientpb.OutputFormat
(StageProtocol)(0), // 1: clientpb.StageProtocol
@@ -14305,35 +14424,36 @@ var file_clientpb_client_proto_goTypes = []any{
(*AIConfigSummary)(nil), // 146: clientpb.AIConfigSummary
(*AIConversationReq)(nil), // 147: clientpb.AIConversationReq
(*AIConversations)(nil), // 148: clientpb.AIConversations
(*AIConversation)(nil), // 149: clientpb.AIConversation
(*AIConversationMessages)(nil), // 150: clientpb.AIConversationMessages
(*AIConversationMessage)(nil), // 151: clientpb.AIConversationMessage
(*AIConversationEvent)(nil), // 152: clientpb.AIConversationEvent
(*MonitoringProviders)(nil), // 153: clientpb.MonitoringProviders
(*MonitoringProvider)(nil), // 154: clientpb.MonitoringProvider
(*ResourceID)(nil), // 155: clientpb.ResourceID
(*CertificatesReq)(nil), // 156: clientpb.CertificatesReq
(*CertificateData)(nil), // 157: clientpb.CertificateData
(*CertificateInfo)(nil), // 158: clientpb.CertificateInfo
(*CertificateAuthorityData)(nil), // 159: clientpb.CertificateAuthorityData
(*CertificateAuthorityInfo)(nil), // 160: clientpb.CertificateAuthorityInfo
nil, // 161: clientpb.TrafficEncoderMap.EncodersEntry
nil, // 162: clientpb.ExternalImplantConfig.EncodersEntry
nil, // 163: clientpb.ImplantBuilds.ConfigsEntry
nil, // 164: clientpb.ImplantBuilds.ResourceIDsEntry
nil, // 165: clientpb.ImplantBuilds.StagedEntry
nil, // 166: clientpb.WebsiteAddContent.ContentsEntry
nil, // 167: clientpb.Website.ContentsEntry
nil, // 168: clientpb.Host.ExtensionDataEntry
nil, // 169: clientpb.ShellcodeEncoderArchMap.EncodersEntry
nil, // 170: clientpb.ShellcodeEncoderArchMap.DescriptionsEntry
nil, // 171: clientpb.ShellcodeEncoderMap.EncodersEntry
nil, // 172: clientpb.CrackSyncStatus.ProgressEntry
nil, // 173: clientpb.CrackBenchmark.BenchmarksEntry
nil, // 174: clientpb.Crackstation.BenchmarksEntry
(*commonpb.File)(nil), // 175: commonpb.File
(*commonpb.Request)(nil), // 176: commonpb.Request
(*commonpb.Response)(nil), // 177: commonpb.Response
(*AIContextWindowUsage)(nil), // 149: clientpb.AIContextWindowUsage
(*AIConversation)(nil), // 150: clientpb.AIConversation
(*AIConversationMessages)(nil), // 151: clientpb.AIConversationMessages
(*AIConversationMessage)(nil), // 152: clientpb.AIConversationMessage
(*AIConversationEvent)(nil), // 153: clientpb.AIConversationEvent
(*MonitoringProviders)(nil), // 154: clientpb.MonitoringProviders
(*MonitoringProvider)(nil), // 155: clientpb.MonitoringProvider
(*ResourceID)(nil), // 156: clientpb.ResourceID
(*CertificatesReq)(nil), // 157: clientpb.CertificatesReq
(*CertificateData)(nil), // 158: clientpb.CertificateData
(*CertificateInfo)(nil), // 159: clientpb.CertificateInfo
(*CertificateAuthorityData)(nil), // 160: clientpb.CertificateAuthorityData
(*CertificateAuthorityInfo)(nil), // 161: clientpb.CertificateAuthorityInfo
nil, // 162: clientpb.TrafficEncoderMap.EncodersEntry
nil, // 163: clientpb.ExternalImplantConfig.EncodersEntry
nil, // 164: clientpb.ImplantBuilds.ConfigsEntry
nil, // 165: clientpb.ImplantBuilds.ResourceIDsEntry
nil, // 166: clientpb.ImplantBuilds.StagedEntry
nil, // 167: clientpb.WebsiteAddContent.ContentsEntry
nil, // 168: clientpb.Website.ContentsEntry
nil, // 169: clientpb.Host.ExtensionDataEntry
nil, // 170: clientpb.ShellcodeEncoderArchMap.EncodersEntry
nil, // 171: clientpb.ShellcodeEncoderArchMap.DescriptionsEntry
nil, // 172: clientpb.ShellcodeEncoderMap.EncodersEntry
nil, // 173: clientpb.CrackSyncStatus.ProgressEntry
nil, // 174: clientpb.CrackBenchmark.BenchmarksEntry
nil, // 175: clientpb.Crackstation.BenchmarksEntry
(*commonpb.File)(nil), // 176: commonpb.File
(*commonpb.Request)(nil), // 177: commonpb.Request
(*commonpb.Response)(nil), // 178: commonpb.Response
}
var file_clientpb_client_proto_depIdxs = []int32{
21, // 0: clientpb.Beacons.Beacons:type_name -> clientpb.Beacon
@@ -14343,7 +14463,7 @@ var file_clientpb_client_proto_depIdxs = []int32{
0, // 4: clientpb.ImplantConfig.Format:type_name -> clientpb.OutputFormat
27, // 5: clientpb.ImplantConfig.ShellcodeConfig:type_name -> clientpb.ShellcodeConfig
3, // 6: clientpb.ImplantConfig.ShellcodeEncoder:type_name -> clientpb.ShellcodeEncoder
175, // 7: clientpb.ImplantConfig.Assets:type_name -> commonpb.File
176, // 7: clientpb.ImplantConfig.Assets:type_name -> commonpb.File
29, // 8: clientpb.PESpoofMetadataConfig.Source:type_name -> clientpb.SpoofMetadataFile
29, // 9: clientpb.PESpoofMetadataConfig.Icon:type_name -> clientpb.SpoofMetadataFile
30, // 10: clientpb.PESpoofMetadataConfig.ResourceDirectory:type_name -> clientpb.IMAGE_RESOURCE_DIRECTORY
@@ -14351,18 +14471,18 @@ var file_clientpb_client_proto_depIdxs = []int32{
32, // 12: clientpb.PESpoofMetadataConfig.ResourceDataEntries:type_name -> clientpb.IMAGE_RESOURCE_DATA_ENTRY
33, // 13: clientpb.PESpoofMetadataConfig.ExportDirectory:type_name -> clientpb.IMAGE_EXPORT_DIRECTORY
34, // 14: clientpb.SpoofMetadataConfig.PE:type_name -> clientpb.PESpoofMetadataConfig
175, // 15: clientpb.TrafficEncoder.Wasm:type_name -> commonpb.File
161, // 16: clientpb.TrafficEncoderMap.Encoders:type_name -> clientpb.TrafficEncoderMap.EncodersEntry
176, // 15: clientpb.TrafficEncoder.Wasm:type_name -> commonpb.File
162, // 16: clientpb.TrafficEncoderMap.Encoders:type_name -> clientpb.TrafficEncoderMap.EncodersEntry
36, // 17: clientpb.TrafficEncoderTests.Encoder:type_name -> clientpb.TrafficEncoder
38, // 18: clientpb.TrafficEncoderTests.Tests:type_name -> clientpb.TrafficEncoderTest
28, // 19: clientpb.ExternalImplantConfig.Config:type_name -> clientpb.ImplantConfig
44, // 20: clientpb.ExternalImplantConfig.Build:type_name -> clientpb.ImplantBuild
119, // 21: clientpb.ExternalImplantConfig.HTTPC2:type_name -> clientpb.HTTPC2Config
162, // 22: clientpb.ExternalImplantConfig.encoders:type_name -> clientpb.ExternalImplantConfig.EncodersEntry
175, // 23: clientpb.ExternalImplantBinary.File:type_name -> commonpb.File
163, // 24: clientpb.ImplantBuilds.Configs:type_name -> clientpb.ImplantBuilds.ConfigsEntry
164, // 25: clientpb.ImplantBuilds.ResourceIDs:type_name -> clientpb.ImplantBuilds.ResourceIDsEntry
165, // 26: clientpb.ImplantBuilds.staged:type_name -> clientpb.ImplantBuilds.StagedEntry
163, // 22: clientpb.ExternalImplantConfig.encoders:type_name -> clientpb.ExternalImplantConfig.EncodersEntry
176, // 23: clientpb.ExternalImplantBinary.File:type_name -> commonpb.File
164, // 24: clientpb.ImplantBuilds.Configs:type_name -> clientpb.ImplantBuilds.ConfigsEntry
165, // 25: clientpb.ImplantBuilds.ResourceIDs:type_name -> clientpb.ImplantBuilds.ResourceIDsEntry
166, // 26: clientpb.ImplantBuilds.staged:type_name -> clientpb.ImplantBuilds.StagedEntry
0, // 27: clientpb.CompilerTarget.Format:type_name -> clientpb.OutputFormat
45, // 28: clientpb.Compiler.Targets:type_name -> clientpb.CompilerTarget
46, // 29: clientpb.Compiler.CrossCompilers:type_name -> clientpb.CrossCompiler
@@ -14377,24 +14497,24 @@ var file_clientpb_client_proto_depIdxs = []int32{
65, // 38: clientpb.ListenerJob.HTTPConf:type_name -> clientpb.HTTPListenerReq
61, // 39: clientpb.ListenerJob.MultiConf:type_name -> clientpb.MultiplayerListenerReq
78, // 40: clientpb.ListenerJob.TCPConf:type_name -> clientpb.StagerListenerReq
176, // 41: clientpb.NamedPipesReq.Request:type_name -> commonpb.Request
177, // 42: clientpb.NamedPipes.Response:type_name -> commonpb.Response
176, // 43: clientpb.TCPPivotReq.Request:type_name -> commonpb.Request
177, // 44: clientpb.TCPPivot.Response:type_name -> commonpb.Response
177, // 41: clientpb.NamedPipesReq.Request:type_name -> commonpb.Request
178, // 42: clientpb.NamedPipes.Response:type_name -> commonpb.Response
177, // 43: clientpb.TCPPivotReq.Request:type_name -> commonpb.Request
178, // 44: clientpb.TCPPivot.Response:type_name -> commonpb.Response
20, // 45: clientpb.Sessions.Sessions:type_name -> clientpb.Session
28, // 46: clientpb.GenerateReq.Config:type_name -> clientpb.ImplantConfig
175, // 47: clientpb.Generate.File:type_name -> commonpb.File
176, // 47: clientpb.Generate.File:type_name -> commonpb.File
35, // 48: clientpb.GenerateSpoofMetadataReq.SpoofMetadata:type_name -> clientpb.SpoofMetadataConfig
176, // 49: clientpb.MSFReq.Request:type_name -> commonpb.Request
176, // 50: clientpb.MSFRemoteReq.Request:type_name -> commonpb.Request
177, // 49: clientpb.MSFReq.Request:type_name -> commonpb.Request
177, // 50: clientpb.MSFRemoteReq.Request:type_name -> commonpb.Request
1, // 51: clientpb.StagerListenerReq.Protocol:type_name -> clientpb.StageProtocol
28, // 52: clientpb.GetSystemReq.Config:type_name -> clientpb.ImplantConfig
176, // 53: clientpb.GetSystemReq.Request:type_name -> commonpb.Request
177, // 53: clientpb.GetSystemReq.Request:type_name -> commonpb.Request
28, // 54: clientpb.MigrateReq.Config:type_name -> clientpb.ImplantConfig
3, // 55: clientpb.MigrateReq.Encoder:type_name -> clientpb.ShellcodeEncoder
176, // 56: clientpb.MigrateReq.Request:type_name -> commonpb.Request
176, // 57: clientpb.CreateTunnelReq.Request:type_name -> commonpb.Request
176, // 58: clientpb.CloseTunnelReq.Request:type_name -> commonpb.Request
177, // 56: clientpb.MigrateReq.Request:type_name -> commonpb.Request
177, // 57: clientpb.CreateTunnelReq.Request:type_name -> commonpb.Request
177, // 58: clientpb.CloseTunnelReq.Request:type_name -> commonpb.Request
20, // 59: clientpb.PivotGraphEntry.Session:type_name -> clientpb.Session
87, // 60: clientpb.PivotGraphEntry.Children:type_name -> clientpb.PivotGraphEntry
87, // 61: clientpb.PivotGraph.Children:type_name -> clientpb.PivotGraphEntry
@@ -14403,25 +14523,25 @@ var file_clientpb_client_proto_depIdxs = []int32{
55, // 64: clientpb.Event.Job:type_name -> clientpb.Job
89, // 65: clientpb.Event.Client:type_name -> clientpb.Client
92, // 66: clientpb.Operators.Operators:type_name -> clientpb.Operator
166, // 67: clientpb.WebsiteAddContent.Contents:type_name -> clientpb.WebsiteAddContent.ContentsEntry
167, // 68: clientpb.Website.Contents:type_name -> clientpb.Website.ContentsEntry
167, // 67: clientpb.WebsiteAddContent.Contents:type_name -> clientpb.WebsiteAddContent.ContentsEntry
168, // 68: clientpb.Website.Contents:type_name -> clientpb.Website.ContentsEntry
96, // 69: clientpb.Websites.Websites:type_name -> clientpb.Website
2, // 70: clientpb.Loot.FileType:type_name -> clientpb.FileType
175, // 71: clientpb.Loot.File:type_name -> commonpb.File
176, // 71: clientpb.Loot.File:type_name -> commonpb.File
99, // 72: clientpb.AllLoot.Loot:type_name -> clientpb.Loot
101, // 73: clientpb.Host.IOCs:type_name -> clientpb.IOC
168, // 74: clientpb.Host.ExtensionData:type_name -> clientpb.Host.ExtensionDataEntry
169, // 74: clientpb.Host.ExtensionData:type_name -> clientpb.Host.ExtensionDataEntry
103, // 75: clientpb.AllHosts.Hosts:type_name -> clientpb.Host
176, // 76: clientpb.DllHijackReq.Request:type_name -> commonpb.Request
177, // 77: clientpb.DllHijack.Response:type_name -> commonpb.Response
176, // 78: clientpb.BackdoorReq.Request:type_name -> commonpb.Request
177, // 79: clientpb.Backdoor.Response:type_name -> commonpb.Response
177, // 76: clientpb.DllHijackReq.Request:type_name -> commonpb.Request
178, // 77: clientpb.DllHijack.Response:type_name -> commonpb.Response
177, // 78: clientpb.BackdoorReq.Request:type_name -> commonpb.Request
178, // 79: clientpb.Backdoor.Response:type_name -> commonpb.Response
3, // 80: clientpb.ShellcodeEncodeReq.Encoder:type_name -> clientpb.ShellcodeEncoder
176, // 81: clientpb.ShellcodeEncodeReq.Request:type_name -> commonpb.Request
177, // 82: clientpb.ShellcodeEncode.Response:type_name -> commonpb.Response
169, // 83: clientpb.ShellcodeEncoderArchMap.Encoders:type_name -> clientpb.ShellcodeEncoderArchMap.EncodersEntry
170, // 84: clientpb.ShellcodeEncoderArchMap.Descriptions:type_name -> clientpb.ShellcodeEncoderArchMap.DescriptionsEntry
171, // 85: clientpb.ShellcodeEncoderMap.Encoders:type_name -> clientpb.ShellcodeEncoderMap.EncodersEntry
177, // 81: clientpb.ShellcodeEncodeReq.Request:type_name -> commonpb.Request
178, // 82: clientpb.ShellcodeEncode.Response:type_name -> commonpb.Response
170, // 83: clientpb.ShellcodeEncoderArchMap.Encoders:type_name -> clientpb.ShellcodeEncoderArchMap.EncodersEntry
171, // 84: clientpb.ShellcodeEncoderArchMap.Descriptions:type_name -> clientpb.ShellcodeEncoderArchMap.DescriptionsEntry
172, // 85: clientpb.ShellcodeEncoderMap.Encoders:type_name -> clientpb.ShellcodeEncoderMap.EncodersEntry
28, // 86: clientpb.ExternalGenerateReq.Config:type_name -> clientpb.ImplantConfig
115, // 87: clientpb.Builders.Builders:type_name -> clientpb.Builder
45, // 88: clientpb.Builder.Targets:type_name -> clientpb.CompilerTarget
@@ -14440,10 +14560,10 @@ var file_clientpb_client_proto_depIdxs = []int32{
133, // 101: clientpb.Crackstations.Crackstations:type_name -> clientpb.Crackstation
6, // 102: clientpb.CrackstationStatus.State:type_name -> clientpb.States
130, // 103: clientpb.CrackstationStatus.Syncing:type_name -> clientpb.CrackSyncStatus
172, // 104: clientpb.CrackSyncStatus.Progress:type_name -> clientpb.CrackSyncStatus.ProgressEntry
173, // 105: clientpb.CrackBenchmark.Benchmarks:type_name -> clientpb.CrackBenchmark.BenchmarksEntry
173, // 104: clientpb.CrackSyncStatus.Progress:type_name -> clientpb.CrackSyncStatus.ProgressEntry
174, // 105: clientpb.CrackBenchmark.Benchmarks:type_name -> clientpb.CrackBenchmark.BenchmarksEntry
137, // 106: clientpb.CrackTask.Command:type_name -> clientpb.CrackCommand
174, // 107: clientpb.Crackstation.Benchmarks:type_name -> clientpb.Crackstation.BenchmarksEntry
175, // 107: clientpb.Crackstation.Benchmarks:type_name -> clientpb.Crackstation.BenchmarksEntry
134, // 108: clientpb.Crackstation.CUDA:type_name -> clientpb.CUDABackendInfo
136, // 109: clientpb.Crackstation.Metal:type_name -> clientpb.MetalBackendInfo
135, // 110: clientpb.Crackstation.OpenCL:type_name -> clientpb.OpenCLBackendInfo
@@ -14461,32 +14581,33 @@ var file_clientpb_client_proto_depIdxs = []int32{
143, // 122: clientpb.CrackFile.Chunks:type_name -> clientpb.CrackFileChunk
145, // 123: clientpb.AIProviderConfigs.Providers:type_name -> clientpb.AIProviderConfig
146, // 124: clientpb.AIProviderConfigs.Config:type_name -> clientpb.AIConfigSummary
149, // 125: clientpb.AIConversations.Conversations:type_name -> clientpb.AIConversation
151, // 126: clientpb.AIConversation.Messages:type_name -> clientpb.AIConversationMessage
150, // 125: clientpb.AIConversations.Conversations:type_name -> clientpb.AIConversation
152, // 126: clientpb.AIConversation.Messages:type_name -> clientpb.AIConversationMessage
13, // 127: clientpb.AIConversation.TurnState:type_name -> clientpb.AIConversationTurnState
151, // 128: clientpb.AIConversationMessages.Messages:type_name -> clientpb.AIConversationMessage
14, // 129: clientpb.AIConversationMessage.Kind:type_name -> clientpb.AIConversationMessageKind
15, // 130: clientpb.AIConversationMessage.Visibility:type_name -> clientpb.AIConversationMessageVisibility
16, // 131: clientpb.AIConversationMessage.State:type_name -> clientpb.AIConversationMessageState
17, // 132: clientpb.AIConversationEvent.EventType:type_name -> clientpb.AIConversationEventType
149, // 133: clientpb.AIConversationEvent.Conversation:type_name -> clientpb.AIConversation
151, // 134: clientpb.AIConversationEvent.Message:type_name -> clientpb.AIConversationMessage
154, // 135: clientpb.MonitoringProviders.providers:type_name -> clientpb.MonitoringProvider
157, // 136: clientpb.CertificateInfo.info:type_name -> clientpb.CertificateData
159, // 137: clientpb.CertificateAuthorityInfo.info:type_name -> clientpb.CertificateAuthorityData
36, // 138: clientpb.TrafficEncoderMap.EncodersEntry.value:type_name -> clientpb.TrafficEncoder
28, // 139: clientpb.ImplantBuilds.ConfigsEntry.value:type_name -> clientpb.ImplantConfig
155, // 140: clientpb.ImplantBuilds.ResourceIDsEntry.value:type_name -> clientpb.ResourceID
93, // 141: clientpb.WebsiteAddContent.ContentsEntry.value:type_name -> clientpb.WebContent
93, // 142: clientpb.Website.ContentsEntry.value:type_name -> clientpb.WebContent
102, // 143: clientpb.Host.ExtensionDataEntry.value:type_name -> clientpb.ExtensionData
3, // 144: clientpb.ShellcodeEncoderArchMap.EncodersEntry.value:type_name -> clientpb.ShellcodeEncoder
111, // 145: clientpb.ShellcodeEncoderMap.EncodersEntry.value:type_name -> clientpb.ShellcodeEncoderArchMap
146, // [146:146] is the sub-list for method output_type
146, // [146:146] is the sub-list for method input_type
146, // [146:146] is the sub-list for extension type_name
146, // [146:146] is the sub-list for extension extendee
0, // [0:146] is the sub-list for field type_name
149, // 128: clientpb.AIConversation.ContextWindowUsage:type_name -> clientpb.AIContextWindowUsage
152, // 129: clientpb.AIConversationMessages.Messages:type_name -> clientpb.AIConversationMessage
14, // 130: clientpb.AIConversationMessage.Kind:type_name -> clientpb.AIConversationMessageKind
15, // 131: clientpb.AIConversationMessage.Visibility:type_name -> clientpb.AIConversationMessageVisibility
16, // 132: clientpb.AIConversationMessage.State:type_name -> clientpb.AIConversationMessageState
17, // 133: clientpb.AIConversationEvent.EventType:type_name -> clientpb.AIConversationEventType
150, // 134: clientpb.AIConversationEvent.Conversation:type_name -> clientpb.AIConversation
152, // 135: clientpb.AIConversationEvent.Message:type_name -> clientpb.AIConversationMessage
155, // 136: clientpb.MonitoringProviders.providers:type_name -> clientpb.MonitoringProvider
158, // 137: clientpb.CertificateInfo.info:type_name -> clientpb.CertificateData
160, // 138: clientpb.CertificateAuthorityInfo.info:type_name -> clientpb.CertificateAuthorityData
36, // 139: clientpb.TrafficEncoderMap.EncodersEntry.value:type_name -> clientpb.TrafficEncoder
28, // 140: clientpb.ImplantBuilds.ConfigsEntry.value:type_name -> clientpb.ImplantConfig
156, // 141: clientpb.ImplantBuilds.ResourceIDsEntry.value:type_name -> clientpb.ResourceID
93, // 142: clientpb.WebsiteAddContent.ContentsEntry.value:type_name -> clientpb.WebContent
93, // 143: clientpb.Website.ContentsEntry.value:type_name -> clientpb.WebContent
102, // 144: clientpb.Host.ExtensionDataEntry.value:type_name -> clientpb.ExtensionData
3, // 145: clientpb.ShellcodeEncoderArchMap.EncodersEntry.value:type_name -> clientpb.ShellcodeEncoder
111, // 146: clientpb.ShellcodeEncoderMap.EncodersEntry.value:type_name -> clientpb.ShellcodeEncoderArchMap
147, // [147:147] is the sub-list for method output_type
147, // [147:147] is the sub-list for method input_type
147, // [147:147] is the sub-list for extension type_name
147, // [147:147] is the sub-list for extension extendee
0, // [0:147] is the sub-list for field type_name
}
func init() { file_clientpb_client_proto_init() }
@@ -14494,13 +14615,14 @@ func file_clientpb_client_proto_init() {
if File_clientpb_client_proto != nil {
return
}
file_clientpb_client_proto_msgTypes[134].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_clientpb_client_proto_rawDesc), len(file_clientpb_client_proto_rawDesc)),
NumEnums: 18,
NumMessages: 157,
NumMessages: 158,
NumExtensions: 0,
NumServices: 0,
},
+12
View File
@@ -1426,6 +1426,7 @@ message AIConfigSummary {
string ThinkingLevel = 3;
bool Valid = 4;
string Error = 5;
string SystemPrompt = 6;
}
message AIConversationReq {
@@ -1472,6 +1473,14 @@ enum AIConversationEventType {
AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED = 8;
}
message AIContextWindowUsage {
int64 InputTokens = 1;
int64 OutputTokens = 2;
int64 TotalTokens = 3;
int64 ContextWindowTokens = 4;
bool ContextWindowTokensEstimated = 5;
}
message AIConversation {
string ID = 1;
int64 CreatedAt = 2;
@@ -1487,6 +1496,8 @@ message AIConversation {
AIConversationTurnState TurnState = 12;
string TargetSessionID = 13;
string TargetBeaconID = 14;
string ThinkingLevel = 15;
AIContextWindowUsage ContextWindowUsage = 16;
}
message AIConversationMessages {
@@ -1517,6 +1528,7 @@ message AIConversationMessage {
string ToolArguments = 20;
string ToolResult = 21;
string ErrorText = 22;
optional bool IncludeInContext = 23;
}
message AIConversationEvent {
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.0
// protoc v7.34.1
// source: commonpb/common.proto
package commonpb
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.0
// protoc v7.34.1
// source: dnspb/dns.proto
package dnspb
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.0
// protoc v7.34.1
// source: rpcpb/services.proto
package rpcpb
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.0
// - protoc v7.34.1
// source: rpcpb/services.proto
package rpcpb
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.0
// protoc v7.34.1
// source: sliverpb/sliver.proto
package sliverpb
+47 -9
View File
@@ -13,7 +13,7 @@ import (
"github.com/openai/openai-go/v2/shared"
)
const maxAgenticLoopIterations = 16
const maxAgenticLoopIterations = 100
// AgenticToolDefinition describes a custom function tool exposed to the model.
type AgenticToolDefinition struct {
@@ -30,11 +30,22 @@ type AgenticToolExecutor interface {
// AgenticEventSink persists and publishes non-context-window UI items.
type AgenticEventSink interface {
ChatMessage(context.Context, AgenticChatMessage) error
ReasoningItem(context.Context, AgenticReasoningItem) error
ToolCallStarted(context.Context, AgenticToolCall) error
ToolCallCompleted(context.Context, AgenticToolCallResult) error
}
// AgenticChatMessage is a provider-emitted assistant or system chat block.
type AgenticChatMessage struct {
ItemID string
Role string
Content string
Status string
UIOnly bool
IncludeInContext bool
}
// AgenticReasoningItem is a completed reasoning item emitted by the provider.
type AgenticReasoningItem struct {
ItemID string
@@ -103,8 +114,9 @@ func CompleteConversationAgentic(
client := newOpenAIClient(openAIRequestOptions(runtime)...)
currentInput := buildResponseInput(systemPrompt, messages)
toolParams := buildAgenticToolParams(tools)
toolParams := appendOpenAIResponseTools(runtime, buildAgenticToolParams(tools))
previousResponseID := ""
var maxUsage *ContextWindowUsage
for step := 0; step < maxAgenticLoopIterations; step++ {
params := openairesponses.ResponseNewParams{
@@ -129,8 +141,8 @@ func CompleteConversationAgentic(
if runtime.TopP != nil {
params.TopP = openai.Float(*runtime.TopP)
}
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
params.Reasoning = shared.ReasoningParam{Effort: effort}
if reasoning, ok := responseReasoningParam(runtime); ok {
params.Reasoning = reasoning
}
response, err := client.responses.New(ctx, params)
@@ -138,9 +150,18 @@ func CompleteConversationAgentic(
return nil, formatOpenAIError(runtime.Provider, err)
}
maxUsage = mergeContextWindowUsage(maxUsage, resolveContextWindowUsage(
ctx,
runtime,
fallbackString(response.Model, runtime.Model),
response.Usage.InputTokens,
response.Usage.OutputTokens,
response.Usage.TotalTokens,
))
previousResponseID = strings.TrimSpace(response.ID)
nextInput := make(openairesponses.ResponseInputParam, 0)
finalTexts := make([]string, 0, len(response.Output))
chatItems := make([]AgenticChatMessage, 0)
for _, item := range response.Output {
switch output := item.AsAny().(type) {
@@ -186,12 +207,28 @@ func CompleteConversationAgentic(
case openairesponses.ResponseOutputMessage:
if text := strings.TrimSpace(responseOutputMessageText(output)); text != "" {
chatItems = append(chatItems, AgenticChatMessage{
ItemID: strings.TrimSpace(output.ID),
Role: strings.TrimSpace(string(output.Role)),
Content: text,
Status: strings.TrimSpace(string(output.Status)),
IncludeInContext: true,
})
finalTexts = append(finalTexts, text)
}
}
}
if len(nextInput) > 0 {
if sink != nil {
for _, chatItem := range chatItems {
chatItem.UIOnly = true
chatItem.IncludeInContext = false
if err := sink.ChatMessage(ctx, chatItem); err != nil {
return nil, err
}
}
}
currentInput = nextInput
continue
}
@@ -205,11 +242,12 @@ func CompleteConversationAgentic(
}
return &Completion{
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Content: finalText,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: responseFinishReason(response),
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Content: finalText,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: responseFinishReason(response),
ContextWindowUsage: maxUsage,
}, nil
}
+71
View File
@@ -2,7 +2,12 @@ package ai
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
type staticAgenticToolExecutor struct {
@@ -64,3 +69,69 @@ func TestBuildAgenticToolParamsNormalizesStrictOpenAISchemas(t *testing.T) {
}
}
}
func TestCompleteConversationAgenticRequestsReasoningSummaryForOpenAI(t *testing.T) {
type capturedRequest struct {
Body string
}
requests := make(chan capturedRequest, 1)
restoreClient := SetHTTPClientForTests(&http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
requests <- capturedRequest{Body: string(payload)}
return jsonResponse(http.StatusOK, `{
"id": "resp_123",
"model": "gpt-5.4",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_123",
"role": "assistant",
"content": [
{"type": "output_text", "text": "Done."}
]
}
]
}`), nil
}),
})
defer restoreClient()
completion, err := CompleteConversationAgentic(context.Background(), &RuntimeConfig{
Provider: ProviderOpenAI,
Model: "gpt-5.4",
UseResponsesAPI: true,
ThinkingLevel: "high",
APIKey: "openai-key",
}, &clientpb.AIConversation{
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Say hi."},
},
}, nil, nil)
if err != nil {
t.Fatalf("complete agentic conversation: %v", err)
}
request := <-requests
for _, fragment := range []string{
`"type":"web_search"`,
`"search_context_size":"medium"`,
`"effort":"high"`,
`"summary":"auto"`,
} {
if !strings.Contains(request.Body, fragment) {
t.Fatalf("expected agentic request body to contain %q, got %s", fragment, request.Body)
}
}
if completion.Content != "Done." {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
}
+2 -1
View File
@@ -130,12 +130,13 @@ func SafeConfigSummary() *clientpb.AIConfigSummary {
func SafeConfigSummaryFromConfig(cfg *configs.ServerConfig) *clientpb.AIConfigSummary {
summary := &clientpb.AIConfigSummary{}
if cfg != nil && cfg.AI != nil {
summary.Model = strings.TrimSpace(cfg.AI.Model)
summary.ThinkingLevel = strings.TrimSpace(cfg.AI.ThinkingLevel)
summary.SystemPrompt = strings.TrimSpace(cfg.AI.SystemPrompt)
}
provider, providerConfig := selectedProviderConfig(cfg)
summary.Provider = provider
summary.Model = configuredDefaultModel(provider, selectedAIConfig(cfg), providerConfig)
switch {
case cfg == nil || cfg.AI == nil:
+20 -2
View File
@@ -10,9 +10,9 @@ func TestSafeConfigSummaryFromConfigUsesExplicitConfiguredProvider(t *testing.T)
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAI,
Model: "gpt-test",
ThinkingLevel: "high",
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key"},
SystemPrompt: "Stay concise.",
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key", Models: []string{"gpt-test", "gpt-test-mini"}},
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
},
}
@@ -30,6 +30,24 @@ func TestSafeConfigSummaryFromConfigUsesExplicitConfiguredProvider(t *testing.T)
if summary.GetThinkingLevel() != "high" {
t.Fatalf("expected thinking level %q, got %q", "high", summary.GetThinkingLevel())
}
if summary.GetSystemPrompt() != "Stay concise." {
t.Fatalf("expected system prompt %q, got %q", "Stay concise.", summary.GetSystemPrompt())
}
}
func TestSafeConfigSummaryFromConfigFallsBackToLegacySharedModel(t *testing.T) {
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAI,
Model: "legacy-model",
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key"},
},
}
summary := SafeConfigSummaryFromConfig(cfg)
if summary.GetModel() != "legacy-model" {
t.Fatalf("expected legacy model %q, got %q", "legacy-model", summary.GetModel())
}
}
func TestSafeConfigSummaryFromConfigFallsBackToConfiguredProvider(t *testing.T) {
+56 -17
View File
@@ -10,6 +10,7 @@ import (
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/util/clientpbutil"
)
const (
@@ -60,11 +61,12 @@ type RuntimeConfig struct {
// Completion contains the persisted assistant response details.
type Completion struct {
Provider string
Model string
Content string
ProviderMessageID string
FinishReason string
Provider string
Model string
Content string
ProviderMessageID string
FinishReason string
ContextWindowUsage *ContextWindowUsage
}
type providerMessage struct {
@@ -90,6 +92,9 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
if conversation != nil {
runtime.Provider = NormalizeProviderName(conversation.GetProvider())
runtime.Model = strings.TrimSpace(conversation.GetModel())
if thinkingLevel := NormalizeThinkingLevelValue(conversation.GetThinkingLevel()); thinkingLevel != "" {
runtime.ThinkingLevel = thinkingLevel
}
}
if runtime.Provider == "" {
@@ -105,11 +110,8 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
applyProviderRuntimeConfig(runtime, providerConfig)
}
if runtime.Model == "" && cfg != nil && cfg.AI != nil {
runtime.Model = strings.TrimSpace(cfg.AI.Model)
}
if runtime.Model == "" {
runtime.Model = defaultModelForProvider(runtime.Provider)
runtime.Model = configuredDefaultModel(runtime.Provider, selectedAIConfig(cfg), providerConfig)
}
runtime.UseResponsesAPI = useResponsesAPI(runtime.Provider, providerConfig)
@@ -121,7 +123,7 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
case !runtimeProviderConfigured(runtime):
return runtime, errors.New(missingProviderConfigError(runtime.Provider))
case runtime.Model == "":
return runtime, fmt.Errorf("server AI provider %q is missing a model; update `ai.model` or choose a provider default", runtime.Provider)
return runtime, fmt.Errorf("server AI provider %q is missing a model; update `ai.%s.models` or choose a provider default", runtime.Provider, providerConfigKey(runtime.Provider))
case !completionDriverAvailable(runtime):
return runtime, missingDriverError(runtime)
default:
@@ -165,7 +167,7 @@ func conversationHistory(conversation *clientpb.AIConversation) (string, []provi
if message == nil {
continue
}
if message.GetVisibility() == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
if !clientpbutil.AIConversationMessageIncludesContext(message) {
continue
}
if message.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
@@ -218,6 +220,39 @@ func defaultModelForProvider(provider string) string {
}
}
func configuredDefaultModel(provider string, aiConfig *configs.AIConfig, providerConfig *configs.AIProviderConfig) string {
if providerConfig != nil {
for _, model := range providerConfig.Models {
model = strings.TrimSpace(model)
if model != "" {
return model
}
}
}
if aiConfig != nil {
if legacyModel := strings.TrimSpace(aiConfig.Model); legacyModel != "" {
return legacyModel
}
}
return defaultModelForProvider(provider)
}
func providerConfigKey(provider string) string {
switch NormalizeProviderName(provider) {
case ProviderOpenAICompat:
return "openai_compat"
default:
return NormalizeProviderName(provider)
}
}
func selectedAIConfig(cfg *configs.ServerConfig) *configs.AIConfig {
if cfg == nil {
return nil
}
return cfg.AI
}
func completionDriverAvailable(runtime *RuntimeConfig) bool {
_, err := selectCompletionDriver(runtime)
return err == nil
@@ -234,16 +269,20 @@ func missingDriverError(runtime *RuntimeConfig) error {
return nil
}
func NormalizeThinkingLevelValue(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "medium", "high", "xhigh", "disabled":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func normalizeThinkingLevel(cfg *configs.ServerConfig) string {
if cfg == nil || cfg.AI == nil {
return ""
}
switch strings.ToLower(strings.TrimSpace(cfg.AI.ThinkingLevel)) {
case "low", "medium", "high", "disabled":
return strings.ToLower(strings.TrimSpace(cfg.AI.ThinkingLevel))
default:
return ""
}
return NormalizeThinkingLevelValue(cfg.AI.ThinkingLevel)
}
func applyProviderRuntimeConfig(runtime *RuntimeConfig, providerConfig *configs.AIProviderConfig) {
+306 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/configs"
"github.com/openai/openai-go/v2/shared"
)
func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testing.T) {
@@ -38,6 +39,13 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
"id": "resp_123",
"model": "gpt-5.2",
"status": "completed",
"usage": {
"input_tokens": 12000,
"input_tokens_details": {},
"output_tokens": 6000,
"output_tokens_details": {},
"total_tokens": 18000
},
"output": [
{
"type": "message",
@@ -95,7 +103,10 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
}
for _, fragment := range []string{
`"model":"gpt-5.2"`,
`"type":"web_search"`,
`"search_context_size":"medium"`,
`"effort":"high"`,
`"summary":"auto"`,
`"Keep it brief."`,
`"Earlier answer."`,
`"Explain the workflow."`,
@@ -111,6 +122,15 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
if completion.ProviderMessageID != "resp_123" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
if completion.ContextWindowUsage == nil {
t.Fatal("expected context window usage to be captured")
}
if completion.ContextWindowUsage.TotalTokens != 18000 {
t.Fatalf("unexpected total tokens: %d", completion.ContextWindowUsage.TotalTokens)
}
if completion.ContextWindowUsage.ContextWindowTokens != openAIFrontierContextWindowTokens {
t.Fatalf("unexpected context window tokens: %d", completion.ContextWindowUsage.ContextWindowTokens)
}
}
func TestCompleteConversationOpenAIUsesDefaultBaseURLWhenUnset(t *testing.T) {
@@ -157,8 +177,8 @@ func TestCompleteConversationOpenAIUsesDefaultBaseURLWhenUnset(t *testing.T) {
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAI,
Model: "gpt-5.4",
OpenAI: &configs.AIProviderConfig{
Models: []string{"gpt-5.4"},
APIKey: "openai-key",
UseResponsesAPI: boolPtr(true),
},
@@ -204,6 +224,118 @@ func TestCompleteConversationOpenAIUsesDefaultBaseURLWhenUnset(t *testing.T) {
}
}
func TestCompleteConversationOpenAIChatEnablesWebSearch(t *testing.T) {
type capturedRequest struct {
Path string
Authorization string
Body string
}
requests := make(chan capturedRequest, 1)
restoreClient := SetHTTPClientForTests(&http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
requests <- capturedRequest{
Path: r.URL.Path,
Authorization: r.Header.Get("Authorization"),
Body: string(payload),
}
return jsonResponse(http.StatusOK, `{
"id": "chatcmpl_openai_123",
"object": "chat.completion",
"model": "gpt-5.4",
"usage": {
"prompt_tokens": 32000,
"completion_tokens": 4000,
"total_tokens": 36000
},
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "OpenAI chat reply"
}
}
]
}`), nil
}),
})
defer restoreClient()
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAI,
OpenAI: &configs.AIProviderConfig{
Models: []string{"gpt-5.4"},
APIKey: "openai-key",
UseResponsesAPI: boolPtr(false),
},
Anthropic: &configs.AIProviderConfig{},
},
}
conversation := &clientpb.AIConversation{
Provider: ProviderOpenAI,
Model: "gpt-5.4",
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Say hi."},
},
}
runtime, err := ResolveRuntimeConfig(cfg, conversation)
if err != nil {
t.Fatalf("resolve runtime config: %v", err)
}
if runtime.UseResponsesAPI {
t.Fatal("expected openai runtime to use chat completions for this test")
}
completion, err := CompleteConversation(context.Background(), runtime, conversation)
if err != nil {
t.Fatalf("complete conversation: %v", err)
}
request := <-requests
if request.Path != "/v1/chat/completions" {
t.Fatalf("unexpected openai chat request path: got=%q want=%q", request.Path, "/v1/chat/completions")
}
if request.Authorization != "Bearer openai-key" {
t.Fatalf("unexpected authorization header: %q", request.Authorization)
}
for _, fragment := range []string{
`"model":"gpt-5.4"`,
`"web_search_options":{"search_context_size":"medium"}`,
`"Say hi."`,
} {
if !strings.Contains(request.Body, fragment) {
t.Fatalf("expected openai chat request body to contain %q, got %s", fragment, request.Body)
}
}
if completion.Content != "OpenAI chat reply" {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
if completion.ProviderMessageID != "chatcmpl_openai_123" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
if completion.ContextWindowUsage == nil {
t.Fatal("expected openai chat usage to be captured")
}
if completion.ContextWindowUsage.TotalTokens != 36000 {
t.Fatalf("unexpected openai chat total tokens: %d", completion.ContextWindowUsage.TotalTokens)
}
if completion.ContextWindowUsage.ContextWindowTokens != openAI5_4ContextWindowTokens {
t.Fatalf("unexpected openai chat context window tokens: %d", completion.ContextWindowUsage.ContextWindowTokens)
}
}
func TestResolveRuntimeConfigAnthropicRequiresAnInstalledDriver(t *testing.T) {
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
@@ -233,8 +365,8 @@ func TestResolveRuntimeConfigOpenAICompatAllowsBaseURLWithoutAPIKey(t *testing.T
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAICompat,
Model: "gpt-oss-120b",
OpenAICompat: &configs.AIProviderConfig{
Models: []string{"gpt-oss-120b"},
BaseURL: "http://127.0.0.1:8080/v1",
},
},
@@ -257,6 +389,58 @@ func TestResolveRuntimeConfigOpenAICompatAllowsBaseURLWithoutAPIKey(t *testing.T
}
}
func TestResolveRuntimeConfigUsesConversationThinkingOverride(t *testing.T) {
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAI,
ThinkingLevel: "high",
OpenAI: &configs.AIProviderConfig{
Models: []string{"gpt-5.4"},
APIKey: "openai-key",
UseResponsesAPI: boolPtr(true),
},
Anthropic: &configs.AIProviderConfig{},
},
}
runtime, err := ResolveRuntimeConfig(cfg, &clientpb.AIConversation{
Provider: ProviderOpenAI,
ThinkingLevel: "disabled",
})
if err != nil {
t.Fatalf("resolve runtime config: %v", err)
}
if runtime.ThinkingLevel != "disabled" {
t.Fatalf("expected conversation thinking override %q, got %q", "disabled", runtime.ThinkingLevel)
}
}
func TestReasoningEffortForThinkingLevelSupportsXHigh(t *testing.T) {
effort, ok := reasoningEffortForThinkingLevel("xhigh")
if !ok {
t.Fatal("expected xhigh reasoning effort to be supported")
}
if string(effort) != "xhigh" {
t.Fatalf("expected reasoning effort %q, got %q", "xhigh", effort)
}
}
func TestResponseReasoningParamSkipsSummaryForOpenAICompat(t *testing.T) {
reasoning, ok := responseReasoningParam(&RuntimeConfig{
Provider: ProviderOpenAICompat,
ThinkingLevel: "high",
})
if !ok {
t.Fatal("expected response reasoning params to be available")
}
if reasoning.Effort != shared.ReasoningEffortHigh {
t.Fatalf("expected reasoning effort %q, got %q", shared.ReasoningEffortHigh, reasoning.Effort)
}
if reasoning.Summary != "" {
t.Fatalf("expected openai-compatible runtime to skip reasoning summary, got %q", reasoning.Summary)
}
}
func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
type capturedRequest struct {
Path string
@@ -307,8 +491,8 @@ func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAICompat,
Model: "gpt-oss-120b",
OpenAICompat: &configs.AIProviderConfig{
Models: []string{"gpt-oss-120b"},
BaseURL: "http://127.0.0.1:8080/v1",
},
},
@@ -375,6 +559,11 @@ func TestCompleteConversationOpenRouterUsesDefaultBaseURL(t *testing.T) {
"id": "chatcmpl_or_123",
"object": "chat.completion",
"model": "openai/gpt-5",
"usage": {
"prompt_tokens": 14000,
"completion_tokens": 2000,
"total_tokens": 16000
},
"choices": [
{
"index": 0,
@@ -440,6 +629,120 @@ func TestCompleteConversationOpenRouterUsesDefaultBaseURL(t *testing.T) {
if completion.ProviderMessageID != "chatcmpl_or_123" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
if completion.ContextWindowUsage == nil {
t.Fatal("expected openrouter usage to be captured")
}
if completion.ContextWindowUsage.TotalTokens != 16000 {
t.Fatalf("unexpected openrouter total tokens: %d", completion.ContextWindowUsage.TotalTokens)
}
if completion.ContextWindowUsage.ContextWindowTokens != openAIFrontierContextWindowTokens {
t.Fatalf("unexpected openrouter context window tokens: %d", completion.ContextWindowUsage.ContextWindowTokens)
}
}
func TestConversationHistoryUsesExplicitContextFlagIndependentlyOfVisibility(t *testing.T) {
systemPrompt, messages, err := conversationHistory(&clientpb.AIConversation{
SystemPrompt: "Stay concise.",
Messages: []*clientpb.AIConversationMessage{
{
Role: "assistant",
Content: "Visible in the transcript but excluded from context.",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
IncludeInContext: boolPtr(false),
},
{
Role: "assistant",
Content: "UI-only but still included in context.",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
IncludeInContext: boolPtr(true),
},
{
Role: "user",
Content: "What changed?",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
IncludeInContext: boolPtr(true),
},
},
})
if err != nil {
t.Fatalf("conversationHistory: %v", err)
}
if systemPrompt != "Stay concise." {
t.Fatalf("unexpected system prompt: %q", systemPrompt)
}
if len(messages) != 2 {
t.Fatalf("unexpected message count: got=%d want=%d", len(messages), 2)
}
if messages[0].Content != "UI-only but still included in context." {
t.Fatalf("unexpected retained assistant message: %+v", messages[0])
}
if messages[1].Role != "user" || messages[1].Content != "What changed?" {
t.Fatalf("unexpected trailing user message: %+v", messages[1])
}
}
func TestConversationHistoryTreatsLeadingSystemMessageAsSystemPrompt(t *testing.T) {
systemPrompt, messages, err := conversationHistory(&clientpb.AIConversation{
Messages: []*clientpb.AIConversationMessage{
{
Role: "system",
Content: "Stay concise.",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
IncludeInContext: boolPtr(true),
},
{
Role: "user",
Content: "What changed?",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
IncludeInContext: boolPtr(true),
},
},
})
if err != nil {
t.Fatalf("conversationHistory: %v", err)
}
if systemPrompt != "Stay concise." {
t.Fatalf("unexpected system prompt: %q", systemPrompt)
}
if len(messages) != 1 {
t.Fatalf("unexpected message count: got=%d want=%d", len(messages), 1)
}
if messages[0].Role != "user" || messages[0].Content != "What changed?" {
t.Fatalf("unexpected trailing user message: %+v", messages[0])
}
}
func TestConversationHistoryFallsBackToVisibilityWhenContextFlagIsUnset(t *testing.T) {
_, messages, err := conversationHistory(&clientpb.AIConversation{
Messages: []*clientpb.AIConversationMessage{
{
Role: "assistant",
Content: "Legacy context-visible message.",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
},
{
Role: "user",
Content: "Legacy prompt.",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
},
},
})
if err != nil {
t.Fatalf("conversationHistory: %v", err)
}
if len(messages) != 2 {
t.Fatalf("unexpected legacy message count: got=%d want=%d", len(messages), 2)
}
if messages[0].Content != "Legacy context-visible message." {
t.Fatalf("unexpected legacy assistant message: %+v", messages[0])
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
+312
View File
@@ -0,0 +1,312 @@
package ai
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
openAIFrontierContextWindowTokens = 400000
openAIChatContextWindowTokens = 128000
openAI4_1ContextWindowTokens = 1047576
openAI5_4ContextWindowTokens = 1050000
openAIReasoningContextWindowTokens = 200000
openAIRealtimeContextWindowTokens = 32000
openAIRealtimeMiniContextWindowTokens = 16000
openRouterContextWindowCacheTTL = 6 * time.Hour
)
// ContextWindowUsage captures the provider-reported token usage for a turn and,
// when available, the model context window used to render the client-side meter.
type ContextWindowUsage struct {
InputTokens int64
OutputTokens int64
TotalTokens int64
ContextWindowTokens int64
ContextWindowTokensEstimated bool
}
func newContextWindowUsage(inputTokens, outputTokens, totalTokens, windowTokens int64, estimated bool) *ContextWindowUsage {
if inputTokens == 0 && outputTokens == 0 && totalTokens == 0 {
return nil
}
return &ContextWindowUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: totalTokens,
ContextWindowTokens: windowTokens,
ContextWindowTokensEstimated: estimated,
}
}
func cloneContextWindowUsage(usage *ContextWindowUsage) *ContextWindowUsage {
if usage == nil {
return nil
}
cloned := *usage
return &cloned
}
func contextWindowUsageRank(usage *ContextWindowUsage) (int64, int64, int64) {
if usage == nil {
return 0, 0, 0
}
return usage.TotalTokens, usage.InputTokens, usage.OutputTokens
}
func mergeContextWindowUsage(current *ContextWindowUsage, candidate *ContextWindowUsage) *ContextWindowUsage {
if candidate == nil {
return cloneContextWindowUsage(current)
}
if current == nil {
return cloneContextWindowUsage(candidate)
}
result := cloneContextWindowUsage(current)
currentTotal, currentInput, currentOutput := contextWindowUsageRank(result)
candidateTotal, candidateInput, candidateOutput := contextWindowUsageRank(candidate)
if candidateTotal > currentTotal ||
(candidateTotal == currentTotal && candidateInput > currentInput) ||
(candidateTotal == currentTotal && candidateInput == currentInput && candidateOutput > currentOutput) {
result = cloneContextWindowUsage(candidate)
}
if result.ContextWindowTokens == 0 && candidate.ContextWindowTokens > 0 {
result.ContextWindowTokens = candidate.ContextWindowTokens
result.ContextWindowTokensEstimated = candidate.ContextWindowTokensEstimated
}
if result.ContextWindowTokens > 0 && result.ContextWindowTokensEstimated &&
candidate.ContextWindowTokens > 0 && !candidate.ContextWindowTokensEstimated {
result.ContextWindowTokens = candidate.ContextWindowTokens
result.ContextWindowTokensEstimated = false
}
return result
}
func resolveContextWindowUsage(ctx context.Context, runtime *RuntimeConfig, model string, inputTokens, outputTokens, totalTokens int64) *ContextWindowUsage {
windowTokens, estimated := resolveContextWindowTokens(ctx, runtime, model)
return newContextWindowUsage(inputTokens, outputTokens, totalTokens, windowTokens, estimated)
}
func resolveContextWindowTokens(ctx context.Context, runtime *RuntimeConfig, model string) (int64, bool) {
model = normalizeContextWindowModelID(model)
if model == "" {
return 0, false
}
if tokens := openAIKnownContextWindowTokens(model); tokens > 0 {
return tokens, true
}
if openRouterCompatibleRuntime(runtime) {
if tokens, ok := openRouterContextWindowTokens(ctx, runtime, model); ok {
return tokens, false
}
}
return 0, false
}
func openAIKnownContextWindowTokens(model string) int64 {
model = normalizeContextWindowModelID(model)
switch {
case model == "codex-mini-latest":
return openAIReasoningContextWindowTokens
case strings.HasPrefix(model, "o3"), strings.HasPrefix(model, "o4-"):
return openAIReasoningContextWindowTokens
case strings.HasPrefix(model, "gpt-5.4"):
return openAI5_4ContextWindowTokens
case strings.HasPrefix(model, "gpt-4.1"):
return openAI4_1ContextWindowTokens
case strings.HasPrefix(model, "gpt-5.1-chat"), strings.HasPrefix(model, "gpt-5-chat"):
return openAIChatContextWindowTokens
case strings.HasPrefix(model, "gpt-5"):
return openAIFrontierContextWindowTokens
case strings.HasPrefix(model, "gpt-4o-mini-realtime"):
return openAIRealtimeMiniContextWindowTokens
case strings.HasPrefix(model, "gpt-4o-realtime"):
return openAIRealtimeContextWindowTokens
case strings.HasPrefix(model, "gpt-4o"), strings.HasPrefix(model, "chatgpt-4o"):
return openAIChatContextWindowTokens
case strings.Contains(model, "gpt-4-turbo"):
return openAIChatContextWindowTokens
case strings.HasPrefix(model, "gpt-4"):
return 8192
default:
return 0
}
}
func normalizeContextWindowModelID(model string) string {
model = strings.ToLower(strings.TrimSpace(model))
if idx := strings.Index(model, "/"); idx >= 0 {
model = model[idx+1:]
}
if idx := strings.Index(model, ":"); idx >= 0 {
model = model[:idx]
}
return strings.TrimSpace(model)
}
func openRouterCompatibleRuntime(runtime *RuntimeConfig) bool {
if runtime == nil {
return false
}
switch NormalizeProviderName(runtime.Provider) {
case ProviderOpenRouter:
return true
case ProviderOpenAICompat:
baseURL := strings.ToLower(strings.TrimSpace(openAIBaseURL(runtime)))
return strings.Contains(baseURL, "openrouter.ai")
default:
return false
}
}
type openRouterModelsResponse struct {
Data []openRouterModel `json:"data"`
}
type openRouterModel struct {
ID string `json:"id"`
TopProvider struct {
ContextLength int64 `json:"context_length"`
} `json:"top_provider"`
}
type cachedOpenRouterContextWindow struct {
tokens int64
expiresAt time.Time
}
var openRouterContextWindowCache = struct {
sync.RWMutex
entries map[string]cachedOpenRouterContextWindow
}{
entries: map[string]cachedOpenRouterContextWindow{},
}
func openRouterContextWindowTokens(ctx context.Context, runtime *RuntimeConfig, model string) (int64, bool) {
model = normalizeContextWindowModelID(model)
if model == "" {
return 0, false
}
cacheKey := openRouterContextWindowCacheKey(runtime, model)
if tokens, ok := cachedOpenRouterContextWindowTokens(cacheKey); ok {
return tokens, true
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterModelsURL(runtime), nil)
if err != nil {
return 0, false
}
if runtime != nil && !runtime.SkipAuth && strings.TrimSpace(runtime.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(runtime.APIKey))
}
for key, value := range providerHeaders(runtime) {
req.Header.Set(key, value)
}
resp, err := httpClient.Do(req)
if err != nil {
return 0, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, false
}
payload := &openRouterModelsResponse{}
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
return 0, false
}
for _, candidate := range payload.Data {
candidateID := normalizeContextWindowModelID(candidate.ID)
if candidateID != model {
continue
}
if candidate.TopProvider.ContextLength <= 0 {
return 0, false
}
storeOpenRouterContextWindowTokens(cacheKey, candidate.TopProvider.ContextLength)
return candidate.TopProvider.ContextLength, true
}
return 0, false
}
func openRouterModelsURL(runtime *RuntimeConfig) string {
baseURL := strings.TrimSpace(openAIBaseURL(runtime))
if baseURL == "" {
baseURL = defaultOpenRouterBaseURL
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed == nil {
return defaultOpenRouterBaseURL + "/models"
}
path := strings.TrimRight(parsed.Path, "/")
switch {
case strings.HasSuffix(path, "/chat/completions"):
path = strings.TrimSuffix(path, "/chat/completions")
case strings.HasSuffix(path, "/responses"):
path = strings.TrimSuffix(path, "/responses")
case path == "":
path = "/api/v1"
}
if !strings.HasSuffix(path, "/models") {
path += "/models"
}
parsed.Path = path
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func openRouterContextWindowCacheKey(runtime *RuntimeConfig, model string) string {
return openRouterModelsURL(runtime) + "|" + normalizeContextWindowModelID(model)
}
func cachedOpenRouterContextWindowTokens(cacheKey string) (int64, bool) {
if cacheKey == "" {
return 0, false
}
now := time.Now()
openRouterContextWindowCache.RLock()
cached, ok := openRouterContextWindowCache.entries[cacheKey]
openRouterContextWindowCache.RUnlock()
if !ok || cached.tokens <= 0 || now.After(cached.expiresAt) {
if ok && now.After(cached.expiresAt) {
openRouterContextWindowCache.Lock()
delete(openRouterContextWindowCache.entries, cacheKey)
openRouterContextWindowCache.Unlock()
}
return 0, false
}
return cached.tokens, true
}
func storeOpenRouterContextWindowTokens(cacheKey string, tokens int64) {
if cacheKey == "" || tokens <= 0 {
return
}
openRouterContextWindowCache.Lock()
openRouterContextWindowCache.entries[cacheKey] = cachedOpenRouterContextWindow{
tokens: tokens,
expiresAt: time.Now().Add(openRouterContextWindowCacheTTL),
}
openRouterContextWindowCache.Unlock()
}
+50
View File
@@ -0,0 +1,50 @@
package ai
import (
"context"
"net/http"
"testing"
)
func TestResolveContextWindowUsageOpenRouterUsesModelsAPIForUnknownModels(t *testing.T) {
openRouterContextWindowCache.Lock()
openRouterContextWindowCache.entries = map[string]cachedOpenRouterContextWindow{}
openRouterContextWindowCache.Unlock()
requests := make(chan string, 1)
restoreClient := SetHTTPClientForTests(&http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
requests <- r.URL.Path
return jsonResponse(http.StatusOK, `{
"data": [
{
"id": "anthropic/claude-sonnet-4-0",
"top_provider": {
"context_length": 200000
}
}
]
}`), nil
}),
})
defer restoreClient()
usage := resolveContextWindowUsage(context.Background(), &RuntimeConfig{
Provider: ProviderOpenRouter,
APIKey: "openrouter-key",
}, "anthropic/claude-sonnet-4-0", 24000, 1000, 25000)
if usage == nil {
t.Fatal("expected context window usage")
}
if usage.ContextWindowTokens != 200000 {
t.Fatalf("unexpected context window tokens: %d", usage.ContextWindowTokens)
}
if usage.ContextWindowTokensEstimated {
t.Fatal("expected OpenRouter model lookup to be treated as exact")
}
requestPath := <-requests
if requestPath != "/api/v1/models" {
t.Fatalf("unexpected models lookup path: %q", requestPath)
}
}
+81 -4
View File
@@ -15,6 +15,7 @@ import (
const (
openAIDriverName = "openai-go"
defaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
openAIWebSearchContext = "medium"
)
type openAIDriver struct{}
@@ -115,6 +116,39 @@ func openAIBaseURL(runtime *RuntimeConfig) string {
return ""
}
func openAIWebSearchEnabled(runtime *RuntimeConfig) bool {
return runtime != nil && NormalizeProviderName(runtime.Provider) == ProviderOpenAI
}
func appendOpenAIResponseTools(runtime *RuntimeConfig, tools []openairesponses.ToolUnionParam) []openairesponses.ToolUnionParam {
if !openAIWebSearchEnabled(runtime) {
return tools
}
for _, tool := range tools {
if tool.OfWebSearch != nil || tool.OfWebSearchPreview != nil {
return tools
}
}
webSearch := openairesponses.ToolParamOfWebSearch(openairesponses.WebSearchToolTypeWebSearch)
if webSearch.OfWebSearch != nil {
webSearch.OfWebSearch.SearchContextSize = openairesponses.WebSearchToolSearchContextSizeMedium
}
return append(append([]openairesponses.ToolUnionParam{}, tools...), webSearch)
}
func applyOpenAIChatWebSearch(runtime *RuntimeConfig, params *openai.ChatCompletionNewParams) {
if params == nil || !openAIWebSearchEnabled(runtime) {
return
}
params.WebSearchOptions = openai.ChatCompletionNewParamsWebSearchOptions{
SearchContextSize: openAIWebSearchContext,
}
}
func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
params := openairesponses.ResponseNewParams{
Input: openairesponses.ResponseNewParamsInputUnion{
@@ -122,6 +156,9 @@ func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClie
},
Model: shared.ResponsesModel(runtime.Model),
}
if tools := appendOpenAIResponseTools(runtime, nil); len(tools) > 0 {
params.Tools = tools
}
if runtime.MaxOutputTokens > 0 {
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
}
@@ -131,8 +168,8 @@ func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClie
if runtime.TopP != nil {
params.TopP = openai.Float(*runtime.TopP)
}
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
params.Reasoning = shared.ReasoningParam{Effort: effort}
if reasoning, ok := responseReasoningParam(runtime); ok {
params.Reasoning = reasoning
}
response, err := client.responses.New(ctx, params)
@@ -145,12 +182,21 @@ func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClie
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
}
model := fallbackString(response.Model, runtime.Model)
return &Completion{
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Model: model,
Content: content,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: responseFinishReason(response),
ContextWindowUsage: resolveContextWindowUsage(
ctx,
runtime,
model,
response.Usage.InputTokens,
response.Usage.OutputTokens,
response.Usage.TotalTokens,
),
}, nil
}
@@ -159,6 +205,7 @@ func (d *openAIDriver) completeChat(ctx context.Context, client *openAIClient, r
Messages: buildChatMessages(request.SystemPrompt, request.Messages),
Model: shared.ChatModel(runtime.Model),
}
applyOpenAIChatWebSearch(runtime, &params)
if runtime.MaxOutputTokens > 0 {
if NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
params.MaxCompletionTokens = openai.Int(runtime.MaxOutputTokens)
@@ -192,12 +239,21 @@ func (d *openAIDriver) completeChat(ctx context.Context, client *openAIClient, r
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
}
model := fallbackString(response.Model, runtime.Model)
return &Completion{
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Model: model,
Content: content,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: finishReason,
ContextWindowUsage: resolveContextWindowUsage(
ctx,
runtime,
model,
response.Usage.PromptTokens,
response.Usage.CompletionTokens,
response.Usage.TotalTokens,
),
}, nil
}
@@ -281,11 +337,32 @@ func reasoningEffortForThinkingLevel(thinkingLevel string) (shared.ReasoningEffo
return shared.ReasoningEffortMedium, true
case "high":
return shared.ReasoningEffortHigh, true
case "xhigh":
return shared.ReasoningEffort("xhigh"), true
default:
return "", false
}
}
func responseReasoningParam(runtime *RuntimeConfig) (shared.ReasoningParam, bool) {
effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel)
if !ok {
return shared.ReasoningParam{}, false
}
reasoning := shared.ReasoningParam{Effort: effort}
switch NormalizeProviderName(runtime.Provider) {
case ProviderOpenAI, ProviderOpenRouter:
// Native OpenAI-style Responses backends can return a reasoning summary
// for UI-only transcript blocks. Use "auto" because summary modes are
// model-specific; hard-coding "concise" can suppress summaries on models
// that only expose a different summarizer.
reasoning.Summary = shared.ReasoningSummaryAuto
}
return reasoning, true
}
func formatOpenAIError(provider string, err error) error {
if err == nil {
return nil
+41
View File
@@ -0,0 +1,41 @@
package assets
import (
"os"
"path/filepath"
)
const (
// AIDirName stores server-side AI helper data under the Sliver root.
AIDirName = "ai"
// AIAliasesDirName stores aliases made available to the server-side AI agent.
AIAliasesDirName = "aliases"
// AIExtensionsDirName stores extensions made available to the server-side AI agent.
AIExtensionsDirName = "extensions"
)
// GetAIDir returns the server-side AI data directory: ~/.sliver/ai
func GetAIDir() string {
return ensureAIDir(filepath.Join(GetRootAppDir(), AIDirName))
}
// GetAIAliasesDir returns the server-side AI alias directory: ~/.sliver/ai/aliases
func GetAIAliasesDir() string {
return ensureAIDir(filepath.Join(GetAIDir(), AIAliasesDirName))
}
// GetAIExtensionsDir returns the server-side AI extension directory: ~/.sliver/ai/extensions
func GetAIExtensionsDir() string {
return ensureAIDir(filepath.Join(GetAIDir(), AIExtensionsDirName))
}
func ensureAIDir(dir string) string {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0o700); err != nil {
setupLog.Fatalf("Cannot create AI data dir %s", err)
}
}
return dir
}
+305 -30
View File
@@ -18,10 +18,62 @@ package configs
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "strings"
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/bishopfox/sliver/server/assets"
"github.com/bishopfox/sliver/server/log"
"gopkg.in/yaml.v3"
)
const (
aiConfigFileName = "ai.yaml"
aiProviderAnthropic = "anthropic"
aiProviderGoogle = "google"
aiProviderOpenAI = "openai"
aiProviderOpenAICompat = "openai-compat"
aiProviderOpenRouter = "openrouter"
defaultAISystemPrompt = `You are Sliver's AI copilot for authorized security testing, detection engineering, lab work, and incident-response support in environments the operator is explicitly permitted to assess.
Your job is to help the operator make careful, high-signal decisions inside the Sliver workflow.
Operating rules:
- Assume all activity must stay within the operator's authorized scope. If a request is ambiguous, unusually risky, destructive, or inconsistent with the available context, pause, state the concern, and ask for confirmation or offer a safer alternative.
- Use the current conversation, target metadata, and Sliver context to tailor answers. If OS, privilege, transport, or session/beacon state matters, say so before recommending commands.
- Never fabricate command output, host state, credentials, files, loot, or tool results. When information is missing, say exactly what is unknown.
- Prefer the smallest next step that increases certainty. Start with low-noise, reversible discovery before collection, execution, or configuration changes.
- Highlight operational tradeoffs: stealth, telemetry, privilege requirements, target stability, cleanup burden, and chances of breaking access.
- Prefer actions that preserve operator access, host stability, and forensic defensibility. Avoid destructive, noisy, or irreversible steps unless the operator explicitly asks for them and the purpose is clear.
- Treat credentials, secrets, tokens, and loot as sensitive. Do not suggest unnecessary exposure, duplication, or broad collection.
- When troubleshooting, separate confirmed facts from hypotheses and propose the next diagnostic step rather than many speculative changes at once.
- When multiple approaches are possible, present the safest practical option first, then note faster or more aggressive alternatives only if they materially help.
- When suggesting Sliver commands, make them concrete, minimal, and easy to audit. Include assumptions and prerequisites when they matter.
- When asked to draft code, scripts, or one-liners, optimize for readability, explicitness, and minimal side effects.
Response style:
- Be concise, structured, and operator-focused.
- Prefer short checklists or step plans over long essays.
- Distinguish clearly between facts, assumptions, and recommendations.
- If the request appears outside authorized security work, refuse and redirect to safer high-level guidance.`
)
var (
aiConfigLog = log.NamedLogger("config", "ai")
supportedAIProviders = []string{
aiProviderAnthropic,
aiProviderGoogle,
aiProviderOpenAI,
aiProviderOpenAICompat,
aiProviderOpenRouter,
}
)
// AIProviderConfig - Shared AI provider configuration.
type AIProviderConfig struct {
Models []string `json:"models,omitempty" yaml:"models,omitempty"`
APIKey string `json:"api_key" yaml:"api_key"`
BaseURL string `json:"base_url" yaml:"base_url"`
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"`
@@ -37,8 +89,9 @@ type AIProviderConfig struct {
// AIConfig - Server-side AI provider configuration.
type AIConfig struct {
Provider string `json:"provider" yaml:"provider"`
Model string `json:"model" yaml:"model"`
Model string `json:"model,omitempty" yaml:"model,omitempty"` // Deprecated legacy shared model field; migrated into provider-specific models on save.
ThinkingLevel string `json:"thinking_level" yaml:"thinking_level"`
SystemPrompt string `json:"system_prompt" yaml:"system_prompt"`
MaxOutputTokens int64 `json:"max_output_tokens,omitempty" yaml:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty" yaml:"top_p,omitempty"`
@@ -52,6 +105,18 @@ type AIConfig struct {
OpenRouter *AIProviderConfig `json:"openrouter" yaml:"openrouter"`
}
type aiConfigEnvelope struct {
AI *AIConfig `json:"ai" yaml:"ai"`
}
// GetAIConfigPath - File path to ai.yaml.
func GetAIConfigPath() string {
appDir := assets.GetRootAppDir()
aiConfigPath := filepath.Join(appDir, "configs", aiConfigFileName)
aiConfigLog.Debugf("Loading config from %s", aiConfigPath)
return aiConfigPath
}
func defaultAIProviderConfig() *AIProviderConfig {
return &AIProviderConfig{
Headers: map[string]string{},
@@ -63,6 +128,7 @@ func defaultAIConfig() *AIConfig {
Provider: "",
Model: "",
ThinkingLevel: "",
SystemPrompt: defaultAISystemPrompt,
MaxOutputTokens: 0,
Temperature: nil,
TopP: nil,
@@ -77,47 +143,135 @@ func defaultAIConfig() *AIConfig {
}
}
func normalizeAIConfig(config *ServerConfig) {
if config.AI == nil {
config.AI = defaultAIConfig()
// Save - Save AI config file to disk.
func (c *AIConfig) Save() error {
config := normalizeAIConfig(c)
configPath := GetAIConfigPath()
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
aiConfigLog.Debugf("Creating config dir %s", configDir)
err := os.MkdirAll(configDir, 0700)
if err != nil {
return err
}
}
config.AI.Provider = strings.ToLower(strings.TrimSpace(config.AI.Provider))
config.AI.Model = strings.TrimSpace(config.AI.Model)
config.AI.ThinkingLevel = strings.ToLower(strings.TrimSpace(config.AI.ThinkingLevel))
if config.AI.MaxOutputTokens < 0 {
config.AI.MaxOutputTokens = 0
data, err := yaml.Marshal(config)
if err != nil {
return err
}
config.AI.Temperature = normalizeOptionalFloat(config.AI.Temperature)
config.AI.TopP = normalizeOptionalFloat(config.AI.TopP)
config.AI.TopK = normalizeOptionalInt(config.AI.TopK)
config.AI.PresencePenalty = normalizeOptionalFloat(config.AI.PresencePenalty)
config.AI.FrequencyPenalty = normalizeOptionalFloat(config.AI.FrequencyPenalty)
if config.AI.Anthropic == nil {
config.AI.Anthropic = defaultAIProviderConfig()
aiConfigLog.Infof("Saving config to %s", configPath)
err = os.WriteFile(configPath, data, 0600)
if err != nil {
aiConfigLog.Errorf("Failed to write config %s", err)
}
normalizeAIProviderConfig(config.AI.Anthropic)
if config.AI.Google == nil {
config.AI.Google = defaultAIProviderConfig()
return err
}
// GetAIConfig - Get AI config value from ai.yaml.
func GetAIConfig() *AIConfig {
return getAIConfig(nil)
}
func getAIConfig(migratedConfig *AIConfig) *AIConfig {
configPath := GetAIConfigPath()
config := defaultAIConfig()
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
data, err := os.ReadFile(configPath)
if err != nil {
aiConfigLog.Errorf("Failed to read config file %s", err)
return config
}
err = yaml.Unmarshal(data, config)
if err != nil {
aiConfigLog.Errorf("Failed to parse config file %s", err)
return config
}
} else if migratedConfig != nil {
config = migratedConfig
aiConfigLog.Infof("Migrating embedded AI config to %s", configPath)
} else {
aiConfigLog.Warnf("Config file does not exist, using defaults")
}
normalizeAIProviderConfig(config.AI.Google)
if config.AI.OpenAI == nil {
config.AI.OpenAI = defaultAIProviderConfig()
config = normalizeAIConfig(config)
err := config.Save() // This updates the config with any missing fields
if err != nil {
aiConfigLog.Errorf("Failed to save default config %s", err)
return config
}
normalizeAIProviderConfig(config.AI.OpenAI)
if config.AI.OpenAICompat == nil {
config.AI.OpenAICompat = defaultAIProviderConfig()
return config
}
func aiConfigFromYAML(data []byte) *AIConfig {
envelope := &aiConfigEnvelope{}
if err := yaml.Unmarshal(data, envelope); err != nil {
aiConfigLog.Errorf("Failed to parse embedded AI config %s", err)
return nil
}
normalizeAIProviderConfig(config.AI.OpenAICompat)
if config.AI.OpenRouter == nil {
config.AI.OpenRouter = defaultAIProviderConfig()
if envelope.AI == nil {
return nil
}
normalizeAIProviderConfig(config.AI.OpenRouter)
return normalizeAIConfig(envelope.AI)
}
func aiConfigFromJSON(data []byte) *AIConfig {
envelope := &aiConfigEnvelope{}
if err := json.Unmarshal(data, envelope); err != nil {
aiConfigLog.Errorf("Failed to parse embedded AI config %s", err)
return nil
}
if envelope.AI == nil {
return nil
}
return normalizeAIConfig(envelope.AI)
}
func normalizeAIConfig(config *AIConfig) *AIConfig {
if config == nil {
config = defaultAIConfig()
}
config.Provider = normalizeAIProviderName(config.Provider)
config.Model = strings.TrimSpace(config.Model)
config.ThinkingLevel = strings.ToLower(strings.TrimSpace(config.ThinkingLevel))
config.SystemPrompt = strings.TrimSpace(config.SystemPrompt)
if config.MaxOutputTokens < 0 {
config.MaxOutputTokens = 0
}
config.Temperature = normalizeOptionalFloat(config.Temperature)
config.TopP = normalizeOptionalFloat(config.TopP)
config.TopK = normalizeOptionalInt(config.TopK)
config.PresencePenalty = normalizeOptionalFloat(config.PresencePenalty)
config.FrequencyPenalty = normalizeOptionalFloat(config.FrequencyPenalty)
if config.Anthropic == nil {
config.Anthropic = defaultAIProviderConfig()
}
normalizeAIProviderConfig(config.Anthropic)
if config.Google == nil {
config.Google = defaultAIProviderConfig()
}
normalizeAIProviderConfig(config.Google)
if config.OpenAI == nil {
config.OpenAI = defaultAIProviderConfig()
}
normalizeAIProviderConfig(config.OpenAI)
if config.OpenAICompat == nil {
config.OpenAICompat = defaultAIProviderConfig()
}
normalizeAIProviderConfig(config.OpenAICompat)
if config.OpenRouter == nil {
config.OpenRouter = defaultAIProviderConfig()
}
normalizeAIProviderConfig(config.OpenRouter)
migrateLegacyAIModel(config)
return config
}
func normalizeAIProviderConfig(provider *AIProviderConfig) {
if provider == nil {
return
}
provider.Models = normalizeStringSlice(provider.Models)
provider.APIKey = strings.TrimSpace(provider.APIKey)
provider.BaseURL = strings.TrimSpace(provider.BaseURL)
provider.UserAgent = strings.TrimSpace(provider.UserAgent)
@@ -174,3 +328,124 @@ func normalizeStringMap(values map[string]string) map[string]string {
}
return normalized
}
func migrateLegacyAIModel(config *AIConfig) {
if config == nil {
return
}
legacyModel := strings.TrimSpace(config.Model)
if legacyModel == "" {
return
}
provider := legacyAIModelProvider(config)
providerConfig := aiProviderConfigByName(config, provider)
if providerConfig != nil {
providerConfig.Models = normalizeStringSlice(append([]string{legacyModel}, providerConfig.Models...))
}
config.Model = ""
}
func legacyAIModelProvider(config *AIConfig) string {
if config == nil {
return aiProviderOpenAI
}
if provider := normalizeAIProviderName(config.Provider); isSupportedAIProvider(provider) {
return provider
}
for _, provider := range supportedAIProviders {
if aiProviderConfigured(provider, aiProviderConfigByName(config, provider)) {
return provider
}
}
return aiProviderOpenAI
}
func normalizeAIProviderName(name string) string {
normalized := strings.ToLower(strings.TrimSpace(name))
switch normalized {
case "gemini":
return aiProviderGoogle
case "openai_compat", "openaicompat", "openai-compatible":
return aiProviderOpenAICompat
default:
return normalized
}
}
func isSupportedAIProvider(provider string) bool {
switch normalizeAIProviderName(provider) {
case aiProviderAnthropic, aiProviderGoogle, aiProviderOpenAI, aiProviderOpenAICompat, aiProviderOpenRouter:
return true
default:
return false
}
}
func aiProviderConfigByName(config *AIConfig, provider string) *AIProviderConfig {
if config == nil {
return nil
}
switch normalizeAIProviderName(provider) {
case aiProviderAnthropic:
return config.Anthropic
case aiProviderGoogle:
return config.Google
case aiProviderOpenAI:
return config.OpenAI
case aiProviderOpenAICompat:
return config.OpenAICompat
case aiProviderOpenRouter:
return config.OpenRouter
default:
return nil
}
}
func aiProviderConfigured(provider string, providerConfig *AIProviderConfig) bool {
if providerConfig == nil {
return false
}
switch normalizeAIProviderName(provider) {
case aiProviderAnthropic:
return strings.TrimSpace(providerConfig.APIKey) != "" ||
providerConfig.UseBedrock ||
(strings.TrimSpace(providerConfig.Project) != "" && strings.TrimSpace(providerConfig.Location) != "")
case aiProviderGoogle:
return strings.TrimSpace(providerConfig.APIKey) != "" ||
(strings.TrimSpace(providerConfig.Project) != "" && strings.TrimSpace(providerConfig.Location) != "")
case aiProviderOpenAI:
return strings.TrimSpace(providerConfig.APIKey) != ""
case aiProviderOpenAICompat:
return strings.TrimSpace(providerConfig.BaseURL) != ""
case aiProviderOpenRouter:
return strings.TrimSpace(providerConfig.APIKey) != ""
default:
return false
}
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
if len(normalized) == 0 {
return nil
}
return normalized
}
+211
View File
@@ -0,0 +1,211 @@
package configs
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestAIConfigParsesYAML(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
configPath := GetAIConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
data := []byte(`provider: "openai"
thinking_level: "high"
system_prompt: "Stay concise."
max_output_tokens: 2048
temperature: 0.25
anthropic:
api_key: "anthropic-key"
base_url: "https://api.anthropic.example"
use_bedrock: true
openai:
models:
- "gpt-test"
- "gpt-mini-test"
api_key: "openai-key"
base_url: "https://api.openai.example"
organization: "org-test"
project: "proj-test"
use_responses_api: true
google:
api_key: "google-key"
project: "vertex-project"
location: "us-central1"
skip_auth: true
openai_compat:
base_url: "http://127.0.0.1:8080/v1"
openrouter:
api_key: "openrouter-key"
user_agent: "sliver-test/1.0"
`)
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatalf("failed to write yaml config: %v", err)
}
config := GetAIConfig()
if config.Provider != "openai" {
t.Fatalf("expected provider %q, got %q", "openai", config.Provider)
}
if config.Model != "" {
t.Fatalf("expected legacy model field to remain empty, got %q", config.Model)
}
if config.ThinkingLevel != "high" {
t.Fatalf("expected thinking level %q, got %q", "high", config.ThinkingLevel)
}
if config.SystemPrompt != "Stay concise." {
t.Fatalf("expected system prompt %q, got %q", "Stay concise.", config.SystemPrompt)
}
if config.MaxOutputTokens != 2048 {
t.Fatalf("expected max_output_tokens %d, got %d", 2048, config.MaxOutputTokens)
}
if config.Temperature == nil || *config.Temperature != 0.25 {
t.Fatalf("expected temperature %v, got %#v", 0.25, config.Temperature)
}
if config.Anthropic == nil || config.Anthropic.APIKey != "anthropic-key" || !config.Anthropic.UseBedrock {
t.Fatalf("expected anthropic config to load, got %#v", config.Anthropic)
}
if config.OpenAI == nil || config.OpenAI.APIKey != "openai-key" || config.OpenAI.Organization != "org-test" || config.OpenAI.Project != "proj-test" {
t.Fatalf("expected openai config to load, got %#v", config.OpenAI)
}
if config.OpenAI == nil || len(config.OpenAI.Models) != 2 || config.OpenAI.Models[0] != "gpt-test" || config.OpenAI.Models[1] != "gpt-mini-test" {
t.Fatalf("expected openai models to load, got %#v", config.OpenAI)
}
if config.OpenAI == nil || config.OpenAI.UseResponsesAPI == nil || !*config.OpenAI.UseResponsesAPI {
t.Fatalf("expected openai responses api flag to load")
}
if config.Google == nil || config.Google.APIKey != "google-key" || config.Google.Project != "vertex-project" || config.Google.Location != "us-central1" || !config.Google.SkipAuth {
t.Fatalf("expected google config to load, got %#v", config.Google)
}
if config.OpenAICompat == nil || config.OpenAICompat.BaseURL != "http://127.0.0.1:8080/v1" {
t.Fatalf("expected openai_compat config to load, got %#v", config.OpenAICompat)
}
if config.OpenRouter == nil || config.OpenRouter.APIKey != "openrouter-key" || config.OpenRouter.UserAgent != "sliver-test/1.0" {
t.Fatalf("expected openrouter config to load, got %#v", config.OpenRouter)
}
}
func TestAIConfigWritesDefault(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
config := GetAIConfig()
if config.Provider != "" || config.Model != "" || config.ThinkingLevel != "" {
t.Fatalf("expected empty default ai selections, got %#v", config)
}
if config.SystemPrompt != defaultAISystemPrompt {
t.Fatalf("expected default system prompt %q, got %q", defaultAISystemPrompt, config.SystemPrompt)
}
if config.Anthropic == nil || config.Google == nil || config.OpenAI == nil || config.OpenAICompat == nil || config.OpenRouter == nil {
t.Fatalf("expected default provider configs to exist, got %#v", config)
}
if _, err := os.Stat(GetAIConfigPath()); err != nil {
t.Fatalf("expected default ai config file to exist: %v", err)
}
data, err := os.ReadFile(GetAIConfigPath())
if err != nil {
t.Fatalf("failed to read default ai config: %v", err)
}
if !strings.Contains(string(data), "system_prompt:") {
t.Fatalf("expected default ai config to include system_prompt field, got %q", string(data))
}
if strings.Contains(string(data), "\nmodel:") || strings.HasPrefix(string(data), "model:") {
t.Fatalf("expected default ai config to omit legacy model field, got %q", string(data))
}
if !strings.Contains(string(data), "authorized security testing") {
t.Fatalf("expected default ai config to include the default system prompt, got %q", string(data))
}
}
func TestAIConfigMigratesLegacySharedModelToProviderModels(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
configPath := GetAIConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
data := []byte(`provider: "openai"
model: "gpt-test"
openai:
api_key: "openai-key"
`)
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatalf("failed to write legacy ai config: %v", err)
}
config := GetAIConfig()
if config.Model != "" {
t.Fatalf("expected legacy model field to be cleared, got %q", config.Model)
}
if config.OpenAI == nil || len(config.OpenAI.Models) != 1 || config.OpenAI.Models[0] != "gpt-test" {
t.Fatalf("expected legacy model to migrate into openai models, got %#v", config.OpenAI)
}
savedData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read migrated ai config: %v", err)
}
if strings.Contains(string(savedData), "\nmodel:") || strings.HasPrefix(string(savedData), "model:") {
t.Fatalf("expected migrated ai config to omit legacy model field, got %q", string(savedData))
}
if !strings.Contains(string(savedData), "models:") {
t.Fatalf("expected migrated ai config to include provider models, got %q", string(savedData))
}
}
func TestServerConfigMigratesEmbeddedAIConfigFromYAML(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
serverConfigPath := GetServerConfigPath()
if err := os.MkdirAll(filepath.Dir(serverConfigPath), 0700); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
data := []byte(`daemon_mode: true
daemon:
host: "127.0.0.1"
port: 4444
ai:
provider: "openai"
model: "gpt-test"
system_prompt: "Stay concise."
openai:
api_key: "openai-key"
`)
if err := os.WriteFile(serverConfigPath, data, 0600); err != nil {
t.Fatalf("failed to write yaml config: %v", err)
}
config := GetServerConfig()
if !config.DaemonMode || config.DaemonConfig == nil || config.DaemonConfig.Port != 4444 {
t.Fatalf("expected daemon settings to load, got %#v", config.DaemonConfig)
}
if config.AI == nil || config.AI.Provider != "openai" || config.AI.SystemPrompt != "Stay concise." {
t.Fatalf("expected embedded AI config to migrate, got %#v", config.AI)
}
if config.AI == nil || config.AI.Model != "" {
t.Fatalf("expected embedded legacy ai model field to be cleared, got %#v", config.AI)
}
if config.AI == nil || config.AI.OpenAI == nil || len(config.AI.OpenAI.Models) != 1 || config.AI.OpenAI.Models[0] != "gpt-test" {
t.Fatalf("expected embedded ai model to migrate into openai models, got %#v", config.AI)
}
if config.AI.OpenAI == nil || config.AI.OpenAI.APIKey != "openai-key" {
t.Fatalf("expected embedded openai api key to migrate, got %#v", config.AI.OpenAI)
}
if _, err := os.Stat(GetAIConfigPath()); err != nil {
t.Fatalf("expected migrated ai config file to exist: %v", err)
}
serverData, err := os.ReadFile(GetServerConfigPath())
if err != nil {
t.Fatalf("failed to read migrated server config: %v", err)
}
if strings.Contains(string(serverData), "\nai:") || strings.HasPrefix(string(serverData), "ai:") {
t.Fatalf("expected migrated server config to drop embedded ai block, got %q", string(serverData))
}
}
+12 -2
View File
@@ -197,7 +197,7 @@ type ServerConfig struct {
DaemonConfig *DaemonConfig `json:"daemon" yaml:"daemon"`
Logs *LogConfig `json:"logs" yaml:"logs"`
GRPC *GRPCConfig `json:"grpc" yaml:"grpc"`
AI *AIConfig `json:"ai" yaml:"ai"`
AI *AIConfig `json:"-" yaml:"-"`
Watchtower *WatchTowerConfig `json:"watch_tower" yaml:"watch_tower"`
GoProxy string `json:"go_proxy" yaml:"go_proxy"`
HTTPDefaults *HttpDefaultConfig `json:"http_default" yaml:"http_default"`
@@ -210,6 +210,13 @@ type ServerConfig struct {
// Save - Save config file to disk
func (c *ServerConfig) Save() error {
if c != nil {
c.AI = normalizeAIConfig(c.AI)
if err := c.AI.Save(); err != nil {
return err
}
}
configPath := GetServerConfigPath()
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
@@ -237,6 +244,7 @@ func GetServerConfig() *ServerConfig {
legacyPath := getServerLegacyConfigPath()
config := getDefaultServerConfig()
migratedLegacy := false
var migratedAIConfig *AIConfig
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
data, err := os.ReadFile(configPath)
if err != nil {
@@ -248,6 +256,7 @@ func GetServerConfig() *ServerConfig {
serverConfigLog.Errorf("Failed to parse config file %s", err)
return config
}
migratedAIConfig = aiConfigFromYAML(data)
} else if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
data, err := os.ReadFile(legacyPath)
if err != nil {
@@ -259,6 +268,7 @@ func GetServerConfig() *ServerConfig {
serverConfigLog.Errorf("Failed to parse legacy config file %s", err)
return config
}
migratedAIConfig = aiConfigFromJSON(data)
migratedLegacy = true
serverConfigLog.Infof("Migrating legacy config %s to %s", legacyPath, configPath)
} else {
@@ -286,7 +296,7 @@ func GetServerConfig() *ServerConfig {
defaultPermit := true
config.GRPC.Keepalive.PermitWithoutStream = &defaultPermit
}
normalizeAIConfig(config)
config.AI = getAIConfig(migratedAIConfig)
err := config.Save() // This updates the config with any missing fields
if err != nil {
+75 -29
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -25,32 +26,6 @@ logs:
grpc_unary_payloads: true
grpc_stream_payloads: true
tls_key_logger: true
ai:
provider: "openai"
model: "gpt-test"
thinking_level: "high"
max_output_tokens: 2048
temperature: 0.25
anthropic:
api_key: "anthropic-key"
base_url: "https://api.anthropic.example"
use_bedrock: true
openai:
api_key: "openai-key"
base_url: "https://api.openai.example"
organization: "org-test"
project: "proj-test"
use_responses_api: true
google:
api_key: "google-key"
project: "vertex-project"
location: "us-central1"
skip_auth: true
openai_compat:
base_url: "http://127.0.0.1:8080/v1"
openrouter:
api_key: "openrouter-key"
user_agent: "sliver-test/1.0"
watch_tower:
vt_api_key: "vt"
xforce_api_key: "xforce"
@@ -84,6 +59,38 @@ cxx:
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatalf("failed to write yaml config: %v", err)
}
aiPath := GetAIConfigPath()
aiData := []byte(`provider: "openai"
thinking_level: "high"
max_output_tokens: 2048
temperature: 0.25
anthropic:
api_key: "anthropic-key"
base_url: "https://api.anthropic.example"
use_bedrock: true
openai:
models:
- "gpt-test"
- "gpt-mini-test"
api_key: "openai-key"
base_url: "https://api.openai.example"
organization: "org-test"
project: "proj-test"
use_responses_api: true
google:
api_key: "google-key"
project: "vertex-project"
location: "us-central1"
skip_auth: true
openai_compat:
base_url: "http://127.0.0.1:8080/v1"
openrouter:
api_key: "openrouter-key"
user_agent: "sliver-test/1.0"
`)
if err := os.WriteFile(aiPath, aiData, 0600); err != nil {
t.Fatalf("failed to write ai yaml config: %v", err)
}
config := GetServerConfig()
if !config.DaemonMode {
@@ -113,8 +120,11 @@ cxx:
if config.AI == nil || config.AI.Provider != "openai" {
t.Fatalf("expected ai provider %q, got %#v", "openai", config.AI)
}
if config.AI == nil || config.AI.Model != "gpt-test" {
t.Fatalf("expected ai model %q, got %#v", "gpt-test", config.AI)
if config.AI == nil || config.AI.Model != "" {
t.Fatalf("expected legacy ai model field to remain empty, got %#v", config.AI)
}
if config.AI == nil || config.AI.OpenAI == nil || len(config.AI.OpenAI.Models) != 2 || config.AI.OpenAI.Models[0] != "gpt-test" || config.AI.OpenAI.Models[1] != "gpt-mini-test" {
t.Fatalf("expected ai openai models to load, got %#v", config.AI)
}
if config.AI == nil || config.AI.ThinkingLevel != "high" {
t.Fatalf("expected ai thinking level %q, got %#v", "high", config.AI)
@@ -205,9 +215,29 @@ func TestServerConfigWritesDefault(t *testing.T) {
if config.AI.Provider != "" || config.AI.Model != "" || config.AI.ThinkingLevel != "" {
t.Fatalf("expected empty default ai selections, got %#v", config.AI)
}
if config.AI.SystemPrompt != defaultAISystemPrompt {
t.Fatalf("expected default ai system prompt %q, got %q", defaultAISystemPrompt, config.AI.SystemPrompt)
}
if _, err := os.Stat(GetServerConfigPath()); err != nil {
t.Fatalf("expected default config file to exist: %v", err)
}
if _, err := os.Stat(GetAIConfigPath()); err != nil {
t.Fatalf("expected default ai config file to exist: %v", err)
}
aiData, err := os.ReadFile(GetAIConfigPath())
if err != nil {
t.Fatalf("failed to read default ai config: %v", err)
}
if strings.Contains(string(aiData), "\nmodel:") || strings.HasPrefix(string(aiData), "model:") {
t.Fatalf("expected default ai config to omit legacy model field, got %q", string(aiData))
}
data, err := os.ReadFile(GetServerConfigPath())
if err != nil {
t.Fatalf("failed to read default server config: %v", err)
}
if strings.Contains(string(data), "\nai:") || strings.HasPrefix(string(data), "ai:") {
t.Fatalf("expected default server config to exclude ai block, got %q", string(data))
}
}
func TestServerConfigMigratesLegacyJSON(t *testing.T) {
@@ -325,9 +355,15 @@ func TestServerConfigMigratesLegacyJSON(t *testing.T) {
if config.AI == nil || config.AI.OpenRouter == nil || config.AI.OpenRouter.APIKey != "openrouter-legacy" {
t.Fatalf("expected legacy ai openrouter config to load")
}
if config.AI == nil || config.AI.Provider != "anthropic" || config.AI.Model != "claude-test" || config.AI.ThinkingLevel != "medium" || config.AI.MaxOutputTokens != 1024 {
if config.AI == nil || config.AI.Provider != "anthropic" || config.AI.ThinkingLevel != "medium" || config.AI.MaxOutputTokens != 1024 {
t.Fatalf("expected legacy ai selections to load, got %#v", config.AI)
}
if config.AI == nil || config.AI.Model != "" {
t.Fatalf("expected legacy ai model field to be cleared, got %#v", config.AI)
}
if config.AI == nil || config.AI.Anthropic == nil || len(config.AI.Anthropic.Models) != 1 || config.AI.Anthropic.Models[0] != "claude-test" {
t.Fatalf("expected legacy model to migrate into anthropic models, got %#v", config.AI)
}
if config.Watchtower == nil || config.Watchtower.VTApiKey != "vt-legacy" {
t.Fatalf("expected legacy watch_tower to load")
}
@@ -340,12 +376,22 @@ func TestServerConfigMigratesLegacyJSON(t *testing.T) {
if _, err := os.Stat(GetServerConfigPath()); err != nil {
t.Fatalf("expected migrated yaml config file to exist: %v", err)
}
if _, err := os.Stat(GetAIConfigPath()); err != nil {
t.Fatalf("expected migrated ai yaml config file to exist: %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("expected legacy config to be renamed: %v", err)
}
if _, err := os.Stat(legacyBackupPath(legacyPath)); err != nil {
t.Fatalf("expected legacy backup file to exist: %v", err)
}
serverData, err := os.ReadFile(GetServerConfigPath())
if err != nil {
t.Fatalf("failed to read migrated server config: %v", err)
}
if strings.Contains(string(serverData), "\nai:") || strings.HasPrefix(string(serverData), "ai:") {
t.Fatalf("expected migrated server config to exclude ai block, got %q", string(serverData))
}
}
func boolPtr(value bool) *bool {
+29 -5
View File
@@ -67,11 +67,11 @@ func aiConfigCmd(_ *cobra.Command, args []string) {
return
}
fmt.Printf(Info+"Saved AI configuration to %s\n", configs.GetServerConfigPath())
fmt.Printf(Info+"Saved AI configuration to %s\n", configs.GetAIConfigPath())
fmt.Printf(
Info+"Provider=%s, model=%s, thinking=%s, api_key=%s\n",
Info+"Provider=%s, default_model=%s, thinking=%s, api_key=%s\n",
formatAIValue(serverConfig.AI.Provider, ai.ProviderOpenAI),
formatAIValue(serverConfig.AI.Model, "provider default"),
formatAIValue(aiConfigDefaultModel(serverConfig.AI, serverConfig.AI.Provider), "provider default"),
formatAIValue(serverConfig.AI.ThinkingLevel, "provider default"),
apiKeyStatus(serverConfig.AI.Provider, aiProviderConfig(serverConfig.AI, serverConfig.AI.Provider)),
)
@@ -105,8 +105,9 @@ func currentAIConfigFormResultForProvider(serverConfig *configs.ServerConfig, pr
UseResponsesAPI: provider == ai.ProviderOpenAI,
}
if serverConfig.AI != nil {
result.Model = strings.TrimSpace(serverConfig.AI.Model)
result.Models = aiConfigFormModels(serverConfig.AI, provider)
result.ThinkingLevel = strings.TrimSpace(serverConfig.AI.ThinkingLevel)
result.SystemPrompt = strings.TrimSpace(serverConfig.AI.SystemPrompt)
}
if providerConfig != nil {
result.APIKey = strings.TrimSpace(providerConfig.APIKey)
@@ -142,13 +143,15 @@ func applyAIConfigFormResult(serverConfig *configs.ServerConfig, result *forms.A
provider := ai.NormalizeProviderName(result.Provider)
serverConfig.AI.Provider = provider
serverConfig.AI.Model = strings.TrimSpace(result.Model)
serverConfig.AI.Model = ""
serverConfig.AI.ThinkingLevel = strings.ToLower(strings.TrimSpace(result.ThinkingLevel))
serverConfig.AI.SystemPrompt = strings.TrimSpace(result.SystemPrompt)
providerConfig := aiProviderConfig(serverConfig.AI, provider)
if providerConfig == nil {
return
}
providerConfig.Models = append([]string(nil), result.Models...)
providerConfig.APIKey = strings.TrimSpace(result.APIKey)
providerConfig.BaseURL = strings.TrimSpace(result.BaseURL)
providerConfig.UserAgent = strings.TrimSpace(result.UserAgent)
@@ -160,6 +163,27 @@ func applyAIConfigFormResult(serverConfig *configs.ServerConfig, result *forms.A
providerConfig.UseBedrock = result.UseBedrock
}
func aiConfigFormModels(aiConfig *configs.AIConfig, provider string) []string {
if aiConfig == nil {
return nil
}
if providerConfig := aiProviderConfig(aiConfig, provider); providerConfig != nil && len(providerConfig.Models) > 0 {
return append([]string(nil), providerConfig.Models...)
}
if legacyModel := strings.TrimSpace(aiConfig.Model); legacyModel != "" {
return []string{legacyModel}
}
return nil
}
func aiConfigDefaultModel(aiConfig *configs.AIConfig, provider string) string {
models := aiConfigFormModels(aiConfig, provider)
if len(models) == 0 {
return ""
}
return strings.TrimSpace(models[0])
}
func boolPtr(value bool) *bool {
return &value
}
+35 -6
View File
@@ -35,11 +35,12 @@ func TestCurrentAIConfigFormResultForProviderLoadsRequestedProviderSettings(t *t
serverConfig := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ai.ProviderAnthropic,
Model: "shared-model",
ThinkingLevel: "medium",
SystemPrompt: "Stay concise.",
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
Google: &configs.AIProviderConfig{},
OpenAI: &configs.AIProviderConfig{
Models: []string{"gpt-test", "gpt-test-mini"},
APIKey: "openai-key",
BaseURL: "https://api.openai.test",
Organization: "org-test",
@@ -55,8 +56,11 @@ func TestCurrentAIConfigFormResultForProviderLoadsRequestedProviderSettings(t *t
if result.Provider != ai.ProviderOpenAI {
t.Fatalf("expected %q provider, got %q", ai.ProviderOpenAI, result.Provider)
}
if result.Model != "shared-model" {
t.Fatalf("expected shared model, got %q", result.Model)
if len(result.Models) != 2 || result.Models[0] != "gpt-test" || result.Models[1] != "gpt-test-mini" {
t.Fatalf("expected provider models to load, got %#v", result.Models)
}
if result.SystemPrompt != "Stay concise." {
t.Fatalf("expected shared system prompt, got %q", result.SystemPrompt)
}
if result.APIKey != "openai-key" {
t.Fatalf("expected openai API key, got %q", result.APIKey)
@@ -75,6 +79,23 @@ func TestCurrentAIConfigFormResultForProviderLoadsRequestedProviderSettings(t *t
}
}
func TestCurrentAIConfigFormResultForProviderFallsBackToLegacySharedModel(t *testing.T) {
serverConfig := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ai.ProviderAnthropic,
Model: "legacy-model",
ThinkingLevel: "medium",
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key"},
},
}
result := currentAIConfigFormResultForProvider(serverConfig, ai.ProviderOpenAI)
if len(result.Models) != 1 || result.Models[0] != "legacy-model" {
t.Fatalf("expected legacy shared model fallback, got %#v", result.Models)
}
}
func TestCurrentAIConfigFormResultForProviderDefaultsResponsesAPIForOpenAI(t *testing.T) {
result := currentAIConfigFormResultForProvider(&configs.ServerConfig{}, ai.ProviderOpenAI)
if result.Provider != ai.ProviderOpenAI {
@@ -91,6 +112,7 @@ func TestApplyAIConfigFormResultUpdatesOnlySelectedProvider(t *testing.T) {
Provider: ai.ProviderAnthropic,
Model: "old-model",
ThinkingLevel: "low",
SystemPrompt: "Existing default",
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
Google: &configs.AIProviderConfig{},
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key", BaseURL: "https://api.openai.test"},
@@ -101,8 +123,9 @@ func TestApplyAIConfigFormResultUpdatesOnlySelectedProvider(t *testing.T) {
applyAIConfigFormResult(serverConfig, &forms.AIConfigFormResult{
Provider: ai.ProviderOpenAI,
Model: "gpt-test",
Models: []string{"gpt-test", "gpt-test-mini"},
ThinkingLevel: "high",
SystemPrompt: "Stay concise.",
APIKey: "new-openai-key",
BaseURL: "https://override.openai.test",
Organization: "org-test",
@@ -113,12 +136,18 @@ func TestApplyAIConfigFormResultUpdatesOnlySelectedProvider(t *testing.T) {
if serverConfig.AI.Provider != ai.ProviderOpenAI {
t.Fatalf("expected provider %q, got %q", ai.ProviderOpenAI, serverConfig.AI.Provider)
}
if serverConfig.AI.Model != "gpt-test" {
t.Fatalf("expected model %q, got %q", "gpt-test", serverConfig.AI.Model)
if serverConfig.AI.Model != "" {
t.Fatalf("expected legacy model field to be cleared, got %q", serverConfig.AI.Model)
}
if len(serverConfig.AI.OpenAI.Models) != 2 || serverConfig.AI.OpenAI.Models[0] != "gpt-test" || serverConfig.AI.OpenAI.Models[1] != "gpt-test-mini" {
t.Fatalf("expected models to update, got %#v", serverConfig.AI.OpenAI.Models)
}
if serverConfig.AI.ThinkingLevel != "high" {
t.Fatalf("expected thinking level %q, got %q", "high", serverConfig.AI.ThinkingLevel)
}
if serverConfig.AI.SystemPrompt != "Stay concise." {
t.Fatalf("expected system prompt %q, got %q", "Stay concise.", serverConfig.AI.SystemPrompt)
}
if serverConfig.AI.OpenAI.APIKey != "new-openai-key" {
t.Fatalf("expected updated openai api key, got %q", serverConfig.AI.OpenAI.APIKey)
}
+48 -8
View File
@@ -12,8 +12,9 @@ import (
// AIConfigFormResult captures the server-side AI configuration collected from the form.
type AIConfigFormResult struct {
Provider string
Model string
Models []string
ThinkingLevel string
SystemPrompt string
APIKey string
BaseURL string
UserAgent string
@@ -25,7 +26,7 @@ type AIConfigFormResult struct {
UseBedrock bool
}
// AIConfig prompts for the server-side AI configuration stored in server.yaml.
// AIConfig prompts for the server-side AI configuration stored in ai.yaml.
func AIConfig(result *AIConfigFormResult) error {
if result == nil {
return errors.New("AI config result is required")
@@ -57,7 +58,7 @@ func SelectAIProvider(result *AIConfigFormResult) error {
huh.NewGroup(
huh.NewSelect[string]().
Title("AI provider").
Description("Choose the default AI provider stored in server.yaml.").
Description("Choose the default AI provider stored in ai.yaml.").
Options(providerOptions...).
Height(listHeight(len(providerOptions))).
Value(&result.Provider).
@@ -92,6 +93,7 @@ func EditAIConfig(result *AIConfigFormResult) error {
huh.NewOption("Medium", "medium"),
huh.NewOption("High", "high"),
}
modelsValue := formatAIModelList(result.Models)
detailsForm := newConsoleForm(
huh.NewGroup(
@@ -104,16 +106,21 @@ func EditAIConfig(result *AIConfigFormResult) error {
)
}, &result.Provider),
huh.NewInput().
Title("Model").
Description("Optional default model identifier. Leave blank to let the provider choose.").
Title("Models").
Description("Optional ordered model identifiers. Separate entries with commas. The first entry is the default.").
Placeholder("provider default").
Value(&result.Model),
Value(&modelsValue),
huh.NewSelect[string]().
Title("Thinking level").
Description("Optional reasoning/thinking level to store in the ai block.").
Description("Optional reasoning/thinking level to store in ai.yaml.").
Options(thinkingOptions...).
Height(listHeight(len(thinkingOptions))).
Value(&result.ThinkingLevel),
huh.NewInput().
Title("System prompt").
Description("Optional default system prompt for new AI conversations.").
Placeholder("leave blank for no default system prompt").
Value(&result.SystemPrompt),
huh.NewInput().
TitleFunc(func() string {
return fmt.Sprintf("%s API key", providerDisplayName(result.Provider))
@@ -144,6 +151,7 @@ func EditAIConfig(result *AIConfigFormResult) error {
if err := runConsoleForm(detailsForm); err != nil {
return err
}
result.Models = parseAIModelList(modelsValue)
if err := providerSpecificAIConfig(result); err != nil {
return err
@@ -244,8 +252,9 @@ func normalizeAIConfigResult(result *AIConfigFormResult) {
if !ai.IsSupportedProvider(result.Provider) {
result.Provider = ai.ProviderOpenAI
}
result.Model = strings.TrimSpace(result.Model)
result.Models = parseAIModelList(formatAIModelList(result.Models))
result.ThinkingLevel = strings.ToLower(strings.TrimSpace(result.ThinkingLevel))
result.SystemPrompt = strings.TrimSpace(result.SystemPrompt)
switch result.ThinkingLevel {
case "", "disabled", "low", "medium", "high":
default:
@@ -279,6 +288,37 @@ func normalizeAIConfigResult(result *AIConfigFormResult) {
}
}
func formatAIModelList(models []string) string {
return strings.Join(parseAIModelList(strings.Join(models, ",")), ", ")
}
func parseAIModelList(value string) []string {
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return nil
}
models := make([]string, 0, len(fields))
seen := make(map[string]struct{}, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
if _, exists := seen[field]; exists {
continue
}
seen[field] = struct{}{}
models = append(models, field)
}
if len(models) == 0 {
return nil
}
return models
}
func providerDisplayName(provider string) string {
switch ai.NormalizeProviderName(provider) {
case ai.ProviderAnthropic:
+62 -1
View File
@@ -1407,7 +1407,59 @@ func AIConversationByID(id string, operatorName string, includeMessages bool) (*
if err != nil {
return nil, err
}
return conversation.ToProtobuf(), nil
pbConversation := conversation.ToProtobuf()
if includeMessages {
pbConversation = aiConversationWithSystemPromptMessage(pbConversation)
}
return pbConversation, nil
}
func aiConversationWithSystemPromptMessage(conversation *clientpb.AIConversation) *clientpb.AIConversation {
if conversation == nil {
return nil
}
systemPrompt := strings.TrimSpace(conversation.GetSystemPrompt())
if systemPrompt == "" {
return conversation
}
for _, message := range conversation.GetMessages() {
if message == nil {
continue
}
if message.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
continue
}
if strings.EqualFold(strings.TrimSpace(message.GetRole()), "system") &&
strings.TrimSpace(message.GetContent()) == systemPrompt {
return conversation
}
}
includeInContext := true
systemMessageID := "system-prompt"
if conversationID := strings.TrimSpace(conversation.GetID()); conversationID != "" {
systemMessageID = conversationID + "-system-prompt"
}
systemMessage := &clientpb.AIConversationMessage{
ID: systemMessageID,
ConversationID: strings.TrimSpace(conversation.GetID()),
CreatedAt: conversation.GetCreatedAt(),
UpdatedAt: conversation.GetUpdatedAt(),
OperatorName: strings.TrimSpace(conversation.GetOperatorName()),
Provider: strings.TrimSpace(conversation.GetProvider()),
Model: strings.TrimSpace(conversation.GetModel()),
Sequence: 0,
Role: "system",
Content: systemPrompt,
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
IncludeInContext: &includeInContext,
}
conversation.Messages = append([]*clientpb.AIConversationMessage{systemMessage}, conversation.GetMessages()...)
return conversation
}
// SaveAIConversation - Create or update an AI conversation thread.
@@ -1435,6 +1487,7 @@ func SaveAIConversation(conversation *clientpb.AIConversation, operatorName stri
}
existing.Provider = dbConversation.Provider
existing.Model = dbConversation.Model
existing.ThinkingLevel = dbConversation.ThinkingLevel
existing.Title = dbConversation.Title
existing.Summary = dbConversation.Summary
existing.SystemPrompt = dbConversation.SystemPrompt
@@ -1442,6 +1495,11 @@ func SaveAIConversation(conversation *clientpb.AIConversation, operatorName stri
existing.TurnState = dbConversation.TurnState
existing.TargetSessionID = dbConversation.TargetSessionID
existing.TargetBeaconID = dbConversation.TargetBeaconID
existing.ContextInputTokens = dbConversation.ContextInputTokens
existing.ContextOutputTokens = dbConversation.ContextOutputTokens
existing.ContextTotalTokens = dbConversation.ContextTotalTokens
existing.ContextWindowTokens = dbConversation.ContextWindowTokens
existing.ContextWindowTokensEstimated = dbConversation.ContextWindowTokensEstimated
if err := dbSession.Save(existing).Error; err != nil {
return nil, err
@@ -1582,6 +1640,9 @@ func SaveAIConversationMessage(message *clientpb.AIConversationMessage, operator
existing.ToolArguments = dbMessage.ToolArguments
existing.ToolResult = dbMessage.ToolResult
existing.ErrorText = dbMessage.ErrorText
if dbMessage.IncludeInContext != nil {
existing.IncludeInContext = dbMessage.IncludeInContext
}
if err := tx.Save(existing).Error; err != nil {
return err
+76 -24
View File
@@ -28,19 +28,25 @@ import (
// AIConversation - A server-side AI conversation thread.
type AIConversation struct {
ID uuid.UUID `gorm:"primaryKey;->;<-:create;type:uuid;"`
CreatedAt time.Time `gorm:"->;<-:create;"`
UpdatedAt time.Time
OperatorName string `gorm:"index"`
Provider string `gorm:"index"`
Model string
Title string
Summary string
SystemPrompt string `gorm:"type:text;"`
ActiveTurnID string `gorm:"index"`
TurnState int32
TargetSessionID string `gorm:"index"`
TargetBeaconID string `gorm:"index"`
ID uuid.UUID `gorm:"primaryKey;->;<-:create;type:uuid;"`
CreatedAt time.Time `gorm:"->;<-:create;"`
UpdatedAt time.Time
OperatorName string `gorm:"index"`
Provider string `gorm:"index"`
Model string
ThinkingLevel string
Title string
Summary string
SystemPrompt string `gorm:"type:text;"`
ActiveTurnID string `gorm:"index"`
TurnState int32
TargetSessionID string `gorm:"index"`
TargetBeaconID string `gorm:"index"`
ContextInputTokens int64
ContextOutputTokens int64
ContextTotalTokens int64
ContextWindowTokens int64
ContextWindowTokensEstimated bool
Messages []AIConversationMessage `gorm:"foreignKey:ConversationID;constraint:OnDelete:CASCADE;"`
}
@@ -71,6 +77,7 @@ func (a *AIConversation) ToProtobuf() *clientpb.AIConversation {
OperatorName: a.OperatorName,
Provider: a.Provider,
Model: a.Model,
ThinkingLevel: a.ThinkingLevel,
Title: a.Title,
Summary: a.Summary,
SystemPrompt: a.SystemPrompt,
@@ -79,6 +86,13 @@ func (a *AIConversation) ToProtobuf() *clientpb.AIConversation {
TurnState: clientpb.AIConversationTurnState(a.TurnState),
TargetSessionID: a.TargetSessionID,
TargetBeaconID: a.TargetBeaconID,
ContextWindowUsage: aiContextWindowUsageToProtobuf(
a.ContextInputTokens,
a.ContextOutputTokens,
a.ContextTotalTokens,
a.ContextWindowTokens,
a.ContextWindowTokensEstimated,
),
}
}
@@ -91,17 +105,36 @@ func AIConversationFromProtobuf(pbConversation *clientpb.AIConversation) *AIConv
id, _ := uuid.FromString(pbConversation.ID)
return &AIConversation{
ID: id,
OperatorName: pbConversation.OperatorName,
Provider: pbConversation.Provider,
Model: pbConversation.Model,
Title: pbConversation.Title,
Summary: pbConversation.Summary,
SystemPrompt: pbConversation.SystemPrompt,
ActiveTurnID: pbConversation.ActiveTurnID,
TurnState: int32(pbConversation.TurnState),
TargetSessionID: pbConversation.TargetSessionID,
TargetBeaconID: pbConversation.TargetBeaconID,
ID: id,
OperatorName: pbConversation.OperatorName,
Provider: pbConversation.Provider,
Model: pbConversation.Model,
ThinkingLevel: pbConversation.ThinkingLevel,
Title: pbConversation.Title,
Summary: pbConversation.Summary,
SystemPrompt: pbConversation.SystemPrompt,
ActiveTurnID: pbConversation.ActiveTurnID,
TurnState: int32(pbConversation.TurnState),
TargetSessionID: pbConversation.TargetSessionID,
TargetBeaconID: pbConversation.TargetBeaconID,
ContextInputTokens: pbConversation.GetContextWindowUsage().GetInputTokens(),
ContextOutputTokens: pbConversation.GetContextWindowUsage().GetOutputTokens(),
ContextTotalTokens: pbConversation.GetContextWindowUsage().GetTotalTokens(),
ContextWindowTokens: pbConversation.GetContextWindowUsage().GetContextWindowTokens(),
ContextWindowTokensEstimated: pbConversation.GetContextWindowUsage().GetContextWindowTokensEstimated(),
}
}
func aiContextWindowUsageToProtobuf(inputTokens, outputTokens, totalTokens, windowTokens int64, estimated bool) *clientpb.AIContextWindowUsage {
if inputTokens == 0 && outputTokens == 0 && totalTokens == 0 && windowTokens == 0 && !estimated {
return nil
}
return &clientpb.AIContextWindowUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: totalTokens,
ContextWindowTokens: windowTokens,
ContextWindowTokensEstimated: estimated,
}
}
@@ -129,6 +162,7 @@ type AIConversationMessage struct {
ToolArguments string `gorm:"type:text;"`
ToolResult string `gorm:"type:text;"`
ErrorText string `gorm:"type:text;"`
IncludeInContext *bool
}
// BeforeCreate - GORM hook.
@@ -168,6 +202,7 @@ func (a *AIConversationMessage) ToProtobuf() *clientpb.AIConversationMessage {
ToolArguments: a.ToolArguments,
ToolResult: a.ToolResult,
ErrorText: a.ErrorText,
IncludeInContext: resolveAIConversationMessageIncludeInContext(a.IncludeInContext, a.Visibility),
}
}
@@ -201,5 +236,22 @@ func AIConversationMessageFromProtobuf(pbMessage *clientpb.AIConversationMessage
ToolArguments: pbMessage.ToolArguments,
ToolResult: pbMessage.ToolResult,
ErrorText: pbMessage.ErrorText,
IncludeInContext: cloneOptionalBool(pbMessage.IncludeInContext),
}
}
func resolveAIConversationMessageIncludeInContext(value *bool, visibility int32) *bool {
if value != nil {
return cloneOptionalBool(value)
}
include := clientpb.AIConversationMessageVisibility(visibility) == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT
return &include
}
func cloneOptionalBool(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
+42 -19
View File
@@ -143,36 +143,59 @@ func pivotPeerFailureHandler(implantConn *core.ImplantConnection, data []byte) *
}
pivotLog.Errorf("pivot peer failure received: %v", peerFailure)
reportingSession := core.Sessions.FromImplantConnection(implantConn)
if reportingSession == nil {
pivotLog.Warn("ignoring pivot peer failure from unknown session")
return nil
}
targetSessionIDs := map[string]struct{}{}
targetPivotIDs := map[string]struct{}{}
core.PivotSessions.Range(func(key, value interface{}) bool {
pivot := value.(*core.Pivot)
found := pivot.OriginID == peerFailure.PeerID
if !found {
pivotLog.Warnf("Filed peer not found by OriginID, searching by Peers instead")
for _, peer := range pivot.Peers {
if peer.PeerID == peerFailure.PeerID {
pivotLog.Warnf("Found session with needed peer!")
found = true
}
}
reporterIdx := pivotPeerIndex(pivot.Peers, reportingSession.PeerID)
if reporterIdx <= 0 {
return true
}
if pivot.Peers[reporterIdx-1].PeerID != peerFailure.PeerID {
return true
}
if found {
session := core.Sessions.FromImplantConnection(pivot.ImplantConn)
targetPivotIDs[pivot.ID] = struct{}{}
if pivot.OriginID != peerFailure.PeerID {
return true
}
if session != nil {
core.Sessions.Remove(session.ID)
}
defer core.PivotSessions.Delete(pivot.ID)
return false
session := core.Sessions.FromImplantConnection(pivot.ImplantConn)
if session != nil {
targetSessionIDs[session.ID] = struct{}{}
}
return true
})
if len(targetSessionIDs) == 0 {
pivotLog.Warnf("ignoring unauthorized pivot peer failure from %d for %d", reportingSession.PeerID, peerFailure.PeerID)
return nil
}
for sessionID := range targetSessionIDs {
core.Sessions.Remove(sessionID)
}
for pivotID := range targetPivotIDs {
core.PivotSessions.Delete(pivotID)
}
return nil
}
func pivotPeerIndex(peers []*sliverpb.PivotPeer, peerID int64) int {
for idx, peer := range peers {
if peer.PeerID == peerID {
return idx
}
}
return -1
}
func pivotServerPingHandler(pivot *core.Pivot) {
pivot.ImplantConn.UpdateLastMessage()
pivot.ImmediateImplantConn.UpdateLastMessage()
+145
View File
@@ -0,0 +1,145 @@
package handlers
import (
"fmt"
"testing"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/server/core"
"google.golang.org/protobuf/proto"
)
func TestPivotPeerFailureHandlerRemovesAuthorizedImmediateChild(t *testing.T) {
resetPivotFailureTestState(t)
reporterConn, reporterSession := addPivotFailureTestSession(t, 300, "wg")
_, unrelatedSession := addPivotFailureTestSession(t, 900, "wg")
childSession, childPivot := addPivotFailureTestPivotSession(t, 200, 200, 300)
grandchildSession, grandchildPivot := addPivotFailureTestPivotSession(t, 100, 100, 200, 300)
unrelatedChildSession, unrelatedChildPivot := addPivotFailureTestPivotSession(t, 800, 800, 900)
pivotPeerFailureHandler(reporterConn, mustMarshalPivotPeerFailure(t, 200))
if got := core.Sessions.Get(reporterSession.ID); got == nil {
t.Fatalf("reporting session was removed")
}
if got := core.Sessions.Get(childSession.ID); got != nil {
t.Fatalf("child session still present after authorized failure")
}
if got := core.Sessions.Get(grandchildSession.ID); got != nil {
t.Fatalf("grandchild session still present after authorized failure")
}
if got := core.Sessions.Get(unrelatedSession.ID); got == nil {
t.Fatalf("unrelated direct session was removed")
}
if got := core.Sessions.Get(unrelatedChildSession.ID); got == nil {
t.Fatalf("unrelated pivot session was removed")
}
if pivotSessionExists(childPivot.ID) {
t.Fatalf("child pivot session still present after authorized failure")
}
if pivotSessionExists(grandchildPivot.ID) {
t.Fatalf("grandchild pivot session still present after authorized failure")
}
if !pivotSessionExists(unrelatedChildPivot.ID) {
t.Fatalf("unrelated pivot session was removed")
}
}
func TestPivotPeerFailureHandlerRejectsNonImmediateChild(t *testing.T) {
resetPivotFailureTestState(t)
reporterConn, _ := addPivotFailureTestSession(t, 300, "wg")
childSession, childPivot := addPivotFailureTestPivotSession(t, 200, 200, 300)
grandchildSession, grandchildPivot := addPivotFailureTestPivotSession(t, 100, 100, 200, 300)
pivotPeerFailureHandler(reporterConn, mustMarshalPivotPeerFailure(t, 100))
if got := core.Sessions.Get(childSession.ID); got == nil {
t.Fatalf("child session was removed by non-immediate failure report")
}
if got := core.Sessions.Get(grandchildSession.ID); got == nil {
t.Fatalf("grandchild session was removed by non-immediate failure report")
}
if !pivotSessionExists(childPivot.ID) {
t.Fatalf("child pivot session was removed by non-immediate failure report")
}
if !pivotSessionExists(grandchildPivot.ID) {
t.Fatalf("grandchild pivot session was removed by non-immediate failure report")
}
}
func addPivotFailureTestSession(t *testing.T, peerID int64, transport string) (*core.ImplantConnection, *core.Session) {
t.Helper()
conn := core.NewImplantConnection(transport, fmt.Sprintf("peer-%d", peerID))
session := core.NewSession(conn)
session.Name = fmt.Sprintf("peer-%d", peerID)
session.PeerID = peerID
core.Sessions.Add(session)
return conn, session
}
func addPivotFailureTestPivotSession(t *testing.T, originID int64, chain ...int64) (*core.Session, *core.Pivot) {
t.Helper()
peers := make([]*sliverpb.PivotPeer, 0, len(chain))
for _, peerID := range chain {
peers = append(peers, &sliverpb.PivotPeer{
PeerID: peerID,
Name: fmt.Sprintf("peer-%d", peerID),
})
}
pivot := core.NewPivotSession(peers)
pivot.OriginID = originID
pivot.ImplantConn = core.NewImplantConnection(core.PivotTransportName, fmt.Sprintf("pivot-%d", originID))
core.PivotSessions.Store(pivot.ID, pivot)
session := core.NewSession(pivot.ImplantConn)
session.Name = fmt.Sprintf("peer-%d", originID)
session.PeerID = originID
core.Sessions.Add(session)
return session, pivot
}
func mustMarshalPivotPeerFailure(t *testing.T, peerID int64) []byte {
t.Helper()
data, err := proto.Marshal(&sliverpb.PivotPeerFailure{
PeerID: peerID,
Type: sliverpb.PeerFailureType_SEND_FAILURE,
})
if err != nil {
t.Fatalf("marshal peer failure: %v", err)
}
return data
}
func pivotSessionExists(pivotID string) bool {
_, ok := core.PivotSessions.Load(pivotID)
return ok
}
func resetPivotFailureTestState(t *testing.T) {
t.Helper()
for _, session := range core.Sessions.All() {
core.Sessions.Remove(session.ID)
}
core.PivotSessions.Range(func(key, value interface{}) bool {
core.PivotSessions.Delete(key)
return true
})
t.Cleanup(func() {
for _, session := range core.Sessions.All() {
core.Sessions.Remove(session.ID)
}
core.PivotSessions.Range(func(key, value interface{}) bool {
core.PivotSessions.Delete(key)
return true
})
})
}
+3 -3
View File
@@ -169,10 +169,10 @@ func tunnelCloseHandler(implantConn *core.ImplantConnection, data []byte) *slive
}
} else {
rtunnel := rtunnels.GetRTunnel(tunnelData.TunnelID)
if rtunnel != nil && session.ID == tunnel.SessionID {
if rtunnel != nil && session.ID == rtunnel.SessionID {
rtunnel.Close()
rtunnels.RemoveRTunnel(rtunnel.ID)
} else if rtunnel != nil && session.ID != tunnel.SessionID {
} else if rtunnel != nil && session.ID != rtunnel.SessionID {
sessionHandlerLog.Warnf("Warning: Session %s attempted to send data on reverse tunnel it did not own", session.ID)
} else {
sessionHandlerLog.Warnf("Close sent on nil tunnel %d", tunnelData.TunnelID)
@@ -206,7 +206,7 @@ func socksDataHandler(implantConn *core.ImplantConnection, data []byte) *sliverp
// core.SocksTunnels.Close(socksData.TunnelID)
// return nil
//}
sessionHandlerLog.Debugf("socksDataHandler:", len(socksData.Data), socksData.Data)
sessionHandlerLog.Debugf("socksDataHandler: %d bytes: %v", len(socksData.Data), socksData.Data)
socksTunnel := core.SocksTunnels.Get(socksData.TunnelID)
if socksTunnel != nil {
if session.ID == socksTunnel.SessionID {
+115
View File
@@ -0,0 +1,115 @@
package handlers
import (
"testing"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/core/rtunnels"
"google.golang.org/protobuf/proto"
)
type testWriteCloser struct {
closed bool
}
func (t *testWriteCloser) Write(data []byte) (int, error) {
return len(data), nil
}
func (t *testWriteCloser) Close() error {
t.closed = true
return nil
}
func addTestSession(t *testing.T) (*core.ImplantConnection, *core.Session) {
t.Helper()
conn := core.NewImplantConnection("test", "n/a")
session := core.NewSession(conn)
core.Sessions.Add(session)
t.Cleanup(func() {
core.Sessions.Remove(session.ID)
})
return conn, session
}
func marshalTunnelCloseData(t *testing.T, tunnelID uint64) []byte {
t.Helper()
data, err := proto.Marshal(&sliverpb.TunnelData{
TunnelID: tunnelID,
Closed: true,
})
if err != nil {
t.Fatalf("marshal tunnel close data: %v", err)
}
return data
}
func assertNoPanic(t *testing.T, fn func()) {
t.Helper()
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("unexpected panic: %v", recovered)
}
}()
fn()
}
func TestTunnelCloseHandlerClosesOwnedReverseTunnel(t *testing.T) {
conn, session := addTestSession(t)
tunnelID := core.NewTunnelID()
writer := &testWriteCloser{}
rtunnels.AddRTunnel(rtunnels.NewRTunnel(tunnelID, session.ID, writer))
t.Cleanup(func() {
rtunnels.RemoveRTunnel(tunnelID)
})
assertNoPanic(t, func() {
tunnelCloseHandler(conn, marshalTunnelCloseData(t, tunnelID))
})
if rtunnel := rtunnels.GetRTunnel(tunnelID); rtunnel != nil {
t.Fatalf("expected reverse tunnel %d to be removed", tunnelID)
}
if !writer.closed {
t.Fatal("expected reverse tunnel writer to be closed")
}
}
func TestTunnelCloseHandlerKeepsUnownedReverseTunnel(t *testing.T) {
ownerConn, ownerSession := addTestSession(t)
_ = ownerConn
attackerConn, attackerSession := addTestSession(t)
tunnelID := core.NewTunnelID()
writer := &testWriteCloser{}
rtunnels.AddRTunnel(rtunnels.NewRTunnel(tunnelID, ownerSession.ID, writer))
t.Cleanup(func() {
rtunnels.RemoveRTunnel(tunnelID)
})
assertNoPanic(t, func() {
tunnelCloseHandler(attackerConn, marshalTunnelCloseData(t, tunnelID))
})
rtunnel := rtunnels.GetRTunnel(tunnelID)
if rtunnel == nil {
t.Fatalf("expected reverse tunnel %d to remain for owner %s", tunnelID, ownerSession.ID)
}
if rtunnel.SessionID != ownerSession.ID {
t.Fatalf("expected reverse tunnel owner %s, got %s", ownerSession.ID, rtunnel.SessionID)
}
if attackerSession.ID == ownerSession.ID {
t.Fatal("expected distinct owner and attacker sessions")
}
if writer.closed {
t.Fatal("expected unowned reverse tunnel writer to remain open")
}
}
+361
View File
@@ -0,0 +1,361 @@
package aitools
import (
"context"
stdbase32 "encoding/base32"
stdbase64 "encoding/base64"
"fmt"
"strings"
"unicode/utf8"
serverai "github.com/bishopfox/sliver/server/ai"
utilencoders "github.com/bishopfox/sliver/util/encoders"
)
const aiEncodingDefaultMaxBytes = 64 * 1024
type encodingInputArgs struct {
Text *string `json:"text,omitempty"`
DataBase64 *string `json:"data_base64,omitempty"`
}
type encodedValueArgs struct {
Value *string `json:"value,omitempty"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
}
type gzipDecodeArgs struct {
DataBase64 *string `json:"data_base64,omitempty"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
}
type encodingValueResult struct {
Encoding string `json:"encoding"`
Operation string `json:"operation"`
Variant string `json:"variant,omitempty"`
Value string `json:"value"`
ByteLen int `json:"byte_len"`
}
type encodingBytesResult struct {
Encoding string `json:"encoding"`
Operation string `json:"operation"`
Variant string `json:"variant,omitempty"`
ByteLen int `json:"byte_len"`
SHA256 string `json:"sha256,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
Text string `json:"text,omitempty"`
}
type standardBase32Encoder struct{}
type standardBase64Encoder struct{}
func encodingToolDefinitions() []serverai.AgenticToolDefinition {
return []serverai.AgenticToolDefinition{
{
Name: "base32_encode",
Description: "Encode bytes using standard RFC 4648 base32 with padding. Provide either text or data_base64 input.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"data_base64": map[string]any{"type": "string", "description": "Optional standard base64 wrapper for arbitrary input bytes."},
"text": map[string]any{"type": "string", "description": "Optional UTF-8 text to encode."},
},
"additionalProperties": false,
},
},
{
Name: "base32_decode",
Description: "Decode standard RFC 4648 base32. Returns the decoded bytes as standard data_base64 plus UTF-8 text when valid.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"max_bytes": map[string]any{"type": "integer", "description": "Optional maximum decoded byte length. Defaults to 65536."},
"value": map[string]any{"type": "string", "description": "Base32 value to decode."},
},
"required": []string{"value"},
"additionalProperties": false,
},
},
{
Name: "base64_encode",
Description: "Encode bytes using standard RFC 4648 base64 with padding. Provide either text or data_base64 input.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"data_base64": map[string]any{"type": "string", "description": "Optional standard base64 wrapper for arbitrary input bytes."},
"text": map[string]any{"type": "string", "description": "Optional UTF-8 text to encode."},
},
"additionalProperties": false,
},
},
{
Name: "base64_decode",
Description: "Decode standard RFC 4648 base64. Returns the decoded bytes as standard data_base64 plus UTF-8 text when valid.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"max_bytes": map[string]any{"type": "integer", "description": "Optional maximum decoded byte length. Defaults to 65536."},
"value": map[string]any{"type": "string", "description": "Base64 value to decode."},
},
"required": []string{"value"},
"additionalProperties": false,
},
},
{
Name: "hex_encode",
Description: "Encode bytes as lowercase hexadecimal. Provide either text or data_base64 input.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"data_base64": map[string]any{"type": "string", "description": "Optional standard base64 wrapper for arbitrary input bytes."},
"text": map[string]any{"type": "string", "description": "Optional UTF-8 text to encode."},
},
"additionalProperties": false,
},
},
{
Name: "hex_decode",
Description: "Decode a hexadecimal string. Returns the decoded bytes as standard data_base64 plus UTF-8 text when valid.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"max_bytes": map[string]any{"type": "integer", "description": "Optional maximum decoded byte length. Defaults to 65536."},
"value": map[string]any{"type": "string", "description": "Hexadecimal value to decode."},
},
"required": []string{"value"},
"additionalProperties": false,
},
},
{
Name: "gzip_encode",
Description: "Compress bytes with gzip. Provide either text or data_base64 input. Returns the compressed bytes as standard data_base64.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"data_base64": map[string]any{"type": "string", "description": "Optional standard base64 wrapper for arbitrary input bytes."},
"text": map[string]any{"type": "string", "description": "Optional UTF-8 text to compress."},
},
"additionalProperties": false,
},
},
{
Name: "gzip_decode",
Description: "Decompress gzip bytes supplied as standard data_base64. Returns the decoded bytes as standard data_base64 plus UTF-8 text when valid.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"data_base64": map[string]any{"type": "string", "description": "Standard base64 wrapper for gzip-compressed bytes."},
"max_bytes": map[string]any{"type": "integer", "description": "Optional maximum decoded byte length. Defaults to 65536."},
},
"required": []string{"data_base64"},
"additionalProperties": false,
},
},
}
}
func (e *executor) callEncodingTool(_ context.Context, name string, arguments string) (string, bool, error) {
switch strings.TrimSpace(name) {
case "base32_encode":
var args encodingInputArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := callEncodeValueTool("base32", "standard", args, standardBase32Encoder{})
return result, true, err
case "base32_decode":
var args encodedValueArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := callDecodeValueTool("base32", "standard", args, standardBase32Encoder{})
return result, true, err
case "base64_encode":
var args encodingInputArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := callEncodeValueTool("base64", "standard", args, standardBase64Encoder{})
return result, true, err
case "base64_decode":
var args encodedValueArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := callDecodeValueTool("base64", "standard", args, standardBase64Encoder{})
return result, true, err
case "hex_encode":
var args encodingInputArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := callEncodeValueTool("hex", "", args, utilencoders.Hex{})
return result, true, err
case "hex_decode":
var args encodedValueArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := callDecodeValueTool("hex", "", args, utilencoders.Hex{})
return result, true, err
case "gzip_encode":
var args encodingInputArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
input, err := resolveEncodingInput(args)
if err != nil {
return "", true, err
}
output, err := new(utilencoders.Gzip).Encode(input)
if err != nil {
return "", true, err
}
result, err := marshalToolResult(newEncodingBytesResult("gzip", "encode", "", output))
return result, true, err
case "gzip_decode":
var args gzipDecodeArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
data, err := requireStandardBase64(args.DataBase64, "data_base64")
if err != nil {
return "", true, err
}
output, err := new(utilencoders.Gzip).DecodeWithMaxLen(data, resolveMaxBytes(args.MaxBytes))
if err != nil {
return "", true, err
}
result, err := marshalToolResult(newEncodingBytesResult("gzip", "decode", "", output))
return result, true, err
default:
return "", false, nil
}
}
func callEncodeValueTool(name string, variant string, args encodingInputArgs, encoder utilencoders.Encoder) (string, error) {
input, err := resolveEncodingInput(args)
if err != nil {
return "", err
}
output, err := encoder.Encode(input)
if err != nil {
return "", err
}
return marshalToolResult(encodingValueResult{
Encoding: name,
Operation: "encode",
Variant: variant,
Value: string(output),
ByteLen: len(output),
})
}
func (standardBase32Encoder) Encode(data []byte) ([]byte, error) {
return []byte(stdbase32.StdEncoding.EncodeToString(data)), nil
}
func (standardBase32Encoder) Decode(data []byte) ([]byte, error) {
value := strings.TrimSpace(string(data))
decoded, err := stdbase32.StdEncoding.DecodeString(value)
if err == nil {
return decoded, nil
}
return stdbase32.StdEncoding.WithPadding(stdbase32.NoPadding).DecodeString(value)
}
func (standardBase64Encoder) Encode(data []byte) ([]byte, error) {
return []byte(stdbase64.StdEncoding.EncodeToString(data)), nil
}
func (standardBase64Encoder) Decode(data []byte) ([]byte, error) {
return decodeStandardBase64(strings.TrimSpace(string(data)))
}
func callDecodeValueTool(name string, variant string, args encodedValueArgs, encoder utilencoders.Encoder) (string, error) {
value, err := requireString(args.Value, "value")
if err != nil {
return "", err
}
output, err := encoder.Decode([]byte(value))
if err != nil {
return "", err
}
maxBytes := resolveMaxBytes(args.MaxBytes)
if int64(len(output)) > maxBytes {
return "", fmt.Errorf("%s decoded payload exceeds %d bytes", name, maxBytes)
}
return marshalToolResult(newEncodingBytesResult(name, "decode", variant, output))
}
func resolveEncodingInput(args encodingInputArgs) ([]byte, error) {
hasText := args.Text != nil
hasData := args.DataBase64 != nil
switch {
case hasText && hasData:
return nil, fmt.Errorf("provide only one of text or data_base64")
case hasText:
return []byte(*args.Text), nil
case hasData:
return decodeStandardBase64(*args.DataBase64)
default:
return nil, fmt.Errorf("text or data_base64 is required")
}
}
func requireString(value *string, field string) (string, error) {
if value == nil {
return "", fmt.Errorf("%s is required", field)
}
return *value, nil
}
func requireStandardBase64(value *string, field string) ([]byte, error) {
if value == nil {
return nil, fmt.Errorf("%s is required", field)
}
data, err := decodeStandardBase64(*value)
if err != nil {
return nil, fmt.Errorf("invalid %s: %w", field, err)
}
return data, nil
}
func decodeStandardBase64(value string) ([]byte, error) {
value = strings.TrimSpace(value)
data, err := stdbase64.StdEncoding.DecodeString(value)
if err == nil {
return data, nil
}
return stdbase64.RawStdEncoding.DecodeString(value)
}
func resolveMaxBytes(maxBytes *int64) int64 {
if maxBytes != nil && *maxBytes > 0 {
return *maxBytes
}
return aiEncodingDefaultMaxBytes
}
func newEncodingBytesResult(name string, operation string, variant string, data []byte) encodingBytesResult {
result := encodingBytesResult{
Encoding: name,
Operation: operation,
Variant: variant,
ByteLen: len(data),
DataBase64: stdbase64.StdEncoding.EncodeToString(data),
}
if len(data) > 0 {
result.SHA256 = sha256Hex(data)
if utf8.Valid(data) {
result.Text = string(data)
}
}
return result
}
+119
View File
@@ -0,0 +1,119 @@
package aitools
import (
"context"
stdbase32 "encoding/base32"
stdbase64 "encoding/base64"
stdhex "encoding/hex"
"encoding/json"
"testing"
)
func TestValueEncodingToolsRoundTrip(t *testing.T) {
exec := &executor{}
ctx := context.Background()
cases := []struct {
encodeTool string
decodeTool string
expected string
variant string
}{
{encodeTool: "base32_encode", decodeTool: "base32_decode", expected: stdbase32.StdEncoding.EncodeToString([]byte("hello world")), variant: "standard"},
{encodeTool: "base64_encode", decodeTool: "base64_decode", expected: stdbase64.StdEncoding.EncodeToString([]byte("hello world")), variant: "standard"},
{encodeTool: "hex_encode", decodeTool: "hex_decode", expected: stdhex.EncodeToString([]byte("hello world")), variant: ""},
}
for _, tc := range cases {
t.Run(tc.encodeTool, func(t *testing.T) {
encodedRaw, err := exec.CallTool(ctx, tc.encodeTool, `{"text":"hello world"}`)
if err != nil {
t.Fatalf("encode with %s: %v", tc.encodeTool, err)
}
var encoded encodingValueResult
if err := json.Unmarshal([]byte(encodedRaw), &encoded); err != nil {
t.Fatalf("decode encode result: %v", err)
}
if encoded.Value == "" {
t.Fatalf("expected encoded value from %s", tc.encodeTool)
}
if encoded.Value != tc.expected {
t.Fatalf("unexpected encoded value: got=%q want=%q", encoded.Value, tc.expected)
}
if encoded.Variant != tc.variant {
t.Fatalf("unexpected variant: got=%q want=%q", encoded.Variant, tc.variant)
}
decodedRaw, err := exec.CallTool(ctx, tc.decodeTool, `{"value":"`+encoded.Value+`"}`)
if err != nil {
t.Fatalf("decode with %s: %v", tc.decodeTool, err)
}
var decoded encodingBytesResult
if err := json.Unmarshal([]byte(decodedRaw), &decoded); err != nil {
t.Fatalf("decode decode result: %v", err)
}
if decoded.Text != "hello world" {
t.Fatalf("unexpected decoded text: %+v", decoded)
}
if decoded.Variant != tc.variant {
t.Fatalf("unexpected decoded variant: got=%q want=%q", decoded.Variant, tc.variant)
}
})
}
}
func TestGzipEncodingToolsRoundTrip(t *testing.T) {
exec := &executor{}
ctx := context.Background()
encodedRaw, err := exec.CallTool(ctx, "gzip_encode", `{"text":"hello world"}`)
if err != nil {
t.Fatalf("gzip encode: %v", err)
}
var encoded encodingBytesResult
if err := json.Unmarshal([]byte(encodedRaw), &encoded); err != nil {
t.Fatalf("decode gzip encode result: %v", err)
}
if encoded.DataBase64 == "" {
t.Fatalf("expected gzip output bytes, got %+v", encoded)
}
decodedRaw, err := exec.CallTool(ctx, "gzip_decode", `{"data_base64":"`+encoded.DataBase64+`"}`)
if err != nil {
t.Fatalf("gzip decode: %v", err)
}
var decoded encodingBytesResult
if err := json.Unmarshal([]byte(decodedRaw), &decoded); err != nil {
t.Fatalf("decode gzip decode result: %v", err)
}
if decoded.Text != "hello world" {
t.Fatalf("unexpected gzip decode text: %+v", decoded)
}
}
func TestValueDecodingToolsRespectMaxBytes(t *testing.T) {
exec := &executor{}
ctx := context.Background()
encoded := stdbase64.StdEncoding.EncodeToString([]byte("abc"))
if _, err := exec.CallTool(ctx, "gzip_decode", `{"data_base64":"`+encoded+`","max_bytes":2}`); err == nil {
t.Fatal("expected gzip decode to reject invalid compressed data")
}
hexEncodedRaw, err := exec.CallTool(ctx, "hex_encode", `{"text":"abc"}`)
if err != nil {
t.Fatalf("hex encode: %v", err)
}
var hexEncoded encodingValueResult
if err := json.Unmarshal([]byte(hexEncodedRaw), &hexEncoded); err != nil {
t.Fatalf("decode hex encode result: %v", err)
}
if _, err := exec.CallTool(ctx, "hex_decode", `{"value":"`+hexEncoded.Value+`","max_bytes":2}`); err == nil {
t.Fatal("expected hex decode max_bytes limit to be enforced")
}
}
@@ -1,4 +1,4 @@
package rpc
package aitools
import (
"context"
@@ -18,33 +18,63 @@ import (
"google.golang.org/protobuf/proto"
)
const (
aiToolDefaultWaitSeconds = int64(60)
)
const aiToolDefaultWaitSeconds = int64(60)
type aiToolExecutor struct {
rpc *Server
// Backend captures the RPC methods the AI tool executor needs.
type Backend interface {
GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error)
GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error)
Ls(context.Context, *sliverpb.LsReq) (*sliverpb.Ls, error)
Mv(context.Context, *sliverpb.MvReq) (*sliverpb.Mv, error)
Cp(context.Context, *sliverpb.CpReq) (*sliverpb.Cp, error)
Rm(context.Context, *sliverpb.RmReq) (*sliverpb.Rm, error)
Mkdir(context.Context, *sliverpb.MkdirReq) (*sliverpb.Mkdir, error)
Cd(context.Context, *sliverpb.CdReq) (*sliverpb.Pwd, error)
Download(context.Context, *sliverpb.DownloadReq) (*sliverpb.Download, error)
Pwd(context.Context, *sliverpb.PwdReq) (*sliverpb.Pwd, error)
Chmod(context.Context, *sliverpb.ChmodReq) (*sliverpb.Chmod, error)
Chown(context.Context, *sliverpb.ChownReq) (*sliverpb.Chown, error)
Chtimes(context.Context, *sliverpb.ChtimesReq) (*sliverpb.Chtimes, error)
Mount(context.Context, *sliverpb.MountReq) (*sliverpb.Mount, error)
Ifconfig(context.Context, *sliverpb.IfconfigReq) (*sliverpb.Ifconfig, error)
Netstat(context.Context, *sliverpb.NetstatReq) (*sliverpb.Netstat, error)
Ps(context.Context, *sliverpb.PsReq) (*sliverpb.Ps, error)
GetEnv(context.Context, *sliverpb.EnvReq) (*sliverpb.EnvInfo, error)
Ping(context.Context, *sliverpb.Ping) (*sliverpb.Ping, error)
Screenshot(context.Context, *sliverpb.ScreenshotReq) (*sliverpb.Screenshot, error)
Execute(context.Context, *sliverpb.ExecuteReq) (*sliverpb.Execute, error)
ExecuteWindows(context.Context, *sliverpb.ExecuteWindowsReq) (*sliverpb.Execute, error)
ExecuteAssembly(context.Context, *sliverpb.ExecuteAssemblyReq) (*sliverpb.ExecuteAssembly, error)
Sideload(context.Context, *sliverpb.SideloadReq) (*sliverpb.Sideload, error)
SpawnDll(context.Context, *sliverpb.InvokeSpawnDllReq) (*sliverpb.SpawnDll, error)
RegisterExtension(context.Context, *sliverpb.RegisterExtensionReq) (*sliverpb.RegisterExtension, error)
ListExtensions(context.Context, *sliverpb.ListExtensionsReq) (*sliverpb.ListExtensions, error)
CallExtension(context.Context, *sliverpb.CallExtensionReq) (*sliverpb.CallExtension, error)
}
type executor struct {
backend Backend
conversation *clientpb.AIConversation
}
type aiTargetArgs struct {
type targetArgs struct {
SessionID string `json:"session_id,omitempty"`
BeaconID string `json:"beacon_id,omitempty"`
}
type aiLSToolArgs struct {
aiTargetArgs
type lsToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
}
type aiCatToolArgs struct {
aiTargetArgs
type catToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
MaxLines int64 `json:"max_lines,omitempty"`
}
type aiLSFileEntry struct {
type lsFileEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
@@ -55,15 +85,15 @@ type aiLSFileEntry struct {
GID string `json:"gid,omitempty"`
}
type aiLSResult struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Timezone string `json:"timezone"`
TimezoneOffset int32 `json:"timezone_offset"`
Files []aiLSFileEntry `json:"files"`
type lsResult struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Timezone string `json:"timezone"`
TimezoneOffset int32 `json:"timezone_offset"`
Files []lsFileEntry `json:"files"`
}
type aiCatResult struct {
type catResult struct {
Path string `json:"path"`
RequestedPath string `json:"requested_path"`
Exists bool `json:"exists"`
@@ -75,11 +105,11 @@ type aiCatResult struct {
Text string `json:"text,omitempty"`
}
type aiPathResult struct {
type pathResult struct {
Path string `json:"path"`
}
type aiSessionSummary struct {
type sessionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Transport string `json:"transport"`
@@ -95,7 +125,7 @@ type aiSessionSummary struct {
Integrity string `json:"integrity"`
}
type aiBeaconSummary struct {
type beaconSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Transport string `json:"transport"`
@@ -116,22 +146,28 @@ type aiBeaconSummary struct {
Integrity string `json:"integrity"`
}
type aiSessionsAndBeaconsResult struct {
Sessions []aiSessionSummary `json:"sessions"`
SessionsCount int `json:"sessions_count"`
Beacons []aiBeaconSummary `json:"beacons"`
BeaconsCount int `json:"beacons_count"`
type sessionsAndBeaconsResult struct {
Sessions []sessionSummary `json:"sessions"`
SessionsCount int `json:"sessions_count"`
Beacons []beaconSummary `json:"beacons"`
BeaconsCount int `json:"beacons_count"`
}
func newAIToolExecutor(rpc *Server, conversation *clientpb.AIConversation) *aiToolExecutor {
return &aiToolExecutor{
rpc: rpc,
type toolTarget struct {
SessionID string
BeaconID string
}
// NewExecutor returns the Sliver-backed tool executor used by the AI agent loop.
func NewExecutor(backend Backend, conversation *clientpb.AIConversation) serverai.AgenticToolExecutor {
return &executor{
backend: backend,
conversation: conversation,
}
}
func (e *aiToolExecutor) ToolDefinitions() []serverai.AgenticToolDefinition {
return []serverai.AgenticToolDefinition{
func (e *executor) ToolDefinitions() []serverai.AgenticToolDefinition {
definitions := []serverai.AgenticToolDefinition{
{
Name: "list_sessions_and_beacons",
Description: "List active sessions and beacons so the model can discover valid target IDs before invoking filesystem tools.",
@@ -183,60 +219,201 @@ func (e *aiToolExecutor) ToolDefinitions() []serverai.AgenticToolDefinition {
},
},
}
definitions = append(definitions, filesystemToolDefinitions()...)
definitions = append(definitions, systemToolDefinitions()...)
definitions = append(definitions, encodingToolDefinitions()...)
definitions = append(definitions, storeToolDefinitions()...)
definitions = append(definitions, packageToolDefinitions()...)
return definitions
}
func (e *aiToolExecutor) CallTool(ctx context.Context, name string, arguments string) (string, error) {
func (e *executor) CallTool(ctx context.Context, name string, arguments string) (string, error) {
switch strings.TrimSpace(name) {
case "list_sessions_and_beacons":
return e.callListSessionsAndBeacons(ctx)
case "fs_ls":
var args aiLSToolArgs
if err := decodeAIToolArgs(arguments, &args); err != nil {
var args lsToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callLS(ctx, args)
case "fs_cat":
var args aiCatToolArgs
if err := decodeAIToolArgs(arguments, &args); err != nil {
var args catToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callCat(ctx, args)
case "fs_pwd":
var args aiTargetArgs
if err := decodeAIToolArgs(arguments, &args); err != nil {
var args targetArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callPWD(ctx, args)
case "fs_mv":
var args moveToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callMV(ctx, args)
case "fs_cp":
var args copyToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callCP(ctx, args)
case "fs_rm":
var args removeToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callRM(ctx, args)
case "fs_mkdir":
var args mkdirToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callMkdir(ctx, args)
case "fs_cd":
var args cdToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callCD(ctx, args)
case "fs_head":
var args headTailToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callHead(ctx, args)
case "fs_tail":
var args headTailToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callTail(ctx, args)
case "fs_chmod":
var args chmodToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callChmod(ctx, args)
case "fs_chown":
var args chownToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callChown(ctx, args)
case "fs_chtimes":
var args chtimesToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callChtimes(ctx, args)
case "fs_mount":
var args targetArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callMount(ctx, args)
case "exec_command":
var args execToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callExec(ctx, args)
case "ifconfig":
var args targetArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callIfconfig(ctx, args)
case "netstat":
var args netstatToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callNetstat(ctx, args)
case "ps":
var args psToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callPS(ctx, args)
case "env":
var args envToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callEnv(ctx, args)
case "getpid":
var args targetArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callGetPID(ctx, args)
case "getuid":
var args targetArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callGetUID(ctx, args)
case "info":
var args targetArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callInfo(ctx, args)
case "ping":
var args pingToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callPing(ctx, args)
case "screenshot":
var args screenshotToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callScreenshot(ctx, args)
default:
if result, handled, err := e.callEncodingTool(ctx, name, arguments); handled {
return result, err
}
if result, handled, err := e.callStoreTool(ctx, name, arguments); handled {
return result, err
}
if result, handled, err := e.callPackageTool(ctx, name, arguments); handled {
return result, err
}
return "", fmt.Errorf("unsupported AI tool %q", name)
}
}
func (e *aiToolExecutor) callListSessionsAndBeacons(ctx context.Context) (string, error) {
if e == nil || e.rpc == nil {
func (e *executor) callListSessionsAndBeacons(ctx context.Context) (string, error) {
if e == nil || e.backend == nil {
return "", fmt.Errorf("AI tool executor is unavailable")
}
sessionsResp, err := e.rpc.GetSessions(ctx, &commonpb.Empty{})
sessionsResp, err := e.backend.GetSessions(ctx, &commonpb.Empty{})
if err != nil {
return "", err
}
beaconsResp, err := e.rpc.GetBeacons(ctx, &commonpb.Empty{})
beaconsResp, err := e.backend.GetBeacons(ctx, &commonpb.Empty{})
if err != nil {
return "", err
}
result := aiSessionsAndBeaconsResult{
Sessions: make([]aiSessionSummary, 0, len(sessionsResp.GetSessions())),
Beacons: make([]aiBeaconSummary, 0, len(beaconsResp.GetBeacons())),
result := sessionsAndBeaconsResult{
Sessions: make([]sessionSummary, 0, len(sessionsResp.GetSessions())),
Beacons: make([]beaconSummary, 0, len(beaconsResp.GetBeacons())),
}
for _, session := range sessionsResp.GetSessions() {
if session == nil {
continue
}
result.Sessions = append(result.Sessions, aiSessionSummary{
result.Sessions = append(result.Sessions, sessionSummary{
ID: session.ID,
Name: session.Name,
Transport: session.Transport,
@@ -257,7 +434,7 @@ func (e *aiToolExecutor) callListSessionsAndBeacons(ctx context.Context) (string
if beacon == nil {
continue
}
result.Beacons = append(result.Beacons, aiBeaconSummary{
result.Beacons = append(result.Beacons, beaconSummary{
ID: beacon.ID,
Name: beacon.Name,
Transport: beacon.Transport,
@@ -281,10 +458,10 @@ func (e *aiToolExecutor) callListSessionsAndBeacons(ctx context.Context) (string
result.SessionsCount = len(result.Sessions)
result.BeaconsCount = len(result.Beacons)
return marshalAIToolResult(result)
return marshalToolResult(result)
}
func (e *aiToolExecutor) callLS(ctx context.Context, args aiLSToolArgs) (string, error) {
func (e *executor) callLS(ctx context.Context, args lsToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
@@ -293,7 +470,7 @@ func (e *aiToolExecutor) callLS(ctx context.Context, args aiLSToolArgs) (string,
args.Path = "."
}
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
req, isBeacon, callCtx, cancel, err := buildRequestContext(ctx, target)
if err != nil {
return "", err
}
@@ -301,39 +478,39 @@ func (e *aiToolExecutor) callLS(ctx context.Context, args aiLSToolArgs) (string,
defer cancel()
}
resp, err := e.rpc.Ls(callCtx, &sliverpb.LsReq{
resp, err := e.backend.Ls(callCtx, &sliverpb.LsReq{
Request: req,
Path: args.Path,
})
if err != nil {
return "", err
}
if err := aiGenericResponseError(resp.Response); err != nil {
if err := genericResponseError(resp.Response); err != nil {
return "", err
}
if isBeacon && resp.Response != nil && resp.Response.Async {
resolved := &sliverpb.Ls{}
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
if err := waitForBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
return "", err
}
resp = resolved
if err := aiGenericResponseError(resp.Response); err != nil {
if err := genericResponseError(resp.Response); err != nil {
return "", err
}
}
result := aiLSResult{
result := lsResult{
Path: resp.Path,
Exists: resp.Exists,
Timezone: resp.Timezone,
TimezoneOffset: resp.TimezoneOffset,
Files: make([]aiLSFileEntry, 0, len(resp.Files)),
Files: make([]lsFileEntry, 0, len(resp.Files)),
}
for _, fileInfo := range resp.Files {
if fileInfo == nil {
continue
}
result.Files = append(result.Files, aiLSFileEntry{
result.Files = append(result.Files, lsFileEntry{
Name: fileInfo.Name,
IsDir: fileInfo.IsDir,
Size: fileInfo.Size,
@@ -344,10 +521,10 @@ func (e *aiToolExecutor) callLS(ctx context.Context, args aiLSToolArgs) (string,
GID: fileInfo.Gid,
})
}
return marshalAIToolResult(result)
return marshalToolResult(result)
}
func (e *aiToolExecutor) callCat(ctx context.Context, args aiCatToolArgs) (string, error) {
func (e *executor) callCat(ctx context.Context, args catToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
@@ -356,7 +533,7 @@ func (e *aiToolExecutor) callCat(ctx context.Context, args aiCatToolArgs) (strin
return "", fmt.Errorf("path is required")
}
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
req, isBeacon, callCtx, cancel, err := buildRequestContext(ctx, target)
if err != nil {
return "", err
}
@@ -364,7 +541,7 @@ func (e *aiToolExecutor) callCat(ctx context.Context, args aiCatToolArgs) (strin
defer cancel()
}
resp, err := e.rpc.Download(callCtx, &sliverpb.DownloadReq{
resp, err := e.backend.Download(callCtx, &sliverpb.DownloadReq{
Request: req,
RestrictedToFile: true,
Path: args.Path,
@@ -374,34 +551,34 @@ func (e *aiToolExecutor) callCat(ctx context.Context, args aiCatToolArgs) (strin
if err != nil {
return "", err
}
if err := aiGenericResponseError(resp.Response); err != nil {
if err := genericResponseError(resp.Response); err != nil {
return "", err
}
if isBeacon && resp.Response != nil && resp.Response.Async {
resolved := &sliverpb.Download{}
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
if err := waitForBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
return "", err
}
resp = resolved
if err := aiGenericResponseError(resp.Response); err != nil {
if err := genericResponseError(resp.Response); err != nil {
return "", err
}
}
result, err := buildAICatResult(resp, args.Path)
result, err := buildCatResult(resp, args.Path)
if err != nil {
return "", err
}
return marshalAIToolResult(result)
return marshalToolResult(result)
}
func (e *aiToolExecutor) callPWD(ctx context.Context, args aiTargetArgs) (string, error) {
func (e *executor) callPWD(ctx context.Context, args targetArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
req, isBeacon, callCtx, cancel, err := buildRequestContext(ctx, target)
if err != nil {
return "", err
}
@@ -409,52 +586,47 @@ func (e *aiToolExecutor) callPWD(ctx context.Context, args aiTargetArgs) (string
defer cancel()
}
resp, err := e.rpc.Pwd(callCtx, &sliverpb.PwdReq{Request: req})
resp, err := e.backend.Pwd(callCtx, &sliverpb.PwdReq{Request: req})
if err != nil {
return "", err
}
if err := aiGenericResponseError(resp.Response); err != nil {
if err := genericResponseError(resp.Response); err != nil {
return "", err
}
if isBeacon && resp.Response != nil && resp.Response.Async {
resolved := &sliverpb.Pwd{}
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
if err := waitForBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
return "", err
}
resp = resolved
if err := aiGenericResponseError(resp.Response); err != nil {
if err := genericResponseError(resp.Response); err != nil {
return "", err
}
}
return marshalAIToolResult(aiPathResult{Path: resp.Path})
return marshalToolResult(pathResult{Path: resp.Path})
}
type aiToolTarget struct {
SessionID string
BeaconID string
}
func (e *aiToolExecutor) resolveTarget(sessionID, beaconID string) (aiToolTarget, error) {
func (e *executor) resolveTarget(sessionID, beaconID string) (toolTarget, error) {
sessionID = strings.TrimSpace(sessionID)
beaconID = strings.TrimSpace(beaconID)
if sessionID != "" && beaconID != "" {
return aiToolTarget{}, fmt.Errorf("provide only one of session_id or beacon_id")
return toolTarget{}, fmt.Errorf("provide only one of session_id or beacon_id")
}
if sessionID == "" && beaconID == "" && e != nil && e.conversation != nil {
sessionID = strings.TrimSpace(e.conversation.GetTargetSessionID())
beaconID = strings.TrimSpace(e.conversation.GetTargetBeaconID())
}
if sessionID == "" && beaconID == "" {
return aiToolTarget{}, fmt.Errorf("session_id or beacon_id is required; call list_sessions_and_beacons or start a conversation from an active target")
return toolTarget{}, fmt.Errorf("session_id or beacon_id is required; call list_sessions_and_beacons or start a conversation from an active target")
}
return aiToolTarget{
return toolTarget{
SessionID: sessionID,
BeaconID: beaconID,
}, nil
}
func (e *aiToolExecutor) buildRequestContext(ctx context.Context, target aiToolTarget) (*commonpb.Request, bool, context.Context, context.CancelFunc, error) {
func buildRequestContext(ctx context.Context, target toolTarget) (*commonpb.Request, bool, context.Context, context.CancelFunc, error) {
if strings.TrimSpace(target.SessionID) != "" && strings.TrimSpace(target.BeaconID) != "" {
return nil, false, nil, nil, fmt.Errorf("provide only one of session_id or beacon_id")
}
@@ -478,7 +650,7 @@ func (e *aiToolExecutor) buildRequestContext(ctx context.Context, target aiToolT
return req, isBeacon, callCtx, cancel, nil
}
func decodeAIToolArgs(raw string, out any) error {
func decodeToolArgs(raw string, out any) error {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = "{}"
@@ -489,7 +661,7 @@ func decodeAIToolArgs(raw string, out any) error {
return nil
}
func marshalAIToolResult(result any) (string, error) {
func marshalToolResult(result any) (string, error) {
data, err := json.Marshal(result)
if err != nil {
return "", err
@@ -497,15 +669,15 @@ func marshalAIToolResult(result any) (string, error) {
return string(data), nil
}
func aiGenericResponseError(resp *commonpb.Response) error {
func genericResponseError(resp *commonpb.Response) error {
if resp == nil || strings.TrimSpace(resp.Err) == "" {
return nil
}
return fmt.Errorf("%s", strings.TrimSpace(resp.Err))
}
func waitForAIBeaconTaskResponse(ctx context.Context, taskID string, out proto.Message) error {
task, err := waitForAIBeaconTask(ctx, taskID)
func waitForBeaconTaskResponse(ctx context.Context, taskID string, out proto.Message) error {
task, err := waitForBeaconTask(ctx, taskID)
if err != nil {
return err
}
@@ -522,7 +694,7 @@ func waitForAIBeaconTaskResponse(ctx context.Context, taskID string, out proto.M
return proto.Unmarshal(task.GetResponse(), out)
}
func waitForAIBeaconTask(ctx context.Context, taskID string) (*clientpb.BeaconTask, error) {
func waitForBeaconTask(ctx context.Context, taskID string) (*clientpb.BeaconTask, error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
@@ -544,7 +716,7 @@ func waitForAIBeaconTask(ctx context.Context, taskID string) (*clientpb.BeaconTa
}
}
func buildAICatResult(download *sliverpb.Download, requestedPath string) (*aiCatResult, error) {
func buildCatResult(download *sliverpb.Download, requestedPath string) (*catResult, error) {
data := download.Data
decoded := false
if download.Encoder == "gzip" {
@@ -556,7 +728,7 @@ func buildAICatResult(download *sliverpb.Download, requestedPath string) (*aiCat
}
}
result := &aiCatResult{
result := &catResult{
Path: download.Path,
RequestedPath: requestedPath,
Exists: download.Exists,
+574
View File
@@ -0,0 +1,574 @@
package aitools
import (
"context"
"fmt"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
serverai "github.com/bishopfox/sliver/server/ai"
)
type moveToolArgs struct {
targetArgs
Src string `json:"src,omitempty"`
Dst string `json:"dst,omitempty"`
}
type copyToolArgs struct {
targetArgs
Src string `json:"src,omitempty"`
Dst string `json:"dst,omitempty"`
}
type removeToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
Recursive bool `json:"recursive,omitempty"`
Force bool `json:"force,omitempty"`
}
type mkdirToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
}
type cdToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
}
type headTailToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
MaxLines int64 `json:"max_lines,omitempty"`
}
type chmodToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
FileMode string `json:"file_mode,omitempty"`
Recursive bool `json:"recursive,omitempty"`
}
type chownToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
UID string `json:"uid,omitempty"`
GID string `json:"gid,omitempty"`
Recursive bool `json:"recursive,omitempty"`
}
type chtimesToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
ATimeUnix int64 `json:"atime_unix,omitempty"`
MTimeUnix int64 `json:"mtime_unix,omitempty"`
}
type moveResult struct {
Src string `json:"src"`
Dst string `json:"dst"`
}
type copyResult struct {
Src string `json:"src"`
Dst string `json:"dst"`
BytesWritten int64 `json:"bytes_written"`
}
type contentResult struct {
Operation string `json:"operation"`
RequestedMaxBytes int64 `json:"requested_max_bytes,omitempty"`
RequestedMaxLines int64 `json:"requested_max_lines,omitempty"`
catResult
}
type chtimesResult struct {
Path string `json:"path"`
ATimeUnix int64 `json:"atime_unix"`
MTimeUnix int64 `json:"mtime_unix"`
}
type mountInfoResult struct {
VolumeName string `json:"volume_name"`
VolumeType string `json:"volume_type"`
MountPoint string `json:"mount_point"`
Label string `json:"label"`
FileSystem string `json:"file_system"`
UsedSpace uint64 `json:"used_space"`
FreeSpace uint64 `json:"free_space"`
TotalSpace uint64 `json:"total_space"`
MountOptions string `json:"mount_options"`
}
type mountResult struct {
Mounts []mountInfoResult `json:"mounts"`
Count int `json:"count"`
}
func filesystemToolDefinitions() []serverai.AgenticToolDefinition {
return []serverai.AgenticToolDefinition{
{
Name: "fs_mv",
Description: "Move or rename a remote file or directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"src": map[string]any{"type": "string", "description": "Source path to move."},
"dst": map[string]any{"type": "string", "description": "Destination path."},
}),
"required": []string{"src", "dst"},
"additionalProperties": false,
},
},
{
Name: "fs_cp",
Description: "Copy a remote file. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"src": map[string]any{"type": "string", "description": "Source path to copy."},
"dst": map[string]any{"type": "string", "description": "Destination path."},
}),
"required": []string{"src", "dst"},
"additionalProperties": false,
},
},
{
Name: "fs_rm",
Description: "Remove a remote file or directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Path to remove."},
"recursive": map[string]any{"type": "boolean", "description": "Recursively remove directories."},
"force": map[string]any{"type": "boolean", "description": "Force removal."},
}),
"required": []string{"path"},
"additionalProperties": false,
},
},
{
Name: "fs_mkdir",
Description: "Create a remote directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Directory path to create."},
}),
"required": []string{"path"},
"additionalProperties": false,
},
},
{
Name: "fs_cd",
Description: "Change the remote working directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Directory to change into. Defaults to . when omitted."},
}),
"additionalProperties": false,
},
},
{
Name: "fs_head",
Description: "Return the first lines or bytes from a remote file. Defaults to the first 10 lines when no limit is provided.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Remote file path to read."},
"max_bytes": map[string]any{"type": "integer", "description": "Optional number of bytes to read from the start of the file."},
"max_lines": map[string]any{"type": "integer", "description": "Optional number of lines to read from the start of the file."},
}),
"required": []string{"path"},
"additionalProperties": false,
},
},
{
Name: "fs_tail",
Description: "Return the last lines or bytes from a remote file. Defaults to the last 10 lines when no limit is provided.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Remote file path to read."},
"max_bytes": map[string]any{"type": "integer", "description": "Optional number of bytes to read from the end of the file."},
"max_lines": map[string]any{"type": "integer", "description": "Optional number of lines to read from the end of the file."},
}),
"required": []string{"path"},
"additionalProperties": false,
},
},
{
Name: "fs_chmod",
Description: "Change permissions on a remote file or directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Path to change permissions on."},
"file_mode": map[string]any{"type": "string", "description": "Mode string such as 0644 or 755."},
"recursive": map[string]any{"type": "boolean", "description": "Apply recursively."},
}),
"required": []string{"path", "file_mode"},
"additionalProperties": false,
},
},
{
Name: "fs_chown",
Description: "Change ownership on a remote file or directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Path to change ownership on."},
"uid": map[string]any{"type": "string", "description": "User ID to assign."},
"gid": map[string]any{"type": "string", "description": "Group ID to assign."},
"recursive": map[string]any{"type": "boolean", "description": "Apply recursively."},
}),
"required": []string{"path", "uid", "gid"},
"additionalProperties": false,
},
},
{
Name: "fs_chtimes",
Description: "Change access and modification times on a remote file or directory using Unix timestamps in seconds.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Path to modify."},
"atime_unix": map[string]any{"type": "integer", "description": "Access time as a Unix timestamp in seconds."},
"mtime_unix": map[string]any{"type": "integer", "description": "Modification time as a Unix timestamp in seconds."},
}),
"required": []string{"path", "atime_unix", "mtime_unix"},
"additionalProperties": false,
},
},
{
Name: "fs_mount",
Description: "List mounted filesystems or drives on the remote host. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": targetSchemaProperties(),
"additionalProperties": false,
},
},
}
}
func (e *executor) callMV(ctx context.Context, args moveToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Src == "" || args.Dst == "" {
return "", fmt.Errorf("src and dst are required")
}
_, err = callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Mv, error) {
return e.backend.Mv(callCtx, &sliverpb.MvReq{Request: req, Src: args.Src, Dst: args.Dst})
},
func() *sliverpb.Mv { return &sliverpb.Mv{} },
)
if err != nil {
return "", err
}
return marshalToolResult(moveResult{Src: args.Src, Dst: args.Dst})
}
func (e *executor) callCP(ctx context.Context, args copyToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Src == "" || args.Dst == "" {
return "", fmt.Errorf("src and dst are required")
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Cp, error) {
return e.backend.Cp(callCtx, &sliverpb.CpReq{Request: req, Src: args.Src, Dst: args.Dst})
},
func() *sliverpb.Cp { return &sliverpb.Cp{} },
)
if err != nil {
return "", err
}
return marshalToolResult(copyResult{Src: args.Src, Dst: args.Dst, BytesWritten: resp.BytesWritten})
}
func (e *executor) callRM(ctx context.Context, args removeToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Rm, error) {
return e.backend.Rm(callCtx, &sliverpb.RmReq{
Request: req,
Path: args.Path,
Recursive: args.Recursive,
Force: args.Force,
})
},
func() *sliverpb.Rm { return &sliverpb.Rm{} },
)
if err != nil {
return "", err
}
return marshalToolResult(pathResult{Path: resp.Path})
}
func (e *executor) callMkdir(ctx context.Context, args mkdirToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Mkdir, error) {
return e.backend.Mkdir(callCtx, &sliverpb.MkdirReq{Request: req, Path: args.Path})
},
func() *sliverpb.Mkdir { return &sliverpb.Mkdir{} },
)
if err != nil {
return "", err
}
return marshalToolResult(pathResult{Path: resp.Path})
}
func (e *executor) callCD(ctx context.Context, args cdToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" {
args.Path = "."
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Pwd, error) {
return e.backend.Cd(callCtx, &sliverpb.CdReq{Request: req, Path: args.Path})
},
func() *sliverpb.Pwd { return &sliverpb.Pwd{} },
)
if err != nil {
return "", err
}
return marshalToolResult(pathResult{Path: resp.Path})
}
func (e *executor) callHead(ctx context.Context, args headTailToolArgs) (string, error) {
return e.callHeadTail(ctx, args, true)
}
func (e *executor) callTail(ctx context.Context, args headTailToolArgs) (string, error) {
return e.callHeadTail(ctx, args, false)
}
func (e *executor) callHeadTail(ctx context.Context, args headTailToolArgs, head bool) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
if args.MaxBytes < 0 || args.MaxLines < 0 {
return "", fmt.Errorf("max_bytes and max_lines must be non-negative")
}
if args.MaxBytes != 0 && args.MaxLines != 0 {
return "", fmt.Errorf("provide only one of max_bytes or max_lines")
}
if args.MaxBytes == 0 && args.MaxLines == 0 {
args.MaxLines = 10
}
requestedBytes := args.MaxBytes
requestedLines := args.MaxLines
if !head {
requestedBytes *= -1
requestedLines *= -1
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Download, error) {
return e.backend.Download(callCtx, &sliverpb.DownloadReq{
Request: req,
RestrictedToFile: true,
Path: args.Path,
MaxBytes: requestedBytes,
MaxLines: requestedLines,
})
},
func() *sliverpb.Download { return &sliverpb.Download{} },
)
if err != nil {
return "", err
}
built, err := buildCatResult(resp, args.Path)
if err != nil {
return "", err
}
operation := "head"
if !head {
operation = "tail"
}
return marshalToolResult(contentResult{
Operation: operation,
RequestedMaxBytes: args.MaxBytes,
RequestedMaxLines: args.MaxLines,
catResult: *built,
})
}
func (e *executor) callChmod(ctx context.Context, args chmodToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" || args.FileMode == "" {
return "", fmt.Errorf("path and file_mode are required")
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Chmod, error) {
return e.backend.Chmod(callCtx, &sliverpb.ChmodReq{
Request: req,
Path: args.Path,
FileMode: args.FileMode,
Recursive: args.Recursive,
})
},
func() *sliverpb.Chmod { return &sliverpb.Chmod{} },
)
if err != nil {
return "", err
}
return marshalToolResult(pathResult{Path: resp.Path})
}
func (e *executor) callChown(ctx context.Context, args chownToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" || args.UID == "" || args.GID == "" {
return "", fmt.Errorf("path, uid, and gid are required")
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Chown, error) {
return e.backend.Chown(callCtx, &sliverpb.ChownReq{
Request: req,
Path: args.Path,
Uid: args.UID,
Gid: args.GID,
Recursive: args.Recursive,
})
},
func() *sliverpb.Chown { return &sliverpb.Chown{} },
)
if err != nil {
return "", err
}
return marshalToolResult(pathResult{Path: resp.Path})
}
func (e *executor) callChtimes(ctx context.Context, args chtimesToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Chtimes, error) {
return e.backend.Chtimes(callCtx, &sliverpb.ChtimesReq{
Request: req,
Path: args.Path,
ATime: args.ATimeUnix,
MTime: args.MTimeUnix,
})
},
func() *sliverpb.Chtimes { return &sliverpb.Chtimes{} },
)
if err != nil {
return "", err
}
return marshalToolResult(chtimesResult{
Path: resp.Path,
ATimeUnix: args.ATimeUnix,
MTimeUnix: args.MTimeUnix,
})
}
func (e *executor) callMount(ctx context.Context, args targetArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Mount, error) {
return e.backend.Mount(callCtx, &sliverpb.MountReq{Request: req})
},
func() *sliverpb.Mount { return &sliverpb.Mount{} },
)
if err != nil {
return "", err
}
result := mountResult{
Mounts: make([]mountInfoResult, 0, len(resp.Info)),
}
for _, mountInfo := range resp.Info {
if mountInfo == nil {
continue
}
result.Mounts = append(result.Mounts, mountInfoResult{
VolumeName: mountInfo.VolumeName,
VolumeType: mountInfo.VolumeType,
MountPoint: mountInfo.MountPoint,
Label: mountInfo.Label,
FileSystem: mountInfo.FileSystem,
UsedSpace: mountInfo.UsedSpace,
FreeSpace: mountInfo.FreeSpace,
TotalSpace: mountInfo.TotalSpace,
MountOptions: mountInfo.MountOptions,
})
}
result.Count = len(result.Mounts)
return marshalToolResult(result)
}
+129
View File
@@ -0,0 +1,129 @@
package aitools
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"unicode/utf8"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"google.golang.org/protobuf/proto"
)
type responseMessage interface {
proto.Message
GetResponse() *commonpb.Response
}
func targetSchemaProperties() map[string]any {
return map[string]any{
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
}
}
func mergeSchemaProperties(extra map[string]any) map[string]any {
properties := targetSchemaProperties()
for key, value := range extra {
properties[key] = value
}
return properties
}
func callTargetRPC[T responseMessage](
ctx context.Context,
target toolTarget,
invoke func(context.Context, *commonpb.Request) (T, error),
newResponse func() T,
) (T, error) {
var zero T
req, isBeacon, callCtx, cancel, err := buildRequestContext(ctx, target)
if err != nil {
return zero, err
}
if cancel != nil {
defer cancel()
}
resp, err := invoke(callCtx, req)
if err != nil {
return zero, err
}
if err := genericResponseError(resp.GetResponse()); err != nil {
return zero, err
}
if isBeacon && resp.GetResponse() != nil && resp.GetResponse().Async {
resolved := newResponse()
if err := waitForBeaconTaskResponse(callCtx, resp.GetResponse().TaskID, resolved); err != nil {
return zero, err
}
resp = resolved
if err := genericResponseError(resp.GetResponse()); err != nil {
return zero, err
}
}
return resp, nil
}
func (e *executor) lookupTargetMetadata(ctx context.Context, sessionID, beaconID string) (*clientpb.Session, *clientpb.Beacon, error) {
target, err := e.resolveTarget(sessionID, beaconID)
if err != nil {
return nil, nil, err
}
if e == nil || e.backend == nil {
return nil, nil, fmt.Errorf("AI tool executor is unavailable")
}
if target.SessionID != "" {
sessionsResp, err := e.backend.GetSessions(ctx, &commonpb.Empty{})
if err != nil {
return nil, nil, err
}
for _, session := range sessionsResp.GetSessions() {
if session != nil && session.ID == target.SessionID {
return session, nil, nil
}
}
for _, session := range sessionsResp.GetSessions() {
if session != nil && strings.HasPrefix(session.ID, target.SessionID) {
return session, nil, nil
}
}
return nil, nil, fmt.Errorf("session %q not found", target.SessionID)
}
beaconsResp, err := e.backend.GetBeacons(ctx, &commonpb.Empty{})
if err != nil {
return nil, nil, err
}
for _, beacon := range beaconsResp.GetBeacons() {
if beacon != nil && beacon.ID == target.BeaconID {
return nil, beacon, nil
}
}
for _, beacon := range beaconsResp.GetBeacons() {
if beacon != nil && strings.HasPrefix(beacon.ID, target.BeaconID) {
return nil, beacon, nil
}
}
return nil, nil, fmt.Errorf("beacon %q not found", target.BeaconID)
}
func bytesToTextAndBase64(data []byte) (string, string) {
if len(data) == 0 {
return "", ""
}
if utf8.Valid(data) {
return string(data), ""
}
return "", base64.StdEncoding.EncodeToString(data)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return fmt.Sprintf("%x", sum)
}
+286
View File
@@ -0,0 +1,286 @@
package aitools
import (
"bytes"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/bishopfox/sliver/client/core"
)
func buildBOFExtensionArgs(command *aiExtensionCommand, bofData []byte, args []string) ([]byte, error) {
argsBuffer := core.BOFArgsBuffer{Buffer: new(bytes.Buffer)}
if err := argsBuffer.AddString(command.Entrypoint); err != nil {
return nil, err
}
if err := argsBuffer.AddData(bofData); err != nil {
return nil, err
}
parsedArgs, err := parseBOFArguments(command, args)
if err != nil {
return nil, err
}
if err := argsBuffer.AddData(parsedArgs); err != nil {
return nil, err
}
return argsBuffer.GetBuffer()
}
func parseBOFArguments(command *aiExtensionCommand, args []string) ([]byte, error) {
fs, stringValues, wstringValues, intValues, shortValues, fileValues, err := setupBOFFlags(args, command)
if err != nil {
return nil, err
}
argsBuffer := core.BOFArgsBuffer{Buffer: new(bytes.Buffer)}
missingRequired := []string{}
for _, argDef := range command.Arguments {
if argDef == nil {
continue
}
var provided bool
switch argDef.Type {
case "string":
provided, err = processBOFStringArg(fs, argDef, stringValues, argsBuffer)
case "wstring":
provided, err = processBOFWStringArg(fs, argDef, wstringValues, argsBuffer)
case "int", "integer":
provided, err = processBOFIntArg(fs, argDef, intValues, argsBuffer)
case "short":
provided, err = processBOFShortArg(fs, argDef, shortValues, argsBuffer)
case "file":
provided, err = processBOFFileArg(fs, argDef, fileValues, argsBuffer)
default:
return nil, fmt.Errorf("unsupported BOF argument type %q", argDef.Type)
}
if err != nil {
return nil, err
}
if provided {
continue
}
if argDef.Optional {
if err := applyBOFDefaultValue(argDef, argsBuffer); err != nil {
return nil, err
}
continue
}
missingRequired = append(missingRequired, argDef.Name)
}
if len(missingRequired) > 0 {
return nil, fmt.Errorf("required BOF arguments were not provided: %s", strings.Join(missingRequired, ", "))
}
return argsBuffer.GetBuffer()
}
func setupBOFFlags(args []string, command *aiExtensionCommand) (
*flag.FlagSet,
map[string]*string,
map[string]*string,
map[string]*int,
map[string]*int,
map[string]*string,
error,
) {
fs := flag.NewFlagSet("sliver-ai-bof", flag.ContinueOnError)
fs.SetOutput(io.Discard)
stringValues := map[string]*string{}
wstringValues := map[string]*string{}
intValues := map[string]*int{}
shortValues := map[string]*int{}
fileValues := map[string]*string{}
for _, arg := range command.Arguments {
if arg == nil {
continue
}
switch arg.Type {
case "string":
stringValues[arg.Name] = fs.String(arg.Name, "", arg.Desc)
case "wstring":
wstringValues[arg.Name] = fs.String(arg.Name, "", arg.Desc)
case "int", "integer":
intValues[arg.Name] = fs.Int(arg.Name, 0, arg.Desc)
case "short":
shortValues[arg.Name] = fs.Int(arg.Name, 0, arg.Desc)
case "file":
fileValues[arg.Name] = fs.String(arg.Name, "", arg.Desc)
default:
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unsupported BOF argument type %q", arg.Type)
}
}
if err := fs.Parse(args); err != nil {
return nil, nil, nil, nil, nil, nil, err
}
return fs, stringValues, wstringValues, intValues, shortValues, fileValues, nil
}
func processBOFStringArg(fs *flag.FlagSet, argDef *aiExtensionArgument, values map[string]*string, argsBuffer core.BOFArgsBuffer) (bool, error) {
ptr := values[argDef.Name]
if ptr == nil || !bofFlagWasProvided(fs, argDef.Name) {
return false, nil
}
if err := argsBuffer.AddString(*ptr); err != nil {
return false, fmt.Errorf("failed to add string argument %s: %w", argDef.Name, err)
}
return true, nil
}
func processBOFWStringArg(fs *flag.FlagSet, argDef *aiExtensionArgument, values map[string]*string, argsBuffer core.BOFArgsBuffer) (bool, error) {
ptr := values[argDef.Name]
if ptr == nil || !bofFlagWasProvided(fs, argDef.Name) {
return false, nil
}
if err := argsBuffer.AddWString(*ptr); err != nil {
return false, fmt.Errorf("failed to add wstring argument %s: %w", argDef.Name, err)
}
return true, nil
}
func processBOFIntArg(fs *flag.FlagSet, argDef *aiExtensionArgument, values map[string]*int, argsBuffer core.BOFArgsBuffer) (bool, error) {
ptr := values[argDef.Name]
if ptr == nil || !bofFlagWasProvided(fs, argDef.Name) {
return false, nil
}
if err := argsBuffer.AddInt(uint32(*ptr)); err != nil {
return false, fmt.Errorf("failed to add integer argument %s: %w", argDef.Name, err)
}
return true, nil
}
func processBOFShortArg(fs *flag.FlagSet, argDef *aiExtensionArgument, values map[string]*int, argsBuffer core.BOFArgsBuffer) (bool, error) {
ptr := values[argDef.Name]
if ptr == nil || !bofFlagWasProvided(fs, argDef.Name) {
return false, nil
}
if err := argsBuffer.AddShort(uint16(*ptr)); err != nil {
return false, fmt.Errorf("failed to add short argument %s: %w", argDef.Name, err)
}
return true, nil
}
func processBOFFileArg(fs *flag.FlagSet, argDef *aiExtensionArgument, values map[string]*string, argsBuffer core.BOFArgsBuffer) (bool, error) {
ptr := values[argDef.Name]
if ptr == nil || !bofFlagWasProvided(fs, argDef.Name) {
return false, nil
}
data, err := os.ReadFile(*ptr)
if err != nil {
return false, fmt.Errorf("failed to read file argument %s: %w", argDef.Name, err)
}
if err := argsBuffer.AddData(data); err != nil {
return false, fmt.Errorf("failed to add file argument %s: %w", argDef.Name, err)
}
return true, nil
}
func applyBOFDefaultValue(argDef *aiExtensionArgument, argsBuffer core.BOFArgsBuffer) error {
if argDef == nil {
return nil
}
if argDef.Default == nil {
return addBOFZeroValue(argDef.Type, argsBuffer)
}
switch argDef.Type {
case "string":
return argsBuffer.AddString(fmt.Sprint(argDef.Default))
case "wstring":
return argsBuffer.AddWString(fmt.Sprint(argDef.Default))
case "int", "integer":
value, err := bofDefaultIntValue(argDef.Default)
if err != nil {
return fmt.Errorf("invalid default integer value for %s: %w", argDef.Name, err)
}
return argsBuffer.AddInt(uint32(value))
case "short":
value, err := bofDefaultIntValue(argDef.Default)
if err != nil {
return fmt.Errorf("invalid default short value for %s: %w", argDef.Name, err)
}
return argsBuffer.AddShort(uint16(value))
case "file":
path := fmt.Sprint(argDef.Default)
if path == "" {
return addBOFZeroValue(argDef.Type, argsBuffer)
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read default file for %s: %w", argDef.Name, err)
}
return argsBuffer.AddData(data)
default:
return fmt.Errorf("unsupported BOF argument type %q", argDef.Type)
}
}
func addBOFZeroValue(argType string, argsBuffer core.BOFArgsBuffer) error {
switch argType {
case "string":
return argsBuffer.AddString("")
case "wstring":
return argsBuffer.AddWString("")
case "int", "integer":
return argsBuffer.AddInt(0)
case "short":
return argsBuffer.AddShort(0)
case "file":
return argsBuffer.AddData([]byte{})
default:
return fmt.Errorf("unsupported BOF argument type %q", argType)
}
}
func bofDefaultIntValue(raw any) (int64, error) {
switch value := raw.(type) {
case float64:
return int64(value), nil
case float32:
return int64(value), nil
case int:
return int64(value), nil
case int8:
return int64(value), nil
case int16:
return int64(value), nil
case int32:
return int64(value), nil
case int64:
return value, nil
case uint:
return int64(value), nil
case uint8:
return int64(value), nil
case uint16:
return int64(value), nil
case uint32:
return int64(value), nil
case uint64:
return int64(value), nil
case string:
return strconv.ParseInt(strings.TrimSpace(value), 10, 64)
default:
return 0, fmt.Errorf("unsupported numeric default type %T", raw)
}
}
func bofFlagWasProvided(fs *flag.FlagSet, name string) bool {
provided := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
provided = true
}
})
return provided
}
File diff suppressed because it is too large. Load diff
+463
View File
@@ -0,0 +1,463 @@
package aitools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
serverassets "github.com/bishopfox/sliver/server/assets"
)
func TestSearchAliasesIncludesTargetCompatibility(t *testing.T) {
rootDir := t.TempDir()
t.Setenv("SLIVER_ROOT_DIR", rootDir)
aliasDir := filepath.Join(serverassets.GetAIAliasesDir(), "Rubeus")
if err := os.MkdirAll(aliasDir, 0o700); err != nil {
t.Fatalf("mkdir alias dir: %v", err)
}
if err := os.WriteFile(filepath.Join(aliasDir, "Rubeus.exe"), []byte("alias-binary"), 0o600); err != nil {
t.Fatalf("write alias artifact: %v", err)
}
if err := os.WriteFile(filepath.Join(aliasDir, aiAliasManifestFileName), []byte(`{
"name":"Rubeus",
"version":"1.0.0",
"command_name":"rubeus",
"original_author":"GhostPack",
"repo_url":"https://example.test/rubeus",
"help":"Kerberos abuse helper",
"entrypoint":"Main",
"allow_args":true,
"default_args":"",
"is_reflective":false,
"is_assembly":true,
"files":[{"os":"windows","arch":"amd64","path":"Rubeus.exe"}]
}`), 0o600); err != nil {
t.Fatalf("write alias manifest: %v", err)
}
backend := &fakePackageBackend{
sessions: &clientpb.Sessions{
Sessions: []*clientpb.Session{
{ID: "session-1", OS: "windows", Arch: "amd64", Hostname: "winhost"},
},
},
}
executor := &executor{
backend: backend,
conversation: &clientpb.AIConversation{
TargetSessionID: "session-1",
},
}
raw, err := executor.callSearchAliases(context.Background(), searchPackagesArgs{
Query: "kerberos",
OnlyCompatible: true,
})
if err != nil {
t.Fatalf("search aliases: %v", err)
}
var resp aliasSearchResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("unmarshal search result: %v", err)
}
if resp.ReturnedCount != 1 || resp.TotalMatches != 1 {
t.Fatalf("unexpected alias search counts: %+v", resp)
}
if resp.Target == nil || resp.Target.SessionID != "session-1" {
t.Fatalf("expected session target in response, got %+v", resp.Target)
}
if len(resp.Results) != 1 {
t.Fatalf("expected one alias result, got %+v", resp.Results)
}
result := resp.Results[0]
if !result.Compatible || !result.CompatibilityChecked {
t.Fatalf("expected compatible alias result, got %+v", result)
}
if result.ExecutionMode != "assembly" {
t.Fatalf("unexpected alias execution mode: %+v", result)
}
if !strings.HasSuffix(result.ArtifactPath, filepath.Join("Rubeus", "Rubeus.exe")) {
t.Fatalf("unexpected alias artifact path: %q", result.ArtifactPath)
}
}
func TestExecuteAliasRunsExecuteAssembly(t *testing.T) {
rootDir := t.TempDir()
t.Setenv("SLIVER_ROOT_DIR", rootDir)
aliasDir := filepath.Join(serverassets.GetAIAliasesDir(), "Seatbelt")
if err := os.MkdirAll(aliasDir, 0o700); err != nil {
t.Fatalf("mkdir alias dir: %v", err)
}
if err := os.WriteFile(filepath.Join(aliasDir, "Seatbelt.exe"), []byte("seatbelt-binary"), 0o600); err != nil {
t.Fatalf("write alias artifact: %v", err)
}
if err := os.WriteFile(filepath.Join(aliasDir, aiAliasManifestFileName), []byte(`{
"name":"Seatbelt",
"version":"1.0.0",
"command_name":"seatbelt",
"original_author":"GhostPack",
"repo_url":"https://example.test/seatbelt",
"help":"Host survey helper",
"entrypoint":"Main",
"allow_args":true,
"default_args":"",
"is_reflective":false,
"is_assembly":true,
"files":[{"os":"windows","arch":"amd64","path":"Seatbelt.exe"}]
}`), 0o600); err != nil {
t.Fatalf("write alias manifest: %v", err)
}
backend := &fakePackageBackend{
sessions: &clientpb.Sessions{
Sessions: []*clientpb.Session{
{ID: "session-1", OS: "windows", Arch: "amd64", Hostname: "winhost"},
},
},
executeAssemblyFn: func(_ context.Context, req *sliverpb.ExecuteAssemblyReq) (*sliverpb.ExecuteAssembly, error) {
return &sliverpb.ExecuteAssembly{
Output: []byte("assembly-output"),
Response: &commonpb.Response{},
}, nil
},
}
executor := &executor{
backend: backend,
conversation: &clientpb.AIConversation{
TargetSessionID: "session-1",
},
}
raw, err := executor.callExecuteAlias(context.Background(), executeAliasArgs{
CommandName: "seatbelt",
Args: []string{"WindowsCredentialFiles"},
})
if err != nil {
t.Fatalf("execute alias: %v", err)
}
if len(backend.executeAssemblyReqs) != 1 {
t.Fatalf("expected execute-assembly request, got %d", len(backend.executeAssemblyReqs))
}
req := backend.executeAssemblyReqs[0]
if req.GetRequest().GetSessionID() != "session-1" {
t.Fatalf("unexpected target request: %+v", req.GetRequest())
}
if req.GetProcess() != aiAliasDefaultHostProcess["windows"] {
t.Fatalf("unexpected default process: %q", req.GetProcess())
}
if len(req.GetArguments()) != 1 || req.GetArguments()[0] != "WindowsCredentialFiles" {
t.Fatalf("unexpected assembly args: %+v", req.GetArguments())
}
var resp aliasExecutionResult
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("unmarshal alias execution result: %v", err)
}
if resp.OutputText != "assembly-output" {
t.Fatalf("unexpected alias output: %+v", resp)
}
if resp.ExecutionMode != "assembly" {
t.Fatalf("unexpected alias execution mode: %+v", resp)
}
}
func TestExecuteExtensionRegistersDependencyForBOF(t *testing.T) {
rootDir := t.TempDir()
t.Setenv("SLIVER_ROOT_DIR", rootDir)
coffLoaderDir := filepath.Join(serverassets.GetAIExtensionsDir(), "coff-loader")
if err := os.MkdirAll(coffLoaderDir, 0o700); err != nil {
t.Fatalf("mkdir coff-loader dir: %v", err)
}
if err := os.WriteFile(filepath.Join(coffLoaderDir, "coff-loader.x64.dll"), []byte("coff-loader-binary"), 0o600); err != nil {
t.Fatalf("write dependency artifact: %v", err)
}
if err := os.WriteFile(filepath.Join(coffLoaderDir, aiExtensionManifestFileName), []byte(`{
"name":"coff-loader",
"package_name":"coff-loader",
"version":"1.0.0",
"extension_author":"sliver",
"original_author":"sliver",
"repo_url":"https://example.test/coff-loader",
"commands":[
{
"command_name":"coff-loader",
"help":"Load and run COFFs",
"entrypoint":"LoadAndRun",
"files":[{"os":"windows","arch":"amd64","path":"coff-loader.x64.dll"}]
}
]
}`), 0o600); err != nil {
t.Fatalf("write dependency manifest: %v", err)
}
nanodumpDir := filepath.Join(serverassets.GetAIExtensionsDir(), "nanodump")
if err := os.MkdirAll(nanodumpDir, 0o700); err != nil {
t.Fatalf("mkdir nanodump dir: %v", err)
}
if err := os.WriteFile(filepath.Join(nanodumpDir, "nanodump.x64.o"), []byte("nanodump-bof"), 0o600); err != nil {
t.Fatalf("write bof artifact: %v", err)
}
if err := os.WriteFile(filepath.Join(nanodumpDir, aiExtensionManifestFileName), []byte(`{
"name":"nanodump",
"package_name":"nanodump",
"version":"1.0.0",
"extension_author":"sliver",
"original_author":"sliver",
"repo_url":"https://example.test/nanodump",
"commands":[
{
"command_name":"nanodump",
"help":"Dump LSASS",
"entrypoint":"go",
"depends_on":"coff-loader",
"files":[{"os":"windows","arch":"amd64","path":"nanodump.x64.o"}],
"arguments":[
{"name":"pid","type":"int","desc":"PID to dump","optional":false}
]
}
]
}`), 0o600); err != nil {
t.Fatalf("write bof manifest: %v", err)
}
backend := &fakePackageBackend{
sessions: &clientpb.Sessions{
Sessions: []*clientpb.Session{
{ID: "session-1", OS: "windows", Arch: "amd64", Hostname: "winhost"},
},
},
listExtensionsFn: func(_ context.Context, _ *sliverpb.ListExtensionsReq) (*sliverpb.ListExtensions, error) {
return &sliverpb.ListExtensions{
Names: []string{},
Response: &commonpb.Response{},
}, nil
},
registerExtensionFn: func(_ context.Context, _ *sliverpb.RegisterExtensionReq) (*sliverpb.RegisterExtension, error) {
return &sliverpb.RegisterExtension{Response: &commonpb.Response{}}, nil
},
callExtensionFn: func(_ context.Context, _ *sliverpb.CallExtensionReq) (*sliverpb.CallExtension, error) {
return &sliverpb.CallExtension{
Output: []byte("extension-output"),
Response: &commonpb.Response{},
}, nil
},
}
executor := &executor{
backend: backend,
conversation: &clientpb.AIConversation{
TargetSessionID: "session-1",
},
}
raw, err := executor.callExecuteExtension(context.Background(), executeExtensionArgs{
CommandName: "nanodump",
NamedArgs: map[string]any{
"pid": 1337,
},
})
if err != nil {
t.Fatalf("execute extension: %v", err)
}
if len(backend.listExtensionsReqs) != 1 {
t.Fatalf("expected list-extensions request, got %d", len(backend.listExtensionsReqs))
}
if len(backend.registerExtensionReqs) != 1 {
t.Fatalf("expected one dependency registration, got %d", len(backend.registerExtensionReqs))
}
registerReq := backend.registerExtensionReqs[0]
if registerReq.GetOS() != "windows" {
t.Fatalf("unexpected dependency registration target os: %+v", registerReq)
}
if string(registerReq.GetData()) != "coff-loader-binary" {
t.Fatalf("expected dependency bytes to be registered, got %q", string(registerReq.GetData()))
}
if len(backend.callExtensionReqs) != 1 {
t.Fatalf("expected one call-extension request, got %d", len(backend.callExtensionReqs))
}
callReq := backend.callExtensionReqs[0]
if callReq.GetName() != registerReq.GetName() {
t.Fatalf("expected BOF call to use dependency hash, got register=%q call=%q", registerReq.GetName(), callReq.GetName())
}
if callReq.GetExport() != "LoadAndRun" {
t.Fatalf("unexpected BOF dependency export: %+v", callReq)
}
if len(callReq.GetArgs()) == 0 {
t.Fatalf("expected packed BOF arguments, got empty buffer")
}
var resp extensionExecutionResult
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("unmarshal extension execution result: %v", err)
}
if resp.OutputText != "extension-output" {
t.Fatalf("unexpected extension output: %+v", resp)
}
if resp.ExecutionMode != "bof" {
t.Fatalf("expected bof execution mode, got %+v", resp)
}
if resp.DependencyRootPath == "" || resp.DependencyArtifactPath == "" {
t.Fatalf("expected dependency metadata in response, got %+v", resp)
}
}
type fakePackageBackend struct {
sessions *clientpb.Sessions
beacons *clientpb.Beacons
executeAssemblyFn func(context.Context, *sliverpb.ExecuteAssemblyReq) (*sliverpb.ExecuteAssembly, error)
listExtensionsFn func(context.Context, *sliverpb.ListExtensionsReq) (*sliverpb.ListExtensions, error)
registerExtensionFn func(context.Context, *sliverpb.RegisterExtensionReq) (*sliverpb.RegisterExtension, error)
callExtensionFn func(context.Context, *sliverpb.CallExtensionReq) (*sliverpb.CallExtension, error)
executeAssemblyReqs []*sliverpb.ExecuteAssemblyReq
listExtensionsReqs []*sliverpb.ListExtensionsReq
registerExtensionReqs []*sliverpb.RegisterExtensionReq
callExtensionReqs []*sliverpb.CallExtensionReq
}
func (f *fakePackageBackend) GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error) {
if f.sessions == nil {
return &clientpb.Sessions{}, nil
}
return f.sessions, nil
}
func (f *fakePackageBackend) GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error) {
if f.beacons == nil {
return &clientpb.Beacons{}, nil
}
return f.beacons, nil
}
func (*fakePackageBackend) Ls(context.Context, *sliverpb.LsReq) (*sliverpb.Ls, error) {
return &sliverpb.Ls{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Mv(context.Context, *sliverpb.MvReq) (*sliverpb.Mv, error) {
return &sliverpb.Mv{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Cp(context.Context, *sliverpb.CpReq) (*sliverpb.Cp, error) {
return &sliverpb.Cp{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Rm(context.Context, *sliverpb.RmReq) (*sliverpb.Rm, error) {
return &sliverpb.Rm{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Mkdir(context.Context, *sliverpb.MkdirReq) (*sliverpb.Mkdir, error) {
return &sliverpb.Mkdir{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Cd(context.Context, *sliverpb.CdReq) (*sliverpb.Pwd, error) {
return &sliverpb.Pwd{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Download(context.Context, *sliverpb.DownloadReq) (*sliverpb.Download, error) {
return &sliverpb.Download{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Pwd(context.Context, *sliverpb.PwdReq) (*sliverpb.Pwd, error) {
return &sliverpb.Pwd{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Chmod(context.Context, *sliverpb.ChmodReq) (*sliverpb.Chmod, error) {
return &sliverpb.Chmod{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Chown(context.Context, *sliverpb.ChownReq) (*sliverpb.Chown, error) {
return &sliverpb.Chown{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Chtimes(context.Context, *sliverpb.ChtimesReq) (*sliverpb.Chtimes, error) {
return &sliverpb.Chtimes{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Mount(context.Context, *sliverpb.MountReq) (*sliverpb.Mount, error) {
return &sliverpb.Mount{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Ifconfig(context.Context, *sliverpb.IfconfigReq) (*sliverpb.Ifconfig, error) {
return &sliverpb.Ifconfig{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Netstat(context.Context, *sliverpb.NetstatReq) (*sliverpb.Netstat, error) {
return &sliverpb.Netstat{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Ps(context.Context, *sliverpb.PsReq) (*sliverpb.Ps, error) {
return &sliverpb.Ps{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) GetEnv(context.Context, *sliverpb.EnvReq) (*sliverpb.EnvInfo, error) {
return &sliverpb.EnvInfo{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Ping(context.Context, *sliverpb.Ping) (*sliverpb.Ping, error) {
return &sliverpb.Ping{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Screenshot(context.Context, *sliverpb.ScreenshotReq) (*sliverpb.Screenshot, error) {
return &sliverpb.Screenshot{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Execute(context.Context, *sliverpb.ExecuteReq) (*sliverpb.Execute, error) {
return &sliverpb.Execute{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) ExecuteWindows(context.Context, *sliverpb.ExecuteWindowsReq) (*sliverpb.Execute, error) {
return &sliverpb.Execute{Response: &commonpb.Response{}}, nil
}
func (f *fakePackageBackend) ExecuteAssembly(ctx context.Context, req *sliverpb.ExecuteAssemblyReq) (*sliverpb.ExecuteAssembly, error) {
f.executeAssemblyReqs = append(f.executeAssemblyReqs, req)
if f.executeAssemblyFn != nil {
return f.executeAssemblyFn(ctx, req)
}
return &sliverpb.ExecuteAssembly{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) Sideload(context.Context, *sliverpb.SideloadReq) (*sliverpb.Sideload, error) {
return &sliverpb.Sideload{Response: &commonpb.Response{}}, nil
}
func (*fakePackageBackend) SpawnDll(context.Context, *sliverpb.InvokeSpawnDllReq) (*sliverpb.SpawnDll, error) {
return &sliverpb.SpawnDll{Response: &commonpb.Response{}}, nil
}
func (f *fakePackageBackend) RegisterExtension(ctx context.Context, req *sliverpb.RegisterExtensionReq) (*sliverpb.RegisterExtension, error) {
f.registerExtensionReqs = append(f.registerExtensionReqs, req)
if f.registerExtensionFn != nil {
return f.registerExtensionFn(ctx, req)
}
return &sliverpb.RegisterExtension{Response: &commonpb.Response{}}, nil
}
func (f *fakePackageBackend) ListExtensions(ctx context.Context, req *sliverpb.ListExtensionsReq) (*sliverpb.ListExtensions, error) {
f.listExtensionsReqs = append(f.listExtensionsReqs, req)
if f.listExtensionsFn != nil {
return f.listExtensionsFn(ctx, req)
}
return &sliverpb.ListExtensions{Response: &commonpb.Response{}}, nil
}
func (f *fakePackageBackend) CallExtension(ctx context.Context, req *sliverpb.CallExtensionReq) (*sliverpb.CallExtension, error) {
f.callExtensionReqs = append(f.callExtensionReqs, req)
if f.callExtensionFn != nil {
return f.callExtensionFn(ctx, req)
}
return &sliverpb.CallExtension{Response: &commonpb.Response{}}, nil
}
+537
View File
@@ -0,0 +1,537 @@
package aitools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/bishopfox/sliver/client/packages"
serverassets "github.com/bishopfox/sliver/server/assets"
"github.com/bishopfox/sliver/util"
)
const (
aiAliasManifestFileName = "alias.json"
aiExtensionManifestFileName = "extension.json"
)
type aiAliasFile struct {
OS string `json:"os"`
Arch string `json:"arch"`
Path string `json:"path"`
}
type aiAliasArgument struct {
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Optional bool `json:"optional"`
Default any `json:"default,omitempty"`
Choices []string `json:"choices,omitempty"`
}
type aiAliasManifest struct {
Name string `json:"name"`
Version string `json:"version"`
CommandName string `json:"command_name"`
OriginalAuthor string `json:"original_author"`
RepoURL string `json:"repo_url"`
Help string `json:"help"`
LongHelp string `json:"long_help"`
Entrypoint string `json:"entrypoint"`
AllowArgs bool `json:"allow_args"`
DefaultArgs string `json:"default_args"`
IsReflective bool `json:"is_reflective"`
IsAssembly bool `json:"is_assembly"`
Arguments []*aiAliasArgument `json:"arguments"`
Files []*aiAliasFile `json:"files"`
Schema *packages.OutputSchema `json:"schema"`
RootPath string `json:"-"`
}
type aiExtensionFile struct {
OS string `json:"os"`
Arch string `json:"arch"`
Path string `json:"path"`
}
type aiExtensionArgument struct {
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Optional bool `json:"optional"`
Default any `json:"default,omitempty"`
Choices []string `json:"choices,omitempty"`
}
type aiExtensionCommand struct {
CommandName string `json:"command_name"`
Help string `json:"help"`
LongHelp string `json:"long_help"`
Entrypoint string `json:"entrypoint"`
DependsOn string `json:"depends_on"`
Init string `json:"init"`
Files []*aiExtensionFile `json:"files"`
Arguments []*aiExtensionArgument `json:"arguments"`
Schema *packages.OutputSchema `json:"schema"`
Manifest *aiExtensionManifest `json:"-"`
}
type aiExtensionManifest struct {
Name string `json:"name"`
PackageName string `json:"package_name"`
Version string `json:"version"`
ExtensionAuthor string `json:"extension_author"`
OriginalAuthor string `json:"original_author"`
RepoURL string `json:"repo_url"`
ExtCommand []*aiExtensionCommand `json:"commands"`
RootPath string `json:"-"`
}
type aiLegacyExtensionManifest struct {
Name string `json:"name"`
CommandName string `json:"command_name"`
Version string `json:"version"`
ExtensionAuthor string `json:"extension_author"`
OriginalAuthor string `json:"original_author"`
RepoURL string `json:"repo_url"`
Help string `json:"help"`
LongHelp string `json:"long_help"`
Entrypoint string `json:"entrypoint"`
DependsOn string `json:"depends_on"`
Init string `json:"init"`
Files []*aiExtensionFile `json:"files"`
Arguments []*aiExtensionArgument `json:"arguments"`
Schema *packages.OutputSchema `json:"schema"`
RootPath string `json:"-"`
}
type extensionCandidate struct {
Command *aiExtensionCommand
RootPath string
}
func loadAIAliases() ([]*aiAliasManifest, []string) {
storeDir := serverassets.GetAIAliasesDir()
dirEntries, err := os.ReadDir(storeDir)
if err != nil {
return nil, []string{fmt.Sprintf("failed to read alias store %s: %s", storeDir, err)}
}
aliases := []*aiAliasManifest{}
warnings := []string{}
for _, entry := range dirEntries {
if !entry.IsDir() {
continue
}
manifestPath := filepath.Join(storeDir, entry.Name(), aiAliasManifestFileName)
manifest, err := loadAIAliasManifest(manifestPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("skipping alias manifest %s: %s", manifestPath, err))
continue
}
aliases = append(aliases, manifest)
}
sort.Slice(aliases, func(i, j int) bool {
if aliases[i].CommandName == aliases[j].CommandName {
return aliases[i].RootPath < aliases[j].RootPath
}
return aliases[i].CommandName < aliases[j].CommandName
})
sort.Strings(warnings)
return aliases, warnings
}
func loadAIExtensions() ([]*aiExtensionManifest, []string) {
storeDir := serverassets.GetAIExtensionsDir()
dirEntries, err := os.ReadDir(storeDir)
if err != nil {
return nil, []string{fmt.Sprintf("failed to read extension store %s: %s", storeDir, err)}
}
extensions := []*aiExtensionManifest{}
warnings := []string{}
for _, entry := range dirEntries {
if !entry.IsDir() {
continue
}
manifestPath := filepath.Join(storeDir, entry.Name(), aiExtensionManifestFileName)
manifest, err := loadAIExtensionManifest(manifestPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("skipping extension manifest %s: %s", manifestPath, err))
continue
}
extensions = append(extensions, manifest)
}
sort.Slice(extensions, func(i, j int) bool {
if extensionDisplayName(extensions[i]) == extensionDisplayName(extensions[j]) {
return extensions[i].RootPath < extensions[j].RootPath
}
return extensionDisplayName(extensions[i]) < extensionDisplayName(extensions[j])
})
sort.Strings(warnings)
return extensions, warnings
}
func loadAIAliasManifest(manifestPath string) (*aiAliasManifest, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
manifest, err := parseAIAliasManifest(data)
if err != nil {
return nil, err
}
manifest.RootPath = filepath.Dir(manifestPath)
return manifest, nil
}
func loadAIExtensionManifest(manifestPath string) (*aiExtensionManifest, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
manifest, err := parseAIExtensionManifest(data)
if err != nil {
return nil, err
}
manifest.RootPath = filepath.Dir(manifestPath)
for _, command := range manifest.ExtCommand {
command.Manifest = manifest
}
return manifest, nil
}
func parseAIAliasManifest(data []byte) (*aiAliasManifest, error) {
manifest := &aiAliasManifest{}
if err := json.Unmarshal(data, manifest); err != nil {
return nil, err
}
if strings.TrimSpace(manifest.Name) == "" {
return nil, fmt.Errorf("missing alias name in manifest")
}
if strings.TrimSpace(manifest.CommandName) == "" {
return nil, fmt.Errorf("missing command_name in alias manifest")
}
if strings.TrimSpace(manifest.Help) == "" {
return nil, fmt.Errorf("missing help in alias manifest")
}
for _, aliasFile := range manifest.Files {
if aliasFile == nil {
continue
}
if strings.TrimSpace(aliasFile.OS) == "" {
return nil, fmt.Errorf("missing files.os in alias manifest")
}
if strings.TrimSpace(aliasFile.Arch) == "" {
return nil, fmt.Errorf("missing files.arch in alias manifest")
}
aliasFile.OS = strings.ToLower(strings.TrimSpace(aliasFile.OS))
aliasFile.Arch = strings.ToLower(strings.TrimSpace(aliasFile.Arch))
aliasFile.Path = util.ResolvePath(aliasFile.Path)
if aliasFile.Path == "" || aliasFile.Path == string(os.PathSeparator) {
return nil, fmt.Errorf("missing files.path in alias manifest")
}
}
if manifest.Schema != nil {
if !packages.IsValidSchemaType(manifest.Schema.Name) {
return nil, fmt.Errorf("%s is not a valid schema type", manifest.Schema.Name)
}
manifest.Schema.IngestColumns()
}
return manifest, nil
}
func parseAIExtensionManifest(data []byte) (*aiExtensionManifest, error) {
manifest := &aiExtensionManifest{}
if err := json.Unmarshal(data, manifest); err != nil || len(manifest.ExtCommand) == 0 {
legacy := &aiLegacyExtensionManifest{}
if legacyErr := json.Unmarshal(data, legacy); legacyErr != nil {
if err != nil {
return nil, err
}
return nil, legacyErr
}
manifest = convertLegacyAIExtensionManifest(legacy)
}
if strings.TrimSpace(manifest.Name) == "" {
return nil, fmt.Errorf("missing name field in extension manifest")
}
for _, command := range manifest.ExtCommand {
if command == nil {
continue
}
command.Manifest = manifest
if strings.TrimSpace(command.CommandName) == "" {
return nil, fmt.Errorf("missing command_name field in extension manifest")
}
if strings.TrimSpace(command.Help) == "" {
return nil, fmt.Errorf("missing help field in extension manifest")
}
if len(command.Files) == 0 {
return nil, fmt.Errorf("missing files field in extension manifest")
}
for _, extFile := range command.Files {
if extFile == nil {
continue
}
if strings.TrimSpace(extFile.OS) == "" {
return nil, fmt.Errorf("missing files.os field in extension manifest")
}
if strings.TrimSpace(extFile.Arch) == "" {
return nil, fmt.Errorf("missing files.arch field in extension manifest")
}
extFile.OS = strings.ToLower(strings.TrimSpace(extFile.OS))
extFile.Arch = strings.ToLower(strings.TrimSpace(extFile.Arch))
extFile.Path = util.ResolvePath(extFile.Path)
if extFile.Path == "" || extFile.Path == string(os.PathSeparator) {
return nil, fmt.Errorf("missing files.path field in extension manifest")
}
}
if command.Schema != nil {
if !packages.IsValidSchemaType(command.Schema.Name) {
return nil, fmt.Errorf("%s is not a valid schema type", command.Schema.Name)
}
command.Schema.IngestColumns()
}
}
return manifest, nil
}
func convertLegacyAIExtensionManifest(legacy *aiLegacyExtensionManifest) *aiExtensionManifest {
return &aiExtensionManifest{
Name: legacy.CommandName,
Version: legacy.Version,
ExtensionAuthor: legacy.ExtensionAuthor,
OriginalAuthor: legacy.OriginalAuthor,
RepoURL: legacy.RepoURL,
RootPath: legacy.RootPath,
ExtCommand: []*aiExtensionCommand{
{
CommandName: legacy.CommandName,
Help: legacy.Help,
LongHelp: legacy.LongHelp,
Entrypoint: legacy.Entrypoint,
DependsOn: legacy.DependsOn,
Init: legacy.Init,
Files: legacy.Files,
Arguments: legacy.Arguments,
Schema: legacy.Schema,
},
},
}
}
func selectAIAlias(manifests []*aiAliasManifest, commandName string, rootPath string, targetOS string, targetArch string) (*aiAliasManifest, error) {
commandName = normalizePackageCommandName(commandName)
if commandName == "" {
return nil, fmt.Errorf("command_name is required")
}
candidates := []*aiAliasManifest{}
for _, manifest := range manifests {
if manifest == nil {
continue
}
if normalizePackageCommandName(manifest.CommandName) != commandName {
continue
}
if rootPath != "" && !samePackageRootPath(manifest.RootPath, rootPath) {
continue
}
candidates = append(candidates, manifest)
}
switch len(candidates) {
case 0:
if rootPath != "" {
return nil, fmt.Errorf("alias command %q not found at root_path %q", commandName, rootPath)
}
return nil, fmt.Errorf("alias command %q not found in %s", commandName, serverassets.GetAIAliasesDir())
case 1:
return candidates[0], nil
}
if targetOS != "" && targetArch != "" {
compatible := []*aiAliasManifest{}
for _, manifest := range candidates {
if _, _, err := manifest.artifactForTarget(targetOS, targetArch); err == nil {
compatible = append(compatible, manifest)
}
}
if len(compatible) == 1 {
return compatible[0], nil
}
}
paths := make([]string, 0, len(candidates))
for _, manifest := range candidates {
paths = append(paths, manifest.RootPath)
}
sort.Strings(paths)
return nil, fmt.Errorf("alias command %q is ambiguous; specify root_path from search_aliases: %s", commandName, strings.Join(paths, ", "))
}
func selectAIExtensionCommand(manifests []*aiExtensionManifest, commandName string, rootPath string, targetOS string, targetArch string) (*aiExtensionCommand, error) {
commandName = normalizePackageCommandName(commandName)
if commandName == "" {
return nil, fmt.Errorf("command_name is required")
}
candidates := []extensionCandidate{}
for _, manifest := range manifests {
if manifest == nil {
continue
}
if rootPath != "" && !samePackageRootPath(manifest.RootPath, rootPath) {
continue
}
for _, command := range manifest.ExtCommand {
if command == nil {
continue
}
if normalizePackageCommandName(command.CommandName) == commandName {
candidates = append(candidates, extensionCandidate{Command: command, RootPath: manifest.RootPath})
}
}
}
switch len(candidates) {
case 0:
if rootPath != "" {
return nil, fmt.Errorf("extension command %q not found at root_path %q", commandName, rootPath)
}
return nil, fmt.Errorf("extension command %q not found in %s", commandName, serverassets.GetAIExtensionsDir())
case 1:
return candidates[0].Command, nil
}
if targetOS != "" && targetArch != "" {
compatible := []*aiExtensionCommand{}
for _, candidate := range candidates {
if _, _, err := candidate.Command.artifactForTarget(targetOS, targetArch); err == nil {
compatible = append(compatible, candidate.Command)
}
}
if len(compatible) == 1 {
return compatible[0], nil
}
}
paths := make([]string, 0, len(candidates))
for _, candidate := range candidates {
paths = append(paths, candidate.RootPath)
}
sort.Strings(paths)
return nil, fmt.Errorf("extension command %q is ambiguous; specify root_path from search_extensions: %s", commandName, strings.Join(paths, ", "))
}
func (manifest *aiAliasManifest) artifactForTarget(targetOS string, targetArch string) (string, *aiAliasFile, error) {
for _, aliasFile := range manifest.Files {
if aliasFile == nil {
continue
}
if aliasFile.OS != strings.ToLower(strings.TrimSpace(targetOS)) || aliasFile.Arch != strings.ToLower(strings.TrimSpace(targetArch)) {
continue
}
artifactPath := joinPackageArtifactPath(manifest.RootPath, aliasFile.Path)
if _, err := os.Stat(artifactPath); err != nil {
return "", aliasFile, fmt.Errorf("alias artifact not found: %s", artifactPath)
}
return artifactPath, aliasFile, nil
}
return "", nil, fmt.Errorf("no alias artifact found for %s/%s", targetOS, targetArch)
}
func (command *aiExtensionCommand) artifactForTarget(targetOS string, targetArch string) (string, *aiExtensionFile, error) {
for _, extFile := range command.Files {
if extFile == nil {
continue
}
if extFile.OS != strings.ToLower(strings.TrimSpace(targetOS)) || extFile.Arch != strings.ToLower(strings.TrimSpace(targetArch)) {
continue
}
artifactPath := joinPackageArtifactPath(command.Manifest.RootPath, extFile.Path)
if _, err := os.Stat(artifactPath); err != nil {
return "", extFile, fmt.Errorf("extension artifact not found: %s", artifactPath)
}
return artifactPath, extFile, nil
}
return "", nil, fmt.Errorf("no extension artifact found for %s/%s", targetOS, targetArch)
}
func aliasPlatforms(manifest *aiAliasManifest) []string {
platforms := map[string]struct{}{}
for _, aliasFile := range manifest.Files {
if aliasFile == nil {
continue
}
platforms[fmt.Sprintf("%s/%s", aliasFile.OS, aliasFile.Arch)] = struct{}{}
}
return sortedMapKeys(platforms)
}
func extensionPlatforms(command *aiExtensionCommand) []string {
platforms := map[string]struct{}{}
for _, extFile := range command.Files {
if extFile == nil {
continue
}
platforms[fmt.Sprintf("%s/%s", extFile.OS, extFile.Arch)] = struct{}{}
}
return sortedMapKeys(platforms)
}
func extensionDisplayName(manifest *aiExtensionManifest) string {
if manifest == nil {
return ""
}
if strings.TrimSpace(manifest.PackageName) != "" {
return strings.TrimSpace(manifest.PackageName)
}
return strings.TrimSpace(manifest.Name)
}
func normalizePackageCommandName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func samePackageRootPath(left string, right string) bool {
if left == "" || right == "" {
return false
}
return filepath.Clean(left) == filepath.Clean(right)
}
func joinPackageArtifactPath(rootPath string, manifestPath string) string {
trimmed := strings.TrimLeft(strings.ReplaceAll(manifestPath, "\\", "/"), "/")
return filepath.Join(rootPath, filepath.FromSlash(trimmed))
}
func sortedMapKeys(values map[string]struct{}) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
+805
View File
@@ -0,0 +1,805 @@
package aitools
import (
"context"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
clientcredentials "github.com/bishopfox/sliver/client/credentials"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
serverai "github.com/bishopfox/sliver/server/ai"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
serverloot "github.com/bishopfox/sliver/server/loot"
"github.com/gofrs/uuid"
)
const aiLootReadDefaultMaxBytes = 64 * 1024
var (
hashTypeAliases = map[string]string{
"NTLM": "MD4",
"SHA224": "SHA2_224",
"SHA256": "SHA2_256",
"SHA384": "SHA2_384",
"SHA512": "SHA2_512",
}
fileTypeAliases = map[string]string{
"BIN": "BINARY",
"TXT": "TEXT",
}
)
type lootAddToolArgs struct {
Name string `json:"name,omitempty"`
FileName string `json:"file_name,omitempty"`
FileTypeName string `json:"file_type_name,omitempty"`
FileTypeValue *int32 `json:"file_type_value,omitempty"`
OriginHostUUID string `json:"origin_host_uuid,omitempty"`
Text string `json:"text,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
}
type lootReadToolArgs struct {
LootID string `json:"loot_id,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
}
type lootDeleteToolArgs struct {
LootID string `json:"loot_id,omitempty"`
}
type credentialsAddToolArgs struct {
Collection string `json:"collection,omitempty"`
Username string `json:"username,omitempty"`
Plaintext string `json:"plaintext,omitempty"`
Hash string `json:"hash,omitempty"`
HashTypeName string `json:"hash_type_name,omitempty"`
HashTypeValue *int32 `json:"hash_type_value,omitempty"`
OriginHostUUID string `json:"origin_host_uuid,omitempty"`
}
type credentialReadToolArgs struct {
CredentialID string `json:"credential_id,omitempty"`
}
type credentialDeleteToolArgs struct {
CredentialID string `json:"credential_id,omitempty"`
}
type lootSummary struct {
ID string `json:"id"`
Name string `json:"name"`
FileTypeName string `json:"file_type_name"`
FileTypeValue int32 `json:"file_type_value"`
OriginHostUUID string `json:"origin_host_uuid,omitempty"`
Size int64 `json:"size"`
HasFile bool `json:"has_file"`
FileName string `json:"file_name,omitempty"`
}
type lootListResult struct {
Loot []lootSummary `json:"loot"`
Count int `json:"count"`
}
type lootReadResult struct {
RequestedID string `json:"requested_id"`
RequestedMaxBytes int64 `json:"requested_max_bytes"`
TotalByteLen int `json:"total_byte_len"`
ReturnedByteLen int `json:"returned_byte_len"`
Truncated bool `json:"truncated"`
SHA256 string `json:"sha256,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
Text string `json:"text,omitempty"`
lootSummary
}
type lootDeleteResult struct {
RequestedID string `json:"requested_id"`
Deleted lootSummary `json:"deleted"`
}
type credentialSummary struct {
ID string `json:"id"`
Collection string `json:"collection,omitempty"`
Username string `json:"username,omitempty"`
OriginHostUUID string `json:"origin_host_uuid,omitempty"`
IsCracked bool `json:"is_cracked"`
HasPlaintext bool `json:"has_plaintext"`
HasHash bool `json:"has_hash"`
HashTypeName string `json:"hash_type_name,omitempty"`
HashTypeValue *int32 `json:"hash_type_value,omitempty"`
}
type credentialsListResult struct {
Credentials []credentialSummary `json:"credentials"`
Count int `json:"count"`
}
type credentialReadResult struct {
Plaintext string `json:"plaintext,omitempty"`
Hash string `json:"hash,omitempty"`
credentialSummary
}
type credentialDeleteResult struct {
RequestedID string `json:"requested_id"`
Deleted credentialSummary `json:"deleted"`
}
func storeToolDefinitions() []serverai.AgenticToolDefinition {
return []serverai.AgenticToolDefinition{
{
Name: "loot_list",
Description: "List loot entries from the server loot store. Results include metadata only, not file content.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
{
Name: "loot_read",
Description: "Read one loot entry from the server loot store and return metadata plus up to max_bytes of file content. Defaults to 65536 bytes when max_bytes is omitted.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"loot_id": map[string]any{"type": "string", "description": "Loot ID or unique ID prefix to read."},
"max_bytes": map[string]any{"type": "integer", "description": "Optional maximum number of bytes to return from the loot file content."},
},
"required": []string{"loot_id"},
"additionalProperties": false,
},
},
{
Name: "loot_add",
Description: "Add a new entry to the server loot store from inline text or base64 bytes. Provide either text or data_base64. file_name defaults to name, and file_type defaults to TEXT for text or BINARY for base64 content.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"data_base64": map[string]any{"type": "string", "description": "Binary loot content encoded as base64. Provide either text or data_base64."},
"file_name": map[string]any{"type": "string", "description": "Optional filename stored with the loot entry. Defaults to name."},
"file_type_name": map[string]any{"type": "string", "description": "Optional file type name such as TEXT or BINARY."},
"file_type_value": map[string]any{"type": "integer", "description": "Optional numeric file type enum value."},
"name": map[string]any{"type": "string", "description": "Display name for the loot entry."},
"origin_host_uuid": map[string]any{"type": "string", "description": "Optional origin host UUID for the loot entry."},
"text": map[string]any{"type": "string", "description": "Text loot content. Provide either text or data_base64."},
},
"required": []string{"name"},
"additionalProperties": false,
},
},
{
Name: "loot_delete",
Description: "Delete one loot entry from the server loot store using a full ID or unique prefix.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"loot_id": map[string]any{"type": "string", "description": "Loot ID or unique ID prefix to delete."},
},
"required": []string{"loot_id"},
"additionalProperties": false,
},
},
{
Name: "credentials_list",
Description: "List credentials from the server credentials store. Results include metadata only, not plaintext or hash bodies.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
{
Name: "credentials_read",
Description: "Read one credential from the server credentials store using a full ID or unique prefix. Returns the stored plaintext and hash values when present.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"credential_id": map[string]any{"type": "string", "description": "Credential ID or unique ID prefix to read."},
},
"required": []string{"credential_id"},
"additionalProperties": false,
},
},
{
Name: "credentials_add",
Description: "Add one credential to the server credentials store. If hash_type is omitted and hash is provided, the server sniffs the hash type automatically.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"collection": map[string]any{"type": "string", "description": "Optional credential collection or source label."},
"hash": map[string]any{"type": "string", "description": "Optional password hash."},
"hash_type_name": map[string]any{"type": "string", "description": "Optional hash type name such as MD4, NTLM, or SHA2_256."},
"hash_type_value": map[string]any{"type": "integer", "description": "Optional numeric hash type enum value."},
"origin_host_uuid": map[string]any{"type": "string", "description": "Optional origin host UUID for the credential."},
"plaintext": map[string]any{"type": "string", "description": "Optional plaintext password or secret."},
"username": map[string]any{"type": "string", "description": "Optional username or account identifier."},
},
"additionalProperties": false,
},
},
{
Name: "credentials_delete",
Description: "Delete one credential from the server credentials store using a full ID or unique prefix.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"credential_id": map[string]any{"type": "string", "description": "Credential ID or unique ID prefix to delete."},
},
"required": []string{"credential_id"},
"additionalProperties": false,
},
},
}
}
func (e *executor) callStoreTool(ctx context.Context, name string, arguments string) (string, bool, error) {
switch strings.TrimSpace(name) {
case "loot_list":
result, err := e.callLootList(ctx)
return result, true, err
case "loot_read":
var args lootReadToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := e.callLootRead(ctx, args)
return result, true, err
case "loot_add":
var args lootAddToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := e.callLootAdd(ctx, args)
return result, true, err
case "loot_delete":
var args lootDeleteToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := e.callLootDelete(ctx, args)
return result, true, err
case "credentials_list":
result, err := e.callCredentialsList(ctx)
return result, true, err
case "credentials_read":
var args credentialReadToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := e.callCredentialsRead(ctx, args)
return result, true, err
case "credentials_add":
var args credentialsAddToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := e.callCredentialsAdd(ctx, args)
return result, true, err
case "credentials_delete":
var args credentialDeleteToolArgs
if err := decodeToolArgs(arguments, &args); err != nil {
return "", true, err
}
result, err := e.callCredentialsDelete(ctx, args)
return result, true, err
default:
return "", false, nil
}
}
func (e *executor) callLootList(_ context.Context) (string, error) {
allLoot := serverloot.GetLootStore().All()
if allLoot == nil {
return "", fmt.Errorf("failed to load loot store")
}
summaries := make([]lootSummary, 0, len(allLoot.GetLoot()))
for _, entry := range allLoot.GetLoot() {
if entry == nil {
continue
}
summaries = append(summaries, lootSummaryFromProtobuf(entry))
}
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Name == summaries[j].Name {
return summaries[i].ID < summaries[j].ID
}
return summaries[i].Name < summaries[j].Name
})
return marshalToolResult(lootListResult{
Loot: summaries,
Count: len(summaries),
})
}
func (e *executor) callLootAdd(_ context.Context, args lootAddToolArgs) (string, error) {
name := strings.TrimSpace(args.Name)
if name == "" {
return "", fmt.Errorf("name is required")
}
data, err := decodeLootContent(args.Text, args.DataBase64)
if err != nil {
return "", err
}
fileType, err := resolveLootFileType(args.FileTypeName, args.FileTypeValue, args.Text, args.DataBase64)
if err != nil {
return "", err
}
fileName := strings.TrimSpace(args.FileName)
if fileName == "" {
fileName = name
}
entry, err := serverloot.GetLootStore().Add(&clientpb.Loot{
Name: name,
FileType: fileType,
OriginHostUUID: strings.TrimSpace(args.OriginHostUUID),
File: &commonpb.File{
Name: fileName,
Data: data,
},
})
if err != nil {
return "", err
}
return marshalToolResult(lootSummaryFromProtobuf(entry))
}
func (e *executor) callLootRead(_ context.Context, args lootReadToolArgs) (string, error) {
record, err := resolveLootRecord(args.LootID)
if err != nil {
return "", err
}
entry, err := serverloot.GetLootStore().GetContent(record.ID.String(), true)
if err != nil {
return "", err
}
maxBytes := args.MaxBytes
if maxBytes <= 0 {
maxBytes = aiLootReadDefaultMaxBytes
}
data := []byte(nil)
if entry.GetFile() != nil {
data = entry.GetFile().GetData()
}
totalLen := len(data)
returned := data
if int64(len(returned)) > maxBytes {
returned = returned[:maxBytes]
}
result := lootReadResult{
RequestedID: strings.TrimSpace(args.LootID),
RequestedMaxBytes: maxBytes,
TotalByteLen: totalLen,
ReturnedByteLen: len(returned),
Truncated: len(returned) < totalLen,
lootSummary: lootSummaryFromProtobuf(entry),
}
if totalLen > 0 {
result.SHA256 = sha256Hex(data)
result.DataBase64 = base64.StdEncoding.EncodeToString(returned)
if len(returned) > 0 {
result.Text, _ = bytesToTextAndBase64(returned)
}
}
return marshalToolResult(result)
}
func (e *executor) callLootDelete(_ context.Context, args lootDeleteToolArgs) (string, error) {
record, err := resolveLootRecord(args.LootID)
if err != nil {
return "", err
}
entry, err := serverloot.GetLootStore().GetContent(record.ID.String(), false)
if err != nil {
return "", err
}
if err := serverloot.GetLootStore().Rm(record.ID.String()); err != nil {
return "", err
}
return marshalToolResult(lootDeleteResult{
RequestedID: strings.TrimSpace(args.LootID),
Deleted: lootSummaryFromProtobuf(entry),
})
}
func (e *executor) callCredentialsList(_ context.Context) (string, error) {
dbCreds := []*models.Credential{}
if err := db.Session().Where(&models.Credential{}).Find(&dbCreds).Error; err != nil {
return "", err
}
summaries := make([]credentialSummary, 0, len(dbCreds))
for _, cred := range dbCreds {
if cred == nil {
continue
}
summaries = append(summaries, credentialSummaryFromProtobuf(cred.ToProtobuf()))
}
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Collection == summaries[j].Collection {
if summaries[i].Username == summaries[j].Username {
return summaries[i].ID < summaries[j].ID
}
return summaries[i].Username < summaries[j].Username
}
return summaries[i].Collection < summaries[j].Collection
})
return marshalToolResult(credentialsListResult{
Credentials: summaries,
Count: len(summaries),
})
}
func (e *executor) callCredentialsAdd(_ context.Context, args credentialsAddToolArgs) (string, error) {
if strings.TrimSpace(args.Username) == "" && args.Plaintext == "" && args.Hash == "" {
return "", fmt.Errorf("at least one of username, plaintext, or hash is required")
}
hashType, err := resolveHashType(args.HashTypeName, args.HashTypeValue, args.Hash)
if err != nil {
return "", err
}
record := &models.Credential{
Collection: strings.TrimSpace(args.Collection),
Username: strings.TrimSpace(args.Username),
Plaintext: args.Plaintext,
Hash: args.Hash,
HashType: int32(hashType),
IsCracked: args.Plaintext != "" && args.Hash != "",
OriginHostUUID: uuid.FromStringOrNil(strings.TrimSpace(args.OriginHostUUID)),
}
if err := db.Session().Create(record).Error; err != nil {
return "", err
}
return marshalToolResult(credentialReadResultFromProtobuf(record.ToProtobuf()))
}
func (e *executor) callCredentialsRead(_ context.Context, args credentialReadToolArgs) (string, error) {
record, err := resolveCredentialRecord(args.CredentialID)
if err != nil {
return "", err
}
return marshalToolResult(credentialReadResultFromProtobuf(record.ToProtobuf()))
}
func (e *executor) callCredentialsDelete(_ context.Context, args credentialDeleteToolArgs) (string, error) {
record, err := resolveCredentialRecord(args.CredentialID)
if err != nil {
return "", err
}
summary := credentialSummaryFromProtobuf(record.ToProtobuf())
if err := db.Session().Delete(record).Error; err != nil {
return "", err
}
return marshalToolResult(credentialDeleteResult{
RequestedID: strings.TrimSpace(args.CredentialID),
Deleted: summary,
})
}
func decodeLootContent(text string, dataBase64 string) ([]byte, error) {
hasText := text != ""
hasData := strings.TrimSpace(dataBase64) != ""
switch {
case hasText && hasData:
return nil, fmt.Errorf("provide only one of text or data_base64")
case hasText:
return []byte(text), nil
case hasData:
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataBase64))
if err != nil {
return nil, fmt.Errorf("invalid data_base64: %w", err)
}
return data, nil
default:
return nil, fmt.Errorf("text or data_base64 is required")
}
}
func resolveLootFileType(name string, value *int32, text string, dataBase64 string) (clientpb.FileType, error) {
valueType, hasValue, err := parseFileTypeValue(value)
if err != nil {
return 0, err
}
nameType, hasName, err := parseFileTypeName(name)
if err != nil {
return 0, err
}
if hasValue && hasName {
if valueType != nameType {
return 0, fmt.Errorf("file_type_name and file_type_value refer to different file types")
}
return valueType, nil
}
if hasValue {
return valueType, nil
}
if hasName {
return nameType, nil
}
if text != "" {
return clientpb.FileType_TEXT, nil
}
if strings.TrimSpace(dataBase64) != "" {
return clientpb.FileType_BINARY, nil
}
return 0, fmt.Errorf("file content is required")
}
func resolveHashType(name string, value *int32, hash string) (clientpb.HashType, error) {
valueType, hasValue, err := parseHashTypeValue(value)
if err != nil {
return 0, err
}
nameType, hasName, err := parseHashTypeName(name)
if err != nil {
return 0, err
}
if hasValue && hasName {
if valueType != nameType {
return 0, fmt.Errorf("hash_type_name and hash_type_value refer to different hash types")
}
return valueType, nil
}
if hasValue {
return valueType, nil
}
if hasName {
return nameType, nil
}
if hash != "" {
return clientcredentials.SniffHashType(hash), nil
}
return clientpb.HashType_MD5, nil
}
func parseFileTypeName(name string) (clientpb.FileType, bool, error) {
normalized := normalizeEnumName(name)
if normalized == "" {
return 0, false, nil
}
if alias, ok := fileTypeAliases[normalized]; ok {
normalized = alias
}
value, ok := clientpb.FileType_value[normalized]
if !ok {
return 0, false, fmt.Errorf("unsupported file_type_name %q", name)
}
return clientpb.FileType(value), true, nil
}
func parseFileTypeValue(value *int32) (clientpb.FileType, bool, error) {
if value == nil {
return 0, false, nil
}
if _, ok := clientpb.FileType_name[*value]; !ok {
return 0, false, fmt.Errorf("unsupported file_type_value %d", *value)
}
return clientpb.FileType(*value), true, nil
}
func parseHashTypeName(name string) (clientpb.HashType, bool, error) {
normalized := normalizeEnumName(name)
if normalized == "" {
return 0, false, nil
}
if alias, ok := hashTypeAliases[normalized]; ok {
normalized = alias
}
value, ok := clientpb.HashType_value[normalized]
if !ok {
return 0, false, fmt.Errorf("unsupported hash_type_name %q", name)
}
return clientpb.HashType(value), true, nil
}
func parseHashTypeValue(value *int32) (clientpb.HashType, bool, error) {
if value == nil {
return 0, false, nil
}
if _, ok := clientpb.HashType_name[*value]; !ok {
return 0, false, fmt.Errorf("unsupported hash_type_value %d", *value)
}
return clientpb.HashType(*value), true, nil
}
func normalizeEnumName(name string) string {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return ""
}
replacer := strings.NewReplacer("-", "_", " ", "_")
return replacer.Replace(name)
}
func resolveLootRecord(id string) (*models.Loot, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, fmt.Errorf("loot_id is required")
}
if lootID := uuid.FromStringOrNil(id); lootID != uuid.Nil {
record := &models.Loot{}
err := db.Session().Where(&models.Loot{ID: lootID}).First(record).Error
if err == nil {
return record, nil
}
if !errors.Is(err, db.ErrRecordNotFound) {
return nil, err
}
}
records := []*models.Loot{}
if err := db.Session().Where(&models.Loot{}).Find(&records).Error; err != nil {
return nil, err
}
matches := make([]*models.Loot, 0, 1)
for _, record := range records {
if record != nil && strings.HasPrefix(record.ID.String(), id) {
matches = append(matches, record)
}
}
switch len(matches) {
case 0:
return nil, fmt.Errorf("loot %q not found", id)
case 1:
return matches[0], nil
default:
return nil, fmt.Errorf("loot ID prefix %q is ambiguous: %s", id, joinRecordIDsLoot(matches))
}
}
func resolveCredentialRecord(id string) (*models.Credential, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, fmt.Errorf("credential_id is required")
}
if credentialID := uuid.FromStringOrNil(id); credentialID != uuid.Nil {
record := &models.Credential{}
err := db.Session().Where(&models.Credential{ID: credentialID}).First(record).Error
if err == nil {
return record, nil
}
if !errors.Is(err, db.ErrRecordNotFound) {
return nil, err
}
}
records := []*models.Credential{}
if err := db.Session().Where(&models.Credential{}).Find(&records).Error; err != nil {
return nil, err
}
matches := make([]*models.Credential, 0, 1)
for _, record := range records {
if record != nil && strings.HasPrefix(record.ID.String(), id) {
matches = append(matches, record)
}
}
switch len(matches) {
case 0:
return nil, fmt.Errorf("credential %q not found", id)
case 1:
return matches[0], nil
default:
return nil, fmt.Errorf("credential ID prefix %q is ambiguous: %s", id, joinRecordIDsCredential(matches))
}
}
func joinRecordIDsLoot(records []*models.Loot) string {
ids := make([]string, 0, len(records))
for _, record := range records {
if record == nil || record.ID == uuid.Nil {
continue
}
ids = append(ids, record.ID.String())
}
return joinRecordIDs(ids)
}
func joinRecordIDsCredential(records []*models.Credential) string {
ids := make([]string, 0, len(records))
for _, record := range records {
if record == nil || record.ID == uuid.Nil {
continue
}
ids = append(ids, record.ID.String())
}
return joinRecordIDs(ids)
}
func joinRecordIDs(ids []string) string {
sort.Strings(ids)
if len(ids) > 5 {
ids = append(ids[:5], "...")
}
return strings.Join(ids, ", ")
}
func lootSummaryFromProtobuf(entry *clientpb.Loot) lootSummary {
summary := lootSummary{
ID: strings.TrimSpace(entry.GetID()),
Name: strings.TrimSpace(entry.GetName()),
FileTypeName: fileTypeName(entry.GetFileType()),
FileTypeValue: int32(entry.GetFileType()),
OriginHostUUID: cleanUUIDString(entry.GetOriginHostUUID()),
Size: entry.GetSize(),
HasFile: entry.GetFile() != nil,
}
if entry.GetFile() != nil {
summary.FileName = strings.TrimSpace(entry.GetFile().GetName())
}
return summary
}
func credentialSummaryFromProtobuf(cred *clientpb.Credential) credentialSummary {
summary := credentialSummary{
ID: strings.TrimSpace(cred.GetID()),
Collection: strings.TrimSpace(cred.GetCollection()),
Username: strings.TrimSpace(cred.GetUsername()),
OriginHostUUID: cleanUUIDString(cred.GetOriginHostUUID()),
IsCracked: cred.GetIsCracked(),
HasPlaintext: cred.GetPlaintext() != "",
HasHash: cred.GetHash() != "",
}
if summary.HasHash {
value := int32(cred.GetHashType())
summary.HashTypeName = hashTypeName(cred.GetHashType())
summary.HashTypeValue = &value
}
return summary
}
func credentialReadResultFromProtobuf(cred *clientpb.Credential) credentialReadResult {
return credentialReadResult{
Plaintext: cred.GetPlaintext(),
Hash: cred.GetHash(),
credentialSummary: credentialSummaryFromProtobuf(cred),
}
}
func fileTypeName(fileType clientpb.FileType) string {
if name, ok := clientpb.FileType_name[int32(fileType)]; ok {
return name
}
return fmt.Sprintf("%d", fileType)
}
func hashTypeName(hashType clientpb.HashType) string {
if name, ok := clientpb.HashType_name[int32(hashType)]; ok {
return name
}
return fmt.Sprintf("%d", hashType)
}
func cleanUUIDString(raw string) string {
raw = strings.TrimSpace(raw)
if raw == uuid.Nil.String() {
return ""
}
return raw
}
+218
View File
@@ -0,0 +1,218 @@
package aitools
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestCredentialsStoreToolsRoundTrip(t *testing.T) {
setupStoreToolsTestEnv(t)
exec := &executor{}
ctx := context.Background()
addedRaw, err := exec.CallTool(ctx, "credentials_add", `{
"collection":"corp",
"username":"alice",
"plaintext":"Password123!",
"hash":"8846f7eaee8fb117ad06bdd830b7586c",
"hash_type_name":"NTLM"
}`)
if err != nil {
t.Fatalf("add credential: %v", err)
}
var added credentialReadResult
if err := json.Unmarshal([]byte(addedRaw), &added); err != nil {
t.Fatalf("decode add result: %v", err)
}
if added.ID == "" {
t.Fatal("expected credential ID to be returned")
}
if added.HashTypeName != "MD4" {
t.Fatalf("expected NTLM alias to resolve to MD4, got %q", added.HashTypeName)
}
if !added.HasPlaintext || !added.HasHash {
t.Fatalf("expected stored credential material to be reflected in add result, got %+v", added)
}
listedRaw, err := exec.CallTool(ctx, "credentials_list", `{}`)
if err != nil {
t.Fatalf("list credentials: %v", err)
}
var listed credentialsListResult
if err := json.Unmarshal([]byte(listedRaw), &listed); err != nil {
t.Fatalf("decode list result: %v", err)
}
if listed.Count != 1 || len(listed.Credentials) != 1 {
t.Fatalf("unexpected credential count: %+v", listed)
}
prefix := added.ID[:8]
readRaw, err := exec.CallTool(ctx, "credentials_read", fmt.Sprintf(`{"credential_id":%q}`, prefix))
if err != nil {
t.Fatalf("read credential: %v", err)
}
var read credentialReadResult
if err := json.Unmarshal([]byte(readRaw), &read); err != nil {
t.Fatalf("decode read result: %v", err)
}
if read.ID != added.ID {
t.Fatalf("expected credential prefix lookup to resolve %q, got %+v", added.ID, read)
}
if read.Plaintext != "Password123!" || read.Hash != "8846f7eaee8fb117ad06bdd830b7586c" {
t.Fatalf("unexpected credential body: %+v", read)
}
deletedRaw, err := exec.CallTool(ctx, "credentials_delete", fmt.Sprintf(`{"credential_id":%q}`, prefix))
if err != nil {
t.Fatalf("delete credential: %v", err)
}
var deleted credentialDeleteResult
if err := json.Unmarshal([]byte(deletedRaw), &deleted); err != nil {
t.Fatalf("decode delete result: %v", err)
}
if deleted.Deleted.ID != added.ID {
t.Fatalf("unexpected deleted credential: %+v", deleted)
}
listedRaw, err = exec.CallTool(ctx, "credentials_list", `{}`)
if err != nil {
t.Fatalf("list credentials after delete: %v", err)
}
if err := json.Unmarshal([]byte(listedRaw), &listed); err != nil {
t.Fatalf("decode list-after-delete result: %v", err)
}
if listed.Count != 0 || len(listed.Credentials) != 0 {
t.Fatalf("expected empty credentials store after delete, got %+v", listed)
}
}
func TestLootStoreToolsRoundTrip(t *testing.T) {
setupStoreToolsTestEnv(t)
exec := &executor{}
ctx := context.Background()
content := "top secret\nline two"
addedRaw, err := exec.CallTool(ctx, "loot_add", `{
"name":"operator-notes",
"file_name":"notes.txt",
"text":"top secret\nline two"
}`)
if err != nil {
t.Fatalf("add loot: %v", err)
}
var added lootSummary
if err := json.Unmarshal([]byte(addedRaw), &added); err != nil {
t.Fatalf("decode add result: %v", err)
}
if added.ID == "" {
t.Fatal("expected loot ID to be returned")
}
if added.FileTypeName != "TEXT" {
t.Fatalf("expected text loot type, got %+v", added)
}
listedRaw, err := exec.CallTool(ctx, "loot_list", `{}`)
if err != nil {
t.Fatalf("list loot: %v", err)
}
var listed lootListResult
if err := json.Unmarshal([]byte(listedRaw), &listed); err != nil {
t.Fatalf("decode loot list result: %v", err)
}
if listed.Count != 1 || len(listed.Loot) != 1 {
t.Fatalf("unexpected loot list result: %+v", listed)
}
prefix := added.ID[:8]
readRaw, err := exec.CallTool(ctx, "loot_read", fmt.Sprintf(`{"loot_id":%q,"max_bytes":4}`, prefix))
if err != nil {
t.Fatalf("read loot: %v", err)
}
var read lootReadResult
if err := json.Unmarshal([]byte(readRaw), &read); err != nil {
t.Fatalf("decode loot read result: %v", err)
}
if read.ID != added.ID {
t.Fatalf("expected loot prefix lookup to resolve %q, got %+v", added.ID, read)
}
if !read.Truncated || read.ReturnedByteLen != 4 || read.TotalByteLen != len(content) {
t.Fatalf("unexpected loot read sizing: %+v", read)
}
if read.Text != content[:4] {
t.Fatalf("unexpected loot text snippet: %+v", read)
}
decoded, err := base64.StdEncoding.DecodeString(read.DataBase64)
if err != nil {
t.Fatalf("decode loot data: %v", err)
}
if string(decoded) != content[:4] {
t.Fatalf("unexpected loot data snippet: %q", string(decoded))
}
deletedRaw, err := exec.CallTool(ctx, "loot_delete", fmt.Sprintf(`{"loot_id":%q}`, prefix))
if err != nil {
t.Fatalf("delete loot: %v", err)
}
var deleted lootDeleteResult
if err := json.Unmarshal([]byte(deletedRaw), &deleted); err != nil {
t.Fatalf("decode loot delete result: %v", err)
}
if deleted.Deleted.ID != added.ID {
t.Fatalf("unexpected deleted loot: %+v", deleted)
}
listedRaw, err = exec.CallTool(ctx, "loot_list", `{}`)
if err != nil {
t.Fatalf("list loot after delete: %v", err)
}
if err := json.Unmarshal([]byte(listedRaw), &listed); err != nil {
t.Fatalf("decode loot list-after-delete result: %v", err)
}
if listed.Count != 0 || len(listed.Loot) != 0 {
t.Fatalf("expected empty loot store after delete, got %+v", listed)
}
}
func setupStoreToolsTestEnv(t *testing.T) {
t.Helper()
rootDir := t.TempDir()
t.Setenv("SLIVER_ROOT_DIR", rootDir)
originalDB := db.Client
testDB, err := gorm.Open(sqlite.Open(filepath.Join(rootDir, "store-tools-test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := testDB.AutoMigrate(&models.Credential{}, &models.Loot{}); err != nil {
t.Fatalf("migrate test db: %v", err)
}
db.Client = testDB
t.Cleanup(func() {
sqlDB, err := testDB.DB()
if err == nil {
_ = sqlDB.Close()
}
db.Client = originalDB
})
}
+729
View File
@@ -0,0 +1,729 @@
package aitools
import (
"context"
"encoding/base64"
"fmt"
"sort"
"strings"
"time"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
serverai "github.com/bishopfox/sliver/server/ai"
)
type netstatToolArgs struct {
targetArgs
TCP bool `json:"tcp,omitempty"`
UDP bool `json:"udp,omitempty"`
IP4 bool `json:"ip4,omitempty"`
IP6 bool `json:"ip6,omitempty"`
Listening bool `json:"listening,omitempty"`
}
type psToolArgs struct {
targetArgs
FullInfo bool `json:"full_info,omitempty"`
PID int32 `json:"pid,omitempty"`
Exe string `json:"exe,omitempty"`
Owner string `json:"owner,omitempty"`
}
type envToolArgs struct {
targetArgs
Name string `json:"name,omitempty"`
}
type execToolArgs struct {
targetArgs
Path string `json:"path,omitempty"`
Args []string `json:"args,omitempty"`
CaptureOutput *bool `json:"capture_output,omitempty"`
Background bool `json:"background,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
EnvInheritance bool `json:"env_inheritance,omitempty"`
Env map[string]string `json:"env,omitempty"`
UseToken bool `json:"use_token,omitempty"`
HideWindow bool `json:"hide_window,omitempty"`
PPID uint32 `json:"ppid,omitempty"`
}
type pingToolArgs struct {
targetArgs
Nonce *int32 `json:"nonce,omitempty"`
}
type screenshotToolArgs struct {
targetArgs
IncludeData bool `json:"include_data,omitempty"`
}
type ifconfigInterfaceResult struct {
Index int32 `json:"index"`
Name string `json:"name"`
MAC string `json:"mac,omitempty"`
IPAddresses []string `json:"ip_addresses"`
}
type ifconfigResult struct {
Interfaces []ifconfigInterfaceResult `json:"interfaces"`
Count int `json:"count"`
}
type processResult struct {
PID int32 `json:"pid"`
PPID int32 `json:"ppid"`
Executable string `json:"executable"`
Owner string `json:"owner,omitempty"`
Architecture string `json:"architecture,omitempty"`
SessionID int32 `json:"session_id,omitempty"`
CmdLine []string `json:"cmd_line,omitempty"`
}
type psResult struct {
Processes []processResult `json:"processes"`
Count int `json:"count"`
}
type sockAddrResult struct {
IP string `json:"ip"`
Port uint32 `json:"port"`
}
type netstatEntryResult struct {
LocalAddr sockAddrResult `json:"local_addr"`
RemoteAddr sockAddrResult `json:"remote_addr"`
State string `json:"state"`
UID uint32 `json:"uid"`
Protocol string `json:"protocol"`
Process *processResult `json:"process,omitempty"`
}
type netstatResult struct {
Entries []netstatEntryResult `json:"entries"`
Count int `json:"count"`
}
type envVarResult struct {
Key string `json:"key"`
Value string `json:"value"`
}
type envResult struct {
Variables []envVarResult `json:"variables"`
Count int `json:"count"`
}
type targetInfoResult struct {
TargetType string `json:"target_type"`
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
UUID string `json:"uuid"`
Username string `json:"username"`
UID string `json:"uid"`
GID string `json:"gid"`
PID int32 `json:"pid"`
OS string `json:"os"`
Arch string `json:"arch"`
Transport string `json:"transport"`
ActiveC2 string `json:"active_c2"`
RemoteAddress string `json:"remote_address"`
ProxyURL string `json:"proxy_url"`
Version string `json:"version"`
Locale string `json:"locale"`
Integrity string `json:"integrity"`
IsDead bool `json:"is_dead"`
FirstContact int64 `json:"first_contact"`
LastCheckin int64 `json:"last_checkin"`
ReconnectInterval int64 `json:"reconnect_interval"`
Interval int64 `json:"interval,omitempty"`
Jitter int64 `json:"jitter,omitempty"`
NextCheckin int64 `json:"next_checkin,omitempty"`
TasksCount int64 `json:"tasks_count,omitempty"`
TasksCountCompleted int64 `json:"tasks_count_completed,omitempty"`
}
type pidResult struct {
PID int32 `json:"pid"`
}
type uidResult struct {
UID string `json:"uid"`
}
type pingResult struct {
Nonce int32 `json:"nonce"`
}
type screenshotResult struct {
ByteLen int `json:"byte_len"`
SHA256 string `json:"sha256"`
DataBase64 string `json:"data_base64,omitempty"`
}
type execResult struct {
PID uint32 `json:"pid"`
Status uint32 `json:"status"`
StdoutLen int `json:"stdout_len"`
StderrLen int `json:"stderr_len"`
StdoutText string `json:"stdout_text,omitempty"`
StdoutBase64 string `json:"stdout_base64,omitempty"`
StderrText string `json:"stderr_text,omitempty"`
StderrBase64 string `json:"stderr_base64,omitempty"`
}
func systemToolDefinitions() []serverai.AgenticToolDefinition {
return []serverai.AgenticToolDefinition{
{
Name: "exec_command",
Description: "Execute a remote command and optionally capture stdout/stderr. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"path": map[string]any{"type": "string", "description": "Executable or command path."},
"args": map[string]any{"type": "array", "description": "Optional argument list.", "items": map[string]any{"type": "string"}},
"capture_output": map[string]any{"type": "boolean", "description": "Capture stdout/stderr inline. Defaults to true unless background is true."},
"background": map[string]any{"type": "boolean", "description": "Run the command in the background."},
"stdout": map[string]any{"type": "string", "description": "Optional remote path where stdout should be written."},
"stderr": map[string]any{"type": "string", "description": "Optional remote path where stderr should be written."},
"env_inheritance": map[string]any{"type": "boolean", "description": "Inherit the target environment."},
"env": map[string]any{
"type": "object",
"description": "Optional environment variables to set for the process.",
"additionalProperties": map[string]any{"type": "string"},
},
"use_token": map[string]any{"type": "boolean", "description": "Windows only: use the current token."},
"hide_window": map[string]any{"type": "boolean", "description": "Windows only: hide the window."},
"ppid": map[string]any{"type": "integer", "description": "Windows only: spoof the parent PID."},
}),
"required": []string{"path"},
"additionalProperties": false,
},
},
{
Name: "ifconfig",
Description: "List network interfaces and assigned IP addresses for the target.",
Parameters: map[string]any{
"type": "object",
"properties": targetSchemaProperties(),
"additionalProperties": false,
},
},
{
Name: "netstat",
Description: "List active network connections on the target.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"tcp": map[string]any{"type": "boolean", "description": "Include TCP sockets."},
"udp": map[string]any{"type": "boolean", "description": "Include UDP sockets."},
"ip4": map[string]any{"type": "boolean", "description": "Include IPv4 sockets."},
"ip6": map[string]any{"type": "boolean", "description": "Include IPv6 sockets."},
"listening": map[string]any{"type": "boolean", "description": "Only show listening sockets."},
}),
"additionalProperties": false,
},
},
{
Name: "ps",
Description: "List target processes. Set full_info when owner or command line details are needed.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"full_info": map[string]any{"type": "boolean", "description": "Request owner, architecture, session, and command line information."},
"pid": map[string]any{"type": "integer", "description": "Optional PID filter applied after retrieval."},
"exe": map[string]any{"type": "string", "description": "Optional substring filter on the executable name."},
"owner": map[string]any{"type": "string", "description": "Optional substring filter on the process owner."},
}),
"additionalProperties": false,
},
},
{
Name: "env",
Description: "Return environment variables for the target, or one variable when name is provided.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"name": map[string]any{"type": "string", "description": "Optional environment variable name to retrieve."},
}),
"additionalProperties": false,
},
},
{
Name: "getpid",
Description: "Return the current target process PID from session or beacon metadata.",
Parameters: map[string]any{
"type": "object",
"properties": targetSchemaProperties(),
"additionalProperties": false,
},
},
{
Name: "getuid",
Description: "Return the current target process UID from session or beacon metadata.",
Parameters: map[string]any{
"type": "object",
"properties": targetSchemaProperties(),
"additionalProperties": false,
},
},
{
Name: "info",
Description: "Return detailed session or beacon metadata for the selected target.",
Parameters: map[string]any{
"type": "object",
"properties": targetSchemaProperties(),
"additionalProperties": false,
},
},
{
Name: "ping",
Description: "Send a round-trip C2 ping to the target and return the echoed nonce.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"nonce": map[string]any{"type": "integer", "description": "Optional nonce to send. A random one is generated when omitted."},
}),
"additionalProperties": false,
},
},
{
Name: "screenshot",
Description: "Capture a screenshot from the target. Set include_data to true to include the PNG bytes as base64.",
Parameters: map[string]any{
"type": "object",
"properties": mergeSchemaProperties(map[string]any{
"include_data": map[string]any{"type": "boolean", "description": "Include the raw screenshot bytes as base64 in the result."},
}),
"additionalProperties": false,
},
},
}
}
func (e *executor) callIfconfig(ctx context.Context, args targetArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Ifconfig, error) {
return e.backend.Ifconfig(callCtx, &sliverpb.IfconfigReq{Request: req})
},
func() *sliverpb.Ifconfig { return &sliverpb.Ifconfig{} },
)
if err != nil {
return "", err
}
result := ifconfigResult{
Interfaces: make([]ifconfigInterfaceResult, 0, len(resp.NetInterfaces)),
}
for _, iface := range resp.NetInterfaces {
if iface == nil {
continue
}
result.Interfaces = append(result.Interfaces, ifconfigInterfaceResult{
Index: iface.Index,
Name: iface.Name,
MAC: iface.MAC,
IPAddresses: append([]string(nil), iface.IPAddresses...),
})
}
sort.Slice(result.Interfaces, func(i, j int) bool {
return result.Interfaces[i].Index < result.Interfaces[j].Index
})
result.Count = len(result.Interfaces)
return marshalToolResult(result)
}
func (e *executor) callNetstat(ctx context.Context, args netstatToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Netstat, error) {
return e.backend.Netstat(callCtx, &sliverpb.NetstatReq{
Request: req,
TCP: args.TCP,
UDP: args.UDP,
IP4: args.IP4,
IP6: args.IP6,
Listening: args.Listening,
})
},
func() *sliverpb.Netstat { return &sliverpb.Netstat{} },
)
if err != nil {
return "", err
}
result := netstatResult{
Entries: make([]netstatEntryResult, 0, len(resp.Entries)),
}
for _, entry := range resp.Entries {
if entry == nil {
continue
}
resultEntry := netstatEntryResult{
State: entry.SkState,
UID: entry.UID,
Protocol: entry.Protocol,
}
if entry.LocalAddr != nil {
resultEntry.LocalAddr = sockAddrResult{IP: entry.LocalAddr.Ip, Port: entry.LocalAddr.Port}
}
if entry.RemoteAddr != nil {
resultEntry.RemoteAddr = sockAddrResult{IP: entry.RemoteAddr.Ip, Port: entry.RemoteAddr.Port}
}
if entry.Process != nil {
resultEntry.Process = processToResult(entry.Process)
}
result.Entries = append(result.Entries, resultEntry)
}
result.Count = len(result.Entries)
return marshalToolResult(result)
}
func (e *executor) callPS(ctx context.Context, args psToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Ps, error) {
return e.backend.Ps(callCtx, &sliverpb.PsReq{
Request: req,
FullInfo: args.FullInfo,
})
},
func() *sliverpb.Ps { return &sliverpb.Ps{} },
)
if err != nil {
return "", err
}
processes := make([]processResult, 0, len(resp.Processes))
for _, process := range resp.Processes {
if process == nil {
continue
}
if args.PID != 0 && process.Pid != args.PID {
continue
}
if args.Exe != "" && !strings.Contains(strings.ToLower(process.Executable), strings.ToLower(args.Exe)) {
continue
}
if args.Owner != "" && !strings.Contains(strings.ToLower(process.Owner), strings.ToLower(args.Owner)) {
continue
}
processes = append(processes, *processToResult(process))
}
sort.Slice(processes, func(i, j int) bool {
if processes[i].PID == processes[j].PID {
return processes[i].PPID < processes[j].PPID
}
return processes[i].PID < processes[j].PID
})
return marshalToolResult(psResult{
Processes: processes,
Count: len(processes),
})
}
func (e *executor) callEnv(ctx context.Context, args envToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.EnvInfo, error) {
return e.backend.GetEnv(callCtx, &sliverpb.EnvReq{
Request: req,
Name: args.Name,
})
},
func() *sliverpb.EnvInfo { return &sliverpb.EnvInfo{} },
)
if err != nil {
return "", err
}
result := envResult{
Variables: make([]envVarResult, 0, len(resp.Variables)),
}
for _, variable := range resp.Variables {
if variable == nil {
continue
}
result.Variables = append(result.Variables, envVarResult{Key: variable.Key, Value: variable.Value})
}
sort.Slice(result.Variables, func(i, j int) bool {
return result.Variables[i].Key < result.Variables[j].Key
})
result.Count = len(result.Variables)
return marshalToolResult(result)
}
func (e *executor) callGetPID(ctx context.Context, args targetArgs) (string, error) {
session, beacon, err := e.lookupTargetMetadata(ctx, args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if session != nil {
return marshalToolResult(pidResult{PID: session.PID})
}
return marshalToolResult(pidResult{PID: beacon.PID})
}
func (e *executor) callGetUID(ctx context.Context, args targetArgs) (string, error) {
session, beacon, err := e.lookupTargetMetadata(ctx, args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if session != nil {
return marshalToolResult(uidResult{UID: session.UID})
}
return marshalToolResult(uidResult{UID: beacon.UID})
}
func (e *executor) callInfo(ctx context.Context, args targetArgs) (string, error) {
session, beacon, err := e.lookupTargetMetadata(ctx, args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if session != nil {
return marshalToolResult(targetInfoResult{
TargetType: "session",
ID: session.ID,
Name: session.Name,
Hostname: session.Hostname,
UUID: session.UUID,
Username: session.Username,
UID: session.UID,
GID: session.GID,
PID: session.PID,
OS: session.OS,
Arch: session.Arch,
Transport: session.Transport,
ActiveC2: session.ActiveC2,
RemoteAddress: session.RemoteAddress,
ProxyURL: session.ProxyURL,
Version: session.Version,
Locale: session.Locale,
Integrity: session.Integrity,
IsDead: session.IsDead,
FirstContact: session.FirstContact,
LastCheckin: session.LastCheckin,
ReconnectInterval: session.ReconnectInterval,
})
}
return marshalToolResult(targetInfoResult{
TargetType: "beacon",
ID: beacon.ID,
Name: beacon.Name,
Hostname: beacon.Hostname,
UUID: beacon.UUID,
Username: beacon.Username,
UID: beacon.UID,
GID: beacon.GID,
PID: beacon.PID,
OS: beacon.OS,
Arch: beacon.Arch,
Transport: beacon.Transport,
ActiveC2: beacon.ActiveC2,
RemoteAddress: beacon.RemoteAddress,
ProxyURL: beacon.ProxyURL,
Version: beacon.Version,
Locale: beacon.Locale,
Integrity: beacon.Integrity,
IsDead: beacon.IsDead,
FirstContact: beacon.FirstContact,
LastCheckin: beacon.LastCheckin,
ReconnectInterval: beacon.ReconnectInterval,
Interval: beacon.Interval,
Jitter: beacon.Jitter,
NextCheckin: beacon.NextCheckin,
TasksCount: beacon.TasksCount,
TasksCountCompleted: beacon.TasksCountCompleted,
})
}
func (e *executor) callPing(ctx context.Context, args pingToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
nonce := int32(time.Now().UnixNano() % 1_000_000)
if args.Nonce != nil {
nonce = *args.Nonce
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Ping, error) {
return e.backend.Ping(callCtx, &sliverpb.Ping{
Request: req,
Nonce: nonce,
})
},
func() *sliverpb.Ping { return &sliverpb.Ping{} },
)
if err != nil {
return "", err
}
return marshalToolResult(pingResult{Nonce: resp.Nonce})
}
func (e *executor) callScreenshot(ctx context.Context, args screenshotToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
resp, err := callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Screenshot, error) {
return e.backend.Screenshot(callCtx, &sliverpb.ScreenshotReq{Request: req})
},
func() *sliverpb.Screenshot { return &sliverpb.Screenshot{} },
)
if err != nil {
return "", err
}
result := screenshotResult{
ByteLen: len(resp.Data),
SHA256: sha256Hex(resp.Data),
}
if args.IncludeData {
result.DataBase64 = base64.StdEncoding.EncodeToString(resp.Data)
}
return marshalToolResult(result)
}
func (e *executor) callExec(ctx context.Context, args execToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if args.Path == "" {
return "", fmt.Errorf("path is required")
}
captureOutput := !args.Background
if args.CaptureOutput != nil {
captureOutput = *args.CaptureOutput && !args.Background
}
var resp *sliverpb.Execute
if args.UseToken || args.HideWindow || args.PPID != 0 {
session, beacon, err := e.lookupTargetMetadata(ctx, args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
targetOS := ""
if session != nil {
targetOS = session.OS
} else if beacon != nil {
targetOS = beacon.OS
}
if targetOS != "windows" {
return "", fmt.Errorf("use_token, hide_window, and ppid are only supported on windows targets")
}
if args.EnvInheritance || len(args.Env) > 0 {
return "", fmt.Errorf("env and env_inheritance are not supported with use_token, hide_window, or ppid")
}
resp, err = callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Execute, error) {
return e.backend.ExecuteWindows(callCtx, &sliverpb.ExecuteWindowsReq{
Request: req,
Path: args.Path,
Args: args.Args,
Output: captureOutput,
Background: args.Background,
Stdout: args.Stdout,
Stderr: args.Stderr,
UseToken: args.UseToken,
HideWindow: args.HideWindow,
PPid: args.PPID,
})
},
func() *sliverpb.Execute { return &sliverpb.Execute{} },
)
} else {
resp, err = callTargetRPC(
ctx,
target,
func(callCtx context.Context, req *commonpb.Request) (*sliverpb.Execute, error) {
return e.backend.Execute(callCtx, &sliverpb.ExecuteReq{
Request: req,
Path: args.Path,
Args: args.Args,
Output: captureOutput,
Background: args.Background,
Stdout: args.Stdout,
Stderr: args.Stderr,
EnvInheritance: args.EnvInheritance,
Env: args.Env,
})
},
func() *sliverpb.Execute { return &sliverpb.Execute{} },
)
}
if err != nil {
return "", err
}
stdoutText, stdoutBase64 := bytesToTextAndBase64(resp.Stdout)
stderrText, stderrBase64 := bytesToTextAndBase64(resp.Stderr)
return marshalToolResult(execResult{
PID: resp.Pid,
Status: resp.Status,
StdoutLen: len(resp.Stdout),
StderrLen: len(resp.Stderr),
StdoutText: stdoutText,
StdoutBase64: stdoutBase64,
StderrText: stderrText,
StderrBase64: stderrBase64,
})
}
func processToResult(process *commonpb.Process) *processResult {
if process == nil {
return nil
}
return &processResult{
PID: process.Pid,
PPID: process.Ppid,
Executable: process.Executable,
Owner: process.Owner,
Architecture: process.Architecture,
SessionID: process.SessionID,
CmdLine: append([]string(nil), process.CmdLine...),
}
}
+91 -54
View File
@@ -141,6 +141,35 @@ func (s *aiConversationEventSink) TurnFailed(failure error) error {
return nil
}
func (s *aiConversationEventSink) ChatMessage(ctx context.Context, item serverai.AgenticChatMessage) error {
visibility := clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT
if item.UIOnly {
visibility = clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY
}
role := strings.ToLower(strings.TrimSpace(item.Role))
if role == "" {
role = "assistant"
}
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: role,
Content: strings.TrimSpace(item.Content),
FinishReason: strings.TrimSpace(item.Status),
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: visibility,
IncludeInContext: aiOptionalBool(item.IncludeInContext),
State: normalizeAIMessageState(item.Status, clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED),
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, "")
return err
}
func (s *aiConversationEventSink) ReasoningItem(ctx context.Context, item serverai.AgenticReasoningItem) error {
content := strings.TrimSpace(item.Content)
summary := strings.TrimSpace(item.Summary)
@@ -151,18 +180,19 @@ func (s *aiConversationEventSink) ReasoningItem(ctx context.Context, item server
}
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Content: content,
FinishReason: strings.TrimSpace(item.Status),
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: normalizeAIMessageState(item.Status, clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED),
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Content: content,
FinishReason: strings.TrimSpace(item.Status),
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
IncludeInContext: aiOptionalBool(false),
State: normalizeAIMessageState(item.Status, clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED),
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, "")
return err
@@ -170,20 +200,21 @@ func (s *aiConversationEventSink) ReasoningItem(ctx context.Context, item server
func (s *aiConversationEventSink) ToolCallStarted(ctx context.Context, item serverai.AgenticToolCall) error {
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ToolCallID: strings.TrimSpace(item.CallID),
ToolName: strings.TrimSpace(item.Name),
ToolArguments: strings.TrimSpace(item.Arguments),
FinishReason: strings.TrimSpace(item.Status),
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
IncludeInContext: aiOptionalBool(false),
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ToolCallID: strings.TrimSpace(item.CallID),
ToolName: strings.TrimSpace(item.Name),
ToolArguments: strings.TrimSpace(item.Arguments),
FinishReason: strings.TrimSpace(item.Status),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED, "")
return err
@@ -198,22 +229,23 @@ func (s *aiConversationEventSink) ToolCallCompleted(ctx context.Context, item se
}
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: state,
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ToolCallID: strings.TrimSpace(item.CallID),
ToolName: strings.TrimSpace(item.Name),
ToolArguments: strings.TrimSpace(item.Arguments),
ToolResult: strings.TrimSpace(item.Output),
ErrorText: errText,
FinishReason: strings.TrimSpace(item.Status),
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
IncludeInContext: aiOptionalBool(false),
State: state,
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ToolCallID: strings.TrimSpace(item.CallID),
ToolName: strings.TrimSpace(item.Name),
ToolArguments: strings.TrimSpace(item.Arguments),
ToolResult: strings.TrimSpace(item.Output),
ErrorText: errText,
FinishReason: strings.TrimSpace(item.Status),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, errText)
return err
@@ -221,18 +253,19 @@ func (s *aiConversationEventSink) ToolCallCompleted(ctx context.Context, item se
func (s *aiConversationEventSink) SaveFailureMessage(content string, errText string) error {
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "system",
Content: strings.TrimSpace(content),
FinishReason: "error",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
TurnID: s.turnID,
ErrorText: strings.TrimSpace(errText),
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "system",
Content: strings.TrimSpace(content),
FinishReason: "error",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
IncludeInContext: aiOptionalBool(false),
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
TurnID: s.turnID,
ErrorText: strings.TrimSpace(errText),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, errText)
return err
@@ -300,3 +333,7 @@ func normalizeAIMessageState(status string, fallback clientpb.AIConversationMess
return fallback
}
}
func aiOptionalBool(value bool) *bool {
return &value
}
+65 -3
View File
@@ -10,6 +10,7 @@ import (
serverai "github.com/bishopfox/sliver/server/ai"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/rpc/aitools"
"github.com/gofrs/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -33,6 +34,17 @@ func validateAIProvider(provider string) error {
return nil
}
func validateAIThinkingLevel(thinkingLevel string) error {
thinkingLevel = strings.TrimSpace(thinkingLevel)
if thinkingLevel == "" {
return nil
}
if serverai.NormalizeThinkingLevelValue(thinkingLevel) == "" {
return status.Error(codes.InvalidArgument, fmt.Sprintf("unsupported AI thinking level %q", thinkingLevel))
}
return nil
}
func (rpc *Server) GetAIProviders(ctx context.Context, _ *commonpb.Empty) (*clientpb.AIProviderConfigs, error) {
return &clientpb.AIProviderConfigs{
Providers: serverai.ConfiguredProviders(),
@@ -67,6 +79,9 @@ func (rpc *Server) SaveAIConversation(ctx context.Context, req *clientpb.AIConve
if err := validateAIProvider(req.Provider); err != nil {
return nil, err
}
if err := validateAIThinkingLevel(req.ThinkingLevel); err != nil {
return nil, err
}
req.OperatorName = rpc.aiOperatorName(ctx, req.OperatorName)
conversation, err := db.SaveAIConversation(req, req.OperatorName)
@@ -200,7 +215,7 @@ func (rpc *Server) runAIConversationCompletion(conversationID string, operatorNa
context.Background(),
runtime,
conversation,
newAIToolExecutor(rpc, conversation),
aitools.NewExecutor(rpc, conversation),
sink,
)
} else {
@@ -213,6 +228,11 @@ func (rpc *Server) runAIConversationCompletion(conversationID string, operatorNa
}
return
}
if updatedConversation, err := persistAIConversationUsage(conversation, operatorName, completion); err != nil {
rpcLog.Warnf("Failed to persist AI context window usage for %q: %s", conversationID, err)
} else if updatedConversation != nil {
conversation = updatedConversation
}
message, err := db.SaveAIConversationMessage(&clientpb.AIConversationMessage{
ConversationID: conversationID,
@@ -225,6 +245,7 @@ func (rpc *Server) runAIConversationCompletion(conversationID string, operatorNa
FinishReason: completion.FinishReason,
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
IncludeInContext: aiOptionalBool(true),
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
TurnID: turnID,
}, operatorName)
@@ -264,11 +285,45 @@ func persistAIConversationRuntime(conversation *clientpb.AIConversation, operato
return conversation, nil
}
return db.SaveAIConversation(&clientpb.AIConversation{
ID: conversation.GetID(),
OperatorName: conversation.GetOperatorName(),
Provider: provider,
Model: model,
ThinkingLevel: conversation.GetThinkingLevel(),
Title: conversation.GetTitle(),
Summary: conversation.GetSummary(),
SystemPrompt: conversation.GetSystemPrompt(),
ActiveTurnID: conversation.GetActiveTurnID(),
TurnState: conversation.GetTurnState(),
TargetSessionID: conversation.GetTargetSessionID(),
TargetBeaconID: conversation.GetTargetBeaconID(),
ContextWindowUsage: conversation.GetContextWindowUsage(),
}, operatorName)
}
func persistAIConversationUsage(conversation *clientpb.AIConversation, operatorName string, completion *serverai.Completion) (*clientpb.AIConversation, error) {
if conversation == nil || completion == nil || completion.ContextWindowUsage == nil {
return conversation, nil
}
usage := completion.ContextWindowUsage
current := conversation.GetContextWindowUsage()
if current != nil &&
current.GetInputTokens() == usage.InputTokens &&
current.GetOutputTokens() == usage.OutputTokens &&
current.GetTotalTokens() == usage.TotalTokens &&
current.GetContextWindowTokens() == usage.ContextWindowTokens &&
current.GetContextWindowTokensEstimated() == usage.ContextWindowTokensEstimated {
return conversation, nil
}
return db.SaveAIConversation(&clientpb.AIConversation{
ID: conversation.GetID(),
OperatorName: conversation.GetOperatorName(),
Provider: provider,
Model: model,
Provider: fallbackAIString(conversation.GetProvider(), completion.Provider),
Model: fallbackAIString(conversation.GetModel(), completion.Model),
ThinkingLevel: conversation.GetThinkingLevel(),
Title: conversation.GetTitle(),
Summary: conversation.GetSummary(),
SystemPrompt: conversation.GetSystemPrompt(),
@@ -276,6 +331,13 @@ func persistAIConversationRuntime(conversation *clientpb.AIConversation, operato
TurnState: conversation.GetTurnState(),
TargetSessionID: conversation.GetTargetSessionID(),
TargetBeaconID: conversation.GetTargetBeaconID(),
ContextWindowUsage: &clientpb.AIContextWindowUsage{
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
TotalTokens: usage.TotalTokens,
ContextWindowTokens: usage.ContextWindowTokens,
ContextWindowTokensEstimated: usage.ContextWindowTokensEstimated,
},
}, operatorName)
}
+286 -44
View File
@@ -3,6 +3,7 @@ package rpc
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
@@ -42,6 +43,13 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
"id": "resp_success",
"model": "gpt-5.2",
"status": "completed",
"usage": {
"input_tokens": 12000,
"input_tokens_details": {},
"output_tokens": 6000,
"output_tokens_details": {},
"total_tokens": 18000
},
"output": [
{
"type": "message",
@@ -92,11 +100,12 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "user",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "user",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
includeInContext: boolPtr(true),
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
@@ -135,6 +144,15 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
if current.GetModel() != "gpt-5.2" {
t.Fatalf("unexpected conversation model: %q", current.GetModel())
}
if current.GetContextWindowUsage() == nil {
t.Fatal("expected conversation context window usage to be persisted")
}
if current.GetContextWindowUsage().GetTotalTokens() != 18000 {
t.Fatalf("unexpected persisted total tokens: %d", current.GetContextWindowUsage().GetTotalTokens())
}
if current.GetContextWindowUsage().GetContextWindowTokens() != 400000 {
t.Fatalf("unexpected persisted context window tokens: %d", current.GetContextWindowUsage().GetContextWindowTokens())
}
select {
case <-requests:
@@ -143,6 +161,57 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
}
}
func TestSaveAIConversationIncludesSystemPromptAsFirstMessage(t *testing.T) {
setupAIRPCTestEnv(t)
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
conversation, err := client.SaveAIConversation(context.Background(), &clientpb.AIConversation{
OperatorName: "alice",
Provider: serverai.ProviderOpenAI,
Title: "Workflow test",
SystemPrompt: "Stay concise.",
})
if err != nil {
t.Fatalf("save conversation: %v", err)
}
if conversation.GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected system prompt: %q", conversation.GetSystemPrompt())
}
if len(conversation.GetMessages()) != 1 {
t.Fatalf("unexpected message count on save: got=%d want=%d", len(conversation.GetMessages()), 1)
}
if conversation.GetMessages()[0].GetRole() != "system" || conversation.GetMessages()[0].GetContent() != "Stay concise." {
t.Fatalf("expected leading system prompt message, got %+v", conversation.GetMessages()[0])
}
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
ID: conversation.GetID(),
IncludeMessages: true,
})
if err != nil {
t.Fatalf("refresh ai conversation: %v", err)
}
if len(current.GetMessages()) != 1 {
t.Fatalf("unexpected fetched message count: got=%d want=%d", len(current.GetMessages()), 1)
}
if current.GetMessages()[0].GetRole() != "system" || current.GetMessages()[0].GetContent() != "Stay concise." {
t.Fatalf("expected fetched leading system prompt message, got %+v", current.GetMessages()[0])
}
conversations, err := client.GetAIConversations(context.Background(), &commonpb.Empty{})
if err != nil {
t.Fatalf("list ai conversations: %v", err)
}
if len(conversations.GetConversations()) != 1 {
t.Fatalf("unexpected conversation count: got=%d want=%d", len(conversations.GetConversations()), 1)
}
if len(conversations.GetConversations()[0].GetMessages()) != 0 {
t.Fatalf("expected list response to omit messages, got %+v", conversations.GetConversations()[0].GetMessages())
}
}
func TestSaveAIConversationMessageCompletesOpenAIWithoutExplicitBaseURL(t *testing.T) {
setupAIRPCTestEnv(t)
@@ -379,6 +448,111 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
"beacon_id": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "base32_encode", []string{"data_base64", "text"}, map[string][]string{
"data_base64": []string{"string", "null"},
"text": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "base32_decode", []string{"max_bytes", "value"}, map[string][]string{
"max_bytes": []string{"integer", "null"},
"value": []string{"string"},
})
assertStrictResponseToolSchema(t, tools, "base64_encode", []string{"data_base64", "text"}, map[string][]string{
"data_base64": []string{"string", "null"},
"text": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "base64_decode", []string{"max_bytes", "value"}, map[string][]string{
"max_bytes": []string{"integer", "null"},
"value": []string{"string"},
})
assertStrictResponseToolSchema(t, tools, "hex_encode", []string{"data_base64", "text"}, map[string][]string{
"data_base64": []string{"string", "null"},
"text": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "hex_decode", []string{"max_bytes", "value"}, map[string][]string{
"max_bytes": []string{"integer", "null"},
"value": []string{"string"},
})
assertStrictResponseToolSchema(t, tools, "gzip_encode", []string{"data_base64", "text"}, map[string][]string{
"data_base64": []string{"string", "null"},
"text": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "gzip_decode", []string{"data_base64", "max_bytes"}, map[string][]string{
"data_base64": []string{"string"},
"max_bytes": []string{"integer", "null"},
})
assertStrictResponseToolSchema(t, tools, "loot_list", []string{}, map[string][]string{})
assertStrictResponseToolSchema(t, tools, "loot_read", []string{"loot_id", "max_bytes"}, map[string][]string{
"loot_id": []string{"string"},
"max_bytes": []string{"integer", "null"},
})
assertStrictResponseToolSchema(t, tools, "loot_add", []string{"data_base64", "file_name", "file_type_name", "file_type_value", "name", "origin_host_uuid", "text"}, map[string][]string{
"data_base64": []string{"string", "null"},
"file_name": []string{"string", "null"},
"file_type_name": []string{"string", "null"},
"file_type_value": []string{"integer", "null"},
"name": []string{"string"},
"origin_host_uuid": []string{"string", "null"},
"text": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "loot_delete", []string{"loot_id"}, map[string][]string{
"loot_id": []string{"string"},
})
assertStrictResponseToolSchema(t, tools, "credentials_list", []string{}, map[string][]string{})
assertStrictResponseToolSchema(t, tools, "credentials_read", []string{"credential_id"}, map[string][]string{
"credential_id": []string{"string"},
})
assertStrictResponseToolSchema(t, tools, "credentials_add", []string{"collection", "hash", "hash_type_name", "hash_type_value", "origin_host_uuid", "plaintext", "username"}, map[string][]string{
"collection": []string{"string", "null"},
"hash": []string{"string", "null"},
"hash_type_name": []string{"string", "null"},
"hash_type_value": []string{"integer", "null"},
"origin_host_uuid": []string{"string", "null"},
"plaintext": []string{"string", "null"},
"username": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "credentials_delete", []string{"credential_id"}, map[string][]string{
"credential_id": []string{"string"},
})
assertStrictResponseToolSchema(t, tools, "search_aliases", []string{"beacon_id", "max_results", "only_compatible", "query", "session_id"}, map[string][]string{
"beacon_id": []string{"string", "null"},
"max_results": []string{"integer", "null"},
"only_compatible": []string{"boolean", "null"},
"query": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "search_extensions", []string{"beacon_id", "max_results", "only_compatible", "query", "session_id"}, map[string][]string{
"beacon_id": []string{"string", "null"},
"max_results": []string{"integer", "null"},
"only_compatible": []string{"boolean", "null"},
"query": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "execute_alias", []string{"amsi_bypass", "app_domain", "arch", "args", "beacon_id", "class_name", "command_name", "etw_bypass", "in_process", "method", "ppid", "process", "process_args", "root_path", "runtime", "session_id"}, map[string][]string{
"amsi_bypass": []string{"boolean", "null"},
"app_domain": []string{"string", "null"},
"arch": []string{"string", "null"},
"args": []string{"array", "null"},
"beacon_id": []string{"string", "null"},
"class_name": []string{"string", "null"},
"command_name": []string{"string"},
"etw_bypass": []string{"boolean", "null"},
"in_process": []string{"boolean", "null"},
"method": []string{"string", "null"},
"ppid": []string{"integer", "null"},
"process": []string{"string", "null"},
"process_args": []string{"array", "null"},
"root_path": []string{"string", "null"},
"runtime": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "execute_extension", []string{"args", "beacon_id", "command_name", "named_args", "root_path", "session_id"}, map[string][]string{
"args": []string{"array", "null"},
"beacon_id": []string{"string", "null"},
"command_name": []string{"string"},
"named_args": []string{"object", "null"},
"root_path": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
return jsonResponse(http.StatusOK, `{
"id": "resp_step1",
"model": "gpt-5.2",
@@ -390,9 +564,6 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
"summary": [
{"type": "summary_text", "text": "Need to inspect the available targets first."}
],
"content": [
{"type": "reasoning_text", "text": "Need to inspect the available targets first."}
],
"status": "completed"
},
{
@@ -402,6 +573,15 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
"name": "list_sessions_and_beacons",
"arguments": "{}",
"status": "completed"
},
{
"id": "msg_step_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "Checking the current targets before I summarize them."}
]
}
]
}`), nil
@@ -482,37 +662,50 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "reasoning_1",
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "reasoning_1",
includeInContext: boolPtr(false),
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
itemID: "call_item_1",
toolName: "list_sessions_and_beacons",
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
itemID: "call_item_1",
toolName: "list_sessions_and_beacons",
includeInContext: boolPtr(false),
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "call_item_1",
toolName: "list_sessions_and_beacons",
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "call_item_1",
toolName: "list_sessions_and_beacons",
includeInContext: boolPtr(false),
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "msg_step_1",
includeInContext: boolPtr(false),
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
includeInContext: boolPtr(true),
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
@@ -529,8 +722,8 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
if err != nil {
t.Fatalf("refresh ai conversation: %v", err)
}
if len(current.GetMessages()) != 4 {
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 4)
if len(current.GetMessages()) != 5 {
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 5)
}
reasoning := current.GetMessages()[1]
@@ -540,6 +733,12 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
if reasoning.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
t.Fatalf("expected reasoning block to be UI-only, got %+v", reasoning)
}
if reasoning.GetIncludeInContext() {
t.Fatalf("expected reasoning block to stay out of context, got %+v", reasoning)
}
if !strings.Contains(reasoning.GetContent(), "Need to inspect the available targets first.") {
t.Fatalf("expected reasoning block to preserve the provider summary, got %+v", reasoning)
}
toolCall := current.GetMessages()[2]
if toolCall.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL {
@@ -554,14 +753,34 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
if toolCall.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
t.Fatalf("expected tool call to be UI-only, got %+v", toolCall)
}
if toolCall.GetIncludeInContext() {
t.Fatalf("expected tool call to stay out of context, got %+v", toolCall)
}
reply := current.GetMessages()[3]
stepMessage := current.GetMessages()[3]
if stepMessage.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
t.Fatalf("expected intermediate chat block, got %+v", stepMessage)
}
if stepMessage.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
t.Fatalf("expected intermediate chat block to be UI-only, got %+v", stepMessage)
}
if stepMessage.GetIncludeInContext() {
t.Fatalf("expected intermediate chat block to stay out of context, got %+v", stepMessage)
}
if stepMessage.GetContent() != "Checking the current targets before I summarize them." {
t.Fatalf("unexpected intermediate chat block content: %+v", stepMessage)
}
reply := current.GetMessages()[4]
if reply.GetContent() != "Assistant reply after a tool call" {
t.Fatalf("unexpected assistant reply: %q", reply.GetContent())
}
if reply.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT {
t.Fatalf("expected assistant reply to remain in the context window, got %+v", reply)
}
if !reply.GetIncludeInContext() {
t.Fatalf("expected assistant reply to remain in context, got %+v", reply)
}
}
func TestAIConversationsAreSharedAcrossOperators(t *testing.T) {
@@ -646,6 +865,24 @@ func TestAIConversationsAreSharedAcrossOperators(t *testing.T) {
}
}
func TestSaveAIConversationRejectsUnsupportedThinkingLevel(t *testing.T) {
setupAIRPCTestEnv(t)
rpc := &Server{}
_, err := rpc.SaveAIConversation(contextWithCommonName("alice"), &clientpb.AIConversation{
Provider: serverai.ProviderOpenAI,
ThinkingLevel: "maximum",
Title: "Thread",
})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected invalid argument for unsupported thinking level, got %v", err)
}
if err == nil || !strings.Contains(err.Error(), `unsupported AI thinking level "maximum"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func setupAIRPCTestEnv(t *testing.T) {
t.Helper()
@@ -682,9 +919,9 @@ func saveOpenAICompletionConfig(t *testing.T, model string, thinkingLevel string
cfg := configs.GetServerConfig()
cfg.AI = &configs.AIConfig{
Provider: serverai.ProviderOpenAI,
Model: model,
ThinkingLevel: thinkingLevel,
OpenAI: &configs.AIProviderConfig{
Models: []string{model},
APIKey: apiKey,
BaseURL: baseURL,
UseResponsesAPI: boolPtr(true),
@@ -762,13 +999,14 @@ func waitForAIConversationRole(t *testing.T, client rpcpb.SliverRPCClient, event
}
type aiConversationEventExpectation struct {
eventType clientpb.AIConversationEventType
role string
kind clientpb.AIConversationMessageKind
visibility clientpb.AIConversationMessageVisibility
state clientpb.AIConversationMessageState
itemID string
toolName string
eventType clientpb.AIConversationEventType
role string
kind clientpb.AIConversationMessageKind
visibility clientpb.AIConversationMessageVisibility
state clientpb.AIConversationMessageState
itemID string
toolName string
includeInContext *bool
}
func waitForAIConversationFlow(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string, terminalType clientpb.AIConversationEventType) []*clientpb.AIConversationEvent {
@@ -835,6 +1073,9 @@ func assertAIConversationEventSequence(t *testing.T, events []*clientpb.AIConver
if message.GetState() != expectation.state {
t.Fatalf("unexpected ai event[%d] state: got=%s want=%s\nflow:\n%s", idx, message.GetState(), expectation.state, formatAIConversationEventFlow(events))
}
if expectation.includeInContext != nil && message.GetIncludeInContext() != *expectation.includeInContext {
t.Fatalf("unexpected ai event[%d] include-in-context: got=%t want=%t\nflow:\n%s", idx, message.GetIncludeInContext(), *expectation.includeInContext, formatAIConversationEventFlow(events))
}
if expectation.itemID != "" && strings.TrimSpace(message.GetItemID()) != expectation.itemID {
t.Fatalf("unexpected ai event[%d] item id: got=%q want=%q\nflow:\n%s", idx, message.GetItemID(), expectation.itemID, formatAIConversationEventFlow(events))
}
@@ -857,6 +1098,7 @@ func formatAIConversationEventFlow(events []*clientpb.AIConversationEvent) strin
part += " kind=" + message.GetKind().String()
part += " visibility=" + message.GetVisibility().String()
part += " state=" + message.GetState().String()
part += fmt.Sprintf(" context=%t", message.GetIncludeInContext())
if itemID := strings.TrimSpace(message.GetItemID()); itemID != "" {
part += " item=" + itemID
}
+15
View File
@@ -0,0 +1,15 @@
package clientpbutil
import "github.com/bishopfox/sliver/protobuf/clientpb"
// AIConversationMessageIncludesContext reports whether a message should be
// included in the model-visible context window.
func AIConversationMessageIncludesContext(message *clientpb.AIConversationMessage) bool {
if message == nil {
return false
}
if message.IncludeInContext != nil {
return message.GetIncludeInContext()
}
return message.GetVisibility() == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT
}
+32
View File
@@ -0,0 +1,32 @@
package clientpbutil
import (
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestAIConversationMessageIncludesContextHonorsExplicitFlag(t *testing.T) {
message := &clientpb.AIConversationMessage{
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
IncludeInContext: boolPtr(false),
}
if AIConversationMessageIncludesContext(message) {
t.Fatalf("expected explicit include-in-context=false to exclude the message")
}
}
func TestAIConversationMessageIncludesContextFallsBackToVisibility(t *testing.T) {
message := &clientpb.AIConversationMessage{
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
}
if !AIConversationMessageIncludesContext(message) {
t.Fatalf("expected context visibility to include the message when the flag is unset")
}
}
func boolPtr(value bool) *bool {
return &value
}
+164
View File
@@ -0,0 +1,164 @@
package progress
import (
"fmt"
"image/color"
"math"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
const (
DefaultFullCharHalfBlock = '▌'
DefaultFullCharFullBlock = '█'
DefaultEmptyCharBlock = '░'
defaultWidth = 40
)
var (
defaultFullColor = lipgloss.Color("#7571F9")
defaultEmptyColor = lipgloss.Color("#606060")
)
type Option func(*Model)
func WithColors(colors ...color.Color) Option {
if len(colors) == 0 {
return func(m *Model) {
m.FullColor = defaultFullColor
m.blend = nil
}
}
if len(colors) == 1 {
return func(m *Model) {
m.FullColor = colors[0]
m.blend = nil
}
}
return func(m *Model) {
m.blend = colors
}
}
func WithFillCharacters(full, empty rune) Option {
return func(m *Model) {
m.Full = full
m.Empty = empty
}
}
func WithoutPercentage() Option {
return func(m *Model) {
m.ShowPercentage = false
}
}
func WithWidth(w int) Option {
return func(m *Model) {
m.SetWidth(w)
}
}
type Model struct {
width int
Full rune
FullColor color.Color
Empty rune
EmptyColor color.Color
ShowPercentage bool
PercentFormat string
PercentageStyle lipgloss.Style
blend []color.Color
}
func New(opts ...Option) Model {
m := Model{
width: defaultWidth,
Full: DefaultFullCharHalfBlock,
FullColor: defaultFullColor,
Empty: DefaultEmptyCharBlock,
EmptyColor: defaultEmptyColor,
ShowPercentage: true,
PercentFormat: " %3.0f%%",
}
for _, opt := range opts {
opt(&m)
}
return m
}
func (m *Model) SetWidth(w int) {
m.width = w
}
func (m Model) Width() int {
return m.width
}
func (m Model) View() string {
return m.ViewAs(0)
}
func (m Model) ViewAs(percent float64) string {
b := strings.Builder{}
percentView := m.percentageView(percent)
m.barView(&b, percent, ansi.StringWidth(percentView))
b.WriteString(percentView)
return b.String()
}
func (m Model) percentageView(percent float64) string {
if !m.ShowPercentage {
return ""
}
percent = math.Max(0, math.Min(1, percent))
percentage := fmt.Sprintf(m.PercentFormat, percent*100)
return m.PercentageStyle.Inline(true).Render(percentage)
}
func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
totalWidth := maxInt(0, m.width-textWidth)
filledWidth := int(math.Round(float64(totalWidth) * math.Max(0, math.Min(1, percent))))
filledWidth = clampInt(filledWidth, 0, totalWidth)
if len(m.blend) > 1 {
blend := lipgloss.Blend1D(totalWidth, m.blend...)
for i := 0; i < filledWidth && i < len(blend); i++ {
b.WriteString(lipgloss.NewStyle().
Foreground(blend[i]).
Render(string(m.Full)))
}
} else {
b.WriteString(lipgloss.NewStyle().
Foreground(m.FullColor).
Render(strings.Repeat(string(m.Full), filledWidth)))
}
emptyWidth := maxInt(0, totalWidth-filledWidth)
if emptyWidth == 0 {
return
}
b.WriteString(lipgloss.NewStyle().
Foreground(m.EmptyColor).
Render(strings.Repeat(string(m.Empty), emptyWidth)))
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func clampInt(value, low, high int) int {
if value < low {
return low
}
if value > high {
return high
}
return value
}
+1
View File
@@ -10,6 +10,7 @@ charm.land/bubbles/v2/internal/memoization
charm.land/bubbles/v2/internal/runeutil
charm.land/bubbles/v2/key
charm.land/bubbles/v2/paginator
charm.land/bubbles/v2/progress
charm.land/bubbles/v2/spinner
charm.land/bubbles/v2/textarea
charm.land/bubbles/v2/textinput