bxctl: sanitize check shows which sanitizer rules a text breaks
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
+11
-2
@@ -33,8 +33,12 @@ usage: bxctl <command> [options]
|
||||
bxctl reindex [--memory-socket PATH]
|
||||
Rebuild memoryd's index from the session logs and notes.
|
||||
|
||||
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> [--memory-socket PATH]
|
||||
List memory edits, undo the latest edit of a file, or answer writes waiting for review.";
|
||||
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> [--memory-socket PATH]
|
||||
List memory edits, undo the latest edit of a file, or answer writes waiting for review.
|
||||
|
||||
bxctl sanitize check [TEXT]
|
||||
Show which sanitizer rules TEXT, or standard input, breaks; writes nothing. Refuse shows
|
||||
the first rule only, as memory answers. Exits 1 if any rule fires, 0 if none does.";
|
||||
|
||||
// The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin
|
||||
// socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`.
|
||||
@@ -100,6 +104,10 @@ pub enum Command {
|
||||
socket: PathBuf,
|
||||
ask: crate::memory::Ask,
|
||||
},
|
||||
// `None`: the text is read from standard input.
|
||||
SanitizeCheck {
|
||||
text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
@@ -116,6 +124,7 @@ pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
|
||||
"audit" => parse_audit(rest, home),
|
||||
"reindex" => crate::memory::parse_reindex(rest, home),
|
||||
"memory" => crate::memory::parse_memory(rest, home),
|
||||
"sanitize" => crate::sanitize::parse_sanitize(rest),
|
||||
// A flag before the command, or any unknown command, is not valid.
|
||||
_ => Err(UsageError),
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ pub mod chat;
|
||||
pub mod cli;
|
||||
pub mod escape;
|
||||
pub mod memory;
|
||||
pub mod sanitize;
|
||||
pub mod verify;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `bxctl`: the owner's chat client for `loopd`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -49,6 +49,7 @@ fn main() -> ExitCode {
|
||||
reason,
|
||||
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
|
||||
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
|
||||
Command::SanitizeCheck { text } => cmd_sanitize_check(text),
|
||||
Command::Memory { socket, ask } => {
|
||||
let mut out = std::io::stdout().lock();
|
||||
match bxctl::memory::run(&socket, &ask, bxctl::memory::MEMORY_TIMEOUT, &mut out) {
|
||||
@@ -108,6 +109,32 @@ fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
|
||||
admin::cmd_grants_check(admin, &mut out)
|
||||
}
|
||||
|
||||
// `bxctl sanitize check`: 0 when no rule fires, 1 when one does. A text that cannot be read (not
|
||||
// UTF-8, like an argument that is not) or an answer that cannot be written is 2, so 1 always
|
||||
// means a rule fired.
|
||||
fn cmd_sanitize_check(text: Option<String>) -> ExitCode {
|
||||
let text = match text {
|
||||
Some(text) => text,
|
||||
None => {
|
||||
let mut text = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut text) {
|
||||
eprintln!("bxctl: cannot read standard input: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
text
|
||||
}
|
||||
};
|
||||
let mut out = std::io::stdout().lock();
|
||||
match bxctl::sanitize::run(&text, &mut out) {
|
||||
Ok(false) => ExitCode::SUCCESS,
|
||||
Ok(true) => ExitCode::from(1),
|
||||
Err(e) => {
|
||||
eprintln!("bxctl: cannot write the report: {e}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_pending(opts: &ChatOptions) -> OnPending {
|
||||
if opts.json {
|
||||
OnPending::EventOnly
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! `bxctl sanitize check` (M5b spec, section 6.6): which of the sanitizer's rules a text breaks,
|
||||
//! and what each would do. It calls `bxcore::sanitize` directly, as `memoryd` and `loopd` do, so
|
||||
//! it needs no daemon, answers as they would, and writes nothing.
|
||||
//!
|
||||
//! Nothing of the checked text is printed: a refusal shows a secret's prefix from `PREFIXES`, a
|
||||
//! character as `U+XXXX` or a tag as its token id, and a flag names its rule only. Every byte
|
||||
//! printed is fixed text or a number, so a key, a tag or a control character never reaches the
|
||||
//! terminal, and no escaping is needed.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use bxcore::sanitize::{Refusal, TAGS, flag, refuse};
|
||||
|
||||
use crate::cli::{Command, UsageError};
|
||||
|
||||
/// The prefixes of `bxcore`'s secret rules (`refuse.rs`), and the start of a private-key line:
|
||||
/// what a refusal shows of a secret, followed by `…`. No prefix is the start of another.
|
||||
const PREFIXES: &[&str] = &[
|
||||
"ghp_",
|
||||
"gho_",
|
||||
"ghu_",
|
||||
"ghs_",
|
||||
"ghr_",
|
||||
"github_pat_",
|
||||
"glpat-",
|
||||
"sk-",
|
||||
"xoxb-",
|
||||
"xoxa-",
|
||||
"xoxp-",
|
||||
"xoxr-",
|
||||
"xoxs-",
|
||||
"AKIA",
|
||||
"ASIA",
|
||||
"-----BEGIN",
|
||||
];
|
||||
|
||||
/// `bxctl sanitize check [TEXT]`: `args` are the words after `sanitize`. `["check"]` gives
|
||||
/// `Command::SanitizeCheck { text: None }` (the text is read from standard input);
|
||||
/// `["check", text]` gives `Some(text.clone())`, whatever the text looks like, `-` and `--x`
|
||||
/// included. Anything else is `Err(UsageError)`. One `match` on `args`, with slice patterns and
|
||||
/// guards: `[check] if check == "check" => …`.
|
||||
pub fn parse_sanitize(args: &[String]) -> Result<Command, UsageError> {
|
||||
match args {
|
||||
[check] if check == "check" => Ok(Command::SanitizeCheck { text: None }),
|
||||
[check, text] if check == "check" => Ok(Command::SanitizeCheck {
|
||||
text: Some(text.clone()),
|
||||
}),
|
||||
_ => Err(UsageError),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines `bxctl sanitize check` prints for `text`, in this order, each only if it fires: the
|
||||
/// first refuse rule (only the first: memory answers with that one), the first flag rule, and the
|
||||
/// number of tags `neutralise` would rewrite. No line: nothing fires.
|
||||
pub fn report(text: &str) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(refusal) = refuse(text) {
|
||||
lines.push(format!(
|
||||
"refuse {:<18} {} at {}",
|
||||
refusal.rule,
|
||||
what(text, &refusal),
|
||||
refusal.at
|
||||
));
|
||||
}
|
||||
if let Some(flagged) = flag(text) {
|
||||
lines.push(format!("flag {}", flagged.rule));
|
||||
}
|
||||
let tags = tag_count(text);
|
||||
if tags > 0 {
|
||||
let noun = if tags == 1 { "tag" } else { "tags" };
|
||||
lines.push(format!("neutralise {tags} {noun}"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Writes each line of `report(text)` to `out`, with a line feed after each. True if any rule
|
||||
/// fired, that is, if there was a line.
|
||||
pub fn run(text: &str, out: &mut dyn Write) -> std::io::Result<bool> {
|
||||
let lines = report(text);
|
||||
for line in &lines {
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
Ok(!lines.is_empty())
|
||||
}
|
||||
|
||||
/// What a refusal shows of the text at `refusal.at`: a character rule its code point, the tag
|
||||
/// rule the tag's token id, any other rule (the secrets) the prefix and `…`.
|
||||
fn what(text: &str, refusal: &Refusal) -> String {
|
||||
match refusal.rule {
|
||||
"control" | "invisible" | "private-use" | "noncharacter" => code_point(text, refusal.at),
|
||||
"tag" => token(text, refusal.at),
|
||||
_ => format!("{}…", prefix(text, refusal.at)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The character that starts at byte `at` of `text`, as `U+` and at least four upper-case hex
|
||||
/// digits (`format!("U+{:04X}", u32::from(c))`: `U+0007`, `U+202E`, `U+10FFFF`). `"…"` if no
|
||||
/// character starts there.
|
||||
fn code_point(text: &str, at: usize) -> String {
|
||||
match text.get(at..).and_then(|rest| rest.chars().next()) {
|
||||
Some(c) => format!("U+{:04X}", u32::from(c)),
|
||||
None => "…".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The listed tag that starts at byte `at` of `text`, as `token ` and its id (`token 248068`):
|
||||
/// the first tag in `TAGS` whose `text` the rest of `text` (`text.get(at..)`, `""` for `None`)
|
||||
/// starts with. `"…"` if none does.
|
||||
fn token(text: &str, at: usize) -> String {
|
||||
match text
|
||||
.get(at..)
|
||||
.and_then(|rest| TAGS.iter().find(|tag| rest.starts_with(tag.text)))
|
||||
{
|
||||
Some(tag) => format!("token {}", tag.id),
|
||||
None => "…".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first entry of `PREFIXES` that the rest of `text` from byte `at` (`text.get(at..)`, `""`
|
||||
/// for `None`) starts with, or `""` if none does.
|
||||
fn prefix(text: &str, at: usize) -> &'static str {
|
||||
text.get(at..)
|
||||
.and_then(|rest| PREFIXES.iter().find(|prefix| rest.starts_with(**prefix)))
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How many tags `neutralise` would rewrite: for each tag in `TAGS`, the number of times its
|
||||
/// `text` occurs in `text` (`str::matches` and `count`), summed. Tags never overlap, so this is
|
||||
/// every occurrence, each counted once.
|
||||
fn tag_count(text: &str) -> usize {
|
||||
TAGS.iter().map(|tag| text.matches(tag.text).count()).sum()
|
||||
}
|
||||
Reference in New Issue
Block a user