Skip to content
Merged
Show file tree
Hide file tree
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
- **The provider integrity guard no longer retains the bytes it only compares, and repeated package-repository builds in one process are memoized** ([#227](https://github.com/L3DigitalNet/project-standards/issues/227)). Every provider invocation captures a whole-repository snapshot before and after the call to prove the provider changed no declared live path; those two captures read each declared target in full, held every byte in memory, and hashed each one twice. They now stream: the precondition hash is seeded from the pre-read mode and fed chunk by chunk, no bytes are retained, and the content digest is computed only when a caller asked for content. `RepositorySnapshot.capture` gained `retain_content` for that, and `assert_current` uses it too. Combined with the snapshot chain below, `project-standards reconcile --check` falls from 17.5 s to 9.5 s on this repository (median of three) and peak RSS from 185 MB to 117 MB. `build_package_repository` additionally serves a repeated build of an unchanged tree from an in-process memo keyed on the size and mtime of every file under `standards/` and `catalogs/` — a cache over parse results, not an integrity guard, which is why `stat` metadata suffices there while the control plane's snapshot still reads and hashes bytes. A second build of this repository costs 0.05 s instead of 2.75 s; the fingerprint itself costs 0.05 s, which a single-build command now pays once.
- **`plan_reconciliation` now shares one integrity snapshot between consecutive providers.** Inside the new `provider_snapshot_chain()` window the AFTER snapshot of one provider becomes the BEFORE snapshot of the next provider declaring the identical target set, so N invocations cost N+1 captures instead of 2N — 201 captures to 102 on this repository. Every AFTER capture is still a fresh full read, so a provider that changes a declared live path is still refused with `CP-PROVIDER-INTEGRITY`. The window is opt-in and planning is the only pass that enters it: it invokes every provider and writes nothing itself, so between two invocations only a provider could have touched a declared path. Publication and the executor's post-publication verification providers stay outside, because a window spanning a write would report the control plane's own bytes as the next provider's violation.

- **The committed `gh-workflow` binary is built with `-s -w` from 1.10 forward**, dropping the symbol table and DWARF debug information: 10,432,350 bytes at 1.9 against 7,307,390 at 1.10, a 30% reduction in bytes every consumer stores and reconcile installs twice. Go's panic traces keep their function names and line numbers, which come from the runtime's pclntab rather than the symbol table. Published payload bytes are immutable, so 1.9 and earlier stay unstripped and are never rebuilt; `make go-check` verifies the new bytes against a reproducible rebuild.

- **`gh-workflow land --pr N [--method M]` runs the whole admission of one pull request as a single transaction** ([#236](https://github.com/L3DigitalNet/project-standards/issues/236), C13). It advances a governing issue that is still `Ready` to `In progress`, then performs exactly the `ready` and `merge` operations — the same code paths, the same fresh gates, the same ordered step record — and ends by naming the merge commit GitHub created together with the `git diff` that proves the admitted head's changed paths now read the same on the integration branch. The proof is printed rather than executed, because the tool has never required Git to be installed. Fail-closed throughout: a domain finding from either gate, a merge method the repository forbids, or a failed write stops the sequence where it stands and emits the receipt of which boundaries completed; nothing is rolled back and no gate is overridden. `--auto` is deliberately not offered, since a merge GitHub has not performed yet cannot be proved. The four-call hand-driven sequence it replaces is where the ordering incidents came from — a pull request reaped as landed while it was still blocked — and one receipt per transaction is what removes that class.

- **`gh-workflow` issues markedly fewer GitHub requests per command** ([#227](https://github.com/L3DigitalNet/project-standards/issues/227), E4 items 1, 2, 4 and 5). Reads shared within one command are now memoized in one place instead of being re-issued per pull request: the repository's permitted merge methods and each base branch's live enforcement are read once per command rather than once per pull request reaching the Merge gate, and the open-issue list a `summary` already holds serves each governed pull request's governing issue. That memo is a positive cache only — an issue that is closed or absent from the open list still costs its own read, so a Final governed by a completed issue never reads as unresolved. `receipt --pr N` projects the pull request and its CI state from the topology it just loaded instead of fetching both a second time, and `check --issue N` projects the issue it already read for the pull-request shape check. Measured on the package's own fixture repository: `summary` 22 requests → 16, `receipt --pr` 10 → 8. Every rendered byte is unchanged; the concurrency item from the same survey was declined, so the surfaces remain fully sequential.

- **The committed `gh-workflow` binary is built with `-s -w` from 1.10 forward**, dropping the symbol table and DWARF debug information: 10,432,350 bytes at 1.9 against 7,307,390 at 1.10, a 30% reduction in bytes every consumer stores and reconcile installs twice. Go's panic traces keep their function names and line numbers, which come from the runtime's pclntab rather than the symbol table. Published payload bytes are immutable, so 1.9 and earlier stay unstripped and are never rebuilt; `make go-check` verifies the new bytes against a reproducible rebuild.

### Fixed

- **`Agent Handoff 1.17` stops an untrusted checkout from executing a command during session start.** The `session-start` launcher ran its Git reads with the full inherited process environment and no configuration isolation, so a repository-local or ancestor `core.fsmonitor` setting named a hook that Git ran — unconditionally, before the operator had seen anything — as soon as that checkout was opened as a session-start target ([#235](https://github.com/L3DigitalNet/project-standards/issues/235)). Every read now runs with an explicit minimal environment (`PATH` and `HOME` only, so no `GIT_DIR`, `GIT_WORK_TREE`, or `GIT_CONFIG_*` value from the harness can redirect it) and passes `-c core.fsmonitor=`, which outranks every configuration file, plus `--no-optional-locks` so the read cannot race a concurrent write. The injected session context is byte-identical to 1.16; reconcile replaces the installed hook because its digest moved. Catalog 5 promotes `agent-handoff@1.17` and retains 1.16.
Expand All @@ -70,6 +78,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
- **A Python payload provider no longer inherits the caller's whole environment.** Command-kind providers already ran with an empty environment, but Python-kind children received a copy of `os.environ`, so provider bytes that got past payload integrity verification could read `GITHUB_TOKEN`, `BAO_*`, or any other secret the parent happened to hold ([#230](https://github.com/L3DigitalNet/project-standards/issues/230)). A child now receives an allowlist and nothing else: `PATH`, `PYTHONPATH` (recomputed from the parent's active `sys.path`, as before), `HOME`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TMPDIR`, `PYTHONDONTWRITEBYTECODE`, and every `COVERAGE_*` variable, the last so the coverage lane keeps measuring provider children. This tightens the ADR 0025 execution boundary rather than reinterpreting it, and the ADR now records the allowlist as part of that boundary's contract. A provider that depended on an inherited variable outside the allowlist must have it passed as typed provider input instead.
- **A payload can no longer declare a group- or other-writable artifact mode.** `PosixMode` accepted any four-digit octal mode, and the executor applies a declared mode verbatim through `fchmod`, so `0777` would have shipped a managed file every local account can rewrite ([#230](https://github.com/L3DigitalNet/project-standards/issues/230)). The pattern is now `^0[0-7][0145][0145]$`: `0644`, `0700`, and `0755` remain valid and `0666`, `0775`, and `0777` are refused where the payload is authored. This is a producer-side validation tightening only — every declared artifact mode in the published catalog is `0755`, so no payload, catalog, or consumer byte changes.

- **`GitHub Workflow 1.10` hardens `gh-workflow` against content it did not author and state it had already read** ([#234](https://github.com/L3DigitalNet/project-standards/issues/234), from security read H13). Eight findings are fixed in one cut, because payload bytes are immutable and every one of them changes the shipped binary. **Disposition evidence is attributed:** the `Final-Disposition:` record is an ordinary pull-request comment, so through 1.9 any account that could comment could pin a permanent disposition conflict on a pull request or supply an outcome in place of the operator's `--reason`; only the authenticated actor's record is evidence now, on the `close --pr` path and the read-only Post-merge gate alike. **Untrusted text is encoded where the envelope is written:** the sanitizer moved from `render` into a leaf package and is applied in `cli.WriteEnvelope` and to raw API error bodies, so an issue body, a comment, or a hostile error page can no longer repaint a terminal through a finding, a step message, or the JSON an agent pipes into a report; live organization schema text printed by `audit` is encoded too. **Two mutation windows narrow:** `merge --auto` arms GitHub's auto-merge against the head SHA the gate validated, and `ready` re-observes the head immediately before marking a draft ready and refuses one that moved. **Two boundaries close:** `policy.toml` and `org-schema.yaml` are resolved only up to the enclosing checkout root rather than to the filesystem root, and a repository derived from `origin` is refused before any write when that remote's host is not the host the tool addresses. **A rate limit reads as a rate limit:** 403 and 429 responses carrying rate-limit headers are waited out with `Retry-After` and retried within a bounded budget, and one that outlasts the retries is reported as `ErrRateLimited` instead of as a credential rejection. No option, subcommand, or gate outcome changes and the rendered `policy.toml` moves only its `package_version` stamp, so the upgrade is a version bump; Catalog 5 promotes `github-workflow@1.10` and retains 1.9.
- The residual `ready` race is documented rather than claimed closed: GraphQL's `markPullRequestReadyForReview` takes no `expectedHeadOid`, so the guard is a compare-then-act and a push landing inside that one round trip is still admitted. `merge`, which does take a head SHA, is the gate that admits content.
- **`GitHub Workflow 1.10` leaves the `release` admission class declared but unenforced**, as 1.9 did (ADR 0031): a `release`-classified commit is still admitted on the author's word, and the release route's real guard remains the repository's own release tooling. Enforcing it stays a candidate for a later cut.

## [5.28.0] — 2026-09-01

### Added
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ Repository-local project knowledge and bounded session continuity for coding age

GitHub work discipline for organization-owned repositories: typed issue contracts, field vocabulary, pull-request evidence, review expectations, and an attention-first operator summary. The package installs a mandatory repo-local `github-workflow` skill that keeps judgment with the agent, plus `gh-workflow` — a committed, reproducibly built static `linux/amd64` Go binary whose ten subcommands audit the organization schema, render operator summaries and creation receipts, apply validated issue mutations, and admit pull requests. Every subcommand reads the consumer's repository and writes none of it: 1.5 removed the `ledger` subcommand that generated `docs/GH-WORKFLOWS.md`, and a repository upgrading from 1.4 or earlier deletes that now-unowned file itself. The organization schema itself is skill-audited and human-applied; the tool never creates or retires an issue type, field, or value.

- **Standard:** [`standards/github-workflow/versions/1.9/README.md`](standards/github-workflow/versions/1.9/README.md)
- **Skill:** [`skills/github-workflow/`](standards/github-workflow/versions/1.9/skills/github-workflow/) — installed repo-local at `.agents/skills/github-workflow/` and `.claude/skills/github-workflow/`, with the tool at `bin/gh-workflow`.
- **Adopt:** [`adopt.md`](standards/github-workflow/versions/1.9/adopt.md)
- **Standard:** [`standards/github-workflow/versions/1.10/README.md`](standards/github-workflow/versions/1.10/README.md)
- **Skill:** [`skills/github-workflow/`](standards/github-workflow/versions/1.10/skills/github-workflow/) — installed repo-local at `.agents/skills/github-workflow/` and `.claude/skills/github-workflow/`, with the tool at `bin/gh-workflow`.
- **Adopt:** [`adopt.md`](standards/github-workflow/versions/1.10/adopt.md)

### Project Toolbox Standard

Expand Down Expand Up @@ -197,7 +197,7 @@ The path must be one exact repo-relative, non-glob path with exclusive whole-fil
| Project Specification | `1.11` | [`standards/project-spec/versions/1.11/adopt.md`](standards/project-spec/versions/1.11/adopt.md) |
| CLI Documentation | `1.6` | [`standards/cli-documentation/versions/1.6/adopt.md`](standards/cli-documentation/versions/1.6/adopt.md) |
| Agent Handoff | `1.17` | [`standards/agent-handoff/versions/1.17/adopt.md`](standards/agent-handoff/versions/1.17/adopt.md) |
| GitHub Workflow | `1.9` | [`standards/github-workflow/versions/1.9/adopt.md`](standards/github-workflow/versions/1.9/adopt.md) |
| GitHub Workflow | `1.10` | [`standards/github-workflow/versions/1.10/adopt.md`](standards/github-workflow/versions/1.10/adopt.md) |
| Project Toolbox | `1.1` | [`standards/project-toolbox/versions/1.1/adopt.md`](standards/project-toolbox/versions/1.1/adopt.md) |

For a V4 repository, do not create `.standards/` separately. Preview the complete migration, resolve every ambiguity, then apply the same command explicitly:
Expand Down
6 changes: 6 additions & 0 deletions catalogs/5.toml
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ role = "retained"
id = "github-workflow"
version = "1.9"
digest = "sha256:2c9de8845e32bf93804b40867dc7f2bdb92ab17f596750e468befe663b40e5e3"
role = "retained"

[[packages]]
id = "github-workflow"
version = "1.10"
digest = "sha256:93c2d40b83875ea82f98cec17567c667eca5327e68b81006a661e3cfa4ad2b99"
role = "default"

[[packages]]
Expand Down
12 changes: 10 additions & 2 deletions internal/ghworkflow/admission/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/L3DigitalNet/project-standards/internal/ghworkflow/cli"
"github.com/L3DigitalNet/project-standards/internal/ghworkflow/policy"
"github.com/L3DigitalNet/project-standards/internal/ghworkflow/render"
"github.com/L3DigitalNet/project-standards/internal/ghworkflow/safetext"
)

func init() {
Expand Down Expand Up @@ -344,11 +345,18 @@ func renderHuman(report *Report) string {
if finding.SHA == "" {
// A range-level finding (CodeEmptyRange) has no commit to identify, so the
// SHA/subject line would render as leading whitespace.
fmt.Fprintf(&b, " %s — %s\n %s\n", finding.Code, finding.Message, finding.Remediation)
fmt.Fprintf(&b, " %s — %s\n %s\n", finding.Code,
safetext.SanitizeText(finding.Message), finding.Remediation)
continue
}
// The subject and the message carry commit text this tool did not author: any
// author who can land a commit in the classified range controls them, and a
// subject carrying ESC or a bidi override would repaint the operator's terminal
// from inside the report that is supposed to expose it. Encoded at the point of
// printing, which is where the untrusted bytes leave the tool.
fmt.Fprintf(&b, " %s %s\n %s — %s\n %s\n",
shortSHA(finding.SHA), finding.Subject, finding.Code, finding.Message, finding.Remediation)
shortSHA(finding.SHA), safetext.SanitizeText(finding.Subject), finding.Code,
safetext.SanitizeText(finding.Message), finding.Remediation)
}

fmt.Fprintf(&b, "\nSummary: %d T0, %d pull request, %d handoff, %d release, %d unadmitted\n",
Expand Down
13 changes: 12 additions & 1 deletion internal/ghworkflow/audit/findings.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/L3DigitalNet/project-standards/internal/ghworkflow/ghapi"
"github.com/L3DigitalNet/project-standards/internal/ghworkflow/orgschema"
"github.com/L3DigitalNet/project-standards/internal/ghworkflow/safetext"
)

// Status is a finding class. These four are the vocabulary FR-016 requires the report to
Expand Down Expand Up @@ -102,7 +103,17 @@ func (r *Report) HasDrift() bool {
// runs, and Go map iteration order would make that useless.
func Compare(schema *orgschema.Schema, liveTypes []ghapi.IssueType, liveFields []ghapi.IssueField) []Finding {
findings := compareIssueTypes(schema.IssueTypes, liveTypes)
return append(findings, compareIssueFields(schema.IssueFields, liveFields)...)
findings = append(findings, compareIssueFields(schema.IssueFields, liveFields)...)
// Every name and detail below carries live organization text — an Issue Type name, a
// field name, a single_select option — that an organization owner authored and this
// tool prints straight to a terminal. Sanitizing once here rather than per renderer
// covers the human report and the JSON alike; the encoder is idempotent, so baseline
// names passing through a second time are unchanged.
for i := range findings {
findings[i].Name = safetext.SanitizeText(findings[i].Name)
findings[i].Detail = safetext.SanitizeText(findings[i].Detail)
}
return findings
}

func compareIssueTypes(baseline []string, live []ghapi.IssueType) []Finding {
Expand Down
Loading
Loading