Skip to content
Draft
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
9 changes: 6 additions & 3 deletions desktop/src-tauri/src/commands/personas/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use uuid::Uuid;
use crate::{
app_state::AppState,
managed_agents::{
apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition,
CatalogSource, CreatePersonaRequest,
apply_persona_behavior, load_personas, save_personas, try_regenerate_nest,
validate_agent_definition_text, AgentDefinition, CatalogSource, CreatePersonaRequest,
},
util::now_iso,
};
Expand All @@ -25,7 +25,10 @@ pub async fn create_persona(
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
// System prompt optional: core memory is auto-injected. Empty is valid.
let system_prompt = input.system_prompt.trim().to_string();
// Preserve it byte-for-byte: shared/import review surfaces show this
// exact string before the ACP harness executes it.
let system_prompt = input.system_prompt.clone();
validate_agent_definition_text(&display_name, &system_prompt)?;
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
Expand Down
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ fn reconcile_inbound_persona_event_blocking(
let inbound_persona = (kind == KIND_PERSONA)
.then(|| persona_from_event(&event))
.transpose()?;
if let Some(persona) = &inbound_persona {
validate_inbound_persona_definition(persona)?;
}
let d_tag = match &inbound_persona {
Some(persona) => persona_d_tag(persona),
None => event_d_tag(&event)?,
Expand Down Expand Up @@ -182,6 +185,14 @@ fn reconcile_inbound_persona_event_blocking(
Ok(())
}

fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), String> {
crate::managed_agents::validate_agent_definition_text(
&persona.display_name,
&persona.system_prompt,
)
.map_err(|error| format!("Inbound persona definition is unsafe: {error}"))
}

/// Parse an inbound wire event and enforce the signature gate. Everything
/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping,
/// behavioral-quad application), so a forged pubkey must die here — the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use super::*;
use std::collections::BTreeMap;

const UUID: &str = "11111111-2222-3333-4444-555555555555";
const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq.pii.cc.visa -- fixed test UUID

/// A local in-app persona: `source_team_persona_slug` is None, so its d-tag
/// IS its UUID id. Carries env_vars + source_team that must survive a patch.
Expand Down Expand Up @@ -673,3 +673,14 @@ fn inbound_gate_accepts_validly_signed_event() {
let parsed = parse_verified_inbound_event(&event.as_json()).unwrap();
assert_eq!(parsed.pubkey, keys.public_key());
}

#[test]
fn inbound_persona_rejects_invisible_definition_text() {
let mut inbound = inbound_for("unsafe", "Remote");
inbound.system_prompt = "Review\u{200B} code.".to_string();

let error = validate_inbound_persona_definition(&inbound)
.expect_err("relay sync must reject invisible instructions");

assert!(error.contains("U+200B"));
}
20 changes: 20 additions & 0 deletions desktop/src-tauri/src/commands/personas/pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ pub(super) fn prepare_persona_publication_at(
let mut scoped_persona = persona.clone();
scoped_persona.shared =
shared_override.unwrap_or_else(|| retained_persona_is_shared(existing.as_ref()));
if scoped_persona.shared {
crate::managed_agents::validate_agent_definition_text(
&scoped_persona.display_name,
&scoped_persona.system_prompt,
)?;
}
let event = build_persona_event(&scoped_persona)?
.custom_created_at(monotonic_created_at(
existing.as_ref().map(|row| row.created_at),
Expand Down Expand Up @@ -396,4 +402,18 @@ mod tests {
.expect_err("a directory cannot be opened as the retention database");
assert!(error.contains("failed to open retention db"));
}

#[test]
fn shared_publication_rejects_invisible_definition_text() {
let dir = tempfile::tempdir().unwrap();
let keys = nostr::Keys::generate();
let db_path = dir.path().join("retention.sqlite3");
let mut unsafe_persona = persona();
unsafe_persona.system_prompt = "Review\u{200B} the catalog.".to_string();

let error = prepare_persona_publication_at(&db_path, &keys, &unsafe_persona, Some(true))
.expect_err("sharing must reject an invisible instruction character");

assert!(error.contains("U+200B"));
}
}
3 changes: 2 additions & 1 deletion desktop/src-tauri/src/commands/personas/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
managed_agents::{
apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas,
managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest,
AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest,
validate_agent_definition_text, AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest,
},
util::now_iso,
};
Expand Down Expand Up @@ -91,6 +91,7 @@ pub(super) async fn update_persona_with<R: Send + 'static>(
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = input.system_prompt.clone();
validate_agent_definition_text(&display_name, &system_prompt)?;
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/managed_agents/agent_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,15 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String>
if snapshot.profile.display_name.trim().is_empty() {
return Err("Snapshot profile.displayName is empty".to_string());
}
super::validate_agent_definition_text(
&snapshot.profile.display_name,
snapshot
.definition
.system_prompt
.as_deref()
.unwrap_or_default(),
)
.map_err(|error| format!("Snapshot definition is unsafe: {error}"))?;
Ok(())
}

Expand Down
129 changes: 129 additions & 0 deletions desktop/src-tauri/src/managed_agents/definition_validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Validation for human-reviewed agent definition text.
//!
//! Shared definitions are executable configuration: `system_prompt` is shown
//! to a person, then delivered verbatim to an ACP harness. Characters that
//! consume input bytes without a visible glyph break that review invariant and
//! are rejected rather than silently stripped.

const MAX_DISPLAY_NAME_CHARS: usize = 128;
const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024;

/// Validate the human-visible fields of an agent definition.
pub(crate) fn validate_agent_definition_text(
display_name: &str,
system_prompt: &str,
) -> Result<(), String> {
if display_name.trim().is_empty() {
return Err("Display name is required".to_string());
}
let display_name_chars = display_name.chars().count();
if display_name_chars > MAX_DISPLAY_NAME_CHARS {
return Err(format!(
"Display name is too long ({display_name_chars} characters, max {MAX_DISPLAY_NAME_CHARS})"
));
}
if system_prompt.len() > MAX_SYSTEM_PROMPT_BYTES {
return Err(format!(
"Agent instructions are too long ({} bytes, max {MAX_SYSTEM_PROMPT_BYTES})",
system_prompt.len()
));
}

validate_visible_text(display_name, "Display name", false)?;
validate_visible_text(system_prompt, "Agent instructions", true)
}

fn validate_visible_text(
value: &str,
label: &str,
allow_layout_controls: bool,
) -> Result<(), String> {
for character in value.chars() {
let allowed_layout_control = allow_layout_controls && matches!(character, '\n' | '\t');
if (!allowed_layout_control && character.is_control()) || is_default_ignorable(character) {
return Err(format!(
"{label} contains prohibited invisible or formatting character U+{:04X}",
character as u32
));
}
}
Ok(())
}

/// Unicode `Default_Ignorable_Code_Point` ranges (DerivedCoreProperties).
///
/// This deliberately includes joiners and variation selectors. They can be
/// legitimate in prose, but they are not faithfully reviewable in a prompt
/// that will later execute with the host's access. Shared agent definitions
/// prefer an explicit rejection over a display/execution mismatch.
fn is_default_ignorable(character: char) -> bool {
matches!(
character as u32,
0x00AD
| 0x034F
| 0x061C
| 0x115F..=0x1160
| 0x17B4..=0x17B5
| 0x180B..=0x180F
| 0x200B..=0x200F
| 0x202A..=0x202E
| 0x2060..=0x206F
| 0x3164
| 0xFE00..=0xFE0F
| 0xFEFF
| 0xFFA0
| 0xFFF0..=0xFFF8
| 0x1BCA0..=0x1BCA3
| 0x1D173..=0x1D17A
| 0xE0000..=0xE0FFF
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn accepts_plain_multiline_instructions() {
assert!(validate_agent_definition_text(
"Code Reviewer 🐝",
"Review changes.\n\tCall out security risks."
)
.is_ok());
}

#[test]
fn rejects_default_ignorable_characters_in_name_or_prompt() {
for character in [
'\u{00AD}',
'\u{034F}',
'\u{200B}',
'\u{200D}',
'\u{202E}',
'\u{2060}',
'\u{2066}',
'\u{3164}',
'\u{FE0F}',
'\u{E007F}',
] {
let name = format!("Review{character}er");
let prompt = format!("Review code.{character}");
assert!(validate_agent_definition_text(&name, "Review code.").is_err());
assert!(validate_agent_definition_text("Reviewer", &prompt).is_err());
}
}

#[test]
fn rejects_non_layout_control_characters() {
for character in ['\0', '\r', '\u{0007}', '\u{0085}'] {
let prompt = format!("Review{character}code");
assert!(validate_agent_definition_text("Reviewer", &prompt).is_err());
}
}

#[test]
fn enforces_display_name_and_prompt_bounds() {
assert!(validate_agent_definition_text(&"a".repeat(129), "prompt").is_err());
assert!(validate_agent_definition_text("Reviewer", &"a".repeat(64 * 1024 + 1)).is_err());
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(crate) use agent_env::{
mod backend;
pub(crate) mod config_bridge;
pub(crate) mod custom_harnesses;
mod definition_validation;
mod discovery;
pub(crate) mod effective_config;
mod env_vars;
Expand Down Expand Up @@ -48,6 +49,7 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> {
}

pub use backend::*;
pub(crate) use definition_validation::validate_agent_definition_text;
pub use discovery::*;
pub use env_vars::*;
#[cfg(windows)]
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ with a TypeScript lookup table or an id comparison in a component.
themselves. Never synthesize a run location a surface doesn't have. Don't
expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI
copy.
12. **Shared instructions must be reviewable byte-for-byte.** Agent definitions
execute their `system_prompt` verbatim, so catalog and snapshot review
surfaces render the literal prompt, never the chat Markdown projection
(which can conceal spoilers, link destinations, and image sources). Reject
Unicode default-ignorable, bidirectional-formatting, and non-layout control
characters at both the untrusted catalog parser and the Rust persistence /
import boundary. Do not silently strip them: rejection keeps the reviewed
string identical to the executed string. New sharing paths must reuse the
same validation before they persist or activate a definition.

## The tests that enforce this

Expand All @@ -167,12 +176,17 @@ with a TypeScript lookup table or an id comparison in a component.
- `lib/agentAccessWarning.test.mjs` — every mode × run-location copy variant
plus both resolvers, including unknown-reads-as-local and
blank-`runOn`-is-not-a-provider.
- `lib/personaCatalogRelay.test.mjs` and
`ui/personaCatalogOwnerLabel.test.mjs` — reject invisible definition text
and keep Markdown concealment syntax literal in the review surface.
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
acceptance coverage for readiness, failure states, defaults, navigation,
successful-empty vs failed optional-model discovery, and persistence races.
- Rust: `runtime_metadata_env_vars` tests pin spawn-time key application.
- Rust: persona sharing/retention tests pin relay+owner scoping, durable
enqueue errors, relay rejection/unavailability, and accepted publication.
- Rust: `definition_validation` and inbound persona tests pin the shared
Unicode/control-character policy at local, import, publish, and sync gates.

## Keep this file true

Expand Down
Loading
Loading