Skip to content

Commit 764f7af

Browse files
committed
🔒 Prove the push object graph is contained before it is read
Pointing the control repository's alternate at the checkout's object database said where native Git starts reading, not where it stops. An object database names further ones in `objects/info/alternates` and Git follows that chain transitively, and a symbolic link under `objects/` redirects a read before Git reports anything about it — both are files inside the Workspace, so a document could write either and choose which objects a push publishes. Everything Git may traverse is now proven to resolve inside the object database this run authenticated. The graph is rejected rather than repaired: deleting an authored alternate would publish from a database the run edited on the author's behalf, and ignoring one would publish from a database that is not the one it verified. The proof is the live provider's first act — after every local authority check and before the first remote observation — and it happens on no other path. A completed replay reaches no provider, so it derives and parses its retained record without walking a graph, building a control repository or running Git for the push. Deriving the object database is now lazy for the same reason. A containment failure is a provider-boundary failure: nothing is observed, nothing is performed, no `git_host_effect` outcome is published, the run fail-stops, and no path an author wrote is repeated. Both regressions are proven discriminating by a mutation run: with the two walks removed, each fails while the other eighteen steps in the suite still pass. The alternates regression additionally proves the escape was reachable — an ordinary `git` in the same export resolves the foreign commit through the planted file. Refs #370.
1 parent 9052d5c commit 764f7af

6 files changed

Lines changed: 544 additions & 88 deletions

File tree

