Skip to content
This repository was archived by the owner on Sep 28, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
uninstall.sh
.direnv
tamdem_tmp
.envrc
tandem.env
.go
.vagrant
Expand Down
10 changes: 1 addition & 9 deletions .tandem/swarm.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand All @@ -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.",
Expand All @@ -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. <example>\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</example>",
"instructions": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand Down
38 changes: 0 additions & 38 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
10 changes: 10 additions & 0 deletions KaliDockerfile
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
export GOPATH="$PWD/.go"
export PATH="$GOPATH/bin:$PATH"
export GOBIN="$GOPATH/bin"
source tandem.env
'';
};
};
Expand Down
47 changes: 27 additions & 20 deletions internal/tools/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

const (
TerminalToolName = "terminal"
DockerImage = "kali:withtools"
DockerImage = "kali:headless"
)

type TerminalArgs struct {
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading