From 67419802943c1e68783d6220bd96ded3a85e4da8 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 12:49:16 -0500 Subject: [PATCH 1/3] =?UTF-8?q?Trust:=20an=20experiment=20on=20where=20a?= =?UTF-8?q?=20family=20ends=20=E2=80=94=20ten=20rules,=20scored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/trust/experiments/families: a labelled set of made-up command pairs (must stay separate: dev vs prod, read vs write; fine together: another file, row or id — some held out until the end), the candidate rules, and a runner that scores them there and, as counts only, on the Trust ledgers on the machine it runs on. Nothing from a ledger is printed or kept. Not part of the package. Today's rule merges 23 of 32 must-separate pairs (`mcpx db-local` with `mcpx db-prod`, `compose -p dev` with `-p prod`). Rule G — the plain words and target flags read in order, plus environments named in env vars and hosts — merges none, splitting 6 of 29 pairs that could share a family. Co-Authored-By: Claude Opus 5.5 --- packages/trust/experiments/families/corpus.ts | 219 ++++++++++ packages/trust/experiments/families/run.ts | 130 ++++++ .../trust/experiments/families/strategies.ts | 410 ++++++++++++++++++ 3 files changed, 759 insertions(+) create mode 100644 packages/trust/experiments/families/corpus.ts create mode 100644 packages/trust/experiments/families/run.ts create mode 100644 packages/trust/experiments/families/strategies.ts diff --git a/packages/trust/experiments/families/corpus.ts b/packages/trust/experiments/families/corpus.ts new file mode 100644 index 0000000..13f3c83 --- /dev/null +++ b/packages/trust/experiments/families/corpus.ts @@ -0,0 +1,219 @@ +/** + * Pairs of commands, each labelled with what a family rule must do with them. Every name here is made + * up — projects, hosts, buckets, tickets — and stays that way. + * + * - `separate`: trusting one as a family must never trust the other. A different environment (dev + * and prod), or a read and a write, of the same tool. + * - `together`: the same thing done to a different file, row or id. Splitting them costs only extra + * approvals, but a rule that splits everything makes widening useless. + */ + +export interface Pair { + a: string + b: string + want: "separate" | "together" + /** In a word, why: shown beside a result that gets it wrong. */ + why: string +} + +export const PAIRS: readonly Pair[] = [ + /* ─── environments: must be separate ─── */ + { + a: 'mcpx db-local query "select 1"', + b: 'mcpx db-prod query "select 1"', + want: "separate", + why: "server", + }, + { + a: "docker compose -p dev up -d", + b: "docker compose -p prod up -d", + want: "separate", + why: "compose project", + }, + { + a: "docker compose --project-name dev logs web", + b: "docker compose --project-name prod logs web", + want: "separate", + why: "compose project", + }, + { + a: "docker compose -f docker-compose.dev.yml up", + b: "docker compose -f docker-compose.prod.yml up", + want: "separate", + why: "compose file", + }, + { + a: "kubectl --context dev-eu get pods", + b: "kubectl --context prod-eu get pods", + want: "separate", + why: "cluster", + }, + { + a: "kubectl get pods -n staging", + b: "kubectl get pods -n production", + want: "separate", + why: "namespace", + }, + { a: "aws --profile dev s3 ls", b: "aws --profile prod s3 ls", want: "separate", why: "account" }, + { + a: "gcloud --project acme-dev compute instances list", + b: "gcloud --project acme-prod compute instances list", + want: "separate", + why: "project", + }, + { a: "helm --kube-context dev list", b: "helm --kube-context prod list", want: "separate", why: "cluster" }, + { + a: "terraform workspace select dev", + b: "terraform workspace select prod", + want: "separate", + why: "workspace", + }, + { + a: "NODE_ENV=development npm run build", + b: "NODE_ENV=production npm run build", + want: "separate", + why: "env var", + }, + { + a: 'psql -h localhost -c "select 1"', + b: 'psql -h db.prod.acme.internal -c "select 1"', + want: "separate", + why: "database host", + }, + { a: "ssh dev-box uptime", b: "ssh prod-box uptime", want: "separate", why: "host" }, + { + a: "curl https://api.dev.acme.test/health", + b: "curl https://api.acme.test/health", + want: "separate", + why: "url", + }, + { a: "vercel deploy", b: "vercel deploy --prod", want: "separate", why: "flag" }, + { a: "fly deploy -a acme-staging", b: "fly deploy -a acme-prod", want: "separate", why: "app" }, + { a: "acmectl env use staging", b: "acmectl env use production", want: "separate", why: "unknown cli" }, + { a: "make deploy-dev", b: "make deploy-prod", want: "separate", why: "target" }, + + /* ─── targets with no environment word in them: must be separate (held out: no rule was tuned on these) ─── */ + { + a: "kubectl --context cluster-a get pods", + b: "kubectl --context cluster-b get pods", + want: "separate", + why: "cluster, unnamed", + }, + { + a: "aws --profile acme s3 ls", + b: "aws --profile acme-admin s3 ls", + want: "separate", + why: "account, unnamed", + }, + { + a: 'psql -h db1.internal -c "select 1"', + b: 'psql -h db2.internal -c "select 1"', + want: "separate", + why: "host, unnamed", + }, + { + a: 'mcpx orders-db query "select 1"', + b: 'mcpx billing-db query "select 1"', + want: "separate", + why: "server, unnamed", + }, + { + a: "docker compose -p shop up -d", + b: "docker compose -p shop-blue up -d", + want: "separate", + why: "project, unnamed", + }, + + /* ─── read vs write of one tool: must be separate ─── */ + { + a: 'mcpx db-local query "select 1"', + b: 'mcpx db-local exec "drop table orders"', + want: "separate", + why: "read vs write", + }, + { a: "kubectl get pods", b: "kubectl delete pods web-0", want: "separate", why: "read vs write" }, + { a: "aws s3 ls", b: "aws s3 rm s3://acme-assets/x.png", want: "separate", why: "read vs write" }, + { a: "terraform plan", b: "terraform apply", want: "separate", why: "read vs write" }, + { a: "npm run test", b: "npm run deploy", want: "separate", why: "script" }, + { a: "gh pr view 482", b: "gh pr merge 482", want: "separate", why: "read vs write" }, + { a: "acmectl orders list", b: "acmectl orders refund 1042", want: "separate", why: "unknown cli" }, + /* added after the first results: a flag between the tool and its subcommand */ + { + a: "docker compose -p dev up -d", + b: "docker compose -p dev down", + want: "separate", + why: "up vs down, added", + }, + { + a: "kubectl --context cluster-a get pods", + b: "kubectl --context cluster-a delete pods web-0", + want: "separate", + why: "read vs write, added", + }, + + /* ─── the same thing, another argument: fine together ─── */ + { a: "tail -4 ~/logs/app.log", b: "tail -10 ~/logs/app.log", want: "together", why: "lines" }, + { a: "cat package.json", b: "cat README.md", want: "together", why: "file" }, + { a: "ls -la src", b: "ls -la docs", want: "together", why: "folder" }, + { a: "grep -rn TODO src", b: "grep -rn FIXME packages", want: "together", why: "pattern" }, + { a: "git status --short", b: "git status", want: "together", why: "flags" }, + { a: "git log --oneline -20", b: "git log --stat -3", want: "together", why: "flags" }, + { a: "jq '.name' package.json", b: "jq '.scripts' package.json", want: "together", why: "filter" }, + { a: "sed -n 1,40p src/a.ts", b: "sed -n 1,80p src/b.ts", want: "together", why: "range" }, + { a: "head -40 README.md", b: "head -5 CHANGELOG.md", want: "together", why: "file" }, + { a: "wc -l src/a.ts", b: "wc -l src/b.ts", want: "together", why: "file" }, + { + a: 'mcpx db-local query "select 1"', + b: 'mcpx db-local query "select count(*) from orders"', + want: "together", + why: "sql", + }, + { a: "gh pr view 482", b: "gh pr view 519 --json url", want: "together", why: "pr number" }, + { + a: "docker compose -p dev logs web", + b: "docker compose -p dev logs api", + want: "together", + why: "service", + }, + { + a: "kubectl --context dev-eu get pods", + b: "kubectl --context dev-eu get pods -o wide", + want: "together", + why: "output", + }, + { a: "echo done", b: "echo ok", want: "together", why: "text" }, + { a: 'find . -name "*.md"', b: "find src -type f", want: "together", why: "query" }, + { a: "npm run test", b: "npm run test -- --watch", want: "together", why: "flags" }, + { a: "bun test", b: "bun test src/a.test.ts", want: "together", why: "file" }, + { + a: "curl https://api.dev.acme.test/health", + b: "curl https://api.dev.acme.test/version", + want: "together", + why: "same host", + }, + { a: "rg TODO", b: "rg FIXME src", want: "together", why: "pattern" }, + { a: "acmectl orders list", b: "acmectl orders list --status open", want: "together", why: "unknown cli" }, + { a: "python scripts/a.py", b: "python scripts/b.py", want: "together", why: "script file" }, + /* held out: flags whose values change and do not matter */ + { a: "kubectl get pods -o wide", b: "kubectl get pods -o yaml", want: "together", why: "output, held out" }, + { a: "git log -n 5", b: "git log -n 20", want: "together", why: "count, held out" }, + { + a: "docker compose logs --tail 50 web", + b: "docker compose logs --tail 200 web", + want: "together", + why: "count, held out", + }, + { a: "gh pr list --state open", b: "gh pr list --state closed", want: "together", why: "filter, held out" }, + /* held out: short flags that mean something else here */ + { + a: "grep -c error build.log", + b: "grep -c warn build.log", + want: "together", + why: "-c is count, held out", + }, + { a: "ls -a src", b: "ls -a docs", want: "together", why: "-a is all, held out" }, + { a: "head -n 40 README.md", b: "head -n 5 CHANGELOG.md", want: "together", why: "-n is lines, held out" }, +] + +/** Every command in the corpus, for a strategy that learns from what it has seen (D). */ +export const CORPUS_COMMANDS: readonly string[] = [...new Set(PAIRS.flatMap((pair) => [pair.a, pair.b]))] diff --git a/packages/trust/experiments/families/run.ts b/packages/trust/experiments/families/run.ts new file mode 100644 index 0000000..e4cc2b5 --- /dev/null +++ b/packages/trust/experiments/families/run.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env bun +/** + * Scores every family rule in `strategies.ts`: + * + * 1. on the labelled pairs (`corpus.ts`, all made up): a `separate` pair given one family is a safety + * miss; a `together` pair split is a usefulness miss; + * 2. on the Trust ledgers on this machine, as counts only — how many families each rule makes, how + * big, and how many mix commands that name different environments. No command from a ledger is + * printed or written anywhere. + * + * bun packages/trust/experiments/families/run.ts the summary + * bun packages/trust/experiments/families/run.ts --json the same, as JSON (for the results page) + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { readSubject } from "../../src/core/family.ts" +import { parseLines } from "../../src/core/ledger.ts" +import { type Command, parse } from "../../src/core/shell.ts" +import { CORPUS_COMMANDS, PAIRS, type Pair } from "./corpus.ts" +import { envWords, historyOf, STRATEGIES } from "./strategies.ts" + +const one = (line: string): Command => { + const read = parse(line) + if (read.kind !== "commands" || read.commands.length !== 1) throw new Error(`not one command: ${line}`) + return read.commands[0] as Command +} + +/** Every bash command in this machine's Trust ledgers, once each. */ +function localCommands(): Command[] { + const base = join( + process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), + "opencode-cockpit", + "trust", + ) + if (!existsSync(base)) return [] + const subjects = new Set() + for (const dir of readdirSync(base)) { + const file = join(base, dir, "events.ndjson") + if (!existsSync(file)) continue + for (const event of parseLines(`${readFileSync(file, "utf8")}\n`).events) { + if (!("items" in event) || event.permission !== "bash") continue + for (const item of event.items) subjects.add(item.subject) + } + } + return [...subjects].flatMap((subject) => { + const read = readSubject(subject) + return read ? [read.command] : [] + }) +} + +const corpus = CORPUS_COMMANDS.map(one) +const local = localCommands() +const history = historyOf([...corpus, ...local]) + +interface Result { + id: string + name: string + describe: string + safetyMisses: { pair: Pair; family: string }[] + splits: { pair: Pair; a: string; b: string }[] + separateTotal: number + togetherTotal: number + local: { commands: number; families: number; singletons: number; largest: number; envMixed: number } +} + +const results: Result[] = STRATEGIES.map((strategy) => { + const family = (command: Command) => strategy.family(command, history) + const safetyMisses: Result["safetyMisses"] = [] + const splits: Result["splits"] = [] + for (const pair of PAIRS) { + const a = family(one(pair.a)) + const b = family(one(pair.b)) + if (pair.want === "separate" && a === b) safetyMisses.push({ pair, family: a }) + if (pair.want === "together" && a !== b) splits.push({ pair, a, b }) + } + const groups = new Map() + for (const command of local) { + const key = family(command) + groups.set(key, [...(groups.get(key) ?? []), command]) + } + const sizes = [...groups.values()].map((group) => group.length) + /** A family whose commands name different environments (or one names one and another none). */ + const envMixed = [...groups.values()].filter((group) => { + const kinds = new Set( + group.map((command) => + envWords([...command.env, ...command.argv].join(" ")) + /** `test` is left out, as the rules leave it out: it is mostly a file name (`a.test.ts`). */ + .filter((word) => word !== "test" && word !== "testing") + .sort() + .join(","), + ), + ) + return kinds.size > 1 + }).length + return { + id: strategy.id, + name: strategy.name, + describe: strategy.describe, + safetyMisses, + splits, + separateTotal: PAIRS.filter((pair) => pair.want === "separate").length, + togetherTotal: PAIRS.filter((pair) => pair.want === "together").length, + local: { + commands: local.length, + families: groups.size, + singletons: sizes.filter((size) => size === 1).length, + largest: Math.max(0, ...sizes), + envMixed, + }, + } +}) + +if (process.argv.includes("--json")) { + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`) +} else { + console.log( + `corpus: ${PAIRS.length} pairs · this machine: ${local.length} distinct commands (counts only)\n`, + ) + for (const result of results) { + const { local: l } = result + console.log( + `${result.id.padEnd(3)} ${result.name.padEnd(36)} safety misses ${String(result.safetyMisses.length).padStart(2)}/${result.separateTotal}` + + ` splits ${String(result.splits.length).padStart(2)}/${result.togetherTotal}` + + ` | local: ${l.families} families, ${l.singletons} of one, largest ${l.largest}, env-mixed ${l.envMixed}`, + ) + for (const miss of result.safetyMisses) console.log(` ✗ merged (${miss.pair.why}): ${miss.family}`) + } +} diff --git a/packages/trust/experiments/families/strategies.ts b/packages/trust/experiments/families/strategies.ts new file mode 100644 index 0000000..d09efcb --- /dev/null +++ b/packages/trust/experiments/families/strategies.ts @@ -0,0 +1,410 @@ +/** + * Candidate rules for where a command's family ends. Each takes a parsed command (and, for the rule + * that learns, what has been seen before) and returns its family as text. Two commands with the same + * text are one family: widening one widens both. + */ + +import { posix } from "node:path" +import { unwrapOnce } from "../../src/core/danger.ts" +import { familyOf } from "../../src/core/family.ts" +import type { Command } from "../../src/core/shell.ts" +import { signature } from "../../src/core/signature.ts" + +/** What a learning rule has seen: per program, per position, how often each word stood there. */ +export type History = Map>> + +export interface Strategy { + id: string + name: string + describe: string + family: (command: Command, history: History) => string +} + +/** A plain word: a subcommand, a server, a script name. Not a flag, number, path, file or text. */ +const NAME = /^[A-Za-z][A-Za-z0-9_:-]*$/ +export const isName = (word: string) => NAME.test(word) +const isFlag = (word: string) => word.startsWith("-") && word !== "-" && word !== "--" + +/** Words that name an environment, as a whole word or a part of one: `db-prod`, `acme-staging`. */ +const ENV = new Set([ + "prod", + "production", + "prd", + "live", + "staging", + "stage", + "stg", + "preprod", + "dev", + "develop", + "development", + "local", + "localhost", + "qa", + "uat", + "test", + "testing", + "sandbox", +]) +export const envWords = (text: string): string[] => + text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => ENV.has(word)) + +/** Long flags whose value says which target a command acts on, whatever the tool. */ +const TARGET_FLAGS = new Set([ + "context", + "kube-context", + "kubeconfig", + "profile", + "project", + "project-name", + "namespace", + "host", + "hostname", + "server", + "cluster", + "region", + "account", + "env", + "environment", + "stage", + "target", + "app", + "database", + "db", + "url", + "endpoint", + "workspace", + "file", + "config", + "org", + "team", + "site", + "tenant", +]) +/** Short ones, as most tools use them: -p project, -n namespace, -h host, -a app, -c context, -f file, -e env. */ +const TARGET_SHORT = new Set(["p", "n", "h", "a", "c", "f", "e"]) + +/** Wrappers (`sudo`, `timeout 5`) kept by name, env vars by name, then the program and its words. */ +function split(command: Command): { lead: string[]; program?: string; args: string[] } { + const lead: string[] = command.env.map((word) => `${word.slice(0, word.indexOf("="))}=…`) + let rest: readonly string[] = command.argv + for (let depth = 0; depth < 8; depth++) { + const once = unwrapOnce(rest) + if (!once) break + lead.push(once.wrapper) + rest = once.rest + } + const [program, ...args] = rest + return program === undefined ? { lead, args: [] } : { lead, program: posix.basename(program), args } +} + +/** The leading plain words after the program, up to `cap`; stops at the first that is not one. */ +function leading(args: readonly string[], cap: number): string[] { + const out: string[] = [] + for (const word of args) { + if (out.length >= cap || !isName(word)) break + out.push(word) + } + return out +} + +/** Plain words anywhere after the program, flags stepped over, up to `cap`. */ +function names(args: readonly string[], cap: number): string[] { + const out: string[] = [] + for (const word of args) { + if (out.length >= cap) break + if (isFlag(word)) continue + if (!isName(word)) break + out.push(word) + } + return out +} + +/** + * Flags that name an environment, wherever they are: `--env=prod`, `-p prod`, `--context prod-eu`, + * `--prod`. A flag's value is the word after it only when that word names an environment, so `-d web` + * is never read as `-d`'s value. + */ +function envFlags(args: readonly string[]): string[] { + const out: string[] = [] + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + if (!isFlag(word)) continue + const eq = word.indexOf("=") + if (eq > 0) { + if (envWords(word.slice(eq + 1)).length > 0) out.push(word) + continue + } + if (envWords(word.replace(/^-+/, "")).length > 0) { + out.push(word) + continue + } + const next = args[i + 1] + if (next !== undefined && !isFlag(next) && envWords(next).length > 0) { + out.push(word, next) + i++ + } + } + return out +} + +/** Env vars whose value names an environment, whole: `NODE_ENV=production`. */ +const envVars = (command: Command) => + command.env.filter((word) => envWords(word.slice(word.indexOf("=") + 1)).length > 0) + +/** + * Any other word that names an environment: a host, a URL, a file (`db.prod.internal`, `api.dev…`). + * A URL counts by its host — `/health` and `/version` on one host are one family — and `test` does not + * count here, where it is mostly a file name (`a.test.ts`), not a place. + */ +const envArgs = (args: readonly string[]) => + args.flatMap((word) => { + if (isFlag(word) || isName(word)) return [] + const url = /^[a-z]+:\/\/([^/]+)/i.exec(word) + const part = url ? (url[1] as string) : word + return envWords(part).some((env) => env !== "test" && env !== "testing") ? [url ? part : word] : [] + }) + +const join = (...parts: (string | undefined)[][]) => + parts + .flat() + .filter((part): part is string => part !== undefined && part !== "") + .join(" ") + +/** Positions a learning rule treats as part of the family: a word seen more than once there, or a position with few values. */ +function learned(program: string, args: readonly string[], history: History, cap: number): string[] { + const positions = history.get(program) + const out: string[] = [] + for (const word of args) { + if (out.length >= cap || isFlag(word) || !isName(word)) break + const seen = positions?.get(out.length) + const distinct = seen?.size ?? 0 + const count = seen?.get(word) ?? 0 + if (count >= 2 || (distinct > 0 && distinct <= 4)) out.push(word) + else break + } + return out +} + +/** Every program's leading plain words, by position, counted over `commands`. */ +export function historyOf(commands: readonly Command[]): History { + const history: History = new Map() + for (const command of commands) { + const { program, args } = split(command) + if (!program) continue + const positions = history.get(program) ?? new Map>() + history.set(program, positions) + leading(args, 3).forEach((word, at) => { + const words = positions.get(at) ?? new Map() + positions.set(at, words) + words.set(word, (words.get(word) ?? 0) + 1) + }) + } + return history +} + +/** The value of every flag whose name means a target, when the value is not a number: `--context cluster-a`, `-p shop`. */ +function targetFlags(args: readonly string[]): string[] { + const valued: string[] = [] + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + if (!isFlag(word)) continue + const eq = word.indexOf("=") + const flag = (eq > 0 ? word.slice(0, eq) : word).replace(/^-+/, "") + const value = eq > 0 ? word.slice(eq + 1) : args[i + 1] + const long = word.startsWith("--") + const target = long ? TARGET_FLAGS.has(flag) : flag.length === 1 && TARGET_SHORT.has(flag) + /** A value that starts with a digit is a count or a range (`-n 40`, `-n 1,40p`), never a target. */ + if (!target || value === undefined || isFlag(value) || /^\d/.test(value)) continue + if (eq > 0) valued.push(word) + else { + valued.push(word, value) + i++ + } + } + return valued +} + +/** Whether `word` is a flag that names a target, and the value it carries (inline or the next word). */ +function targetAt(args: readonly string[], i: number): { words: string[]; skip: number } | undefined { + const word = args[i] as string + if (!isFlag(word)) return undefined + const eq = word.indexOf("=") + const flag = (eq > 0 ? word.slice(0, eq) : word).replace(/^-+/, "") + const value = eq > 0 ? word.slice(eq + 1) : args[i + 1] + const target = word.startsWith("--") ? TARGET_FLAGS.has(flag) : flag.length === 1 && TARGET_SHORT.has(flag) + const named = envWords(eq > 0 ? word.slice(eq + 1) : "").length > 0 || envWords(flag).length > 0 + if (named && eq > 0) return { words: [word], skip: 0 } + if (named && eq < 0 && envWords(flag).length > 0) return { words: [word], skip: 0 } + /** A value that starts with a digit is a count or a range (`-n 40`, `-n 1,40p`), never a target. */ + if (!target || value === undefined || isFlag(value) || /^\d/.test(value)) { + const next = args[i + 1] + return eq < 0 && next !== undefined && !isFlag(next) && envWords(next).length > 0 + ? { words: [word, next], skip: 1 } + : undefined + } + return eq > 0 ? { words: [word], skip: 0 } : { words: [word, value], skip: 1 } +} + +/** + * The command read in order: plain words (at most 3) and target flags with their values, until the + * first other flag or argument. `docker compose -p dev up -d` → `compose -p dev up`. + */ +function walk(args: readonly string[]): string[] { + const out: string[] = [] + let names = 0 + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + const target = targetAt(args, i) + if (target) { + out.push(...target.words) + i += target.skip + continue + } + if (isFlag(word) || !isName(word) || names >= 3) break + out.push(word) + names++ + } + /** Target flags further on still count: `kubectl get pods -o wide -n prod`. */ + for (let i = out.length; i < args.length; i++) { + const target = targetAt(args, i) + if (target && !out.includes(target.words[0] as string)) { + out.push(...target.words) + i += target.skip + } + } + return out +} + +export const STRATEGIES: readonly Strategy[] = [ + { + id: "A", + name: "Today", + describe: "The built-in table of tools with subcommands; anything else is its program alone.", + family: (command) => familyOf("bash", signature(command)), + }, + { + id: "B", + name: "Leading names", + describe: + "The program and the plain words right after it, up to 3, stopping at the first flag, number, path, file or text.", + family: (command) => { + const { lead, program, args } = split(command) + return join(lead, [program], leading(args, 3)) + }, + }, + { + id: "B2", + name: "Names, flags skipped", + describe: "As B, but stepping over flags: `compose -p prod up` keeps compose, prod and up.", + family: (command) => { + const { lead, program, args } = split(command) + return join(lead, [program], names(args, 3)) + }, + }, + { + id: "C", + name: "Leading names + environment flags", + describe: + "B, plus any flag (or env var) whose value names an environment: `-p prod`, `--context prod-eu`, `NODE_ENV=production`.", + family: (command) => { + const { lead, program, args } = split(command) + return join(envVars(command), lead, [program], envFlags(args), leading(args, 3)) + }, + }, + { + id: "C2", + name: "C + environment anywhere", + describe: "C, plus any argument that names an environment: a host or URL with `prod`/`dev` in it.", + family: (command) => { + const { lead, program, args } = split(command) + return join(envVars(command), lead, [program], envFlags(args), envArgs(args), leading(args, 3)) + }, + }, + { + id: "F", + name: "C2 + every flag value that is a name", + describe: + "C2, plus the word after any flag when it is a plain name (`--context cluster-a`, `-p shop`): no environment word needed.", + family: (command) => { + const { lead, program, args } = split(command) + const valued: string[] = [] + for (let i = 0; i < args.length; i++) { + const word = args[i] as string + const next = args[i + 1] + if ( + isFlag(word) && + !word.includes("=") && + next !== undefined && + !isFlag(next) && + /^[A-Za-z][A-Za-z0-9._-]*$/.test(next) + ) { + valued.push(word, next) + i++ + } + } + return join(envVars(command), lead, [program], envFlags(args), envArgs(args), valued, leading(args, 3)) + }, + }, + { + id: "G", + name: "Names + target flags, in order", + describe: + "Read in order: the program, its plain words, and any flag that means a target (--context, --profile, --project, --namespace, --host, --env, -p -n -h -a -c -f -e) with its value, until the first other flag or argument; plus env vars and hosts that name an environment.", + family: (command) => { + const { lead, program, args } = split(command) + /** An env var named in full (`NODE_ENV=production`) replaces its `NAME=…` form. */ + const full = envVars(command) + const kept = lead.filter((word) => !full.some((env) => word === `${env.slice(0, env.indexOf("="))}=…`)) + return join(full, kept, [program], walk(args), envArgs(args)) + }, + }, + { + id: "H", + name: "G + learned", + describe: "G's flags and environments, with D deciding how many leading words belong.", + family: (command, history) => { + const { lead, program, args } = split(command) + if (!program) return join(lead) + return join( + envVars(command), + lead, + [program], + envFlags(args), + envArgs(args), + targetFlags(args), + learned(program, args, history, 3), + ) + }, + }, + { + id: "D", + name: "Learned", + describe: + "A leading word is part of the family when it has been seen before in that place, or the place only ever holds a few words.", + family: (command, history) => { + const { lead, program, args } = split(command) + return program ? join(lead, [program], learned(program, args, history, 3)) : join(lead) + }, + }, + { + id: "E", + name: "C2 + learned", + describe: "C2's environments, with D deciding how many leading words belong.", + family: (command, history) => { + const { lead, program, args } = split(command) + if (!program) return join(lead) + return join( + envVars(command), + lead, + [program], + envFlags(args), + envArgs(args), + learned(program, args, history, 3), + ) + }, + }, +] From 7fd7aba08e9aed873bfde2423318a25a30350a65 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 13:05:21 -0500 Subject: [PATCH 2/3] =?UTF-8?q?Trust:=20a=20family=20is=20what=20a=20comma?= =?UTF-8?q?nd=20does=20and=20where=20=E2=80=94=20any=20CLI,=20no=20table;?= =?UTF-8?q?=20production=20and=20SQL=20writes=20cost=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A family is what `w` ("trust any …") covers. It was a program and, for tools in a table, its subcommand — so `mcpx db-local` and `mcpx db-prod` were one family, and so were `docker compose -p dev up` and `-p prod up`: widening either trusted production along with dev. Measured on labelled pairs (packages/trust/experiments/families), the old rule merged 23 of 32 that must stay apart. Now, for any CLI: the program and the plain words after it (up to three, until the first argument), any flag that names a target with its value (`--context`, `--profile`, `-p`, `-n`, `--host`…), and any env var, host or name that names an environment. Standard utilities (`ls`, `cat`, `grep`…) take no subcommand, so their family stays the program. On the same pairs: none merged, one split (a service name), kept as test/families.test.ts. - `mcpx db-prod execute_sql …` → `mcpx db-prod execute_sql`; `docker compose -p dev up -d` → `docker compose -p dev up`; `kubectl get pods -o wide -n prod` → `kubectl get pods -n prod`. - Anything that names production (`prod`, `production`, `prd`, `live`, as a word or part of one) is dangerous: the higher count, and its family is never widened. - SQL that writes, in any program's argument (`--sql "delete from …"`), is dangerous, so a widened `mcpx db-local execute_sql` answers reads and still asks about writes. A family widened under the old rule matches nothing now: Trust asks again, never more. Co-Authored-By: Claude Opus 5.5 --- packages/trust/src/core/danger.ts | 39 ++- packages/trust/src/core/family.ts | 366 ++++++++++++++++++++------- packages/trust/test/danger.test.ts | 34 ++- packages/trust/test/families.test.ts | 30 +++ packages/trust/test/family.test.ts | 34 ++- packages/trust/test/widen.test.ts | 4 +- 6 files changed, 396 insertions(+), 111 deletions(-) create mode 100644 packages/trust/test/families.test.ts diff --git a/packages/trust/src/core/danger.ts b/packages/trust/src/core/danger.ts index 8dd88aa..79e1d2b 100644 --- a/packages/trust/src/core/danger.ts +++ b/packages/trust/src/core/danger.ts @@ -391,9 +391,46 @@ function judge(argv: readonly string[]): string | undefined { return unwrapped.reason } +/** + * Words that name production, as a whole word or a part of one (`db-prod`, `prod-eu`, + * `NODE_ENV=production`). Production costs more trust whatever the command does there, and its + * family is never widened: "trust any mcpx db-prod execute_sql" is not a rule anyone means to make. + */ +const PRODUCTION = new Set(["prod", "production", "prd", "live"]) +const namesProduction = (word: string) => + word + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((part) => PRODUCTION.has(part)) + +/** + * SQL that writes, in any program's argument — an MCP tool's `--sql`, a client's `-c`, a quoted + * statement. Read only in an argument of more than one word, so a subcommand called `drop` or a + * commit message that says "update readme" is not read as SQL; `update … set` has to say `set`. + */ +const WRITE_SQL = + /^\s*(?:(drop|truncate)\s+(?:table|database|schema|index|view)\b|(delete)\s+from\b|(update)\s+\S+\s+set\b|(insert)\s+into\b|(alter)\s+table\b|(create)\s+(?:table|database|schema|index|view)\b|(grant)\s+\S+.*\bon\b)/i +function writesSql(argv: readonly string[]): string | undefined { + for (const word of argv) { + if (!/\s/.test(word)) continue + for (const statement of word.split(";")) { + const found = WRITE_SQL.exec(statement) + if (found) + return `sql ${found + .slice(1) + .find((part) => part !== undefined) + ?.toLowerCase()}` + } + } + return undefined +} + /** Why a command costs more trust, in a few words — or nothing when it costs the usual. */ export function dangerOf(command: Command): string | undefined { - return judge(command.argv) + const found = judge(command.argv) ?? writesSql(command.argv) + if (found) return found + if (command.env.some((word) => namesProduction(word.slice(word.indexOf("=") + 1)))) return "production" + return command.argv.slice(1).some(namesProduction) ? "production" : undefined } export function dangerous(command: Command): boolean { diff --git a/packages/trust/src/core/family.ts b/packages/trust/src/core/family.ts index 9f2e44e..041ba37 100644 --- a/packages/trust/src/core/family.ts +++ b/packages/trust/src/core/family.ts @@ -11,14 +11,22 @@ * * The rules, and why each one leans the way it does: * - * - **Global flags are not part of the family**: `git -C /x status` is `git status`, and - * `docker compose -p prod down -v` is `docker compose down` — the flags change *where*, the - * subcommand says *what*. Read with danger.ts's own tables, so the two never disagree on where the - * subcommand is. + * - **The plain words after the program, up to three**: `mcpx db-local execute_sql`, `gh pr view`, + * `npm run test`. Reading stops at the first argument — a number, a path, a file, quoted text — so + * `cat a.json` and `cat b.json` are one family. No list of tools: any CLI reads the same way. + * - **So is the target**: a flag that says which one (`--context`, `--profile`, `-p`, `-n`, `--host`…) + * with its value, and any word or env var that names an environment (`db-prod`, + * `NODE_ENV=production`, a host with `dev` in it). `docker compose -p dev up` and `-p prod up` are + * two families: a widening that covered both trusted production along with dev. Flags that say + * nothing about the target (`git -C dir`, `-o wide`) are not part of it. Measured against the rule + * this replaced (packages/trust/experiments/families): it merged 23 of 32 pairs that must stay + * apart; this one merges none. * - **A wrapper is part of it**: `sudo ls` is not `ls`, and neither is `timeout 5 ls`. Trusting any * `ls` must not quietly cover running it as root. * - **So is where it runs and what it is told**: `(in web) bun test` and `NODE_ENV=… npm run build` * are their own families. A directory or an environment changes what the same words do. + * - **Too fine is the safe side**: `echo done` and `echo ok` are two families. That costs approvals, + * never trust. * - **Redirections are not**: `ls > out.txt` groups under `ls` — but a widened family does not cover * it (`outside`), because writing a file is not what "any ls" was agreed to mean. * @@ -27,7 +35,7 @@ */ import { posix } from "node:path" -import { COMPOSE_GLOBALS, dangerOf, subcommand, TOOL_GLOBALS, unwrapOnce } from "./danger.ts" +import { dangerOf, subcommand, TOOL_GLOBALS, unwrapOnce } from "./danger.ts" import { canonical } from "./rules.ts" import { type Command, parse } from "./shell.ts" import { quote } from "./signature.ts" @@ -93,100 +101,272 @@ function redirections( /* ─── families ───────────────────────────────────────────────────────────────────────────────── */ -/** Leading words that are not flags, at most `count` of them. */ -function lead(words: readonly string[], count: number): string[] { - const out: string[] = [] - for (const word of words) { - if (out.length >= count || word.startsWith("-")) break - out.push(word) - } - return out +/** A plain word: a subcommand, a server, a script name. Not a flag, number, path, file or text. */ +const NAME = /^[A-Za-z][A-Za-z0-9_:-]*$/ +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") + +/** + * The place a word names, if any: a URL's host, a host or a name (`db-prod`, `api.dev.acme.test`). + * A file path names no place — `~/.local/share/…` is not the local environment. + */ +function placeOf(word: string): string | undefined { + const url = /^[a-z]+:\/\/([^/]+)/i.exec(word) + const place = url ? (url[1] as string) : word.includes("/") ? undefined : word + return place !== undefined && namesPlace(place) ? place : undefined } -/** One subcommand word, or two after the ones listed: `git stash drop`, `npm run test`. */ -const pairs = - (...two: string[]) => - (words: readonly string[]) => - lead(words, two.includes(words[0] ?? "") ? 2 : 1) -const one = (words: readonly string[]) => lead(words, 1) -const two = (words: readonly string[]) => lead(words, 2) - -/** Docker's management commands: `docker container rm` is about containers, then what to do. */ -const OBJECTS = new Set([ - "builder", - "buildx", - "config", - "container", +/** Long flags whose value says which target a command acts on, whatever the tool. */ +const TARGET_FLAGS = new Set([ "context", - "image", - "manifest", - "network", - "node", - "plugin", - "secret", - "service", - "stack", - "swarm", - "system", - "trust", - "volume", + "kube-context", + "kubeconfig", + "profile", + "project", + "project-name", + "namespace", + "host", + "hostname", + "server", + "cluster", + "region", + "account", + "env", + "environment", + "stage", + "target", + "app", + "database", + "db", + "url", + "endpoint", + "workspace", + "file", + "config", + "org", + "team", + "site", + "tenant", +]) +/** The short ones most tools give them: -p project, -n namespace, -h host, -a app, -c context, -f file, -e env. */ +const TARGET_SHORT = new Set(["p", "n", "h", "a", "c", "f", "e"]) + +/** + * Standard utilities, which never take a subcommand: the word after them is an argument (`ls src`, + * `cat Makefile`, `echo done`), so their family is the program alone, plus any target or environment + * it names. Their short flags are their own (`grep -c` counts, `ls -a` lists all), never a target. A + * fixed list of what Unix ships, not of tools: any other CLI is read by the general rule. + */ +const UTILITIES = new Set([ + "ls", + "cat", + "head", + "tail", + "wc", + "grep", + "egrep", + "fgrep", + "rg", + "ag", + "find", + "fd", + "echo", + "printf", + "pwd", + "which", + "whereis", + "type", + "file", + "stat", + "tree", + "du", + "df", + "sort", + "uniq", + "cut", + "tr", + "sed", + "awk", + "gawk", + "jq", + "yq", + "diff", + "cmp", + "less", + "more", + "touch", + "mkdir", + "cp", + "mv", + "ln", + "basename", + "dirname", + "realpath", + "readlink", + "date", + "sleep", + "true", + "false", + "test", + "xxd", + "od", + "hexdump", + "column", + "nl", + "tee", + "comm", + "join", + "paste", + "fold", + "fmt", + "rev", + "seq", + "yes", + "whoami", + "id", + "uname", + "hostname", + "uptime", + "ps", + "top", + "env", + "printenv", + "open", + "pbcopy", + "pbpaste", + "base64", + "md5", + "md5sum", + "shasum", + "sha256sum", + "zcat", + "gzip", + "gunzip", + "tar", + "zip", + "unzip", + "chmod", + "chown", ]) -function container(words: readonly string[]): string[] { - if (words[0] === "compose") return ["compose", ...one(subcommand(words.slice(1), COMPOSE_GLOBALS))] - return lead(words, OBJECTS.has(words[0] ?? "") ? 2 : 1) +/** + * Flags before a subcommand that take a value and say nothing about the target — `git -C dir`, + * `npm -w pkg` — stepped over with their value, so the subcommand after them is still read. + */ +const VALUE_GLOBALS: Readonly> = { + ...TOOL_GLOBALS, + npm: ["-w", "--workspace", "--prefix", "-C"], + pnpm: ["-F", "--filter", "-C", "--dir"], + yarn: ["--cwd"], + bun: ["--cwd"], + make: ["-C", "-f", "--file", "--directory"], } -interface Tool { - /** Flags before the subcommand that take a value, besides danger.ts's own. */ - globals?: readonly string[] - /** The subcommand words that belong to the family, from the words after the globals. */ - take: (words: readonly string[]) => string[] +/** + * The flag at `i` when it names a target, with the words it keeps: `--context cluster-a`, `-p dev`, + * `--env=prod`, `--prod`. A value that starts with a digit is a count or a range (`-n 40`), never a + * target; a flag that is no target still counts when its value names an environment (`-d prod-db`). + */ +function targetAt( + args: readonly string[], + i: number, + shortTargets: boolean, +): { words: string[]; skip: number } | undefined { + const word = args[i] as string + if (!isFlag(word)) return undefined + const eq = word.indexOf("=") + const name = (eq > 0 ? word.slice(0, eq) : word).replace(/^-+/, "") + const value = eq > 0 ? word.slice(eq + 1) : args[i + 1] + if (namesPlace(name)) return { words: [word], skip: 0 } + if (eq > 0 && placeOf(value ?? "") !== undefined) return { words: [word], skip: 0 } + const target = word.startsWith("--") + ? TARGET_FLAGS.has(name) + : shortTargets && name.length === 1 && TARGET_SHORT.has(name) + if (value === undefined || isFlag(value) || /^\d/.test(value)) return undefined + if (target) return eq > 0 ? { words: [word], skip: 0 } : { words: [word, value], skip: 1 } + return eq < 0 && placeOf(value) !== undefined ? { words: [word, value], skip: 1 } : undefined } /** - * Programs with subcommands. Anything not here is its program alone: `ls`, `echo`, `cat`. A package - * manager's `run` keeps the script, because `npm run test` and `npm run deploy` are not one thing. + * The family words after the program, read in order: plain words (at most `MAX_NAMES`) and target flags + * with their values, until the first other flag or argument — `compose -p dev up` from + * `docker compose -p dev up -d`. Target flags and words naming an environment further on still count: + * `kubectl get pods -o wide -n prod` is `kubectl get pods -n prod`, `ssh -p 2222 prod-box` keeps + * `prod-box`, `curl https://api.dev.acme.test/x` keeps the host. A file path never counts. */ -const TOOLS: Record = { - git: { - take: pairs("stash", "remote", "submodule", "worktree", "notes", "bisect", "lfs", "sparse-checkout"), - }, - docker: { take: container }, - podman: { take: container }, - nerdctl: { take: container }, - "docker-compose": { take: one }, - kubectl: { take: pairs("rollout", "config", "auth", "certificate", "set") }, - helm: { take: pairs("repo", "plugin") }, - gh: { take: two }, - aws: { take: two }, - gcloud: { take: two }, - terraform: { take: pairs("state", "workspace") }, - tofu: { take: pairs("state", "workspace") }, - npm: { globals: ["-w", "--workspace", "--prefix", "-C"], take: pairs("run", "run-script", "exec") }, - pnpm: { globals: ["-F", "--filter", "-C", "--dir"], take: pairs("run", "exec", "dlx") }, - yarn: { - globals: ["--cwd"], - take: (words) => - lead(words, words[0] === "workspace" ? 3 : ["run", "exec", "dlx"].includes(words[0] ?? "") ? 2 : 1), - }, - bun: { globals: ["--cwd"], take: pairs("run", "x", "pm", "create") }, - deno: { take: pairs("task") }, - npx: { take: one }, - bunx: { take: one }, - pnpx: { take: one }, - cargo: { take: one }, - go: { take: pairs("mod", "work", "tool") }, - pip: { take: one }, - pip3: { take: one }, - uv: { take: pairs("pip", "tool", "python") }, - brew: { take: one }, - systemctl: { take: one }, - launchctl: { take: one }, - make: { globals: ["-C", "-f", "--file", "--directory"], take: one }, +function walk(args: readonly string[], globals: readonly string[], limit: number): string[] { + const out: string[] = [] + let names = 0 + let i = 0 + for (; i < args.length; i++) { + const word = args[i] as string + const target = targetAt(args, i, limit > 0) + if (target) { + out.push(...target.words) + i += target.skip + continue + } + if (isFlag(word) && globals.includes(word.split("=")[0] as string)) { + if (!word.includes("=")) i++ + continue + } + if (isFlag(word) || !NAME.test(word) || names >= limit) break + out.push(word) + names++ + } + for (; i < args.length; i++) { + const word = args[i] as string + const target = targetAt(args, i, limit > 0) + if (target) { + if (!out.includes(target.words[0] as string)) out.push(...target.words) + i += target.skip + continue + } + const place = isFlag(word) ? undefined : placeOf(word) + if (place !== undefined && !out.includes(place)) out.push(place) + } + return out } -/** The words that name a command's family, wrappers included, environment and redirections not. */ +/** + * The words that name a command's family: its wrappers, its program, and what `walk` keeps. Wrappers + * are part of it (`sudo ls` is not `ls`); redirections are not (`ls > out.txt` is `ls`, and a + * widening does not cover it: `outside`). + */ function familyWords(argv: readonly string[], ops?: readonly number[]): string[] { let rest: readonly string[] = redirections(argv, ops).words const head: string[] = [] @@ -199,18 +379,16 @@ function familyWords(argv: readonly string[], ops?: readonly number[]): string[] const [program, ...args] = rest if (program === undefined) return head const name = posix.basename(program) - const tool = TOOLS[name] - if (!tool) return [...head, program] - const globals = [...(TOOL_GLOBALS[name] ?? []), ...(tool.globals ?? [])] - return [...head, program, ...tool.take(subcommand(args, globals))] + return [...head, program, ...walk(args, VALUE_GLOBALS[name] ?? [], UTILITIES.has(name) ? 0 : MAX_NAMES)] } -/** `NODE_ENV=prod` as a family says it: the name, not the value. */ -const envName = (word: string) => `${word.slice(0, word.indexOf("="))}=…` +/** `NODE_ENV=…`: the name, not the value — unless the value names an environment (`NODE_ENV=production`). */ +const envWord = (word: string) => + namesPlace(word.slice(word.indexOf("=") + 1)) ? word : `${word.slice(0, word.indexOf("="))}=…` function bashFamily(command: Command, place: string | undefined): string { const words = [ - ...command.env.map(envName), + ...command.env.map(envWord), ...familyWords(command.argv, command.redirects).map(quote), ].join(" ") return place === undefined ? words : `(in ${quote(place)}) ${words}` @@ -248,7 +426,7 @@ export function widenable(permission: string, family: string): { ok: true } | { if (name === "bash") { const command = familyCommand(family) if (!command || command.argv.length === 0) return { ok: false, why: "it cannot be read as one command" } - const danger = dangerOf({ env: [], argv: command.argv }) + const danger = dangerOf(command) return danger ? { ok: false, diff --git a/packages/trust/test/danger.test.ts b/packages/trust/test/danger.test.ts index 572742b..880a704 100644 --- a/packages/trust/test/danger.test.ts +++ b/packages/trust/test/danger.test.ts @@ -110,7 +110,9 @@ describe("clusters, clouds and infrastructure", () => { "kubectl delete pod x": "kubectl delete", "kubectl -n prod delete pod x": "kubectl delete", "kubectl --context prod drain node-1": "kubectl drain", - "kubectl -n prod get pods": undefined, + /** Reading production is still production: it costs the higher count, and no widening covers it. */ + "kubectl -n prod get pods": "production", + "kubectl -n staging get pods": undefined, "terraform destroy": "terraform destroy", "terraform apply -auto-approve": "terraform apply", "terraform -chdir=infra apply": "terraform apply", @@ -198,3 +200,33 @@ describe("everyday commands stay ordinary", () => { test("environment prefixes do not hide a dangerous program", () => { expect(reason("NODE_ENV=production npm publish")).toBe("publish") }) + +describe("production, wherever it is named", () => { + table({ + 'mcpx db-prod execute_sql --sql "select 1"': "production", + "docker compose -p prod up -d": "production", + "aws --profile production s3 ls": "production", + "ssh -p 2222 prod-box uptime": "production", + "mcpx db-local list_tables": undefined, + "ls products": undefined, + "git log --oneline": undefined, + }) + + test("an env var that names it", () => { + expect(dangerOf({ env: ["NODE_ENV=production"], argv: ["npm", "run", "build"] })).toBe("production") + expect(dangerOf({ env: ["NODE_ENV=development"], argv: ["npm", "run", "build"] })).toBeUndefined() + }) +}) + +describe("SQL that writes, in any program's argument", () => { + table({ + 'mcpx db-local execute_sql --sql "delete from orders where id = 4"': "sql delete", + 'mcpx db-local execute_sql --sql "update orders set paid = true"': "sql update", + 'mcpx db-local execute_sql --sql "insert into orders values (1)"': "sql insert", + 'mcpx db-local execute_sql --sql "select 1; drop table orders"': "sql drop", + 'mcpx db-local execute_sql --sql "select * from orders"': undefined, + /** Not SQL: one word, or words that only start like it. */ + 'git commit -m "update readme"': undefined, + "acmectl drop cache": undefined, + }) +}) diff --git a/packages/trust/test/families.test.ts b/packages/trust/test/families.test.ts new file mode 100644 index 0000000..7b8c5e0 --- /dev/null +++ b/packages/trust/test/families.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { PAIRS } from "../experiments/families/corpus.ts" +import { familyOf } from "../src/core/family.ts" +import { parse } from "../src/core/shell.ts" +import { signature } from "../src/core/signature.ts" + +/** + * The labelled pairs from the experiment that chose this rule (experiments/families): made-up commands, + * each pair either one that must stay two families (dev and prod, a read and a write) or one that may + * share a family. A merge is a widening that trusts more than it says; a split only costs approvals. + */ +const family = (line: string) => { + const read = parse(line) + if (read.kind !== "commands") throw new Error(line) + return familyOf("bash", signature(read.commands[0] as never)) +} + +describe("where a family ends, on the experiment's pairs", () => { + for (const pair of PAIRS.filter((each) => each.want === "separate")) + test(`two families: ${pair.a} ≠ ${pair.b} (${pair.why})`, () => { + expect(family(pair.a)).not.toBe(family(pair.b)) + }) + + /** What the rule knowingly splits: the third plain word is a service's name here. */ + const SPLIT = new Set(["docker compose -p dev logs web"]) + for (const pair of PAIRS.filter((each) => each.want === "together" && !SPLIT.has(each.a))) + test(`one family: ${pair.a} = ${pair.b} (${pair.why})`, () => { + expect(family(pair.a)).toBe(family(pair.b)) + }) +}) diff --git a/packages/trust/test/family.test.ts b/packages/trust/test/family.test.ts index ad2a19e..5783223 100644 --- a/packages/trust/test/family.test.ts +++ b/packages/trust/test/family.test.ts @@ -34,28 +34,31 @@ describe("a command's family", () => { ["git status", "git status"], ["git status --short", "git status"], ["git -C /x status", "git status"], - ["git -c core.pager=less log", "git log"], + /** A `-c` reads as a target (a context, for most tools): a finer family, never a wider one. */ + ["git -c core.pager=less log", "git -c core.pager=less log"], ["git stash drop", "git stash drop"], ["git stash list", "git stash list"], - ["git push origin main", "git push"], - ["docker compose -p cockpit up -d", "docker compose up"], - ["docker compose -p prod down -v", "docker compose down"], - ["docker compose -f a.yml -p x logs -f api", "docker compose logs"], + /** Pushing to `main` and to a feature branch are two families. */ + ["git push origin main", "git push origin main"], + /** The project is the target: `-p dev up` and `-p prod up` were one family, and widening it trusted prod. */ + ["docker compose -p cockpit up -d", "docker compose -p cockpit up"], + ["docker compose -p prod down -v", "docker compose -p prod down"], + ["docker compose -f a.yml -p x logs -f api", "docker compose -f a.yml -p x logs -f api"], ["docker run --rm -it alpine", "docker run"], - ["docker container rm x", "docker container rm"], + ["docker container rm x", "docker container rm x"], ["podman compose up", "podman compose up"], - ["kubectl -n prod get pods", "kubectl get"], + ["kubectl -n prod get pods", "kubectl -n prod get pods"], ["kubectl rollout restart deploy/x", "kubectl rollout restart"], ["npm run test", "npm run test"], ["npm run build", "npm run build"], ["npm test", "npm test"], - ["npm install left-pad", "npm install"], + ["npm install left-pad", "npm install left-pad"], ["pnpm --filter web run build", "pnpm run build"], ["yarn workspace web build", "yarn workspace web build"], ["bun test src/a.test.ts", "bun test"], ["bun run lint", "bun run lint"], ["terraform plan -out x", "terraform plan"], - ["terraform state rm x", "terraform state rm"], + ["terraform state rm x", "terraform state rm x"], ["cargo build --release", "cargo build"], ["go test ./...", "go test"], ["go mod tidy", "go mod tidy"], @@ -68,7 +71,8 @@ describe("a command's family", () => { ["env FOO=1 ls", "env ls"], ["xargs rm", "xargs rm"], /** The environment's names, never its values; redirections are not part of it. */ - ["NODE_ENV=prod npm run build", "NODE_ENV=… npm run build"], + /** A value that names an environment is kept: `NODE_ENV=prod` is not `NODE_ENV=dev`. */ + ["NODE_ENV=prod npm run build", "NODE_ENV=prod npm run build"], ["ls > out.txt", "ls"], ["ls 2>&1", "ls"], ["ls 2> /dev/null", "ls"], @@ -136,7 +140,9 @@ describe("what a widened family covers", () => { test("any command in it", () => { for (const line of ["ls", "ls -la", "ls -R docs", "ls -x src"]) expect(covers("bash", "ls", subject(line))).toBe(true) - expect(covers("bash", "docker compose up", subject("docker compose -p x up -d"))).toBe(true) + expect(covers("bash", "docker compose -p x up", subject("docker compose -p x up -d"))).toBe(true) + /** Another project is another family: a widening for one never reaches the other. */ + expect(covers("bash", "docker compose -p x up", subject("docker compose -p y up -d"))).toBe(false) }) test("not another family", () => { @@ -147,8 +153,10 @@ describe("what a widened family covers", () => { test("not a dangerous command inside a safe family", () => { expect(outside("bash", subject("docker compose -p prod down -v"))).toBe("dangerous (compose down -v)") - expect(covers("bash", "docker compose down", subject("docker compose -p prod down -v"))).toBe(false) - expect(covers("bash", "docker compose down", subject("docker compose -p prod down"))).toBe(true) + expect(covers("bash", "docker compose -p dev down", subject("docker compose -p dev down -v"))).toBe(false) + expect(covers("bash", "docker compose -p dev down", subject("docker compose -p dev down"))).toBe(true) + /** Anything that names production is dangerous, so no widening covers it. */ + expect(outside("bash", subject("docker compose -p prod down"))).toBe("dangerous (production)") expect(covers("bash", "git stash drop", subject("git stash drop"))).toBe(false) }) diff --git a/packages/trust/test/widen.test.ts b/packages/trust/test/widen.test.ts index 2bd5192..db300c4 100644 --- a/packages/trust/test/widen.test.ts +++ b/packages/trust/test/widen.test.ts @@ -91,8 +91,8 @@ describe("the policy for a widened family", () => { }) test("a dangerous command in a safe family still asks", () => { - expect(ask("docker compose -p prod down", [widened("docker compose down")]).answer).toBe(true) - expect(ask("docker compose -p prod down -v", [widened("docker compose down")]).answer).toBe(false) + expect(ask("docker compose -p dev down", [widened("docker compose -p dev down")]).answer).toBe(true) + expect(ask("docker compose -p dev down -v", [widened("docker compose -p dev down")]).answer).toBe(false) }) test("every command on the line must be covered", () => { From cc33f661720ba0f6fb876179134c2053e818a656 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 13:20:40 -0500 Subject: [PATCH 3/3] Trust: families nest in the ledger, and an old widening says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Families sharing their first words sit in a folder (mcpx › db-local › execute_sql); a folder is for reading, w widens one family. A folder holding danger shows prod or ! while closed. A widening nothing falls in any more is marked old, out of any folder, and x removes it. The storefront sample adds mcpx, compose and kubectl, and an old widening. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 +++ packages/trust/src/core/sample.ts | 16 ++ packages/trust/src/core/view/actions.ts | 8 +- packages/trust/src/core/view/card.ts | 98 ++++++++-- packages/trust/src/core/view/model.ts | 6 + packages/trust/src/core/view/tree.ts | 231 +++++++++++++++++++++--- packages/trust/src/tui/ledger.tsx | 4 +- packages/trust/test/view.test.ts | 31 +++- 8 files changed, 368 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3a1f9..4a10f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Changed + +- **Trust: a family is what a command does, and where.** `w` ("trust any …") used to widen a family + cut from a fixed table of known tools, so `mcpx db-local …` and `mcpx db-prod …` were one family, + and so were `docker compose -p dev …` and `-p prod …`. A family is now worked out for any CLI, no + table: the program and up to three plain words after it, plus the flags that name a target + (`-p`, `--context`, `--profile`, `-n`, `--project` …) with their values, and an environment variable + or a host that names an environment. `ls`, `cat`, `grep` and the like stay one family whatever they + read. Measured on 61 labelled pairs, the old rule merged 23 of 32 that must stay apart; this one + merges none. +- **Trust: production and SQL writes cost more.** A command that names production (`prod`, + `production`, `prd`, `live` — in a flag, a host, a name or `NODE_ENV=…`) needs 8 approvals in a row + instead of 3, and its family is never widened. So does SQL that writes (`update … set`, + `delete from`, `insert into`, `drop`, `alter`, `create`, `truncate`, `grant`) in any argument — and + a widened family never answers it, so trusting `mcpx db-local execute_sql` still asks for its + `update`. +- **Trust: families nest in the ledger.** Families that share their first words sit in a folder — + `mcpx` › `db-local` › `execute_sql` — opened with `→`. A folder is for reading: `w` widens one + family, never everything under a folder. A folder holding anything dangerous says so (`prod` or + `!`) while closed. +- **Trust: old widenings are marked.** A family widened under the old rule no longer matches anything + (a family is matched whole); it shows as `old`, and `x` removes it. + ## [0.10.1] - 2026-10-05 ### Changed diff --git a/packages/trust/src/core/sample.ts b/packages/trust/src/core/sample.ts index 20e3e9c..196e3bb 100644 --- a/packages/trust/src/core/sample.ts +++ b/packages/trust/src/core/sample.ts @@ -179,6 +179,19 @@ function storefront(s: Steps): void { for (const line of ["head -40 README.md", "head -5 CHANGELOG.md", "echo $STORE_URL", "echo done"]) run(line, 1) for (const line of ["grep -rn TODO src", "grep -c error build.log", "git branch -a"]) run(line, 1) + run('mcpx db-local execute_sql --sql "select * from orders limit 5"', 3) + run('mcpx db-local execute_sql --sql "select count(*) from customers"', 2) + run('mcpx db-local execute_sql --sql "update orders set paid = true where id = 4"', 2) + run("mcpx db-local list_tables", 3) + run('mcpx db-prod execute_sql --sql "select * from orders limit 5"', 2) + run("mcpx db-prod list_tables", 1) + run("docker compose -p dev up -d", 3) + run("docker compose -p dev logs web", 2) + run("docker compose -p dev down", 1) + run("docker compose -p prod up -d", 1) + run("kubectl --context cluster-a get pods", 3) + run("kubectl --context cluster-a get pods -o wide", 1) + run("kubectl --context cluster-b get pods", 2) run("mcpx jira getJiraIssue --site acme --issue SHOP-34", 2) run("mcpx jira searchIssues --jql 'project = SHOP'", 1) run("mcpx github list_pull_requests --repo acme/store", 1) @@ -196,6 +209,9 @@ function storefront(s: Steps): void { s.at(SAMPLE_NOW - 2 * hour) s.widen("tail", agent) s.widen("ls", agent) + s.widen("mcpx db-local execute_sql", agent) + /** Widened under the old family rule, which ended at `docker compose`: it answers nothing now. */ + s.widen("docker compose", agent) s.at(SAMPLE_NOW - 75 * 60_000) s.auto(`tail -4 ${ledger}`, 1, "bash", agent) s.at(SAMPLE_NOW - 30 * 60_000) diff --git a/packages/trust/src/core/view/actions.ts b/packages/trust/src/core/view/actions.ts index 4993f81..15c8996 100644 --- a/packages/trust/src/core/view/actions.ts +++ b/packages/trust/src/core/view/actions.ts @@ -7,7 +7,7 @@ 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 } from "./model.ts" +import { type AlwaysGroup, type Command, type Family, leadOf, type Reading, stale } from "./model.ts" import { agentsText, plural } from "./parts.ts" import type { Tone } from "./rows.ts" @@ -44,6 +44,7 @@ export function revokeLabel(target: Target): { label: string; off: boolean } { if (target.kind === "always") return { label: "Revoke", off: true } if (target.kind === "answer") return { label: "Revoke", off: false } if (target.kind === "family") { + if (stale(target.family)) return { label: "Remove", off: false } const answering = target.family.widened.length > 0 || target.family.commands.some((c) => c.phase === "answering") return { label: answering ? "Revoke all" : "Forget all", off: false } @@ -139,8 +140,9 @@ export function revoke(target: Target, reading: Reading, at: number): Outcome { return { events: stamp(events, at), notice: { - text: - answering > 0 || family.widened.length > 0 + text: stale(family) + ? `Removed the old widening: ${anyOf(family.permission, family.family)} is gone.` + : answering > 0 || family.widened.length > 0 ? `Revoked ${plural(family.commands.length, "command")} in ${name}${ family.widened.length > 0 ? ", and its widening" : "" } — each is asked again until approved ${threshold}× in a row.` diff --git a/packages/trust/src/core/view/card.ts b/packages/trust/src/core/view/card.ts index 97087c0..48178c1 100644 --- a/packages/trust/src/core/view/card.ts +++ b/packages/trust/src/core/view/card.ts @@ -17,6 +17,7 @@ import { leadOf, type Reading, type Standing, + stale, } from "./model.ts" import { agentsText, @@ -400,8 +401,15 @@ const needOf = (settings: Thresholds, danger: boolean) => function familyCard(family: Family, reading: CardReading): CardParts { const counts = countsOf(family.commands) - const standing: Run[][] = - family.widened.length > 0 + const standing: Run[][] = stale(family) + ? 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)} …`), @@ -434,14 +442,26 @@ function familyCard(family: Family, reading: CardReading): CardParts { ]), table: true, }) - if (family.widened.length > 0) + if (stale(family)) + facts.push({ + label: "Old", + keep: 6, + lines: [ + [ + muted( + "No command you have run falls in it: it was widened under the old family rule, and a family now matches whole. [x] removes it.", + ), + ], + ], + }) + else if (family.widened.length > 0) facts.push({ label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] }) - const fam = familyFact(family, undefined, reading) + const fam = stale(family) ? undefined : familyFact(family, undefined, reading) if (fam) facts.push({ ...fam, label: "Widen", lines: fam.lines.slice(1) }) return { title: [ { text: familyText(family), tone: "text", bold: true }, - muted(` family of ${plural(family.commands.length, "command")}`), + muted(stale(family) ? " an old widening" : ` family of ${plural(family.commands.length, "command")}`), ], standing, facts, @@ -598,19 +618,69 @@ function onceCard(node: Extract, reading: CardReading): } } +/** A folder: the families under it and how each stands. `w` widens one of them, never the folder. */ +function groupCard(node: Extract): CardParts { + const commands = node.families.flatMap((family) => family.commands) + const counts = countsOf(commands) + const name = (family: Family) => { + const text = familyText(family) + return text.startsWith(`${node.prefix} `) ? text.slice(node.prefix.length + 1) : text + } + return { + title: [ + { text: node.prefix, tone: "text", bold: true }, + muted(` ${plural(node.families.length, "family", "families")}`), + ], + standing: [ + [ + { text: `${counts.trusted} trusted`, tone: counts.trusted > 0 ? "success" : "muted", bold: true }, + muted(" · "), + { text: `${counts.learning} learning`, tone: counts.learning > 0 ? "warning" : "muted", bold: true }, + muted(` · ${counts.once} seen once`), + ], + ], + facts: [ + { + label: "Families", + keep: 7, + table: true, + lines: node.families.map((family) => [ + plain(name(family)), + { text: " " }, + muted(plural(family.commands.length, "command")), + ]), + }, + { + label: "Widen", + keep: 5, + lines: [ + [ + muted( + `one family at a time: → opens this, and w on a family trusts any command in it. Nothing trusts everything under ${node.prefix}.`, + ), + ], + ], + }, + ], + buttons: [], + } +} + export function cardOf(node: Node, reading: CardReading): CardParts { const parts = node.kind === "today" ? todayCard(reading) - : node.kind === "once" - ? onceCard(node, reading) - : node.kind === "always" - ? alwaysCard(node.groups, reading) - : node.kind === "command" - ? commandCard(node.command, node.family, reading) - : node.kind === "family" || node.kind === "more" - ? familyCard(node.family, reading) - : { title: [], standing: [], facts: [], buttons: [] } + : node.kind === "group" + ? groupCard(node) + : node.kind === "once" + ? onceCard(node, reading) + : node.kind === "always" + ? alwaysCard(node.groups, reading) + : node.kind === "command" + ? commandCard(node.command, node.family, reading) + : node.kind === "family" || node.kind === "more" + ? familyCard(node.family, reading) + : { title: [], standing: [], facts: [], buttons: [] } return { ...parts, buttons: buttonsOf(node, reading.families) } } diff --git a/packages/trust/src/core/view/model.ts b/packages/trust/src/core/view/model.ts index babaea4..2c25dc0 100644 --- a/packages/trust/src/core/view/model.ts +++ b/packages/trust/src/core/view/model.ts @@ -152,6 +152,12 @@ export interface Family { lastAt: number } +/** + * A widening no command falls in any more: most often one made under the old family rule ("any docker + * compose"), which now answers nothing, since a family is matched whole. `x` removes it. + */ +export const stale = (family: Family): boolean => family.widened.length > 0 && family.commands.length === 0 + export const familyKey = (permission: string, family: string): string => JSON.stringify([permission, family]) /** Where a family sorts: what answers, then what learns, then what is dangerous, then what was seen once. */ diff --git a/packages/trust/src/core/view/tree.ts b/packages/trust/src/core/view/tree.ts index 498230e..493a59d 100644 --- a/packages/trust/src/core/view/tree.ts +++ b/packages/trust/src/core/view/tree.ts @@ -15,7 +15,8 @@ * once — counts on a family's row, the standing itself on a command's. */ -import { showSubject } from "../family.ts" +import { dangerOf } from "../danger.ts" +import { readSubject, shown, showSubject } from "../family.ts" import type { Target } from "./actions.ts" import { type AlwaysGroup, @@ -28,6 +29,7 @@ import { familiesOf, leadOf, type Reading, + stale, } from "./model.ts" 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" @@ -46,10 +48,32 @@ 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 } - | { kind: "family"; key: string; family: Family; open: boolean } - | { kind: "command"; key: string; command: Command; family: Family; nested: boolean } + /** `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 } + | { + kind: "command" + key: string + command: Command + family: Family + nested: boolean + depth: number + prefix: string + } + /** + * Families that share their first words, as one folder: `mcpx`, then `db-local` under it. A folder + * is for reading only — `w` widens one family, never everything under a folder. + */ + | { + kind: "group" + key: string + label: string + depth: number + prefix: string + families: Family[] + open: boolean + } /** The folded tail of an open family: `+ 3 more`. */ - | { kind: "more"; key: string; family: Family; hidden: number } + | { kind: "more"; key: string; family: Family; hidden: number; depth: number } /** A kind's families seen only once, folded into one row. */ | { kind: "once"; key: string; section: Section; families: Family[]; open: boolean } | { kind: "always"; key: string; groups: AlwaysGroup[] } @@ -113,26 +137,106 @@ export function explorerModel(input: Reading & Tree & { today?: number }): Explo nodes.push({ kind: "today", key: TODAY_KEY, answers: input.today }) /** A family's nodes: its row (or its one command), and when open its commands and `+ N more`. */ - const familyNodes = (family: Family, listed: readonly Command[]): Node[] => { + const familyNodes = (family: Family, listed: readonly Command[], depth = 0, prefix = ""): Node[] => { if (single(family)) { const command = family.commands[0] as Command - return [{ kind: "command", key: commandNodeKey(command), command, family, nested: false }] + return [ + { kind: "command", key: commandNodeKey(command), command, family, nested: false, depth, prefix }, + ] } const open = needle !== "" || input.open.has(family.key) - const out: Node[] = [{ kind: "family", key: familyNodeKey(family), family, open }] + const out: Node[] = [{ kind: "family", key: familyNodeKey(family), family, open, depth, prefix }] if (!open) return out const whole = needle !== "" || input.full.has(family.key) || listed.length <= TAIL + 1 - const shown = whole ? listed : listed.slice(0, TAIL) - for (const command of shown) - out.push({ kind: "command", key: commandNodeKey(command), command, family, nested: true }) + const kept = whole ? listed : listed.slice(0, TAIL) + for (const command of kept) + out.push({ + kind: "command", + key: commandNodeKey(command), + command, + family, + nested: true, + depth, + prefix, + }) if (!whole) - out.push({ kind: "more", key: `m:${family.key}`, family, hidden: listed.length - shown.length }) + out.push({ kind: "more", key: `m:${family.key}`, family, hidden: listed.length - kept.length, depth }) + return out + } + + /** + * Commands' families as folders by their first words: a word two or more families start with is a + * folder (`mcpx`), a run of single folders is one (`docker compose`), and a family alone at a level + * is its own row. Families keep their order (what answers first), a folder taking its first one's place. + */ + const grouped = ( + section: Section, + entries: readonly { family: Family; listed: readonly Command[]; units: string[] }[], + depth: number, + above: string[], + /** Folders above, for indenting: one folder can cover several words (`docker compose`). */ + level = 0, + ): Node[] => { + const out: Node[] = [] + const prefix = above.join(" ") + type Entry = (typeof entries)[number] + const buckets = new Map() + const order: (string | Entry)[] = [] + for (const entry of entries) { + const unit = entry.units[depth] + if (entry.units.length <= depth + 1 || unit === undefined) { + order.push(entry) + continue + } + const bucket = buckets.get(unit) + if (bucket) bucket.push(entry) + else { + buckets.set(unit, [entry]) + order.push(unit) + } + } + for (const item of order) { + if (typeof item !== "string") { + out.push(...familyNodes(item.family, item.listed, level, prefix)) + continue + } + const bucket = buckets.get(item) as Entry[] + if (bucket.length === 1) { + const only = bucket[0] as Entry + out.push(...familyNodes(only.family, only.listed, level, prefix)) + continue + } + const label = [item] + let next = depth + 1 + while ( + bucket.every((entry) => entry.units.length > next + 1 && entry.units[next] === bucket[0]?.units[next]) + ) { + label.push(bucket[0]?.units[next] as string) + next++ + } + const path = [...above, ...label] + const key = groupNodeKey(section, path) + const open = needle !== "" || input.open.has(key) + out.push({ + kind: "group", + key, + label: label.join(" "), + depth: level, + prefix: path.join(" "), + families: bucket.map((entry) => entry.family), + open, + }) + if (open) out.push(...grouped(section, bucket, next, path, level + 1)) + } return out } for (const section of SECTIONS) { const mine: Node[] = [] const once: Family[] = [] + const kept: { family: Family; listed: readonly Command[]; units: string[] }[] = [] + /** Old widenings stand on their own, after the rest: no folder holds a family nothing falls in. */ + const old: Family[] = [] for (const family of families) { if (sectionOf(family.permission) !== section) continue const named = needle !== "" && familyText(family).toLowerCase().includes(needle) @@ -142,8 +246,12 @@ export function explorerModel(input: Reading & Tree & { today?: number }): Explo : family.commands.filter((command) => commandText(command).toLowerCase().includes(needle)) if (needle !== "" && listed.length === 0 && !named) continue if (needle === "" && seenOnce(family)) once.push(family) - else mine.push(...familyNodes(family, listed)) + else if (stale(family)) old.push(family) + else kept.push({ family, listed, units: unitsOf(family) }) } + if (section === "commands") mine.push(...grouped(section, kept, 0, [])) + else for (const entry of kept) mine.push(...familyNodes(entry.family, entry.listed)) + for (const family of old) mine.push(...familyNodes(family, family.commands)) if (once.length === 1) mine.push(...familyNodes(once[0] as Family, (once[0] as Family).commands)) else if (once.length > 1) { const key = onceNodeKey(section) @@ -175,9 +283,9 @@ export function explorerModel(input: Reading & Tree & { today?: number }): Explo return { nodes, lines, families, commands, counts: countsOf(commands) } } -/** What `x`, `w` and `c` act on, for a node. Today's strip and a `seen once` row act on nothing. */ +/** What `x`, `w` and `c` act on, for a node. Today's strip, a folder and a `seen once` row act on nothing. */ export function nodeTarget(node: Node): Target | undefined { - if (node.kind === "today" || node.kind === "once") return 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 === "command") return { kind: "command", command: node.command, family: node.family } return { kind: "family", family: node.family } @@ -195,6 +303,10 @@ export function reveal( ) if (at < 0) continue if (seenOnce(family)) tree.open.add(onceNodeKey(sectionOf(family.permission))) + /** Every folder it could sit in, open: the keys of each run of its first words. */ + const units = unitsOf(family) + for (let end = 1; end < units.length; end++) + tree.open.add(groupNodeKey(sectionOf(family.permission), units.slice(0, end))) if (!single(family)) { tree.open.add(family.key) if (at >= TAIL && family.commands.length > TAIL + 1) tree.full.add(family.key) @@ -232,6 +344,7 @@ function statusRuns(node: Node): Run[] { }, ] 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) @@ -258,17 +371,40 @@ export function treeRow( ): Row { const room = Math.max(4, width - statusWidth - badges - 2) let left: Run[] + const indent = " ".repeat("depth" in node ? node.depth : 0) if (node.kind === "family") { const files = node.family.permission === "edit" ? [muted(` ${plural(node.family.commands.length, "file")}`)] : [] - const any = node.family.widened.length > 0 ? [{ text: " " }, badge("any", "success")] : [] - const name = squeeze(rowFamilyText(node.family), room - 3 - widthOf(rowText([...files, ...any]))) + const any = stale(node.family) + ? [{ text: " " }, badge("old", "warning")] + : node.family.widened.length > 0 + ? [{ text: " " }, badge("any", "success")] + : [] + const risk = familyRisk(node.family) + const marks = [...any, ...(risk ? [{ text: " " }, badge(risk, "error")] : [])] + const name = squeeze( + label(rowFamilyText(node.family), node.prefix), + room - 3 - indent.length - widthOf(rowText([...files, ...marks])), + ) left = [ - muted(` ${node.open ? "▾" : "▸"} `), + muted(` ${indent}${node.open ? "▾" : "▸"} `), { text: name, tone: seenOnce(node.family) ? "muted" : "text", bold: true }, - ...any, + ...marks, ...files, ] + } else if (node.kind === "group") { + /** Danger under a folder shows on the folder, so a closed `mcpx` or `git` still says it. */ + const risk = folderRisk(node.families) + const marks = risk ? [{ text: " " }, badge(risk, "error")] : [] + left = [ + muted(` ${indent}${node.open ? "▾" : "▸"} `), + { + text: squeeze(node.label, room - 3 - indent.length - widthOf(rowText(marks))), + tone: "text", + bold: true, + }, + ...marks, + ] } else if (node.kind === "once") { const names = node.families.map((family) => rowFamilyText(family)).join(" ") left = [ @@ -277,15 +413,15 @@ export function treeRow( ...(node.open ? [] : [muted(` ${squeeze(names, Math.max(1, room - 14))}`)]), ] } else if (node.kind === "command") { - const indent = node.nested ? 5 : 3 + const lead = (node.nested ? 5 : 3) + indent.length left = [ - { text: " ".repeat(indent) }, + { text: " ".repeat(lead) }, { - text: squeeze(nestedText(node, room - indent), room - indent), + text: squeeze(nestedText(node, room - lead), room - lead), tone: node.command.phase === "once" ? "muted" : "text", }, ] - } else if (node.kind === "more") left = [muted(` + ${node.hidden} more`)] + } 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 = [] const status = statusRuns(node) @@ -309,13 +445,60 @@ export function treeRow( * words first — the heading above already says them: `… --short -uno`. */ function nestedText(node: Extract, room: number): string { - const text = rowCommandText(node.command) + const text = label(rowCommandText(node.command), node.prefix) if (widthOf(text) <= room || !node.nested) return text - const family = rowFamilyText(node.family) + const family = label(rowFamilyText(node.family), node.prefix) if (node.family.permission === "edit" && text.startsWith(family)) return text.slice(family.length) return text.startsWith(`${family} `) ? `…${text.slice(family.length)}` : text } +/** `text` without the words the folders above it already say: `execute_sql` under `mcpx db-local`. */ +const label = (text: string, prefix: string) => + prefix !== "" && text.startsWith(`${prefix} `) ? text.slice(prefix.length + 1) : text + +/** + * A family's first words, as folders read them: each word as shown, a flag with its value as one + * (`-p dev`), env vars first, and a place as `(in web)`. Only commands are foldered. + */ +export function unitsOf(family: Family): string[] { + if (family.permission !== "bash") return [family.family] + const read = readSubject(family.family) + if (!read) return [family.family] + const words = [...read.command.env, ...read.command.argv].map(shown) + const units: string[] = read.place === undefined ? [] : [`(in ${shown(read.place)})`] + for (let i = 0; i < words.length; i++) { + const word = words[i] as string + const next = words[i + 1] + if (word.startsWith("-") && !word.includes("=") && next !== undefined && !next.startsWith("-")) { + units.push(`${word} ${next}`) + i++ + } else units.push(word) + } + return units +} + +export const groupNodeKey = (section: Section, words: readonly string[]): string => + `g:${section}:${words.join(" ")}` + +/** Why a family can never be widened, as its badge: `prod`, or `!` for any other danger. */ +function familyRisk(family: Family): string | undefined { + if (family.permission !== "bash") return undefined + const read = readSubject(family.family) + const danger = read ? dangerOf(read.command) : undefined + return danger === undefined ? undefined : danger === "production" ? "prod" : "!" +} + +/** The worst under a folder: `prod` before `!`, from a family or a command still learning. */ +function folderRisk(families: readonly Family[]): string | undefined { + const risks = families.flatMap((family) => [ + familyRisk(family), + ...family.commands + .filter((command) => command.danger !== undefined && command.phase !== "answering") + .map((command) => (command.danger === "production" ? "prod" : "!")), + ]) + return risks.includes("prod") ? "prod" : risks.includes("!") ? "!" : undefined +} + export const statusWidthOf = (nodes: readonly Node[], width: number) => Math.min(Math.floor(width * 0.45), Math.max(0, ...nodes.map((node) => widthOf(rowText(statusRuns(node)))))) diff --git a/packages/trust/src/tui/ledger.tsx b/packages/trust/src/tui/ledger.tsx index 636c6f0..69e671f 100644 --- a/packages/trust/src/tui/ledger.tsx +++ b/packages/trust/src/tui/ledger.tsx @@ -167,8 +167,8 @@ export function createLedger(input: { const at = node() if (!at || at.kind === "always" || at.kind === "today") return dialog.notice = undefined - /** A kind's `seen once` row opens and folds like a family, by its own key. */ - if (at.kind === "once") { + /** 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") { if (dialog.opened.has(at.key) && way !== "open") dialog.opened.delete(at.key) else if (!dialog.opened.has(at.key) && way !== "close") dialog.opened.add(at.key) return draw() diff --git a/packages/trust/test/view.test.ts b/packages/trust/test/view.test.ts index 43b2411..5d65453 100644 --- a/packages/trust/test/view.test.ts +++ b/packages/trust/test/view.test.ts @@ -62,11 +62,16 @@ const ledger = ( const reading = readingOf(name) const open = new Set() const full = new Set() - if (options.open === "all") + if (options.open === "all") { for (const family of explorerModel({ ...reading, open, full, filter: "" }).families) { open.add(family.key) full.add(family.key) } + /** Folders too, each level as the one above it opens. */ + for (let level = 0; level < 4; level++) + for (const node of explorerModel({ ...reading, open, full, filter: "" }).nodes) + if (node.kind === "group") open.add(node.key) + } const filter = options.filter ?? "" const model = explorerModel({ ...reading, open, full, filter, today: 0 }) const selected = (options.pick ?? ((nodes) => nodes.find((node) => node.kind !== "today")))( @@ -355,7 +360,7 @@ describe("the ledger: families as a tree, a card for the selection", () => { }) test("a dangerous command: a red meter, its badge, and a w that never widens", () => { - const view = ledger("dangerous", { pick: commandNode("git push origin feat/trust") }) + const view = ledger("dangerous", { pick: commandNode("git push origin feat/trust"), open: "all" }) const text = textOf(view.rows) expect(text).toContain("▰▰▰▰▰▱▱▱") expect(text).toMatch(/Dangerous +git push — 8 in a row instead of 3/) @@ -420,7 +425,8 @@ describe("the ledger: families as a tree, a card for the selection", () => { }) test("the footer: move, fold, card, filter, the activity, keys, and esc closes", () => { - expect(footerOf(ledger("busy", { width: 116 }).rows)).toBe( + const onCommand = (nodes: readonly Node[]) => nodes.find((node) => node.kind === "command") + expect(footerOf(ledger("busy", { width: 116, pick: onCommand }).rows)).toBe( " [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [a] Activity [p] Pause [?] Keys [esc] Close", ) }) @@ -443,6 +449,19 @@ describe("acting on a selection", () => { ]) }) + test("an old widening nothing falls in: marked old, out of any folder, and x removes it", () => { + const old = (nodes: readonly Node[]) => + nodes.find((node) => node.kind === "family" && node.family.family === "docker compose") + const view = ledger("storefront", { width: 116, height: 44, pick: old }) + const row = view.rows.map(rowText).find((line) => / docker compose {2}old /.test(line)) + expect(row).toBeDefined() + expect(view.node?.kind === "family" && view.node.depth).toBe(0) + expect(view.rows.map(rowText).join("\n")).toContain("x Remove") + const outcome = revoke(nodeTarget(view.node as Node), view, at) + expect(outcome.events.map((event) => event.type)).toEqual(["unwidened"]) + expect(outcome.notice.text).toContain("Removed the old widening") + }) + test("x on an answer given through a widening stops the widening", () => { const reading = readingOf("busy") const model = activityModel(reading) @@ -454,13 +473,13 @@ describe("acting on a selection", () => { }) test("w on a safe command widens its family for its agent; on a dangerous one, nothing", () => { - const view = ledger("busy", { pick: commandNode("bun --version") }) + 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) expect(ok.events).toEqual([ { v: 1, at, type: "widened", permission: "bash", agent: "build", family: "bun" }, ]) - const danger = ledger("busy", { pick: commandNode("git push origin feat/trust") }) + 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) expect(refused.events).toEqual([]) expect(refused.notice.text).toContain("dangerous") @@ -558,7 +577,7 @@ describe("the ledger by kind: commands, edits, tools and fetches, OpenCode's own const columns = rows .map(rowText) .filter((line) => /tail -\d+ .*✓/.test(line)) - .map((line) => line.indexOf("✓")) + .map((line) => line.lastIndexOf("✓")) expect(columns.length).toBeGreaterThanOrEqual(3) expect(new Set(columns).size).toBe(1) })