‎architecture.md‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Existing documents and code get aligned to this section retroactively.
6262
| effect transaction | the single atomic SQLite transaction that publishes one Workspace-local mutation together with that effect's journal result |
6363
| external effect | an effect whose provider-owned outcome cannot participate in the Workspace SQLite transaction and therefore requires a stable identity and provider reconciliation |
6464
| XMD-mediated HTTP read | one bounded, non-mutating HTTP request an authored document performs through the contextual Fetch capability, whose detached response is retained as an ordinary durable observation. It is distinct from an Agent's own network authority, which it does not grant, and from a reconciled mutating external effect, which it does not claim: an interruption before its record commits may repeat the read |
65-
| object-source attachment | adapter-private composition data the trusted host builds for exactly one external-effect reconciliation, giving the selected provider authorized live access to local state the frozen request cannot describe — the Git objects a push publishes, and the retained locator it is authorized to reach. It is authenticated against the durable identity it belongs to, held in that provider's own closure, disposed with the invocation, and is never durable, never part of a natural key and never visible to routing middleware |
65+
| object-source attachment | adapter-private composition data the trusted host builds for exactly one external-effect reconciliation, giving the selected provider authorized live access to local state the frozen request cannot describe — the Git objects a push publishes, and the retained locator it is authorized to reach. It is authenticated against the durable identity it belongs to and proven contained before its first use — the local state behind one can name further state, so the graph a provider could traverse through it is validated rather than assumed — held in that provider's own closure, disposed with the invocation, and is never durable, never part of a natural key and never visible to routing middleware |
6666
| checkpoint | a completed journal boundary associated with the logical Workspace root visible after that effect |
6767
| history fork | a new workflow run that replays a compatible journal prefix and continues from its checkpoint and Workspace root under a new immutable document definition |
6868
| loaded copy | one independently evaluated instance of a package, such as the copy bundled into the binary or a separately installed dependency |
@@ -1035,7 +1035,19 @@ retained identity they belong to, and held in the selected provider's closure.
10351035
Nothing about an attachment reaches the durable request, the natural key or the
10361036
routing surface, so what discriminates the effect stays the filtered identity a
10371037
journal may hold while what performs it stays where no replaceable code can
1038-
address it. The Workspace transaction such an effect opens is read-only and is
1038+
address it.
1039+
1040+
What an attachment grants access to is authenticated separately from where it
1041+
came from, because local state can name further local state: a Git object
1042+
database names further ones through its alternates chain, and a symbolic link
1043+
under it redirects a read before Git reports anything about it. The graph a
1044+
provider could traverse is therefore proven contained on the live provider's
1045+
first use of the attachment — before any external observation, and on no other
1046+
path, so a completed replay reaches no provider and performs no such proof. A
1047+
graph that leaves what the run authenticated is a boundary failure: it publishes
1048+
no outcome, repeats no authored path, and activates fail-stop. It is rejected
1049+
rather than repaired, because editing authored state to make it acceptable would
1050+
publish from state the run wrote on the author's behalf. The Workspace transaction such an effect opens is read-only and is
10391051
closed before the host is observed: no transaction is claimed across the
10401052
Workspace and the external service, and reconciliation rather than a distributed
10411053
transaction is what makes an interrupted attempt reach the host once.
@@ -2264,7 +2276,7 @@ Status is measured against main.
22642276
| implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | document filesystem built on the #366 stack and Repository/Worktree composition on the #293 stack; process capabilities unbuilt (#218) |
22652277
| `<Repository>` / `<Worktree>` / `<Dir>` composition | names a Git repository and its linked checkouts inside the run-owned Workspace, installs each as contextual working directory, and retains creation identity beside the retained Git bytes | built on the #293 stack, Deno provider only |
22662278
| transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation carries authority — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of authority, of retained state or of an unrecognized native condition fails the run instead of publishing a result | built on the #294 stack, Deno provider only |
2267-
| `Git.Push` | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an authenticated object-source attachment aimed at the exact private retained locator | built on the #370 stack, Deno provider only |
2279+
| `Git.Push` | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an object-source attachment whose alternates chain and object tree are proven contained before the first remote observation, aimed at the exact private retained locator | built on the #370 stack, Deno provider only |
22682280
| workflow lifecycle inspection and control | reads status/list/history without advancing a run, enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | built on the #367 stack |
22692281
| historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry | built on the #367 stack |
22702282
| history fork | creates a new run from one compatible checkpoint and retained Workspace root | defined in `specs/workflow-workspace-spec.md`, unbuilt (#368) |
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* The authorized way one push reaches this run's Git objects, and nothing more.
3+
*
4+
* Never the selected checkout itself. A checkout carries a `.git/config` the
5+
* Workspace retains and a document can write, and several ordinary settings in
6+
* one name a program or a destination: `remote.origin.pushurl`, a
7+
* `url.<base>.pushInsteadOf` rewrite, a `pre-push` hook, a credential helper,
8+
* `gpg.program`. Running the transport there would let retained document data
9+
* choose where this run publishes and what else runs while it happens.
10+
*
11+
* So the transport runs in a bare repository this provider created, configured
12+
* by nothing but its own creation, which reads the checkout's object database
13+
* through a read-only alternate. Same objects, different door — and the door
14+
* this run owns.
15+
*
16+
* ## The door is not the whole of what is behind it
17+
*
18+
* Pointing that alternate at the checkout's object database says where Git
19+
* starts reading, not where it stops. An object database names further ones in
20+
* `objects/info/alternates`, Git follows that chain transitively, and every
21+
* file in the chain is inside the Workspace this run restores — so a document
22+
* that writes one is choosing which objects a push may publish. A symbolic link
23+
* anywhere under `objects/` does the same thing without a chain at all: the
24+
* operating system resolves it before Git reports anything about it.
25+
*
26+
* Neither is repaired here. Deleting an authored alternate would publish from a
27+
* database this run edited on the author's behalf, and ignoring one would
28+
* publish from a database that is not the one it verified. The graph is
29+
* *rejected*: everything Git may traverse has to resolve inside the object
30+
* database this run authenticated, and a graph that leaves it is not a source
31+
* this push has an authorized way to read.
32+
*
33+
* ## Why it is lazy
34+
*
35+
* Nothing here runs until a live provider first needs the source, which is
36+
* before its first remote observation and after every local authority check. An
37+
* execution that replays a completed push reaches no provider at all: the
38+
* shared engine hands back the retained record, so no control repository is
39+
* built, no object graph is walked and no Git runs for the push.
40+
*/
41+
42+
import { type Operation, until } from "effection";
43+
import { ensureDir, lstat, readdir, readTextFile, writeTextFile } from "@effectionx/fs";
44+
import { realpath } from "node:fs/promises";
45+
import type { Stats } from "node:fs";
46+
import { GitOperationInfrastructureError } from "../../composition/errors.ts";
47+
import type { GitObjectFormat } from "../../composition/records.ts";
48+
import { commonDirectory, gitSession, initControlPlane, type GitSession } from "./git.ts";
49+
import type { RepositoryHost } from "./host.ts";
50+
import type { GitCheckout } from "./operations.ts";
51+
52+
/**
53+
* A source the transport may read, once something needs it.
54+
*
55+
* `ready()` answers with the control repository, having proven on its first
56+
* call that the object graph behind it is contained. Later calls answer with
57+
* the same one: a source is validated once per invocation, and the invocation
58+
* owns it.
59+
*/
60+
export interface PushObjectSource {
61+
readonly git: GitSession;
62+
ready(): Operation<string>;
63+
}
64+
65+
/**
66+
* A containment failure, said without saying what it found.
67+
*
68+
* The path an author wrote is untrusted input, and a diagnostic is the last
69+
* place it should reappear. Nothing that reaches here quotes one — and the
70+
* Git-host boundary withholds even this sentence, because a provider's failure
71+
* is not something a document may read.
72+
*/
73+
function uncontained(operation: string, reason: string): never {
74+
throw new GitOperationInfrastructureError(operation, reason);
75+
}
76+
77+
/** What the host holds at this path, or `undefined` when it holds nothing. */
78+
function* entry(path: string): Operation<Stats | undefined> {
79+
try {
80+
return yield* lstat(path);
81+
} catch (error) {
82+
const code = error instanceof Error ? Reflect.get(error, "code") : undefined;
83+
if (code === "ENOENT" || code === "ENOTDIR") {
84+
return undefined;
85+
}
86+
throw error;
87+
}
88+
}
89+
90+
/**
91+
* Every entry beneath the object database is one the operating system will not
92+
* redirect.
93+
*
94+
* `lstat` throughout, so a symbolic link answers as itself rather than as
95+
* whatever it points at. A link under `objects/` — `objects/pack` is the one
96+
* worth planting — makes an external directory the thing Git reads packs from,
97+
* and no question asked about the object database afterwards would say so,
98+
* because every one of them would already have followed it.
99+
*/
100+
function* containedTree(directory: string, operation: string): Operation<void> {
101+
for (const name of yield* readdir(directory)) {
102+
const path = `${directory}/${name}`;
103+
const info = yield* entry(path);
104+
if (info === undefined) {
105+
continue;
106+
}
107+
if (info.isSymbolicLink()) {
108+
uncontained(
109+
operation,
110+
"its object database holds a symbolic link, so what native Git would read is not what " +
111+
"this run retained",
112+
);
113+
}
114+
if (info.isDirectory()) {
115+
yield* containedTree(path, operation);
116+
continue;
117+
}
118+
if (!info.isFile()) {
119+
uncontained(
120+
operation,
121+
"its object database holds an entry that is neither a file nor a directory",
122+
);
123+
}
124+
}
125+
}
126+
127+
/**
128+
* The object directories one alternates file names, as Git would read them.
129+
*
130+
* One path per line. A blank line is nothing and a `#` line is a comment, which
131+
* is what Git's own reader does with them; every other line is a path, absolute
132+
* or relative to the object directory that named it.
133+
*/
134+
function alternatePaths(content: string, from: string): string[] {
135+
const paths: string[] = [];
136+
for (const line of content.split("\n")) {
137+
if (line === "" || line.startsWith("#")) {
138+
continue;
139+
}
140+
paths.push(line.startsWith("/") ? line : `${from}/${line}`);
141+
}
142+
return paths;
143+
}
144+
145+
/**
146+
* Walk the alternates chain, and refuse a graph that leaves the database.
147+
*
148+
* Transitive, because Git's is: an alternate may name a database that names
149+
* another. Cycles are the author's to write and this one's to survive, so a
150+
* directory already walked is not walked again.
151+
*
152+
* Containment is decided on the resolved path. `realpath` is what a traversal
153+
* actually reaches, and a relative entry, a `..` entry and a link at the far
154+
* end all arrive at the same question — is what Git would read part of the
155+
* object database this run authenticated?
156+
*/
157+
function* containedAlternates(
158+
objects: string,
159+
directory: string,
160+
seen: Set<string>,
161+
operation: string,
162+
): Operation<void> {
163+
if (seen.has(directory)) {
164+
return;
165+
}
166+
seen.add(directory);
167+
168+
const file = `${directory}/info/alternates`;
169+
if ((yield* entry(file))?.isFile() !== true) {
170+
return;
171+
}
172+
for (const named of alternatePaths(yield* readTextFile(file), directory)) {
173+
let resolved: string;
174+
try {
175+
resolved = yield* until(realpath(named));
176+
} catch {
177+
uncontained(
178+
operation,
179+
"its object database names an alternate object directory that does not resolve",
180+
);
181+
}
182+
if (resolved !== objects && !resolved.startsWith(`${objects}/`)) {
183+
uncontained(
184+
operation,
185+
"its object database names an alternate object directory outside the database this " +
186+
"run authenticated, so a push from it could publish objects this run does not hold",
187+
);
188+
}
189+
if ((yield* entry(resolved))?.isDirectory() !== true) {
190+
uncontained(
191+
operation,
192+
"its object database names an alternate that is not a real object directory",
193+
);
194+
}
195+
yield* containedAlternates(objects, resolved, seen, operation);
196+
}
197+
}
198+
199+
/**
200+
* The object database this checkout publishes from, once it is contained.
201+
*
202+
* Three questions in one order, and the order is what makes each answerable.
203+
* The database has to be the retained Repository's own — a linked worktree
204+
* shares it, which is why one proof covers both kinds of checkout. It has to be
205+
* a real directory that resolves to itself, so the walk below is walking the
206+
* export rather than somewhere a link led. And then everything Git may traverse
207+
* from it — every entry beneath it, and every object directory the alternates
208+
* chain names — has to resolve inside it.
209+
*/
210+
function* containedObjectDatabase(checkout: GitCheckout, operation: string): Operation<string> {
211+
const administration = `${checkout.repositoryDirectory}/.git`;
212+
if ((yield* commonDirectory(checkout.git, checkout.directory)) !== administration) {
213+
uncontained(
214+
operation,
215+
"the checkout it ran in does not share the retained repository's object database",
216+
);
217+
}
218+
219+
const objects = `${administration}/objects`;
220+
if ((yield* entry(objects))?.isDirectory() !== true) {
221+
uncontained(operation, "the retained repository holds no object database to publish from");
222+
}
223+
if ((yield* until(realpath(objects))) !== objects) {
224+
uncontained(
225+
operation,
226+
"its object database does not resolve inside the checkout this run materialized",
227+
);
228+
}
229+
230+
yield* containedTree(objects, operation);
231+
yield* containedAlternates(objects, objects, new Set<string>(), operation);
232+
return objects;
233+
}
234+
235+
/**
236+
* A source for one push, built the first time something needs to read it.
237+
*
238+
* The directory is acquired by the caller's scope, so it is removed with the
239+
* push whether or not anything was ever built inside it. What is deferred is
240+
* every Git command and every filesystem walk: on a completed replay none of
241+
* them happens, because no provider is reached to ask.
242+
*/
243+
export function objectSource(
244+
host: RepositoryHost,
245+
root: string,
246+
checkout: GitCheckout,
247+
format: GitObjectFormat,
248+
operation: string,
249+
): PushObjectSource {
250+
const git = gitSession(host, root);
251+
const directory = `${root}/control`;
252+
let built = false;
253+
return {
254+
git,
255+
*ready(): Operation<string> {
256+
if (!built) {
257+
const objects = yield* containedObjectDatabase(checkout, operation);
258+
yield* initControlPlane(git, root, directory, format);
259+
yield* ensureDir(`${directory}/objects/info`);
260+
yield* writeTextFile(`${directory}/objects/info/alternates`, `${objects}\n`);
261+
built = true;
262+
}
263+
return directory;
264+
},
265+
};
266+
}

0 commit comments

Comments
 (0)