chore: seed sgclaw rust baseline

This commit is contained in:
zyl
2026-03-25 02:17:55 +00:00
parent 5063adc530
commit 8757bbb266
26 changed files with 2825 additions and 0 deletions

37
src/lib.rs Normal file
View File

@@ -0,0 +1,37 @@
pub mod pipe;
pub mod security;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use pipe::{perform_handshake, BrowserPipeTool, PipeError, StdioTransport, Transport};
use security::MacPolicy;
fn default_rules_path() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("resources")
.join("rules.json")
}
pub fn run() -> Result<(), PipeError> {
let transport = Arc::new(StdioTransport::new(std::io::stdin(), std::io::stdout()));
let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(5))?;
let mac_policy = MacPolicy::load_from_path(default_rules_path())?;
let _browser_tool = BrowserPipeTool::new(transport.clone(), mac_policy, handshake.session_key)
.with_response_timeout(Duration::from_secs(30));
eprintln!("sgclaw ready: agent_id={}", handshake.agent_id);
loop {
match transport.recv_timeout(Duration::from_secs(3600)) {
Ok(message) => {
eprintln!("ignoring unsolicited browser message: {:?}", message);
}
Err(PipeError::Timeout) => continue,
Err(PipeError::PipeClosed) => return Ok(()),
Err(err) => return Err(err),
}
}
}

10
src/main.rs Normal file
View File

