seisin 0.1.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.
- package/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +52 -0
- package/src/cli.js +125 -0
- package/src/commands/check.js +9 -0
- package/src/commands/explain.js +87 -0
- package/src/commands/hook.js +42 -0
- package/src/commands/init.js +181 -0
- package/src/commands/log.js +68 -0
- package/src/commands/requests.js +145 -0
- package/src/commands/review.js +94 -0
- package/src/commands/run.js +395 -0
- package/src/commands/scan.js +21 -0
- package/src/commands/ui.js +31 -0
- package/src/commands/whose.js +94 -0
- package/src/commands/wire.js +74 -0
- package/src/config.js +317 -0
- package/src/env.js +61 -0
- package/src/handoff.js +111 -0
- package/src/hook.js +195 -0
- package/src/index.js +52 -0
- package/src/inspect.js +477 -0
- package/src/layout.js +47 -0
- package/src/log.js +124 -0
- package/src/mcp.js +290 -0
- package/src/owners.js +166 -0
- package/src/redact.js +82 -0
- package/src/render.js +150 -0
- package/src/requests.js +259 -0
- package/src/review.js +167 -0
- package/src/scan.js +175 -0
- package/src/serve.js +168 -0
- package/src/spool.js +164 -0
- package/src/srt.js +424 -0
- package/src/violations.js +540 -0
- package/src/worktree.js +216 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +937 -0
package/src/worktree.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the same file lives twice, and which of the two the policy is about.
|
|
3
|
+
*
|
|
4
|
+
* A policy names one path on disk. A git worktree is the same repository at a
|
|
5
|
+
* different path, and to the kernel the two are unrelated places: territory
|
|
6
|
+
* granted on the canonical checkout grants nothing inside the worktree. That is
|
|
7
|
+
* correct, and not the problem. The problem is that `explain` answered about
|
|
8
|
+
* whichever path it was asked about, and nobody asked it about the path the
|
|
9
|
+
* agent was standing in:
|
|
10
|
+
*
|
|
11
|
+
* seisin explain qa write src/api/x allowed
|
|
12
|
+
* the role's own Write, inside a worktree Operation not permitted
|
|
13
|
+
*
|
|
14
|
+
* Both true. Measured in production on three roles of one team, and expensive
|
|
15
|
+
* every time, because a diagnosis tool that answers a different question from
|
|
16
|
+
* the one the person believes they asked is worse than one that says nothing.
|
|
17
|
+
*
|
|
18
|
+
* ── What this does, and does not, do ──
|
|
19
|
+
* It finds the twin of a path in the other checkouts of the same repo, so the
|
|
20
|
+
* commands can compare the two answers and say so when they differ. It changes
|
|
21
|
+
* no verdict. The boundary is built from the policy as written, before this
|
|
22
|
+
* file is consulted, and would be identical with it deleted — the same rule
|
|
23
|
+
* that lets the kernel monitor exist at all: observing costs nothing to observe.
|
|
24
|
+
*
|
|
25
|
+
* ── Without git ──
|
|
26
|
+
* A worktree is recognisable from one file. In a normal checkout `.git` is a
|
|
27
|
+
* directory; in a worktree it is a file reading
|
|
28
|
+
* `gitdir: <canonical>/.git/worktrees/<name>`, and the canonical side keeps the
|
|
29
|
+
* reverse pointer in `.git/worktrees/<name>/gitdir`. Two reads, no subprocess,
|
|
30
|
+
* and no dependency — a tool people install to shrink their attack surface
|
|
31
|
+
* should not shell out to find out where it is.
|
|
32
|
+
*/
|
|
33
|
+
import { statSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
34
|
+
import { dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
35
|
+
import { findConfig } from "./config.js";
|
|
36
|
+
|
|
37
|
+
const GITDIR = /^gitdir:\s*(.+?)\s*$/;
|
|
38
|
+
const WORKTREE = /^(.+)\/\.git\/worktrees\/[^/]+$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The canonical checkout a worktree's `.git` file points at, or null.
|
|
42
|
+
*
|
|
43
|
+
* `at` is the directory holding the file, for the relative form newer git can
|
|
44
|
+
* write. Anything that is not the `/.git/worktrees/<name>` shape is refused
|
|
45
|
+
* rather than guessed at: a submodule's `.git` file points into
|
|
46
|
+
* `.git/modules/`, and a worktree of a bare repo points at a directory with no
|
|
47
|
+
* working tree, so neither has a twin to name.
|
|
48
|
+
*/
|
|
49
|
+
export function parseGitdir(text, at = "/") {
|
|
50
|
+
const m = GITDIR.exec(text.trim());
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const target = resolve(at, m[1]).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
53
|
+
const w = WORKTREE.exec(target);
|
|
54
|
+
return w ? w[1] : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** stat, or null — a path that is not there yet is a normal thing to ask about. */
|
|
58
|
+
function statOf(p) {
|
|
59
|
+
try {
|
|
60
|
+
return statSync(p);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The path with symlinks followed, or the path itself when it is not on disk. */
|
|
67
|
+
function real(p) {
|
|
68
|
+
try {
|
|
69
|
+
return realpathSync(p);
|
|
70
|
+
} catch {
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Two spellings of one place — `/tmp` and `/private/tmp` — compare equal. */
|
|
76
|
+
function same(a, b) {
|
|
77
|
+
return real(a) === real(b);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The checkout a path sits in: `{ root, canonical, worktree }`, or null.
|
|
82
|
+
*
|
|
83
|
+
* Walks up until a `.git` shows up. The path itself need not exist — the file
|
|
84
|
+
* an agent was refused is usually the one it was about to create — so a missing
|
|
85
|
+
* directory is stepped over rather than treated as an answer.
|
|
86
|
+
*
|
|
87
|
+
* A `.git` file that does not name a worktree is stepped over too. It marks a
|
|
88
|
+
* submodule, and the question is where the enclosing repo is: a submodule
|
|
89
|
+
* inside a worktree is as displaced as everything else in it.
|
|
90
|
+
*/
|
|
91
|
+
export function checkoutOf(path) {
|
|
92
|
+
let dir = resolve(path);
|
|
93
|
+
for (;;) {
|
|
94
|
+
const dotgit = join(dir, ".git");
|
|
95
|
+
const st = statOf(dotgit);
|
|
96
|
+
if (st?.isDirectory()) return { root: dir, canonical: dir, worktree: false };
|
|
97
|
+
if (st?.isFile()) {
|
|
98
|
+
const canonical = parseGitdir(readFileSync(dotgit, "utf8"), dir);
|
|
99
|
+
if (canonical) return { root: dir, canonical, worktree: true };
|
|
100
|
+
}
|
|
101
|
+
const parent = dirname(dir);
|
|
102
|
+
if (parent === dir) return null;
|
|
103
|
+
dir = parent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The worktrees a canonical checkout has registered and that are still there.
|
|
109
|
+
*
|
|
110
|
+
* `git worktree remove` deletes the registration; `rm -rf` on the worktree
|
|
111
|
+
* does not, and the entry stays until someone runs `prune`. An entry whose
|
|
112
|
+
* `.git` file is gone is skipped, and so is one that no longer points back
|
|
113
|
+
* here — a directory can be deleted and reused for something else.
|
|
114
|
+
*/
|
|
115
|
+
export function worktreesOf(canonical) {
|
|
116
|
+
const dir = join(canonical, ".git", "worktrees");
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(dir);
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
let pointer;
|
|
126
|
+
try {
|
|
127
|
+
pointer = readFileSync(join(dir, name, "gitdir"), "utf8").trim();
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// `<worktree>/.git`, the file checkoutOf reads from the other side.
|
|
132
|
+
const root = dirname(resolve(join(dir, name), pointer));
|
|
133
|
+
const back = checkoutOf(root);
|
|
134
|
+
if (back?.worktree && same(back.root, root) && same(back.canonical, canonical)) out.push(root);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Every other checkout in which the asked path has a twin, under this policy.
|
|
141
|
+
*
|
|
142
|
+
* Returns `{ here, twins }`: where the asked path is, and for each other
|
|
143
|
+
* checkout of the same repo the twin's absolute `path`, its `rel` spelling
|
|
144
|
+
* against the policy root (absolute when it is outside), whether that checkout
|
|
145
|
+
* is a `worktree`, and whether the process is `standing` in it. The one the
|
|
146
|
+
* process is standing in comes first, because that is the one the person
|
|
147
|
+
* forgot to ask about.
|
|
148
|
+
*
|
|
149
|
+
* Two checkouts are compared only when a process standing in either would load
|
|
150
|
+
* the SAME policy file. A repo that tracks its `seisin.toml` gives every
|
|
151
|
+
* worktree a copy, and a role launched there resolves its territory against
|
|
152
|
+
* that copy — the canonical side is then a different policy's business, and
|
|
153
|
+
* reporting it under this one would be the confusion this file exists to end.
|
|
154
|
+
* Which is also why a checkout outside the policy root never qualifies:
|
|
155
|
+
* `findConfig` from there does not arrive at this file.
|
|
156
|
+
*
|
|
157
|
+
* Reads are not a question here. A key is a file in a declared directory, not
|
|
158
|
+
* a place in a checkout, so a twin of a key path is not a key.
|
|
159
|
+
*/
|
|
160
|
+
export function twinsOf(config, target, cwd = process.cwd()) {
|
|
161
|
+
const abs = isAbsolute(target) ? resolve(target) : resolve(config.root, target);
|
|
162
|
+
const here = checkoutOf(abs);
|
|
163
|
+
const none = { here, twins: [] };
|
|
164
|
+
if (!here || !sharesPolicy(config, here.root)) return none;
|
|
165
|
+
|
|
166
|
+
const others = here.worktree
|
|
167
|
+
? [here.canonical, ...worktreesOf(here.canonical).filter((w) => !same(w, here.root))]
|
|
168
|
+
: worktreesOf(here.canonical);
|
|
169
|
+
|
|
170
|
+
const standing = checkoutOf(cwd);
|
|
171
|
+
const inside = relative(here.root, abs);
|
|
172
|
+
const twins = [];
|
|
173
|
+
for (const root of others) {
|
|
174
|
+
if (!sharesPolicy(config, root)) continue;
|
|
175
|
+
const path = join(root, inside);
|
|
176
|
+
const under = relative(config.root, path);
|
|
177
|
+
twins.push({
|
|
178
|
+
path,
|
|
179
|
+
rel: under.startsWith("..") || isAbsolute(under) ? path : under,
|
|
180
|
+
root,
|
|
181
|
+
worktree: !same(root, here.canonical),
|
|
182
|
+
standing: Boolean(standing && same(standing.root, root)),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
twins.sort((a, b) => Number(b.standing) - Number(a.standing));
|
|
186
|
+
return { here, twins };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One sentence placing the two checkouts, in the policy's own spelling.
|
|
191
|
+
*
|
|
192
|
+
* "Standing" gets its own wording because it is the whole finding: the answer
|
|
193
|
+
* above was about one place and the process is in the other. A checkout is
|
|
194
|
+
* shown relative to the policy root, and the root itself is "this repo" rather
|
|
195
|
+
* than an empty string or a dot.
|
|
196
|
+
*/
|
|
197
|
+
export function whereIs(config, here, twin) {
|
|
198
|
+
const show = (p) => {
|
|
199
|
+
const under = relative(config.root, p);
|
|
200
|
+
return under === "" ? "this repo" : under.startsWith("..") || isAbsolute(under) ? p : under;
|
|
201
|
+
};
|
|
202
|
+
const canonical = show(here.canonical);
|
|
203
|
+
if (twin.standing)
|
|
204
|
+
return twin.worktree
|
|
205
|
+
? `you are standing in a worktree of ${canonical}, at ${show(twin.root)}`
|
|
206
|
+
: `you are standing in ${canonical}, and ${show(here.root)} is a worktree of it`;
|
|
207
|
+
return twin.worktree
|
|
208
|
+
? `${canonical} has a worktree at ${show(twin.root)}`
|
|
209
|
+
: `${show(here.root)} is a worktree of ${canonical}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Would a process standing at `dir` load this very policy file? */
|
|
213
|
+
function sharesPolicy(config, dir) {
|
|
214
|
+
const found = findConfig(dir);
|
|
215
|
+
return Boolean(found) && same(found, config.path);
|
|
216
|
+
}
|
package/ui/favicon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#12161c"/><g clip-path="url(#p)"><clipPath id="p"><rect x="6" y="6" width="20" height="20" rx="4"/></clipPath><rect x="6" y="6" width="9" height="11" fill="#3fb950"/></g><rect x="6" y="6" width="20" height="20" rx="4" fill="none" stroke="#e6edf3" stroke-width="2.6"/><path d="M15 6v11M6 17h20" stroke="#e6edf3" stroke-width="2.6" stroke-linecap="square"/></svg>
|