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
26 changes: 20 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ sources must stay erasable (`erasableSyntaxOnly` is on: no enums, no parameter p
container can create one. Compare real paths (`realpathSync`), and ask what else can make a path point
elsewhere: the first fix for this was a prefix check, and the bypass was found by a review bot asking the
question the fix had not.

**The other half is what it compares against.** The directories shared with the container are the
user's to name, so there is no list to hard-code.
A process inside the container reads its own mount table (`/proc/self/mountinfo`); a host process asks
a container runtime — docker, podman or nerdctl, whichever is there — for the paths its containers
bind-mount, running or stopped, which is a superset on purpose: any container that can rewrite the
broker's code is as good a reason not to stage there as the agent is. A runtime that knows of no
container is not an answer — that is exactly when the check is blind, since the agent container may
simply be stopped with its mounts still configured — so it refuses rather than guesses, as it does
when nothing can answer at all.
- **The process that owns a boundary decides what crosses it, not the error class.** A response body put
into the message of a `ProviderConfigError` reached the container, because a caller that wraps that error
to name a missing permission wraps it in the same class and so passes the whole message through. Detail
Expand Down Expand Up @@ -164,8 +174,10 @@ sources must stay erasable (`erasableSyntaxOnly` is on: no enums, no parameter p
is still the broker — `--via-host` is asked for, never assumed.

The opener is started once per machine, not once per repository: it discovers the checkouts under
its `--root`s (default `$HOME`, so `~/Workspaces`, `~/.dotfiles` and anything added later are
served). The paths on the two sides never have to match — each side reads and writes its own view
its `--root`s (default `$HOME`) within `--depth` levels of each (4 by default), so anything added
later is picked up by the next sweep. The bound is deliberate — the walk repeats every `--interval`
seconds — and `--depth` raises it for a deeper layout. The paths on the two sides never have to
match — each side reads and writes its own view
of the same `.git` directory — so only the roots it scans are host-side paths. A request is served in the
checkout it was written in. That is a mechanism, not a boundary: the container can edit any checkout it can
write, its remote included, so what a deployment controls is which roots are served and which program runs.
Expand Down Expand Up @@ -229,14 +241,16 @@ Full detail and the measurements behind them:
- **Egress requires the proxy.** Direct connections to github.com hang rather than fail.
The credential channel is a unix socket, so it is unaffected — that is why it is a socket.
- **The container is recreated often**, so its writable layer is ephemeral. Anything that must
survive belongs in compose `environment:` or on a mounted path. `~/Workspaces`, `~/.dsh` and
`~/.dotfiles` are mounts; `/home/app` itself is not; `/volume1` does not exist inside.
survive belongs in compose `environment:` or on a mounted path. Which directories this deployment
mounts is the deployment's business — the tool discovers them rather than knowing them — and
`/home/app` itself is not one of them, nor does `/volume1` exist inside.
- **Do not rely on the executable bit in commits**: this share's ACLs defeat git's exec-bit
detection, so every sibling repository has `core.fileMode=false`. The exec bit for the CLI
entry points is applied by `scripts/postbuild.mjs` at build time, and npm sets it for the
installed bins, so nothing depends on it being tracked.
- **`~/.dsh` lives *inside* the `~/Workspaces` mount**, so "put it in `~/.dsh`" means "put it on
the shared NAS share". Never treat it as a private location for secrets.
- **A directory the container shares is not a private location**, even one that looks like the
tool's own home: this deployment's socket directory lives inside another shared directory, so
treating it as private would put a secret on the share.

## Where things are

Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ read-only mount, so nothing is fetched or built at boot.

```sh
DEPLOY=/srv/git-cred-broker # broker.config.json, app.pem and log/ live here
SOCKET_DIR=~/.dsh/git-broker # a host directory the pushing container already mounts
SOCKET_DIR=/srv/git-broker-socket # a host directory the pushing container already mounts

git-credential-broker init --mode sidecar --dir "$DEPLOY" \
--cert ~/Downloads/app.private-key.pem \
Expand All @@ -115,6 +115,15 @@ git-credential-broker compose --code "$DEPLOY/app" --socket-dir "$SOCKET_DIR" \
docker compose -f "$DEPLOY/docker-compose.broker.yml" up -d
```

Nothing above has to name the shared directories. A host process asks the container runtime — docker,
podman or nerdctl, whichever is there — for the host paths its containers bind-mount, running or
stopped; a process inside the container reads `/proc/self/mountinfo`. When neither can answer — no
runtime, a runtime that knows of no container at all, or a host whose only runtime is plain
`containerd`, which has no interface this knows — `init`, `stage` and `authorize` refuse rather than
guess. The runtime route is deliberately a superset: a directory bind-mounted into *any* container
counts as shared, because any container that can rewrite the broker's code is as good a reason not to
stage there as the agent is.

`stage` prints the commit it exported; check that sha against your own clone or the remote before
trusting it, since the repository you export from is one an agent can write to. It leaves a
`STAGED.json` beside the code recording where it came from.
Expand Down Expand Up @@ -157,7 +166,7 @@ when the image has none. In compose:
- GIT_BROKER_SOCKET=/run/git-broker/broker.sock
- GIT_BROKER_REQUIRE=1
volumes:
- ~/.dsh/git-broker:/run/git-broker
- /srv/git-broker-socket:/run/git-broker
```

## Everyday changes
Expand Down Expand Up @@ -284,8 +293,10 @@ git-credential-broker pr --via-host --repo owner/repo --head feature --base main
The opener creates pull requests with the credentials of whoever started it, which is the point;
it never runs a shell, and the program it calls is configuration rather than a hard-coded `gh`:
`--command /path/to/gh`, or `$GIT_BROKER_PR_COMMAND`. It cannot push, merge, close or read
anything. `--root` may be repeated and defaults to `$HOME`, so `~/Workspaces`, `~/.dotfiles` and
checkouts created later are all served with no further setup. To start it once and keep it,
anything. `--root` may be repeated and defaults to `$HOME`; checkouts within `--depth` levels of a root
(4 by default) are served with no further setup, including ones created later, which the next sweep
picks up. The bound exists because the walk repeats every `--interval` seconds; raise `--depth` for
deeper layouts. To start it once and keep it,
`examples/host-opener.service` is a systemd user unit; on a system without user services,
`examples/host-opener-boot.sh` is the same thing for a DSM boot-up task — absolute paths, one
instance at a time, and its output in a log file. Without `--via-host`, or with no opener running, `pr` behaves
Expand Down Expand Up @@ -453,8 +464,9 @@ matter:
can rewrite that code, the agent owns the allowlist. That is what `stage` is for.

With a shared `.git/config` between host and container: never set credentials with `git config
--local` (it is the same file on both sides); the container's own `~/.gitconfig` is private, but
`~/.dotfiles` is mounted, which is why `setup` refuses to write through a symlink into it.
--local` (it is the same file on both sides); the container's own `~/.gitconfig` is private, but any
directory the container shares is not, which is why `setup` refuses to write the config through a
symlink or into one.

Credential helpers are only consulted for HTTP(S) remotes, so none of this affects the host's SSH
pushes.
Expand Down
14 changes: 6 additions & 8 deletions examples/docker-compose.snippet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ services:
- ./log:/var/log/git-cred-broker
# The socket directory, shared with the DSH container below. This is the only interface
# between the two: no ports are published, and none should be added.
- /volume1/homes/<you>/Workspaces/<project>/.dsh/git-broker:/run/git-broker
- /srv/git-broker-socket:/run/git-broker
```

Pin a version (`npx --yes --package 'git-credential-broker@0.1.0'`) if you would rather not
Expand All @@ -110,10 +110,8 @@ services:
- GIT_BROKER_REQUIRE=1
volumes:
# ... existing mounts ...
- git-cred-broker-run:/run/git-broker

volumes:
git-cred-broker-run:
# The *same* host directory the broker binds, or the two are not sharing a socket at all.
- /srv/git-broker-socket:/run/git-broker
```

Then, inside the container:
Expand All @@ -122,6 +120,6 @@ Then, inside the container:
git-credential-broker setup
```

Do **not** put the private key or any credential in `~/Workspaces`, `~/.dsh` or `~/.dotfiles`:
all three are mounted into the container. `~/.dsh` in particular is *inside* the
`~/Workspaces` mount, so it is not a safe place for anything secret.
Do **not** put the private key or any credential anywhere the agent container mounts: every such
directory is shared with it. The broker's configuration, key and token belong outside all of them,
and `init`, `stage` and `authorize` refuse a target they detect as shared.
2 changes: 1 addition & 1 deletion src/commands/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export function runCompose(argv: readonly string[]): number {
const configPath = resolveConfigPath(args.value('config'));
const socketDir = args.value('socket-dir');
if (!socketDir) {
fail('--socket-dir is required (a host directory the pushing container already mounts, e.g. its ~/.dsh)');
fail('--socket-dir is required (a host directory the pushing container already mounts)');
}
const dir = args.value('dir') ?? deploymentDir(configPath);

Expand Down
4 changes: 3 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface InitEnvironment {
readonly isInsideContainer?: () => boolean;
/** The home directory whose mounts must not contain the key. */
readonly home?: string;
/** The directories the container shares, when the caller already has them. */
readonly mounts?: readonly string[];
}

/** Everything `init` needs, resolved. */
Expand Down Expand Up @@ -235,7 +237,7 @@ export function performInit(input: InitInput): InitResult {
}

const home = input.env?.home ?? (process.env['HOME'] ?? '');
const mounted = insideMountedPath(input.dir, home);
const mounted = insideMountedPath(input.dir, home, input.env?.mounts);
if (mounted) {
throw new Error(
`${input.dir} is inside ${mounted}, which is mounted into the container; the private key must live outside every mount`,
Expand Down
30 changes: 23 additions & 7 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import {
findSystemCaBundle,
gitConfigList,
gitConfigSet,
insideMountedPath,
listFlag,
mountedPaths,
parseArgs,
say,
warn,
Expand All @@ -51,6 +53,10 @@ export interface SetupInput {
readonly rewriteSshHost: string | null;
/** Report what would happen without writing anything. */
readonly dryRun: boolean;
/** Home whose mounts the config must not be written into; defaults to the real home. */
readonly home?: string;
/** The directories the container shares, when the caller already has them. */
readonly mounts?: readonly string[];
/** Platform to answer for; injectable so the refusal is testable. */
readonly platform?: NodeJS.Platform;
}
Expand Down Expand Up @@ -92,7 +98,7 @@ export function defaultHelperPath(): string {
*
* @param input - the resolved options.
* @returns what was done.
* @throws {Error} when the target config is a symlink or resolves into the host's dotfiles.
* @throws {Error} when the target config is a symlink, or inside a directory the container shares.
*/
export async function performSetup(input: SetupInput): Promise<SetupResult> {
// Refuse on native Windows: configuring git there would look like success and never work, because
Expand All @@ -101,16 +107,25 @@ export async function performSetup(input: SetupInput): Promise<SetupResult> {
if (refusal !== null) {
throw new Error(refusal);
}
// Writing through a symlink would edit whatever it points at. On the host, ~/.gitconfig is a
// symlink into ~/.dotfiles, which is mounted into the container — so this is not theoretical.
// Writing through a symlink would edit whatever it points at, and a config that lives in a directory
// the container shares is one the container can rewrite.
if (fs.existsSync(input.gitconfig) && fs.lstatSync(input.gitconfig).isSymbolicLink()) {
const target = fs.realpathSync(input.gitconfig);
throw new Error(`${input.gitconfig} is a symlink to ${target}; refusing to write through it`);
}
const dotfiles = path.join(os.homedir(), '.dotfiles');
const resolved = path.resolve(input.gitconfig);
if (resolved === dotfiles || resolved.startsWith(`${dotfiles}${path.sep}`)) {
throw new Error(`${input.gitconfig} resolves into ~/.dotfiles, which is the host's own configuration`);
// The config must not be written into any directory the container can rewrite. Canonical, so the
// host's spelling of a shared directory and the container's spelling of the same one are one place.
// `setup` runs in the container by definition, so the mount table it is standing on is the
// authority. Reading it directly means a container that leaves no marker for `isInsideContainer`
// (plain containerd, a Kubernetes pod) still gets the right answer instead of hunting for a runtime
// it does not have.
const mounts = input.mounts ?? mountedPaths(input.home, { container: true });
const mounted = insideMountedPath(input.gitconfig, input.home, mounts);
if (mounted) {
throw new Error(
`${input.gitconfig} is inside ${mounted}, which is mounted into the container; refusing to write ` +
"this environment's git config where the container can rewrite it",
);
}

const warnings: string[] = [];
Expand Down Expand Up @@ -228,6 +243,7 @@ export async function runSetup(argv: readonly string[]): Promise<number> {
caBundlePath: args.value('ca-bundle') ?? path.join(home, '.config', 'git-credential-broker', 'ca-bundle.pem'),
rewriteSshHost: args.has('no-rewrite-ssh') ? null : (args.value('rewrite-ssh') ?? 'github.com'),
dryRun: args.has('dry-run'),
home,
});

say(`git config : ${result.gitconfig}${args.has('dry-run') ? ' (dry run)' : ''}`);
Expand Down
7 changes: 5 additions & 2 deletions src/commands/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { canonicalPath, fail, insideMountedPath, parseArgs, say, warn, writeSecretFile } from './support.ts';
import { canonicalPath } from '../paths.ts';
import { fail, insideMountedPath, parseArgs, say, warn, writeSecretFile } from './support.ts';

/** The record `stage` leaves behind, so the running code is identifiable afterwards. */
export const STAGED_RECORD = 'STAGED.json';
Expand All @@ -36,6 +37,8 @@ export interface StageInput {
readonly ref: string;
/** The home whose mounts the target must not be inside. */
readonly home?: string;
/** The directories the container shares, when the caller already has them. */
readonly mounts?: readonly string[];
/** Replace a target that this command did not stage itself. */
readonly force?: boolean;
}
Expand All @@ -62,7 +65,7 @@ export function performStage(input: StageInput): StageResult {
const to = path.resolve(input.to);
const home = input.home ?? process.env['HOME'] ?? '';

const mounted = insideMountedPath(to, home);
const mounted = insideMountedPath(to, home, input.mounts);
if (mounted) {
throw new Error(
`${to} is inside ${mounted}, which is mounted into the container. The broker runs with the ` +
Expand Down
Loading
Loading