@@ -0,0 +1,10 @@
use std::process::ExitCode;
fn main() -> ExitCode {
if let Err(err) = sgclaw::run() {
eprintln!("sgclaw failed: {err}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}

103
src/pipe/browser_tool.rs Normal file
View File

@@ -0,0 +1,103 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::pipe::protocol::{Action, AgentMessage, BrowserMessage, SecurityFields, Timing};
use crate::pipe::{PipeError, Transport};
use crate::security::{sign_command, MacPolicy};
#[derive(Debug, Clone, PartialEq)]
pub struct CommandOutput {
pub seq: u64,
pub success: bool,
pub data: Value,
pub aom_snapshot: Vec<Value>,
pub timing: Timing,
}
pub struct BrowserPipeTool<T: Transport> {
transport: Arc<T>,
mac_policy: MacPolicy,
session_key: Vec<u8>,
next_seq: AtomicU64,
response_timeout: Duration,
}
impl<T: Transport> BrowserPipeTool<T> {
pub fn new(transport: Arc<T>, mac_policy: MacPolicy, session_key: Vec<u8>) -> Self {
Self {
transport,
mac_policy,
session_key,
next_seq: AtomicU64::new(1),
response_timeout: Duration::from_secs(30),
}
}
pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self {
self.response_timeout = response_timeout;
self
}
pub fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.mac_policy.validate(&action, expected_domain)?;
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
let hmac = sign_command(&self.session_key, seq, &action, &params, expected_domain)?;
let command = AgentMessage::Command {
seq,
action,
params,
security: SecurityFields {
expected_domain: expected_domain.to_string(),
hmac,
},
};
self.transport.send(&command)?;
let started = Instant::now();
loop {
let Some(remaining) = self.response_timeout.checked_sub(started.elapsed()) else {
return Err(PipeError::Timeout);
};
match self.transport.recv_timeout(remaining)? {
BrowserMessage::Response {
seq: response_seq,
success,
data,
aom_snapshot,
timing,
} if response_seq == seq => {
return Ok(CommandOutput {
seq: response_seq,
success,
data,
aom_snapshot,
timing,
});
}
BrowserMessage::Response {
seq: response_seq, ..
} => {
return Err(PipeError::Protocol(format!(
"received response seq {response_seq} while waiting for {seq}"
)));
}
BrowserMessage::Init { .. } => {
return Err(PipeError::UnexpectedMessage(
"received duplicate init after handshake".to_string(),
));
}
}
}
}
}

53
src/pipe/handshake.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::time::Duration;
use uuid::Uuid;
use crate::pipe::protocol::{supported_actions, AgentMessage, BrowserMessage, PROTOCOL_VERSION};
use crate::pipe::{PipeError, Transport};
use crate::security::derive_session_key;
#[derive(Debug, Clone)]
pub struct HandshakeResult {
pub agent_id: String,
pub session_key: Vec<u8>,
pub capabilities: Vec<String>,
}
pub fn perform_handshake<T: Transport>(
transport: &T,
timeout: Duration,
) -> Result<HandshakeResult, PipeError> {
let init = transport.recv_timeout(timeout)?;
match init {
BrowserMessage::Init {
version,
hmac_seed,
capabilities,
} => {
if version != PROTOCOL_VERSION {
return Err(PipeError::Protocol(format!(
"unsupported protocol version: {version}"
)));
}
let session_key = derive_session_key(&hmac_seed)?;
let agent_id = Uuid::new_v4().to_string();
let ack = AgentMessage::InitAck {
version: PROTOCOL_VERSION.to_string(),
agent_id: agent_id.clone(),
supported_actions: supported_actions(),
};
transport.send(&ack)?;
Ok(HandshakeResult {
agent_id,
session_key,
capabilities,
})
}
other => Err(PipeError::UnexpectedMessage(format!(
"expected init as first message, got {other:?}"
))),
}
}

125
src/pipe/mod.rs Normal file
View File

@@ -0,0 +1,125 @@
pub mod browser_tool;
pub mod handshake;
pub mod protocol;
pub use browser_tool::{BrowserPipeTool, CommandOutput};
pub use handshake::{perform_handshake, HandshakeResult};
pub use protocol::{
supported_actions, Action, AgentMessage, BrowserMessage, SecurityFields, Timing,
};
use std::io::{BufRead, BufReader, Read, Write};
use std::sync::{mpsc, Mutex};
use std::time::Duration;
use thiserror::Error;
const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
#[derive(Debug, Error)]
pub enum PipeError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("timeout while waiting for browser message")]
Timeout,
#[error("pipe closed")]
PipeClosed,
#[error("protocol error: {0}")]
Protocol(String),
#[error("unexpected message: {0}")]
UnexpectedMessage(String),
#[error("message too large: {0} bytes")]
MessageTooLarge(usize),
#[error(transparent)]
Security(#[from] crate::security::SecurityError),
}
pub trait Transport: Send + Sync {
fn send(&self, message: &AgentMessage) -> Result<(), PipeError>;
fn recv_timeout(&self, timeout: Duration) -> Result<BrowserMessage, PipeError>;
}
pub struct StdioTransport {
rx: Mutex<mpsc::Receiver<Result<BrowserMessage, PipeError>>>,
writer: Mutex<Box<dyn Write + Send>>,
}
impl StdioTransport {
pub fn new<R, W>(reader: R, writer: W) -> Self
where
R: Read + Send + 'static,
W: Write + Send + 'static,
{
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut reader = BufReader::new(reader);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let line = line.trim_end_matches(&['\r', '\n'][..]);
if line.is_empty() {
let _ = tx.send(Err(PipeError::Protocol(
"received empty JSON line".to_string(),
)));
continue;
}
if line.as_bytes().len() > MAX_MESSAGE_BYTES {
let _ = tx.send(Err(PipeError::MessageTooLarge(line.len())));
continue;
}
let parsed =
serde_json::from_str::<BrowserMessage>(line).map_err(PipeError::from);
let _ = tx.send(parsed);
}
Err(err) => {
let _ = tx.send(Err(PipeError::Io(err)));
break;
}
}
}
});
Self {
rx: Mutex::new(rx),
writer: Mutex::new(Box::new(writer)),
}
}
}
impl Transport for StdioTransport {
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
let payload = serde_json::to_vec(message)?;
if payload.len() > MAX_MESSAGE_BYTES {
return Err(PipeError::MessageTooLarge(payload.len()));
}
let mut writer = self
.writer
.lock()
.map_err(|_| PipeError::Protocol("writer lock poisoned".to_string()))?;
writer.write_all(&payload)?;
writer.write_all(b"\n")?;
writer.flush()?;
Ok(())
}
fn recv_timeout(&self, timeout: Duration) -> Result<BrowserMessage, PipeError> {
let rx = self
.rx
.lock()
.map_err(|_| PipeError::Protocol("receiver lock poisoned".to_string()))?;
match rx.recv_timeout(timeout) {
Ok(result) => result,
Err(mpsc::RecvTimeoutError::Timeout) => Err(PipeError::Timeout),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(PipeError::PipeClosed),
}
}
}

115
src/pipe/protocol.rs Normal file
View File

