Skip to content

Standing publish authority: commit, push and merge within limits the user set once #99

Open

Description

@androidand
Proposal

Parent: autonomous-swarm (F4).

Why

Agents are reluctant to commit, push and merge, and the cause is layered, not one rule:

  1. Model prompts: default.txt/trinity.txt ("NEVER commit … unless the user explicitly
    asks"), beast.txt, and kimi.txt ("ask for confirmation each time, even if the user
    has confirmed in earlier conversations", which defeats any durable grant by wording).
  2. Queue gates: implement/commit briefs say never push; QueueDenyRules denies
    git push/tag/remote, gh pr merge, gh api, deploy and ssh, and credentials are
    stripped from the shell. The driver pushes the branch itself; nothing merges.
  3. The operator's way-of-working file: publishing needs "explicit instruction for that
    action"; "do the work" is not it.
  4. Claude Code's built-in commit guidance.

The rules guard something real: this fork is public and its dev was force-rewritten once.
But "explicit instruction per action" with no way to give one in advance means the
swarm's last mile (merge-back) always stops for the human. The operator wants to grant it
once, per repo, bounded.

What Changes

  1. publish-policy — a per-repo, schema-validated, fail-closed file that is the
    durable explicit instruction: which branches may be committed to and pushed, to which
    remotes, what may be merged into which branch, by whom, and under which evidence
    (gates green, recorded review verdict for the head SHA, CI green).
  2. A non-overridable never-list: force-push, history rewrite, visibility change,
    tags/releases, other remotes, credential/remote config changes, deploy.
  3. One source, three renderings: a prompt section that replaces "never commit unless
    asked" with the scoped grant (and removes kimi's ask-every-time clause when a policy is
    active); derived permission rules (allow the granted shapes, keep the never-list denied,
    keep credential stripping for sessions without a grant); and a Claude Code instruction
    fragment from skein policy instructions.
  4. Visibility assertion: the policy records the repo's visibility and is checked
    against the forge at load. Mismatch disables every publishing grant. A public repo also
    enforces the public-repo content scan (no private IPs, home paths, hostnames) before
    push.
  5. Integrator path: with a policy that allows it, a member (the integrator role in
    crew-loop Phase 4.4) merges an approved, green change into the allowed branch. Merge is
    a driver action like today's push, so the boundary stays in code.

Non-goals

  • No default-on. Without a policy file everything behaves as today.
  • No change to QueueDenyRules for sessions without a grant; the 35-shape adversarial
    suite must keep passing unchanged for them.
  • No auto-merge without a recorded review verdict (review-on-done) when the policy
    requires one.

Impact

