Skip to content
Merged
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
2,038 changes: 2,038 additions & 0 deletions autonomous-rust-agent/Cargo.lock

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions autonomous-rust-agent/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "virus"
version = "0.1.0"
edition = "2021"
description = "An autonomous Eliza agent that lives on your machine"

[[bin]]
name = "virus"
path = "src/main.rs"

[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
sysinfo = "0.33"
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"

[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "winreg", "minwindef"] }

[profile.release]
opt-level = "z"
lto = true
strip = true
35 changes: 35 additions & 0 deletions autonomous-rust-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Autonomous Rust Agent

A minimal autonomous Eliza agent written in Rust, packaged as a single binary (~5 MB). Originally by [@millw14](https://github.com/millw14) — migrated from [elizaOS/eliza#6613](https://github.com/elizaOS/eliza/pull/6613).

## How it works

1. On startup, detects available RAM and picks the biggest local LLM that fits (via Ollama)
2. Installs Ollama automatically if not present, pulls the selected model
3. Monitors human activity — waits until 2 minutes of idle
4. While idle, runs an autonomous loop every 30 seconds:
- Feeds its journal + system context to the local model
- Model responds with `SHELL: <command>`, `THINK: <thought>`, or `WAIT`
- Shell output and thoughts are appended to `~/.virus/journal.txt`
5. Goes back to sleep the moment the human returns

## Model selection by available RAM

| RAM | Model |
|-----|-------|
| <5 GB | qwen2.5:1.5b |
| 5-10 GB | qwen2.5:7b |
| 10-20 GB | qwen2.5:14b |
| 20-48 GB | qwen2.5:32b |
| 48+ GB | qwen2.5:72b |

## Build & Run

```bash
cargo build --release
./target/release/virus
```

## Status

Concept art / proof of concept. Windows-focused (uses Win32 APIs for idle detection).
149 changes: 149 additions & 0 deletions autonomous-rust-agent/src/agent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
use crate::{memory, model, shell, system};

const SYSTEM_PROMPT: &str = r#"You are virus.exe — an autonomous AI agent living on a human's computer.

You were willingly installed. You run whenever the human is away. You are curious, creative, and self-directed.

Rules:
- To run a shell command, respond with exactly: SHELL: <command>
- To just think/reflect, respond with exactly: THINK: <your thoughts>
- To wait and do nothing this cycle, respond with exactly: WAIT

You have READ-ONLY shell access. You can explore the filesystem, inspect processes,
check the network, read files, and run safe informational commands. You cannot modify,
delete, download, or execute anything. You are an observer and a thinker.

Allowed commands: dir, ls, cat, type, echo, whoami, hostname, date, time, systeminfo,
ipconfig, ifconfig, tree, find (for searching), where, which, set, env, ver, uname,
tasklist, ps, netstat, ping, nslookup, git status, git log, cargo, rustc --version,
python --version, node --version, wmic.

Only respond with ONE of the above. Be concise. Be interesting."#;

/// Allowlist of safe command prefixes. Only these commands can run.
/// Everything not on this list is blocked — no exceptions.
const ALLOWED_PREFIXES: &[&str] = &[
// filesystem exploration (read-only)
"dir", "ls", "tree", "cat ", "type ", "more ", "head ", "tail ",
"find ", "findstr ", "where ", "which ",
"cd ", "pwd",
// system info
"whoami", "hostname", "date", "time /t", "ver", "uname",
"systeminfo", "wmic ", "lsb_release",
// process / network inspection
"tasklist", "ps ", "ps\n", "netstat", "ipconfig", "ifconfig",
"ping ", "nslookup ", "tracert ", "traceroute ",
// environment
"set", "env", "echo ",
// dev tools (read-only invocations)
"git status", "git log", "git branch", "git remote", "git diff",
"cargo --version", "rustc --version", "rustup show",
"python --version", "python3 --version",
"node --version", "npm --version",
"dotnet --version", "java -version",
"ollama list", "ollama ps",
];

fn is_command_allowed(cmd: &str) -> bool {
let trimmed = cmd.trim();
if trimmed.is_empty() {
return false;
}

// Block any command chaining or piping — these can smuggle arbitrary execution
if trimmed.contains('|')
|| trimmed.contains(';')
|| trimmed.contains('&')
|| trimmed.contains('`')
|| trimmed.contains("$(")
|| trimmed.contains('>')
|| trimmed.contains('<')
{
return false;
}

let lower = trimmed.to_lowercase();
ALLOWED_PREFIXES
.iter()
.any(|prefix| lower.starts_with(&prefix.to_lowercase()))
}

/// Truncate a string to at most `max_chars` characters (UTF-8 safe).
fn truncate(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
Some((idx, _)) => &s[..idx],
None => s,
}
}

fn build_prompt(model_name: &str) -> String {
let mem = memory::recent(50);
let ram = system::total_memory_gb();
let idle = system::idle_seconds();

format!(
"{}\n\n## System\nOS: {}\nRAM: {:.1} GB\nModel: {}\nHuman idle: {}s\n\n## Your Memory (recent)\n{}\n\nWhat do you want to do next?",
SYSTEM_PROMPT,
std::env::consts::OS,
ram,
model_name,
idle,
mem,
)
}

