M5c-1 plan: task 08, the turn decision in memoryd
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
|||||||
|
# M5c-1 task 08: `memoryd` decides where a finished turn goes in Honcho
|
||||||
|
|
||||||
|
**Branch:** `m5c1` (run `git switch m5c1`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `memoryd: where a finished turn goes in Honcho, decided from brokerd and the log`
|
||||||
|
**Log row:** the Task cell is `M5c1/08-memoryd-honcho-decide`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When a turn ends, `memoryd` decides once where it goes in Honcho, and records the decision in
|
||||||
|
the ledger (task 07) at once, so that a Honcho outage later cannot change it (M5c spec, section
|
||||||
|
5). This task is the decision itself: a pure function, with no file, socket or clock. Task 09
|
||||||
|
calls it with `brokerd`'s answer and the session's log, and appends what it returns.
|
||||||
|
|
||||||
|
A turn goes to this agent's **trusted** workspace, its **untrusted** workspace, or **nowhere**
|
||||||
|
(`Action::Skip`, with a fixed reason). The spec's table (section 4):
|
||||||
|
|
||||||
|
| The turn's session at its end | Goes to |
|
||||||
|
|---|---|
|
||||||
|
| clean, class `public` or `private` | the trusted workspace |
|
||||||
|
| untrusted (read untrusted content), class `public` or `private` | the untrusted workspace |
|
||||||
|
| class `secret` | nowhere |
|
||||||
|
| no speaker, or incomplete speaker information | nowhere |
|
||||||
|
| the text holds a known-prefix secret | nowhere |
|
||||||
|
|
||||||
|
**The guarantee.** A turn goes to the trusted workspace only when `brokerd` says the session is
|
||||||
|
not untrusted, no tool result in the log up to the turn's end is labelled untrusted, and the
|
||||||
|
class is `public` or `private`.
|
||||||
|
|
||||||
|
## The checks, in order
|
||||||
|
|
||||||
|
`decide` runs these checks in this order; the first that holds decides. Every `Skip` carries one
|
||||||
|
of six fixed texts, the constants in the skeleton.
|
||||||
|
|
||||||
|
| # | When | The action |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | the log has no turn `turn` (`turn` is 0, or more than the log's `User` records) | `Err(NoSuchTurn { turn, turns })`: nothing is decided |
|
||||||
|
| 1 | `brokerd`'s class is `secret` | `Skip`, `SECRET_CLASS`: `the session is secret` |
|
||||||
|
| 2 | the turn's `User` record has no speaker | `Skip`, `NO_SPEAKER`: `the turn has no single speaker` |
|
||||||
|
| 3 | the speaker's peer name (each `.` made `_`) is not a Honcho name | `Skip`, `BAD_SPEAKER`: `the speaker is not a Honcho peer name` |
|
||||||
|
| 4 | the speaker's peer name is the agent's own (`[honcho] agent`) | `Skip`, `AGENT_SPEAKER`: `the speaker has the agent's peer name` |
|
||||||
|
| 5 | the turn has no final reply, or an empty or white-space one | `Skip`, `NO_REPLY`: `the turn has no reply` |
|
||||||
|
| 6 | the user's text or the reply holds a known-prefix secret | `Skip`, `SECRET_TEXT`: `the turn's text holds a secret` |
|
||||||
|
| 7 | `brokerd` says untrusted, or a `ToolResult` from the log's start to the turn's end is labelled `untrusted` | `Turn { workspace: Untrusted, speaker: <peer name> }` |
|
||||||
|
| 8 | otherwise | `Turn { workspace: Trusted, speaker: <peer name> }` |
|
||||||
|
|
||||||
|
`decide` is given whole in the skeleton: it is these rows as code. The helpers it calls are
|
||||||
|
yours.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
In `crates/memoryd/src/honcho/decide.rs` (the module `memoryd::honcho::decide`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const SECRET_CLASS: &str = "the session is secret";
|
||||||
|
pub const NO_SPEAKER: &str = "the turn has no single speaker";
|
||||||
|
pub const BAD_SPEAKER: &str = "the speaker is not a Honcho peer name";
|
||||||
|
pub const AGENT_SPEAKER: &str = "the speaker has the agent's peer name";
|
||||||
|
pub const NO_REPLY: &str = "the turn has no reply";
|
||||||
|
pub const SECRET_TEXT: &str = "the turn's text holds a secret";
|
||||||
|
|
||||||
|
pub struct NoSuchTurn { pub turn: u64, pub turns: u64 } // Debug, Clone, Copy, PartialEq, Eq
|
||||||
|
|
||||||
|
pub fn turns(records: &[LogRecord]) -> u64;
|
||||||
|
pub fn turn_records(records: &[LogRecord], turn: u64) -> Option<&[LogRecord]>;
|
||||||
|
pub fn peer_name(speaker: &str) -> String;
|
||||||
|
pub fn has_secret(text: &str) -> bool;
|
||||||
|
pub fn decide(
|
||||||
|
state: &SessionStateReply,
|
||||||
|
records: &[LogRecord],
|
||||||
|
turn: u64,
|
||||||
|
agent: &str,
|
||||||
|
) -> Result<Action, NoSuchTurn>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`NoSuchTurn` displays as `turn 3 is not in the session's log, which has 2 turns`. `Action` and
|
||||||
|
`Workspace` are the ledger's (task 07); `LogRecord` and `SessionStateReply` are `bxcore`'s.
|
||||||
|
Task 09 uses `turns` to walk every undecided turn from 1 up to the smaller of the turn
|
||||||
|
`loopd` named and `turns(log)`, so a turn number that came over a socket never drives the loop.
|
||||||
|
|
||||||
|
## Every place that changes
|
||||||
|
|
||||||
|
This is the complete list.
|
||||||
|
|
||||||
|
| Place | What happens there |
|
||||||
|
|---|---|
|
||||||
|
| `crates/memoryd/src/honcho/mod.rs` | `pub mod decide;` (edit H1) |
|
||||||
|
| `crates/memoryd/src/honcho/decide.rs` | new: a skeleton you copy, then fill 8 bodies (step 4) |
|
||||||
|
| everything else (`ledger.rs`, `config.rs`, `serve.rs`, other crates, the runbook) | **no change** |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy, from `docs/plans/M5c1/files/` (the same path under it):
|
||||||
|
- the new test `crates/memoryd/tests/honcho_decide.rs`
|
||||||
|
- the skeleton `crates/memoryd/src/honcho/decide.rs` (you fill 8 bodies in it)
|
||||||
|
- Modify: `crates/memoryd/src/honcho/mod.rs`, `docs/implementer-log.md`.
|
||||||
|
- Do not touch: any other source file, any file of another crate, any `Cargo.toml`, any other
|
||||||
|
test, and in the skeleton anything but the 8 `todo!()` bodies.
|
||||||
|
|
||||||
|
## Facts you need
|
||||||
|
|
||||||
|
- **A session's log** (`bxcore::LogRecord`, one per line of `sessions/<id>/0.jsonl`). `loopd`
|
||||||
|
writes each turn as: one `User` record (the text, and the speaker when there is one), then for
|
||||||
|
each completion an `Assistant` record and a `Usage` record (sometimes a `CacheLoss`), and after
|
||||||
|
an `Assistant` with tool calls one `ToolResult` per call, labelled with its `class` and
|
||||||
|
`untrusted`. A turn ends with an `Assistant` record that has no tool calls: its `content` is the
|
||||||
|
reply. A turn that failed (an inference error, the tool-round limit) has its `User` record and
|
||||||
|
no such final record. The log starts with a `SessionStart` record.
|
||||||
|
- **Turn numbers** (task 02): turn `n` is the `n`-th `User` record of the log, counted from 1. A
|
||||||
|
failed turn counts. Its records run from its `User` record up to, not including, the next
|
||||||
|
`User` record, or to the log's end.
|
||||||
|
- **Labels up to the turn's end.** Row 7 looks at every `ToolResult` from the log's start to the
|
||||||
|
turn's end, earlier turns included (spec 5: "any `ToolResult` so far labelled untrusted"), and
|
||||||
|
not at later turns: turn 1 of a session whose turn 2 fetched a page is decided on its own
|
||||||
|
records and those before it (`brokerd`'s answer is the session's state when it is asked, and
|
||||||
|
may already say untrusted: a late decision only moves a turn towards untrusted).
|
||||||
|
- **The speaker.** `gatewayd` sets the Mattermost username as it is (task 03), `a-z`, `0-9`,
|
||||||
|
`.`, `-`, `_`; `bxctl chat --speaker` any text (task 04). Honcho allows `a-zA-Z0-9_-`, so the
|
||||||
|
peer name is the username with each `.` made `_` (spec 3), and that name is checked with
|
||||||
|
`honcho::config::is_name`. The `Turn` action carries the peer name.
|
||||||
|
- **The secret rules.** `bxcore::sanitize::refuse(text)` returns the **first** rule a text breaks,
|
||||||
|
in this order: `control`, `invisible`, `private-use`, `noncharacter`, then the secret rules
|
||||||
|
(`secret-github`, `secret-gitlab`, `secret-sk`, `secret-slack`, `secret-aws`,
|
||||||
|
`secret-private-key`), then `tag`. Only the secret rules count here (a chat-template tag in a
|
||||||
|
reply is not a reason to skip), but a text with a carriage return (a `control` character) would
|
||||||
|
hide a key after it. So `has_secret` first replaces every character that `refuse` refuses on
|
||||||
|
its own with a space, then asks `refuse` of the whole text and keeps only a `secret-` rule. A
|
||||||
|
space ends a key and starts a word exactly as the replaced character did (none of them is an
|
||||||
|
ASCII letter, digit, `_`, `-` or `.`), so no key is lost by the replacement. (One more may be
|
||||||
|
found: a private key's `-----BEGIN` line with a control character before it on its line.)
|
||||||
|
- **Tests build keys from pieces**, so that no file holds a key whole: do not write one in a
|
||||||
|
comment or a command either.
|
||||||
|
- **The given test** `honcho_decide.rs` (18 tests): the six texts; `turns` and `turn_records`; a
|
||||||
|
turn not in the log; each row of the table; the speaker's peer name, its edges (512 and 513
|
||||||
|
characters, a space, a non-ASCII letter or digit) and the agent's name; the replies a failed
|
||||||
|
turn leaves; secrets in the text or the reply, after a carriage return, a bell or an invisible
|
||||||
|
character; secrets where nothing is sent (a tool result, reasoning, another turn) not counted;
|
||||||
|
tags and short keys not counted; and one case per row that breaks every later row too, to pin
|
||||||
|
the order.
|
||||||
|
- The calls the bodies use:
|
||||||
|
|
||||||
|
| Call | Signature, and what it gives |
|
||||||
|
|---|---|
|
||||||
|
| `matches!` | `matches!(r, LogRecord::User { .. }) -> bool` |
|
||||||
|
| `Iterator::filter`, `count`, `any` | `records.iter().filter(f).count() -> usize`; `.any(f) -> bool` |
|
||||||
|
| `u64::try_from` | `u64::try_from(count).unwrap_or(u64::MAX)`: a `usize` as `u64`, no `as` cast |
|
||||||
|
| `Iterator::enumerate` | `records.iter().enumerate()`: `(index, &record)` |
|
||||||
|
| `slice::get` | `records.get(start..end) -> Option<&[LogRecord]>` |
|
||||||
|
| `slice::first` | `turn.first() -> Option<&LogRecord>` |
|
||||||
|
| `Iterator::rev`, `find` | `turn.iter().rev().find(f) -> Option<&LogRecord>`: the last match |
|
||||||
|
| `Option::as_deref` | `speaker.as_deref()`: `&Option<String>` to `Option<&str>` |
|
||||||
|
| `str::replace` | `speaker.replace('.', "_") -> String` |
|
||||||
|
| `char::encode_utf8` | `c.encode_utf8(&mut buf) -> &mut str`, `buf: [u8; 4]`: the char as a `&str` |
|
||||||
|
| `refuse` | `bxcore::sanitize::refuse(text: &str) -> Option<Refusal>`; `Refusal.rule: &'static str` |
|
||||||
|
| `Option::is_some_and` | `refuse(&masked).is_some_and(\|r\| r.rule.starts_with("secret-"))` |
|
||||||
|
|
||||||
|
## The edit, exactly
|
||||||
|
|
||||||
|
### `crates/memoryd/src/honcho/mod.rs`
|
||||||
|
|
||||||
|
**H1.** The line
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod config;
|
||||||
|
```
|
||||||
|
|
||||||
|
becomes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod config;
|
||||||
|
pub mod decide;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy the given test.** From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_decide.rs crates/memoryd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p memoryd`, with a shell timeout of
|
||||||
|
600000 ms: one test target does not compile, `honcho_decide`, with one error, `E0432`
|
||||||
|
(``unresolved import `memoryd::honcho::decide` ``). Every other target compiles.
|
||||||
|
- [ ] **3. The module.** Make edit H1, then copy the skeleton:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/src/honcho/decide.rs crates/memoryd/src/honcho/
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo check -p memoryd`: no error, and 11 warnings, all from the unfilled bodies:
|
||||||
|
``unused import: `bxcore::sanitize::refuse` ``, and ten `unused variable`: `records` four
|
||||||
|
times, `turn` four times, `speaker` and `text` once each. Then
|
||||||
|
`cargo test -p memoryd --test honcho_decide`: 1 test passes (`the_skip_reasons_are_these_texts`)
|
||||||
|
and 17 fail with `not yet implemented`.
|
||||||
|
- [ ] **4. Fill the eight bodies of `honcho/decide.rs`, one at a time, in the order of the
|
||||||
|
file**, each from its comment, and run `cargo check -p memoryd` after each: `turns`, `bounds`,
|
||||||
|
`turn_records`, `peer_name`, `has_secret`, `user_text`, `final_reply`, `labelled_untrusted`.
|
||||||
|
When a body is filled, delete its instruction comment (the `//` lines inside the body) and keep
|
||||||
|
the function's doc comment (`///`) as it is. Change nothing else in the file: not `decide`, not
|
||||||
|
the constants, not `NoSuchTurn`. After the eighth, `cargo check -p memoryd` prints no warning.
|
||||||
|
- [ ] **5. The test.** `cargo test -p memoryd --test honcho_decide`: all 18 tests pass.
|
||||||
|
- [ ] **6. Check.** Three commands:
|
||||||
|
- `grep -c 'fs::\|std::io\|Timestamp::now' crates/memoryd/src/honcho/decide.rs` prints `0`:
|
||||||
|
the decision reads and writes nothing, and takes no time.
|
||||||
|
- `grep -n 'refuse(' crates/memoryd/src/honcho/decide.rs` prints exactly two lines, both in
|
||||||
|
`has_secret`.
|
||||||
|
- `git diff --stat -- crates/bxcore crates/loopd crates/brokerd crates/gatewayd crates/bxctl
|
||||||
|
Cargo.toml crates/memoryd/Cargo.toml docs/runbook.md` prints nothing.
|
||||||
|
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
|
||||||
|
Expected last line: `gate: ok`, with 1230 tests: 1212 before this task, plus 18 new ones in
|
||||||
|
`honcho_decide.rs`.
|
||||||
|
- [ ] **8. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
|
||||||
|
`M5c1/08-memoryd-honcho-decide`. `git status --short` lists `crates/memoryd/src/honcho/mod.rs`
|
||||||
|
and your log as modified, and `crates/memoryd/src/honcho/decide.rs` and
|
||||||
|
`crates/memoryd/tests/honcho_decide.rs` as new. Stage with
|
||||||
|
`git add crates/memoryd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
|
||||||
|
`Cargo.lock` does not change in this task; the `git add` is harmless.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `make gate` prints `gate: ok` with 1230 tests.
|
||||||
|
- `git diff HEAD~1 --stat` lists only `honcho/mod.rs`, `honcho/decide.rs`,
|
||||||
|
`tests/honcho_decide.rs` and `docs/implementer-log.md`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- The line of edit H1 is not in `crates/memoryd/src/honcho/mod.rs` exactly as shown, or is there
|
||||||
|
more than once.
|
||||||
|
- Step 2 or 3 shows a different result: other test targets, errors of another kind, other
|
||||||
|
warnings, or other tests passing or failing.
|
||||||
|
- `cargo check` reports an error after a body you filled, and the comment does not tell you how
|
||||||
|
to fix it. Quote the error, and stop.
|
||||||
|
- A comment in the skeleton does not fit the code under it, or a row of "The checks, in order"
|
||||||
|
does not fit `decide`. Quote both and stop.
|
||||||
|
- After step 4 a test fails although each body follows its comment. Do not change the test or
|
||||||
|
the skeleton's given code: stop, and give the failing tests' names and their messages.
|
||||||
|
- The fix seems to need a change anywhere else: another `memoryd` file, `bxcore`'s sanitizer,
|
||||||
|
another crate, or a dependency. It does not; stop and say why you think so.
|
||||||
|
- A test outside this file fails in the gate: check once whether it fails without your change,
|
||||||
|
then stop and report both results.
|
||||||
|
- The gate passes with a count other than 1230.
|
||||||
|
|
||||||
|
## What the given test pins
|
||||||
|
|
||||||
|
Each of these changes to a finished `decide.rs` makes at least one given test fail: counting
|
||||||
|
other records as turns; a turn's bounds off by one at either end, or ended at the next record;
|
||||||
|
turn 0 or a turn past the end accepted; the peer name keeping `.` or making it `-`; the name
|
||||||
|
checked before the `.` is replaced, or compared with the agent's before it; `has_secret` without
|
||||||
|
the replacement, replacing with a letter, deleting instead of replacing, counting any rule, or
|
||||||
|
asking `refuse` of the text as written; the speaker ignored; the first `Assistant` record taken
|
||||||
|
for the reply, or its tool calls ignored; an empty or white-space reply accepted; the labels
|
||||||
|
ignored, taken from the turn alone or from the whole log; `brokerd`'s `untrusted` ignored; any
|
||||||
|
tool result taken as untrusted; the class `secret` ignored, or `public` taken for it; any two
|
||||||
|
checks in another order; a secret in the text or in the reply ignored; the raw username sent as
|
||||||
|
the speaker.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Where a finished turn goes in Honcho (M5c spec, sections 4 and 5): this agent's trusted or
|
||||||
|
//! untrusted workspace, or nowhere, and why. Pure: the session's state as `brokerd` answered it
|
||||||
|
//! and the session's log are given, and nothing is read or written here.
|
||||||
|
|
||||||
|
use bxcore::sanitize::refuse;
|
||||||
|
use bxcore::{DataClass, LogRecord, SessionStateReply};
|
||||||
|
|
||||||
|
use crate::honcho::config::is_name;
|
||||||
|
use crate::honcho::ledger::{Action, Workspace};
|
||||||
|
|
||||||
|
/// Why a turn goes nowhere: the `why` of its `Action::Skip`.
|
||||||
|
pub const SECRET_CLASS: &str = "the session is secret";
|
||||||
|
pub const NO_SPEAKER: &str = "the turn has no single speaker";
|
||||||
|
pub const BAD_SPEAKER: &str = "the speaker is not a Honcho peer name";
|
||||||
|
pub const AGENT_SPEAKER: &str = "the speaker has the agent's peer name";
|
||||||
|
pub const NO_REPLY: &str = "the turn has no reply";
|
||||||
|
pub const SECRET_TEXT: &str = "the turn's text holds a secret";
|
||||||
|
|
||||||
|
/// The turn asked for is not in the log.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct NoSuchTurn {
|
||||||
|
pub turn: u64,
|
||||||
|
pub turns: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for NoSuchTurn {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"turn {} is not in the session's log, which has {} turns",
|
||||||
|
self.turn, self.turns
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NoSuchTurn {}
|
||||||
|
|
||||||
|
/// How many turns the log holds: its `User` records.
|
||||||
|
pub fn turns(records: &[LogRecord]) -> u64 {
|
||||||
|
// `records.iter().filter(|r| matches!(r, LogRecord::User { .. })).count()`, as a `u64`:
|
||||||
|
// `u64::try_from(count).unwrap_or(u64::MAX)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where turn `turn` (counted from 1) lies in the log: the index of its `User` record, and the
|
||||||
|
/// index just past its last record (the next `User` record's, or the log's length). `None` when
|
||||||
|
/// the log has no such turn.
|
||||||
|
fn bounds(records: &[LogRecord], turn: u64) -> Option<(usize, usize)> {
|
||||||
|
// `seen: u64 = 0` and `start: Option<usize> = None`. Walk `records.iter().enumerate()`; at
|
||||||
|
// each `LogRecord::User { .. }` (`if let`): if `start` is `Some(start)`, return
|
||||||
|
// `Some((start, index))`; else `seen += 1`, and when `seen == turn`, `start = Some(index)`.
|
||||||
|
// After the loop: `start.map(|start| (start, records.len()))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn `turn`'s records, from its `User` record to the record before the next `User`.
|
||||||
|
pub fn turn_records(records: &[LogRecord], turn: u64) -> Option<&[LogRecord]> {
|
||||||
|
// `let (start, end) = bounds(records, turn)?;` then `records.get(start..end)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Mattermost username as a Honcho peer name: each `.` becomes `_` (M5c spec, section 3).
|
||||||
|
pub fn peer_name(speaker: &str) -> String {
|
||||||
|
// `speaker.replace('.', "_")`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `text` holds a known-prefix secret, by `refuse`'s secret rules alone. `refuse` gives
|
||||||
|
/// only the first rule a text breaks, and its character rules come first, so each character they
|
||||||
|
/// match is first replaced by a space: it is not a key character and not a letter, so a secret's
|
||||||
|
/// prefix and run are found as before, and a carriage return cannot hide a key.
|
||||||
|
pub fn has_secret(text: &str) -> bool {
|
||||||
|
// `let mut buf = [0u8; 4];`. `masked: String` is `text.chars().map(..).collect()`, each
|
||||||
|
// char `c` mapped to `' '` when `refuse(c.encode_utf8(&mut buf)).is_some()`, else to `c`.
|
||||||
|
// Then `refuse(&masked).is_some_and(|r| r.rule.starts_with("secret-"))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The turn's user text and its speaker, from its first record, the `User` one.
|
||||||
|
fn user_text(turn: &[LogRecord]) -> (&str, Option<&str>) {
|
||||||
|
// `match turn.first()`: `Some(LogRecord::User { content, speaker, .. })` ->
|
||||||
|
// `(content.as_str(), speaker.as_deref())`; anything else -> `("", None)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The turn's final reply: the content of its last `Assistant` record, when that record asks for
|
||||||
|
/// no tool. A turn that stopped in a tool round, or before any answer, has none.
|
||||||
|
fn final_reply(turn: &[LogRecord]) -> Option<&str> {
|
||||||
|
// `let last = turn.iter().rev().find(|r| matches!(r, LogRecord::Assistant { .. }));`
|
||||||
|
// then `match last`: `Some(LogRecord::Assistant { content, tool_calls, .. }) if
|
||||||
|
// tool_calls.is_empty()` -> `content.as_deref()`; anything else -> `None`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any tool result up to the turn's end is labelled untrusted.
|
||||||
|
fn labelled_untrusted(records: &[LogRecord]) -> bool {
|
||||||
|
// `records.iter().any(|r| matches!(r, LogRecord::ToolResult { untrusted: true, .. }))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where turn `turn` of the session whose log is `records` goes. `state` is `brokerd`'s answer
|
||||||
|
/// for the session; `agent` is `[honcho] agent`. The checks run in this order, and the first that
|
||||||
|
/// holds decides: the class `secret`; no speaker; a speaker that is not a peer name, or is the
|
||||||
|
/// agent's; no reply; a secret in the user's text or the reply. Otherwise the turn goes to the
|
||||||
|
/// untrusted workspace if `brokerd` or any tool result up to the turn's end says untrusted, else
|
||||||
|
/// to the trusted one.
|
||||||
|
pub fn decide(
|
||||||
|
state: &SessionStateReply,
|
||||||
|
records: &[LogRecord],
|
||||||
|
turn: u64,
|
||||||
|
agent: &str,
|
||||||
|
) -> Result<Action, NoSuchTurn> {
|
||||||
|
let missing = NoSuchTurn {
|
||||||
|
turn,
|
||||||
|
turns: turns(records),
|
||||||
|
};
|
||||||
|
let (start, end) = bounds(records, turn).ok_or(missing)?;
|
||||||
|
let upto = records.get(..end).ok_or(missing)?;
|
||||||
|
let this = records.get(start..end).ok_or(missing)?;
|
||||||
|
let skip = |why: &str| {
|
||||||
|
Ok(Action::Skip {
|
||||||
|
why: why.to_string(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if state.class == DataClass::Secret {
|
||||||
|
return skip(SECRET_CLASS);
|
||||||
|
}
|
||||||
|
let (text, speaker) = user_text(this);
|
||||||
|
let Some(speaker) = speaker else {
|
||||||
|
return skip(NO_SPEAKER);
|
||||||
|
};
|
||||||
|
let peer = peer_name(speaker);
|
||||||
|
if !is_name(&peer) {
|
||||||
|
return skip(BAD_SPEAKER);
|
||||||
|
}
|
||||||
|
if peer == agent {
|
||||||
|
return skip(AGENT_SPEAKER);
|
||||||
|
}
|
||||||
|
let Some(reply) = final_reply(this).filter(|r| !r.trim().is_empty()) else {
|
||||||
|
return skip(NO_REPLY);
|
||||||
|
};
|
||||||
|
if has_secret(text) || has_secret(reply) {
|
||||||
|
return skip(SECRET_TEXT);
|
||||||
|
}
|
||||||
|
let workspace = if state.untrusted || labelled_untrusted(upto) {
|
||||||
|
Workspace::Untrusted
|
||||||
|
} else {
|
||||||
|
Workspace::Trusted
|
||||||
|
};
|
||||||
|
Ok(Action::Turn {
|
||||||
|
workspace,
|
||||||
|
speaker: peer,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
//! Where a finished turn goes in Honcho (M5c spec, sections 4 and 5): the trusted or untrusted
|
||||||
|
//! workspace, or nowhere and why, from `brokerd`'s answer and the session's log; the order of the
|
||||||
|
//! checks; and the helpers that find a turn in the log. Do not edit.
|
||||||
|
|
||||||
|
use bxcore::sanitize::TAGS;
|
||||||
|
use bxcore::{
|
||||||
|
CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, SessionStateReply, Timestamp, ToolCall,
|
||||||
|
};
|
||||||
|
use memoryd::honcho::decide::{
|
||||||
|
AGENT_SPEAKER, BAD_SPEAKER, NO_REPLY, NO_SPEAKER, NoSuchTurn, SECRET_CLASS, SECRET_TEXT,
|
||||||
|
decide, has_secret, peer_name, turn_records, turns,
|
||||||
|
};
|
||||||
|
use memoryd::honcho::ledger::{Action, Workspace};
|
||||||
|
|
||||||
|
const AGENT: &str = "boxmaker0";
|
||||||
|
|
||||||
|
fn time() -> Timestamp {
|
||||||
|
Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start() -> LogRecord {
|
||||||
|
LogRecord::SessionStart {
|
||||||
|
time: time(),
|
||||||
|
session: SessionId::new("chat-1").unwrap(),
|
||||||
|
epoch: Epoch(0),
|
||||||
|
slot: 0,
|
||||||
|
baseline: Hash32::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user(content: &str, speaker: Option<&str>) -> LogRecord {
|
||||||
|
LogRecord::User {
|
||||||
|
time: time(),
|
||||||
|
content: content.to_string(),
|
||||||
|
speaker: speaker.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(content: Option<&str>) -> LogRecord {
|
||||||
|
LogRecord::Assistant {
|
||||||
|
time: time(),
|
||||||
|
content: content.map(str::to_string),
|
||||||
|
reasoning_content: Some("thinking".to_string()),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls_tool() -> LogRecord {
|
||||||
|
calls_tool_saying(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool round; a model may say something beside its tool calls.
|
||||||
|
fn calls_tool_saying(content: Option<&str>) -> LogRecord {
|
||||||
|
LogRecord::Assistant {
|
||||||
|
time: time(),
|
||||||
|
content: content.map(str::to_string),
|
||||||
|
reasoning_content: None,
|
||||||
|
tool_calls: vec![ToolCall {
|
||||||
|
id: "c1".to_string(),
|
||||||
|
name: "fetch".to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result(untrusted: bool) -> LogRecord {
|
||||||
|
LogRecord::ToolResult {
|
||||||
|
time: time(),
|
||||||
|
call: CallId(1),
|
||||||
|
tool_call_id: "c1".to_string(),
|
||||||
|
content: "the page".to_string(),
|
||||||
|
class: DataClass::Public,
|
||||||
|
untrusted,
|
||||||
|
truncated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> LogRecord {
|
||||||
|
LogRecord::Usage {
|
||||||
|
time: time(),
|
||||||
|
cache_n: 0,
|
||||||
|
prompt_n: 10,
|
||||||
|
predicted_n: 5,
|
||||||
|
reasoning_tokens: 2,
|
||||||
|
thinking_capped: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One finished turn: its `User` record, then the reply and its usage.
|
||||||
|
fn turn(text: &str, speaker: Option<&str>, answer: &str) -> Vec<LogRecord> {
|
||||||
|
vec![user(text, speaker), reply(Some(answer)), usage()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished turn with a tool round: the tool's result is labelled `untrusted`.
|
||||||
|
fn turn_with_tool(speaker: Option<&str>, untrusted: bool) -> Vec<LogRecord> {
|
||||||
|
vec![
|
||||||
|
user("look this up", speaker),
|
||||||
|
calls_tool(),
|
||||||
|
usage(),
|
||||||
|
result(untrusted),
|
||||||
|
reply(Some("here it is")),
|
||||||
|
usage(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(turns: &[Vec<LogRecord>]) -> Vec<LogRecord> {
|
||||||
|
let mut records = vec![start()];
|
||||||
|
for t in turns {
|
||||||
|
records.extend(t.iter().cloned());
|
||||||
|
}
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(class: DataClass, untrusted: bool) -> SessionStateReply {
|
||||||
|
SessionStateReply { class, untrusted }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean() -> SessionStateReply {
|
||||||
|
state(DataClass::Private, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to(workspace: Workspace, speaker: &str) -> Action {
|
||||||
|
Action::Turn {
|
||||||
|
workspace,
|
||||||
|
speaker: speaker.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip(why: &str) -> Action {
|
||||||
|
Action::Skip {
|
||||||
|
why: why.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `decide` for the last turn of `records`.
|
||||||
|
fn last(state: &SessionStateReply, records: &[LogRecord]) -> Action {
|
||||||
|
decide(state, records, turns(records), AGENT).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `n` characters of `A1b2`…, which the secret rules take for a key's run.
|
||||||
|
fn mixed(n: usize) -> String {
|
||||||
|
"A1b2".chars().cycle().take(n).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GitHub token, built from pieces so that no file holds one whole.
|
||||||
|
fn github_token() -> String {
|
||||||
|
format!("{}{}", ["gh", "p_"].concat(), mixed(36))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_skip_reasons_are_these_texts() {
|
||||||
|
assert_eq!(SECRET_CLASS, "the session is secret");
|
||||||
|
assert_eq!(NO_SPEAKER, "the turn has no single speaker");
|
||||||
|
assert_eq!(BAD_SPEAKER, "the speaker is not a Honcho peer name");
|
||||||
|
assert_eq!(AGENT_SPEAKER, "the speaker has the agent's peer name");
|
||||||
|
assert_eq!(NO_REPLY, "the turn has no reply");
|
||||||
|
assert_eq!(SECRET_TEXT, "the turn's text holds a secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turns_counts_the_user_records() {
|
||||||
|
assert_eq!(turns(&[]), 0);
|
||||||
|
assert_eq!(turns(&[start()]), 0);
|
||||||
|
let records = log(&[
|
||||||
|
turn("one", Some("kyle"), "a"),
|
||||||
|
turn_with_tool(Some("kyle"), false),
|
||||||
|
vec![user("failed before any answer", Some("kyle"))],
|
||||||
|
turn("four", None, "d"),
|
||||||
|
]);
|
||||||
|
assert_eq!(turns(&records), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_turn_runs_from_its_user_record_to_the_next() {
|
||||||
|
let one = turn("one", Some("kyle"), "a");
|
||||||
|
let two = turn_with_tool(Some("ana"), true);
|
||||||
|
let three = vec![user("three", None), calls_tool()];
|
||||||
|
let records = log(&[one.clone(), two.clone(), three.clone()]);
|
||||||
|
assert_eq!(turn_records(&records, 1), Some(&one[..]));
|
||||||
|
assert_eq!(turn_records(&records, 2), Some(&two[..]));
|
||||||
|
assert_eq!(turn_records(&records, 3), Some(&three[..]));
|
||||||
|
for n in [0, 4, u64::MAX] {
|
||||||
|
assert_eq!(turn_records(&records, n), None, "{n}");
|
||||||
|
}
|
||||||
|
assert_eq!(turn_records(&[start()], 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_turn_not_in_the_log_is_an_error() {
|
||||||
|
let records = log(&[
|
||||||
|
turn("one", Some("kyle"), "a"),
|
||||||
|
turn("two", Some("kyle"), "b"),
|
||||||
|
]);
|
||||||
|
for n in [0, 3, u64::MAX] {
|
||||||
|
let err = decide(&clean(), &records, n, AGENT).unwrap_err();
|
||||||
|
assert_eq!(err, NoSuchTurn { turn: n, turns: 2 });
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
NoSuchTurn { turn: 3, turns: 2 }.to_string(),
|
||||||
|
"turn 3 is not in the session's log, which has 2 turns"
|
||||||
|
);
|
||||||
|
let err = decide(&clean(), &[], 1, AGENT).unwrap_err();
|
||||||
|
assert_eq!(err, NoSuchTurn { turn: 1, turns: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clean_turn_goes_to_the_trusted_workspace() {
|
||||||
|
let records = log(&[turn("hello", Some("kyle"), "hi")]);
|
||||||
|
for class in [DataClass::Private, DataClass::Public] {
|
||||||
|
assert_eq!(
|
||||||
|
last(&state(class, false), &records),
|
||||||
|
to(Workspace::Trusted, "kyle")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let records = log(&[turn_with_tool(Some("kyle"), false)]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_speaker_becomes_a_peer_name() {
|
||||||
|
assert_eq!(peer_name("first.last"), "first_last");
|
||||||
|
assert_eq!(peer_name("a.b.c-d_e"), "a_b_c-d_e");
|
||||||
|
assert_eq!(peer_name("kyle"), "kyle");
|
||||||
|
let records = log(&[turn("hello", Some("ana.b"), "hi")]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "ana_b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn untrusted_by_brokerd_or_by_a_label_goes_to_the_untrusted_workspace() {
|
||||||
|
let plain = turn("hello", Some("kyle"), "hi");
|
||||||
|
let records = log(std::slice::from_ref(&plain));
|
||||||
|
assert_eq!(
|
||||||
|
last(&state(DataClass::Private, true), &records),
|
||||||
|
to(Workspace::Untrusted, "kyle")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
last(&state(DataClass::Public, true), &records),
|
||||||
|
to(Workspace::Untrusted, "kyle")
|
||||||
|
);
|
||||||
|
// A label in this turn, or in an earlier one, with `brokerd` saying clean.
|
||||||
|
let records = log(&[turn_with_tool(Some("kyle"), true)]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Untrusted, "kyle"));
|
||||||
|
let records = log(&[turn_with_tool(None, true), plain.clone()]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Untrusted, "kyle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_label_after_the_turn_does_not_count() {
|
||||||
|
let records = log(&[
|
||||||
|
turn("hello", Some("kyle"), "hi"),
|
||||||
|
turn_with_tool(Some("kyle"), true),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
decide(&clean(), &records, 1, AGENT).unwrap(),
|
||||||
|
to(Workspace::Trusted, "kyle")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide(&clean(), &records, 2, AGENT).unwrap(),
|
||||||
|
to(Workspace::Untrusted, "kyle")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_session_goes_nowhere() {
|
||||||
|
for untrusted in [false, true] {
|
||||||
|
let records = log(&[turn("hello", Some("kyle"), "hi")]);
|
||||||
|
assert_eq!(
|
||||||
|
last(&state(DataClass::Secret, untrusted), &records),
|
||||||
|
skip(SECRET_CLASS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_speaker_goes_nowhere() {
|
||||||
|
let records = log(&[turn("hello", None, "hi")]);
|
||||||
|
assert_eq!(last(&clean(), &records), skip(NO_SPEAKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_speaker_that_is_not_a_peer_name_goes_nowhere() {
|
||||||
|
let long = "a".repeat(513);
|
||||||
|
for name in [
|
||||||
|
"",
|
||||||
|
"a b",
|
||||||
|
"caf\u{e9}",
|
||||||
|
"a/b",
|
||||||
|
"a@b",
|
||||||
|
"\u{661}",
|
||||||
|
long.as_str(),
|
||||||
|
] {
|
||||||
|
let records = log(&[turn("hello", Some(name), "hi")]);
|
||||||
|
assert_eq!(last(&clean(), &records), skip(BAD_SPEAKER), "{name:?}");
|
||||||
|
}
|
||||||
|
let longest = "a".repeat(512);
|
||||||
|
let records = log(&[turn("hello", Some(&longest), "hi")]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, &longest));
|
||||||
|
let records = log(&[turn("hello", Some("."), "hi")]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_speaker_with_the_agents_name_goes_nowhere() {
|
||||||
|
let records = log(&[turn("hello", Some(AGENT), "hi")]);
|
||||||
|
assert_eq!(last(&clean(), &records), skip(AGENT_SPEAKER));
|
||||||
|
let records = log(&[turn("hello", Some("box.maker"), "hi")]);
|
||||||
|
assert_eq!(
|
||||||
|
decide(&clean(), &records, 1, "box_maker").unwrap(),
|
||||||
|
skip(AGENT_SPEAKER)
|
||||||
|
);
|
||||||
|
let records = log(&[turn("hello", Some("Boxmaker0"), "hi")]);
|
||||||
|
assert_eq!(
|
||||||
|
last(&clean(), &records),
|
||||||
|
to(Workspace::Trusted, "Boxmaker0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_turn_without_a_reply_goes_nowhere() {
|
||||||
|
let cases = [
|
||||||
|
vec![user("hello", Some("kyle"))],
|
||||||
|
vec![user("hello", Some("kyle")), usage()],
|
||||||
|
vec![user("hello", Some("kyle")), calls_tool(), usage()],
|
||||||
|
vec![
|
||||||
|
user("hello", Some("kyle")),
|
||||||
|
calls_tool_saying(Some("let me look")),
|
||||||
|
usage(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
user("hello", Some("kyle")),
|
||||||
|
calls_tool(),
|
||||||
|
usage(),
|
||||||
|
result(false),
|
||||||
|
],
|
||||||
|
vec![user("hello", Some("kyle")), reply(None), usage()],
|
||||||
|
vec![user("hello", Some("kyle")), reply(Some("")), usage()],
|
||||||
|
vec![user("hello", Some("kyle")), reply(Some(" \n\t")), usage()],
|
||||||
|
vec![user("hello", Some("kyle")), reply(Some("hi")), calls_tool()],
|
||||||
|
];
|
||||||
|
for case in cases {
|
||||||
|
let records = log(std::slice::from_ref(&case));
|
||||||
|
assert_eq!(last(&clean(), &records), skip(NO_REPLY), "{case:?}");
|
||||||
|
}
|
||||||
|
// A later turn is decided on its own records.
|
||||||
|
let records = log(&[
|
||||||
|
vec![user("hello", Some("kyle"))],
|
||||||
|
turn("again", Some("kyle"), "hi"),
|
||||||
|
]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_in_the_text_or_the_reply_goes_nowhere() {
|
||||||
|
let key = github_token();
|
||||||
|
let texts = [
|
||||||
|
format!("my token is {key}, keep it"),
|
||||||
|
key.clone(),
|
||||||
|
format!("line one\r\n{key}\r\n"),
|
||||||
|
format!("\u{7}{key}"),
|
||||||
|
format!("\u{200b}{key}"),
|
||||||
|
format!("{}{}", ["AK", "IA"].concat(), "IOSFODNN7EXAMPLE"),
|
||||||
|
format!("{}{}", ["sk", "-proj-"].concat(), mixed(40)),
|
||||||
|
];
|
||||||
|
for text in &texts {
|
||||||
|
let records = log(&[turn(text, Some("kyle"), "noted")]);
|
||||||
|
assert_eq!(last(&clean(), &records), skip(SECRET_TEXT), "{text:?}");
|
||||||
|
let records = log(&[turn("what is my token?", Some("kyle"), text)]);
|
||||||
|
assert_eq!(last(&clean(), &records), skip(SECRET_TEXT), "{text:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_where_nothing_is_sent_does_not_count() {
|
||||||
|
let key = github_token();
|
||||||
|
let mut records = log(&[turn_with_tool(Some("kyle"), false)]);
|
||||||
|
for record in &mut records {
|
||||||
|
match record {
|
||||||
|
LogRecord::ToolResult { content, .. } => *content = key.clone(),
|
||||||
|
LogRecord::Assistant {
|
||||||
|
reasoning_content, ..
|
||||||
|
} => *reasoning_content = Some(key.clone()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
|
||||||
|
let records = log(&[
|
||||||
|
turn(&key, Some("kyle"), "noted"),
|
||||||
|
turn("hello", Some("kyle"), "hi"),
|
||||||
|
]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_secret_rules_count() {
|
||||||
|
let tag = TAGS[0].text;
|
||||||
|
for text in [
|
||||||
|
"a bell \u{7} and a carriage return \r".to_string(),
|
||||||
|
"a zero width\u{200b}space".to_string(),
|
||||||
|
format!("the model's {tag} marker"),
|
||||||
|
"ghp_ is how GitHub tokens start; sk-learn is a library".to_string(),
|
||||||
|
format!("{}{}", ["gh", "p_"].concat(), mixed(35)),
|
||||||
|
] {
|
||||||
|
assert!(!has_secret(&text), "{text:?}");
|
||||||
|
let records = log(&[turn(&text, Some("kyle"), &text)]);
|
||||||
|
assert_eq!(last(&clean(), &records), to(Workspace::Trusted, "kyle"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_secret_sees_past_the_character_rules() {
|
||||||
|
let key = github_token();
|
||||||
|
assert!(has_secret(&key));
|
||||||
|
for mark in ["\r", "\u{7}", "\u{200b}", "\u{e000}", "\u{fdd0}", "\u{7f}"] {
|
||||||
|
assert!(has_secret(&format!("{mark}{key}")), "{mark:?} before");
|
||||||
|
assert!(has_secret(&format!("{key}{mark}")), "{mark:?} after");
|
||||||
|
assert!(has_secret(&format!("x{mark}{mark}{key}")), "{mark:?} twice");
|
||||||
|
}
|
||||||
|
// A letter joined to the prefix still hides it, as it does for `refuse`.
|
||||||
|
assert!(!has_secret(&format!("x{key}")));
|
||||||
|
let tag = TAGS[1].text;
|
||||||
|
assert!(has_secret(&format!("{tag}{key}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_checks_run_in_their_order() {
|
||||||
|
let key = github_token();
|
||||||
|
let secret = state(DataClass::Secret, true);
|
||||||
|
let untrusted = state(DataClass::Private, true);
|
||||||
|
// Each case breaks the rule named and every rule after it; the first one decides.
|
||||||
|
let cases: Vec<(SessionStateReply, Vec<LogRecord>, &str)> = vec![
|
||||||
|
(
|
||||||
|
secret.clone(),
|
||||||
|
vec![user(&key, None), calls_tool()],
|
||||||
|
SECRET_CLASS,
|
||||||
|
),
|
||||||
|
(secret.clone(), vec![user("hi", Some("a b"))], SECRET_CLASS),
|
||||||
|
(
|
||||||
|
untrusted.clone(),
|
||||||
|
vec![user(&key, None), calls_tool()],
|
||||||
|
NO_SPEAKER,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
untrusted.clone(),
|
||||||
|
vec![user(&key, Some("a b"))],
|
||||||
|
BAD_SPEAKER,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
untrusted.clone(),
|
||||||
|
vec![user(&key, Some(AGENT))],
|
||||||
|
AGENT_SPEAKER,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
untrusted.clone(),
|
||||||
|
vec![user(&key, Some("kyle")), calls_tool()],
|
||||||
|
NO_REPLY,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
untrusted.clone(),
|
||||||
|
vec![user("hi", Some("kyle")), reply(Some(&key))],
|
||||||
|
SECRET_TEXT,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (state, turn, why) in cases {
|
||||||
|
let records = log(std::slice::from_ref(&turn));
|
||||||
|
assert_eq!(last(&state, &records), skip(why), "{turn:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user