mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(fleet): bound fleet identity and route values
F01-04 fleet half. Refs #6556. `FleetFile::validate` bounded only `display_name`; the fleet name, member id, role, provider, model, reasoning, and instructions were accepted with control characters (terminal escapes reach roster views, selectors, and receipts) and without length limits, and a member id with surrounding whitespace was kept although dispatch identity is keyed on its trimmed form. Duplicate ids were already rejected case-insensitively. Now: fleet name <= 120 chars, member id <= 64 and exactly trimmed, role <= 80, provider/model/reasoning <= 256, all single-line and control-free; instructions stay multi-line (newline/tab allowed, other controls not) and are capped at 32 Ki chars. Invalid documents fail loud at parse/save, as the other checks do. Validation: `validation_rejects_bad_documents_with_specific_errors` extended (escape in id, over-long id, untrimmed id, BEL in name, multi-line instructions accepted, oversized instructions rejected); all 16 fleet::store tests pass. Focused run: `403 passed; 1 failed` (failure unrelated: T07-01 fixture). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
d940a3e894
commit
e787a79d67
1 file changed
+108
@@ -38,6 +38,36 @@ use super::roster::FleetRoster;
|
||||
pub const FLEET_SCHEMA_KIND: &str = "fleet";
|
||||
pub const FLEET_SCHEMA_REVISION: u32 = 2;
|
||||
const MAX_MEMBER_DISPLAY_NAME_CHARS: usize = 80;
|
||||
const MAX_FLEET_NAME_CHARS: usize = 120;
|
||||
const MAX_MEMBER_ID_CHARS: usize = 64;
|
||||
const MAX_MEMBER_ROLE_CHARS: usize = 80;
|
||||
const MAX_ROUTE_FIELD_CHARS: usize = 256;
|
||||
const MAX_MEMBER_INSTRUCTIONS_CHARS: usize = 32 * 1024;
|
||||
|
||||
/// A value that reaches selectors, receipts, and terminal rendering: printable
|
||||
/// and bounded. `multiline` admits newlines and tabs (instruction overlays)
|
||||
/// but no other control characters.
|
||||
fn validate_text(
|
||||
what: &str,
|
||||
value: &str,
|
||||
max_chars: usize,
|
||||
multiline: bool,
|
||||
) -> Result<(), FleetStoreError> {
|
||||
let printable = value
|
||||
.chars()
|
||||
.all(|ch| !ch.is_control() || (multiline && matches!(ch, '\n' | '\r' | '\t')));
|
||||
if !printable || value.chars().count() > max_chars {
|
||||
return Err(FleetStoreError::Invalid(format!(
|
||||
"{what} must be printable {} no longer than {max_chars} characters",
|
||||
if multiline {
|
||||
"text"
|
||||
} else {
|
||||
"single-line text"
|
||||
}
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The directory name used by both roots (next to `agents/` for legacy
|
||||
/// profiles). Also used by the workflow crate for its own legacy/exact files;
|
||||
@@ -286,6 +316,7 @@ impl FleetFile {
|
||||
"fleet name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
validate_text("fleet name", name, MAX_FLEET_NAME_CHARS, false)?;
|
||||
let mut seen: BTreeMap<String, String> = BTreeMap::new();
|
||||
for member in &self.members {
|
||||
let member_id = member.id.trim();
|
||||
@@ -294,6 +325,56 @@ impl FleetFile {
|
||||
"member id must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// The id is dispatch identity: exact, not merely equal once trimmed.
|
||||
if member_id != member.id {
|
||||
return Err(FleetStoreError::Invalid(format!(
|
||||
"member id {:?} must not carry leading or trailing whitespace",
|
||||
member.id
|
||||
)));
|
||||
}
|
||||
validate_text("member id", member_id, MAX_MEMBER_ID_CHARS, false)?;
|
||||
let fields = [
|
||||
(
|
||||
"role",
|
||||
Some(member.role.as_str()),
|
||||
MAX_MEMBER_ROLE_CHARS,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
member.provider.as_deref(),
|
||||
MAX_ROUTE_FIELD_CHARS,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"model",
|
||||
member.model.as_deref(),
|
||||
MAX_ROUTE_FIELD_CHARS,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"reasoning",
|
||||
member.reasoning.as_deref(),
|
||||
MAX_ROUTE_FIELD_CHARS,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"instructions",
|
||||
member.instructions.as_deref(),
|
||||
MAX_MEMBER_INSTRUCTIONS_CHARS,
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (field, value, max_chars, multiline) in fields {
|
||||
if let Some(value) = value {
|
||||
validate_text(
|
||||
&format!("member `{member_id}` {field}"),
|
||||
value,
|
||||
max_chars,
|
||||
multiline,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let member_key = member_id.to_ascii_lowercase();
|
||||
if let Some(existing) = seen.insert(member_key, member.id.clone()) {
|
||||
return Err(FleetStoreError::Invalid(format!(
|
||||
@@ -1249,6 +1330,33 @@ mod tests {
|
||||
"{err}"
|
||||
);
|
||||
|
||||
// Identity and route values are bounded and control-free too; only
|
||||
// the instruction overlay may span lines.
|
||||
let mut fleet = sample_fleet();
|
||||
fleet.members[0].id = "scout\u{1b}[2J".to_string();
|
||||
let err = fleet.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member id must be printable"),
|
||||
"{err}"
|
||||
);
|
||||
let mut fleet = sample_fleet();
|
||||
fleet.members[0].id = "x".repeat(MAX_MEMBER_ID_CHARS + 1);
|
||||
assert!(fleet.validate().is_err());
|
||||
let mut fleet = sample_fleet();
|
||||
fleet.members[0].id = format!("{} ", fleet.members[0].id);
|
||||
assert!(
|
||||
fleet.validate().is_err(),
|
||||
"an untrimmed id is not its trimmed twin"
|
||||
);
|
||||
let mut fleet = sample_fleet();
|
||||
fleet.name = "Team\u{7}".to_string();
|
||||
assert!(fleet.validate().is_err());
|
||||
let mut fleet = sample_fleet();
|
||||
fleet.members[0].instructions = Some("line one\nline two\ttabbed".to_string());
|
||||
fleet.validate().expect("instructions may span lines");
|
||||
fleet.members[0].instructions = Some("x".repeat(MAX_MEMBER_INSTRUCTIONS_CHARS + 1));
|
||||
assert!(fleet.validate().is_err());
|
||||
|
||||
// Lone provider / lone model: never silently reinterpreted.
|
||||
let mut fleet = sample_fleet();
|
||||
fleet.members[0].provider = Some("deepseek".to_string());
|
||||
|
||||
Reference in new issue
Block a user