Allow quire mirror push over SSH dispatch
Assisted-by: GLM-5.3 via pi
diff --git a/README.md b/README.md
index 5c3532a..63852f8 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,9 @@ jj git remote add origin git@host:foo.git
jj git push
```
-Other management commands: `quire repo list` and `quire repo rm foo.git`, invoked the same way via SSH.
+Other management commands: `quire repo list`, `quire repo rm foo.git`, and
+`quire mirror push foo.git refs/heads/main` (re-trigger mirroring of a ref),
+invoked the same way via SSH.
## Design principles
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 1293490..09be7d6 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -122,7 +122,7 @@ Things most likely to go wrong here:
### 2. `quire exec` dispatch subcommand
-Replace the ad-hoc shell dispatch from step 1 with `docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. The `quire exec` subcommand takes the original command string, parses it properly (shell-style with a real parser, not regex), validates it against a strict allowlist — `git-receive-pack`, `git-upload-pack`, `git-upload-archive`, and a specific set of `quire` subcommands (`new`, `list`, `rm`) — and execs the appropriate binary.
+Replace the ad-hoc shell dispatch from step 1 with `docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. The `quire exec` subcommand takes the original command string, parses it properly (shell-style with a real parser, not regex), validates it against a strict allowlist — `git-receive-pack`, `git-upload-pack`, `git-upload-archive`, and a specific set of `quire` subcommands (`repo new`, `repo list`, `repo rm`, `mirror push`) — and execs the appropriate binary.
**This is the only dispatch surface into the container.** There's no sshd in the container to backstop a permissive parser; anything that gets past `quire exec` runs as trusted. The allowlist is the security boundary — not a UX convenience, the actual boundary. Treat it that way: explicit enumeration, reject by default, no regex-based "looks safe enough," tests for the rejection paths as well as the accept paths.
diff --git a/docs/host/README.md b/docs/host/README.md
index f191de2..b256381 100644
--- a/docs/host/README.md
+++ b/docs/host/README.md
@@ -63,9 +63,10 @@ Reference configs for dispatching SSH connections into the quire container.
SSH dispatch is handled by `quire exec` inside the container. The sshd
ForceCommand passes `$SSH_ORIGINAL_COMMAND` directly to the binary,
-which validates the git command against an allowlist (git-receive-pack,
-git-upload-pack, git-upload-archive) and sanitizes the repository path
-before exec'ing the git subprocess.
+which validates the command against an allowlist (git-receive-pack,
+git-upload-pack, git-upload-archive, and the `quire repo`/`quire mirror
+push` subcommands) and sanitizes the repository path
+before exec'ing the appropriate subprocess.
The container image doesn't bake in a `quire` user — it runs as whatever
uid/gid the host passes via `--user`. This avoids "dubious ownership"
diff --git a/quire-server/src/bin/quire/commands/exec.rs b/quire-server/src/bin/quire/commands/exec.rs
index 96da931..79205fa 100644
--- a/quire-server/src/bin/quire/commands/exec.rs
+++ b/quire-server/src/bin/quire/commands/exec.rs
@@ -64,9 +64,36 @@ fn dispatch_git(quire: &Quire, git_cmd: &str, args: &[String]) -> Result<()> {
fn dispatch_quire(args: &[String]) -> Result<()> {
ensure!(!args.is_empty(), "no quire subcommand provided");
- ensure!(args[0] == "repo", "unsupported quire command: {}", args[0]);
+ // Allowlist: `repo <any>` and `mirror push <repo> <ref>`. An explicit mirror
+ // push grants no new capability over SSH — pushing already mirrors every
+ // updated ref — it just re-triggers after a mirror-side failure.
+ let words: Vec<&str> = args.iter().map(String::as_str).collect();
+ ensure!(
+ matches!(words.as_slice(), ["repo", ..] | ["mirror", "push", ..]),
+ "unsupported quire command: {}",
+ args.join(" ")
+ );
- tracing::info!(subcmd = "repo", "dispatching quire command");
- let err = Command::new("quire").arg("repo").args(&args[1..]).exec();
+ tracing::info!(subcmd = %args[0], "dispatching quire command");
+ let err = Command::new("quire").args(args).exec();
bail!("exec failed: {err}");
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn mirror_dispatch_requires_push_subcommand() {
+ assert!(dispatch_quire(&["mirror".into()]).is_err());
+ assert!(dispatch_quire(&["mirror".into(), "status".into()]).is_err());
+ }
+
+ #[test]
+ fn non_allowlisted_quire_commands_are_rejected() {
+ // `serve` and `ci` are real subcommands that must stay undispatchable
+ // over SSH; the accept paths exec and can't run in-process.
+ assert!(dispatch_quire(&["serve".into()]).is_err());
+ assert!(dispatch_quire(&["ci".into(), "run".into()]).is_err());
+ }
+}