Skip to content

v0.1: pull a repo, ask the owner on Telegram, apply one exact commit #1

Open

Description

@groupsky

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-pull has 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-playbook locally on the approved commit.

Rules

  1. 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.
  2. One exact commit. The question shows the full SHA. The apply step
    applies only that SHA, even when a newer commit arrives in between.
  3. 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). The check and summary commands 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.
  4. 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 (getUpdates long-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.
  5. 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.
  6. Root never runs git in a directory another user can write. A
    .git/config that someone else can write is code execution as root
    (core.fsmonitor, hooks, and others). So pullgate run runs 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

  1. A timer starts the run. A lock allows one run per instance. If a pause
    file exists, the run logs "paused" and exits.
  2. Fetch the branch with a read-only deploy key and a pinned SSH host
    key.
  3. 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, or pullgate 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.
  4. 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
      watch list;
    • the output of an optional summary command 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.
  5. Run the optional check command (rule 3). If the check fails, send a
    notice and do not ask. check and summary get the same env
    variables as apply (step 7), with PULLGATE_TREE read-only.
  6. 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-byte callback_data), not the SHA. A second tap does nothing.
  7. After Apply, root runs the apply command with these env variables:
    PULLGATE_SHA, PULLGATE_OLD_SHA, PULLGATE_TREE (a clean copy of
    that commit) and PULLGATE_CHANGED_FILES. Then it runs the optional
    health command. If apply or health fails, it runs apply again with the
    last good SHA and reports. So apply must 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.
  8. 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.md and THREAT-MODEL.md cover 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, .gitattributes filters, 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), and pullgate 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-playbook on 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;
    - check and summary run 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 the auto mode 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.

Activity

  1. groupsky commented on Nov 11, 2026

    @groupsky
    ContributorAuthor

    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": run apply again with the last good SHA, then health. 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.

  2. groupsky commented on Nov 11, 2026

    @groupsky
    ContributorAuthor

    Two more additions (2026-11-11, from review):

    1. 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 with pause, a newer commit is asked and can be applied.
    2. Give check, summary and apply the old tree too: PULLGATE_OLD_TREE is a clean, read-only copy of the last applied SHA (empty on the first run). A summary command can then compare the two commits from git, for example config-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.
  3. groupsky commented on Nov 11, 2026

    @groupsky
    ContributorAuthor

    hq-jobs pullgate-1-build (run r-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

    • DESIGN.md and THREAT-MODEL.md (docs: design, threat model, rules and CI #2, 2bc846d). DESIGN.md section 2 has rules 1-6; sections 7-13 have the run, the spool, approvals, the apply with on_failure (rollback/pause, code default pause, pause holds the SHA and a newer commit is asked with a warning), and PULLGATE_OLD_TREE. THREAT-MODEL.md: stolen bot token T1, compromised listener T2, hostile commit T3 (content, file names, symlinks, large trees, .gitattributes filters, 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.
    • The commands (feat: run, listen, apply and status #3, d5e71ec): cmd/pullgate/main.go has run, listen, apply (started by pullgate-apply@.path) and status (--ask-again, --pause, --resume).
    • Example units and configs (feat: run, listen, apply and status #3): 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's apply as root; README says why and how to add a drop-in). Examples: contrib/examples/config-file/ (on_failure = "rollback", copy a file and reload) and contrib/examples/ansible/ (on_failure = "pause", ansible-playbook on the tree). TestExamples checks both load and set on_failure themselves.
    • Tests for every rule (feat: run, listen, apply and status #3), with a fake Telegram server (internal/telegram/faketg) and a local bare repo (internal/testgit):
      • tap from another user or chat: TestTapsThatDoNotCount/another_user, /another_chat
      • tap on an old or closed question: TestTapsThatDoNotCount/closed_question, /another_message, /text_edited_by_a_token_thief; replay: TestUnknownCodeAndReplayAreIgnored, second tap: TestValidTapWritesOnlyTheCode
      • expired approval: TestExpiredApprovalIsIgnored, TestClockBeforeAskedIsIgnored, TestTapsThatDoNotCount/expired
      • SHA from root's own state: TestRootTakesSHAFromItsOwnState, TestBadApprovalFilesAreRemoved (an approval with a sha field is refused)
      • a newer commit does not change what is applied: TestRootTakesSHAFromItsOwnState, TestTapOnSupersededQuestionAppliesOldSHA
      • an older SHA is refused: TestSecondCommitGetsOldTreeAndForwardOnly, TestForcePushRefusedUnlessAllowOlder
      • failing check: TestFailingCheckSendsNoQuestionAndIsNotCheckedAgain, TestCheckTimeout
      • check and summary without root: TestCheckAndSummaryRunAsCheckUser, TestResolveSandboxNeverRoot
      • git in a writable directory: TestRootRefusesGitInWritableDir, TestRunRefusesUnsafeGitDir, TestInitRefusesUnsafeParent, TestCheckTreeDenies, TestHooksDoNotRun
      • failing apply or health rolls back and reports: TestFailingApplyRollsBack/fail-apply, /fail-health
      • failing rollback pauses: TestFailingRollbackPauses
      • on_failure = "pause": TestOnFailurePauseHoldsAndAsksNewer (a newer commit is asked and applied), TestCodeDefaultIsPause, TestFirstApplyFailsWithRollbackHolds
      • pause file, lock, limits, auto: TestPauseFile, TestLock, TestQuestionsPerDay, TestMaxFailedTries, TestExpiredQuestionIsClosedByRun, TestAutoMode, TestOffMode
      • crash between apply and state write: TestCrashBetweenApplyAndStateWrite, TestStopDuringApplyLeavesInProgress
      • summary cut to one message: TestSummaryIsCutToFit, TestSummaryPreClosedAtCut, TestManyWatchWarningsStillFit
      • watch warnings: TestWatchWarningsAppear, TestFirstRunAsksWithAllFilesThenApplies
      • tree export without filters, submodules, symlinks, limits: TestExport, TestExportLimitsAndSubmodules, TestSymlinkWarningAndRefuse
    • End-to-end test in CI (feat: run, listen, apply and status #3): test/e2e/e2e_test.go TestEndToEnd builds the binary, makes a bare repo with two commits, runs pullgate 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.
    • A release. Not done. ci: release setup with static binaries and checksums #4 (e0848f0) adds the release setup (static linux amd64/arm64 with CGO_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.
    • README (feat: run, listen, apply and status #3): what it is, install, config, the two examples, the unit scores. Only example names (example-host, config-repo, git.example.com).
    • Security review (the security-reviewer agent) 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

    • Set up the release App for this repo (docs/releasing.md), then say whether to release v0.1.0.
    • A test on a real host with root (the tests run as one user; the switch to check_user and the unit sandbox are only checked by unit tests and systemd-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions