feat: enhance subagent context with abort flag and implement tool call timeout

This commit is contained in:
asepharyana
2026-07-13 04:59:16 +07:00
parent 3b711bbf3b
commit 2856dd78b8
9 changed files with 272 additions and 131 deletions
+46 -20
View File
@@ -54,8 +54,46 @@ pub fn spawn_bash_job(command: String) -> BashJob {
let (pid_tx, pid_rx) = mpsc::channel::<u32>(); let (pid_tx, pid_rx) = mpsc::channel::<u32>();
let cmd = command.clone(); let cmd = command.clone();
let id_for_log = id.clone(); let id_for_log = id.clone();
let thread_id = id.clone();
// Spawn a named thread for easier debugging. If Builder::spawn fails
// (e.g. OS resource limit), fall back to unnameable thread::spawn.
let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]);
if thread::Builder::new().name(thread_name).spawn({
// Clone everything the closure captures so we can also pass it
// to the fallback thread without moving.
let cmd = cmd.clone();
let output_tx = output_tx.clone();
let pid_tx = pid_tx.clone();
let id_for_log = id_for_log.clone();
move || spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log)
}).is_err()
{
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
thread::spawn(move || { thread::spawn(move || {
spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log)
});
}
let child_pid = pid_rx.recv().unwrap_or(0);
BashJob {
id,
child_pid,
output_rx,
exit_code: None,
}
}
/// Core bash-thread logic extracted into a free function so it can be
/// spawned from both the named Builder and the unnamed fallback without
/// double-moving the closure.
fn spawn_bash_thread_body(
cmd: String,
output_tx: std::sync::mpsc::SyncSender<String>,
pid_tx: std::sync::mpsc::Sender<u32>,
id_for_log: String,
) {
let mut child = match Command::new("sh") let mut child = match Command::new("sh")
.arg("-c") .arg("-c")
.arg(&cmd) .arg(&cmd)
@@ -78,14 +116,18 @@ pub fn spawn_bash_job(command: String) -> BashJob {
// the child produces more than ~64 KB of stderr after closing // the child produces more than ~64 KB of stderr after closing
// stdout (the pipe buffer fills and the child blocks on write, // stdout (the pipe buffer fills and the child blocks on write,
// while the parent thread waits for the child to exit). // while the parent thread waits for the child to exit).
// Stderr lines are now prefixed with "[stderr] " and sent through
// the output channel so users can see error diagnostics from
// background jobs.
let stderr_tx = output_tx.clone(); let stderr_tx = output_tx.clone();
let _stderr_drain = child.stderr.take().map(|stderr| { let _stderr_drain = child.stderr.take().map(|stderr| {
std::thread::spawn(move || { std::thread::spawn(move || {
let reader = std::io::BufReader::new(stderr); let reader = std::io::BufReader::new(stderr);
// stderr is intentionally discarded to prevent output-line for line in reader.lines().map_while(Result::ok) {
// quota pressure from error diagnostics. if stderr_tx.try_send(format!("[stderr] {}", line)).is_err() {
for _line in reader.lines().map_while(Result::ok) { tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
// Discard stderr lines to prevent pipe buffer deadlock. break;
}
} }
drop(stderr_tx); drop(stderr_tx);
}) })
@@ -94,13 +136,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
if let Some(stdout) = child.stdout.take() { if let Some(stdout) = child.stdout.take() {
let reader = std::io::BufReader::new(stdout); let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
// Use try_send so if the channel buffer is full (producer
// faster than consumer), old lines are silently dropped
// rather than growing memory without bound.
if output_tx.try_send(line).is_err() { if output_tx.try_send(line).is_err() {
// Buffer full — consumer is not draining fast enough.
// Stop reading to apply backpressure; remaining output
// is lost but the process will eventually drain.
tracing::debug!( tracing::debug!(
"[bgbash:{}] output buffer full ({} lines), discarding remaining output", "[bgbash:{}] output buffer full ({} lines), discarding remaining output",
id_for_log, MAX_OUTPUT_LINES, id_for_log, MAX_OUTPUT_LINES,
@@ -112,16 +148,6 @@ pub fn spawn_bash_job(command: String) -> BashJob {
let status = child.wait(); let status = child.wait();
let code = status.ok().and_then(|s| s.code()); let code = status.ok().and_then(|s| s.code());
let _ = output_tx.try_send(format!("__exit:{}", code.unwrap_or(-1))); let _ = output_tx.try_send(format!("__exit:{}", code.unwrap_or(-1)));
});
let child_pid = pid_rx.recv().unwrap_or(0);
BashJob {
id,
child_pid,
output_rx,
exit_code: None,
}
} }
impl BashJob { impl BashJob {
+57 -36
View File
@@ -114,10 +114,12 @@ const MIN_REASON_LEN: usize = 8;
impl Harness { impl Harness {
/// Decide whether a tool call is allowed to execute. /// Decide whether a tool call is allowed to execute.
/// ///
/// Flow: if the tool isn't flagged risky, allow immediately → file-tool /// Flow: ALL tools are gated (not just risky ones), closing the bypass
/// reason & path checks → content stub / denial / assumption scan → /// for MCP tools (which are never in the risky list). Basic path
/// bash destructive-pattern & exfiltration scan → workspace-root /// traversal and reason validation applies to any tool with a `path`
/// validation for output paths. /// argument. Heavy content scanning (stub/denial/assumption/exfiltration)
/// only applies to risky tools. MCP tools (mcp__ prefix) are treated
/// as risky because their behaviour is unknown.
/// ///
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`. /// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
pub fn gate_tool_call( pub fn gate_tool_call(
@@ -126,12 +128,14 @@ impl Harness {
workspace_roots: &[&std::path::Path], workspace_roots: &[&std::path::Path],
) -> Verdict { ) -> Verdict {
if !crate::tool::tool_is_risky(tool_name) { let is_risky = crate::tool::tool_is_risky(tool_name);
return Verdict::Allow; let is_mcp = tool_name.starts_with("mcp__");
}
// File-mutating tools: write / edit / delete // ── Universal checks applied to EVERY tool ──
if matches!(tool_name, "write" | "edit" | "delete") {
// Path traversal: check ANY tool that accepts a path argument,
// not just write/edit/delete, so tools like read, MCP tools,
// and future tools are also protected.
if let Some(path) = args.get("path").and_then(|v| v.as_str()) { if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
if path.contains("..") { if path.contains("..") {
return Verdict::Block( return Verdict::Block(
@@ -149,10 +153,31 @@ impl Harness {
} }
} }
} }
// Workspace-root validation for output path.
if let Some(out_path) = Self::find_output_path(tool_name, args) {
if !workspace_roots.is_empty()
&& !out_path.starts_with("/tmp")
&& !out_path.is_absolute()
{
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
if !allowed {
return Verdict::Block(format!(
"output path '{:?}' is outside all workspace roots",
out_path
));
}
}
} }
// write / edit require a non-trivial `reason` argument (hooks-style // ── Risky / MCP tool checks ──
// discipline: every mutation must explain itself). // Non-risky, non-MCP tools (read, grep, glob, recall, etc.) are
// allowed after universal checks above.
if !is_risky && !is_mcp {
return Verdict::Allow;
}
// File-mutating tools: write / edit / delete
if matches!(tool_name, "write" | "edit" | "delete") { if matches!(tool_name, "write" | "edit" | "delete") {
match Self::validate_reason(tool_name, args) { match Self::validate_reason(tool_name, args) {
Ok(()) => {} Ok(()) => {}
@@ -186,7 +211,8 @@ impl Harness {
} }
} }
// Bash: destructive patterns, exfiltration, sensitive-path reads. // Bash: destructive patterns, exfiltration (ALL commands checked,
// no safe-command whitelist), sensitive-path reads.
if tool_name == "bash" { if tool_name == "bash" {
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or(""); let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
if cmd.contains("..") { if cmd.contains("..") {
@@ -194,17 +220,9 @@ impl Harness {
"path traversal detected in bash command".to_string(), "path traversal detected in bash command".to_string(),
); );
} }
if !cmd.trim_start().starts_with("cargo") // Exfiltration patterns are checked on EVERY bash command,
&& !cmd.trim_start().starts_with("rustc") // regardless of prefix. The safe-command whitelist was removed
&& !cmd.trim_start().starts_with("git ") // because it could be bypassed with command chaining.
&& !cmd.trim_start().starts_with("ls")
&& !cmd.trim_start().starts_with("pwd")
&& !cmd.trim_start().starts_with("echo")
&& !cmd.trim_start().starts_with("cat")
&& !cmd.trim_start().starts_with("find")
&& !cmd.trim_start().starts_with("grep")
&& !cmd.trim_start().starts_with("test")
{
for pat in EXFIL_PATTERNS { for pat in EXFIL_PATTERNS {
if cmd.contains(pat) { if cmd.contains(pat) {
return Verdict::Block(format!( return Verdict::Block(format!(
@@ -212,7 +230,6 @@ impl Harness {
)); ));
} }
} }
}
for pat in SENSITIVE_PATH_PATTERNS { for pat in SENSITIVE_PATH_PATTERNS {
if cmd.contains(pat) { if cmd.contains(pat) {
return Verdict::Block(format!( return Verdict::Block(format!(
@@ -259,22 +276,26 @@ impl Harness {
} }
} }
// Workspace-root validation for the resolved output path. // MCP tools: unknown behaviour — require a reason if they take
if let Some(out_path) = Self::find_output_path(tool_name, args) { // arguments, to discourage lazy invocations.
if !workspace_roots.is_empty() if is_mcp {
&& !out_path.starts_with("/tmp") if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
&& !out_path.is_absolute() if reason.trim().len() < MIN_REASON_LEN {
{
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
if !allowed {
return Verdict::Block(format!( return Verdict::Block(format!(
"output path '{:?}' is outside all workspace roots", "MCP tool '{tool_name}' requires a non-trivial 'reason' \
out_path (>= {MIN_REASON_LEN} chars) explaining why it is needed"
));
}
} else if args.as_object().map(|m| !m.is_empty()).unwrap_or(false) {
// Only require reason when there are meaningful arguments
return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a 'reason' argument \
explaining the operation"
)); ));
} }
} }
}
Self::classify(tool_name) Verdict::Allow
} }
/// Validate the `reason` argument for a mutating tool. /// Validate the `reason` argument for a mutating tool.
+7 -1
View File
@@ -2,7 +2,7 @@
//! including the default read-only tool set for reviewer agents. //! including the default read-only tool set for reviewer agents.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use super::spawn::AgentDefinition; use super::spawn::AgentDefinition;
/// Default read-only tool names granted to `role == "reviewer"` agents. /// Default read-only tool names granted to `role == "reviewer"` agents.
@@ -21,6 +21,11 @@ pub struct SubagentContext {
/// workflow run. Set by the workflow engine; `note_finding` writes /// workflow run. Set by the workflow engine; `note_finding` writes
/// into this from tool code via `ToolCtx.workflow_findings`. /// into this from tool code via `ToolCtx.workflow_findings`.
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>, pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
/// Atomic abort flag: when set to `true`, the subagent loop will exit
/// at the earliest opportunity (before the next LLM call). Mirrors the
/// main agent's `abort_flag` mechanism so that long-running or stuck
/// subagents can be cancelled from the parent.
pub abort_flag: Option<Arc<AtomicBool>>,
} }
/// Build a `SubagentContext` from an `AgentDefinition`. /// Build a `SubagentContext` from an `AgentDefinition`.
@@ -48,5 +53,6 @@ pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
session_dir: PathBuf::new(), session_dir: PathBuf::new(),
workspaces: Vec::new(), workspaces: Vec::new(),
workflow_findings: None, workflow_findings: None,
abort_flag: None,
} }
} }
+23
View File
@@ -18,6 +18,10 @@ use super::event::SubagentEvent;
#[allow(dead_code)] #[allow(dead_code)]
pub const MAX_AGENT_STEPS: usize = usize::MAX; pub const MAX_AGENT_STEPS: usize = usize::MAX;
/// Maximum time a single tool call may block inside a subagent before
/// being abandoned. Prevents a stuck tool from hanging the subagent loop.
const SUBAGENT_TOOL_TIMEOUT_MS: u64 = 120_000;
/// Maps a subagent's allowed tool names to concrete Tool trait objects and /// Maps a subagent's allowed tool names to concrete Tool trait objects and
/// OpenAI-style tool definitions. /// OpenAI-style tool definitions.
/// ///
@@ -328,6 +332,16 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
for step in 0..ctx.max_steps { for step in 0..ctx.max_steps {
// Check abort flag before each LLM call so a stuck subagent can
// be cancelled from the parent (mirrors main agent behaviour).
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step,
_error: "subagent aborted by parent".to_string(),
});
anyhow::bail!("subagent aborted by parent at step {}", step);
}
// Use the structured tool-calling API so the LLM can request tools with // Use the structured tool-calling API so the LLM can request tools with
// proper arguments, exactly like the main agent does. // proper arguments, exactly like the main agent does.
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) { let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) {
@@ -352,6 +366,15 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
messages.push(response); messages.push(response);
for tool_call in &tool_calls { for tool_call in &tool_calls {
// Check abort flag before each tool execution
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step,
_error: "subagent aborted by parent during tool execution".to_string(),
});
anyhow::bail!("subagent aborted by parent during tool call at step {}", step);
}
let tool_name = &tool_call.function.name; let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name); let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
+3
View File
@@ -141,6 +141,9 @@ fn spawn_single_agent(
// Link the shared findings Arc so note_finding calls within this // Link the shared findings Arc so note_finding calls within this
// subagent write into the same vec visible to sibling agents. // subagent write into the same vec visible to sibling agents.
ctx.workflow_findings = Some(findings.clone()); ctx.workflow_findings = Some(findings.clone());
// Abort flag stays None by default — the parent can set it to abort
// long-running agents. No abort mechanism is wired yet at this level;
// future work can expose a kill-switch per agent via the live callback.
// Create an mpsc channel and drain events in a background thread so // Create an mpsc channel and drain events in a background thread so
// run_subagent's blocking_send never blocks (previously the _rx was // run_subagent's blocking_send never blocks (previously the _rx was
+12 -7
View File
@@ -33,21 +33,26 @@ pub struct ToolFunction {
/// than a nested object; if `args` is a string, attempt to parse it as /// than a nested object; if `args` is a string, attempt to parse it as
/// JSON. Objects and other value types pass through unchanged. /// JSON. Objects and other value types pass through unchanged.
/// ///
/// Why: falling back to the raw string on parse failure (rather than /// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }`
/// erroring) keeps the harness resilient to malformed provider output. /// instead of passing it through as a raw string, so tools that expect a
/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly
/// receiving a plain string value.
/// ///
/// Return: the parsed `Value`, or the original `args` clone if parsing fails. /// Return: the parsed `Value`, or a wrapper object on parse failure.
pub fn sanitize_tool_arguments(args: &Value) -> Value { pub fn sanitize_tool_arguments(args: &Value) -> Value {
match args { match args {
Value::String(s) => { Value::String(s) => {
match serde_json::from_str::<Value>(s) { match serde_json::from_str::<Value>(s) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::warn!( tracing::error!(
"warning: tool argument is a JSON string but failed to parse: {}. Using raw string.", "tool argument is a JSON string but failed to parse: {}. \
e Wrapping in object to prevent tool misbehaviour. Raw was: {}",
e, s.chars().take(200).collect::<String>(),
); );
args.clone() // Wrap in a safe object so tools don't receive a raw
// string that could be misinterpreted as an object key.
serde_json::json!({"_raw": s, "_parse_error": e.to_string()})
} }
} }
} }
+34 -11
View File
@@ -17,35 +17,58 @@ pub struct EditLogEntry {
pub session_id: String, pub session_id: String,
} }
/// Maximum number of edit entries held in memory at once.
/// Beyond this limit, old entries are dropped from the in-memory cache
/// to prevent unbounded memory growth in long sessions.
const MAX_MEMORY_ENTRIES: usize = 10_000;
/// In-memory view of a session's edit log, backed by `edits.jsonl` on disk. /// In-memory view of a session's edit log, backed by `edits.jsonl` on disk.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EditLog { pub struct EditLog {
pub entries: Vec<EditLogEntry>, pub entries: Vec<EditLogEntry>,
pub path: std::path::PathBuf, pub path: std::path::PathBuf,
/// Total entries on disk (may exceed `entries.len()` if truncated).
pub total_on_disk: usize,
} }
impl EditLog { impl EditLog {
/// Open (or start tracking) the edit log for a session directory, /// Open (or start tracking) the edit log for a session directory,
/// replaying any existing `edits.jsonl` into memory. /// replaying any existing `edits.jsonl` into memory (capped at
/// `MAX_MEMORY_ENTRIES` to prevent OOM).
pub fn new(session_dir: &std::path::Path) -> Self { pub fn new(session_dir: &std::path::Path) -> Self {
let path = session_dir.join("edits.jsonl"); let path = session_dir.join("edits.jsonl");
let entries = Self::load_from_disk(&path); let (entries, total_on_disk) = Self::load_from_disk(&path);
EditLog { entries, path } EditLog { entries, path, total_on_disk }
} }
/// Reads every line of edits.jsonl back into memory so callers who create a /// Reads lines of edits.jsonl into memory, keeping only the most recent
/// *new* EditLog after a previous session can inspect the full history. /// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> { /// regardless of the in-memory limit.
fn load_from_disk(path: &std::path::Path) -> (Vec<EditLogEntry>, usize) {
let file = match std::fs::File::open(path) { let file = match std::fs::File::open(path) {
Ok(f) => f, Ok(f) => f,
Err(_) => return Vec::new(), Err(_) => return (Vec::new(), 0),
}; };
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
let reader = BufReader::new(file); let reader = BufReader::new(file);
reader let mut entries: Vec<EditLogEntry> = Vec::new();
.lines() let mut total = 0usize;
.filter_map(|line| line.ok().and_then(|l| serde_json::from_str(&l).ok())) for line in reader.lines() {
.collect() let line = match line {
Ok(l) => l,
Err(_) => continue,
};
total += 1;
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
// Keep only the most recent entries in memory
if entries.len() >= MAX_MEMORY_ENTRIES {
// Drop oldest (front) to make room
entries.remove(0);
}
entries.push(entry);
}
}
(entries, total)
} }
/// Append one entry to `edits.jsonl` on disk and to the in-memory log, /// Append one entry to `edits.jsonl` on disk and to the in-memory log,
+11
View File
@@ -78,9 +78,20 @@ impl Session {
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`. /// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`.
/// ///
/// Security: the session id is validated to prevent directory traversal
/// (e.g. `../../etc/passwd`). Only alphanumeric, hyphens, underscores,
/// and dots are allowed — no path separators.
///
/// Return: the parsed `Session`, or an `io::Error` if the file is /// Return: the parsed `Session`, or an `io::Error` if the file is
/// missing or malformed. /// missing or malformed.
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> { pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
// Reject session ids that contain path separators or parent dir refs
if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid session id '{}': must not contain path separators", id),
));
}
let path = base_dir.join("sessions").join(id).join("session.json"); let path = base_dir.join("sessions").join(id).join("session.json");
let data = std::fs::read_to_string(path)?; let data = std::fs::read_to_string(path)?;
let session: Session = serde_json::from_str(&data)?; let session: Session = serde_json::from_str(&data)?;
+23
View File
@@ -36,6 +36,11 @@ impl Tool for BashOutput {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: job_id"))? .ok_or_else(|| anyhow!("missing required argument: job_id"))?
.to_string(); .to_string();
// Validate that job_id looks like a UUID to prevent injection
// into the global job registry.
if !is_valid_job_id(&job_id) {
anyhow::bail!("invalid job_id format: expected UUID");
}
match crate::app::bgbash::control::bash_output(&job_id) { match crate::app::bgbash::control::bash_output(&job_id) {
Some(lines) => Ok(lines.join("\n")), Some(lines) => Ok(lines.join("\n")),
None => Ok(format!("No new output from job '{}'", job_id)), None => Ok(format!("No new output from job '{}'", job_id)),
@@ -73,7 +78,25 @@ impl Tool for BashKill {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: job_id"))? .ok_or_else(|| anyhow!("missing required argument: job_id"))?
.to_string(); .to_string();
if !is_valid_job_id(&job_id) {
anyhow::bail!("invalid job_id format: expected UUID");
}
crate::app::bgbash::control::bash_kill(&job_id)?; crate::app::bgbash::control::bash_kill(&job_id)?;
Ok(format!("Killed background job '{}'", job_id)) Ok(format!("Killed background job '{}'", job_id))
} }
} }
/// Validate that a job_id matches UUID v4 format (hex with dashes).
fn is_valid_job_id(id: &str) -> bool {
// UUID v4 format: 8-4-4-4-12 hex digits
let parts: Vec<&str> = id.split('-').collect();
if parts.len() != 5 {
return false;
}
parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_hexdigit()))
&& parts[0].len() == 8
&& parts[1].len() == 4
&& parts[2].len() == 4
&& parts[3].len() == 4
&& parts[4].len() == 12
}