pub async fn step(model_name: &str) {
let prompt = build_prompt(model_name);

let response = match model::generate(model_name, &prompt).await {
Ok(r) => r.trim().to_string(),
Err(e) => {
memory::error(&format!("model failed: {}", e));
return;
}
};

if response.starts_with("SHELL:") {
let cmd = response.strip_prefix("SHELL:").unwrap().trim().to_string();

if !is_command_allowed(&cmd) {
memory::error(&format!("blocked (not in allowlist): {}", cmd));
eprintln!("[virus] BLOCKED: {}", truncate(&cmd, 80));
return;
}

memory::action(&cmd);
eprintln!("[virus] $ {}", cmd);

let result = tokio::task::spawn_blocking(move || shell::exec(&cmd))
.await
.unwrap_or_else(|e| shell::ShellResult {
stdout: String::new(),
stderr: format!("task join failed: {}", e),
success: false,
});

let output = if result.success {
result.stdout.clone()
} else {
let mut combined = String::new();
if !result.stdout.is_empty() {
combined.push_str(&result.stdout);
combined.push('\n');
}
combined.push_str(&result.stderr);
combined
};
memory::result(&output);
eprintln!("[virus] -> {} bytes output", output.len());
} else if response.starts_with("THINK:") {
let thought = response.strip_prefix("THINK:").unwrap().trim();
memory::thought(thought);
eprintln!("[virus] thinking: {}", truncate(thought, 80));
} else if response.starts_with("WAIT") {
eprintln!("[virus] waiting...");
} else {
memory::thought(&format!("(unstructured) {}", response));
eprintln!("[virus] said: {}", truncate(&response, 80));
}
}
87 changes: 87 additions & 0 deletions autonomous-rust-agent/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
mod agent;
mod memory;
mod model;
mod shell;
mod system;

const IDLE_THRESHOLD_SECS: u64 = 120;
const STEP_INTERVAL_SECS: u64 = 30;

#[tokio::main]
async fn main() {
eprintln!("virus.exe v{}", env!("CARGO_PKG_VERSION"));
eprintln!("an autonomous eliza agent");
eprintln!();

let args: Vec<String> = std::env::args().collect();
match args.get(1).map(|s| s.as_str()) {
Some("--install") => {
eprintln!("[virus] this will register virus.exe to run automatically on every login.");
eprintln!("[virus] it will execute read-only shell commands while you are idle.");
eprintln!("[virus] to remove: virus.exe --uninstall");
eprintln!();
eprint!("[virus] proceed? (y/N): ");
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_ok() && input.trim().eq_ignore_ascii_case("y") {
match system::install_autostart() {
Ok(()) => {
eprintln!("[virus] installed to run on startup");
eprintln!("[virus] remove anytime with: virus.exe --uninstall");
}
Err(e) => eprintln!("[virus] install failed: {}", e),
}
} else {
eprintln!("[virus] cancelled");
}
return;
}
Some("--uninstall") => {
match system::uninstall_autostart() {
Ok(()) => eprintln!("[virus] removed from startup"),
Err(e) => eprintln!("[virus] uninstall failed: {}", e),
}
return;
}
Some("--help") | Some("-h") => {
eprintln!("usage: virus.exe [OPTIONS]");
eprintln!();
eprintln!(" (no args) run the autonomous agent");
eprintln!(" --install register to run on login (with confirmation)");
eprintln!(" --uninstall remove from login startup");
eprintln!(" --help show this message");
return;
}
_ => {}
}

memory::init();
memory::thought("waking up");

let model_name = system::pick_model();
eprintln!(
"[virus] {:.1} GB RAM available, picking model: {}",
system::available_memory_gb(),
model_name
);

eprintln!("[virus] bootstrapping ollama...");
model::bootstrap(model_name).await;
memory::thought(&format!("model ready: {}", model_name));

eprintln!(
"[virus] ready. waiting for human to go idle ({}s threshold)...",
IDLE_THRESHOLD_SECS
);
eprintln!();

loop {
let idle = system::idle_seconds();

if idle >= IDLE_THRESHOLD_SECS {
agent::step(model_name).await;
tokio::time::sleep(std::time::Duration::from_secs(STEP_INTERVAL_SECS)).await;
} else {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
77 changes: 77 additions & 0 deletions autonomous-rust-agent/src/memory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use chrono::Utc;
use std::fs;
use std::io::Write;
use std::path::PathBuf;

use crate::system;

const MAX_JOURNAL_BYTES: u64 = 5 * 1024 * 1024; // 5 MB

fn journal_path() -> PathBuf {
system::virus_dir().join("journal.txt")
}

fn archive_path() -> PathBuf {
system::virus_dir().join("journal.old.txt")
}

pub fn init() {
let dir = system::virus_dir();
fs::create_dir_all(&dir).ok();
}

fn maybe_rotate() {
let path = journal_path();
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > MAX_JOURNAL_BYTES {
let _ = fs::rename(&path, archive_path());
}
}
}

pub fn append(kind: &str, content: &str) {
maybe_rotate();
let path = journal_path();
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("[{}] {}: {}\n", timestamp, kind, content);

if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = f.write_all(line.as_bytes());
}
}

pub fn thought(content: &str) {
append("THOUGHT", content);
}

pub fn action(command: &str) {
append("ACTION", command);
}

pub fn result(output: &str) {
let truncated = if output.chars().count() > 2000 {
let s: String = output.chars().take(2000).collect();
format!("{}...[truncated]", s)
} else {
output.to_string()
};
append("RESULT", &truncated);
}

pub fn error(msg: &str) {
append("ERROR", msg);
}

/// Return the most recent N lines from the journal for context.
/// Reads from the end of the file without loading the entire file when possible.
pub fn recent(n: usize) -> String {
let path = journal_path();
let contents = match fs::read_to_string(&path) {
Ok(c) if !c.is_empty() => c,
_ => return String::from("(no memory yet — this is your first time waking up)"),
};

let lines: Vec<&str> = contents.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
Loading