@opencode-cockpit/trust 0.0.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli/preview.js +251 -0
  3. package/dist/core/adapt/seen.js +24 -0
  4. package/dist/core/adapt/v1.js +109 -0
  5. package/dist/core/adapt/v2.js +109 -0
  6. package/dist/core/config.js +93 -0
  7. package/dist/core/danger.js +366 -0
  8. package/dist/core/engine.js +245 -0
  9. package/dist/core/family.js +512 -0
  10. package/dist/core/history.js +169 -0
  11. package/dist/core/index.js +16 -0
  12. package/dist/core/keys.js +149 -0
  13. package/dist/core/ledger.js +202 -0
  14. package/dist/core/paths.js +38 -0
  15. package/dist/core/policy.js +128 -0
  16. package/dist/core/rules.js +152 -0
  17. package/dist/core/sample.js +311 -0
  18. package/dist/core/shell.js +253 -0
  19. package/dist/core/signature.js +38 -0
  20. package/dist/core/view/actions.js +321 -0
  21. package/dist/core/view/activity.js +557 -0
  22. package/dist/core/view/explorer.js +970 -0
  23. package/dist/core/view/model.js +193 -0
  24. package/dist/core/view/parts.js +290 -0
  25. package/dist/core/view/rows.js +165 -0
  26. package/dist/core/view/sidebar.js +170 -0
  27. package/dist/tui/index.js +920 -0
  28. package/dist/tui/journal.js +80 -0
  29. package/dist/tui/render.js +85 -0
  30. package/dist/tui/source.js +112 -0
  31. package/dist/tui/view/dialog.js +43 -0
  32. package/dist/tui/view/rows.js +69 -0
  33. package/package.json +49 -3
  34. package/tui.js +6 -0
  35. package/types/cli/preview.d.ts +22 -0
  36. package/types/core/adapt/seen.d.ts +46 -0
  37. package/types/core/adapt/v1.d.ts +18 -0
  38. package/types/core/adapt/v2.d.ts +20 -0
  39. package/types/core/config.d.ts +57 -0
  40. package/types/core/danger.d.ts +50 -0
  41. package/types/core/engine.d.ts +117 -0
  42. package/types/core/family.d.ts +97 -0
  43. package/types/core/history.d.ts +81 -0
  44. package/types/core/index.d.ts +16 -0
  45. package/types/core/keys.d.ts +68 -0
  46. package/types/core/ledger.d.ts +158 -0
  47. package/types/core/paths.d.ts +24 -0
  48. package/types/core/policy.d.ts +52 -0
  49. package/types/core/rules.d.ts +55 -0
  50. package/types/core/sample.d.ts +22 -0
  51. package/types/core/shell.d.ts +40 -0
  52. package/types/core/signature.d.ts +18 -0
  53. package/types/core/view/actions.d.ts +76 -0
  54. package/types/core/view/activity.d.ts +101 -0
  55. package/types/core/view/explorer.d.ts +154 -0
  56. package/types/core/view/model.d.ts +106 -0
  57. package/types/core/view/parts.d.ts +76 -0
  58. package/types/core/view/rows.d.ts +60 -0
  59. package/types/core/view/sidebar.d.ts +62 -0
  60. package/types/tui/index.d.ts +15 -0
  61. package/types/tui/journal.d.ts +19 -0
  62. package/types/tui/render.d.ts +14 -0
  63. package/types/tui/source.d.ts +35 -0
  64. package/types/tui/view/dialog.d.ts +24 -0
  65. package/types/tui/view/rows.d.ts +19 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Which commands cost more trust.
