From 67c5e2468d7ce05814de2e3c37bef64dbdedfbd3 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 21:43:20 -0500 Subject: [PATCH 1/2] Trust: reads learn as families, a widening is suggested, secrets stay out, and trust is the project's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads learn by themselves: core/effect.ts says what a command does — an allowlist of plain reads (print-only sed, git that only looks, eza…), flags that write or run (#44), secret files — and the ledger folds threshold approvals in a row of reads into a learned family that answers plain reads only. A reject of a read resets it; w forgets it; trust.learnReads turns it off. Suggestions (#45): a family whose approvals across two or more commands reach the threshold is listed under SUGGESTED; w widens, d dismisses (a new dismissed event). Never a dangerous family. Secrets: a signature masks secret values with a keyed hash before the ledger, the screen or the log (core/secret.ts, mask.key per project); environment words survive so prod stays prod. Trust is the project's: keyOf drops the agent, so an approval by any agent counts towards one rule; events still say who asked. OpenCode's own tools: read by file and folder, glob and grep as one family each (learned), websearch suggested, secret-file reads never answered. Git read families stop at the subcommand. experiments/reads replays a ledger, counts only: one real day went from 1.4% answered to 22.4% (35.9% the next), 0 dangerous. Co-Authored-By: Claude Opus 5.5 --- .../cockpit-setup/references/settings.md | 1 + packages/client/src/settings/keys/trust.ts | 7 + packages/trust/experiments/reads/run.ts | 260 +++++++++++ packages/trust/src/core/config.ts | 9 + packages/trust/src/core/effect.ts | 409 ++++++++++++++++++ packages/trust/src/core/engine.ts | 17 +- packages/trust/src/core/env.ts | 32 ++ packages/trust/src/core/family.ts | 100 +++-- packages/trust/src/core/history.ts | 6 +- packages/trust/src/core/keys.ts | 20 +- packages/trust/src/core/ledger.ts | 146 ++++++- packages/trust/src/core/paths.ts | 4 +- packages/trust/src/core/policy.ts | 50 ++- packages/trust/src/core/sample.ts | 48 +- packages/trust/src/core/secret.ts | 121 ++++++ packages/trust/src/core/signature.ts | 9 +- packages/trust/src/core/suggest.ts | 54 +++ packages/trust/src/core/view/actions.ts | 187 ++++---- packages/trust/src/core/view/activity.ts | 50 ++- packages/trust/src/core/view/card.ts | 182 +++++--- packages/trust/src/core/view/explorer.ts | 8 +- packages/trust/src/core/view/model.ts | 47 +- packages/trust/src/core/view/sidebar.ts | 8 +- packages/trust/src/core/view/tree.ts | 43 +- packages/trust/src/tui/index.tsx | 4 +- packages/trust/src/tui/key.ts | 55 +++ packages/trust/src/tui/ledger.tsx | 25 +- packages/trust/src/tui/requests.ts | 7 +- packages/trust/test/effect.test.ts | 218 ++++++++++ packages/trust/test/engine.test.ts | 7 +- packages/trust/test/history.test.ts | 2 +- packages/trust/test/learn.test.ts | 250 +++++++++++ packages/trust/test/ledger.test.ts | 16 +- packages/trust/test/secret.test.ts | 158 +++++++ packages/trust/test/suggest.test.ts | 166 +++++++ packages/trust/test/view.test.ts | 150 ++++++- packages/trust/test/widen.test.ts | 12 +- 37 files changed, 2528 insertions(+), 360 deletions(-) create mode 100644 packages/trust/experiments/reads/run.ts create mode 100644 packages/trust/src/core/effect.ts create mode 100644 packages/trust/src/core/env.ts create mode 100644 packages/trust/src/core/secret.ts create mode 100644 packages/trust/src/core/suggest.ts create mode 100644 packages/trust/src/tui/key.ts create mode 100644 packages/trust/test/effect.test.ts create mode 100644 packages/trust/test/learn.test.ts create mode 100644 packages/trust/test/secret.test.ts create mode 100644 packages/trust/test/suggest.test.ts diff --git a/packages/client/skills/cockpit-setup/references/settings.md b/packages/client/skills/cockpit-setup/references/settings.md index f6edcb61..f4630af4 100644 --- a/packages/client/skills/cockpit-setup/references/settings.md +++ b/packages/client/skills/cockpit-setup/references/settings.md @@ -105,6 +105,7 @@ Settings are read when OpenCode starts: a change applies after a restart. | `threshold` | number | `3` | approvals in a row, by you, before Trust answers | | `dangerExtra` | number | `5` | what a dangerous command costs on top | | `expireDays` | number | `30` | days unused before trust has to be earned again; `0` never | +| `learnReads` | boolean | `true` | learn a family of plain reads (`head`, `rg`, `git log`…) from your approvals; `false` leaves families to `w` | ## `review` — the pane for reviewing changes; no sidebar block diff --git a/packages/client/src/settings/keys/trust.ts b/packages/client/src/settings/keys/trust.ts index 48f54ba4..cff42063 100644 --- a/packages/client/src/settings/keys/trust.ts +++ b/packages/client/src/settings/keys/trust.ts @@ -16,4 +16,11 @@ export const TRUST_KEYS: readonly KeyInfo[] = [ default: 30, about: "days unused before trust has to be earned again; `0` never", }, + { + key: "learnReads", + type: "boolean", + default: true, + about: + "learn a family of plain reads (`head`, `rg`, `git log`…) from your approvals; `false` leaves families to `w`", + }, ] diff --git a/packages/trust/experiments/reads/run.ts b/packages/trust/experiments/reads/run.ts new file mode 100644 index 00000000..774b517f --- /dev/null +++ b/packages/trust/experiments/reads/run.ts @@ -0,0 +1,260 @@ +#!/usr/bin/env bun +/** + * How much of a real day Trust would answer, under the rules of each release — replayed from ledgers, + * printed as counts only. No command, path or argument from a ledger is printed or written anywhere: + * a blocker is named by its program (when it is named like one) and the reason, never its words. + * + * bun packages/trust/experiments/reads/run.ts every ledger on this machine + * bun packages/trust/experiments/reads/run.ts … these ledgers (a masked one too) + * … --day2 the same day again, scored alone + * + * Each request is taken in order, as it was asked. If a scenario's rules trust every part of it, Trust + * answers it — which earns nothing, as in real use. Otherwise what the person did is applied: the + * approval or reject the ledger recorded, or nothing. A request answered early never adds to a streak + * later, so the numbers are what that release would have done that day, not what it could have. + * + * The danger the ledger recorded wins over today's reading of the subject: a masked ledger can hide + * what made a command dangerous (a production id inside a masked value). + * + * Trust is a project's (0.11): every scenario counts approvals by any agent towards one rule. 0.10.2 + * counted per agent and answered 1.4% of the day below; the replay no longer reproduces that. + * + * Measured on one user's working day (434 requests), the run that #44 and #45 came from: + * + * A exact only 1.6% day 2 6.2% + * B exact + the 7 widenings made 2.1% 24.9% + * C 0.11: exact + learned reads 22.4% 35.9% + * E 0.11: C + every suggestion accepted 31.1% 54.8% + * dangerous commands answered, every scenario: 0 + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { notReadSubject, readSubject } from "../../src/core/family.ts" +import { + apply, + DAY, + type Event, + emptyState, + type Item, + keyOf, + parseLines, + standing, +} from "../../src/core/ledger.ts" +import { widenedFor } from "../../src/core/policy.ts" +import { suggestionsOf } from "../../src/core/suggest.ts" +import { commandsOf, familiesOf } from "../../src/core/view/model.ts" + +const SETTINGS = { threshold: 3, dangerExtra: 5, expireDays: 30 } +const args = process.argv.slice(2) +const DAY2 = args.includes("--day2") +const files = args.filter((arg) => !arg.startsWith("--")) + +function localLedgers(): string[] { + const base = join( + process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), + "opencode-cockpit", + "trust", + ) + if (!existsSync(base)) return [] + return readdirSync(base) + .map((dir) => join(base, dir, "events.ndjson")) + .filter((file) => existsSync(file)) +} + +/** A secret file a masking tool wrote as `‹secret-file #…›` reads as one again. */ +const unmask = (subject: string) => subject.replace(/‹secret-file #([0-9a-f]+)›/g, ".env.masked-$1") + +interface Req { + id: string + at: number + agent: string + permission: string + items: Item[] + outcome?: "approved" | "rejected" +} + +/** Requests in the order they were asked, and the widenings a person made, from one ledger. */ +function readLedger(file: string): { requests: Req[]; widenings: Event[] } { + const events = parseLines(`${readFileSync(file, "utf8")}\n`).events.sort((a, b) => a.at - b.at) + const byId = new Map() + const widenings: Event[] = [] + for (const event of events) { + if (event.type === "widened") widenings.push(event) + if (!("request" in event)) continue + let req = byId.get(event.request) + if (!req) { + req = { id: event.request, at: event.at, agent: event.agent, permission: event.permission, items: [] } + byId.set(event.request, req) + } + if (event.items.length > 0) + req.items = event.items.map((item) => ({ ...item, subject: unmask(item.subject) })) + if ((event.type === "approved" || event.type === "rejected") && !req.outcome) req.outcome = event.type + } + const requests = [...byId.values()].sort((a, b) => a.at - b.at) + if (!DAY2) return { requests, widenings } + const again = (id: string) => `${id}#2` + return { + requests: [...requests, ...requests.map((req) => ({ ...req, id: again(req.id), at: req.at + DAY }))], + widenings: [...widenings, ...widenings.map((event) => ({ ...event, at: event.at + DAY }))], + } +} + +interface Scenario { + name: string + learn: boolean + hand: boolean + suggestions: boolean +} + +const SCENARIOS: Scenario[] = [ + { name: "A exact only", learn: false, hand: false, suggestions: false }, + { name: "B exact + widenings made", learn: false, hand: true, suggestions: false }, + { name: "C 0.11: exact + learned reads", learn: true, hand: false, suggestions: false }, + { name: "D 0.11: C + widenings made", learn: true, hand: true, suggestions: false }, + { name: "E 0.11: C + every suggestion accepted", learn: true, hand: false, suggestions: true }, +] + +interface Stats { + asked: number + auto: number + dangerous: number + learned: number + widened: number + accepted: number + blockers: Map +} + +const scored = (req: Req) => !DAY2 || req.id.endsWith("#2") + +function replay(ledger: { requests: Req[]; widenings: Event[] }, scenario: Scenario, stats: Stats): void { + const fold = { + expireMs: SETTINGS.expireDays * DAY, + ...(scenario.learn ? { threshold: SETTINGS.threshold } : {}), + } + const state = emptyState() + const widenings = scenario.hand ? [...ledger.widenings] : [] + for (const original of ledger.requests) { + const req = original + while (widenings.length > 0 && (widenings[0] as Event).at <= req.at) + apply(state, widenings.shift() as Event, fold) + if (req.permission === "external_directory" || req.items.length === 0) continue + const counted = scored(req) + if (counted) stats.asked++ + const parts = req.items.map((item) => { + if ( + standing(state.entries.get(keyOf(req.permission, item.subject)), item.danger, SETTINGS, req.at) + .trusted + ) + return { item, ok: true } + const through = item.danger + ? undefined + : widenedFor(state, req.permission, item.subject, SETTINGS, req.at) + return { item, ok: through !== undefined, via: through?.family, learned: through?.learned } + }) + const base = { + v: 1 as const, + at: req.at, + request: req.id, + session: "replay", + permission: req.permission, + agent: req.agent, + } + if (parts.every((part) => part.ok)) { + if (counted) { + stats.auto++ + if (parts.some((part) => part.item.danger)) stats.dangerous++ + if (parts.some((part) => part.learned)) stats.learned++ + if (parts.some((part) => part.via !== undefined && !part.learned)) stats.widened++ + } + const items = parts.map((part) => ({ + subject: part.item.subject, + ...(part.via ? { via: part.via } : {}), + })) + apply(state, { ...base, type: "auto", rule: "replay", items }, fold) + continue + } + if (counted) + for (const part of parts) + if (!part.ok) + stats.blockers.set( + blocker(req.permission, part.item), + (stats.blockers.get(blocker(req.permission, part.item)) ?? 0) + 1, + ) + if (req.outcome) apply(state, { ...base, type: req.outcome, items: req.items }, fold) + if (scenario.suggestions) { + const reading = { state, settings: SETTINGS, now: req.at } + for (const suggestion of suggestionsOf(reading, familiesOf(reading, commandsOf(reading)))) { + apply( + state, + { + v: 1, + at: req.at, + type: "widened", + permission: suggestion.family.permission, + agent: suggestion.agent, + family: suggestion.family.family, + }, + fold, + ) + if (counted) stats.accepted++ + } + } + } +} + +/** `program · reason`, the program only when it is named like one — never an argument. */ +function blocker(permission: string, item: Item): string { + if (permission !== "bash") return `${permission} · exact only` + const program = (readSubject(item.subject)?.command.argv[0] ?? "").replace(/^.*\//, "") + const name = /^[a-z][a-z0-9_.+-]{0,30}$/.test(program) ? program : "‹other›" + if (item.danger) return `${name} · dangerous (${item.danger})` + const why = notReadSubject(permission, item.subject) + if (why === undefined) return `${name} · a read, family not learned yet` + if (/ is not a known read$/.test(why)) return `${name} · not in the read list` + if (/^git \S* ?is not a read$/.test(why)) return `${name} · a git subcommand that is not a read` + if (/may hold secrets$/.test(why)) return `${name} · names a secret file` + if (/^-|^--/.test(why) || /writes|rewrites|downloads|extracts|is written/.test(why)) + return `${name} · writes a file` + return `${name} · ${why.replace(/[`'"].*$/, "").replace(/\S*[/.]\S*/g, "…")}` +} + +const ledgers = (files.length > 0 ? files : localLedgers()).map(readLedger) +if (ledgers.length === 0) { + process.stderr.write("No ledgers found. Pass one: run.ts \n") + process.exit(1) +} + +const pct = (a: number, b: number) => (b === 0 ? "–" : `${((100 * a) / b).toFixed(1)}%`) +const results = SCENARIOS.map((scenario) => { + const stats: Stats = { + asked: 0, + auto: 0, + dangerous: 0, + learned: 0, + widened: 0, + accepted: 0, + blockers: new Map(), + } + for (const ledger of ledgers) replay(ledger, scenario, stats) + return { scenario, stats } +}) + +console.log( + `${ledgers.length} ledger(s), ${results[0]?.stats.asked} requests scored${DAY2 ? " (day 2)" : ""}\n`, +) +console.log( + "scenario answered % learned widened dangerous suggestions", +) +for (const { scenario, stats } of results) + console.log( + `${scenario.name.padEnd(42)} ${String(stats.auto).padStart(8)} ${pct(stats.auto, stats.asked).padStart(6)} ${String(stats.learned).padStart(9)} ${String(stats.widened).padStart(8)} ${String(stats.dangerous).padStart(10)} ${String(stats.accepted).padStart(12)}`, + ) +for (const { scenario, stats } of results.filter( + (each) => each.scenario.name.startsWith("C") || each.scenario.name.startsWith("E"), +)) { + console.log(`\nleft to you under "${scenario.name.slice(3)}" — parts, by program · reason:`) + for (const [why, count] of [...stats.blockers].sort((a, b) => b[1] - a[1]).slice(0, 15)) + console.log(` ${String(count).padStart(4)} ${why}`) +} diff --git a/packages/trust/src/core/config.ts b/packages/trust/src/core/config.ts index 3eed445e..fb4dc0e0 100644 --- a/packages/trust/src/core/config.ts +++ b/packages/trust/src/core/config.ts @@ -22,6 +22,11 @@ export interface TrustConfig { dangerExtra?: number /** Days a trusted command may go unused before it has to be earned again. Default 30; 0 never. */ expireDays?: number + /** + * Learn a family of plain reads by itself: `threshold` approvals in a row of `head …`, any file, and + * any `head` that only reads is answered (core/effect.ts). Default true; false leaves families to `w`. + */ + learnReads?: boolean /** * Whether Trust draws a block in the sidebar. Default false: the sidebar already carries the * statusline, subagents and shells, and Trust works the same without it — `/trust` opens the @@ -39,6 +44,7 @@ export interface TrustSettings { threshold: number dangerExtra: number expireDays: number + learnReads: boolean sidebar: boolean sidebarRows: number } @@ -48,6 +54,7 @@ export const DEFAULTS: TrustSettings = { threshold: 3, dangerExtra: 5, expireDays: 30, + learnReads: true, sidebar: false, sidebarRows: 3, } @@ -57,6 +64,7 @@ const KEYS = [ "threshold", "dangerExtra", "expireDays", + "learnReads", "sidebar", "sidebarRows", "keybinds", @@ -126,6 +134,7 @@ export function resolveSettings(config: TrustConfig): TrustSettings { threshold: whole(config.threshold, DEFAULTS.threshold, 1), dangerExtra: whole(config.dangerExtra, DEFAULTS.dangerExtra, 0), expireDays: whole(config.expireDays, DEFAULTS.expireDays, 0), + learnReads: config.learnReads !== false, sidebar: config.sidebar === true, sidebarRows: whole(config.sidebarRows, DEFAULTS.sidebarRows, 0), } diff --git a/packages/trust/src/core/effect.ts b/packages/trust/src/core/effect.ts new file mode 100644 index 00000000..5c0fa37b --- /dev/null +++ b/packages/trust/src/core/effect.ts @@ -0,0 +1,409 @@ +/** + * What a command does to the machine, read from its words: does it only read, does a flag make it + * write a file, does a flag make it run another program. + * + * Two questions lean on this, from opposite sides: + * + * - **What a widening never covers** (`family.outside`). A widened `sed` was "any sed", and every + * `sed -i` with it — a redirection was the only write it saw. `writesByFlag` and `runsByFlag` are + * that check for the flags that make a program write or launch something (#44). A blocklist, so it + * misses: the reason widening stays a person's decision. + * - **What may be learned as a family** (`readOnly`). An allowlist, so it fails closed: a program not + * in `PROGRAMS` with `read: true`, or one whose arguments say anything this table does not know to + * be harmless, is not a read — it keeps earning trust one exact command at a time, as before. + * + * `sed` is a read only when its script is read here and only prints (`sed -n '1,50p'`, `s/a/b/g`): + * `w`, `e` and anything unusual are not. Kept out on purpose: `awk` (`system()`, `print > f` — its + * script is not read here), `find` (`-exec`, `-delete`), `less` + * and `more` (`!` opens a shell), `xargs` (runs whatever it is given), interpreters. + */ + +import { posix } from "node:path" +import { dangerOf, subcommand, TOOL_GLOBALS, unwrapOnce } from "./danger.ts" +import type { Command } from "./shell.ts" + +interface Program { + /** It only reads, unless a check below says otherwise. */ + read?: true + /** Why its arguments make it write a file, if they do. */ + writes?: (args: readonly string[]) => string | undefined + /** Why its arguments make it run another program, if they do. */ + runs?: (args: readonly string[]) => string | undefined + /** For a read: why these arguments are not one after all (a subcommand that changes things). */ + only?: (args: readonly string[]) => string | undefined +} + +/* ─── reading flags ──────────────────────────────────────────────────────────────────────────── */ + +const isFlag = (word: string) => word.startsWith("-") && word !== "-" && word !== "--" + +/** Words before `--`: after it everything is an operand, `-i` included. */ +const flagsOf = (args: readonly string[]): string[] => { + const end = args.indexOf("--") + return (end < 0 ? args : args.slice(0, end)).filter(isFlag) +} + +/** `--output`, `--output=x`. */ +const long = (args: readonly string[], ...names: string[]) => + flagsOf(args).find((flag) => names.some((name) => flag === name || flag.startsWith(`${name}=`))) + +/** + * A short letter, alone or in a cluster: `-i`, `-ni`, `-i.bak`, `-pi`. A cluster's attached value can + * hold the letter too (`-e's/i/x/'`); that reads as the flag, which is the safe side. + */ +const short = (args: readonly string[], letter: string) => + flagsOf(args).find((flag) => !flag.startsWith("--") && flag.slice(1).includes(letter)) + +/** Operands: what is not a flag. A flag's value counts as one — more operands is the safe side. */ +const operands = (args: readonly string[]): string[] => { + const end = args.indexOf("--") + const before = (end < 0 ? args : args.slice(0, end)).filter((word) => !isFlag(word)) + return end < 0 ? before : [...before, ...args.slice(end + 1)] +} + +const flag = + (why: string, longs: readonly string[], letters = "") => + (args: readonly string[]): string | undefined => { + const found = long(args, ...longs) ?? [...letters].map((letter) => short(args, letter)).find(Boolean) + return found === undefined ? undefined : `${found} ${why}` + } + +const IN_PLACE = "rewrites files in place" +const OUTPUT = "writes a file" +const RUNS = "runs another program" + +/* ─── the table ──────────────────────────────────────────────────────────────────────────────── */ + +const READ: Program = { read: true } + +/** `git` subcommands that only look, whatever follows. Anything not here or in `GIT_SOMETIMES` is not a read. */ +export const GIT_READS: ReadonlySet = new Set([ + "status", + "log", + "diff", + "show", + "blame", + "describe", + "shortlog", + "rev-parse", + "rev-list", + "ls-files", + "ls-tree", + "cat-file", + "merge-base", + "grep", + "check-ignore", + "ls-remote", + "for-each-ref", + "name-rev", + "count-objects", + "whatchanged", +]) + +const listing = (args: readonly string[]) => args.length === 0 || ["list", "show"].includes(args[0] as string) + +/** + * `git` subcommands that read in one form and write in another — the reading form, exactly. Each gets + * the words after the subcommand. `git branch foo` makes a branch; `git branch -r --contains x` looks. + */ +const GIT_SOMETIMES: Readonly boolean>> = { + branch: (args) => { + const LOOKS = + /^(-a|-r|-v|-vv|-l|--list|--all|--remotes|--show-current|--verbose|--contains|--no-contains|--merged|--no-merged|--points-at|--format(=.*)?|--sort(=.*)?|--color(=.*)?|--no-color|--column|--no-column)$/ + const TAKES = new Set([ + "--contains", + "--no-contains", + "--merged", + "--no-merged", + "--points-at", + "--sort", + "--format", + ]) + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + /** A pattern to list by is a word, and only with `--list`; any other flag may delete or move. */ + if (!LOOKS.test(word)) return !word.startsWith("-") && (args.includes("-l") || args.includes("--list")) + if (TAKES.has(word)) i++ + } + return true + }, + /** `stash list`, `stash show`; a bare `git stash` stashes. */ + stash: (args) => args.length > 0 && listing(args), + worktree: (args) => args[0] === "list", + /** `reflog` is `reflog show`; `expire` and `delete` rewrite it. */ + reflog: (args) => args.length === 0 || args[0] === "show" || (args[0] as string).startsWith("-"), + remote: (args) => + args.length === 0 || + /^(-v|--verbose)$/.test(args[0] as string) || + ["show", "get-url"].includes(args[0] as string), + tag: (args) => args.length === 0 || args.some((word) => word === "-l" || word === "--list"), + config: (args) => + args.some((word) => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(word)) && + !args.some((word) => + /^(--unset|--unset-all|--add|--replace-all|--rename-section|--remove-section|--edit|-e)$/.test(word), + ), +} + +const PROGRAMS: Readonly> = { + ls: READ, + cat: READ, + head: READ, + tail: READ, + wc: READ, + grep: READ, + egrep: READ, + fgrep: READ, + echo: READ, + printf: READ, + pwd: READ, + which: READ, + whereis: READ, + type: READ, + stat: READ, + du: READ, + df: READ, + cut: READ, + tr: READ, + jq: READ, + diff: READ, + cmp: READ, + comm: READ, + join: READ, + paste: READ, + column: READ, + nl: READ, + fold: READ, + fmt: READ, + rev: READ, + seq: READ, + basename: READ, + dirname: READ, + realpath: READ, + readlink: READ, + sleep: READ, + true: READ, + false: READ, + test: READ, + whoami: READ, + id: READ, + uname: READ, + uptime: READ, + ps: READ, + od: READ, + hexdump: READ, + md5: READ, + md5sum: READ, + shasum: READ, + sha1sum: READ, + sha256sum: READ, + zcat: READ, + strings: READ, + eza: READ, + exa: READ, + lsd: READ, + rg: { read: true, runs: flag(RUNS, ["--pre"]) }, + ag: { read: true, runs: flag(RUNS, ["--pager"]) }, + fd: { read: true, runs: flag(RUNS, ["--exec", "--exec-batch"], "xX") }, + sort: { read: true, writes: flag(OUTPUT, ["--output"], "o"), runs: flag(RUNS, ["--compress-program"]) }, + uniq: { + read: true, + writes: (args) => (operands(args).length > 1 ? `${operands(args)[1]} is written` : undefined), + }, + tree: { read: true, writes: flag(OUTPUT, [], "o") }, + file: { read: true, writes: flag("compiles a magic file", ["--compile"], "C") }, + base64: { read: true, writes: flag(OUTPUT, ["--output"], "o") }, + xxd: { + read: true, + writes: (args) => (operands(args).length > 1 ? `${operands(args)[1]} is written` : undefined), + }, + yq: { read: true, writes: flag(IN_PLACE, ["--inplace"], "i") }, + date: { read: true, writes: flag("sets the clock", ["--set"], "s") }, + git: { + read: true, + writes: flag(OUTPUT, ["--output"]), + /** + * `--ext-diff` runs a diff driver; before the subcommand, `-c core.pager=…`, `--config-env` and + * `--exec-path` run whatever their value names, whatever the subcommand (as `family.runsAnother`). + */ + runs: (args) => { + const globals = args.slice(0, args.length - subcommand(args, TOOL_GLOBALS.git).length) + const set = globals.find((arg) => /^(-c|--config-env|--exec-path)(=|$)/.test(arg)) + return set !== undefined ? `${set} ${RUNS}` : flag(RUNS, ["--ext-diff"])(args) + }, + only: (args) => { + const [sub, ...rest] = subcommand(args, TOOL_GLOBALS.git) + if (sub !== undefined && GIT_READS.has(sub)) return undefined + if (sub !== undefined && GIT_SOMETIMES[sub]?.(rest)) return undefined + return `git ${sub ?? ""} is not a read`.trim() + }, + }, + sed: { read: true, writes: flag(IN_PLACE, ["--in-place"], "i"), only: (args) => sedPrints(args) }, + /* Not reads — here for what a widening of them must not cover (#44). */ + perl: { writes: flag(IN_PLACE, [], "i") }, + awk: { writes: (args) => inplace(args) }, + gawk: { writes: (args) => inplace(args) }, + tee: { + writes: (args) => { + const file = operands(args).find((word) => word !== "/dev/null") + return file === undefined ? undefined : `${file} is written` + }, + }, + curl: { + writes: flag( + OUTPUT, + ["--output", "--remote-name", "--remote-name-all", "--dump-header", "--cookie-jar"], + "oODc", + ), + }, + wget: { + writes: (args) => + args.includes("--spider") || args.includes("-O-") || args.includes("-qO-") || stdout(args, "-O") + ? undefined + : "downloads to a file", + }, + find: { + writes: (args) => args.find((arg) => /^-(delete|fprint0?|fprintf|fls)$/.test(arg)), + runs: (args) => args.find((arg) => /^-(exec|execdir|ok|okdir)$/.test(arg)), + }, + tar: { + writes: (args) => { + const mode = args.find((arg) => /^-?[A-Za-z]+$/.test(arg) && !arg.startsWith("--")) + return mode !== undefined && /[xcruA]/.test(mode) ? `${mode} writes an archive or its files` : undefined + }, + }, + unzip: { + writes: (args) => (flagsOf(args).some((f) => /^-[ltvpZ]/.test(f)) ? undefined : "extracts files"), + }, +} + +/* ─── sed ────────────────────────────────────────────────────────────────────────────────────── */ + +/** An address: a line, the last line, or a `/regex/`; a range of two, or `N,+M` / `N,~M`. */ +const ADDRESS = String.raw`(?:\d+|\$|/(?:[^/\\]|\\.)*/I?)` +const RANGE = String.raw`(?:${ADDRESS}(?:\s*,\s*(?:${ADDRESS}|[+~]\d+))?)` +/** + * The sed commands that only print: `p`, `d`, `q`, `=`, `n`, `l`, each behind an optional address + * (`1,50p`, `/^#/d`, `$=`), and `s/a/b/` with flags that print or repeat — never `w` (writes a file), + * never `e` (runs one). Only `/` as the delimiter; anything else is not read here, so it is not a read. + */ +const SED_COMMAND = new RegExp( + `^(?:${RANGE}\\s*!?\\s*)?(?:[pdq=nlPDN]|q\\d+|s/(?:[^/\\\\]|\\\\.)*/(?:[^/\\\\]|\\\\.)*/[gpiI0-9]*)$`, +) + +/** Flags that say nothing about what the script does: quiet, extended regex, separate files. */ +const SED_FLAGS = /^-(?:[nErsuz]+|-quiet|-silent|-regexp-extended|-separate|-unbuffered|-null-data|-posix)$/ + +/** + * Why this `sed` is not a print-only one: a flag outside the harmless few (`-i`, `-f file`), or a + * script command that writes or runs (`w out`, `s/a/b/w out`, `e cmd`), or one too unusual to read. + */ +function sedPrints(args: readonly string[]): string | undefined { + const scripts: string[] = [] + const rest: string[] = [] + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + if (word === "-e" || word === "--expression") { + const next = args[++i] + if (next === undefined) return "a sed script is missing" + scripts.push(next) + } else if (word.startsWith("--expression=")) scripts.push(word.slice("--expression=".length)) + else if (/^-[nErsuz]*e$/.test(word)) { + /** `-ne 2p`: the cluster's last letter takes the next word as the script. */ + const next = args[++i] + if (next === undefined) return "a sed script is missing" + scripts.push(next) + } else if (/^-[nErsuz]*e./.test(word)) scripts.push(word.slice(word.indexOf("e") + 1)) + else if (word.startsWith("-") && word !== "-") { + if (!SED_FLAGS.test(word)) return `sed ${word} is not a plain print` + } else rest.push(word) + } + if (scripts.length === 0) { + const first = rest.shift() + if (first === undefined) return "a sed script is missing" + scripts.push(first) + } + for (const script of scripts) + for (const part of script.split(/[;\n]/)) { + const command = part.trim() + if (command !== "" && !SED_COMMAND.test(command)) return "the sed script may write or run something" + } + return undefined +} + +/** `awk -i inplace`, `--include=inplace`. */ +function inplace(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + if ((word === "-i" || word === "--include") && args[i + 1] === "inplace") return `-i inplace ${IN_PLACE}` + if (/^(-i|--include=)inplace$/.test(word)) return `${word} ${IN_PLACE}` + } + return undefined +} + +/** `-O -`: written to the terminal, not a file. */ +const stdout = (args: readonly string[], name: string) => { + const at = args.indexOf(name) + return at >= 0 && args[at + 1] === "-" +} + +/* ─── the questions ──────────────────────────────────────────────────────────────────────────── */ + +/** The program and its arguments, under any wrappers (`timeout 5 sed -i …` is `sed -i …`). */ +function program( + argv: readonly string[], +): { name: string; args: readonly string[]; wrapped: boolean } | undefined { + let rest = argv + let wrapped = false + for (let depth = 0; depth < 8; depth++) { + const once = unwrapOnce(rest) + if (!once) break + rest = once.rest + wrapped = true + } + const [first, ...args] = rest + return first === undefined ? undefined : { name: posix.basename(first), args, wrapped } +} + +/** Why a flag makes this command write a file — the redirections are `family.redirections`'s. */ +export function writesByFlag(argv: readonly string[]): string | undefined { + const found = program(argv) + return found && PROGRAMS[found.name]?.writes?.(found.args) +} + +/** Why a flag makes this command run another program. */ +export function runsByFlag(argv: readonly string[]): string | undefined { + const found = program(argv) + return found && PROGRAMS[found.name]?.runs?.(found.args) +} + +/** + * Files whose contents are secrets: reading one is never routine, whatever program does it. A learned + * `cat` covers `cat README.md`, not `cat .env`. + */ +const SENSITIVE = + /(^|[/=])(\.env(\.[\w.-]+)?|\.netrc|\.npmrc|\.pypirc|\.pgpass|\.git-credentials|id_(rsa|dsa|ecdsa|ed25519)\b[^/]*|[^/]*\.(pem|key|p12|pfx|keystore|jks))$|(^|\/)(\.ssh|\.aws|\.gnupg|\.kube|\.docker)(\/|$)|credentials|secrets?(\.|\/|$)/i + +export const sensitive = (word: string): boolean => SENSITIVE.test(word) + +/** + * Why this command is not a plain read, or `undefined` when it is one: a program in the table that + * only reads, no flag that writes or runs, no redirection that writes (`writes`, from the caller), + * nothing in front of it (an env var — `LD_PRELOAD` — or a wrapper — `sudo`, `xargs`), nothing + * dangerous, and no secret file named. + */ +export function notRead(command: Command, writes: readonly string[]): string | undefined { + if (command.env.length > 0) return "it sets an environment variable" + const found = program(command.argv) + if (!found) return "nothing runs" + if (found.wrapped) return "it runs under a wrapper" + const known = PROGRAMS[found.name] + if (!known?.read) return `${found.name} is not a known read` + if (writes.length > 0) return "it writes to a file" + const why = known.writes?.(found.args) ?? known.runs?.(found.args) ?? known.only?.(found.args) + if (why) return why + const danger = dangerOf(command) + if (danger) return `dangerous (${danger})` + const secret = found.args.find(sensitive) + if (secret) return `${secret} may hold secrets` + return undefined +} diff --git a/packages/trust/src/core/engine.ts b/packages/trust/src/core/engine.ts index cb29bf95..bfcdcaf3 100644 --- a/packages/trust/src/core/engine.ts +++ b/packages/trust/src/core/engine.ts @@ -20,6 +20,7 @@ import type { Context, Request } from "./keys.ts" import { apply, type Event, emptyState, type FoldOptions, type State, type Thresholds } from "./ledger.ts" import { decide, type Judgement } from "./policy.ts" import type { ConfigRule } from "./rules.ts" +import { maskPattern, plainHash } from "./secret.ts" /** Faster than this, nobody read the prompt. Measured: auto mode 15–22ms, a person 1.2s and up. */ export const PERSON_MS = 300 @@ -78,6 +79,8 @@ export interface Answered { export interface EngineOptions extends Thresholds { /** Answers by Trust kept for the sidebar. */ keep?: number + /** Learn families of plain reads (config `learnReads`). Default true. */ + learnReads?: boolean } export interface Engine { @@ -119,7 +122,10 @@ export interface Engine { export function createEngine(initial: EngineOptions): Engine { let options = initial - const foldOptions = (): FoldOptions => ({ expireMs: options.expireDays * 86_400_000 }) + const foldOptions = (): FoldOptions => ({ + expireMs: options.expireDays * 86_400_000, + ...(options.learnReads !== false ? { threshold: options.threshold } : {}), + }) let events: Event[] = [] let state = emptyState() let history = emptyHistory() @@ -169,7 +175,14 @@ export function createEngine(initial: EngineOptions): Engine { }, ask({ request, context, agent, rules, at }) { const judgement = decide({ request, context, agent, rules, state, settings: options, now: at }) - const entry: Pending = { request, agent, askedAt: at, judgement } + /** OpenCode's "always" is written to the ledger as given: a secret in it is masked first. */ + const kept = request.always.length + ? { + ...request, + always: request.always.map((pattern) => maskPattern(pattern, context.hash ?? plainHash)), + } + : request + const entry: Pending = { request: kept, agent, askedAt: at, judgement } pending.set(request.id, entry) if (judgement.answer) { ours.set(request.id, entry) diff --git a/packages/trust/src/core/env.ts b/packages/trust/src/core/env.ts new file mode 100644 index 00000000..2debcbbc --- /dev/null +++ b/packages/trust/src/core/env.ts @@ -0,0 +1,32 @@ +/** Environment words: shared by families (family.ts) and masks (secret.ts), which must agree on them. */ + +/** + * Words that name an environment, as a whole word or a part of one: `db-prod`, `acme-staging`, + * `api.dev.acme.test`. `test` and `testing` count only in a flag's value: in an argument they are + * mostly a file name (`a.test.ts`). + */ +export const ENV = new Set([ + "prod", + "production", + "prd", + "live", + "staging", + "stage", + "stg", + "preprod", + "dev", + "develop", + "development", + "local", + "localhost", + "qa", + "uat", + "sandbox", + "test", + "testing", +]) +export const envWords = (text: string): string[] => + text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => ENV.has(word)) diff --git a/packages/trust/src/core/family.ts b/packages/trust/src/core/family.ts index 041ba375..9afd021b 100644 --- a/packages/trust/src/core/family.ts +++ b/packages/trust/src/core/family.ts @@ -7,7 +7,9 @@ * exact lines does not say "you trust ls". A family is the part of a command that names *what it * does*: the program, and for a tool with subcommands, the subcommand (`git status`, `docker compose * up`, `npm run test`). The ledger groups by it, and it is the one unit a person can widen trust to, - * on purpose (`w` in the ledger) — never Trust by itself (docs/roadmap/trust.md). + * on purpose (`w` in the ledger). Trust learns one kind of family by itself — plain reads, and only + * the reads in it (`notReadSubject`, effect.ts); every other widening is a person's (docs/roadmap/trust.md). + * It suggests them (suggest.ts); it never makes them. * * The rules, and why each one leans the way it does: * @@ -36,6 +38,8 @@ import { posix } from "node:path" import { dangerOf, subcommand, TOOL_GLOBALS, unwrapOnce } from "./danger.ts" +import { GIT_READS, notRead, runsByFlag, sensitive, writesByFlag } from "./effect.ts" +import { envWords } from "./env.ts" import { canonical } from "./rules.ts" import { type Command, parse } from "./shell.ts" import { quote } from "./signature.ts" @@ -108,36 +112,6 @@ const isFlag = (word: string) => word.startsWith("-") && word !== "-" && word != /** The plain words a family keeps at most: `mcpx db-local execute_sql`, `gh pr view`. */ const MAX_NAMES = 3 -/** - * Words that name an environment, as a whole word or a part of one: `db-prod`, `acme-staging`, - * `api.dev.acme.test`. `test` and `testing` count only in a flag's value: in an argument they are - * mostly a file name (`a.test.ts`). - */ -const ENV = new Set([ - "prod", - "production", - "prd", - "live", - "staging", - "stage", - "stg", - "preprod", - "dev", - "develop", - "development", - "local", - "localhost", - "qa", - "uat", - "sandbox", - "test", - "testing", -]) -export const envWords = (text: string): string[] => - text - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((word) => ENV.has(word)) const namesPlace = (text: string) => envWords(text).some((word) => word !== "test" && word !== "testing") /** @@ -379,7 +353,35 @@ function familyWords(argv: readonly string[], ops?: readonly number[]): string[] const [program, ...args] = rest if (program === undefined) return head const name = posix.basename(program) - return [...head, program, ...walk(args, VALUE_GLOBALS[name] ?? [], UTILITIES.has(name) ? 0 : MAX_NAMES)] + return [...head, program, ...walk(args, VALUE_GLOBALS[name] ?? [], namesFor(name, args))] +} + +/** `git` subcommands that take one of their own: `git stash list`, `git worktree add`. */ +const GIT_NESTED = new Set([ + "stash", + "worktree", + "remote", + "submodule", + "notes", + "bisect", + "lfs", + "sparse-checkout", +]) + +/** + * Plain words a family keeps after the program. A utility's are its input (`cat a.json`): none. After + * a git subcommand that only looks, they are refs and paths — `git show abc123`, `git merge-base feat + * origin/main` — so one `git show` is one family, not one per commit. A git subcommand that changes + * things keeps its words: pushing to `main` and to a feature branch stay two families. + */ +function namesFor(name: string, args: readonly string[]): number { + if (UTILITIES.has(name)) return 0 + if (name === "git") { + const sub = subcommand(args, TOOL_GLOBALS.git)[0] ?? "" + if (GIT_NESTED.has(sub)) return 2 + if (GIT_READS.has(sub)) return 1 + } + return MAX_NAMES } /** `NODE_ENV=…`: the name, not the value — unless the value names an environment (`NODE_ENV=production`). */ @@ -404,13 +406,21 @@ export function familyOf(permission: string, subject: string): string { const read = readSubject(subject) return read && read.command.argv.length > 0 ? bashFamily(read.command, read.place) : subject } - if (name === "edit") { + if (name === "edit" || name === "read") { const folder = posix.dirname(subject) return folder === "." ? "./" : folder.endsWith("/") ? folder : `${folder}/` } + /** A search and a web query are new words every time: the tool is the family (`any grep`). */ + if (WHOLE_TOOL.has(name)) return "*" return subject } +/** OpenCode tools whose every request is new text — a glob, a regex, a query — so one family each. */ +const WHOLE_TOOL = new Set(["glob", "grep", "websearch"]) + +/** OpenCode's own tools that only read the project: learned like a command that only reads. */ +const READ_TOOLS = new Set(["read", "glob", "grep", "list", "lsp"]) + /** The family's own words as a command, for judging the family itself. */ function familyCommand(family: string): Command | undefined { return readSubject(family)?.command @@ -436,7 +446,7 @@ export function widenable(permission: string, family: string): { ok: true } | { } : { ok: true } } - if (name === "edit") return { ok: true } + if (name === "edit" || name === "read" || WHOLE_TOOL.has(name)) return { ok: true } if (name === "webfetch") return { ok: false, why: "a fetch rule already covers the whole host" } if (name === "task") return { ok: false, why: "an agent type is already one rule" } return { ok: false, why: `a ${name} rule is already as wide as it goes` } @@ -452,6 +462,7 @@ function runsAnother(argv: readonly string[]): boolean { } const [program, ...args] = rest if (program === undefined) return false + if (runsByFlag(rest)) return true const name = posix.basename(program) if (name === "find") return args.some((arg) => ["-exec", "-execdir", "-ok", "-okdir"].includes(arg)) if (name === "git") { @@ -474,6 +485,8 @@ export function outside(permission: string, subject: string): string | undefined const danger = dangerOf(read.command) if (danger) return `dangerous (${danger})` if (redirections(read.command.argv, read.command.redirects).writes.length > 0) return "it writes to a file" + const writes = writesByFlag(read.command.argv) + if (writes) return `it writes to a file (${writes})` if (runsAnother(read.command.argv)) return "it runs another program" return undefined } @@ -482,6 +495,21 @@ export function outside(permission: string, subject: string): string | undefined export const covers = (permission: string, family: string, subject: string): boolean => familyOf(permission, subject) === family && outside(permission, subject) === undefined +/** + * Why a learned family would still ask about this subject — or nothing when it is a plain read + * (effect.ts). Narrower than `outside`: a family Trust learned by itself covers reads only, so + * `head -3 a.txt` is in a learned `head` and `head .env` or `head -3 a > b` are not. + */ +export function notReadSubject(permission: string, subject: string): string | undefined { + const name = canonical(permission) + if (READ_TOOLS.has(name)) + return name === "read" && sensitive(subject) ? `${subject} may hold secrets` : undefined + if (name !== "bash") return `a ${name} is not a read` + const read = readSubject(subject) + if (!read) return "it cannot be read as one command" + return notRead(read.command, redirections(read.command.argv, read.command.redirects).writes) +} + /* ─── showing it ─────────────────────────────────────────────────────────────────────────────── */ /** Words that read as themselves with no quotes, in any shell and any font. */ @@ -609,7 +637,9 @@ export function showSubject(permission: string, subject: string): string { /** `any ls …`, `any file in src/`: what a widened family answers, in words. */ export function anyOf(permission: string, family: string): string { const name = canonical(permission) - if (name === "edit") return family === "./" ? "any file at the project's top" : `any file in ${family}` + if (name === "edit" || name === "read") + return family === "./" ? "any file at the project's top" : `any file in ${family}` + if (family === "*") return `any ${name}` return `any ${showSubject(name, family)} …` } diff --git a/packages/trust/src/core/history.ts b/packages/trust/src/core/history.ts index 603ee7f1..00ba2052 100644 --- a/packages/trust/src/core/history.ts +++ b/packages/trust/src/core/history.ts @@ -35,7 +35,7 @@ export interface Answer { } export interface History { - /** By `keyOf(permission, agent, subject)`, oldest first. */ + /** By `keyOf(permission, subject)`, oldest first. */ marks: Map /** Oldest first, the last `ANSWERS`. */ answers: Answer[] @@ -86,7 +86,7 @@ export function note(history: History, event: Event, settled: ReadonlySet ({ subject: p, texts: [p] }))), diff --git a/packages/trust/src/core/ledger.ts b/packages/trust/src/core/ledger.ts index 2c830f7e..d6ef0e17 100644 --- a/packages/trust/src/core/ledger.ts +++ b/packages/trust/src/core/ledger.ts @@ -12,6 +12,7 @@ * a ledger you edited by hand should cost you a rule, not the bay. */ +import { familyOf, notReadSubject } from "./family.ts" import { canonical } from "./rules.ts" export interface Item { @@ -19,6 +20,8 @@ export interface Item { danger?: string /** On an answer by Trust: the widened family that answered it, rather than the rule's own count. */ via?: string + /** `via` is a family Trust learned (reads only), not one a person widened. */ + learned?: true } /** What every event about one request carries. */ @@ -42,12 +45,15 @@ export type Event = { v: 1; at: number } & ( /** You took a rule's trust away; it has to be earned again. */ | { type: "revoked"; permission: string; agent: string; subject: string } /** - * You trusted a whole family (family.ts) for one agent: any command in it is answered, except the - * ones a widening never covers. Only a person writes this; Trust never widens by itself. + * You trusted a whole family (family.ts) in this project: any command in it is answered, except the + * ones a widening never covers. Only a person writes this. (Trust learns a family of plain reads by + * itself — `Widened.learned` — but that is a fold of approvals, never an event.) */ | { type: "widened"; permission: string; agent: string; family: string } /** The family is back to its exact rules, each standing on its own count. */ | { type: "unwidened"; permission: string; agent: string; family: string } + /** You said no to Trust's suggestion to widen this family (suggest.ts): it is not suggested again. */ + | { type: "dismissed"; permission: string; agent: string; family: string } /** Trust stops answering in this project until resumed — every window, not just this one. */ | { type: "paused" } | { type: "resumed" } @@ -83,19 +89,36 @@ export interface Always { patterns: string[] } -/** A family you trusted as a whole, for one agent under one permission. */ +/** A family trusted as a whole in this project, under one permission. `agent`: who asked, or `ANY_AGENT`. */ export interface Widened { key: string permission: string agent: string family: string at: number + /** + * Trust learned it: `threshold` approvals in a row of plain reads in it (effect.ts). It covers + * reads only — `head -3 a`, never `head .env` or `head a > b` — and lasts while it is used. + */ + learned?: true + /** A learned family's last approval or answer: what its expiry counts from. */ + lastAt?: number +} + +/** Approvals in a row of plain reads in one family, towards learning it. */ +export interface ReadStreak { + streak: number + lastAt: number } export interface State { entries: Map - /** By `keyOf(permission, agent, family)`: the same key shape as a rule, a different namespace. */ + /** By `keyOf(permission, family)`: the same key shape as a rule, a different namespace. */ widened: Map + /** By the same key as `widened`: reads approved in a row per family, towards a learned family. */ + reads: Map + /** Families not to suggest widening again, by the same key: dismissed, or widened and undone. */ + dismissed: Set always: Always[] paused: boolean /** Requests already settled: two windows writing one outcome count it once. */ @@ -105,6 +128,8 @@ export interface State { export interface FoldOptions { /** Unused this long, trust starts again from nothing. 0 never expires. */ expireMs: number + /** Reads approved in a row that teach Trust their family. Absent: no family is learned. */ + threshold?: number } export const DAY = 86_400_000 @@ -112,16 +137,30 @@ export const DAY = 86_400_000 export const emptyState = (): State => ({ entries: new Map(), widened: new Map(), + reads: new Map(), + dismissed: new Set(), always: [], paused: false, settled: new Set(), }) -export const keyOf = (permission: string, agent: string, subject: string): string => - JSON.stringify([canonical(permission), agent, subject]) +/** + * A rule's key: what was asked, under which permission — and nothing about who asked. Trust is a + * project's: you give permission for the work in a project, so `ls` approved while `build` ran is + * `ls` for `general` and every subagent too. The agent stays on every event, for the history. + */ +export const keyOf = (permission: string, subject: string): string => + JSON.stringify([canonical(permission), subject]) + +/** + * The agent a person's own act is written with — a widening, a revoke, a dismissal: it is for the + * project, not for whoever happened to ask. A ledger from before 0.11 names an agent there; it is + * read the same, since no key carries one. + */ +export const ANY_AGENT = "*" function entry(state: State, permission: string, agent: string, item: Item, at: number): Entry { - const key = keyOf(permission, agent, item.subject) + const key = keyOf(permission, item.subject) let found = state.entries.get(key) if (!found) { found = { @@ -137,6 +176,8 @@ function entry(state: State, permission: string, agent: string, item: Item, at: } state.entries.set(key, found) } + /** Who asked it last: for the history, never for a decision. */ + found.agent = agent if (item.danger) found.danger = item.danger else delete found.danger return found @@ -154,6 +195,7 @@ export function apply(state: State, event: Event, options: FoldOptions): State { case "auto": { if (state.settled.has(event.request)) return state state.settled.add(event.request) + learn(state, event, options) for (const item of event.items) { const found = entry(state, event.permission, event.agent, item, event.at) if (event.type === "rejected") { @@ -179,7 +221,7 @@ export function apply(state: State, event: Event, options: FoldOptions): State { return state } case "revoked": { - const found = state.entries.get(keyOf(event.permission, event.agent, event.subject)) + const found = state.entries.get(keyOf(event.permission, event.subject)) if (found) { found.streak = 0 found.revokedAt = event.at @@ -187,7 +229,7 @@ export function apply(state: State, event: Event, options: FoldOptions): State { return state } case "widened": { - const key = keyOf(event.permission, event.agent, event.family) + const key = keyOf(event.permission, event.family) state.widened.set(key, { key, permission: canonical(event.permission), @@ -197,8 +239,17 @@ export function apply(state: State, event: Event, options: FoldOptions): State { }) return state } - case "unwidened": - state.widened.delete(keyOf(event.permission, event.agent, event.family)) + case "unwidened": { + /** A learned family taken away starts again from nothing, as a revoked rule does. */ + const key = keyOf(event.permission, event.family) + state.widened.delete(key) + state.reads.delete(key) + /** A suggestion was answered once: undoing what it led to is not a reason to ask again. */ + state.dismissed.add(key) + return state + } + case "dismissed": + state.dismissed.add(keyOf(event.permission, event.family)) return state case "paused": state.paused = true @@ -209,6 +260,70 @@ export function apply(state: State, event: Event, options: FoldOptions): State { } } +/** + * A request's part in learning families of reads. Each family counts once per request, so + * `head a | head b` is one approval of `head`. A reject of a read in a family starts it over and + * takes a learned family away; a reject of something it never covers (`head .env`) leaves it. + */ +function learn( + state: State, + event: Extract, + options: FoldOptions, +): void { + if (options.threshold === undefined) return + const seen = new Set() + for (const item of event.items) { + const key = keyOf(event.permission, familyOf(event.permission, item.subject)) + let learned = state.widened.get(key) + /** Unused too long, a learned family is gone, and has to be learned again from nothing. */ + if ( + learned?.learned && + options.expireMs > 0 && + event.at - (learned.lastAt ?? learned.at) > options.expireMs + ) { + state.widened.delete(key) + state.reads.delete(key) + learned = undefined + } + if (event.type === "rejected") { + /** + * Only a no to something learning would answer counts against it: rejecting `head .env` says + * nothing about `head README.md`, which is all a learned `head` ever covers. + */ + if (notReadSubject(event.permission, item.subject) !== undefined) continue + state.reads.delete(key) + if (learned?.learned) state.widened.delete(key) + continue + } + if (event.type === "auto") { + if (learned?.learned && item.via !== undefined) learned.lastAt = Math.max(learned.lastAt ?? 0, event.at) + continue + } + if (item.danger || notReadSubject(event.permission, item.subject) !== undefined) continue + /** Counted once per request, by its first read: `head .env | head a` counts `head a`. */ + if (seen.has(key)) continue + seen.add(key) + const found = state.reads.get(key) ?? { streak: 0, lastAt: event.at } + if (options.expireMs > 0 && event.at - found.lastAt > options.expireMs) found.streak = 0 + found.streak++ + found.lastAt = Math.max(found.lastAt, event.at) + state.reads.set(key, found) + if (learned?.learned) learned.lastAt = Math.max(learned.lastAt ?? 0, event.at) + else if (!learned && found.streak >= options.threshold) { + const family = familyOf(event.permission, item.subject) + state.widened.set(key, { + key, + permission: canonical(event.permission), + agent: event.agent, + family, + at: event.at, + learned: true, + lastAt: event.at, + }) + } + } +} + export function applyAll(state: State, events: readonly Event[], options: FoldOptions): State { for (const event of events) apply(state, event, options) return state @@ -230,6 +345,12 @@ export interface Standing { expired: boolean } +/** A widening answers now: one a person made always; a learned one until it goes unused too long. */ +export const live = (widened: Widened, settings: Thresholds, now: number): boolean => + !widened.learned || + settings.expireDays <= 0 || + now - (widened.lastAt ?? widened.at) <= settings.expireDays * DAY + export function needFor(danger: string | undefined, settings: Thresholds): number { return settings.threshold + (danger ? settings.dangerExtra : 0) } @@ -258,6 +379,7 @@ const TYPES = new Set([ "revoked", "widened", "unwidened", + "dismissed", "paused", "resumed", ]) @@ -273,7 +395,7 @@ function asEvent(value: unknown): Event | undefined { typeof raw.subject === "string" ? (raw as Event) : undefined - if (raw.type === "widened" || raw.type === "unwidened") + if (raw.type === "widened" || raw.type === "unwidened" || raw.type === "dismissed") return typeof raw.permission === "string" && typeof raw.agent === "string" && typeof raw.family === "string" diff --git a/packages/trust/src/core/paths.ts b/packages/trust/src/core/paths.ts index 8e4d7288..08536f68 100644 --- a/packages/trust/src/core/paths.ts +++ b/packages/trust/src/core/paths.ts @@ -21,6 +21,8 @@ export interface TrustPaths { dir: string /** The append-only ledger: one event per line. */ events: string + /** The key secrets are hashed with in signatures (core/secret.ts): beside the ledger, never in it. */ + key: string } /** Anything that is not plainly a filename becomes a dash. */ @@ -49,5 +51,5 @@ export function trustPaths( env.COCKPIT_HOME ?? join(env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share"), "opencode-cockpit") const dir = join(base, "trust", `${projectSlug(directory)}-${shortHash(directory)}`) - return { dir, events: join(dir, "events.ndjson") } + return { dir, events: join(dir, "events.ndjson"), key: join(dir, "mask.key") } } diff --git a/packages/trust/src/core/policy.ts b/packages/trust/src/core/policy.ts index 78f50c04..c915d048 100644 --- a/packages/trust/src/core/policy.ts +++ b/packages/trust/src/core/policy.ts @@ -11,16 +11,17 @@ * 3. **Is every part trusted, or allowed by config?** One untrusted command in a line is enough to * ask: `git status && rm -rf build` is not half-approved. A part is trusted by its own count, or * by a family you widened for this agent — unless it is one a widening never covers (dangerous, - * writing a file, running another program: `family.outside`). Config's `ask` was settled in 2, so - * it still wins over a widening. + * writing a file, by redirection or by flag, running another program: `family.outside`) — or by a + * family of reads Trust learned for this agent, which covers plain reads only (effect.ts). Config's + * `ask` was settled in 2, so it still wins over both. * * The answer always carries what an approval of the request would count towards, so a person's * approval of a request Trust declined is counted against exactly what Trust looked at. */ -import { familyOf, outside } from "./family.ts" +import { anyOf, familyOf, notReadSubject, outside } from "./family.ts" import { type Context, type Request, subjectsOf } from "./keys.ts" -import { type Item, keyOf, type State, standing, type Thresholds } from "./ledger.ts" +import { type Item, keyOf, live, type State, standing, type Thresholds } from "./ledger.ts" import { type ConfigRule, describeRule, gate } from "./rules.ts" export interface Progress { @@ -31,6 +32,8 @@ export interface Progress { trusted: boolean /** Trusted through this widened family rather than by its own count. */ via?: string + /** The family in `via` is one Trust learned from your approvals of reads in it. */ + learned?: true } export interface Judgement { @@ -58,7 +61,7 @@ export interface DecideInput { const left = (why: string): Judgement => ({ answer: false, why, items: [], progress: [] }) export function decide(input: DecideInput): Judgement { - const { request, context, agent, rules, state, settings, now } = input + const { request, context, rules, state, settings, now } = input const keyed = subjectsOf(request, context) if (keyed.kind !== "subjects") return left(keyed.why) @@ -76,9 +79,12 @@ export function decide(input: DecideInput): Judgement { for (const subject of keyed.subjects) { /** Allowed by config, all of it: not Trust's to count, and no reason to ask. */ if (subject.texts.every((text) => gate(rules, request.permission, text).kind === "allowed")) continue - const found = state.entries.get(keyOf(request.permission, agent, subject.subject)) + const found = state.entries.get(keyOf(request.permission, subject.subject)) const where = standing(found, subject.danger, settings, now) - const via = where.trusted ? undefined : widenedFor(state, request.permission, agent, subject.subject) + const through = where.trusted + ? undefined + : widenedFor(state, request.permission, subject.subject, settings, now) + const via = through?.family progress.push({ subject: subject.subject, ...(subject.danger ? { danger: subject.danger } : {}), @@ -86,6 +92,7 @@ export function decide(input: DecideInput): Judgement { need: where.need, trusted: where.trusted || via !== undefined, ...(via !== undefined ? { via } : {}), + ...(through?.learned ? { learned: true as const } : {}), }) } const items: Item[] = progress.map(({ subject, danger }) => (danger ? { subject, danger } : { subject })) @@ -112,10 +119,11 @@ export function decide(input: DecideInput): Judgement { if (state.paused) return { answer: false, why: "Trust is paused in this project", items, progress } const only = progress[0] as Progress /** An answer records which widening gave it, so the ledger can say "any ls" answered `ls -R`. */ - const answered: Item[] = progress.map(({ subject, danger, via }) => ({ + const answered: Item[] = progress.map(({ subject, danger, via, learned }) => ({ subject, ...(danger ? { danger } : {}), ...(via !== undefined ? { via } : {}), + ...(learned ? { learned } : {}), })) const widened = progress.filter((each) => each.via !== undefined) return { @@ -123,20 +131,34 @@ export function decide(input: DecideInput): Judgement { why: progress.length === 1 ? only.via !== undefined - ? `in a family you widened: ${only.via}` + ? only.learned + ? `a read Trust learned: ${anyOf(request.permission, only.via)}` + : `in a family you widened: ${anyOf(request.permission, only.via)}` : `approved by you ${only.have}× in a row${only.danger ? ` (dangerous: ${only.danger})` : ""}` : widened.length > 0 - ? `all ${progress.length} commands trusted (${widened.length} by a family you widened)` + ? `all ${progress.length} commands trusted (${widened.length} through a family)` : `all ${progress.length} commands approved enough times in a row`, items: answered, progress, } } -/** The family this subject is answered through, when you widened it for this agent and it covers it. */ -function widenedFor(state: State, permission: string, agent: string, subject: string): string | undefined { +/** + * The family this subject is answered through, for this agent: one you widened covers what a widening + * covers (`outside`); one Trust learned covers plain reads only (`notReadSubject`). + */ +export function widenedFor( + state: State, + permission: string, + subject: string, + settings: Thresholds, + now: number, +): { family: string; learned?: true } | undefined { if (state.widened.size === 0) return undefined const family = familyOf(permission, subject) - if (!state.widened.has(keyOf(permission, agent, family))) return undefined - return outside(permission, subject) === undefined ? family : undefined + const found = state.widened.get(keyOf(permission, family)) + if (!found || !live(found, settings, now)) return undefined + if (found.learned) + return notReadSubject(permission, subject) === undefined ? { family, learned: true } : undefined + return outside(permission, subject) === undefined ? { family } : undefined } diff --git a/packages/trust/src/core/sample.ts b/packages/trust/src/core/sample.ts index 196e3bb6..86cf5a52 100644 --- a/packages/trust/src/core/sample.ts +++ b/packages/trust/src/core/sample.ts @@ -16,6 +16,13 @@ export const SAMPLE_ROOT = "/work/app" export const SAMPLE_NOW = 1_790_300_000_000 export const SAMPLE_SETTINGS = { threshold: 3, dangerExtra: 5, expireDays: 30, keep: 20 } +/** + * The ledgers the screenshots were taken from were made before Trust learned reads (0.11): replayed + * with learning on, half their commands would be answered by a learned family and the layouts they + * exist to show — a family's `+ N more`, a column of meters — would be gone. `learning` shows 0.11. + */ +const BEFORE_0_11 = { ...SAMPLE_SETTINGS, learnReads: false } + export interface Sample { engine: Engine trouble?: string @@ -26,8 +33,12 @@ const RULES = rulesFrom({ }) /** Runs `script` on a fresh engine; `record` gets every event it loads, in order — a ledger file's lines. */ -function build(script: (step: Steps) => void, record?: Event[]): Engine { - const real = createEngine(SAMPLE_SETTINGS) +function build( + script: (step: Steps) => void, + record?: Event[], + settings: typeof SAMPLE_SETTINGS & { learnReads?: boolean } = SAMPLE_SETTINGS, +): Engine { + const real = createEngine(settings) const engine: Engine = Object.create(real) engine.load = (events, options) => { record?.push(...events) @@ -99,6 +110,30 @@ interface Steps { widen: (family: string, agent: string, permission?: string) => void } +/** + * A morning with 0.11: an orchestrator reading a codebase. Three `head`s, `rg`s and `sed -n`s on three + * files each, and each family is learned — the fourth of each is answered by itself. `head .env` is + * a read of a secret and still asks; `sed -i` writes and counts on its own. Three different local + * queries make `mcpx db-local execute_sql` a suggestion; the production one never will be. + */ +function learning(s: Steps): void { + const orchestrator = (line: string, times = 1) => s.approve(line, times, "once", "bash", "orchestrator") + s.at(SAMPLE_NOW - 5 * 3_600_000) + for (const file of ["src/app.ts", "src/server.ts", "README.md"]) { + orchestrator(`head -40 ${file}`) + orchestrator(`rg -n createServer ${file}`) + orchestrator(`sed -n 1,80p ${file}`) + } + for (const line of ["head -20 src/db.ts", "rg -n session src/auth.ts", "sed -n 40,120p src/db.ts"]) + s.auto(line, 1, "bash", "orchestrator") + orchestrator("head .env") + orchestrator("sed -i s/old/new/ src/app.ts") + for (const query of ["select 1", "select count(*) from users", "select id from orders limit 5"]) + orchestrator(`mcpx db-local execute_sql --sql "${query}"`) + orchestrator('mcpx db-prod execute_sql --sql "select count(*) from users"', 2) + s.auto("head -60 src/routes.ts", 1, "bash", "orchestrator") +} + /** The `crowded` afternoon, as steps: the oldest first, so the newest are what the ledger leads with. */ function crowd(s: Steps): void { const general = (line: string, times: number, how: "once" | "always" = "once") => @@ -305,6 +340,9 @@ export const SAMPLES: Record Sample> = { */ busy: () => ({ engine: build(week) }), + /** 0.11: reads learned as families, a widening suggested, a secret file and production still asking. */ + learning: () => ({ engine: build(learning) }), + /** Dangerous commands on their way: each needs eight in a row, and none of their families widens. */ dangerous: () => ({ engine: build((s) => { @@ -357,14 +395,14 @@ export const SAMPLES: Record Sample> = { * approved once, families half trusted and half counting, a family widened by hand, dangerous rules * on their way, a long `find`, the `---` a font merges, and OpenCode's own "always" twice. */ - crowded: () => ({ engine: build(crowd) }), + crowded: () => ({ engine: build(crowd, undefined, BEFORE_0_11) }), /** The project the ledger's redesign was drawn from: kinds, folders of edits, a long tail seen once. */ - storefront: () => ({ engine: build(storefront) }), + storefront: () => ({ engine: build(storefront, undefined, BEFORE_0_11) }), /** The same afternoon, paused: the dialog has to say it before anything else. */ "crowded-paused": () => { - const engine = build(crowd) + const engine = build(crowd, undefined, BEFORE_0_11) engine.load([{ v: 1, at: SAMPLE_NOW - 60_000, type: "paused" }]) return { engine } }, diff --git a/packages/trust/src/core/secret.ts b/packages/trust/src/core/secret.ts new file mode 100644 index 00000000..b7642802 --- /dev/null +++ b/packages/trust/src/core/secret.ts @@ -0,0 +1,121 @@ +/** + * Secrets out of the ledger: a command's signature is written to disk, shown in the ledger and logged, + * so a value that is a secret is replaced before any of that — `TOKEN=‹#3fa9c2›`. + * + * **Masked, not dropped.** Two commands with different tokens stay two commands: the mask is a keyed + * hash of the value (`Hasher`), so trust earned with one value is not trust for another — and the + * key, kept beside the ledger and never in it, means a weak password cannot be confirmed by hashing + * guesses. Without a key (tests, the preview) the hash is plain SHA-256, still never the value. + * + * **What stays readable** is what Trust reads: an environment word (`NODE_ENV=production`) and a + * short plain value under a name that does not say secret (`PORT=3000`, `DEBUG=true`). A masked value + * keeps the environment words found in it — `DATABASE_URL=‹#a1b2c3 prod›` — because families and + * danger are told apart by them (family.ts): masking must not make a production URL look like dev. + * + * **What is masked:** + * - an env value (`NAME=value` before the program, or `export NAME=value`), unless it stays readable; + * - a flag's value when the flag names a secret: `--token=…`, `--password …`, `--api-key …`; + * - a header that carries one: `Authorization: …`, `X-Api-Key: …`, `Cookie: …`; + * - `Bearer …` anywhere, and the password in `scheme://user:pass@host`; + * - a token by its shape anywhere in a word: `sk-…`, `ghp_…`, `github_pat_…`, `xoxb-…`, `AKIA…`, + * `shpat_…`, a JWT. + * + * A miss here leaves a secret where it was before 0.11; it never makes Trust answer more. + */ + +import { createHash } from "node:crypto" +import { envWords } from "./env.ts" + +/** A value to its mask's hash part: short hex. */ +export type Hasher = (value: string) => string + +export const plainHash: Hasher = (value) => createHash("sha256").update(value).digest("hex").slice(0, 6) + +/** Names that say the value is a secret, as a word of the name: `GITHUB_TOKEN`, `DB_PASSWORD`, `apiKey`. */ +const SECRET_NAME = + /(^|[_-]|[a-z](?=[A-Z]))(token|secret|pass(word|wd|phrase)?|pwd|key|apikey|auth|credentials?|cred|cookie|session|private|signature|sig|dsn|salt|otp|pin)s?($|[_-]|(?<=[a-z])(?=[A-Z]))/i + +/** A flag whose value is a secret: `--token`, `--client-secret`, `--password`. */ +const SECRET_FLAG = + /^--?([\w-]*[-_])?(token|secret|pass(word|wd)?|api-?key|auth|credentials?|cookie|session-?id)$/i + +/** A value short enough to read at a glance, on one line. Longer is masked: a key, a blob, a script. */ +const READABLE = /^[^\n]{1,40}$/ +/** A run that looks generated — 20 letters and digits, or 6 digits — rather than written by a person. */ +const RANDOM = /[A-Za-z0-9+/_-]{20,}|\d{6,}/ + +/** A token, by the shapes issuers give them. */ +const TOKEN_SHAPE = + /\b(sk-(?:proj-|ant-|live-|test-)?[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|shp(?:at|ss|ca|pa)_[a-f0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|npm_[A-Za-z0-9]{30,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/ + +const TOKEN = new RegExp(TOKEN_SHAPE.source, "g") + +/** `‹#3fa9c2›`, or `‹#3fa9c2 prod›` when the value names an environment. */ +export function maskOf(value: string, hash: Hasher): string { + const places = [...new Set(envWords(value))] + return `‹#${hash(value)}${places.length > 0 ? ` ${places.join(" ")}` : ""}›` +} + +/** Whether `NAME=value` may be shown as it is. */ +export function readable(name: string, value: string): boolean { + if (SECRET_NAME.test(name)) return false + if (value === "") return true + return READABLE.test(value) && !TOKEN_SHAPE.test(value) && !RANDOM.test(value) +} + +/** `NAME=value`, its value masked unless it is readable. */ +export function maskAssignment(word: string, hash: Hasher): string { + const eq = word.indexOf("=") + if (eq <= 0) return maskWord(word, hash) + const name = word.slice(0, eq) + const value = word.slice(eq + 1) + /** Readable, a password in a URL is still masked: `postgres://app:‹#…›@db/app`. */ + return readable(name, value) ? `${name}=${maskWord(value, hash)}` : `${name}=${maskOf(value, hash)}` +} + +/** Tokens, `Bearer …`, URL passwords and secret headers inside one word. */ +export function maskWord(word: string, hash: Hasher): string { + const header = word.match( + /^\s*(authorization|proxy-authorization|x-api-key|x-auth-token|api-key|cookie|x-access-token)\s*:\s*(.+)$/is, + ) + if (header) return `${header[1]}: ${maskOf(header[2] as string, hash)}` + return word + .replace( + /\b(Bearer|Basic|Token)\s+([A-Za-z0-9._~+/=-]{8,})/g, + (_, kind: string, value: string) => `${kind} ${maskOf(value, hash)}`, + ) + .replace( + /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@/]+)(@)/gi, + (_, head: string, value: string, at: string) => `${head}${maskOf(value, hash)}${at}`, + ) + .replace(TOKEN, (value) => maskOf(value, hash)) +} + +/** + * Every word of a command, masked: env assignments in front, then each argument — with a secret + * flag's value, whether `--token=x` or `--token x`, and `export NAME=value`'s value. + */ +export function maskArgv(argv: readonly string[], hash: Hasher, keep?: ReadonlySet): string[] { + const out: string[] = [] + const exporting = argv[0] === "export" || argv[0] === "declare" || argv[0] === "set" + for (let i = 0; i < argv.length; i++) { + const word = argv[i] as string + if (keep?.has(i)) { + out.push(word) + continue + } + const before = argv[i - 1] + const eq = word.indexOf("=") + if (before !== undefined && !keep?.has(i - 1) && SECRET_FLAG.test(before) && !word.startsWith("-")) + out.push(maskOf(word, hash)) + else if (word.startsWith("-") && eq > 0 && SECRET_FLAG.test(word.slice(0, eq))) + out.push(`${word.slice(0, eq)}=${maskOf(word.slice(eq + 1), hash)}`) + else if (exporting && i > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) out.push(maskAssignment(word, hash)) + else out.push(maskWord(word, hash)) + } + return out +} + +/** A pattern as OpenCode writes one (`git push *`), masked word by word: its "always" goes in the ledger too. */ +export const maskPattern = (pattern: string, hash: Hasher): string => + maskArgv(pattern.split(" "), hash).join(" ") diff --git a/packages/trust/src/core/signature.ts b/packages/trust/src/core/signature.ts index 38151fd0..59aa6dc0 100644 --- a/packages/trust/src/core/signature.ts +++ b/packages/trust/src/core/signature.ts @@ -10,6 +10,7 @@ */ import { posix } from "node:path" +import { type Hasher, maskArgv, maskAssignment, plainHash } from "./secret.ts" import type { Command } from "./shell.ts" /** Words that need no quotes to read back as themselves. */ @@ -33,13 +34,15 @@ export function place(cwd: string | undefined, root: string | undefined): string return relative } -export function signature(command: Command, root?: string): string { +export function signature(command: Command, root?: string, hash: Hasher = plainHash): string { const where = place(command.cwd, root) /** A redirection is written bare and an argument quoted, so `echo > x` and `echo '>' x` differ. */ const ops = new Set(command.redirects ?? []) + /** A secret never reaches the ledger: its value is a keyed hash from here on (secret.ts). */ + const argv = maskArgv(command.argv, hash, ops) const words = [ - ...command.env.map(quote), - ...command.argv.map((word, i) => (ops.has(i) ? word : quote(word))), + ...command.env.map((word) => quote(maskAssignment(word, hash))), + ...argv.map((word, i) => (ops.has(i) ? word : quote(word))), ].join(" ") return where === undefined ? words : `(in ${quote(where)}) ${words}` } diff --git a/packages/trust/src/core/suggest.ts b/packages/trust/src/core/suggest.ts new file mode 100644 index 00000000..dc395364 --- /dev/null +++ b/packages/trust/src/core/suggest.ts @@ -0,0 +1,54 @@ +/** + * Families worth widening, suggested — never widened (#45). + * + * Exact rules converge slowly on real work: most lines carry one command never seen before (a new + * path in a `sed -n`), so a family a person approves all day may never have one exact command trusted. + * A family Trust may learn by itself is a read (effect.ts); every other family stays a person's call, + * and this is Trust making that call easy to see: when one agent's approvals across a family reach the + * threshold, the ledger offers `w` for it, once, until it is widened or dismissed. + * + * Never suggested: a family that is already widened or learned for that agent, one a widening refuses + * (dangerous — `family.widenable`), one dismissed or widened and undone before, and one with a single + * command — its own count already says what a family would. + */ + +import { widenable } from "./family.ts" +import { keyOf } from "./ledger.ts" +import { type Family, type Reading, standOf } from "./view/model.ts" + +export interface Suggestion { + /** `keyOf(permission, family)`. */ + key: string + family: Family + /** Approvals in a row still counting, across the family's commands. */ + approvals: number + /** Commands in the family with approvals that count. */ + commands: number +} + +/** + * Kinds a suggestion is made for: commands, edits and reads by folder, searches and web queries as a + * tool. Fetches (a host), agents and skills are as wide as they go already. + */ +const SUGGESTED = new Set(["bash", "edit", "read", "glob", "grep", "websearch"]) + +export function suggestionsOf(reading: Reading, families: readonly Family[]): Suggestion[] { + const { state, settings } = reading + const out: Suggestion[] = [] + for (const family of families) { + if (!SUGGESTED.has(family.permission) || !widenable(family.permission, family.family).ok) continue + const sum = { approvals: 0, commands: 0 } + for (const command of family.commands) + for (const { entry } of command.standings) { + const stand = standOf(entry, reading) + if (stand.kind !== "counting" || stand.have === 0 || entry.danger) continue + sum.approvals += stand.have + sum.commands++ + } + const key = keyOf(family.permission, family.family) + if (state.widened.has(key) || state.dismissed.has(key)) continue + if (sum.commands < 2 || sum.approvals < settings.threshold) continue + out.push({ key, family, ...sum }) + } + return out.sort((a, b) => b.approvals - a.approvals) +} diff --git a/packages/trust/src/core/view/actions.ts b/packages/trust/src/core/view/actions.ts index 15c89968..4e333d17 100644 --- a/packages/trust/src/core/view/actions.ts +++ b/packages/trust/src/core/view/actions.ts @@ -6,14 +6,15 @@ import { anyOf, readSubject, showSubject, widenable } from "../family.ts" import type { Answer } from "../history.ts" -import type { Event } from "../ledger.ts" -import { type AlwaysGroup, type Command, type Family, leadOf, type Reading, stale } from "./model.ts" -import { agentsText, plural } from "./parts.ts" +import { ANY_AGENT, type Event, type Widened } from "../ledger.ts" +import { type AlwaysGroup, type Command, type Family, type Reading, stale } from "./model.ts" +import { plural } from "./parts.ts" import type { Tone } from "./rows.ts" export type Target = | { kind: "command"; command: Command; family?: Family } - | { kind: "family"; family: Family } + /** `suggested`: reached from a suggestion, where `d` dismisses it. */ + | { kind: "family"; family: Family; suggested?: true } | { kind: "answer"; answer: Answer } | { kind: "always"; groups: AlwaysGroup[] } @@ -26,15 +27,27 @@ export interface Outcome { /** What a widening never covers, in words — the three `family.outside` holds back. */ export const EXCEPT = "except dangerous ones, ones that write a file and ones that run another program" export const NOT_COVERED = "dangerous ones, and any that write a file or run another program" +/** What a family Trust learned never answers: anything but a plain read (effect.ts). */ +export const NOT_READ = + "anything but a plain read — a write, a flag that writes or runs, an env var, a wrapper, a secret file" -const revoked = (permission: string, agent: string, subject: string): Event => ({ +/** A person's own act is the project's, whichever agent asked: written with `ANY_AGENT`. */ +const revoked = (permission: string, subject: string): Event => ({ v: 1, at: 0, type: "revoked", permission, - agent, + agent: ANY_AGENT, subject, }) +const unwidened = (permission: string, family: string): Event => ({ + v: 1, + at: 0, + type: "unwidened", + permission, + agent: ANY_AGENT, + family, +}) const stamp = (events: Event[], at: number): Event[] => events.map((event) => ({ ...event, at })) /* ─── x ──────────────────────────────────────────────────────────────────────────────────────── */ @@ -53,7 +66,7 @@ export function revokeLabel(target: Target): { label: string; off: boolean } { } /** - * `x`. A command loses its standing for every agent. A family loses every command in it and its + * `x`. A command loses its standing in the project. A family loses every command in it and its * widenings. An answer in the feed stops what gave it: the rule's own count, or the widening. * OpenCode's own "always" is OpenCode's: Trust cannot take it back, and says so. */ @@ -75,15 +88,8 @@ export function revoke(target: Target, reading: Reading, at: number): Outcome { if (item.via !== undefined) { if (widened.has(item.via)) continue widened.add(item.via) - events.push({ - v: 1, - at, - type: "unwidened", - permission: answer.permission, - agent: answer.agent, - family: item.via, - }) - } else events.push(revoked(answer.permission, answer.agent, item.subject)) + events.push(unwidened(answer.permission, item.via)) + } else events.push(revoked(answer.permission, item.subject)) } const name = answer.items.map((item) => showSubject(answer.permission, item.subject)).join(" && ") return { @@ -91,8 +97,8 @@ export function revoke(target: Target, reading: Reading, at: number): Outcome { notice: { text: widened.size > 0 - ? `Stopped: ${[...widened].map((family) => anyOf(answer.permission, family)).join(", ")} no longer answered for ${answer.agent}.` - : `Revoked: ${name} is asked again until you approve it ${threshold}× more, as ${answer.agent}.`, + ? `Stopped: ${[...widened].map((family) => anyOf(answer.permission, family)).join(", ")} no longer answered.` + : `Revoked: ${name} is asked again until you approve it ${threshold}× more.`, tone: "muted", }, } @@ -100,40 +106,25 @@ export function revoke(target: Target, reading: Reading, at: number): Outcome { if (target.kind === "command") { const { command } = target const name = showSubject(command.permission, command.subject) - const agents = command.standings.map((each) => each.entry.agent) const via = command.standings.find((each) => each.stand.kind === "widened") - const events = command.standings.map((each) => - revoked(each.entry.permission, each.entry.agent, each.entry.subject), - ) - const others = agents.length > 1 ? ` — for ${agentsText(agents)}` : "" + const events = [revoked(command.permission, command.subject)] return { events: stamp(events, at), notice: { text: via?.stand.kind === "widened" - ? `Forgot ${name}'s own count${others}; ${anyOf(command.permission, via.stand.family)} still answers it — [w] undoes that.` + ? `Forgot ${name}'s own count; ${anyOf(command.permission, via.stand.family)} still answers it — [w] undoes that.` : command.phase === "answering" - ? `Revoked: ${name} is asked again until you approve it ${threshold}× more${others}.` - : `Forgot the count for ${name}${others}: it starts again from 0.`, + ? `Revoked: ${name} is asked again until you approve it ${threshold}× more.` + : `Forgot the count for ${name}: it starts again from 0.`, tone: "muted", }, } } const { family } = target const events: Event[] = [ - ...family.commands.flatMap((command) => - command.standings.map((each) => revoked(each.entry.permission, each.entry.agent, each.entry.subject)), - ), - ...family.widened.map( - (each): Event => ({ - v: 1, - at, - type: "unwidened", - permission: each.permission, - agent: each.agent, - family: each.family, - }), - ), + ...family.commands.map((command) => revoked(command.permission, command.subject)), + ...family.widened.map((each) => unwidened(each.permission, each.family)), ] const name = showSubject(family.permission, family.family) const answering = family.commands.filter((command) => command.phase === "answering").length @@ -154,48 +145,44 @@ export function revoke(target: Target, reading: Reading, at: number): Outcome { /* ─── w ──────────────────────────────────────────────────────────────────────────────────────── */ -/** The family `w` acts on, and for which agent. */ +/** The family `w` acts on. */ export interface WidenScope { permission: string family: string - /** The agent `w` would widen it for. */ - agent?: string - /** Agents it is widened for now, of those `w` would act on: non-empty means `w` undoes. */ - undo: string[] + /** It is widened, or learned, now: `w` undoes it. */ + undo: boolean + /** What `w` would undo is a family Trust learned (reads only): `w` forgets it. */ + learned?: true } export function widenScope(target: Target, families: readonly Family[]): WidenScope | undefined { if (target.kind === "always") return undefined - const find = (permission: string, family: string) => - families.find((each) => each.permission === permission && each.family === family) - if (target.kind === "family") { - const { family } = target - return { - permission: family.permission, - family: family.family, - ...(family.commands[0] ? { agent: leadOf(family.commands[0]).entry.agent } : {}), - undo: family.widened.map((each) => each.agent), - } - } - const [permission, familyName, agent] = - target.kind === "answer" - ? [ - target.answer.permission, - target.answer.items.find((item) => item.via !== undefined)?.via ?? - familyOfAnswer(target.answer, families), - target.answer.agent, - ] - : [target.command.permission, target.command.family, leadOf(target.command).entry.agent] + const [permission, familyName] = + target.kind === "family" + ? [target.family.permission, target.family.family] + : target.kind === "answer" + ? [ + target.answer.permission, + target.answer.items.find((item) => item.via !== undefined)?.via ?? + familyOfAnswer(target.answer, families), + ] + : [target.command.permission, target.command.family] if (familyName === undefined) return undefined - const widened = find(permission, familyName)?.widened ?? [] + const widened = + target.kind === "family" + ? target.family.widened + : (families.find((each) => each.permission === permission && each.family === familyName)?.widened ?? []) return { permission, family: familyName, - agent, - undo: widened.filter((each) => each.agent === agent).map((each) => each.agent), + undo: widened.length > 0, + ...(learnedOnly(widened) ? { learned: true as const } : {}), } } +const learnedOnly = (widened: readonly Widened[]) => + widened.length > 0 && widened.every((each) => each.learned) + /** The family of an answer's first command, as the model grouped it. */ function familyOfAnswer(answer: Answer, families: readonly Family[]): string | undefined { const first = answer.items[0]?.subject @@ -215,56 +202,42 @@ export function familyLabel(permission: string, family: string): string { export function widenLabel(scope: WidenScope | undefined, named = true): { label: string; off: boolean } { if (!scope) return { label: "Trust any", off: true } const name = named ? ` ${familyLabel(scope.permission, scope.family)}` : "" - if (scope.undo.length > 0) return { label: `Undo any${name}`, off: false } + if (scope.undo) return { label: scope.learned ? `Forget${name} reads` : `Undo any${name}`, off: false } return { label: `Trust any${name}`, off: !widenable(scope.permission, scope.family).ok } } /** - * `w`: trust a whole family for one agent, or take that back. Never automatic — this is the only place - * a `widened` event is made, and only a person reaches it. A dangerous family is refused, with why. + * `w`: trust a whole family in this project, or take that back — a learned family of reads included, + * which `w` forgets. Never automatic — this is the only place a `widened` event is made, and only a + * person reaches it; a suggestion (suggest.ts) only puts it in reach. A dangerous family is refused. */ -export function widen(scope: WidenScope | undefined, families: readonly Family[], at: number): Outcome { +export function widen(scope: WidenScope | undefined, at: number): Outcome { if (!scope) return { events: [], notice: { text: "OpenCode's own approvals have no family to widen.", tone: "warning" }, } const any = anyOf(scope.permission, scope.family) - if (scope.undo.length > 0) + if (scope.undo) return { - events: scope.undo.map((agent) => ({ - v: 1, - at, - type: "unwidened", - permission: scope.permission, - agent, - family: scope.family, - })), + events: [ + { v: 1, at, type: "unwidened", permission: scope.permission, agent: ANY_AGENT, family: scope.family }, + ], notice: { - text: `Back to exact rules: ${any} is no longer answered for ${agentsText(scope.undo)}.`, + text: scope.learned + ? `Forgot ${any} reads: back to exact rules, until enough reads in a row teach it again.` + : `Back to exact rules: ${any} is no longer answered.`, tone: "muted", }, } const can = widenable(scope.permission, scope.family) if (!can.ok) return { events: [], notice: { text: `Not widened: ${can.why}.`, tone: "warning" } } - if (scope.agent === undefined) - return { events: [], notice: { text: "No agent to widen it for.", tone: "warning" } } - const family = families.find((each) => each.permission === scope.permission && each.family === scope.family) - const others = [ - ...new Set( - (family?.commands ?? []).flatMap((command) => - command.standings.map((each) => each.entry.agent).filter((agent) => agent !== scope.agent), - ), - ), - ] return { events: [ - { v: 1, at, type: "widened", permission: scope.permission, agent: scope.agent, family: scope.family }, + { v: 1, at, type: "widened", permission: scope.permission, agent: ANY_AGENT, family: scope.family }, ], notice: { - text: `Trusted ${any} for ${scope.agent}${scope.permission === "bash" ? ` — ${EXCEPT} still ask` : ""}.${ - others.length > 0 ? ` ${agentsText(others)} keep${others.length === 1 ? "s" : ""} counting.` : "" - } [w] again undoes it.`, + text: `Trusted ${any}${scope.permission === "bash" ? ` — ${EXCEPT} still ask` : ""}. [w] again undoes it.`, tone: "success", }, } @@ -274,8 +247,8 @@ export function widen(scope: WidenScope | undefined, families: readonly Family[] /** * The target as `opencode.json` would say it, for you to paste — Trust never writes OpenCode's - * config. A family is a wildcard (`"ls *": "allow"`), and config says less than a widening: not which - * agent, and not the commands a widening still asks about. + * config. A family is a wildcard (`"ls *": "allow"`), and config says more than a widening: it + * allows the commands a widening still asks about too. */ export function configSnippet(target: Target): { text: string; note?: string } { if (target.kind === "always") { @@ -319,11 +292,27 @@ function familySnippet(family: Family): { text: string; note?: string } { const any = anyOf(family.permission, family.family) notes.unshift( family.widened.length > 0 - ? `config cannot say which agent, so it allows ${any} for every one — even the ones Trust still asks about` - : `wider than anything Trust answers here: ${any}, for every agent`, + ? `it allows ${any} — even the ones Trust still asks about` + : `wider than anything Trust answers here: ${any}`, ) return { text: JSON.stringify({ permission: { [family.permission]: { [pattern]: "allow" } } }), note: notes.join("; "), } } + +/* ─── d ──────────────────────────────────────────────────────────────────────────────────────── */ + +/** `d` on a suggestion: not suggested again in this project. Nothing is widened or forgotten. */ +export function dismiss(target: Target | undefined, at: number): Outcome { + if (target?.kind !== "family" || !target.suggested) + return { events: [], notice: { text: "Only a suggestion can be dismissed.", tone: "muted" } } + const { permission, family } = target.family + return { + events: [{ v: 1, at, type: "dismissed", permission, agent: ANY_AGENT, family }], + notice: { + text: `Not suggested again: ${anyOf(permission, family)}. [w] on the family still widens it.`, + tone: "muted", + }, + } +} diff --git a/packages/trust/src/core/view/activity.ts b/packages/trust/src/core/view/activity.ts index ac8b37d0..3eea0489 100644 --- a/packages/trust/src/core/view/activity.ts +++ b/packages/trust/src/core/view/activity.ts @@ -6,7 +6,7 @@ * * TODAY Trust answered 4 prompts for you ▁▁▃▁▅▂█ last 7 days * ▌✓ 09:41 git status --short build trusted since yesterday, 3 in a row - * ✓ 09:40 ls -la general in a family you widened: ls + * ✓ 09:40 ls -la general in a family you widened: any ls … * * ALMOST THERE one more approval and Trust answers these * ○ bun --version build ▰▰▱ 2 of 3 @@ -20,9 +20,10 @@ * [enter] Why [x] Revoke [w] Trust Family [a] Ledger [p] Pause [?] Keys [esc] Back * * One left edge: every row starts with its mark (`✓` answered, `○` close, `!` OpenCode's own), then - * the time column, then the command, so the three lists read as one. The agent has a column only - * when there is more than one; with one, the header names it and its chip is not repeated on every - * row. Paths are cut in the middle (`rows.squeeze`), so the file each command touched stays visible. + * the time column, then the command, so the three lists read as one. Trust is the project's, so an + * agent is history here: which one an answer went to, whose session an "always" came from. It has a + * column only when there is more than one; with one, the header names it. A command still counting + * has no chip: its count is every agent's. Paths are cut in the middle (`rows.squeeze`), so the file each command touched stays visible. * * The ledger (explorer.ts) is what `/trust` opens on; this is the "what happened" view, reached from * its Today strip or `a`. Every list is a window that follows the cursor, so a short dialog still @@ -30,7 +31,7 @@ */ import { closeHint, type Hint } from "@opencode-cockpit/client/design" -import { showSubject } from "../family.ts" +import { anyOf, showSubject } from "../family.ts" import { type Answer, answersPerDay, dayOf, earned, type History, latestAnswers } from "../history.ts" import { keyOf, needFor } from "../ledger.ts" import { revokeLabel, type Target, widenLabel, widenScope } from "./actions.ts" @@ -145,7 +146,7 @@ export function activityModel(input: Reading & { history: History }): ActivityMo } } -/** Approvals still to go for the agent closest to trusting it. */ +/** Approvals still to go before Trust answers it. */ function distanceOf(command: Command): number { const { stand } = leadOf(command) return stand.kind === "counting" ? (stand.expired ? Number.MAX_SAFE_INTEGER : stand.need - stand.have) : 0 @@ -183,16 +184,24 @@ export function answerWhy(answer: Answer, reading: Reading & { history: History const count = answer.items.length if (count > 1) { if (widened.length === 0) return count === 2 ? "both commands trusted" : `all ${count} commands trusted` + const whose = widened.every((item) => item.learned) + ? "Trust learned" + : widened.some((item) => item.learned) + ? "learned or widened" + : "you widened" if (count === 2) return widened.length === 2 - ? "both through families you widened" - : "both trusted, one through a family you widened" - return `all ${count} trusted, ${widened.length} through a family you widened` + ? `both through families ${whose}` + : `both trusted, one through a family ${whose}` + return `all ${count} trusted, ${widened.length} through a family ${whose}` } const item = answer.items[0] if (!item) return answer.rule - if (item.via !== undefined) return `in a family you widened: ${showSubject(answer.permission, item.via)}` - const marks = history.marks.get(keyOf(answer.permission, answer.agent, item.subject)) ?? [] + if (item.via !== undefined) + return item.learned + ? `a read Trust learned: ${anyOf(answer.permission, item.via)}` + : `in a family you widened: ${anyOf(answer.permission, item.via)}` + const marks = history.marks.get(keyOf(answer.permission, item.subject)) ?? [] const need = needFor(item.danger, settings) const got = earned(marks, need, settings.expireDays * 86_400_000, answer.at) if (got.since === undefined) return answer.rule @@ -310,7 +319,7 @@ export function activityRows(input: ActivityInput): ActivityView { quiet: wrapRuns( [ muted( - `Approve the same command ${settings.threshold} times in a row and Trust answers it from then on — that exact command, for that agent.`, + `Approve the same command ${settings.threshold} times in a row and Trust answers it from then on — that exact command, in this project.`, ), ], Math.max(1, width - 3), @@ -488,12 +497,8 @@ function columnsOf(model: ActivityModel, width: number, now: number): Columns { 5, ...model.feed.map((item) => (item.kind === "answer" ? clock(item.answer.at, now).length : 0)), ) - const agents = model.items.map((item) => - item.kind === "answer" - ? item.answer.agent - : item.kind === "almost" - ? leadOf(item.command).entry.agent - : item.group.agent, + const agents = model.items.flatMap((item) => + item.kind === "answer" ? [item.answer.agent] : item.kind === "always" ? [item.group.agent] : [], ) const distinct = [...new Set(agents)] const only = distinct.length === 1 ? distinct[0] : undefined @@ -529,9 +534,10 @@ const cell = (text: string, room: number) => { return `${shown}${" ".repeat(Math.max(0, room - widthOf(shown)))}` } -/** The agent's chip and two cells after it; nothing when there is one agent. */ -function chipCell(agent: string, columns: Columns): Run[] { +/** The agent's chip and two cells after it; blank for a row no agent owns; nothing when there is one agent. */ +function chipCell(agent: string | undefined, columns: Columns): Run[] { if (columns.chip === 0) return [] + if (agent === undefined) return [{ text: " ".repeat(columns.chip + 2) }] return [chip(agent), { text: " ".repeat(Math.max(0, columns.chip - widthOf(agent) - 2)) }, { text: " " }] } @@ -564,7 +570,7 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid } if (item.kind === "almost") { const { command } = item - const { stand, entry } = leadOf(command) + const { stand } = leadOf(command) const danger = command.danger !== undefined const room = Math.max(4, columns.chipAt - lead - 2) const counting = stand.kind === "counting" ? stand : undefined @@ -574,7 +580,7 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid ...start("○", danger ? "error" : "warning"), plain(cell(name, room)), { text: " " }, - ...chipCell(entry.agent, columns), + ...chipCell(undefined, columns), ...(counting ? meter(counting.have, counting.need, danger) : []), { text: " ".repeat(Math.max(0, columns.meter - (counting?.need ?? 0))) }, muted(counting ? ` ${counting.have} of ${counting.need}` : ""), diff --git a/packages/trust/src/core/view/card.ts b/packages/trust/src/core/view/card.ts index 48178c1a..2b2a217c 100644 --- a/packages/trust/src/core/view/card.ts +++ b/packages/trust/src/core/view/card.ts @@ -6,7 +6,8 @@ import { anyOf, narrower, outside, redirected, showSubject, spelledWords, widenable } from "../family.ts" import { dayOf, earned, type History, latestAnswers, type Mark } from "../history.ts" import { keyOf, type Thresholds } from "../ledger.ts" -import { NOT_COVERED, revokeLabel, widenLabel, widenScope } from "./actions.ts" +import type { Suggestion } from "../suggest.ts" +import { EXCEPT, NOT_COVERED, NOT_READ, revokeLabel, type Target, widenLabel, widenScope } from "./actions.ts" import { type AlwaysGroup, alwaysGroups, @@ -16,22 +17,11 @@ import { type Family, leadOf, type Reading, + type Stand, type Standing, stale, } from "./model.ts" -import { - agentsText, - button, - chip, - clock, - meter, - muted, - plain, - plural, - since, - when, - wrapRuns, -} from "./parts.ts" +import { button, chip, clock, meter, muted, plain, plural, since, when, wrapRuns } from "./parts.ts" import { filled, fit, type Row, type Run, rowText, squeeze, widthOf } from "./rows.ts" import { commandText, familyText, type Node, nodeTarget, SECTION_TITLES } from "./tree.ts" @@ -80,7 +70,7 @@ function tableRow(line: readonly Run[], width: number, restWidth: number): Row { function standingShort(command: Command): Run[] { const { stand } = leadOf(command) if (stand.kind === "trusted") return [{ text: "✓ trusted", tone: "success" }] - if (stand.kind === "widened") return [{ text: "✓ any", tone: "success" }] + if (stand.kind === "widened") return [{ text: stand.learned ? "✓ read" : "✓ any", tone: "success" }] if (stand.kind === "counting" && stand.expired) return [muted("expired")] if (command.phase === "once") return [muted("○ once")] return stand.kind === "counting" @@ -92,7 +82,7 @@ export interface Button { key: string label: string off: boolean - action: "revoke" | "widen" | "copy" | "activity" + action: "revoke" | "widen" | "dismiss" | "copy" | "activity" } export interface CardParts { @@ -110,6 +100,13 @@ export interface CardReading extends Reading { /** The buttons a node offers: `x`, `w` where a family can widen, `c`; today's strip, the activity. */ export function buttonsOf(node: Node, families: readonly Family[]): Button[] { if (node.kind === "today") return [{ key: "a", label: "Full activity", off: false, action: "activity" }] + if (node.kind === "suggest") { + const w = widenLabel(widenScope(nodeTarget(node) as Target, families)) + return [ + { key: "w", label: w.label, off: w.off, action: "widen" }, + { key: "d", label: "Dismiss", off: false, action: "dismiss" }, + ] + } const target = nodeTarget(node) if (!target) return [] const x = revokeLabel(target) @@ -144,8 +141,9 @@ function exactly(command: Command): Fact { } /** What still asks once a command is trusted exactly: a smaller one, and the same into a file. */ -function stillAsks(command: Command, widened: boolean): Fact { - if (widened) return { label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] } +function stillAsks(command: Command, stand: Stand): Fact { + if (stand.kind === "widened") + return { label: "Still asks", keep: 6, lines: [[muted(`${stand.learned ? NOT_READ : NOT_COVERED}.`)]] } if (command.permission === "edit") return { label: "Still asks", keep: 6, lines: [[muted("any other file")]] } if (command.permission !== "bash") @@ -229,7 +227,13 @@ function historySaid(standing: Standing, marks: readonly Mark[], reading: CardRe return [muted(`${got.streak} ${got.streak === 1 ? "approval" : "approvals"} in a row, all yours`)] } if (stand.kind === "widened") - return [muted(`answered through ${anyOf(entry.permission, stand.family)}, not by its own count`)] + return [ + muted( + stand.learned + ? `a plain read, answered through ${showSubject(entry.permission, stand.family)} reads Trust learned from your approvals` + : `answered through ${anyOf(entry.permission, stand.family)}, not by its own count`, + ), + ] if (stand.expired) return [muted(`unused over ${settings.expireDays} days, so it counts again from 0`)] const got = earned(marks, stand.need, expireMs) const left = stand.need - stand.have @@ -242,44 +246,32 @@ function historySaid(standing: Standing, marks: readonly Mark[], reading: CardRe return [muted(`${stand.have} in a row, all yours · ${left} more and Trust answers it`)] } -/** One agent's standing, on the card's panel. */ +/** The command's standing in the project, on the card's panel. */ function standingRuns(standing: Standing, danger: boolean): Run[] { const { entry, stand } = standing if (stand.kind === "trusted") return [ { text: "✓ Trusted", tone: "success", bold: true }, - muted(" for "), - chip(entry.agent), muted(entry.autos > 0 ? ` · answered ${entry.autos}×` : " · ready, not used yet"), ] if (stand.kind === "widened") return [ { text: "✓ Answered", tone: "success", bold: true }, - muted(" for "), - chip(entry.agent), - muted(` · through ${anyOf(entry.permission, stand.family)}`), - ] - if (stand.expired) - return [ - { text: "○ Expired", tone: "muted", bold: true }, - muted(" for "), - chip(entry.agent), - muted(" · counting from 0"), + muted( + stand.learned + ? ` · a read, through learned ${showSubject(entry.permission, stand.family)}` + : ` · through ${anyOf(entry.permission, stand.family)}`, + ), ] + if (stand.expired) return [{ text: "○ Expired", tone: "muted", bold: true }, muted(" · counting from 0")] return [ ...meter(stand.have, stand.need, danger), { text: ` ${stand.have} of ${stand.need}`, tone: "text", bold: true }, - muted(" for "), - chip(entry.agent), muted(stand.have <= 1 && entry.autos === 0 ? " · seen once" : ` · ${stand.need - stand.have} more to go`), ] } -function familyFact( - family: Family | undefined, - command: Command | undefined, - reading: CardReading, -): Fact | undefined { +function familyFact(family: Family | undefined, reading: CardReading): Fact | undefined { if (!family) return undefined const trusted = family.commands.filter((each) => each.phase === "answering").length const name = familyText(family) @@ -287,25 +279,23 @@ function familyFact( plain(name), muted(` · ${plural(family.commands.length, "command")}, ${trusted} trusted`), ] - const agent = command - ? leadOf(command).entry.agent - : family.commands[0] - ? leadOf(family.commands[0]).entry.agent - : undefined - const widenedFor = family.widened.filter((each) => agent === undefined || each.agent === agent) - if (widenedFor.length > 0) { - const at = Math.max(...widenedFor.map((each) => each.at)) + if (family.widened.length > 0) { + const at = Math.max(...family.widened.map((each) => each.at)) + const learned = family.widened.every((each) => each.learned) return { label: "Family", keep: 4, lines: [ head, - [ - plain(anyOf(family.permission, family.family)), - muted( - ` trusted for ${agentsText(widenedFor.map((each) => each.agent))} ${when(reading.now - at)} · [w] undoes it`, - ), - ], + learned + ? [ + plain(`${anyOf(family.permission, family.family)} read`), + muted(` learned ${when(reading.now - at)} from your approvals · [w] forgets it`), + ] + : [ + plain(anyOf(family.permission, family.family)), + muted(` trusted ${when(reading.now - at)} · [w] undoes it`), + ], ], } } @@ -319,7 +309,7 @@ function familyFact( [ muted("[w] trusts "), plain(anyOf(family.permission, family.family)), - muted(` for ${agent ?? "this agent"}${family.commands.length > 1 ? ", not one by one" : ""}`), + muted(family.commands.length > 1 ? ", not one by one" : ""), ], ], } @@ -330,21 +320,20 @@ function commandCard(command: Command, family: Family | undefined, reading: Card const lead = leadOf(command) const danger = command.danger !== undefined const expireMs = settings.expireDays * 86_400_000 - const marks = history.marks.get(keyOf(command.permission, lead.entry.agent, command.subject)) ?? [] + const marks = history.marks.get(keyOf(command.permission, command.subject)) ?? [] const need = lead.stand.kind === "counting" ? lead.stand.need : settings.threshold + (danger ? settings.dangerExtra : 0) const facts: Fact[] = [exactly(command)] - if (command.phase === "answering") facts.push(stillAsks(command, lead.stand.kind === "widened")) - const many = command.standings.length > 1 + if (command.phase === "answering") facts.push(stillAsks(command, lead.stand)) facts.push({ label: "History", keep: 7, newest: true, lines: [ marks.length > 0 ? historyRuns(marks, need, expireMs, now) : [muted("—")], - [...(many ? [chip(lead.entry.agent), { text: " " }] : []), ...historySaid(lead, marks, reading)], + [...historySaid(lead, marks, reading), muted(` · last asked by ${lead.entry.agent}`)], ], }) if (danger && command.phase !== "answering") { @@ -362,7 +351,7 @@ function commandCard(command: Command, family: Family | undefined, reading: Card }) } const why = outside(command.permission, command.subject) - if (family && why !== undefined && family.widened.some((each) => each.agent === lead.entry.agent)) + if (family && why !== undefined && family.widened.length > 0) facts.push({ label: "Widened", keep: 5, @@ -370,7 +359,7 @@ function commandCard(command: Command, family: Family | undefined, reading: Card [muted("but "), plain(anyOf(command.permission, family.family)), muted(` leaves it out: ${why}`)], ], }) - const fam = familyFact(family, command, reading) + const fam = familyFact(family, reading) if (fam) facts.push(fam) if (lead.stand.kind === "trusted" && settings.expireDays > 0) { const left = Math.max(0, Math.ceil((lead.entry.lastAt + expireMs - now) / 86_400_000)) @@ -405,17 +394,17 @@ function familyCard(family: Family, reading: CardReading): CardParts { ? family.widened.map((each) => [ { text: "Old", tone: "warning", bold: true }, plain(` ${anyOf(family.permission, family.family)}`), - muted(" for "), - chip(each.agent), muted(` · widened ${when(reading.now - each.at)} · answers nothing now`), ]) : family.widened.length > 0 ? family.widened.map((each) => [ { text: "✓ Any", tone: "success", bold: true }, plain(` ${showSubject(family.permission, family.family)} …`), - muted(" trusted for "), - chip(each.agent), - muted(` · you widened it ${when(reading.now - each.at)}`), + muted( + each.learned + ? ` reads · learned from your approvals ${when(reading.now - each.at)}` + : ` · you widened it ${when(reading.now - each.at)}`, + ), ]) : [ [ @@ -455,8 +444,12 @@ function familyCard(family: Family, reading: CardReading): CardParts { ], }) else if (family.widened.length > 0) - facts.push({ label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] }) - const fam = stale(family) ? undefined : familyFact(family, undefined, reading) + facts.push({ + label: "Still asks", + keep: 6, + lines: [[muted(`${family.widened.every((each) => each.learned) ? NOT_READ : NOT_COVERED}.`)]], + }) + const fam = stale(family) ? undefined : familyFact(family, reading) if (fam) facts.push({ ...fam, label: "Widen", lines: fam.lines.slice(1) }) return { title: [ @@ -666,6 +659,57 @@ function groupCard(node: Extract): CardParts { } } +/** A suggestion, selected: what crossed the threshold, what `w` would answer and what it never will. */ +function suggestCard(suggestion: Suggestion, reading: CardReading): CardParts { + const { family, approvals, commands } = suggestion + const parts = familyCard(family, reading) + /** What `w` would have saved today: your approvals in the family since midnight. */ + const midnight = dayOf(reading.now) + const today = family.commands + .flatMap((command) => command.standings) + .flatMap(({ entry }) => reading.history.marks.get(entry.key) ?? []) + .filter((each) => each.kind === "approved" && each.at >= midnight).length + return { + ...parts, + /** The family's own `Widen` says what `w` does in general; this card says it for the suggestion. */ + standing: [ + [ + { text: "★ Suggested", tone: "warning", bold: true }, + muted(` · ${plural(approvals, "approval")} in a row across ${plural(commands, "command")}`), + ], + ], + facts: [ + { + label: "Widen", + keep: 6, + lines: [ + [ + muted("[w] trusts "), + plain(anyOf(family.permission, family.family)), + muted(`${family.permission === "bash" ? ` — ${EXCEPT}` : ""}. [d] stops suggesting it.`), + ], + ], + }, + ...(today > 0 + ? [ + { + label: "Today", + keep: 5, + lines: [ + [ + muted( + `${plural(today, "approval")} of yours today ${today === 1 ? "was" : "were"} in it — [w] answers prompts like ${today === 1 ? "it" : "them"} from now on`, + ), + ], + ], + }, + ] + : []), + ...parts.facts.filter((fact) => fact.label !== "Widen"), + ], + } +} + export function cardOf(node: Node, reading: CardReading): CardParts { const parts = node.kind === "today" @@ -680,7 +724,9 @@ export function cardOf(node: Node, reading: CardReading): CardParts { ? commandCard(node.command, node.family, reading) : node.kind === "family" || node.kind === "more" ? familyCard(node.family, reading) - : { title: [], standing: [], facts: [], buttons: [] } + : node.kind === "suggest" + ? suggestCard(node.suggestion, reading) + : { title: [], standing: [], facts: [], buttons: [] } return { ...parts, buttons: buttonsOf(node, reading.families) } } diff --git a/packages/trust/src/core/view/explorer.ts b/packages/trust/src/core/view/explorer.ts index 1c070626..80856a05 100644 --- a/packages/trust/src/core/view/explorer.ts +++ b/packages/trust/src/core/view/explorer.ts @@ -20,10 +20,10 @@ * * **A command is one row.** The list it replaced split a family into what answers and what learns, so * `head -30` was in one section while `head` was in the other, and the details repeated. Here a family - * is one fold, like a folder in an editor, and a command one row in it whatever its agents say; the - * card lists each agent's standing. + * is one fold, like a folder in an editor, and a command one row in it; the card says where it stands + * in the project — whichever agent asked, a rule is the project's. * - * **The card is the explanation.** The command whole, on a raised panel; where each agent stands; the + * **The card is the explanation.** The command whole, on a raised panel; where it stands; the * exact text and what still asks; the approvals that earned it (history.ts); its family and what `w` * would do; and the actions as buttons you can click or reach with `tab`. * @@ -205,7 +205,7 @@ export function explorerRows(input: ExplorerInput): ExplorerView { const nothing = input.filter !== "" ? `Nothing matches "${input.filter}". esc clears the filter.` - : `Nothing learned yet. Approve the same command ${input.settings.threshold} times in a row and Trust answers it for you from then on — that exact command, for that agent. A dangerous one takes ${input.settings.threshold + input.settings.dangerExtra}.` + : `Nothing learned yet. Approve the same command ${input.settings.threshold} times in a row and Trust answers it for you from then on — that exact command, in this project, whichever agent runs it. A dangerous one takes ${input.settings.threshold + input.settings.dangerExtra}.` if (lines.length === 0) treeLines.push(fit([muted(input.filter !== "" ? " No match." : " Nothing yet.")], treeWidth)) /** Which node each tree row draws, for clicks. */ diff --git a/packages/trust/src/core/view/model.ts b/packages/trust/src/core/view/model.ts index 2c25dc0d..8bf67d8d 100644 --- a/packages/trust/src/core/view/model.ts +++ b/packages/trust/src/core/view/model.ts @@ -1,23 +1,16 @@ /** - * What both Trust screens read: every command Trust has counted, once, with where each agent stands + * What both Trust screens read: every command Trust has counted, once, with where it stands * on it; the families they group into; and the three numbers that sum a project up. * * **One command, one place.** The ledger used to split a family by section, so `head -30` was under * Answers while `head` was also under Learning and its details repeated. Here a command is one - * `Command` whatever its agents say about it — trusted for one, two of three for another — and a + * `Command`, whichever agents asked it — a rule is the project's (ledger.keyOf) — and a * family one `Family`. The screens draw each once. */ -import { familyOf, outside } from "../family.ts" -import { - type Always, - type Entry, - keyOf, - type State, - standing, - type Thresholds, - type Widened, -} from "../ledger.ts" +import { familyOf } from "../family.ts" +import { type Always, type Entry, type State, standing, type Thresholds, type Widened } from "../ledger.ts" +import { widenedFor } from "../policy.ts" export interface Reading { state: State @@ -25,22 +18,18 @@ export interface Reading { now: number } -/** Where one agent stands on one command. */ +/** Where a command stands in the project. */ export type Stand = | { kind: "trusted" } - /** Not trusted by its own count, answered through a family you widened. */ - | { kind: "widened"; family: string } + /** Not trusted by its own count, answered through a family you widened — or Trust learned. */ + | { kind: "widened"; family: string; learned?: true } | { kind: "counting"; have: number; need: number; expired: boolean } export function standOf(entry: Entry, { state, settings, now }: Reading): Stand { const where = standing(entry, entry.danger, settings, now) if (where.trusted) return { kind: "trusted" } - const family = familyOf(entry.permission, entry.subject) - if ( - state.widened.has(keyOf(entry.permission, entry.agent, family)) && - outside(entry.permission, entry.subject) === undefined - ) - return { kind: "widened", family } + const through = widenedFor(state, entry.permission, entry.subject, settings, now) + if (through) return { kind: "widened", ...through } return { kind: "counting", have: where.have, need: where.need, expired: where.expired } } @@ -50,32 +39,32 @@ export const answers = (stand: Stand): boolean => stand.kind !== "counting" export const distance = (stand: Stand): number => stand.kind !== "counting" ? 0 : stand.expired ? Number.MAX_SAFE_INTEGER : stand.need - stand.have -/** One agent's standing on a command. */ +/** A command's standing: its entry (the project's), and where that puts it. */ export interface Standing { entry: Entry stand: Stand } /** - * Where a command is, all agents together: - * - `answering`: Trust answers it for at least one agent; + * Where a command is: + * - `answering`: Trust answers it; * - `learning`: counting, with approvals that matter (two in a row, or answered before); * - `once`: approved once and never again — most never come back, so they are kept out of the way. */ export type Phase = "answering" | "learning" | "once" export interface Command { - /** `[permission, subject]`, the same for every agent. */ + /** `[permission, subject]`. */ key: string permission: string subject: string family: string - /** Every agent's standing, the one that answers first, then the closest to it. */ + /** Its standing — one, since a rule is the project's; a list so a ledger never has to say so twice. */ standings: Standing[] phase: Phase /** Today's reading of why it costs more, as of the last time it was asked. */ danger?: string - /** The last approval or answer, any agent. */ + /** The last approval or answer. */ lastAt: number } @@ -85,7 +74,7 @@ export const commandKey = (permission: string, subject: string): string => /** The standing the command is drawn by: the one that answers, else the closest. */ export const leadOf = (command: Command): Standing => command.standings[0] as Standing -/** Approvals the closest agent still needs. */ +/** Approvals still needed. */ export const closeness = (command: Command): number => distance(leadOf(command).stand) function phaseOf(standings: readonly Standing[]): Phase { @@ -147,7 +136,7 @@ export interface Family { family: string /** Its commands, `byPhase`. */ commands: Command[] - /** The agents you trusted the whole family for. */ + /** The widening on it, when you widened it or Trust learned it (at most one: it is the project's). */ widened: Widened[] lastAt: number } diff --git a/packages/trust/src/core/view/sidebar.ts b/packages/trust/src/core/view/sidebar.ts index 5d9ff335..56808341 100644 --- a/packages/trust/src/core/view/sidebar.ts +++ b/packages/trust/src/core/view/sidebar.ts @@ -52,15 +52,15 @@ export interface SidebarInput { project?: Tally } -/** Commands in the project's ledger: trusted by some agent, or on their way there. */ +/** Commands in the project's ledger: trusted, or on their way there. */ export interface Tally { trusted: number counting: number } /** - * One count per command, however many agents it was approved for — as the ledger lists them: trusted - * when any agent trusts it, counting when none does yet but one has approvals that still count. + * One count per command — as the ledger lists them: trusted when Trust answers it, counting when it + * has approvals that still count. */ export function tally(state: State, settings: Thresholds, now: number): Tally { const commands = new Map() @@ -108,7 +108,7 @@ export function labelOf(permission: string, label: string): Row { /** How often Trust answered this in all: the least of its parts, since a line is answered whole. */ function times(state: State, answered: Answered): number { const counts = answered.subjects.map( - (subject) => state.entries.get(keyOf(answered.permission, answered.agent, subject))?.autos ?? 0, + (subject) => state.entries.get(keyOf(answered.permission, subject))?.autos ?? 0, ) return Math.max(1, counts.length > 0 ? Math.min(...counts) : 1) } diff --git a/packages/trust/src/core/view/tree.ts b/packages/trust/src/core/view/tree.ts index 493a59d7..6d023808 100644 --- a/packages/trust/src/core/view/tree.ts +++ b/packages/trust/src/core/view/tree.ts @@ -1,6 +1,6 @@ /** * The ledger's tree: what Trust holds, by what was asked — commands, edits, tools and fetches — then - * by family, a command one row in its family whatever its agents say. + * by family, a command one row in its family. * * **Kinds before families.** One list ordered only by standing put `edit packages/api/…` between * `git status` and `jq`, and three edits in three folders read as three unrelated rows. Each kind @@ -17,6 +17,7 @@ import { dangerOf } from "../danger.ts" import { readSubject, shown, showSubject } from "../family.ts" +import { type Suggestion, suggestionsOf } from "../suggest.ts" import type { Target } from "./actions.ts" import { type AlwaysGroup, @@ -34,10 +35,11 @@ import { import { badge, meter, muted, plain, plural } from "./parts.ts" import { cursorRow, fit, type Row, type Run, rowText, spread, squeeze, type Tone, widthOf } from "./rows.ts" -/** The kinds a ledger is grouped by, in the order they are listed. */ -export type Section = "commands" | "edits" | "tools" +/** The kinds a ledger is grouped by, in the order they are listed; suggestions above them all. */ +export type Section = "suggested" | "commands" | "edits" | "tools" export const SECTIONS: readonly Section[] = ["commands", "edits", "tools"] export const SECTION_TITLES: Record = { + suggested: "SUGGESTED", commands: "COMMANDS", edits: "EDITS", tools: "TOOLS & FETCHES", @@ -48,6 +50,8 @@ export const sectionOf = (permission: string): Section => export type Node = /** Today's answers, the strip above the tree: selected, the card lists them. */ | { kind: "today"; key: string; answers: number } + /** A family Trust suggests widening (suggest.ts): `w` widens it, `d` dismisses it. */ + | { kind: "suggest"; key: string; suggestion: Suggestion } /** `depth`: folders above it; `prefix`: the words they already say, left out of its own label. */ | { kind: "family"; key: string; family: Family; open: boolean; depth: number; prefix: string } | { @@ -119,8 +123,11 @@ const seenOnce = (family: Family) => export const commandText = (command: Command) => `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` +/** A family as its heading says it: `head`, `read src/`, and a whole tool (`*`) as its name alone — `grep`. */ export const familyText = (family: Family) => - `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}` + family.family === "*" + ? family.permission + : `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}` /** A command as its row says it: under Edits the path alone, the heading says what kind it is. */ const rowCommandText = (command: Command) => @@ -231,6 +238,16 @@ export function explorerModel(input: Reading & Tree & { today?: number }): Explo return out } + if (needle === "") { + const suggested = suggestionsOf(input, families) + if (suggested.length > 0) lines.push({ kind: "heading", section: "suggested" }) + for (const suggestion of suggested) { + const node: Node = { kind: "suggest", key: `s:${suggestion.key}`, suggestion } + nodes.push(node) + lines.push({ kind: "node", node }) + } + } + for (const section of SECTIONS) { const mine: Node[] = [] const once: Family[] = [] @@ -287,6 +304,7 @@ export function explorerModel(input: Reading & Tree & { today?: number }): Explo export function nodeTarget(node: Node): Target | undefined { if (node.kind === "today" || node.kind === "once" || node.kind === "group") return undefined if (node.kind === "always") return { kind: "always", groups: node.groups } + if (node.kind === "suggest") return { kind: "family", family: node.suggestion.family, suggested: true } if (node.kind === "command") return { kind: "command", command: node.command, family: node.family } return { kind: "family", family: node.family } } @@ -343,13 +361,15 @@ function statusRuns(node: Node): Run[] { tone: "warning", }, ] + if (node.kind === "suggest") + return [{ text: `${node.suggestion.approvals} in ${node.suggestion.commands}`, tone: "warning" }] if (node.kind === "once") return tallyRuns(node.families.flatMap((family) => family.commands)) if (node.kind === "group") return tallyRuns(node.families.flatMap((family) => family.commands)) if (node.kind === "family") return tallyRuns(node.family.commands) const { command } = node const { stand } = leadOf(command) if (stand.kind === "trusted") return [{ text: "✓ trusted", tone: "success" }] - if (stand.kind === "widened") return [{ text: "✓ any", tone: "success" }] + if (stand.kind === "widened") return [{ text: stand.learned ? "✓ read" : "✓ any", tone: "success" }] if (stand.expired) return [muted("expired")] if (command.phase === "once") return [muted("○ once")] return [ @@ -378,7 +398,10 @@ export function treeRow( const any = stale(node.family) ? [{ text: " " }, badge("old", "warning")] : node.family.widened.length > 0 - ? [{ text: " " }, badge("any", "success")] + ? [ + { text: " " }, + badge(node.family.widened.every((each) => each.learned) ? "reads" : "any", "success"), + ] : [] const risk = familyRisk(node.family) const marks = [...any, ...(risk ? [{ text: " " }, badge(risk, "error")] : [])] @@ -423,7 +446,13 @@ export function treeRow( ] } else if (node.kind === "more") left = [muted(` ${indent}+ ${node.hidden} more`)] else if (node.kind === "always") left = [{ text: " ! ", tone: "warning" }, plain("OpenCode always")] - else left = [] + else if (node.kind === "suggest") { + left = [ + { text: " ★ ", tone: "warning" }, + plain(squeeze(`any ${rowFamilyText(node.suggestion.family)}`, Math.max(4, room - 4))), + muted("?"), + ] + } else left = [] const status = statusRuns(node) const danger = node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering" diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index 426013c1..66b29184 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -18,6 +18,7 @@ import { createEngine } from "../core/engine.ts" import type { Event } from "../core/ledger.ts" import { trustPaths } from "../core/paths.ts" import { createJournal } from "./journal.ts" +import { maskHasher } from "./key.ts" import { createLedger } from "./ledger.tsx" import { closedDialog, createPainter, type Live } from "./paint.ts" import { createRequests } from "./requests.ts" @@ -98,7 +99,8 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = }) } - const requests = createRequests({ api, log, engine, directory, live, write, draw }) + const hash = maskHasher(paths, log) + const requests = createRequests({ api, log, engine, directory, hash, live, write, draw }) const ledger = createLedger({ api, log, diff --git a/packages/trust/src/tui/key.ts b/packages/trust/src/tui/key.ts new file mode 100644 index 00000000..1b7712ec --- /dev/null +++ b/packages/trust/src/tui/key.ts @@ -0,0 +1,55 @@ +/** + * The key a project's secrets are hashed with in signatures (core/secret.ts). + * + * Kept beside the ledger as `mask.key`, readable by you alone, and never written into the ledger: a + * hash under a key no one else has cannot be checked against guesses, so a short password in a + * masked command stays unknown even to someone holding the ledger. Every window of a project reads + * the same key, so one command is one signature everywhere. + * + * Read once, synchronously, when Trust starts: a few bytes, before the first request can be asked. + * If it cannot be read or made, this window hashes with a key of its own and says so — its masks + * then match no other window's, which costs approvals and never shows a secret. + */ + +import { createHmac, randomBytes } from "node:crypto" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import type { Log } from "@opencode-cockpit/client/log" +import type { TrustPaths } from "../core/paths.ts" +import type { Hasher } from "../core/secret.ts" + +const KEY = /^[0-9a-f]{64}$/ + +function loadKey(paths: TrustPaths): string { + try { + const found = readFileSync(paths.key, "utf8").trim() + if (KEY.test(found)) return found + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + const made = randomBytes(32).toString("hex") + mkdirSync(paths.dir, { recursive: true }) + try { + /** `wx`: two windows starting at once — the one that loses reads the winner's key. */ + writeFileSync(paths.key, `${made}\n`, { flag: "wx", mode: 0o600 }) + return made + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error + const theirs = readFileSync(paths.key, "utf8").trim() + if (KEY.test(theirs)) return theirs + throw new Error("mask.key is not a key") + } +} + +export const keyedHash = + (key: string): Hasher => + (value) => + createHmac("sha256", key).update(value).digest("hex").slice(0, 6) + +export function maskHasher(paths: TrustPaths, log: Log): Hasher { + try { + return keyedHash(loadKey(paths)) + } catch (error) { + log.warn("mask key unavailable — secrets hashed with this window's own key", { file: paths.key, error }) + return keyedHash(randomBytes(32).toString("hex")) + } +} diff --git a/packages/trust/src/tui/ledger.tsx b/packages/trust/src/tui/ledger.tsx index 69e671ff..505c2349 100644 --- a/packages/trust/src/tui/ledger.tsx +++ b/packages/trust/src/tui/ledger.tsx @@ -10,7 +10,15 @@ import type { Host, Layer } from "@opencode-cockpit/client/host" import type { Log } from "@opencode-cockpit/client/log" import type { Engine } from "../core/engine.ts" import type { Event } from "../core/ledger.ts" -import { configSnippet, type Outcome, revoke, type Target, widen, widenScope } from "../core/view/actions.ts" +import { + configSnippet, + dismiss, + type Outcome, + revoke, + type Target, + widen, + widenScope, +} from "../core/view/actions.ts" import { targetOf } from "../core/view/activity.ts" import type { Family } from "../core/view/model.ts" import type { Hit } from "../core/view/parts.ts" @@ -65,7 +73,7 @@ export function createLedger(input: { events: outcome.events.map((event) => event.type === "revoked" ? { type: event.type, agent: event.agent, subject: event.subject } - : event.type === "widened" || event.type === "unwidened" + : event.type === "widened" || event.type === "unwidened" || event.type === "dismissed" ? { type: event.type, agent: event.agent, family: event.family } : { type: event.type }, ), @@ -80,7 +88,11 @@ export function createLedger(input: { const widenSelected = () => { const now = selected() - if (now) act("widen", widen(widenScope(now.target, now.families), now.families, Date.now())) + if (now) act("widen", widen(widenScope(now.target, now.families), Date.now())) + } + + const dismissSelected = () => { + act("dismissed", dismiss(selected()?.target, Date.now())) } const copy = () => { @@ -165,7 +177,7 @@ export function createLedger(input: { */ const fold = (way: "toggle" | "open" | "close" = "toggle") => { const at = node() - if (!at || at.kind === "always" || at.kind === "today") return + if (!at || at.kind === "always" || at.kind === "today" || at.kind === "suggest") return dialog.notice = undefined /** A kind's `seen once` row and a folder open and fold like a family, by their own key. */ if (at.kind === "once" || at.kind === "group") { @@ -197,6 +209,7 @@ export function createLedger(input: { const press = (action: string) => { if (action === "revoke") revokeSelected() else if (action === "widen") widenSelected() + else if (action === "dismiss") dismissSelected() else if (action === "copy") copy() else if (action === "ledger") toLedger() else if (action === "activity") toActivity() @@ -211,7 +224,7 @@ export function createLedger(input: { } const at = node() if (at?.kind === "today") return toActivity() - if (at?.kind === "command" || at?.kind === "always") { + if (at?.kind === "command" || at?.kind === "always" || at?.kind === "suggest") { if (buttons().length > 0) dialog.button = 0 return draw() } @@ -311,6 +324,7 @@ export function createLedger(input: { title: "Trust the whole family, or undo it", run: listed(() => widenSelected()), }, + { name: "cockpit.trust.dismiss", title: "Dismiss a suggestion", run: listed(() => dismissSelected()) }, { name: "cockpit.trust.copy", title: "Copy as config", run: listed(() => copy()) }, { name: "cockpit.trust.togglePause", title: "Pause or resume", run: listed(() => togglePause()) }, { name: "cockpit.trust.close", title: "Close", run: () => api.ui.dialog.clear() }, @@ -329,6 +343,7 @@ export function createLedger(input: { { key: "?,shift+/", cmd: "cockpit.trust.keys" }, { key: "x", cmd: "cockpit.trust.revoke" }, { key: "w", cmd: "cockpit.trust.widen" }, + { key: "d", cmd: "cockpit.trust.dismiss" }, { key: "c", cmd: "cockpit.trust.copy" }, { key: "p", cmd: "cockpit.trust.togglePause" }, { key: "q", cmd: "cockpit.trust.close" }, diff --git a/packages/trust/src/tui/requests.ts b/packages/trust/src/tui/requests.ts index 99fc671c..465c1ff5 100644 --- a/packages/trust/src/tui/requests.ts +++ b/packages/trust/src/tui/requests.ts @@ -11,6 +11,7 @@ import type { Engine } from "../core/engine.ts" import type { Request } from "../core/keys.ts" import type { Event } from "../core/ledger.ts" import { rulesFrom } from "../core/rules.ts" +import type { Hasher } from "../core/secret.ts" import type { Live } from "./paint.ts" import { createSource } from "./source.ts" @@ -34,11 +35,13 @@ export function createRequests(input: { log: Log engine: Engine directory: string + /** Secrets in a signature are hashed with this project's key (key.ts). */ + hash: Hasher live: Live write: (events: readonly Event[]) => void draw: () => void }): Requests { - const { api, log, engine, directory, live, write, draw } = input + const { api, log, engine, directory, hash, live, write, draw } = input /** OpenCode's config as its `config.get` returned it; undefined until read, and Trust stays out. */ let opencodeConfig: unknown let rulesReady = false @@ -106,7 +109,7 @@ export function createRequests(input: { agents.get(request.sessionID) ?? feed.agent(request.sessionID, request.messageID) ?? "unknown" const { judgement, event } = engine.ask({ request, - context: { ...call, root: directory }, + context: { ...call, root: directory, hash }, agent, rules: rulesFrom(opencodeConfig, agent), at, diff --git a/packages/trust/test/effect.test.ts b/packages/trust/test/effect.test.ts new file mode 100644 index 00000000..b4407ec7 --- /dev/null +++ b/packages/trust/test/effect.test.ts @@ -0,0 +1,218 @@ +/** + * What a command does, read from its words: the flags that make a program write or run another (#44), + * and the allowlist of plain reads Trust may learn as a family — failing closed. + */ +import { describe, expect, test } from "bun:test" +import { notRead, runsByFlag, sensitive, writesByFlag } from "../src/core/effect.ts" +import { notReadSubject } from "../src/core/family.ts" +import { type Command, parse } from "../src/core/shell.ts" +import { signature } from "../src/core/signature.ts" + +function one(line: string): Command { + const parsed = parse(line) + if (parsed.kind !== "commands" || parsed.commands.length !== 1) throw new Error(`not one command: ${line}`) + return parsed.commands[0] as Command +} +const argv = (line: string) => one(line).argv +/** Why it is not a plain read, redirections included, as Trust reads a stored subject. */ +const why = (line: string) => notReadSubject("bash", signature(one(line), "/work/app")) +const isRead = (line: string) => why(line) === undefined + +describe("flags that write a file (#44)", () => { + for (const line of [ + "sed -i s/a/b/ f", + "sed -i.bak s/a/b/ f", + "sed --in-place s/a/b/ f", + "sed -ni s/a/b/p f", + "perl -pi -e s/a/b/ f", + "awk -i inplace '{print}' f", + "sort -o out.txt in.txt", + "sort -uo out.txt in.txt", + "tee out.txt", + "uniq in.txt out.txt", + "curl -o page.html https://x.test", + "curl -O https://x.test/a.tgz", + "wget https://x.test/a.tgz", + "find . -name '*.tmp' -delete", + "find . -fprint list.txt", + "tar xf a.tar", + "unzip a.zip", + ]) + test(line, () => expect(writesByFlag(argv(line))).toBeDefined()) + + for (const line of [ + "sed -n 1,5p f", + "sort -rn f", + "tee /dev/null", + "tee", + "uniq -c in.txt", + "curl https://x.test", + "wget -O - https://x.test", + "find . -name '*.ts'", + "tar tf a.tar", + "unzip -l a.zip", + ]) + test(`${line} — writes nothing`, () => expect(writesByFlag(argv(line))).toBeUndefined()) + + test("under a wrapper, the program is what is read: timeout 5 sed -i …", () => + expect(writesByFlag(argv("timeout 5 sed -i s/a/b/ f"))).toBeDefined()) + test("after `--` a word is an operand, `-i` included", () => + expect(writesByFlag(argv("sed -n p -- -i"))).toBeUndefined()) +}) + +describe("flags that run another program", () => { + for (const line of [ + "rg --pre cat x", + "rg --pre=./decode.sh x", + "fd -x rm", + "fd --exec rm", + "sort --compress-program=gzip f", + "git diff --ext-diff", + ]) + test(line, () => expect(runsByFlag(argv(line))).toBeDefined()) + test("rg -n x — runs nothing", () => expect(runsByFlag(argv("rg -n x src"))).toBeUndefined()) +}) + +describe("the read allowlist: everything that is not a plain read is said why", () => { + for (const line of [ + "ls -la", + "cat package.json", + "head -30 src/app.ts", + "tail -f log.txt", + "wc -l src/a.ts", + "rg -n createServer src", + "grep -R foo src", + "echo ---", + "jq .name package.json", + "sort -rn counts.txt", + "fd -e ts", + "eza -la", + "strings bin/app", + "git status --short", + "git log --oneline -5", + "git -C web status --short", + "ls 2>/dev/null", + ]) + test(`${line} is a read`, () => expect(why(line)).toBeUndefined()) + + test("an env var in front is not: LD_PRELOAD can load code", () => + expect(why("FOO=1 head a")).toBe("it sets an environment variable")) + for (const line of ["sudo head a", "timeout 5 head a", "xargs head"]) + test(`${line}: under a wrapper`, () => expect(why(line)).toBe("it runs under a wrapper")) + test("a program the table does not know", () => + expect(why("python3 x.py")).toBe("python3 is not a known read")) + test("awk, find and less are kept out on purpose", () => { + for (const line of ["awk '{print}' f", "find . -name x", "less f", "xargs"]) + expect(isRead(line)).toBe(false) + }) + test("a redirection that writes", () => expect(why("head a > out.txt")).toBe("it writes to a file")) + test("a flag that writes, on a program that reads", () => expect(why("sort -o out f")).toContain("-o")) + test("dangerous is never a read", () => expect(isRead("cat /dev/sda > disk.img")).toBe(false)) + for (const file of [ + ".env", + ".env.local", + "id_rsa", + "~/.ssh/config", + "~/.aws/credentials", + "certs/server.pem", + ]) + test(`cat ${file}: a secret file`, () => expect(why(`cat ${file}`)).toContain("may hold secrets")) +}) + +describe("sensitive files", () => { + for (const word of [ + ".env", + "app/.env.production", + "id_ed25519", + ".ssh/known_hosts", + "x.key", + "creds/credentials.json", + ]) + test(`${word} is sensitive`, () => expect(sensitive(word)).toBe(true)) + for (const word of ["README.md", "src/env.ts", "environment.md", "keyboard.ts", "src/app.ts"]) + test(`${word} is not`, () => expect(sensitive(word)).toBe(false)) +}) + +describe("sed: a read only when its script only prints", () => { + for (const line of [ + "sed -n 1,50p a.ts", + "sed -n '10,20p;30p' a", + "sed s/foo/bar/g a", + "sed -n /^export/p a", + "sed -E 's/(a)/b/g' a", + "sed -n '$=' a", + "sed -n '/start/,/end/p' a", + "sed 1d a", + "sed -e 1p -e 5p a", + "sed -n 5,+3p a", + "sed -ne 2p a", + "sed -ne2p a", + ]) + test(`${line} is a read`, () => expect(why(line)).toBeUndefined()) + + for (const line of [ + "sed -i s/a/b/ a", + "sed s/a/b/w out a", + "sed '1e date' a", + "sed -f script.sed a", + "sed 's|a|b|' a", + "sed w out a", + "sed -n '1,3p;w out' a", + "sed -nf x a", + ]) + test(`${line} is not`, () => expect(why(line)).toBeDefined()) +}) + +describe("git: what only looks, and the reading form of what sometimes writes", () => { + for (const line of [ + "git branch --show-current", + "git branch -r --contains abc", + "git branch --list", + "git branch --list 'feat*'", + "git stash list", + "git worktree list", + "git reflog -15", + "git remote -v", + "git config --get user.name", + "git config --list", + "git tag", + "git check-ignore -v x", + "git ls-remote --heads origin", + ]) + test(`${line} is a read`, () => expect(why(line)).toBeUndefined()) + + for (const line of [ + "git branch feature", + "git branch -D x", + "git branch -D x --list", + "git branch -m a b", + "git stash", + "git stash pop", + "git worktree add x", + "git reflog expire", + "git remote add o u", + "git config user.name x", + "git config --get --unset x", + "git tag v1", + "git fetch", + "git push", + "git log --output=x", + ]) + test(`${line} is not`, () => expect(why(line)).toBeDefined()) + + /** + * Git's globals that run whatever their value names: `git --exec-path=/tmp status` is in family + * `git status`, so a learned `git status` must not answer it. + */ + for (const line of [ + "git -c core.pager=less log", + "git --exec-path=/tmp status", + "git --config-env=core.pager=X log", + ]) + test(`${line} is not (runs a program through a git global)`, () => expect(why(line)).toBeDefined()) +}) + +test("notRead takes the redirect writes it is given", () => { + expect(notRead(one("head a"), [])).toBeUndefined() + expect(notRead(one("head a"), ["out.txt"])).toBe("it writes to a file") +}) diff --git a/packages/trust/test/engine.test.ts b/packages/trust/test/engine.test.ts index d097f364..d440aac1 100644 --- a/packages/trust/test/engine.test.ts +++ b/packages/trust/test/engine.test.ts @@ -158,10 +158,11 @@ describe("what is the same thing", () => { ]) }) - test("the agent is part of it", () => { + test("the agent is not: trust is the project's, whichever agent asked", () => { const w = world() - w.approve("git status", 3, { agent: "build" }) - expect(w.ask("git status", { agent: "general" }).judgement.answer).toBe(false) + w.approve("git status", 2, { agent: "build" }) + w.approve("git status", 1, { agent: "general" }) + expect(w.ask("git status", { agent: "explore" }).judgement.answer).toBe(true) expect(w.ask("git status", { agent: "build" }).judgement.answer).toBe(true) }) diff --git a/packages/trust/test/history.test.ts b/packages/trust/test/history.test.ts index 5cd3289d..d0239894 100644 --- a/packages/trust/test/history.test.ts +++ b/packages/trust/test/history.test.ts @@ -25,7 +25,7 @@ const auto = (at: number, subject = "ls"): Event => ({ ...about(subject), }) const marksOf = (engine: ReturnType, subject = "ls") => - engine.history.marks.get(JSON.stringify(["bash", "build", subject])) ?? [] + engine.history.marks.get(JSON.stringify(["bash", subject])) ?? [] describe("history: when things happened, beside the state", () => { test("a rule's moments, a run of answers as one with a count", () => { diff --git a/packages/trust/test/learn.test.ts b/packages/trust/test/learn.test.ts new file mode 100644 index 00000000..3da897fd --- /dev/null +++ b/packages/trust/test/learn.test.ts @@ -0,0 +1,250 @@ +/** + * Families of reads Trust learns by itself (0.11): `threshold` approved reads in a row in one family, + * any file, and the family answers plain reads — never a write, a secret file, an env var or a wrapper. + * Plus #44: a family a person widened no longer covers the flags that make a program write. + */ +import { describe, expect, test } from "bun:test" +import { createEngine } from "../src/core/engine.ts" +import { covers, familyOf, outside } from "../src/core/family.ts" +import type { Request } from "../src/core/keys.ts" +import { applyAll, DAY, type Event, emptyState, keyOf, live, type State } from "../src/core/ledger.ts" +import { decide } from "../src/core/policy.ts" +import { rulesFrom } from "../src/core/rules.ts" +import { parse } from "../src/core/shell.ts" +import { signature } from "../src/core/signature.ts" + +const ROOT = "/work/app" +const SETTINGS = { threshold: 3, dangerExtra: 5, expireDays: 30 } +const FOLD = { expireMs: 30 * DAY, threshold: 3 } +const OPEN = rulesFrom({ permission: { bash: "ask" } }) + +/** A line's subjects, as Trust stores them. */ +const subjects = (line: string): string[] => { + const parsed = parse(line) + if (parsed.kind !== "commands") throw new Error(`opaque: ${line}`) + return parsed.commands.map((command) => signature(command, ROOT)) +} +const sub = (line: string) => subjects(line)[0] as string + +let n = 0 +function ev( + type: "approved" | "rejected" | "auto", + line: string, + at: number, + more: { agent?: string; danger?: string; via?: string } = {}, +): Event { + n++ + return { + v: 1, + at, + type, + request: `per_${n}`, + session: "ses_1", + permission: "bash", + agent: more.agent ?? "build", + items: subjects(line).map((subject) => ({ + subject, + ...(more.danger ? { danger: more.danger } : {}), + ...(more.via ? { via: more.via } : {}), + })), + ...(type === "auto" ? { rule: "trusted" } : {}), + } as Event +} +const approve = (line: string, at: number, more: { agent?: string; danger?: string } = {}) => + ev("approved", line, at, more) + +const learned = (state: State, family: string) => state.widened.get(keyOf("bash", family)) +const streakOf = (state: State, family: string) => state.reads.get(keyOf("bash", family))?.streak ?? 0 + +const THREE_HEADS = [approve("head -3 a.ts", 1), approve("head -3 b.ts", 2), approve("head -40 c.md", 3)] + +describe("learning a family of reads in the ledger", () => { + test("three approved reads of three files teach the family", () => { + const state = applyAll(emptyState(), THREE_HEADS, FOLD) + expect(learned(state, "head")).toMatchObject({ learned: true, family: "head", agent: "build", at: 3 }) + }) + + test("without a threshold in the fold nothing is learned (learnReads: false)", () => { + const state = applyAll(emptyState(), THREE_HEADS, { expireMs: 30 * DAY }) + expect(learned(state, "head")).toBeUndefined() + expect(state.reads.size).toBe(0) + }) + + test("two of a family in one request count once", () => { + const state = applyAll(emptyState(), [approve("head a | head b", 1)], FOLD) + expect(streakOf(state, "head")).toBe(1) + }) + + test("a read counts even after a part of its family that is not one: head .env | head a", () => { + const state = applyAll(emptyState(), [approve("head .env | head a", 1)], FOLD) + expect(streakOf(state, "head")).toBe(1) + }) + + test("what is not a read never counts: a secret file, a redirection, an env var", () => { + const state = applyAll( + emptyState(), + [approve("head .env", 1), approve("head a > out.txt", 2), approve("FOO=1 head a", 3)], + FOLD, + ) + expect(streakOf(state, "head")).toBe(0) + expect(learned(state, "head")).toBeUndefined() + }) + + test("a dangerous item never counts", () => { + const state = applyAll(emptyState(), [approve("head a", 1, { danger: "production" })], FOLD) + expect(streakOf(state, "head")).toBe(0) + }) + + test("a reject of a read in the family takes the learned family away and starts over", () => { + const state = applyAll(emptyState(), [...THREE_HEADS, ev("rejected", "head -5 d.ts", 4)], FOLD) + expect(learned(state, "head")).toBeUndefined() + expect(streakOf(state, "head")).toBe(0) + }) + + test("a reject of something it never covers leaves it: head .env", () => { + const state = applyAll(emptyState(), [...THREE_HEADS, ev("rejected", "head .env", 4)], FOLD) + expect(learned(state, "head")?.learned).toBe(true) + expect(streakOf(state, "head")).toBe(3) + }) + + test("`w` on a learned family forgets it, and it is never suggested again", () => { + const state = applyAll( + emptyState(), + [ + ...THREE_HEADS, + { v: 1, at: 4, type: "unwidened", permission: "bash", agent: "build", family: "head" }, + ], + FOLD, + ) + expect(learned(state, "head")).toBeUndefined() + expect(streakOf(state, "head")).toBe(0) + expect(state.dismissed.has(keyOf("bash", "head"))).toBe(true) + }) + + test("unused past expiry, a learned family is gone and the next read starts again from one", () => { + const later = 3 + 31 * DAY + const state = applyAll(emptyState(), [...THREE_HEADS, approve("head -9 e.ts", later)], FOLD) + expect(learned(state, "head")).toBeUndefined() + expect(streakOf(state, "head")).toBe(1) + }) + + test("an answer through the learned family keeps it alive", () => { + const at = 3 + 20 * DAY + const state = applyAll( + emptyState(), + [...THREE_HEADS, ev("auto", "head -1 x.ts", at, { via: "head" })], + FOLD, + ) + expect(learned(state, "head")?.lastAt).toBe(at) + }) + + test("a family a person widened is never replaced by a learned one", () => { + const widened: Event = { + v: 1, + at: 0, + type: "widened", + permission: "bash", + agent: "build", + family: "head", + } + const state = applyAll(emptyState(), [widened, ...THREE_HEADS], FOLD) + expect(learned(state, "head")?.learned).toBeUndefined() + }) + + test("learning is the project's: reads approved while different agents ran add up", () => { + const state = applyAll( + emptyState(), + [approve("head a", 1), approve("head b", 2, { agent: "general" }), approve("head c", 3)], + FOLD, + ) + expect(streakOf(state, "head")).toBe(3) + expect(learned(state, "head")?.learned).toBe(true) + }) + + test("live: a person's widening always, a learned one until unused past expireDays", () => { + const state = applyAll(emptyState(), THREE_HEADS, FOLD) + const found = learned(state, "head") + if (!found) throw new Error("not learned") + expect(live(found, SETTINGS, 3 + 29 * DAY)).toBe(true) + expect(live(found, SETTINGS, 3 + 31 * DAY)).toBe(false) + expect(live(found, { ...SETTINGS, expireDays: 0 }, 3 + 365 * DAY)).toBe(true) + expect(live({ ...found, learned: undefined } as never, SETTINGS, 3 + 365 * DAY)).toBe(true) + }) +}) + +let r = 0 +function ask(line: string, events: readonly Event[], options: { agent?: string; now?: number } = {}) { + r++ + const request: Request = { + id: `ask_${r}`, + sessionID: "ses_1", + permission: "bash", + patterns: [line], + always: [], + } + return decide({ + request, + context: { line, root: ROOT }, + agent: options.agent ?? "build", + rules: OPEN, + state: applyAll(emptyState(), events, FOLD), + settings: SETTINGS, + now: options.now ?? 10, + }) +} + +describe("answering through a learned family", () => { + test("a plain read of a file never seen is answered, and says it was learned", () => { + const judgement = ask("head -20 src/new.ts", THREE_HEADS) + expect(judgement.answer).toBe(true) + expect(judgement.why).toContain("learned") + expect(judgement.items).toEqual([{ subject: "head -20 src/new.ts", via: "head", learned: true }]) + }) + + for (const line of ["head .env", "head a > out.txt", "FOO=1 head a", "sudo head a"]) + test(`${line} still asks`, () => expect(ask(line, THREE_HEADS).answer).toBe(false)) + + test("every part is checked: head -c 5 x | head .env asks", () => { + const judgement = ask("head -c 5 x | head .env", THREE_HEADS) + expect(judgement.answer).toBe(false) + expect(judgement.progress.map((each) => each.trusted)).toEqual([true, false]) + }) + + test("another agent is answered too: it is the project's", () => + expect(ask("head x", THREE_HEADS, { agent: "general" }).answer).toBe(true)) + + test("unused past expireDays it stops answering", () => + expect(ask("head x", THREE_HEADS, { now: 3 + 31 * DAY }).answer).toBe(false)) + + test("an engine with learnReads: false learns nothing", () => { + const engine = createEngine({ ...SETTINGS, learnReads: false }) + engine.load(THREE_HEADS) + expect(engine.state.widened.size).toBe(0) + const on = createEngine(SETTINGS) + on.load(THREE_HEADS) + expect(on.state.widened.size).toBe(1) + }) +}) + +describe("#44: a widened family does not cover the flags that make it write", () => { + test("a widened sed does not cover sed -i", () => { + expect(familyOf("bash", sub("sed -i s/a/b/ f.ts"))).toBe("sed") + expect(outside("bash", sub("sed -i s/a/b/ f.ts"))).toContain("writes to a file") + expect(covers("bash", "sed", sub("sed -i s/a/b/ f.ts"))).toBe(false) + expect(covers("bash", "sed", sub("sed -n 1,5p f.ts"))).toBe(true) + }) + + test("a widened sort does not cover sort -o", () => { + expect(covers("bash", "sort", sub("sort -o out.txt in.txt"))).toBe(false) + expect(covers("bash", "sort", sub("sort -rn in.txt"))).toBe(true) + }) + + test("a widened rg does not cover rg --pre", () => + expect(outside("bash", sub("rg --pre ./x.sh foo"))).toBe("it runs another program")) + + test("decide: widened sed answers sed -n, asks for sed -i", () => { + const widened: Event = { v: 1, at: 0, type: "widened", permission: "bash", agent: "build", family: "sed" } + expect(ask("sed -n 1,5p f.ts", [widened]).answer).toBe(true) + expect(ask("sed -i s/a/b/ f.ts", [widened]).answer).toBe(false) + }) +}) diff --git a/packages/trust/test/ledger.test.ts b/packages/trust/test/ledger.test.ts index 333a35e8..c2114160 100644 --- a/packages/trust/test/ledger.test.ts +++ b/packages/trust/test/ledger.test.ts @@ -35,8 +35,8 @@ function ev( } as Event } -const entryOf = (state: ReturnType, subject: string, agent = "build") => - state.entries.get(keyOf("bash", agent, subject)) +const entryOf = (state: ReturnType, subject: string) => + state.entries.get(keyOf("bash", subject)) describe("the fold", () => { test("approvals in a row add up; a reject resets", () => { @@ -99,10 +99,14 @@ describe("the fold", () => { expect(state.paused).toBe(false) }) - test("the agent is part of the key", () => { - const state = applyAll(emptyState(), [ev("approved", "ls", 1, { agent: "general" })], FOLD) - expect(entryOf(state, "ls", "build")).toBeUndefined() - expect(entryOf(state, "ls", "general")?.streak).toBe(1) + test("the agent is not part of the key: approvals by any agent add up, the last one is kept", () => { + const state = applyAll( + emptyState(), + [ev("approved", "ls", 1, { agent: "general" }), ev("approved", "ls", 2, { agent: "build" })], + FOLD, + ) + expect(entryOf(state, "ls")?.streak).toBe(2) + expect(entryOf(state, "ls")?.agent).toBe("build") }) test("OpenCode's own always is kept to show", () => { diff --git a/packages/trust/test/secret.test.ts b/packages/trust/test/secret.test.ts new file mode 100644 index 00000000..52d5dd77 --- /dev/null +++ b/packages/trust/test/secret.test.ts @@ -0,0 +1,158 @@ +/** + * Secrets out of the ledger (0.11): a secret's value is a keyed hash in every signature, while what + * Trust reads to tell commands apart — an environment word, a short plain value — stays as it is. + */ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, statSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { Log } from "@opencode-cockpit/client/log" +import { dangerOf } from "../src/core/danger.ts" +import { createEngine } from "../src/core/engine.ts" +import { familyOf, readSubject } from "../src/core/family.ts" +import { trustPaths } from "../src/core/paths.ts" +import { rulesFrom } from "../src/core/rules.ts" +import { type Hasher, maskPattern, plainHash } from "../src/core/secret.ts" +import { parse } from "../src/core/shell.ts" +import { signature } from "../src/core/signature.ts" +import { keyedHash, maskHasher } from "../src/tui/key.ts" + +/** + * Made-up tokens, put together at run time so no token-shaped literal sits in the repository for a + * secret scanner to stop a push over. + */ +const AWS = ["AKIA", "ABCDEFGHIJKLMNOP"].join("") +const GITHUB = ["ghp", "_abcdefghijklmnopqrstuvwxyz0123"].join("") +const SHOPIFY = ["shpat", "_0123456789abcdef0123456789abcdef"].join("") +const OPENAI = ["sk-proj", "-abcdefghijklmnopqrstuv"].join("") + +const ROOT = "/work/app" +const sig = (line: string, hash: Hasher = plainHash) => { + const parsed = parse(line) + if (parsed.kind !== "commands" || parsed.commands.length !== 1) throw new Error(`not one command: ${line}`) + return signature(parsed.commands[0] as never, ROOT, hash) +} +const MASK = /‹#[0-9a-f]{6}( [a-z ]+)?›/ + +describe("what is masked", () => { + const cases: [string, string][] = [ + [`SHOPIFY_ADMIN_API_ACCESS_TOKEN=${SHOPIFY} bun run sync`, "shpat_"], + ["TOKEN=hunter2 ./deploy.sh", "hunter2"], + ['curl -H "Authorization: Bearer abcdef1234567890xyz" https://api.example.com', "abcdef1234567890xyz"], + [`gh api --token ${GITHUB} repos`, "ghp_"], + ["mysql --password=hunter2 -u root", "hunter2"], + ["mysql --password hunter2 -u root", "hunter2"], + [`export OPENAI_API_KEY=${OPENAI}`, "sk-proj"], + [`echo ${AWS}`, "AKIA"], + ["psql postgres://app:s3cretPw@db/app", "s3cretPw"], + ] + for (const [line, secret] of cases) + test(line.slice(0, 60), () => { + const subject = sig(line) + expect(subject).not.toContain(secret) + expect(subject).toMatch(MASK) + }) + + test("a URL keeps everything but its password", () => + expect(sig("psql postgres://app:s3cretPw@db/app")).toMatch( + /^psql 'postgres:\/\/app:‹#[0-9a-f]{6}›@db\/app'$/, + )) +}) + +describe("what stays readable", () => { + for (const line of [ + "NODE_ENV=production npm run build", + "PORT=3000 DEBUG=true bun dev", + "aws s3 ls --profile prod", + "git log --oneline -5", + ]) + test(line, () => expect(sig(line)).toBe(line)) + + test("a value with a space, under a name that says nothing secret", () => + expect(sig("NODE_ENV='a b' npm test")).toBe("'NODE_ENV=a b' npm test")) +}) + +describe("a masked value keeps the environment it names", () => { + const line = "DATABASE_URL=postgres://app:s3cretPw@db-prod.internal:5432/application_db psql -c 'select 1'" + + test("the mask says prod, and nothing else of the value", () => { + const subject = sig(line) + expect(subject).toMatch(/DATABASE_URL=‹#[0-9a-f]{6} prod›/) + expect(subject).not.toContain("s3cretPw") + expect(subject).not.toContain("db-prod.internal") + }) + + test("read back, it is still production: for danger and for its family", () => { + const read = readSubject(sig(line)) + if (!read) throw new Error("unreadable") + expect(dangerOf(read.command)).toBe("production") + expect(familyOf("bash", sig(line))).toContain("prod") + }) +}) + +describe("one value, one subject", () => { + test("two secrets are two subjects; the same secret is the same subject", () => { + expect(sig("TOKEN=aaaa1111 ./run")).not.toBe(sig("TOKEN=bbbb2222 ./run")) + expect(sig("TOKEN=aaaa1111 ./run")).toBe(sig("TOKEN=aaaa1111 ./run")) + }) + + test("the hasher given is the one used", () => { + const fixed: Hasher = () => "abcdef" + expect(sig("TOKEN=x ./run", fixed)).toBe("'TOKEN=‹#abcdef›' ./run") + }) + + test("keyed hashes differ by key, and match under one", () => { + expect(keyedHash("a".repeat(64))("pw")).not.toBe(keyedHash("b".repeat(64))("pw")) + expect(keyedHash("a".repeat(64))("pw")).toBe(keyedHash("a".repeat(64))("pw")) + expect(keyedHash("a".repeat(64))("pw")).toMatch(/^[0-9a-f]{6}$/) + }) +}) + +describe("OpenCode's always", () => { + test("a pattern is masked word by word", () => { + const masked = maskPattern(`curl --token ${GITHUB} *`, plainHash) + expect(masked).toMatch(/^curl --token ‹#[0-9a-f]{6}› \*$/) + expect(maskPattern("git push *", plainHash)).toBe("git push *") + }) + + test("an always given to a request reaches the ledger masked", () => { + const engine = createEngine({ threshold: 3, dangerExtra: 5, expireDays: 30 }) + const line = `gh api --token ${GITHUB} repos` + engine.ask({ + request: { id: "per_1", sessionID: "s", permission: "bash", patterns: [line], always: [`${line} *`] }, + context: { line, root: ROOT }, + agent: "build", + rules: rulesFrom({ permission: { bash: "ask" } }), + at: 1_000, + }) + const { events } = engine.replied({ requestID: "per_1", reply: "always", at: 5_000 }) + const text = JSON.stringify(events) + expect(events[0]?.type).toBe("approved") + expect(text).not.toContain("ghp_") + expect(text).toMatch(/--token ‹#[0-9a-f]{6}›/) + }) +}) + +describe("the project's key", () => { + const log: Log = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child: () => log, + file: undefined, + } + + test("made once, readable by you alone, and the same next time", () => { + const home = mkdtempSync(join(tmpdir(), "trust-key-")) + const paths = trustPaths("/work/app", { COCKPIT_HOME: home }) + const first = maskHasher(paths, log) + const key = readFileSync(paths.key, "utf8").trim() + expect(key).toMatch(/^[0-9a-f]{64}$/) + expect(statSync(paths.key).mode & 0o777).toBe(0o600) + const second = maskHasher(paths, log) + expect(second("secret")).toBe(first("secret")) + expect(readFileSync(paths.key, "utf8").trim()).toBe(key) + expect(first("secret")).toBe(keyedHash(key)("secret")) + }) +}) diff --git a/packages/trust/test/suggest.test.ts b/packages/trust/test/suggest.test.ts new file mode 100644 index 00000000..5ee1dcd4 --- /dev/null +++ b/packages/trust/test/suggest.test.ts @@ -0,0 +1,166 @@ +/** + * Suggestions (#45): when one agent's approvals across a family reach the threshold, the ledger offers + * `w` for it — once, until widened or dismissed. Never for a dangerous family, never by itself. + */ +import { describe, expect, test } from "bun:test" +import { ANY_AGENT, applyAll, DAY, type Event, emptyState } from "../src/core/ledger.ts" +import { parse } from "../src/core/shell.ts" +import { signature } from "../src/core/signature.ts" +import { suggestionsOf } from "../src/core/suggest.ts" +import { dismiss } from "../src/core/view/actions.ts" +import { familiesOf, type Reading } from "../src/core/view/model.ts" +import { explorerModel } from "../src/core/view/tree.ts" + +const ROOT = "/work/app" +const SETTINGS = { threshold: 3, dangerExtra: 5, expireDays: 30 } +const FOLD = { expireMs: 30 * DAY, threshold: 3 } + +const sub = (line: string) => { + const parsed = parse(line) + if (parsed.kind !== "commands") throw new Error(line) + return signature(parsed.commands[0] as never, ROOT) +} + +let n = 0 +const approve = (line: string, agent = "build", danger?: string): Event => { + n++ + return { + v: 1, + at: n, + type: "approved", + request: `per_${n}`, + session: "ses_1", + permission: "bash", + agent, + items: [{ subject: sub(line), ...(danger ? { danger } : {}) }], + } +} + +const LOCAL = [ + 'mcpx db-local execute_sql --sql "select 1"', + 'mcpx db-local execute_sql --sql "select count(*) from users"', + 'mcpx db-local execute_sql --sql "select id from orders limit 5"', +] + +const readingOf = (events: readonly Event[]): Reading => ({ + state: applyAll(emptyState(), events, FOLD), + settings: SETTINGS, + now: n + 1, +}) +const suggest = (events: readonly Event[]) => { + const reading = readingOf(events) + return suggestionsOf(reading, familiesOf(reading)) +} + +describe("what is suggested", () => { + test("three approvals across three commands of one family", () => { + const found = suggest(LOCAL.map((line) => approve(line))) + expect(found).toHaveLength(1) + expect(found[0]).toMatchObject({ approvals: 3, commands: 3 }) + expect(found[0]?.family.family).toBe("mcpx db-local execute_sql") + }) + + test("one command approved again and again is its own count's business, not a family's", () => { + const line = LOCAL[0] as string + expect(suggest([approve(line), approve(line), approve(line)])).toHaveLength(0) + }) + + test("below the threshold: nothing yet", () => + expect(suggest(LOCAL.slice(0, 2).map((line) => approve(line)))).toHaveLength(0)) + + test("a dangerous family never is: production", () => { + const prod = LOCAL.map((line) => approve(line.replace("db-local", "db-prod"), "build", "production")) + expect(suggest(prod)).toHaveLength(0) + }) + + test("a dangerous family never is: git push", () => { + const push = ["git push origin a", "git push origin b", "git push origin c"].map((line) => + approve(line, "build", "git push"), + ) + expect(suggest(push)).toHaveLength(0) + }) + + test("a family already widened is not suggested", () => { + const widened: Event = { + v: 1, + at: 0, + type: "widened", + permission: "bash", + agent: "build", + family: "mcpx db-local execute_sql", + } + expect(suggest([widened, ...LOCAL.map((line) => approve(line))])).toHaveLength(0) + }) + + test("a family Trust learned is not suggested", () => + expect(suggest(["head a", "head b", "head c"].map((line) => approve(line)))).toHaveLength(0)) + + test("dismissed, it is not suggested again", () => { + const dismissed: Event = { + v: 1, + at: 99, + type: "dismissed", + permission: "bash", + agent: "build", + family: "mcpx db-local execute_sql", + } + expect(suggest([...LOCAL.map((line) => approve(line)), dismissed])).toHaveLength(0) + }) + + test("approvals by different agents add up: the project's family, one suggestion", () => { + const events = [ + approve(LOCAL[0] as string), + approve(LOCAL[1] as string, "general"), + approve(LOCAL[2] as string, "explore"), + ] + expect(suggest(events)).toHaveLength(1) + }) + + test("however many agents reach it, it is suggested once", () => { + const events = [...LOCAL.map((line) => approve(line)), ...LOCAL.map((line) => approve(line, "general"))] + expect(suggest(events)).toHaveLength(1) + }) +}) + +describe("dismiss", () => { + test("a suggestion's family: one dismissed event, for the project", () => { + const reading = readingOf(LOCAL.map((line) => approve(line))) + const [suggestion] = suggestionsOf(reading, familiesOf(reading)) + if (!suggestion) throw new Error("no suggestion") + const outcome = dismiss({ kind: "family", family: suggestion.family, suggested: true }, 50) + expect(outcome.events).toEqual([ + { + v: 1, + at: 50, + type: "dismissed", + permission: "bash", + agent: ANY_AGENT, + family: "mcpx db-local execute_sql", + }, + ]) + }) + + test("anything else: nothing written", () => { + const reading = readingOf(LOCAL.map((line) => approve(line))) + const family = familiesOf(reading)[0] + if (!family) throw new Error("no family") + expect(dismiss({ kind: "family", family }, 50).events).toEqual([]) + expect(dismiss(undefined, 50).events).toEqual([]) + }) +}) + +describe("in the ledger", () => { + const reading = () => readingOf(LOCAL.map((line) => approve(line))) + + test("suggestions come first, under their own heading", () => { + const model = explorerModel({ ...reading(), open: new Set(), full: new Set(), filter: "" }) + expect(model.lines[0]).toEqual({ kind: "heading", section: "suggested" }) + expect(model.nodes[0]?.kind).toBe("suggest") + }) + + test("a filter shows what matches, not suggestions", () => { + const model = explorerModel({ ...reading(), open: new Set(), full: new Set(), filter: "mcpx" }) + expect(model.nodes.some((node) => node.kind === "suggest")).toBe(false) + expect(model.lines.some((line) => line.kind === "heading" && line.section === "suggested")).toBe(false) + }) +}) diff --git a/packages/trust/test/view.test.ts b/packages/trust/test/view.test.ts index 5d654535..5405d36a 100644 --- a/packages/trust/test/view.test.ts +++ b/packages/trust/test/view.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { createEngine } from "../src/core/engine.ts" -import { DAY, type Event } from "../src/core/ledger.ts" +import { ANY_AGENT, DAY, type Event } from "../src/core/ledger.ts" import { SAMPLE_NOW, SAMPLE_SETTINGS, SAMPLES } from "../src/core/sample.ts" -import { configSnippet, revoke, widen, widenScope } from "../src/core/view/actions.ts" +import { configSnippet, NOT_READ, revoke, widen, widenScope } from "../src/core/view/actions.ts" import { activityModel, activityRows, answerWhy, targetOf } from "../src/core/view/activity.ts" import { historyRuns } from "../src/core/view/card.ts" import { explorerRows } from "../src/core/view/explorer.ts" @@ -240,7 +240,7 @@ describe("the week, as the activity reads it", () => { test("a rule's moments read back as how it earned its trust", () => { const { history } = readingOf("busy") - const marks = history.marks.get(JSON.stringify(["bash", "build", "git status --short"])) ?? [] + const marks = history.marks.get(JSON.stringify(["bash", "git status --short"])) ?? [] const text = historyRuns(marks, 3, 30 * DAY, SAMPLE_NOW) .map((run) => run.text) .join("") @@ -266,7 +266,7 @@ describe("the activity: what Trust did, then what it is about to do", () => { item.kind === "answer" ? answerWhy(item.answer, reading) : "", ) expect(said).toContain("both commands trusted") - expect(said).toContain("in a family you widened: cat") + expect(said).toContain("in a family you widened: any cat …") expect(said.some((each) => /^trusted .*, 3 in a row$/.test(each))).toBe(true) }) @@ -321,7 +321,8 @@ describe("the ledger: families as a tree, a card for the selection", () => { test("a family opens to its first commands and folds the rest into + N more", () => { const reading = readingOf("crowded") const families = explorerModel({ ...reading, open: new Set(), full: new Set(), filter: "" }).families - const head = families.find((family) => family.family === "head") + /** The biggest family: which one it is moves as Trust learns families of reads. */ + const head = [...families].sort((a, b) => b.commands.length - a.commands.length)[0] expect(head?.commands.length).toBeGreaterThan(TAIL + 1) const open = new Set([head?.key ?? ""]) const nodes = explorerModel({ ...reading, open, full: new Set(), filter: "" }).nodes @@ -340,23 +341,28 @@ describe("the ledger: families as a tree, a card for the selection", () => { expect(nodes.some((node) => node.key === key)).toBe(true) }) - test("the card: the command, each agent's standing, the history that earned it, the buttons", () => { + test("the card: the command, its standing in the project, the history that earned it, the buttons", () => { const view = ledger("busy", { pick: commandNode("git status --short"), open: "all" }) const text = textOf(view.rows) - expect(text).toContain("✓ Trusted for build · answered") + expect(text).toContain("✓ Trusted · answered") + expect(text).not.toContain("Trusted for") + /** Who asked is history, said once and small — it wraps beside the tree, so only its start is read. */ + expect(text).toContain("last asked") expect(text).toMatch(/History +✓ \d+d {2}✓ \d+d {2}✓ \d+d {2}→ trusted/) expect(text).toContain("3 approvals in a row, all yours") expect(text).toMatch(/Still asks +git status · git status --short > out\.txt/) expect(text).toContain(" x Revoke ") - expect(text).toContain(" w Trust any git status ") + /** `git status` is a read: three of them in a row taught Trust the family, so `w` forgets it. */ + expect(text).toContain(" w Forget git status reads ") expect(text).toContain(" c Copy rule ") }) - test("a command trusted for one agent and counting for another: one row, both standings", () => { + test("a command approved while different agents ran: one row, one standing — the project's", () => { const view = ledger("crowded", { pick: commandNode("git status --short"), open: "all" }) + expect(view.node?.kind === "command" && view.node.command.standings).toHaveLength(1) const text = textOf(view.rows) - expect(text).toContain("✓ Trusted for build ") - expect(text).toContain("for general ") + expect(text).toContain("✓ Trusted") + expect(text).not.toMatch(/for {2}(build|general) /) }) test("a dangerous command: a red meter, its badge, and a w that never widens", () => { @@ -371,7 +377,7 @@ describe("the ledger: families as a tree, a card for the selection", () => { const view = ledger("families", { pick: (nodes) => nodes.find((node) => node.kind === "family" && node.family.widened.length > 0), }) - expect(textOf(view.rows)).toMatch(/✓ Any ls … trusted for {2}general /) + expect(textOf(view.rows)).toMatch(/✓ Any ls … · you widened it /) expect(view.buttons.find((each) => each.key === "w")?.label).toBe("Undo any ls") }) @@ -440,13 +446,13 @@ describe("the ledger: families as a tree, a card for the selection", () => { describe("acting on a selection", () => { const at = SAMPLE_NOW + 1 - test("x on a trusted command revokes it for every agent", () => { + test("x on a trusted command revokes it in the project: one event, by no agent in particular", () => { const view = ledger("crowded", { pick: commandNode("git status --short"), open: "all" }) const outcome = revoke(nodeTarget(view.node as Node), view, at) - expect(outcome.events.map((event) => event.type === "revoked" && event.agent).sort()).toEqual([ - "build", - "general", + expect(outcome.events).toEqual([ + { v: 1, at, type: "revoked", permission: "bash", agent: ANY_AGENT, subject: "git status --short" }, ]) + expect(outcome.notice.text).not.toMatch(/ for | as /) }) test("an old widening nothing falls in: marked old, out of any folder, and x removes it", () => { @@ -468,30 +474,31 @@ describe("acting on a selection", () => { const item = model.feed.find((each) => each.kind === "answer" && each.answer.items.some((i) => i.via)) const outcome = revoke(targetOf(item as NonNullable, model), reading, at) expect(outcome.events).toEqual([ - { v: 1, at, type: "unwidened", permission: "bash", agent: "general", family: "cat" }, + { v: 1, at, type: "unwidened", permission: "bash", agent: ANY_AGENT, family: "cat" }, ]) }) - test("w on a safe command widens its family for its agent; on a dangerous one, nothing", () => { + test("w on a safe command widens its family in the project; on a dangerous one, nothing", () => { const view = ledger("busy", { pick: commandNode("bun --version"), open: "all" }) const families = view.model.families - const ok = widen(widenScope(nodeTarget(view.node as Node), families), families, at) + const ok = widen(widenScope(nodeTarget(view.node as Node), families), at) expect(ok.events).toEqual([ - { v: 1, at, type: "widened", permission: "bash", agent: "build", family: "bun" }, + { v: 1, at, type: "widened", permission: "bash", agent: ANY_AGENT, family: "bun" }, ]) + expect(ok.notice.text).not.toContain(" for ") const danger = ledger("busy", { pick: commandNode("git push origin feat/trust"), open: "all" }) - const refused = widen(widenScope(nodeTarget(danger.node as Node), families), families, at) + const refused = widen(widenScope(nodeTarget(danger.node as Node), families), at) expect(refused.events).toEqual([]) expect(refused.notice.text).toContain("dangerous") }) - test("c on a widened family is a wildcard, and says config cannot name the agent", () => { + test("c on a widened family is a wildcard, and says config allows more than the widening", () => { const view = ledger("families", { pick: (nodes) => nodes.find((node) => node.kind === "family" && node.family.widened.length > 0), }) const snippet = configSnippet(nodeTarget(view.node as Node)) expect(snippet.text).toBe(JSON.stringify({ permission: { bash: { "ls *": "allow" } } })) - expect(snippet.note).toContain("config cannot say which agent") + expect(snippet.note).toContain("even the ones Trust still asks about") }) }) @@ -537,7 +544,9 @@ describe("the ledger by kind: commands, edits, tools and fetches, OpenCode's own test("each kind has one heading, in a fixed order, OpenCode's own last", () => { const headings = model().lines.flatMap((line) => (line.kind === "heading" ? [line.section] : [])) - expect(headings).toEqual(["commands", "edits", "tools"]) + /** Suggestions, when there are any, come first: they are what is waiting for you. */ + expect(headings.filter((section) => section !== "suggested")).toEqual(["commands", "edits", "tools"]) + expect(headings.indexOf("suggested")).toBeLessThanOrEqual(0) expect(model().nodes.at(-1)?.kind).toBe("always") }) @@ -582,3 +591,96 @@ describe("the ledger by kind: commands, edits, tools and fetches, OpenCode's own expect(new Set(columns).size).toBe(1) }) }) + +describe("0.11: families of reads Trust learned, and widenings it suggests", () => { + const familyNode = (name: string) => (nodes: readonly Node[]) => + nodes.find((node) => node.kind === "family" && node.family.family === name) + + test("a learned family's row says reads, its commands ✓ read", () => { + const text = textOf(ledger("learning", { open: "all", width: 120, height: 40 }).rows) + expect(text).toMatch(/▾ head {2}reads/) + expect(text).toMatch(/head -60 src\/routes\.ts +✓ read/) + }) + + test("its card: w forgets it, and what it never answers", () => { + const text = textOf( + ledger("learning", { pick: familyNode("head"), open: "all", width: 120, height: 40 }).rows, + ) + expect(text).toContain(" w Forget head reads ") + expect(text).toContain("reads · learned from your approvals") + expect(text.replace(/\s+│\s+/g, " ").replace(/\s+/g, " ")).toContain(NOT_READ.slice(0, 40)) + }) + + test("a suggestion is the first stop, with w and d", () => { + const view = ledger("learning", { width: 120, height: 30 }) + expect(view.node?.kind).toBe("suggest") + const text = textOf(view.rows) + expect(text).toContain("SUGGESTED") + expect(text).toContain("★ Suggested · 3 approvals in a row across 3 commands") + expect(text).toMatch(/★ any mcpx db-local exec\S*\?/) + expect(text).toContain(" d Dismiss ") + }) + + test("a secret file and production still ask", () => { + const text = textOf(ledger("learning", { open: "all", width: 120, height: 40 }).rows) + expect(text).toMatch(/head \.env +○ once/) + expect(text).toMatch(/mcpx db-prod execute_sql.*2\/8/) + }) +}) + +describe("0.11: OpenCode's own read tools, learned as the project's", () => { + /** Three searches with nothing in common, and three files read in one folder, by two agents. */ + const ask = (permission: string, subject: string, at: number): Event => ({ + v: 1, + at, + type: "approved", + request: `per_${permission}_${at}`, + session: "ses_1", + permission, + agent: at % 2 ? "build" : "general", + items: [{ subject }], + }) + const events: Event[] = [ + ask("grep", "createServer", 1), + ask("grep", "session\\(", 2), + ask("grep", "TODO", 3), + ask("read", "src/a.ts", 4), + ask("read", "src/b.ts", 5), + ask("read", "src/c.ts", 6), + ] + const engine = createEngine({ ...SAMPLE_SETTINGS }) + engine.load(events) + const reading = { state: engine.state, settings: SAMPLE_SETTINGS, now: 10, history: engine.history } + + test("a whole tool is its name, a folder of reads is `read src/`, both marked `reads`", () => { + const rows = explorerRows({ + ...reading, + width: 100, + height: 24, + project: "app", + open: new Set(), + full: new Set(), + filter: "", + }).rows.map(rowText) + expect(rows.some((row) => /▸ grep {2}reads/.test(row))).toBe(true) + expect(rows.some((row) => /▸ read src\/ {2}reads/.test(row))).toBe(true) + expect(rows.join("\n")).not.toContain("grep *") + }) + + test("an answer through it says `any grep`, never `*`", () => { + const answer = { + at: 11, + request: "per_x", + permission: "grep", + agent: "explore", + label: "anything", + subjects: ["anything"], + items: [{ subject: "anything", via: "*", learned: true as const }], + why: "", + rule: "", + } + expect(answerWhy(answer as Parameters[0], reading)).toBe( + "a read Trust learned: any grep", + ) + }) +}) diff --git a/packages/trust/test/widen.test.ts b/packages/trust/test/widen.test.ts index db300c40..68b182b3 100644 --- a/packages/trust/test/widen.test.ts +++ b/packages/trust/test/widen.test.ts @@ -63,12 +63,12 @@ describe("the policy for a widened family", () => { test("a variant never approved is answered, and says which family answered", () => { const judged = ask("ls -R docs", [widened("ls")]) expect(judged.answer).toBe(true) - expect(judged.why).toBe("in a family you widened: ls") + expect(judged.why).toBe("in a family you widened: any ls …") expect(judged.items).toEqual([{ subject: "ls -R docs", via: "ls" }]) }) - test("only for the agent it was widened for", () => { - expect(ask("ls -la", [widened("ls")], { agent: "build" }).answer).toBe(false) + test("for every agent: a widening is the project's, whoever it was widened while", () => { + expect(ask("ls -la", [widened("ls")], { agent: "build" }).answer).toBe(true) }) test("not another family, nor the same words somewhere else or as root", () => { @@ -132,9 +132,9 @@ describe("widenings in the ledger file", () => { FOLD, ) expect([...state.widened.keys()].sort()).toEqual( - [keyOf("bash", "build", "ls"), keyOf("bash", "general", "git status")].sort(), + [keyOf("bash", "ls"), keyOf("bash", "git status")].sort(), ) - expect(state.widened.get(keyOf("bash", "build", "ls"))?.at).toBe(5) + expect(state.widened.get(keyOf("bash", "ls"))?.at).toBe(5) }) test("written and read back whole; a torn last line is waited for, not lost", () => { @@ -181,7 +181,7 @@ describe("an answer through a widening", () => { const auto = engine.answered("per_x", 120) expect(auto).toMatchObject({ type: "auto", - rule: "in a family you widened: ls", + rule: "in a family you widened: any ls …", items: [{ subject: "ls -x", via: "ls" }], }) if (auto) engine.load([auto]) From dc21d21bb28ff01b5369ede8a17cf7c307479865 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 21:43:20 -0500 Subject: [PATCH 2/2] =?UTF-8?q?Trust:=20the=20docs=20say=20what=200.11=20d?= =?UTF-8?q?oes=20=E2=80=94=20learned=20reads,=20suggestions,=20masked=20se?= =?UTF-8?q?crets,=20a=20project's=20trust,=20OpenCode's=20own=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 72 +++++++ packages/trust/README.md | 261 ++++++++++++++++------- site/src/content/docs/configuration.md | 1 + site/src/content/docs/trust/overview.mdx | 237 ++++++++++++++------ 4 files changed, 429 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e13358a..8bce157f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,78 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- **Trust learns reads as families.** One day of real use answered 1.4% of prompts automatically: + most lines carry a command never seen before (`head -3` on a new file, `rg` for a new word), so an + exact rule rarely repeats. A command that only reads — `ls`, `cat`, `head`, `tail`, `wc`, `grep`, + `rg`, `fd`, `echo`, `jq`, `sort`, `eza`, a `sed` whose script only prints (`sed -n 1,80p`), a `git` + that only looks (`status`, `log`, `diff`, `show`, `branch --show-current`, `stash list`, + `worktree list`, `config --get` …) — now counts towards its family too: three approved `head`s on + three files, and any `head` that only reads is answered. A learned family covers + plain reads only — never a command with an env var or a wrapper in front, a redirection or a flag + that writes (`sort -o`, `tree -o`), a flag that runs something (`rg --pre`, `fd -x`), anything + dangerous, or a file that may hold secrets (`.env`, `*.pem`, `id_rsa`, `~/.ssh`, `~/.aws`, + `credentials`). A reject of a read in the family starts it over; a reject of `head .env`, which it + never covers, does not. It expires like any rule, and `w` on it forgets it. The ledger marks it + `reads` and its commands `✓ read`. `"trust": { "learnReads": false }` turns it off. +- **Trust suggests families to widen** ([#45](https://github.com/Codestz/opencode-cockpit/issues/45)). + Everything that is not a read still widens only when you press `w` — and now the ledger says when + it is worth it: when approvals across two or more commands of a family reach the threshold, the + family is listed under **SUGGESTED** at the top of `/trust` (`★ any mcpx db-local execute_sql?`), its card saying how many of today's + approvals were in it. `w` widens it; `d` dismisses it for good. A dangerous family is never + suggested, and undoing a widening does not bring its suggestion back. +- **Trust handles OpenCode's own tools by what they do** — read from OpenCode 1.18.33's tools, every + permission they ask. `read` is counted by file and learned by folder, like an edit; `glob` and + `grep` send a new pattern every time, so an exact rule never repeated — each is now one family, + learned as a read (any `grep`, after three). `websearch` is one family too, suggested and never + learned: a query leaves the machine. A `read` of a file that may hold secrets (`.env`, a key) is + OpenCode's own default ask and now always yours to answer, however often you approve it. + `todowrite`, `lsp`, `skill`, `task` and `webfetch` keep one rule each, as before. +- **`trust preview --sample learning`**: a morning with learned reads, a suggestion, and a secret file + and a production query still asking. + +### Changed + +- **Trust is a project's, not an agent's.** A count was kept per agent, so `ls` approved three times + while `build` ran asked again for `general`, for `explore`, for every subagent. You give permission + for the work in a project: an approval by any agent now counts towards the one rule, and a + widening, a learned family and a suggestion are the project's. The ledger still records which + agent asked, and the activity shows it. Ledgers from before fold into the new scope as they are — + widenings made for one agent now hold for all. + +- **Trust: a widened family no longer covers flag-driven writes** + ([#44](https://github.com/Codestz/opencode-cockpit/issues/44)). A widening left out a command that + writes a file through a redirection, and nothing else: a widened `sed` answered every `sed -i`. It + now also leaves out the flags that make a program write — `sed -i`, `perl -i`, `awk -i inplace`, + `sort -o`, `tee file`, `curl -o`, `wget`, `tar x`/`c`, `unzip`, `find -delete` — and those that make + it run another program — `rg --pre`, `fd -x`, `sort --compress-program`, `git --ext-diff`. Still a + list, so still a decision you make. +- **Trust: a git command that only looks is one family per subcommand.** `git show abc123` and + `git show def456` were two families, and so was every `git merge-base` with its branches: the words + after the subcommand were read as names, and on one real day 20 `git` families never repeated. After + a subcommand that only looks (`show`, `log`, `diff`, `rev-parse`, `merge-base` …) they are refs and + paths now, so `git show` is one family. A subcommand that changes things keeps its words: pushing to + `main` and to a feature branch stay two families. +- **Measured** on one user's real day (434 requests, replayed): 0.10.2 answered 1.4%; 0.11 answers + 22.4% the first day and 35.9% the second; with every suggestion accepted, 31.1% and 54.8%. No + dangerous command was answered in any of them. + +### Security + +- **Trust keeps secrets out of its ledger.** A command was written to the ledger as run, so + `GITHUB_TOKEN=ghp_… gh api …` kept the token on disk, on screen and in the log. A secret value is + now a keyed hash — `GITHUB_TOKEN=‹#3fa9c2›` — before any of that: an env value under a name that + says secret (`TOKEN`, `KEY`, `SECRET`, `PASSWORD` …) or that looks generated, the value of + `--token`, `--password` and the like, `Authorization:` and `Cookie:` headers, `Bearer …`, the + password in `postgres://user:pass@host`, and tokens by their shape (`sk-…`, `ghp_…`, + `github_pat_…`, `xoxb-…`, `AKIA…`, `shpat_…`, a JWT). Two different tokens stay two commands. A + masked value keeps the environment it names — `DATABASE_URL=‹#a1b2c3 prod›` — so production still + costs more and never shares a family with dev. The key is per project, in `mask.key` beside the + ledger, readable by you alone; OpenCode's own "always" patterns are masked too. The ledger is only + ever appended, so lines written before 0.11 keep what they held: delete them from `events.ndjson` + by hand if you want them gone. + ## [0.10.2] - 2026-10-05 ### Changed diff --git a/packages/trust/README.md b/packages/trust/README.md index a435dd26..0edad945 100644 --- a/packages/trust/README.md +++ b/packages/trust/README.md @@ -23,7 +23,8 @@ sees it. ## What counts as "the same command" -Exactly the same, and nothing wider — unless you widen it yourself, in the ledger (`w`). +Exactly the same, and nothing wider — unless you widen it yourself, in the ledger (`w`), or it only +reads ([below](#reads-are-learned-as-a-family)). `docker compose -p cockpit up -d` and `docker compose -p prod down -v` are two commands — OpenCode's own "Always" would treat them as one (`docker compose -p *`). The same command in another directory (`cd /tmp && rm -rf dist`) is @@ -34,9 +35,83 @@ answered only when **every** one is trusted — or allowed by your config. Anyth for certain is always yours to answer: `$(…)`, backticks, `$VAR`, `eval`, `sh -c`, a pipe into a shell, a heredoc. -Each count is kept per project and **per agent**: `build` earning `git push` is not `general` earning -it. Other permissions have their own notion of "the same": an edit by its file, a web fetch by its -host, a subagent by its type. `external_directory` and `doom_loop` are never answered. +Each count is kept **per project**, whichever agent asked: `git status` approved while `build` ran +counts for `general`, `explore` and every subagent too — you give permission for the work in a +project, not for one agent in it. The ledger still records who asked. Other permissions have their +own notion of "the same" — see [OpenCode's own tools](#opencodes-own-tools). + +## OpenCode's own tools + +Read from OpenCode 1.18.33's tools: every permission they ask, and what Trust counts it by. They +only reach Trust when your config sets them to `ask`. + +| Permission (tool) | Counted by | Family | Learned by itself | Suggested | +| --- | --- | --- | --- | --- | +| `bash` | the command, exactly | program and subcommand (`git status`) | its plain reads | yes | +| `edit` (`edit`, `write`, `apply_patch`) | the file | its folder (`src/`) | no | yes | +| `read` | the file | its folder (`read src/`) | yes | yes | +| `glob`, `grep` | the pattern | the whole tool (`any grep`) | yes | yes | +| `websearch` | the query | the whole tool | no — a query leaves the machine | yes | +| `webfetch` | the host | — | no | no | +| `todowrite`, `lsp` | one rule | — | no | no | +| `skill`, `task` | the skill, the subagent type | — | no | no | +| `external_directory`, `doom_loop` | never answered | | | | + +A `read` of a file that may hold secrets (`.env`, a key, `~/.ssh`) is OpenCode's own default ask, and +stays yours to answer however often you approve it. + +## Reads are learned as a family + +An exact rule rarely repeats in real work: `head -3` on a new file, `rg` for a new word, one new +command in a line of five, and the whole line asks again. So a command that **only reads** counts +twice — for itself, and for its family. Three approved `head`s on three files, and any `head` that +only reads is answered, by a family Trust learned. `glob`, `grep` and `read` (by folder) are learned +the same way. The ledger marks the family +`reads` and its commands `✓ read`. + +What reads: `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `fd`, `echo`, `jq`, `sort`, `cut`, +`diff`, `stat`, `eza` and the like; a `sed` whose script only prints (`sed -n 1,80p`, `s/a/b/g` — +never `w`, `e` or `-i`); a `git` that only looks (`status`, `log`, `diff`, `show`, `blame`, +`branch --show-current`, `stash list`, `worktree list`, `remote -v`, `config --get` …). It is an +allowlist: a program not on it is not a read, and keeps earning trust one command at a time. + +A learned family covers plain reads only — never: + +- a command with an env var or a wrapper in front (`LD_PRELOAD=… ls`, `sudo cat`, `xargs head`); +- one that writes: a redirection (`ls > out.txt`) or a flag (`sort -o`, `tree -o`, `uniq a b`); +- one that runs something: `rg --pre`, `fd -x`, `git --ext-diff`; +- anything dangerous; +- a file that may hold secrets: `.env`, `*.pem`, `*.key`, `id_rsa`, `~/.ssh`, `~/.aws`, + `credentials` — `cat README.md` is in a learned `cat`, `cat .env` asks. + +A reject of a read in the family starts it over; a reject of something it never covers +(`head .env`) does not. Unused for `expireDays`, it is gone. `w` on it — "Forget head reads" — takes +it back, until enough reads in a row teach it again. `"learnReads": false` turns it off: then only +the families you widen answer. + +`awk`, `find`, `less` and `xargs` are not reads: their scripts and actions can write or run. + +## Secrets stay out of the ledger + +A command is written to the ledger, shown on screen and logged. A secret in it is replaced first, +with a keyed hash: `GITHUB_TOKEN=‹#3fa9c2› gh api …`. Masked: + +- an env value under a name that says secret (`TOKEN`, `KEY`, `SECRET`, `PASSWORD`, `AUTH` …), or + that looks generated, or is long; +- the value of `--token`, `--password`, `--api-key` and the like, and of `export NAME=…`; +- `Authorization:`, `Cookie:` and `X-Api-Key:` headers, `Bearer …`, and the password in + `postgres://user:pass@host`; +- tokens by their shape, wherever they are: `sk-…`, `ghp_…`, `github_pat_…`, `xoxb-…`, `AKIA…`, + `shpat_…`, a JWT. + +Two different tokens stay two commands: trust earned with one is not trust for another. A masked +value keeps the environment it names — `DATABASE_URL=‹#a1b2c3 prod›` — so a production URL still +costs more and never shares a family with dev. `NODE_ENV=production` and `PORT=3000` stay as they +are. The key is per project, in `mask.key` beside the ledger, readable by you alone, so a short +password cannot be found by hashing guesses. OpenCode's own "always" patterns are masked too. + +The ledger is only ever appended: lines written before 0.11 keep what they held. Delete them from +`events.ndjson` by hand if you want them gone. ## What it will not touch @@ -74,96 +149,121 @@ A filled dot is something Trust just answered, with how many times it has answer hollow one is a request on screen now and how far it is from being trusted. No answers and nothing counting, no block. Each answer is also a line in `~/.cache/opencode-cockpit/cockpit.log`. No toasts. -## What Trust did, and the ledger +## The ledger, and what Trust did -`/trust`, `ctrl+x p` or the palette opens on **what Trust did for you**: today's answers, newest -first, each with why it was answered; a sparkline of the week; what is one approval away; and -OpenCode's own broad "always" approvals in a band of their own. +`/trust`, `ctrl+x p` or the palette opens on **the ledger**: every rule, as a tree of **families** — +`ls -la`, `ls -x` and `ls -R docs` are all `ls` — and a card for the one selected, always on screen. +Today's answers are one strip above it; `a` opens them in full. ``` + Trust · app 8 trusted · 2 learning · 6 seen once ● answering + Today ✓ 5 answered · last 20:31 git status --short [a] Activity +────────────────────────────────────────┬─────────────────────────────────────────────────────────── + COMMANDS / filter │ cat src/app.ts + ▸ git ! 1 ✓ 1 ▰ │ ✓ Answered · through any cat … + ▸ ls reads 1 ✓ │ + bun test ✓ trusted │ Exactly cat src/app.ts + ▸ echo reads 1 ✓ │ Still asks dangerous ones, and any that write a file or + ▾ cat any 2 ✓ │ run another program. +▌ cat src/app.ts ✓ any │ History answered 1× + cat package.json ✓ any │ answered through any cat …, not by its own + ▸ head reads 1 ✓ │ count · last asked by general + bun --version ▰▰▱ 2/3 │ Family cat · 2 commands, 2 trusted + ▸ seen once pwd sed … 6 ○ │ any cat … trusted 7d ago · [w] undoes it + │ + EDITS │ + ▸ src/ 1 file 1 ✓ │ + │ + ! OpenCode always 2 broad rules │ + │ x Revoke w Undo any cat c Copy rule +────────────────────────────────────────┴─────────────────────────────────────────────────────────── + [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [a] Activity [?] Keys … [esc] Close +``` + +- **Each command is one row**, under its family, however many agents asked it: a rule is the + project's. A family with one command is drawn as that row; others start folded, and an open + family lists its first three commands and `+ N more`. +- **The card**: the command whole, on a raised panel; how it stands; exactly what it is, every + argument quoted where a font could merge it (`echo "---"`, never `echo ──`, and said in words too: + "3 hyphens"); what still asks; the **history** that earned it (`✓ 7d ✓ 7d ✓ 7d → trusted`) and + which agent asked it last; its family and what `w` would do; when it expires. Its buttons — `x`, + `w`, `c` — can be clicked, or reached with `tab`. +- **Below 90 columns** the card moves under the tree, the selection kept in view above it. +- **A family** is the program, or the program and its subcommand for tools that have them: + `git status`, `docker compose up` (`-p prod` and other global flags are not part of it), + `npm run test` (the script is). A wrapper is: `sudo ls` is not `ls`. So is where it runs and the + environment it is given: `(in web) bun test`, `NODE_ENV=… npm run build`. An edit's family, and a + read's, is its folder; a `grep` or a `glob` is one family, the tool. +- **`w` trusts the whole family**, in this project — on purpose, never by itself. Any `ls …` is + then answered, **except** a dangerous command, one that writes a file through a redirection + (`ls > out.txt`; `2>/dev/null` and `2>&1` write nothing and are fine) or a flag (`sed -i`, + `perl -i`, `awk -i inplace`, `sort -o`, `tee file`, `curl -o`, `wget`, `tar x`, `unzip`, + `find -delete`), one that runs another program (`find -exec`, `git -c`, `rg --pre`, `fd -x`), and + any line that cannot be read. A specific `ask` in your config still wins. A dangerous family + (`git push`, `rm`, `sudo …`) can never be widened. `w` again — or `x` on the family — goes back to + exact rules. Answers through a widened family say so, on both screens, the sidebar + (`● ls -x · any ls`) and the log. +- **Suggested families** sit at the top of the ledger, under **SUGGESTED**, when approvals across + two or more commands of a family reach the threshold: `★ any mcpx db-local execute_sql?`. Its card + says how many of today's approvals were in it. `w` widens it; `d` dismisses it for good. A + dangerous family is never suggested, and a widening you undo is not suggested again. A suggestion + never widens anything by itself. - Trust · app ● answering +``` + Trust · app 13 trusted · 1 learning · 5 seen once ● answering + Today ✓ 4 answered · last 15:54 head -60 src/routes.ts [a] Activity +────────────────────────────────────────┬─────────────────────────────────────────────────────────── + SUGGESTED / filter │ mcpx db-local execute_sql family of 3 commands +▌★ any mcpx db-local…? 3 in 3 │ ★ Suggested · 3 approvals in a row across 3 commands + │ + COMMANDS │ Widen [w] trusts any mcpx db-local execute_sql … — + ▸ head reads 5 ✓ 1 ○ │ except dangerous ones, ones that write a file + ▸ sed reads 4 ✓ 1 ○ │ and ones that run another program. [d] stops + ▸ rg reads 4 ✓ │ suggesting it. + mcpx db-prod execu… ! ▰▰▱▱▱▱▱▱ 2/8 │ Today 3 approvals of yours today were in it — [w] … + ▸ mcpx db-local exec… 3 ○ │ Commands mcpx db-local execute_sql --sql "se… ○ once + │ + │ w Trust any mcpx db-local execu… d Dismiss +────────────────────────────────────────┴─────────────────────────────────────────────────────────── + [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [a] Activity [?] Keys … [esc] Close +``` + +**What Trust did** (`a`) is today's answers, newest first, each with the agent it answered and why; +a sparkline of the week; what is one approval away; and OpenCode's own broad "always" approvals in a +band of their own. + +``` + Trust · app activity ● answering TODAY Trust answered 5 prompts for you ▂▁▅▂█▄█ last 7 days -▌20:31 ✓ git status --short build trusted since Sep 16, 3 in a row - 20:30 ✓ ls -la general trusted since Sep 16, 3 in a row - 20:21 ✓ bun test build trusted since Sep 16, 3 in a row - 20:13 ✓ git status --short && echo tr… build both commands trusted - 19:43 ✓ cat src/app.ts general in a family you widened: cat +▌✓ 20:31 git status --short build trusted since Sep 16, 3 in a row + ✓ 20:30 ls -la general trusted since Sep 16, 3 in a row + ✓ 20:21 bun test build trusted since Sep 16, 3 in a row + ✓ 20:13 git status --short && echo tr… build both commands trusted + ✓ 19:43 cat src/app.ts general in a family you widened: any cat … ALMOST THERE closest first - ○ bun --version build ▰▰▱ 2 of 3 - ○ git status --short -uno general ▰▰▱ 2 of 3 - ○ head -60 general ▰▰▱ 2 of 3 - ○ head -40 general ▰▰▱ 2 of 3 - ○ git push origin feat/trust build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous + ○ bun --version ▰▰▱ 2 of 3 + ○ git push origin feat/trust ▰▰▰▰▰▱▱▱ 5 of 8 dangerous ! WATCH OUT OpenCode's own "always" approves more than it looks, until it restarts - ! find . * sort -rn * general [enter] what it covers + ! find . * sort -rn * general [enter] what it covers - RULES 8 trusted · 5 learning · 7 seen once l Open the ledger + RULES 8 trusted · 2 learning · 6 seen once l Open the ledger - [enter] Why [x] Revoke [w] Trust Family [l] Ledger [p] Pause [?] Keys [esc] Close + [enter] Why [x] Revoke [w] Trust Family [a] Ledger [p] Pause [?] Keys [esc] Back ``` - **Today**: every answer Trust gave in this project — from any window — with the time, the agent - and why in a few words: the approvals that earned it and when, or the family you widened. The same - line answered again and again is one row with a count (`3×`). With nothing today, the latest from - earlier days, with their day. + it answered, and why in a few words: the approvals that earned it and when, the family you widened, + or the reads it learned. The same line answered again and again is one row with a count (`3×`). + With nothing today, the latest from earlier days, with their day. - **Almost there**: what is still learning, closest first, a dangerous command last. The meter is the approvals in a row that count (`▰`) and those still to go (`▱`); a dangerous one is red and longer (`threshold + dangerExtra`). - **Watch out**: only when you gave OpenCode an "always" — it approves every command that starts that way until OpenCode restarts, and Trust cannot take it back. -- **`enter`** shows why: the card of that rule, in the ledger. **`l`** opens the ledger. - -**The ledger** (`l`) is every rule, as a tree of **families** — `ls -la`, `ls -x` and `ls -R docs` -are all `ls` — and a card for the one selected, always on screen: - -``` - Trust · app 8 trusted · 5 learning · 7 seen once ● answering -────────────────────────────────────────┬─────────────────────────────────────────────────────────── - FAMILIES 1–15 of 18 │ git status --short - ▾ git status 1 ✓ 1 ○ │ ✓ Trusted for build · answered 8× -▌ … --short ✓ trusted 8× │ - … --short -uno ▰▰▱ 2 of 3 │ Exactly git status --short - ls -la ✓ trusted 2× │ Still asks git status · git status --short > out.txt · - bun test ✓ trusted 2× │ any other argument - echo trust-test ✓ trusted 3× │ History ✓ 7d ✓ 7d ✓ 7d → trusted answered 8× - ▸ cat any 2 ✓ │ 3 approvals in a row, all yours - ▸ head 1 ✓ 3 ○ │ Family git status · 2 commands, 1 trusted - edit src/app.ts ✓ trusted 2× │ [w] trusts any git status … for build, not - bun --version ▰▰▱ 2 of 3 │ one by one - git push origin fe… ! ▰▰▰▰▰▱▱▱ 5/8 │ Expires if unused for 30 days - pwd ▰▱▱ 1 of 3 │ - sed -n 1,40p src/a… ▰▱▱ 1 of 3 │ x Revoke w Trust any git status c Copy rule - wc -l src/app.ts ▰▱▱ 1 of 3 │ - sort -rn ▰▱▱ 1 of 3 │ -────────────────────────────────────────┴─────────────────────────────────────────────────────────── - [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [p] Pause [?] Keys [esc] Back -``` - -- **Each command is one row**, under its family, whatever its agents say about it; the card lists - each agent's standing. A family with one command is drawn as that row; others start folded, and - an open family lists its first three commands and `+ N more`. -- **The card**: the command whole, on a raised panel; where each agent stands; exactly what it is, - every argument quoted where a font could merge it (`echo "---"`, never `echo ──`, and said in words - too: "3 hyphens"); what still asks; the **history** that earned it (`✓ 7d ✓ 7d ✓ 7d → trusted`); - its family and what `w` would do; when it expires. Its buttons — `x`, `w`, `c` — can be clicked, - or reached with `tab`. -- **Below 90 columns** the card moves under the tree, the selection kept in view above it. -- **A family** is the program, or the program and its subcommand for tools that have them: - `git status`, `docker compose up` (`-p prod` and other global flags are not part of it), - `npm run test` (the script is). A wrapper is: `sudo ls` is not `ls`. So is where it runs and the - environment it is given: `(in web) bun test`, `NODE_ENV=… npm run build`. An edit's family is its - folder. -- **`w` trusts the whole family**, for the selected command's agent — on purpose, never by itself. - Any `ls …` is then answered for that agent, **except** a dangerous command, one that writes a file - through a redirection (`ls > out.txt`; `2>/dev/null` and `2>&1` write nothing and are fine), one - that runs another program (`find -exec`, `git -c`), and any line that cannot be read. A specific - `ask` in your config still wins. A dangerous family (`git push`, `rm`, `sudo …`) can never be - widened. `w` again — or `x` on the family — goes back to exact rules. Answers through a widened - family say so, on both screens, the sidebar (`● ls -x · any ls`) and the log. +- **`enter`** shows why: the card of that rule, in the ledger. **`a`** goes back to the ledger. On the activity: @@ -174,7 +274,7 @@ On the activity: | `x` | Revoke what answered (the rule, or the widening that answered it); forget a count still learning | | `w` | Trust any command in the family, or undo it | | `c` | Copy it as an `opencode.json` rule, to paste yourself | -| `l`, click on the button | Open the ledger | +| `a`, `l`, click on the button | Back to the ledger | | `/` | Open the ledger and filter it | | `p` | Pause Trust in this project (it keeps counting, and answers nothing) — again to resume | | `?` | Every key, one line each | @@ -188,9 +288,10 @@ In the ledger: | `←` `h` / `→` `l` | Fold / open a family — on a command inside one, go to its heading | | `space` `enter` | Open or fold a family; on `+ N more`, list the rest | | `tab` | Into the card's buttons and back; `←` `→` choose one, `enter` presses it | -| `x` | Revoke a command for every agent, or a family — every command in it, and its widening. Still learning, it forgets the count | -| `w` | Trust any command in the family, or undo it | -| `c` | Copy it as an `opencode.json` rule — a family as `"ls *": "allow"` (config cannot say which agent) | +| `x` | Revoke a command, or a family — every command in it, and its widening. Still learning, it forgets the count | +| `w` | Trust any command in the family, or undo it; on a learned family, forget it | +| `d` | Dismiss a suggestion: it is not suggested again in this project | +| `c` | Copy it as an `opencode.json` rule — a family as `"ls *": "allow"` | | `/` | Filter by text; `enter` keeps it, `esc` clears it | | `p` | Pause or resume | | `?` | Every key, one line each | @@ -208,6 +309,7 @@ In the bundle's entry (`"trust": { … }`), this package's own, or the `trust` s | `threshold` | `3` | Approvals in a row, by you, before Trust answers | | `dangerExtra` | `5` | What a dangerous command costs on top | | `expireDays` | `30` | Days unused before trust has to be earned again; `0` never | +| `learnReads` | `true` | Learn a family of plain reads by itself (`head`, `rg`, `git status` …); `false` leaves families to `w` | | `enabled` | `true` | `false` turns Trust off (the bundle also has `features.trust: false`) | | `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session | | `sidebarRows` | `3` | Answers listed in the sidebar | @@ -221,7 +323,7 @@ cannot use (`"threshold": "3"`) is a `!` row in the block. See The ledger lives outside the project, in `~/.local/share/opencode-cockpit/trust/-/events.ndjson` (`$COCKPIT_HOME` or `$XDG_DATA_HOME` move it): one line per event, appended, shared by every OpenCode window on the -project. +project. Beside it, `mask.key` is the key secrets are hashed with; it is never in the ledger. ## See it without OpenCode @@ -230,6 +332,7 @@ bunx @opencode-cockpit/trust preview ``` Draws the sidebar block, the activity and the ledger from sample projects, in your terminal — +`--sample learning` for learned reads and a suggestion, `--view activity` or `--view ledger` for one screen, `--columns`/`--rows` for another size, `--html` to judge the colours in a browser. diff --git a/site/src/content/docs/configuration.md b/site/src/content/docs/configuration.md index 1c5b3c68..6f64c672 100644 --- a/site/src/content/docs/configuration.md +++ b/site/src/content/docs/configuration.md @@ -215,6 +215,7 @@ See [Trust](/trust/overview/#settings). | `threshold` | Approvals in a row, by you, before Trust answers | `3` | | `dangerExtra` | What a dangerous command costs on top | `5` | | `expireDays` | Days unused before trust has to be earned again; `0` never | `30` | +| `learnReads` | Learn a family of plain reads by itself (`head`, `rg`, `git status` …); `false` leaves families to `w` | `true` | Its block is off by default: `"sidebar": true` shows it, and the palette flips it for the session. diff --git a/site/src/content/docs/trust/overview.mdx b/site/src/content/docs/trust/overview.mdx index ec2ea07c..13d58d82 100644 --- a/site/src/content/docs/trust/overview.mdx +++ b/site/src/content/docs/trust/overview.mdx @@ -30,19 +30,40 @@ the request. | `git status` (spacing), `git 'status'` (quoting) | yes | | `git status -s` | no — another argument | | `cd web && git status` | no — another directory | -| `git status` run by the `general` agent | no — another agent | +| `git status` run by the `general` agent | yes — trust is the project's, whichever agent asks | A line with several commands counts for each, and is answered only when **every** one is trusted or allowed by your config: `git status && rm -rf build` waits for you even when `git status` is trusted. Anything that cannot be read for certain is always asked and never counted: `$(…)`, backticks, `$VAR`, `eval`, `sh -c`, a pipe into a shell, a heredoc. -Other permissions have their own "same": an edit by its file, a web fetch by its host, a subagent by -its type. `external_directory` and `doom_loop` are never answered. +An approval by any agent counts towards the one rule: you give permission for the work in a project, +not for one agent in it. The ledger still records which agent asked. + +## OpenCode's own tools + +Every permission OpenCode 1.18.33's tools ask, and what Trust counts each by. They only reach Trust +when your config sets them to `ask`. + +| Permission (tool) | Counted by | Family | Learned by itself | Suggested | +| --- | --- | --- | --- | --- | +| `bash` | the command, exactly | program and subcommand (`git status`) | its plain reads | yes | +| `edit` (`edit`, `write`, `apply_patch`) | the file | its folder (`src/`) | no | yes | +| `read` | the file | its folder (`read src/`) | yes | yes | +| `glob`, `grep` | the pattern | the whole tool (`any grep`) | yes | yes | +| `websearch` | the query | the whole tool | no — a query leaves the machine | yes | +| `webfetch` | the host | — | no | no | +| `todowrite`, `lsp` | one rule | — | no | no | +| `skill`, `task` | the skill, the subagent type | — | no | no | +| `external_directory`, `doom_loop` | never answered | | | | + +A `read` of a file that may hold secrets (`.env`, a key, `~/.ssh`) is OpenCode's own default ask, and +stays yours to answer however often you approve it. ## Earned, and lost -- **Three approvals in a row** (`threshold`) and it is trusted. +- **Three approvals in a row** (`threshold`) and it is trusted — or, for a command that only reads, + its whole family is ([below](#reads-are-learned-as-a-family)). - **A reject resets** the count to nothing. - **Dangerous commands cost more** — `threshold + dangerExtra`, eight by default: `rm`, `rmdir`, `dd`, `kill`, `chmod -R`, `git push`, `git reset --hard`, `git clean`, `git checkout -- …`, @@ -53,6 +74,59 @@ its type. `external_directory` and `doom_loop` are never answered. - **Only your approvals count.** Trust's own answers do not, and nor does any reply faster than 300ms — nobody reads a prompt that fast; OpenCode's `--auto` answers in about 20ms. +## Reads are learned as a family + +An exact rule rarely repeats in real work: `head -3` on a new file, `rg` for a new word, one new +command in a line of five, and the whole line asks again. So a command that **only reads** counts +twice — for itself, and for its family. Three approved `head`s on three files, and any `head` that +only reads is answered, by a family Trust learned. `glob`, `grep` and `read` (by folder) are learned +the same way. The ledger marks the family +`reads` and its commands `✓ read`. + +What reads: `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `fd`, `echo`, `jq`, `sort`, `cut`, +`diff`, `stat`, `eza` and the like; a `sed` whose script only prints (`sed -n 1,80p`, `s/a/b/g` — +never `w`, `e` or `-i`); a `git` that only looks (`status`, `log`, `diff`, `show`, `blame`, +`branch --show-current`, `stash list`, `worktree list`, `remote -v`, `config --get` …). It is an +allowlist: a program not on it is not a read, and keeps earning trust one command at a time. + +A learned family covers plain reads only — never: + +- a command with an env var or a wrapper in front (`LD_PRELOAD=… ls`, `sudo cat`, `xargs head`); +- one that writes: a redirection (`ls > out.txt`) or a flag (`sort -o`, `tree -o`, `uniq a b`); +- one that runs something: `rg --pre`, `fd -x`, `git --ext-diff`; +- anything dangerous; +- a file that may hold secrets: `.env`, `*.pem`, `*.key`, `id_rsa`, `~/.ssh`, `~/.aws`, + `credentials` — `cat README.md` is in a learned `cat`, `cat .env` asks. + +A reject of a read in the family starts it over; a reject of something it never covers +(`head .env`) does not. Unused for `expireDays`, it is gone. `w` on it — "Forget head reads" — takes +it back, until enough reads in a row teach it again. `"learnReads": false` turns it off: then only +the families you widen answer. + +`awk`, `find`, `less` and `xargs` are not reads: their scripts and actions can write or run. + +## Secrets stay out of the ledger + +A command is written to the ledger, shown on screen and logged. A secret in it is replaced first, +with a keyed hash: `GITHUB_TOKEN=‹#3fa9c2› gh api …`. Masked: + +- an env value under a name that says secret (`TOKEN`, `KEY`, `SECRET`, `PASSWORD`, `AUTH` …), or + that looks generated, or is long; +- the value of `--token`, `--password`, `--api-key` and the like, and of `export NAME=…`; +- `Authorization:`, `Cookie:` and `X-Api-Key:` headers, `Bearer …`, and the password in + `postgres://user:pass@host`; +- tokens by their shape, wherever they are: `sk-…`, `ghp_…`, `github_pat_…`, `xoxb-…`, `AKIA…`, + `shpat_…`, a JWT. + +Two different tokens stay two commands: trust earned with one is not trust for another. A masked +value keeps the environment it names — `DATABASE_URL=‹#a1b2c3 prod›` — so a production URL still +costs more and never shares a family with dev. `NODE_ENV=production` and `PORT=3000` stay as they +are. The key is per project, in `mask.key` beside the ledger, readable by you alone, so a short +password cannot be found by hashing guesses. OpenCode's own "always" patterns are masked too. + +The ledger is only ever appended: lines written before 0.11 keep what they held. Delete them from +`events.ndjson` by hand if you want them gone. + ## Your config always wins A **specific** pattern set to ask — `"git push *": "ask"` — is you asking to be asked, and Trust @@ -91,40 +165,37 @@ cannot get in before OpenCode's own handler. ## What Trust did -`/trust`, `ctrl+x p`, or "Trust" in the palette opens on what Trust did for you, newest first, and -what it is about to do: +`/trust`, `ctrl+x p`, or "Trust" in the palette opens on [the ledger](#the-ledger), with today's +answers as a strip above it; `a` opens them in full — what Trust did for you, newest first, and what +it is about to do: ``` - - Trust · app ● answering + Trust · app activity ● answering TODAY Trust answered 5 prompts for you ▂▁▅▂█▄█ last 7 days -▌20:31 ✓ git status --short build trusted since Sep 16, 3 in a row - 20:30 ✓ ls -la general trusted since Sep 16, 3 in a row - 20:21 ✓ bun test build trusted since Sep 16, 3 in a row - 20:13 ✓ git status --short && echo tr… build both commands trusted - 19:43 ✓ cat src/app.ts general in a family you widened: cat +▌✓ 20:31 git status --short build trusted since Sep 16, 3 in a row + ✓ 20:30 ls -la general trusted since Sep 16, 3 in a row + ✓ 20:21 bun test build trusted since Sep 16, 3 in a row + ✓ 20:13 git status --short && echo tr… build both commands trusted + ✓ 19:43 cat src/app.ts general in a family you widened: any cat … ALMOST THERE closest first - ○ bun --version build ▰▰▱ 2 of 3 - ○ git status --short -uno general ▰▰▱ 2 of 3 - ○ head -60 general ▰▰▱ 2 of 3 - ○ head -40 general ▰▰▱ 2 of 3 - ○ git push origin feat/trust build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous + ○ bun --version ▰▰▱ 2 of 3 + ○ git push origin feat/trust ▰▰▰▰▰▱▱▱ 5 of 8 dangerous ! WATCH OUT OpenCode's own "always" approves more than it looks, until it restarts - ! find . * sort -rn * general [enter] what it covers + ! find . * sort -rn * general [enter] what it covers - RULES 8 trusted · 5 learning · 7 seen once l Open the ledger + RULES 8 trusted · 2 learning · 6 seen once l Open the ledger - [enter] Why [x] Revoke [w] Trust Family [l] Ledger [p] Pause [?] Keys [esc] Close + [enter] Why [x] Revoke [w] Trust Family [a] Ledger [p] Pause [?] Keys [esc] Back ``` -- **Today** lists every answer Trust gave in this project, from any window: when, for which agent, - and why in a few words — the approvals that earned it and when, both commands of a line trusted, - or the family you widened. One line answered again and again is one row with a count. With +- **Today** lists every answer Trust gave in this project, from any window: when, the agent it + answered, and why in a few words — the approvals that earned it and when, both commands of a line + trusted, the family you widened, or the reads it learned. One line answered again and again is one row with a count. With nothing today, the latest answers from earlier days, with their day. The sparkline is answers per day for the last week, each scaled to the busiest. - **Almost there** is what is still learning, closest first and a dangerous command last: a meter @@ -135,8 +206,8 @@ what it is about to do: - **Rules** counts the project: trusted, learning, and seen once — the commands approved a single time that mostly never come back. -`enter` on any row shows why: that rule's card in the ledger. `l`, or a click on the button, opens -the ledger; `esc` there comes back here, and `esc` here closes. +`enter` on any row shows why: that rule's card in the ledger. `a`, `l`, or a click on the button, +goes back to the ledger. ## The ledger @@ -146,33 +217,36 @@ no details key to find: ``` - Trust · app 8 trusted · 5 learning · 7 seen once ● answering + Trust · app 8 trusted · 2 learning · 6 seen once ● answering + Today ✓ 5 answered · last 20:31 git status --short [a] Activity ────────────────────────────────────────┬─────────────────────────────────────────────────────────── - FAMILIES 1–15 of 18 │ git status --short - ▾ git status 1 ✓ 1 ○ │ ✓ Trusted for build · answered 8× -▌ … --short ✓ trusted 8× │ - … --short -uno ▰▰▱ 2 of 3 │ Exactly git status --short - ls -la ✓ trusted 2× │ Still asks git status · git status --short > out.txt · - bun test ✓ trusted 2× │ any other argument - echo trust-test ✓ trusted 3× │ History ✓ 7d ✓ 7d ✓ 7d → trusted answered 8× - ▸ cat any 2 ✓ │ 3 approvals in a row, all yours - ▸ head 1 ✓ 3 ○ │ Family git status · 2 commands, 1 trusted - edit src/app.ts ✓ trusted 2× │ [w] trusts any git status … for build, not - bun --version ▰▰▱ 2 of 3 │ one by one - git push origin fe… ! ▰▰▰▰▰▱▱▱ 5/8 │ Expires if unused for 30 days - pwd ▰▱▱ 1 of 3 │ - sed -n 1,40p src/a… ▰▱▱ 1 of 3 │ x Revoke w Trust any git status c Copy rule - wc -l src/app.ts ▰▱▱ 1 of 3 │ - sort -rn ▰▱▱ 1 of 3 │ + COMMANDS / filter │ cat src/app.ts + ▸ git ! 1 ✓ 1 ▰ │ ✓ Answered · through any cat … + ▸ ls reads 1 ✓ │ + bun test ✓ trusted │ Exactly cat src/app.ts + ▸ echo reads 1 ✓ │ Still asks dangerous ones, and any that write a file or + ▾ cat any 2 ✓ │ run another program. +▌ cat src/app.ts ✓ any │ History answered 1× + cat package.json ✓ any │ answered through any cat …, not by its own + ▸ head reads 1 ✓ │ count · last asked by general + bun --version ▰▰▱ 2/3 │ Family cat · 2 commands, 2 trusted + ▸ seen once pwd sed … 6 ○ │ any cat … trusted 7d ago · [w] undoes it + │ + EDITS │ + ▸ src/ 1 file 1 ✓ │ + │ + ! OpenCode always 2 broad rules │ + │ x Revoke w Undo any cat c Copy rule ────────────────────────────────────────┴─────────────────────────────────────────────────────────── - [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [p] Pause [?] Keys [esc] Back + [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [a] Activity [?] Keys … [esc] Close ``` -The card holds the command whole, on a raised panel; where each agent stands on it; the exact text -and what still asks; the **history** that earned it — each approval with how long ago, `→ trusted` -where the streak reached the threshold, then how often Trust answered it; its family and what `w` -would do; when it expires. Its buttons can be clicked, or reached with `tab`. Below 90 columns the -card moves under the tree, with the selection kept in view above it. `/` filters the tree by text. +The card holds the command whole, on a raised panel; how it stands; the exact text and what still +asks; the **history** that earned it — each approval with how long ago, `→ trusted` where the streak +reached the threshold, then how often Trust answered it, and which agent asked it last; its family +and what `w` would do; when it expires. Its buttons can be clicked, or reached with `tab`. Below 90 +columns the card moves under the tree, with the selection kept in view above it. `/` filters the +tree by text. ### Families @@ -192,12 +266,14 @@ two families. | `NODE_ENV=prod npm run build` | `NODE_ENV=… npm run build` | | `ls > out.txt` | `ls` | | edit `src/app.ts` | `edit src/` | +| read `src/app.ts` | `read src/` | +| grep `createServer`, glob `**/*.ts` | `grep`, `glob` — the tool | A family with one command is drawn as that row; the others start folded, with how many of their commands are trusted (`✓`) and how many are not yet (`○`). `space`, `enter` or `→` opens one, listing its first three commands and `+ N more`; `←` folds it, or from a command inside it goes to its heading. -A command appears once, however many agents approved it: the card lists each agent's standing — a -command trusted for build and two of three for general is one row, and two lines on its card. +A command appears once, however many agents asked it: approvals by `build` and by `general` add up to +one rule, the project's. ### Exactly @@ -209,14 +285,15 @@ hyphens", "hyphen, greater-than" for `->`. ### Trusting a whole family -`w` trusts any command in the family, for the agent of the selected rule. It is the one way trust -gets wider than what you approved, and only you do it — it is written to the ledger like everything -else. From then on any `ls …` is answered for that agent, **except**: +`w` trusts any command in the family, in this project. It is the one way trust gets wider than what +you approved, other than the reads Trust learns, and only you do it — it is written to the ledger +like everything else. From then on any `ls …` is answered, **except**: - a dangerous command — `docker compose down -v` is not covered by `docker compose down`; - one that writes a file through a redirection — `ls > out.txt` (`2>/dev/null` and `2>&1` write - nothing, and are covered); -- one that runs another program — `find -exec`, `git -c …`; + nothing, and are covered) — or through a flag: `sed -i`, `perl -i`, `awk -i inplace`, `sort -o`, + `tee file`, `curl -o`, `wget`, `tar x`, `unzip`, `find -delete`; +- one that runs another program — `find -exec`, `git -c …`, `rg --pre`, `fd -x`; - a line that cannot be read, and anything a specific `ask` in your config matches. A dangerous family — `git push`, `rm`, `kubectl delete`, `sudo …` — can never be widened: `w` says @@ -224,6 +301,36 @@ why and does nothing. `w` again, or `x` on the family, goes back to exact rules. widened family says so: `✓ widened` in the ledger, "in a family you widened" in the activity, `● ls -x · any ls` in the sidebar, the family in the log. Widening does not expire; undo it when you no longer want it. +### Suggested families + +Everything that is not a read widens only when you press `w` — and the ledger says when it is worth +it. When approvals across two or more commands of a family reach the threshold, the family is listed +under **SUGGESTED** at the top of the ledger: + +``` + Trust · app 13 trusted · 1 learning · 5 seen once ● answering + Today ✓ 4 answered · last 15:54 head -60 src/routes.ts [a] Activity +────────────────────────────────────────┬─────────────────────────────────────────────────────────── + SUGGESTED / filter │ mcpx db-local execute_sql family of 3 commands +▌★ any mcpx db-local…? 3 in 3 │ ★ Suggested · 3 approvals in a row across 3 commands + │ + COMMANDS │ Widen [w] trusts any mcpx db-local execute_sql … — + ▸ head reads 5 ✓ 1 ○ │ except dangerous ones, ones that write a file + ▸ sed reads 4 ✓ 1 ○ │ and ones that run another program. [d] stops + ▸ rg reads 4 ✓ │ suggesting it. + mcpx db-prod execu… ! ▰▰▱▱▱▱▱▱ 2/8 │ Today 3 approvals of yours today were in it — [w] … + ▸ mcpx db-local exec… 3 ○ │ Commands mcpx db-local execute_sql --sql "se… ○ once + │ + │ w Trust any mcpx db-local execu… d Dismiss +────────────────────────────────────────┴─────────────────────────────────────────────────────────── + [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [a] Activity [?] Keys … [esc] Close +``` + +Its card says how many approvals in a row across how many commands, and how many of today's approvals +were in it. `w` widens it, with the same exceptions as any widening; `d` dismisses it, for good. A dangerous family — `mcpx db-prod …`, `git push` — is never +suggested, and a widening you undo is not suggested again. A suggestion never widens anything by +itself. + ### Keys On the activity: @@ -235,7 +342,7 @@ On the activity: | `x` | Revoke what answered (the rule, or the widening that answered it); forget a count still learning | | `w` | Trust any command in the family, or undo it | | `c` | Copy it as an `opencode.json` rule, to paste yourself | -| `l`, click on the button | Open the ledger | +| `a`, `l`, click on the button | Back to the ledger | | `/` | Open the ledger and filter it | | `p` | Pause Trust in this project — it keeps counting and answers nothing; again to resume | | `?` | Every key, one line each | @@ -249,9 +356,10 @@ In the ledger: | `←` `h` / `→` `l` | Fold / open a family — on a command inside one, go to its heading | | `space` `enter` | Open or fold a family; on `+ N more`, list the rest | | `tab` | Into the card's buttons and back; `←` `→` choose one, `enter` presses it | -| `x` | Revoke a command for every agent; on a family, every command in it and its widening. Still learning, it forgets the count | -| `w` | Trust any command in the family, or undo it | -| `c` | Copy it as an `opencode.json` rule — a family as `"ls *": "allow"`, which config cannot limit to one agent | +| `x` | Revoke a command; on a family, every command in it and its widening. Still learning, it forgets the count | +| `w` | Trust any command in the family, or undo it; on a learned family, forget it | +| `d` | Dismiss a suggestion: it is not suggested again in this project | +| `c` | Copy it as an `opencode.json` rule — a family as `"ls *": "allow"` | | `/` | Filter by text; `enter` keeps it, `esc` clears it | | `p` | Pause or resume | | `?` | Every key, one line each | @@ -267,6 +375,7 @@ In the bundle's entry (`"trust": { … }`), the package's own, or the `trust` se | `threshold` | `3` | Approvals in a row, by you, before Trust answers | | `dangerExtra` | `5` | What a dangerous command costs on top | | `expireDays` | `30` | Days unused before trust has to be earned again; `0` never | +| `learnReads` | `true` | Learn a family of plain reads by itself (`head`, `rg`, `git status` …); `false` leaves families to `w` | | `enabled` | `true` | `false` turns Trust off | | `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session | | `sidebarRows` | `3` | Answers listed in the sidebar | @@ -280,8 +389,9 @@ Where the block sits is the top-level `"sidebar"` list's to say — Trust last b `~/.local/share/opencode-cockpit/trust/-/events.ndjson` — outside the project, so it never turns up in `git status` or travels to anyone who clones the repository. One line per event -(asked, approved by you, rejected, answered by Trust, revoked, paused), only ever appended, and shared -by every OpenCode window on the project. `$COCKPIT_HOME` or `$XDG_DATA_HOME` move it. +(asked, approved by you, rejected, answered by Trust, revoked, widened, dismissed, paused), only ever +appended, and shared by every OpenCode window on the project. `$COCKPIT_HOME` or `$XDG_DATA_HOME` +move it. Beside it, `mask.key` is the key secrets are hashed with; it is never in the ledger. ## See it without OpenCode @@ -290,6 +400,7 @@ bunx @opencode-cockpit/trust preview ``` Draws the sidebar block, the activity and the ledger from sample projects — a busy week, a new -project, a paused one, dangerous commands on their way, a widened family — in your terminal. +project, a paused one, dangerous commands on their way, a widened family, reads learned and a +family suggested (`--sample learning`) — in your terminal. `--view activity` or `--view ledger` draws one screen, `--columns` and `--rows` another size, and `--html` a page to judge the colours in a browser.