Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
219 changes: 219 additions & 0 deletions packages/trust/experiments/families/corpus.ts
Original file line number Diff line number Diff line change
@@ -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]))]
130 changes: 130 additions & 0 deletions packages/trust/experiments/families/run.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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<string, Command[]>()
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}`)
}
}
Loading
Loading