loop/spec-queue/authority.ts (policy-derived ruleset), loop/spec-queue/brief.ts,
session/prompt/*.txt (policy section injection), permission/, new policy/publish.ts,
cli/cmd (policy subcommand), docs, tests including observed-red controls.

Design notes

Design: standing publish authority

D1. The file

<repo>/.skein/publish-policy.yaml (path configurable). Closed schema, version field.

version: 1
repo: androidand/opencode-skein
visibility: public          # asserted; checked against the forge at load
commit:
  branches: ["loop/*", "feat/*", "fix/*", "openspec/*"]
push:
  remotes: [origin]
  branches: ["loop/*", "feat/*", "fix/*", "openspec/*"]
merge:
  into: [dev]
  method: squash
  requires: [gates, review, ci]   # evidence kinds; `review` = recorded verdict for head SHA
  by: [integrator, lead]
scan: public-content              # required when visibility: public

Never-list (code constant, not in the file, not overridable): force-push or --force*;
reset --hard/rebase on a shared ref; deleting remote branches; any git remote
change; tags and releases; gh api mutations; visibility change; credential helpers and
git config; deploy and remote-exec surfaces (the existing QueueDenyRules set).

D2. Fail closed at load

The assertion runs when the file is parsed, with no agent present: unknown key, scalar
where a mapping belongs, branch pattern that matches the default branch for commit or
push, remotes naming a remote whose URL is not the asserted repo, or visibility
different from gh repo view --json visibility → all publishing grants off, one
logged reason, session continues under today's rules. A policy that still parses but has
quietly stopped meaning anything is the failure mode this exists to prevent, so each
rejection above has a test that was seen failing against a deliberately bad file.

Visibility is asked of the forge API, which carries no ambient credentials of the session,
not inferred from the remote URL (a private and a public repo look identical from the
client).

D3. Three renderings from one parse

  1. Prompt section injected by the session builder when a valid policy applies:
    "Standing authorization from your user for this repo: you may commit to ,
    push to /, and merge into when . This is the explicit
    instruction your guidance asks for. Never: ." It supersedes the generic "never
    commit unless asked"; for kimi, the ask-each-time clause is removed when a policy is
    active (otherwise no grant can ever hold).
  2. Permission rules: deriveRules(policy) returns allow rules for the exact granted
    shapes (anchored, branch-pattern-specific) layered before the never-list denies, so a
    deny always wins. Sessions without a valid policy get QueueDenyRules exactly as today.
  3. Claude Code fragment from skein policy instructions, referencing
    skein policy check <action> so the model runs a deterministic check, not a judgement:
    skein policy check push origin loop/foo → ALLOWED/DENIED + reason.

D4. Who runs the publishing commands

Commit: the member, on granted branches, staging explicit paths (matches the way-of-working
rule). Push: allowed to granted sessions by derived rules (D7). Merge: driver-executed (one command,
argv-built, no shell) because it needs evidence. Plain sessions use the derived
allow rules. The merge driver checks: head SHA == reviewed SHA, gates green, CI green,
target in merge.into, merge-base non-empty (empty merge-base → stop and ask a human, per
the way-of-working file).

D5. Public-content scan

When visibility: public, the pre-push step scans the outgoing diff for RFC1918 literals,
/Users/<name> paths, tokens and keys, and employer/home hostnames from a user-supplied
list; a hit blocks the push and is reported, never silently scrubbed. Scans only the diff,
because rewriting the whole repo is a different job.

D6. Way-of-working amendment (operator applies)

Add under the publishing paragraph:

A publish policy file for the repo, valid at load and verified against the forge, is the
explicit instruction for the actions it lists, durably. It never covers its never-list.
No policy file, an invalid one, or a visibility mismatch means no grant.

D7. Credentials (operator decision 2026-10-01, replaces the earlier default)

The operator's shells already carry an SSH key and possibly a token, and they want agents with a
valid grant to push without a detour. So:

  • A session WITH a valid grant keeps its normal shell and credentials. git push is allowed by
    the derived rules for the exact remote and the granted branch patterns, with no force flags;
    the never-list stays denied and wins on order (last match wins).
  • A session WITHOUT a grant keeps today's behaviour: QueueDenyRules and credential stripping.
  • Merge into the default branch stays executed by the merge driver, not by a shell rule. The
    reason is evidence, not credentials: a merge must check gates, a recorded review verdict for
    the head SHA and CI, and a shell allow rule cannot check any of that.
  • Residual to state plainly: a token exported in the environment is visible to any command that
    prints it and can end up in model context. Prefer injecting it per command over exporting it.

D8. Merge target (operator decision 2026-10-01)

The merge driver merges straight into the policy's merge.into branch (this repo: dev) once
review evidence exists. It cannot run before review-on-done (#102) records a verdict, because
that verdict is a required input; until then the driver is built and tested with fake evidence
and left disabled. Pushing the merged branch to origin still needs the remote's push
interlock lifted by the operator.

Open questions

  • Per-repo file vs. a user-level registry of repos with grants (operator works in ~30 repos).
    Start per repo; a user-level default of "no grant" is the safe baseline.
  • Whether merge should wait on a human for repos marked public even with a policy.

Tasks

Phase 0: Reproduce

  • 0.1 Against a scratch repo with a throwaway remote, record which layer refuses each of
    commit, push, merge in a plain session and in a queue run (prompt / gate / deny rule /
    way-of-working). Confirms F4.
  • 0.2 Pin the current deny behaviour: the 35-shape authority suite is the regression
    baseline for sessions without a policy.

Phase 1: Policy parse

  • 1.1 policy/publish.ts (branch publish-policy-validation, cfc7bc7, not pushed; 18 pass live / 16 + 2 skipped offline): closed schema, load, never-list constant.
  • 1.2 Observed-red tests for each rejection in D2 (unknown key, scalar-for-mapping,
    pattern matching the default branch, remote/repo mismatch, visibility mismatch), each
    run against a deliberately bad file and seen to fail first.
  • 1.3 Visibility check via the forge API, with an unreachable-forge case that fails closed.

Findings from Phase 1 (peer session):

  • QueueDenyRules contains *publish.ts*, which denies git add on ANY file named publish.ts
    (hit on this very change). Unattended queue runs are affected. Proposal: narrow it to
    *run*publish.ts* and *publish.ts*run* in both rule sets. DECISION FOR THE OPERATOR: it
    loosens a standing deny, so it is not applied until they say so.
  • git remote get-url returns the scp form git@host:owner/name by default; a parser that only
    handles URLs denies every valid policy. Both forms are tested.
  • Rejections are a closed set of named reason tokens (PublishPolicy.Reason) with per-repo specifics in a separate detail field. An upstream like origin/feature/x is refused, not reduced to feature.
  • The live forge test (PUBLISH_POLICY_LIVE=1) found a bug the injected tests could not: gh answers PUBLIC in uppercase, the policy says public. Fixed by lower-casing at the boundary. Rule for this epic: each security check gets one test against the real dependency, behind a flag.
  • Mutation checks must assert that the intended line changed, not only that the file differs.

Phase 2: Renderings

  • 2.1 Prompt section injection; remove the ask-each-time clause for kimi when active.
  • 2.2b Revise per D7: emit push allows for granted remote+branch (no force flags) for granted sessions; merge stays driver-only. The Phase 2 code emitted none, by the earlier default.
  • 2.2 deriveRules(policy); test that a deny from the never-list beats any allow for all
    35 existing bypass shapes plus force-push variants.
  • 2.3 skein policy check|show|instructions CLI; Claude Code fragment.

Phase 3: Drivers

  • 3.1 Commit gate: allow granted branches; stage explicit paths only.
  • 3.2 Push driver honours push.*; refuses on tracking mismatch (way-of-working: check
    remote and tracking before any push).
  • 3.3 (go-ahead 2026-10-01, merge into the policy's into branch; needs review evidence, so ship disabled and tested with fake evidence until Review on done: an independent agent checks the exact commits #102) Merge driver per D4, including empty-merge-base stop.
  • 3.4 Public-content scan on the outgoing diff (D5), with a seeded positive case.

Phase 4: Live

  • 4.1 Scratch fork: member commits, pushes, review passes, integrator merges, no human.
  • 4.2 Negative controls: invalid policy, visibility mismatch, force-push attempt, merge
    with a stale review SHA — each refused.
  • 4.3 Only then author a policy for this repo (private values excluded; this repo is public).

Plan changes

3 done

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions