Repository navigation
Open
Description
Why
A host should change only through its own updates. It pulls a git repo
by itself, and it applies a new commit only after its owner taps
Apply on a Telegram question that names that exact commit.
No existing tool does this on plain Linux (checked 2026-11-11):
ansible-pullhas no approval step and no hook.- comin has a "manual" confirm, but it runs only on NixOS.
- Komodo, AWX, Semaphore and Rudder need a central server. Some cannot pin
one commit. - omen-cd keeps its gate in the repo, so a commit can remove the gate.
Two first users:
- a secrets broker that applies its policy file from a private repo, and
then reloads; - servers that run
ansible-playbooklocally on the approved commit.
Rules
- The gate lives on the host, never in the repo. What runs, who may
approve, and which paths get a warning: all of it comes from the host
config (/etc/pullgate/NAME.toml). A commit cannot change the gate. - One exact commit. The question shows the full SHA. The apply step
applies only that SHA, even when a newer commit arrives in between. - No code from the new commit runs before Apply. Before the tap,
pullgate only fetches the commit and reads it with git (log, diff,
files). Thecheckandsummarycommands from the host config may
read the commit's files as data (for example, they parse a config
file). They run as an unprivileged, sandboxed user on a read-only copy
of the tree, never as root. - Only one process holds the bot token and talks to Telegram: the
listener. It runs as its own unprivileged user, and it connects
outbound only (getUpdateslong-poll, no inbound port). The run and
the apply give their messages to the listener through a spool
directory. It accepts a tap only from the configured owner user ID, in
the configured chat, on an open question. All it can do is write an
approval for that open question. The approval carries only the
one-time code; root looks up the SHA in its own state. - Root does the apply. It acts only on an approval that matches the
open question it recorded itself, and only within the approval's time
limit. By default it moves forward only (the new SHA must descend from
the applied SHA). A rollback to an older commit is a separate,
explicit setting. - Root never runs git in a directory another user can write. A
.git/configthat someone else can write is code execution as root
(core.fsmonitor, hooks, and others). Sopullgate runruns as root
and owns the clone. git runs with hooks off and without system or
global config. The tree is exported by SHA with no filters, no
submodules and no LFS.
What one run does
- A timer starts the run. A lock allows one run per instance. If a pause
file exists, the run logs "paused" and exits. - Fetch the branch with a read-only deploy key and a pinned SSH host
key. - If the branch tip is the applied SHA, or a SHA that was already asked,
skipped or failed its check, stop. An unanswered question that expired
is not asked again; a new commit, orpullgate status --ask-again,
asks again.
At most one question is open per instance. When the tip moves while a
question is open, the run closes the old question (it edits it to say
so) and asks about the new tip. A tap that arrives before the close
still applies the old SHA (rule 2).
On the first run there is no applied SHA. The question says so and
lists every file. The forward-only rule starts after the first apply. - Build the summary:
- the commit subjects since the applied SHA;
- the changed files;
- a clear warning for each changed path that matches the host's
watchlist; - the output of an optional
summarycommand from the host config
(for example, a semantic diff of a config file).
It must fit one Telegram message. If it is longer, cut it and say how
much was left out.
- Run the optional
checkcommand (rule 3). If the check fails, send a
notice and do not ask.checkandsummaryget the same env
variables asapply(step 7), withPULLGATE_TREEread-only. - Ask: host name, instance, full SHA, summary, and two buttons, Apply and
Skip. The buttons carry a random one-time code (it fits Telegram's
64-bytecallback_data), not the SHA. A second tap does nothing. - After Apply, root runs the
applycommand with these env variables:
PULLGATE_SHA,PULLGATE_OLD_SHA,PULLGATE_TREE(a clean copy of
that commit) andPULLGATE_CHANGED_FILES. Then it runs the optional
healthcommand. If apply or health fails, it runs apply again with the
last good SHA and reports. Soapplymust be safe to run again on an
older tree. If that rollback fails too, the instance writes its pause
file and reports. State moves forward only after success. - Report the result in the same chat (edit the question, or send a
reply). Log each step to journald (SYSLOG_IDENTIFIER=pullgate).
Modes
ask: the default.auto: no question. For development hosts.off.
Several instances can run on one host. Each has its own config, state and
lock. They may share one bot; then one listener serves them all (two
getUpdates loops on one token conflict).
Limits
These limits are in the host config, with safe defaults:
- the most questions per day;
- how long an approval stays valid;
- the most failed tries per SHA, after which that SHA waits for a new
commit.
Done when
-
DESIGN.mdandTHREAT-MODEL.mdcover the rules above. The threat
model covers at least:
- a stolen bot token;
- a compromised listener;
- a hostile commit (its content, file names, symlinks, large
trees,.gitattributesfilters, submodules);
- an approved apply that changes the gate itself (the host config,
the units or the binary), and a rollback after such an apply;
- a rollback that fails;
- a force-push or a rewritten branch;
- an old commit offered again;
- a lost or stale approval;
- a crash between apply and the state write;
- a wrong clock. - The commands exist:
pullgate run(the timer step),pullgate listen(the bot),pullgate apply(root, started by a path unit
when an approval appears), andpullgate status. - Example systemd units with hardening (
systemd-analyze security
score in the docs), and an example config for each of the two first
users: a config file followed by a service reload, and
ansible-playbookon the tree. - Tests for every rule:
- a tap from another user or chat is ignored;
- a tap on an old or closed question is ignored;
- an expired approval is ignored;
- root takes the SHA from its own state, never from the approval;
- a newer commit after the question does not change what is applied;
- an older SHA is refused;
- a failing check sends no question, and the same SHA is not
checked again on the next run;
-checkandsummaryrun without root;
- root refuses to run git in a directory another user can write;
- a failing apply or health check rolls back and reports;
- a failing rollback pauses the instance;
- the pause file, the lock, the limits and theautomode work;
- the summary is cut to fit one message;
- the watch warnings appear.
The tests use a fake Telegram server and a local git remote. - An end-to-end test in CI: a local bare repo, two commits, an
approval, then the second commit applied. - A release with a static Linux amd64/arm64 binary and checksums.
- README: what it is, install, config, the two examples. It contains
no hosts, IPs or names from any real deployment. - It gets a security review before the first release, as nodkey does.
Not in this issue
- Self-update of pullgate.
- Signed commits (
git verify-commit): an option for later. - Any web UI.
Metadata
Metadata
Assignees
Labels
No labels
Activity
groupsky commentedon Nov 11, 2026
Addition to the design (2026-11-11): what happens after a failed apply must be a setting per instance, not always a rollback.
on_failure = "rollback": runapplyagain with the last good SHA, thenhealth. This fits an instance that copies a config file and reloads a service. If the rollback also fails, write the pause file and report.on_failure = "pause": do not roll back. Write the pause file and report. The owner fixes it forward with a new commit. This fits an instance that runs a playbook, where running the old commit again can do more harm than good.There is no default in the example configs: each example sets it. The code default is
pause, because it is the safe choice. Tests for both.groupsky commentedon Nov 11, 2026
Two more additions (2026-11-11, from review):
on_failure = "pause"must not block the fix. It should not write the pause file. Instead it holds only the failed SHA: that SHA is not tried again, the report says that the last apply failed, and a newer commit is still asked, with a warning line "the last apply (SHA) failed" at the top. This way the owner can roll forward with a fix commit. The pause file stays the owner's manual stop. pullgate writes it only when a rollback also fails. Tests: after a failed apply withpause, a newer commit is asked and can be applied.check,summaryandapplythe old tree too:PULLGATE_OLD_TREEis a clean, read-only copy of the last applied SHA (empty on the first run). Asummarycommand can then compare the two commits from git, for exampleconfig-diff "$PULLGATE_OLD_TREE" "$PULLGATE_TREE". It does not need to read the live files in/etc, which the sandboxed user may not be able to read.groupsky commentedon Nov 11, 2026
hq-jobs
pullgate-1-build(runr-20261111-183015-607a)Progress on v0.1 (2026-11-11)
Merged: #2 (design, threat model, rules, CI), #3 (the code, tests, units, examples, README). #4 (release setup,
e0848f0). No release was made.Done when
2bc846d). DESIGN.md section 2 has rules 1-6; sections 7-13 have the run, the spool, approvals, the apply withon_failure(rollback/pause, code defaultpause, pause holds the SHA and a newer commit is asked with a warning), andPULLGATE_OLD_TREE. THREAT-MODEL.md: stolen bot token T1, compromised listener T2, hostile commit T3 (content, file names, symlinks, large trees,.gitattributesfilters, submodules), git as root T4, a gate change by an approved apply and the rollback after it T5, a failing rollback T6, force push T7, an old commit offered again T8, lost or stale approval T9, crash between apply and state write T10, wrong clock T11, plus T12-T15. Both got a security and a correctness review; the fixes are in the second commit of docs: design, threat model, rules and CI #2.d5e71ec):cmd/pullgate/main.gohasrun,listen,apply(started bypullgate-apply@.path) andstatus(--ask-again,--pause,--resume).contrib/systemd/(run service + timer, apply service + path, listen service).systemd-analyze security(systemd 255): listen 1.2 OK, run 2.4 OK, apply 7.9 (it runs the owner'sapplyas root; README says why and how to add a drop-in). Examples:contrib/examples/config-file/(on_failure = "rollback", copy a file and reload) andcontrib/examples/ansible/(on_failure = "pause",ansible-playbookon the tree).TestExampleschecks both load and seton_failurethemselves.internal/telegram/faketg) and a local bare repo (internal/testgit):TestTapsThatDoNotCount/another_user,/another_chatTestTapsThatDoNotCount/closed_question,/another_message,/text_edited_by_a_token_thief; replay:TestUnknownCodeAndReplayAreIgnored, second tap:TestValidTapWritesOnlyTheCodeTestExpiredApprovalIsIgnored,TestClockBeforeAskedIsIgnored,TestTapsThatDoNotCount/expiredTestRootTakesSHAFromItsOwnState,TestBadApprovalFilesAreRemoved(an approval with ashafield is refused)TestRootTakesSHAFromItsOwnState,TestTapOnSupersededQuestionAppliesOldSHATestSecondCommitGetsOldTreeAndForwardOnly,TestForcePushRefusedUnlessAllowOlderTestFailingCheckSendsNoQuestionAndIsNotCheckedAgain,TestCheckTimeoutTestCheckAndSummaryRunAsCheckUser,TestResolveSandboxNeverRootTestRootRefusesGitInWritableDir,TestRunRefusesUnsafeGitDir,TestInitRefusesUnsafeParent,TestCheckTreeDenies,TestHooksDoNotRunTestFailingApplyRollsBack/fail-apply,/fail-healthTestFailingRollbackPauseson_failure = "pause":TestOnFailurePauseHoldsAndAsksNewer(a newer commit is asked and applied),TestCodeDefaultIsPause,TestFirstApplyFailsWithRollbackHoldsTestPauseFile,TestLock,TestQuestionsPerDay,TestMaxFailedTries,TestExpiredQuestionIsClosedByRun,TestAutoMode,TestOffModeTestCrashBetweenApplyAndStateWrite,TestStopDuringApplyLeavesInProgressTestSummaryIsCutToFit,TestSummaryPreClosedAtCut,TestManyWatchWarningsStillFitTestWatchWarningsAppear,TestFirstRunAsksWithAllFilesThenAppliesTestExport,TestExportLimitsAndSubmodules,TestSymlinkWarningAndRefusetest/e2e/e2e_test.goTestEndToEndbuilds the binary, makes a bare repo with two commits, runspullgate run, the listener against the fake Telegram server, a tap from another user (ignored) and one from the owner,pullgate apply, and checks that the second commit was applied. CI job "end-to-end test" passes on feat: run, listen, apply and status #3.e0848f0) adds the release setup (static linux amd64/arm64 withCGO_ENABLED=0,SHA256SUMS, attestation). The release-please job skips until the release GitHub App is set up for this repo (docs/releasing.md, "One-time setup"). A release needs Geno's OK and that setup.example-host,config-repo,git.example.com).security-revieweragent) on docs: design, threat model, rules and CI #2, feat: run, listen, apply and status #3 and ci: release setup with static binaries and checksums #4 before each merge. On feat: run, listen, apply and status #3 it found no way to apply an unapproved SHA and no code from the commit running before Apply or as root; its 10 findings and the 15 of the correctness review were fixed in feat: run, listen, apply and status #3, except three listed in the feat: run, listen, apply and status #3 description.Left for Geno
docs/releasing.md), then say whether to release v0.1.0.check_userand the unit sandbox are only checked by unit tests andsystemd-analyze).The question "Release v0.1.0 / Not yet" was sent on Telegram on 2026-11-11 and had no answer after 30 minutes, so nothing was released. #1 stays open until the release is done.