Skip to content
Closed
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
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ An attacker who lands a prompt injection in tend's harness can reach three secre

**Upstream compromise.** Tend's action is referenced as `max-sixty/tend/claude@0.1.18` in every generated workflow — a **tag**, not a commit SHA. A tag is mutable by whoever owns that repository, so upstream can change what our workflows execute without any commit landing here, and `workflow-audit.yaml` would see nothing: the file is byte-identical. This is a real residual, not a solved problem. It is accepted because the file is generated (a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable) and because the trust it represents is the same trust the harness already has — tend runs the agent that holds `TEND_BOT_TOKEN` either way. What it means concretely is that the version pin bounds *deliberate* upgrades, not a hostile upstream. `uvx tend@latest` runs only at install and during nightly regen; a compromise of that path affects the next re-run, not the in-flight workflows.

**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` or `.config/tend.yaml` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the *unexplained*: two routine sources are classified and omitted on independently checked provenance and content. A Renovate pin bump must be a valid GitHub-signed commit with `author.login == "renovate[bot]"` and `committer.login == "web-flow"`, must be associated only with Renovate-authored PRs, and must change nothing but the ref of an already-referenced action. The signed author/committer pair is the provenance control: GitHub's automatically signed `createCommitOnBranch` mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring `web-flow` therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration. The content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name — the residual being a ref selected by Renovate inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@<version> init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window.
**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/`, `.config/tend.yaml`, or `.github/audit/` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. `.github/audit/` is in it for the same reason one step further out: those files hold this audit's own prompts — which domain owns which section below, what each is told to check — so editing them changes what gets audited without touching a workflow at all, and the `FAIL IF` further down asserts that the path stays in this window. This paragraph is the prose spec of that job's selection pathspec, so the two enumerations name the same paths: a path added to the window without landing here leaves a reader checking that `FAIL IF` against a paragraph that contradicts it. It reports the *unexplained*: two routine sources are classified and omitted on independently checked provenance and content. A Renovate pin bump must be a valid GitHub-signed commit with `author.login == "renovate[bot]"` and `committer.login == "web-flow"`, must be associated only with Renovate-authored PRs, and must change nothing but the ref of an already-referenced action. The signed author/committer pair is the provenance control: GitHub's automatically signed `createCommitOnBranch` mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring `web-flow` therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration. The content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name — the residual being a ref selected by Renovate inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@<version> init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. That refusal is stated per-path deliberately but generalizes to the window as a whole: both arms inspect only `.github/workflows/`, so every other path the window selects has to be refused outright rather than examined — widening the window without widening those refusals is what would let a commit hide inside an already-classified diff shape, which is the opposite of what widening it is for. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window.

**Two known evasions of that diff window**, both from how the window is computed rather than from what it classifies. The lower bound is server-set (the previous successful run's `created_at`), but the filter that applies it is `git log --all --since`, which compares against the **committer date** — a field the pusher sets freely, so `GIT_COMMITTER_DATE=2020-01-01` on a commit adding a workflow makes it invisible to every future window. And a branch pushed, run with repo-level secrets in scope, and deleted before the nightly fetch is never in any window at all, because the audit only ever sees refs that still exist. Closing both means keying on server-observed ref changes (the repository activity API records pushes, force-pushes, and deletions with server timestamps and before/after SHAs) rather than on the commit graph as the client presents it. Neither is closed today; they are stated here so the control is not read as stronger than it is.

Expand Down