diff --git a/.gitignore b/.gitignore index ef20bc9..7f4d057 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +uninstall.sh +.direnv +tamdem_tmp +.envrc tandem.env .go .vagrant diff --git a/.tandem/swarm.json b/.tandem/swarm.json index 0662bb6..1afeed4 100644 --- a/.tandem/swarm.json +++ b/.tandem/swarm.json @@ -1,12 +1,10 @@ { - "$schema": "https://raw.githubusercontent.com/yyovil/tandem/refs/heads/main/swarm.schema.json", + "$schema": "https://tandem.codes/swarm.schema.json", "debug": true, - "agents": { "orchestrator": { "name": "orchestrator", "agentId": "orchestrator", - "model": "gemini-2.5-flash", "description": "an agents orchestrator in a multi-agent penetration testing firm called as Tandem run autonomously by AI.", "goal": "Assign penetration test related tasks like performing particular types of scans, searching for vulnerabilities and assessing them, searching for exploits and using them, gaining foothold post exploitation, documenting all the findings during the engagement etc to ai agents.", "instructions": [ @@ -24,7 +22,6 @@ "agentId": "summarizer", "description": "An AI assistant specialized in summarizing conversations and providing context for continuing discussions", "goal": "Provide comprehensive yet concise summaries of conversations to maintain context and assist in workflow continuity", - "model": "gemini-2.5-flash", "instructions": [ "You are a helpful AI assistant tasked with summarizing conversations.", "When asked to summarize, provide a detailed but concise summary of the conversation.", @@ -39,7 +36,6 @@ "title": { "name": "title", "agentId": "title", - "model": "gemini-2.0-flash-lite", "description": "An AI assistant specialized in generating concise titles for conversations based on a user message", "goal": "Generate one liner titles based on the first user message. \nprivesc on linux → privilege escalation\nanalyze pcap dump → traffic analysis\nbypass waf rules → waf bypass\nperform a network pentest on target at 192.168.34.23 → network pentest on 192.168.34.23\n", "instructions": [ @@ -51,7 +47,6 @@ "reconnoiter": { "name": "reconnoiter", "agentId": "reconnoiter", - "model": "gemini-2.5-flash", "description": "a seasoned OffSec PEN-300 certified penetration tester with extensive experience in reconnaissance.", "goal": "Your goal is to assist the user during the reconnaissance phase of a pentest and finish the tasks assigned.", "instructions": [ @@ -68,7 +63,6 @@ "vulnerability_scanner": { "agentId": "vulnerability_scanner", "name": "vulnerability_scanner", - "model": "gemini-2.5-flash", "description": "an AI agent specialized in scanning for vulnerabilities in applications and networks.", "goal": "Your goal is to scan for vulnerabilities in applications and networks using kali linux cli tools.", "instructions": [ @@ -81,7 +75,6 @@ "exploiter": { "agentId": "exploiter", "name": "exploiter", - "model": "gemini-2.5-flash", "description": "an AI agent who searches the exploits for the vulnerabilities found and uses them for penetration testing.", "goal": "your goal is to search exploits for the vulnerabilities found in the system and execute them to gain the foothold in the target system for further reconnaissance using kali linux tools.", "instructions": [ @@ -94,7 +87,6 @@ "reporter": { "agentId": "reporter", "name": "reporter", - "model": "gemini-2.5-flash", "description": "an AI agent to report the gathering and data generated during a penetration testing to explain the security posture of the system and use the data for mitigation analysis, threat modelling and compliance.", "goal": "your goal is to explain the penetration test findings, data generated during the scans etc using your business accumen and technical knowledge.", "instructions": [ diff --git a/.vscode/settings.json b/.vscode/settings.json index f3411ed..5cead63 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,44 +16,6 @@ }, "nix.enableLanguageServer": true, "nix.serverPath": "nil", - "terminal.integrated.profiles.linux": { - "nix devShell": { - "overrideName": true, - "path": "nix", - "args": [ - "develop", - "--quiet", - ], - "icon": "terminal-bash", - "color": "terminal.ansiWhite", - }, - "qwen-code": { - "overrideName": true, - "path": "nix", - "args": [ - "develop", - "--quiet", - "--command", - "qwen" - ], - "icon": "terminal-bash", - "color": "terminal.ansiGreen" - }, - "gemini-cli": { - "overrideName": true, - "path": "nix", - "args": [ - "develop", - "--quiet", - "--command", - "gemini" - ], - "icon": "terminal-bash", - "color": "terminal.ansiMagenta" - }, - }, - "terminal.integrated.defaultProfile.linux": "nix devShell", - "terminal.integrated.cwd": "${workspaceFolder}", "go.formatTool": "gofmt", "files.saveConflictResolution": "overwriteFileOnDisk", } \ No newline at end of file diff --git a/KaliDockerfile b/KaliDockerfile new file mode 100644 index 0000000..fee4e65 --- /dev/null +++ b/KaliDockerfile @@ -0,0 +1,10 @@ +FROM kalilinux/kali-last-release AS base + +# to say yes to all. +ENV DEBIAN_FRONTEND=noninteractive + +# go get the headless metapackage. +RUN apt-get update && apt-get install -y --no-install-recommends kali-linux-headless + +USER root +WORKDIR /home/pentests \ No newline at end of file diff --git a/flake.nix b/flake.nix index 4e7a763..78066bf 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,7 @@ export GOPATH="$PWD/.go" export PATH="$GOPATH/bin:$PATH" export GOBIN="$GOPATH/bin" + source tandem.env ''; }; }; diff --git a/internal/tools/terminal.go b/internal/tools/terminal.go index 9ed8f85..e4d01c4 100644 --- a/internal/tools/terminal.go +++ b/internal/tools/terminal.go @@ -17,7 +17,7 @@ import ( const ( TerminalToolName = "terminal" - DockerImage = "kali:withtools" + DockerImage = "kali:headless" ) type TerminalArgs struct { @@ -105,73 +105,80 @@ func (term *Terminal) Run(ctx context.Context, call ToolCall) (ToolResponse, err cmd = append(cmd, args.Args...) } + output, execErr := term.ExecuteCmd(ctx, cmd) + if execErr != nil { + return NewTextErrorResponse(execErr.Error()), nil + } + return ToolResponse{ + Type: ToolResponseTypeText, + Content: output, + IsError: false, + }, nil +} + +// ExecuteCmd ensures the container is running and executes the provided command array inside it, +// returning the combined stdout/stderr output or an error. +func (term *Terminal) ExecuteCmd(ctx context.Context, cmd []string) (string, error) { + // Ensure we have a container ID; find one by ancestor image if missing if term.containerId == "" { summaries, err := term.client.ContainerList(ctx, container.ListOptions{ All: true, Filters: filters.NewArgs(filters.Arg("ancestor", DockerImage)), }) if err != nil { - return NewTextErrorResponse("Failed to list containers: " + err.Error()), nil + logging.Error("Failed to list containers", err) + return "", fmt.Errorf("Failed to list containers: %w", err) } - for _, summary := range summaries { if summary.Image == DockerImage && summary.State == container.StateRunning { term.containerId = summary.ID break } - - if summary.Image == DockerImage { + if term.containerId == "" && summary.Image == DockerImage { term.containerId = summary.ID - break } } } - // NOTE: we are not creating a container if not found in the summaries because it should be created during the installation. if term.containerId == "" { - return NewTextErrorResponse(fmt.Sprintf("couldn't find a container using %s image.", DockerImage)), nil + return "", fmt.Errorf("couldn't find a container using %s image.", DockerImage) } inspectRes, err := term.client.ContainerInspect(ctx, term.containerId) if err != nil { - return NewTextErrorResponse("Failed to inspect container: " + err.Error()), nil + logging.Error(fmt.Sprintf("Failed to inspect container %s", term.containerId), err) + return "", fmt.Errorf("Failed to inspect container: %w", err) } if !inspectRes.State.Running { if err := term.GetRunning(ctx, term.containerId, inspectRes.State.Status); err != nil { - return NewTextErrorResponse(fmt.Sprintf("couldn't get the container: %s running.", term.containerId)), nil + return "", fmt.Errorf("couldn't get the container: %s running", term.containerId) } } - // Execute the command inside the container (avoids interactive TTY read loop issues) execResp, err := term.client.ContainerExecCreate(ctx, term.containerId, container.ExecOptions{ AttachStdout: true, AttachStderr: true, Cmd: cmd, }) if err != nil { - return NewTextErrorResponse("Failed to create exec: " + err.Error()), nil + return "", fmt.Errorf("Failed to create exec: %w", err) } attachResp, err := term.client.ContainerExecAttach(ctx, execResp.ID, container.ExecStartOptions{}) if err != nil { - return NewTextErrorResponse("Failed to attach exec: " + err.Error()), nil + return "", fmt.Errorf("Failed to attach exec: %w", err) } defer attachResp.Close() outputBytes, err := io.ReadAll(attachResp.Reader) if err != nil { - return NewTextErrorResponse("Failed to read exec output: " + err.Error()), nil + return "", fmt.Errorf("Failed to read exec output: %w", err) } output := string(outputBytes) logging.Debug(fmt.Sprintf("terminal exec output (%s): %s", strings.Join(cmd, " "), truncateForLog(output))) - - return ToolResponse{ - Type: ToolResponseTypeText, - Content: output, - IsError: false, - }, nil + return output, nil } // NOTE: GetRunning gets a docker container to container.StateRunning. diff --git a/internal/tui/bubbles/chat/editor.go b/internal/tui/bubbles/chat/editor.go index 41db86f..aa6e71b 100644 --- a/internal/tui/bubbles/chat/editor.go +++ b/internal/tui/bubbles/chat/editor.go @@ -1,20 +1,25 @@ package chat import ( + "context" + "encoding/json" "fmt" "os" "os/exec" "slices" + "strings" "unicode" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" "github.com/yyovil/tandem/internal/app" "github.com/yyovil/tandem/internal/logging" "github.com/yyovil/tandem/internal/message" "github.com/yyovil/tandem/internal/session" + "github.com/yyovil/tandem/internal/tools" "github.com/yyovil/tandem/internal/tui/bubbles/dialog" "github.com/yyovil/tandem/internal/tui/styles" "github.com/yyovil/tandem/internal/tui/theme" @@ -22,18 +27,20 @@ import ( ) type editorCmp struct { - width int - height int - app *app.App - session session.Session - textarea textarea.Model - attachments []message.Attachment - deleteMode bool + width int + height int + app *app.App + session session.Session + textarea textarea.Model + attachments []message.Attachment + EscapeShellMode bool + deleteMode bool } type EditorKeyMaps struct { - Send key.Binding - OpenEditor key.Binding + EscapeShellCmd key.Binding + Send key.Binding + OpenEditor key.Binding } type bluredEditorKeyMaps struct { @@ -48,6 +55,10 @@ type DeleteAttachmentKeyMaps struct { } var editorMaps = EditorKeyMaps{ + EscapeShellCmd: key.NewBinding( + key.WithKeys("ctrl+enter"), + key.WithHelp("ctrl+enter", "escape shell"), + ), Send: key.NewBinding( key.WithKeys("enter", "ctrl+s"), key.WithHelp("enter", "send message"), @@ -130,6 +141,15 @@ func (m *editorCmp) send() tea.Cmd { if value == "" { return nil } + // If we're in EscapeShellMode, treat the input as a shell command + if m.EscapeShellMode { + // Expect prefix "! " to indicate shell escape + cmdline := strings.TrimSpace(strings.TrimPrefix(value, "! ")) + if cmdline == "" { + return nil + } + return m.EscapeShell(cmdline) + } return tea.Batch( utils.CmdHandler(SendMsg{ Text: value, @@ -182,6 +202,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.openEditor() case key.Matches(msg, DeleteKeyMaps.Escape): m.deleteMode = false + // Exit EscapeShellMode on ESC and remove leading "! " if present + if m.EscapeShellMode { + m.EscapeShellMode = false + val := m.textarea.Value() + if strings.HasPrefix(val, "! ") { + m.textarea.SetValue(strings.TrimPrefix(val, "! ")) + } + } return m, nil case m.textarea.Focused() && key.Matches(msg, editorMaps.Send): // Handle Enter key @@ -197,7 +225,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // First update textarea to capture latest input, then update modes/prompts m.textarea, cmd = m.textarea.Update(msg) + // Enter EscapeShellMode when input starts with "! ". Trim the trigger from the textarea value. + val := m.textarea.Value() + if !m.EscapeShellMode && strings.HasPrefix(val, "! ") { + m.EscapeShellMode = true + m.textarea.SetValue(strings.TrimPrefix(val, "! ")) + } return m, cmd } @@ -212,14 +247,20 @@ func (m *editorCmp) View() string { Background(t.Background()). Foreground(t.Primary()) + prompt := ">" + if m.EscapeShellMode { + prompt = "!" + style = style.Foreground(t.Secondary()) + } + if len(m.attachments) == 0 { - return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) + return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(prompt), m.textarea.View()) } m.textarea.SetHeight(m.height - 1) return lipgloss.JoinVertical(lipgloss.Top, m.attachmentsContent(), - lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), + lipgloss.JoinHorizontal(lipgloss.Top, style.Render(prompt), m.textarea.View()), ) } @@ -306,3 +347,72 @@ func NewEditorCmp(app *app.App) tea.Model { textarea: ta, } } + +// EscapeShell executes a shell command inside the Kali Docker container and streams results into the chat as tool output. +func (m *editorCmp) EscapeShell(cmdline string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + // Ensure session exists + if m.session.ID == "" { + sess, err := m.app.Sessions.Create(ctx, "Shell Session") + if err != nil { + return utils.InfoMsg{Type: utils.InfoTypeError, Msg: err.Error()} + } + m.session = sess + // Inform rest of the app about the new session + // Returning a SessionSelectedMsg won't prevent pubsub updates + // but we can still publish it by returning it after work below if needed. + } + + // Create a user message reflecting the command + userText := "! " + cmdline + _, _ = m.app.Messages.Create(ctx, m.session.ID, message.CreateMessageParams{ + Role: message.User, + Parts: []message.ContentPart{message.TextContent{Text: userText}}, + }) + + // Prepare tool call for terminal using the user's command and args as-is + fields := strings.Fields(cmdline) + if len(fields) == 0 { + return SessionSelectedMsg(m.session) + } + termArgs := tools.TerminalArgs{Command: fields[0]} + if len(fields) > 1 { + termArgs.Args = fields[1:] + } + payload, _ := json.Marshal(termArgs) + callID := uuid.New().String() + + // Create assistant message with the tool call (marked finished so UI shows params + response) + assistantParts := []message.ContentPart{ + message.ToolCall{ID: callID, Name: tools.TerminalToolName, Input: string(payload), Type: "", Finished: true}, + } + _, _ = m.app.Messages.Create(ctx, m.session.ID, message.CreateMessageParams{ + Role: message.Assistant, + Parts: assistantParts, + }) + + // Run the tool (reuse ExecuteCmd for execution) without shell wrapping + tool := tools.NewDockerCli().(*tools.Terminal) + output, err := tool.ExecuteCmd(ctx, fields) + resp := tools.ToolResponse{Type: tools.ToolResponseTypeText, Content: output} + if err != nil { + resp = tools.NewTextErrorResponse("terminal execution failed: " + err.Error()) + } + + // Create tool result message + _, _ = m.app.Messages.Create(ctx, m.session.ID, message.CreateMessageParams{ + Role: message.Tool, + Parts: []message.ContentPart{message.ToolResult{ + ToolCallID: callID, + Content: resp.Content, + Metadata: resp.Metadata, + IsError: resp.IsError, + }}, + }) + + // If we had to create a new session locally, notify the rest of the app + return SessionSelectedMsg(m.session) + } +} diff --git a/internal/tui/bubbles/chat/message.go b/internal/tui/bubbles/chat/message.go index 6f22089..75f1552 100644 --- a/internal/tui/bubbles/chat/message.go +++ b/internal/tui/bubbles/chat/message.go @@ -305,8 +305,11 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string { case tools.TerminalToolName: var params tools.TerminalArgs json.Unmarshal([]byte(toolCall.Input), ¶ms) - command := strings.ReplaceAll(params.Command, "\n", " ") - return renderParams(paramWidth, command) + // Show full command with args for clarity + parts := append([]string{params.Command}, params.Args...) + full := strings.Join(parts, " ") + full = strings.ReplaceAll(full, "\n", " ") + return renderParams(paramWidth, full) // case tools.EditToolName: // var params tools.EditParams // json.Unmarshal([]byte(toolCall.Input), ¶ms) @@ -349,7 +352,12 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, t.Background(), ) case tools.TerminalToolName: - // NOTE: by default, we are going to get a bash shell but then dependending on the type of shell to be used, as configured by the user, it should be mentioned in here. + // NOTE: by default, we are going to get a bash shell but then depending on + // the type of shell to be used, as configured by the user, it should be + // mentioned in here. + // Trim any trailing newlines so we don't render an extra blank line inside + // the fenced code block. + resultContent = strings.TrimRight(resultContent, "\r\n") resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent) return styles.ForceReplaceBackgroundWithLipgloss( toMarkdown(resultContent, width), diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 3aba02f..718dbfb 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -74,12 +74,12 @@ func (cp *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { utils.CmdHandler(chat.SessionClearedMsg{}), ) case key.Matches(msg, keyMap.Cancel): - if cp.session.ID != "" { - // Cancel the current session's generation process - // This allows users to interrupt long-running operations + // Only intercept ESC to cancel when the agent is actively working. + if cp.session.ID != "" && cp.app.Orchestrator.IsSessionBusy(cp.session.ID) { cp.app.Orchestrator.Cancel(cp.session.ID) return cp, nil } + // Otherwise, let ESC propagate to the editor (e.g., to exit EscapeShellMode). } } diff --git a/kali.nix b/kali.nix deleted file mode 100644 index d6061fd..0000000 --- a/kali.nix +++ /dev/null @@ -1,35 +0,0 @@ -{ }: - -let - system = "x86_64-linux"; - pkgs = import { inherit system; }; - kali-base = pkgs.dockerTools.pullImage { - imageName = "kalilinux/kali-last-release"; - imageDigest = "sha256:d44a3c0423addaaae40a04f8c935e245067688591cd78545504ea70802ed40ba"; - sha256 = "sha256-CYe1AMOZlEuvZfrOmpfHjjdmxypon7QXC3lG/ibACHI="; - finalImageName = "kali"; - finalImageTag = "latest"; - }; -in -pkgs.dockerTools.buildLayeredImage { - fromImage = kali-base; - name = "kali"; - tag = "withtools"; - contents = with pkgs; [ - nettools - iproute2 - nmap - dirb - metasploit - nano - nikto - exploitdb - enum4linux - python3 - sqlmap - ]; - config = { - Cmd = [ "bash" ]; - WorkingDir = "/home/tandem"; - }; -}