@@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
pub const PROTOCOL_VERSION: &str = "1.0";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BrowserMessage {
Init {
version: String,
hmac_seed: String,
#[serde(default)]
capabilities: Vec<String>,
},
Response {
seq: u64,
success: bool,
#[serde(default = "default_object")]
data: Value,
#[serde(default)]
aom_snapshot: Vec<Value>,
timing: Timing,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentMessage {
InitAck {
version: String,
agent_id: String,
supported_actions: Vec<Action>,
},
Command {
seq: u64,
action: Action,
params: Value,
security: SecurityFields,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Action {
Click,
Type,
Navigate,
GetText,
GetHtml,
WaitForSelector,
PageScreenshot,
Select,
ScrollTo,
GetAomSnapshot,
StorageSet,
StorageGet,
ZombieSpawn,
ZombieKill,
}
impl Action {
pub fn as_str(&self) -> &'static str {
match self {
Action::Click => "click",
Action::Type => "type",
Action::Navigate => "navigate",
Action::GetText => "getText",
Action::GetHtml => "getHtml",
Action::WaitForSelector => "waitForSelector",
Action::PageScreenshot => "pageScreenshot",
Action::Select => "select",
Action::ScrollTo => "scrollTo",
Action::GetAomSnapshot => "getAomSnapshot",
Action::StorageSet => "storageSet",
Action::StorageGet => "storageGet",
Action::ZombieSpawn => "zombieSpawn",
Action::ZombieKill => "zombieKill",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityFields {
pub expected_domain: String,
pub hmac: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Timing {
pub queue_ms: u64,
pub exec_ms: u64,
}
pub fn supported_actions() -> Vec<Action> {
vec![
Action::Click,
Action::Type,
Action::Navigate,
Action::GetText,
Action::GetHtml,
Action::WaitForSelector,
Action::PageScreenshot,
Action::Select,
Action::ScrollTo,
Action::GetAomSnapshot,
Action::StorageSet,
Action::StorageGet,
Action::ZombieSpawn,
Action::ZombieKill,
]
}
fn default_object() -> Value {
json!({})
}

48
src/security/hmac.rs Normal file
View File

@@ -0,0 +1,48 @@
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::pipe::Action;
use crate::security::SecurityError;
type HmacSha256 = Hmac<Sha256>;
pub fn derive_session_key(hmac_seed: &str) -> Result<Vec<u8>, SecurityError> {
let seed = hex::decode(hmac_seed)?;
if seed.is_empty() {
return Err(SecurityError::InvalidSeed(
"hmac_seed must not be empty".to_string(),
));
}
let mut hasher = Sha256::new();
hasher.update(seed);
hasher.update(b"sgclaw-session-v1");
Ok(hasher.finalize().to_vec())
}
pub fn sign_command(
session_key: &[u8],
seq: u64,
action: &Action,
params: &Value,
expected_domain: &str,
) -> Result<String, SecurityError> {
if session_key.is_empty() {
return Err(SecurityError::InvalidSeed(
"session key must not be empty".to_string(),
));
}
let mut mac = HmacSha256::new_from_slice(session_key)
.map_err(|err| SecurityError::Hmac(err.to_string()))?;
mac.update(seq.to_string().as_bytes());
mac.update(b"|");
mac.update(action.as_str().as_bytes());
mac.update(b"|");
mac.update(expected_domain.as_bytes());
mac.update(b"|");
mac.update(serde_json::to_string(params)?.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}

111
src/security/mac_policy.rs Normal file
View File

@@ -0,0 +1,111 @@
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::pipe::Action;
use crate::security::SecurityError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacPolicy {
pub version: String,
pub domains: DomainRules,
pub pipe_actions: PipeActionRules,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainRules {
pub allowed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipeActionRules {
pub allowed: Vec<String>,
#[serde(default)]
pub blocked: Vec<String>,
}
impl MacPolicy {
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SecurityError> {
let contents = fs::read_to_string(path)?;
let policy: Self = serde_json::from_str(&contents)?;
policy.validate_rules()?;
Ok(policy)
}
pub fn from_json_str(contents: &str) -> Result<Self, SecurityError> {
let policy: Self = serde_json::from_str(contents)?;
policy.validate_rules()?;
Ok(policy)
}
pub fn validate(&self, action: &Action, expected_domain: &str) -> Result<(), SecurityError> {
let action_name = action.as_str();
if self
.pipe_actions
.blocked
.iter()
.any(|blocked| blocked == action_name)
{
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
}
if !self
.pipe_actions
.allowed
.iter()
.any(|allowed| allowed == action_name)
{
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
}
let normalized = normalize_domain(expected_domain);
if normalized.is_empty() {
return Err(SecurityError::DomainNotAllowed(expected_domain.to_string()));
}
if !self
.domains
.allowed
.iter()
.map(|domain| normalize_domain(domain))
.any(|allowed| allowed == normalized)
{
return Err(SecurityError::DomainNotAllowed(normalized));
}
Ok(())
}
fn validate_rules(&self) -> Result<(), SecurityError> {
if self.version.trim().is_empty() {
return Err(SecurityError::InvalidRules(
"rules version must not be empty".to_string(),
));
}
if self.domains.allowed.is_empty() {
return Err(SecurityError::InvalidRules(
"at least one allowed domain is required".to_string(),
));
}
if self.pipe_actions.allowed.is_empty() {
return Err(SecurityError::InvalidRules(
"at least one allowed action is required".to_string(),
));
}
Ok(())
}
}
fn normalize_domain(raw: &str) -> String {
raw.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}

27
src/security/mod.rs Normal file
View File

@@ -0,0 +1,27 @@
mod hmac;
mod mac_policy;
pub use hmac::{derive_session_key, sign_command};
pub use mac_policy::MacPolicy;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SecurityError {
#[error("invalid hmac seed: {0}")]
InvalidSeed(String),
#[error("action is not allowed: {0}")]
ActionNotAllowed(String),
#[error("domain is not allowed: {0}")]
DomainNotAllowed(String),
#[error("invalid rules: {0}")]
InvalidRules(String),
#[error("hmac error: {0}")]
Hmac(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Hex(#[from] hex::FromHexError),
}