3
+ *
4
+ * Nothing here blocks anything. A dangerous command still earns trust like any other; it needs the
5
+ * normal threshold *plus* `dangerExtra` approvals in a row (docs/roadmap/trust.md, "Decided"). So a
6
+ * miss in this list is never a hole — the command still had to be approved `threshold` times — it is
7
+ * only a command that became automatic sooner than it should have. A false alarm costs a few more
8
+ * approvals and nothing else, which is why every doubt below is settled towards "dangerous".
9
+ *
10
+ * It sees one command at a time: `git add -A && git push --force` arrives as two, and only the second
11
+ * is dangerous. `cwd` is already part of the signature; this is about what the words do.
12
+ *
13
+ * The answer is a reason rather than a yes, because the ledger shows it: "git push" says why a rule
14
+ * needs eight approvals where `true` would leave the person guessing.
15
+ */
16
+ import type { Command } from "./shell.ts";
17
+ /**
18
+ * Wrappers run the command after them, so the wrapped command is the one judged. Each lists the
19
+ * flags that take a separate value, so `timeout -s KILL 5 rm x` finds `rm` rather than `KILL`.
20
+ * `sudo` and `doas` are dangerous in themselves: whatever runs, runs as root.
21
+ */
22
+ export interface Wrapper {
23
+ /** Flags whose value is the next word. */
24
+ values?: readonly string[];
25
+ /** Positional words the wrapper takes before the command: `timeout 5`, `nice` has none. */
26
+ positionals?: number;
27
+ /** Running as someone else is dangerous whatever the command is. */
28
+ dangerous?: string;
29
+ }
30
+ export declare const WRAPPERS: Readonly<Record<string, Wrapper>>;
31
+ export declare const COMPOSE_GLOBALS: string[];
32
+ /**
33
+ * The flags before a tool's subcommand that take a separate value, by program: what family.ts needs
34
+ * to find `status` in `git -C /x status`. Read from the table above so the two cannot disagree.
35
+ */
36
+ export declare const TOOL_GLOBALS: Readonly<Record<string, readonly string[]>>;
37
+ /** The words after the global flags: `[-C, /tmp, push, origin]` → `[push, origin]`. */
38
+ export declare function subcommand(args: readonly string[], globals?: readonly string[]): string[];
39
+ /**
40
+ * One wrapper taken off the front: its name, and the words it runs. Undefined when `argv` does not
41
+ * start with a wrapper. Families (family.ts) keep the wrapper's name — `sudo ls` is not `ls` — and
42
+ * drop its flags, so this is shared rather than copied.
43
+ */
44
+ export declare function unwrapOnce(argv: readonly string[]): {
45
+ wrapper: string;
46
+ rest: readonly string[];
47
+ } | undefined;
48
+ /** Why a command costs more trust, in a few words — or nothing when it costs the usual. */
49
+ export declare function dangerOf(command: Command): string | undefined;
50
+ export declare function dangerous(command: Command): boolean;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Trust in one window: the requests it is watching, the replies it sent, and the events each turn
3
+ * of that produces. No clock, no file, no OpenCode — times are passed in, events are handed back to
4
+ * be written, and the file's events (this window's and every other's) come back in through `load`.
5
+ *
6
+ * That last loop is deliberate. An event this window writes is applied when it is read back from the
7
+ * file, in the file's order, exactly like another window's: the state is one fold over one sequence,
8
+ * never a fold of "ours" patched with "theirs".
9
+ *
10
+ * **Who answered.** `permission.replied` does not say (docs/opencode/permissions.md), so:
11
+ * our own replies are known by request id; a reply under `PERSON_MS` after the request is not a
12
+ * person — OpenCode's `--auto` answers in 15–22ms, a person in a second or more — and is not counted;
13
+ * a request asked before this window was watching has no time to measure, and is not counted either.
14
+ * Every doubt under-counts a person, never over-counts one. A reject always counts: resetting is the
15
+ * safe direction.
16
+ */
17
+ import { type History } from "./history.ts";
18
+ import type { Context, Request } from "./keys.ts";
19
+ import { type Event, type State, type Thresholds } from "./ledger.ts";
20
+ import { type Judgement } from "./policy.ts";
21
+ import type { ConfigRule } from "./rules.ts";
22
+ /** Faster than this, nobody read the prompt. Measured: auto mode 15–22ms, a person 1.2s and up. */
23
+ export declare const PERSON_MS = 300;
24
+ /** How long a request must have been pending before the host's list may say it is gone. */
25
+ export declare const RECONCILE_AFTER_MS = 10000;
26
+ export type Reply = "once" | "always" | "reject";
27
+ export type Credit = {
28
+ kind: "approved";
29
+ always?: string[];
30
+ } | {
31
+ kind: "rejected";
32
+ } | {
33
+ kind: "ignored";
34
+ why: string;
35
+ };
36
+ /** Whose reply this was, as far as can be known — and so what it counts for. */
37
+ export declare function credit(input: {
38
+ reply: Reply;
39
+ /** When the request was asked; undefined when this window never saw it asked. */
40
+ askedAt?: number;
41
+ repliedAt: number;
42
+ /** This window answered it. */
43
+ ours: boolean;
44
+ always?: string[];
45
+ }): Credit;
46
+ export interface Pending {
47
+ request: Request;
48
+ agent: string;
49
+ askedAt: number;
50
+ judgement: Judgement;
51
+ }
52
+ /** One answer Trust gave, for the sidebar's list. */
53
+ export interface Answered {
54
+ at: number;
55
+ request: string;
56
+ permission: string;
57
+ agent: string;
58
+ /** The subjects, as one line: `git status`, or `git add -A && git commit -m wip`. */
59
+ label: string;
60
+ subjects: string[];
61
+ why: string;
62
+ /** The widened family that answered it, when one did: the sidebar says "any ls". */
63
+ via?: string;
64
+ }
65
+ export interface EngineOptions extends Thresholds {
66
+ /** Answers by Trust kept for the sidebar. */
67
+ keep?: number;
68
+ }
69
+ export interface Engine {
70
+ readonly state: State;
71
+ /**
72
+ * When things happened, for the screens that say why (history.ts): folded from the same events as
73
+ * the state, bounded, and never read by a decision.
74
+ */
75
+ readonly history: History;
76
+ /** Events read from the ledger file, in file order. */
77
+ load(events: readonly Event[], options?: {
78
+ reset?: boolean;
79
+ }): void;
80
+ /**
81
+ * A request was asked. Returns the judgement and the `asked` event to write; when the judgement is
82
+ * to answer, the request is marked ours *before* the reply is sent, so the `replied` it causes is
83
+ * never mistaken for a person's.
84
+ */
85
+ ask(input: {
86
+ request: Request;
87
+ context: Context;
88
+ agent: string;
89
+ rules: readonly ConfigRule[];
90
+ at: number;
91
+ }): {
92
+ judgement: Judgement;
93
+ event: Event;
94
+ };
95
+ /** Our reply went through: the `auto` event to write. */
96
+ answered(requestID: string, at: number): Event | undefined;
97
+ /** Our reply failed: the request is the person's again, and their answer counts. */
98
+ failed(requestID: string): void;
99
+ /** A reply arrived — ours, OpenCode's, a person's. The events to write, and what it was taken for. */
100
+ replied(input: {
101
+ requestID: string;
102
+ reply: Reply;
103
+ at: number;
104
+ }): {
105
+ events: Event[];
106
+ credit: Credit;
107
+ };
108
+ /** Requests the host says are no longer pending: answered while we were not looking. */
109
+ reconcile(stillPending: ReadonlySet<string>, now: number): void;
110
+ pending(): Pending[];
111
+ /** Trust's answers in this window, newest first. */
112
+ recent(): Answered[];
113
+ /** How many answers Trust gave in this window. */
114
+ count(): number;
115
+ reset(options: EngineOptions): void;
116
+ }
117
+ export declare function createEngine(initial: EngineOptions): Engine;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Families: what several exact rules have in common, and how a rule is shown so nothing about it is
3
+ * left to the font.
4
+ *
5
+ * A signature is exact on purpose (signature.ts), so a project that runs `ls -la`, `ls -la src` and
6
+ * `ls -R docs` has three rules — which is right for trust and wrong for reading: a ledger of forty
7
+ * exact lines does not say "you trust ls". A family is the part of a command that names *what it
8
+ * does*: the program, and for a tool with subcommands, the subcommand (`git status`, `docker compose
9
+ * up`, `npm run test`). The ledger groups by it, and it is the one unit a person can widen trust to,
10
+ * on purpose (`w` in the ledger) — never Trust by itself (docs/roadmap/trust.md).
11
+ *
12
+ * The rules, and why each one leans the way it does:
13
+ *
14
+ * - **Global flags are not part of the family**: `git -C /x status` is `git status`, and
15
+ * `docker compose -p prod down -v` is `docker compose down` — the flags change *where*, the
16
+ * subcommand says *what*. Read with danger.ts's own tables, so the two never disagree on where the
17
+ * subcommand is.
18
+ * - **A wrapper is part of it**: `sudo ls` is not `ls`, and neither is `timeout 5 ls`. Trusting any
19
+ * `ls` must not quietly cover running it as root.
20
+ * - **So is where it runs and what it is told**: `(in web) bun test` and `NODE_ENV=… npm run build`
21
+ * are their own families. A directory or an environment changes what the same words do.
22
+ * - **Redirections are not**: `ls > out.txt` groups under `ls` — but a widened family does not cover
23
+ * it (`outside`), because writing a file is not what "any ls" was agreed to mean.
24
+ *
25
+ * Other permissions: an edit's family is its folder (`src/`), a fetch's its host, an agent type is
26
+ * its own — the last two already are as wide as they go.
27
+ */
28
+ import { type Command } from "./shell.ts";
29
+ /**
30
+ * A bash subject read back into its command. A signature is written to be parsed again — every word
31
+ * quoted by `quote` — so this is exact; anything that does not read as one command is left alone.
32
+ */
33
+ export declare function readSubject(subject: string): {
34
+ place?: string;
35
+ command: Command;
36
+ } | undefined;
37
+ export declare const isRedirect: (word: string) => boolean;
38
+ /**
39
+ * A subject's family, under its permission. The same string for every command in it, so it is what
40
+ * a widening is recorded against and what the ledger groups by.
41
+ */
42
+ export declare function familyOf(permission: string, subject: string): string;
43
+ /**
44
+ * Whether a family may be widened at all. A dangerous family never: if `git push` itself is
45
+ * dangerous, "any git push" is a rule that answers force-pushes — every one of them has to earn
46
+ * trust on its own, at the higher count. A fetch or an agent type is already as wide as it goes.
47
+ */
48
+ export declare function widenable(permission: string, family: string): {
49
+ ok: true;
50
+ } | {
51
+ ok: false;
52
+ why: string;
53
+ };
54
+ /**
55
+ * Why a widened family would still ask about this subject — or nothing when it covers it. A widening
56
+ * is "any `ls`", not "anything that starts with ls": a dangerous command, one that writes a file
57
+ * through a redirection, and one that hands its arguments to another program each still ask.
58
+ */
59
+ export declare function outside(permission: string, subject: string): string | undefined;
60
+ /** A widened `family` answers `subject`. */
61
+ export declare const covers: (permission: string, family: string, subject: string) => boolean;
62
+ /**
63
+ * One word as the ledger shows it: bare when nothing about it can be misread, otherwise in double
64
+ * quotes — easier to read than the signature's single quotes, and this is display, never parsed.
65
+ * A punctuation-only word is always quoted (a lone `-` or `.` aside: one character cannot merge).
66
+ */
67
+ export declare function shown(word: string): string;
68
+ /**
69
+ * The runs of a word a font may draw as one glyph, said in words: `---` is "3 hyphens", `->` is
70
+ * "hyphen, greater-than". Quotes were not enough — inside them `"---"` still drew as `"──"` on the
71
+ * user's screen — so the ledger says it in letters, which no font merges.
72
+ */
73
+ export declare function spelled(word: string): string | undefined;
74
+ /**
75
+ * Each argument of a subject a font may draw as something else, as the ledger shows it (`"---"`)
76
+ * beside what it is in words (`3 hyphens`). The panel says which word it means: a bare `3 hyphens`
77
+ * after a command read as a riddle (a user's screenshot).
78
+ */
79
+ export declare function spelledWords(permission: string, subject: string): {
80
+ word: string;
81
+ said: string;
82
+ }[];
83
+ /** The spelled runs of every argument of a subject, for the ledger to put beside it. */
84
+ export declare function spelledSubject(permission: string, subject: string): string | undefined;
85
+ /** A command as the ledger shows it: every argument unambiguous, redirections as redirections. */
86
+ export declare function showCommand(command: Command): string;
87
+ /** A subject — or a family — as the ledger and the sidebar show it. Paths and hosts are themselves. */
88
+ export declare function showSubject(permission: string, subject: string): string;
89
+ /** `any ls …`, `any file in src/`: what a widened family answers, in words. */
90
+ export declare function anyOf(permission: string, family: string): string;
91
+ /**
92
+ * A command a little different from `subject`, for the sentence that says what still asks: the
93
+ * command with its last argument dropped, or nothing when it has none past its family.
94
+ */
95
+ export declare function narrower(subject: string): string | undefined;
96
+ /** The subject with its output sent to a file: the other thing that still asks. */
97
+ export declare function redirected(subject: string): string | undefined;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What happened, and when — read from the same events the state is folded from, for the screens
3
+ * that say *why*: what Trust answered today, how the week went, and the approvals that earned a rule.
4
+ *
5
+ * The state (ledger.ts) keeps what a rule adds up to — a streak, a count, the last time — because
6
+ * that is all a decision needs. A person asking "why is this trusted?" needs the moments themselves:
7
+ * `✓ 9h ✓ 9h ✓ 9h → trusted`. So this is a second fold over the same sequence, beside the state and
8
+ * never consulted by `decide`: nothing here changes what Trust answers.
9
+ *
10
+ * Bounded, because a ledger only grows: the last `MARKS` moments per rule (a run of answers is one
11
+ * moment with a count, so a rule answered a thousand times does not push out the approvals that
12
+ * earned it), the last `ANSWERS` answers, and a count of answers per day for the last `DAYS` days.
13
+ * A rule older than its window still has its totals in the state; the screens fall back to those.
14
+ */
15
+ import type { Event, Item } from "./ledger.ts";
16
+ /** One moment in a rule's life. A run of answers in a row is one mark, `count` of them. */
17
+ export type Mark = {
18
+ kind: "approved";
19
+ at: number;
20
+ } | {
21
+ kind: "rejected";
22
+ at: number;
23
+ } | {
24
+ kind: "revoked";
25
+ at: number;
26
+ } | {
27
+ kind: "auto";
28
+ at: number;
29
+ count: number;
30
+ };
31
+ /** One answer Trust gave, from any window on the project. */
32
+ export interface Answer {
33
+ at: number;
34
+ request: string;
35
+ permission: string;
36
+ agent: string;
37
+ items: Item[];
38
+ /** Why, as the window that answered put it (policy.ts). */
39
+ rule: string;
40
+ }
41
+ export interface History {
42
+ /** By `keyOf(permission, agent, subject)`, oldest first. */
43
+ marks: Map<string, Mark[]>;
44
+ /** Oldest first, the last `ANSWERS`. */
45
+ answers: Answer[];
46
+ /** Answers per local day, by the day's first millisecond. */
47
+ days: Map<number, number>;
48
+ }
49
+ /** Moments kept per rule. */
50
+ export declare const MARKS = 24;
51
+ /** Answers kept for the activity feed: far more than a screen lists. */
52
+ export declare const ANSWERS = 200;
53
+ /** Days of answers counted: a week, and a week before it to spare. */
54
+ export declare const DAYS = 14;
55
+ export declare const emptyHistory: () => History;
56
+ /** The first millisecond of the local day `at` falls in. */
57
+ export declare function dayOf(at: number): number;
58
+ /**
59
+ * One event into the history. `settled` is the state's set of requests already counted, read before
60
+ * the state applies this event: two windows writing one outcome are one moment here as there.
61
+ */
62
+ export declare function note(history: History, event: Event, settled: ReadonlySet<string>): void;
63
+ /** Answers newest first. */
64
+ export declare const latestAnswers: (history: History, limit?: number) => Answer[];
65
+ /** Answers on each of the last `days` local days, the oldest first and today last. */
66
+ export declare function answersPerDay(history: History, now: number, days?: number): number[];
67
+ /** How a rule came to be trusted, read back from its moments. */
68
+ export interface Earned {
69
+ /** Approvals in a row that count, as of `upTo`. */
70
+ streak: number;
71
+ /** When the streak reached what it needed; undefined when it has not (or the window lost it). */
72
+ since?: number;
73
+ /** The streak was broken by a reject or a revoke before it began: when. */
74
+ brokenAt?: number;
75
+ broken?: "rejected" | "revoked" | "expired";
76
+ }
77
+ /**
78
+ * The streak a rule's moments add up to, the way the state folds it (a reject or revoke starts it
79
+ * over, an approval after `expireMs` unused starts it over) — and when it reached `need`.
80
+ */
81
+ export declare function earned(marks: readonly Mark[], need: number, expireMs: number, upTo?: number): Earned;
@@ -0,0 +1,16 @@
1
+ /** Published entry point: `@opencode-cockpit/trust/core` — the pure half, no OpenCode, no terminal. */
2
+ export { commandOf, type Seen } from "./adapt/seen.ts";
3
+ export { fromV1Event, fromV1Pending, V1_EVENTS } from "./adapt/v1.ts";
4
+ export { fromV2Event, fromV2Pending } from "./adapt/v2.ts";
5
+ export * from "./config.ts";
6
+ export { dangerOf, dangerous } from "./danger.ts";
7
+ export * from "./engine.ts";
8
+ export { anyOf, covers, familyOf, outside, shown, showSubject, widenable } from "./family.ts";
9
+ export { type Answer, answersPerDay, earned, type History, latestAnswers, type Mark } from "./history.ts";
10
+ export * from "./keys.ts";
11
+ export * from "./ledger.ts";
12
+ export * from "./paths.ts";
13
+ export * from "./policy.ts";
14
+ export * from "./rules.ts";
15
+ export { type Command, type Parsed, parse } from "./shell.ts";
16
+ export { place, quote, signature } from "./signature.ts";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What a permission request is a request *for*, as the things an approval can be counted against.
3
+ *
4
+ * Each permission type has its own notion of "the same thing again":
5
+ *
6
+ * | permission | one subject per | e.g. |
7
+ * | --- | --- | --- |
8
+ * | `bash` (v2 `shell`) | command, by its exact signature | `git status`, `(in web) bun test` |
9
+ * | `edit` | file path | `src/app.ts` |
10
+ * | `webfetch` | host | `docs.example.com` |
11
+ * | `task` (v2 `subagent`) | agent type | `explore` |
12
+ * | `external_directory`, `doom_loop` | — never answered | |
13
+ * | anything else | the request's patterns, together | `opencode_read_mcp_resource` `*` |
14
+ *
15
+ * The key a count is kept under is the subject *and* the permission *and* the agent: trust earned
16
+ * by `build` running `git push` is not trust for `general` to run it.
17
+ */
18
+ /** A permission request, whichever OpenCode asked it (see `adapt/`). */
19
+ export interface Request {
20
+ id: string;
21
+ sessionID: string;
22
+ /** Canonical: `bash`, `edit`, `task`… (`rules.canonical`). */
23
+ permission: string;
24
+ /** What OpenCode matched against config: v1 `patterns`, v2 `resources`. */
25
+ patterns: string[];
26
+ /** What OpenCode's own "always" would approve: v1 `always`, v2 `save`. */
27
+ always: string[];
28
+ /** The tool call that asked, so a later surface can mark it answered by Trust. */
29
+ call?: string;
30
+ messageID?: string;
31
+ }
32
+ /** Everything about the call that the request itself does not carry. */
33
+ export interface Context {
34
+ /** The command line as the agent wrote it — a bash request's patterns lose `cd`. */
35
+ line?: string;
36
+ /** The tool's own working directory argument (v1 `workdir`, v2 `cwd`). */
37
+ workdir?: string;
38
+ /** The project's directory: signatures name places relative to it. */
39
+ root: string;
40
+ }
41
+ export interface Subject {
42
+ /** The exact thing counted: a signature, a path, a host… */
43
+ subject: string;
44
+ /** What config is matched against for this subject: the command as written, the path, the URL. */
45
+ texts: string[];
46
+ /** Why it costs more trust, when it does. */
47
+ danger?: string;
48
+ }
49
+ export type Keyed = {
50
+ kind: "subjects";
51
+ subjects: Subject[];
52
+ /**
53
+ * The request's own patterns. They are what OpenCode checked its rules against, so a specific
54
+ * `ask` matching any of them holds the whole request, even where our reading of it differs.
55
+ */
56
+ patterns: string[];
57
+ }
58
+ /** Never answered by Trust, whatever the count. */
59
+ | {
60
+ kind: "never";
61
+ why: string;
62
+ }
63
+ /** Cannot be read for certain, so it is asked — and nothing is counted. */
64
+ | {
65
+ kind: "opaque";
66
+ why: string;
67
+ };
68
+ export declare function subjectsOf(request: Request, context: Context): Keyed;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The ledger: what happened, one event per line, and what it adds up to.
3
+ *
4
+ * Append-only on purpose. Several OpenCode windows on one project write to the same file, and a
5
+ * whole line appended in one write is the only update that needs no lock: nothing is ever rewritten,
6
+ * so no window can undo another's. The state is never stored — it is a fold over the events, so it
7
+ * cannot drift from them, and a rule's history ("approved 3×, rejected once, approved again") is
8
+ * there to read rather than summarised away.
9
+ *
10
+ * Reading is forgiving in exactly one way: a line cut short by a crash is skipped, and a line written
11
+ * after it (which then starts mid-line) is recovered. Anything else that does not parse is ignored —
12
+ * a ledger you edited by hand should cost you a rule, not the bay.
13
+ */
14
+ export interface Item {
15
+ subject: string;
16
+ danger?: string;
17
+ /** On an answer by Trust: the widened family that answered it, rather than the rule's own count. */
18
+ via?: string;
19
+ }
20
+ /** What every event about one request carries. */
21
+ interface About {
22
+ request: string;
23
+ session: string;
24
+ /** The tool call that asked: what a later surface needs to mark the call answered by Trust. */
25
+ call?: string;
26
+ permission: string;
27
+ agent: string;
28
+ items: Item[];
29
+ }
30
+ export type Event = {
31
+ v: 1;
32
+ at: number;
33
+ } & (({
34
+ type: "asked";
35
+ why?: string;
36
+ } & About)
37
+ /** A person approved it (a reply too fast for a person is never recorded as one). */
38
+ | ({
39
+ type: "approved";
40
+ always?: string[];
41
+ } & About) | ({
42
+ type: "rejected";
43
+ } & About)
44
+ /** Trust approved it; `rule` says why, in words. */
45
+ | ({
46
+ type: "auto";
47
+ rule: string;
48
+ } & About)
49
+ /** You took a rule's trust away; it has to be earned again. */
50
+ | {
51
+ type: "revoked";
52
+ permission: string;
53
+ agent: string;
54
+ subject: string;
55
+ }
56
+ /**
57
+ * You trusted a whole family (family.ts) for one agent: any command in it is answered, except the
58
+ * ones a widening never covers. Only a person writes this; Trust never widens by itself.
59
+ */
60
+ | {
61
+ type: "widened";
62
+ permission: string;
63
+ agent: string;
64
+ family: string;
65
+ }
66
+ /** The family is back to its exact rules, each standing on its own count. */
67
+ | {
68
+ type: "unwidened";
69
+ permission: string;
70
+ agent: string;
71
+ family: string;
72
+ }
73
+ /** Trust stops answering in this project until resumed — every window, not just this one. */
74
+ | {
75
+ type: "paused";
76
+ } | {
77
+ type: "resumed";
78
+ });
79
+ /** One subject's standing, under one permission and one agent. */
80
+ export interface Entry {
81
+ key: string;
82
+ permission: string;
83
+ agent: string;
84
+ subject: string;
85
+ /** Why it costs more, as of the last time it was asked. */
86
+ danger?: string;
87
+ /** Approvals by a person in a row since the last reject, revoke or expiry. */
88
+ streak: number;
89
+ /** Every approval by a person, ever. */
90
+ approvals: number;
91
+ /** Every answer by Trust, ever. */
92
+ autos: number;
93
+ firstAt: number;
94
+ /** Last approved or answered: what expiry counts from. */
95
+ lastAt: number;
96
+ rejectedAt?: number;
97
+ revokedAt?: number;
98
+ }
99
+ /** An "always" a person gave OpenCode itself: broader than it looks, and gone when OpenCode stops. */
100
+ export interface Always {
101
+ at: number;
102
+ session: string;
103
+ permission: string;
104
+ agent: string;
105
+ patterns: string[];
106
+ }
107
+ /** A family you trusted as a whole, for one agent under one permission. */
108
+ export interface Widened {
109
+ key: string;
110
+ permission: string;
111
+ agent: string;
112
+ family: string;
113
+ at: number;
114
+ }
115
+ export interface State {
116
+ entries: Map<string, Entry>;
117
+ /** By `keyOf(permission, agent, family)`: the same key shape as a rule, a different namespace. */
118
+ widened: Map<string, Widened>;
119
+ always: Always[];
120
+ paused: boolean;
121
+ /** Requests already settled: two windows writing one outcome count it once. */
122
+ settled: Set<string>;
123
+ }
124
+ export interface FoldOptions {
125
+ /** Unused this long, trust starts again from nothing. 0 never expires. */
126
+ expireMs: number;
127
+ }
128
+ export declare const DAY = 86400000;
129
+ export declare const emptyState: () => State;
130
+ export declare const keyOf: (permission: string, agent: string, subject: string) => string;
131
+ export declare function apply(state: State, event: Event, options: FoldOptions): State;
132
+ export declare function applyAll(state: State, events: readonly Event[], options: FoldOptions): State;
133
+ export interface Thresholds {
134
+ threshold: number;
135
+ dangerExtra: number;
136
+ expireDays: number;
137
+ }
138
+ export interface Standing {
139
+ /** Approvals in a row that still count. */
140
+ have: number;
141
+ need: number;
142
+ trusted: boolean;
143
+ expired: boolean;
144
+ }
145
+ export declare function needFor(danger: string | undefined, settings: Thresholds): number;
146
+ /** Where a subject stands now. `danger` is today's reading, which wins over the one stored. */
147
+ export declare function standing(found: Entry | undefined, danger: string | undefined, settings: Thresholds, now: number): Standing;
148
+ /**
149
+ * Events in `text`, and what is left after the last newline. The rest is not an error: it is a line
150
+ * another window is still writing, read again next time.
151
+ */
152
+ export declare function parseLines(text: string): {
153
+ events: Event[];
154
+ rest: string;
155
+ };
156
+ /** One line of the file. Never contains a newline, whatever a subject holds. */
157
+ export declare const serialize: (event: Event) => string;
158
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Where a project's ledger lives on disk.
3
+ *
4
+ * Pure: creates nothing, reads nothing, and takes its environment as an argument — as Review's
5
+ * `core/store/paths.ts` does, which this mirrors.
6
+ *
7
+ * **Outside the project.** What you have approved is about you, not the work; a ledger in the repo
8
+ * would turn up in `git status` and, worse, travel to everyone who clones it — trust you earned would
9
+ * answer for them.
10
+ *
11
+ * **Keyed by the project's full path.** The readable part is the last two segments, which tells two
12
+ * checkouts apart at a glance but not for certain (`~/a/web` and `~/b/web`); a short hash of the whole
13
+ * path makes it certain, so one checkout's trust can never answer in another.
14
+ */
15
+ export interface TrustPaths {
16
+ dir: string;
17
+ /** The append-only ledger: one event per line. */
18
+ events: string;
19
+ }
20
+ /** Anything that is not plainly a filename becomes a dash. */
21
+ export declare function slug(text: string): string;
22
+ export declare function projectSlug(directory: string): string;
23
+ export declare function shortHash(text: string): string;
24
+ export declare function trustPaths(directory: string, env?: Record<string, string | undefined>): TrustPaths;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Whether Trust answers a request, and why — a pure function of the request, what config says, and
3
+ * what the ledger adds up to.
4
+ *
5
+ * The order of the questions is the design:
6
+ *
7
+ * 1. **Can it be read?** A command line with `$(…)` in it, a permission that exists to make a person
8
+ * look (`external_directory`, `doom_loop`): asked, and nothing is counted.
9
+ * 2. **Did you ask to be asked?** A specific `ask` rule in config matching any part of the request
10
+ * holds all of it. So does a `deny`, though OpenCode never lets one reach us.
11
+ * 3. **Is every part trusted, or allowed by config?** One untrusted command in a line is enough to
12
+ * ask: `git status && rm -rf build` is not half-approved. A part is trusted by its own count, or
13
+ * by a family you widened for this agent — unless it is one a widening never covers (dangerous,
14
+ * writing a file, running another program: `family.outside`). Config's `ask` was settled in 2, so
15
+ * it still wins over a widening.
16
+ *
17
+ * The answer always carries what an approval of the request would count towards, so a person's
18
+ * approval of a request Trust declined is counted against exactly what Trust looked at.
19
+ */
20
+ import { type Context, type Request } from "./keys.ts";
21
+ import { type Item, type State, type Thresholds } from "./ledger.ts";
22
+ import { type ConfigRule } from "./rules.ts";
23
+ export interface Progress {
24
+ subject: string;
25
+ danger?: string;
26
+ have: number;
27
+ need: number;
28
+ trusted: boolean;
29
+ /** Trusted through this widened family rather than by its own count. */
30
+ via?: string;
31
+ }
32
+ export interface Judgement {
33
+ /** Trust approves it now. */
34
+ answer: boolean;
35
+ /** In words: logged with every answer, shown beside a request Trust left to you. */
36
+ why: string;
37
+ /** What a person's approval of this request counts towards. Empty: nothing is counted. */
38
+ items: Item[];
39
+ /** Where each counted subject stands, for the sidebar's `2/3`. */
40
+ progress: Progress[];
41
+ }
42
+ export interface DecideInput {
43
+ request: Request;
44
+ context: Context;
45
+ agent: string;
46
+ /** OpenCode's rules for this agent (`rules.rulesFrom`). */
47
+ rules: readonly ConfigRule[];
48
+ state: State;
49
+ settings: Thresholds;
50
+ now: number;
51
+ }
52
+ export declare function decide(input: DecideInput): Judgement;