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
20 changes: 16 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:
inputs:
tag:
description: release tag to rebuild, matching the crate version
required: true
type: string

permissions: {}

Expand Down Expand Up @@ -56,6 +61,12 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A dispatched run releases the tag it was asked for, not whatever the
# dispatching branch happened to point at -- `github.ref_name` there
# is the branch, and `gh release create` would mint a tag named after
# it. On a tag push the input is empty and `github.ref_name` is the
# tag, so the expression is the tag either way.
ref: ${{ inputs.tag || github.ref_name }}
persist-credentials: false

- name: Set up Rust toolchain
Expand All @@ -77,9 +88,9 @@ jobs:
# -- only `python` is -- so running it on all three would fail there for a
# reason that has nothing to do with the release.
- name: Refuse a tag that does not match the crate version
if: matrix.os == 'ubuntu-latest' && github.event_name == 'push'
if: matrix.os == 'ubuntu-latest'
env:
TAG: ${{ github.ref_name }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
version=$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "opencode-setup-system"))')
Expand Down Expand Up @@ -170,7 +181,7 @@ jobs:
- name: Publish one immutable release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
gh release create "$TAG" staging/* \
--repo "$GITHUB_REPOSITORY" \
Expand All @@ -196,6 +207,7 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref_name }}
persist-credentials: false

- name: Collect the built binaries
Expand Down Expand Up @@ -224,7 +236,7 @@ jobs:
- name: Build and push both architectures
id: push
env:
TAG: ${{ github.ref_name }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
image="ghcr.io/$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')"
docker buildx create --use --name release >/dev/null 2>&1 || docker buildx use release
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ before, and reports identity digests. It does not read credentials, does not
send anything over the network in its configuration lifecycle, and records no
secret values in its state.

A finding that breaks any of those four statements is in scope regardless of
A finding that breaks any of those statements is in scope regardless of
severity.
2 changes: 1 addition & 1 deletion SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ newer. `scoped_projection_profiles` (`ADR-0125`) is the field this applies to,
and it is omitted entirely when empty -- so a build that declares no scope
satisfies an older checker by accident, and a build that declares one does not.

Two versions, two different answers, both measured:
Three versions, three different answers, all measured:

| checker | result |
| --- | --- |
Expand Down
11 changes: 6 additions & 5 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ doc-valid-idents = ["NDDev", "OpenNetwork", "OpenCode", "GitHub", "macOS", "Setu
# `std::net` is refused outright: no command here opens a socket, and every v3
# phase declares `network_requirement: none`.
#
# `std::process::Command` is refused everywhere except two `#[allow]` sites that
# name their reason: `harness_runtime::software` starts the product for `launch`,
# which is `runtime_external` by declaration, and `harness_runtime::probe` drives
# this binary's own executable from a test. An allowlist of two, both named, is a
# boundary; an allowlist nobody counted is a habit.
# `std::process::Command` is refused everywhere except two spawn sites that
# name their reason: `harness_runtime::software` builds the product command for
# `launch` and hands it to `exec` -- one site in two `#[allow]` attributes --
# and `harness_runtime::probe` drives this binary's own executable from a test.
# An allowlist of two, both named, is a boundary; an allowlist nobody counted is
# a habit.
#
# Chosen over asserting a child count at run time: counting children portably
# means /proc on Linux and something else on each of the other two, so that
Expand Down
19 changes: 11 additions & 8 deletions crates/harness-runtime/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,12 +428,14 @@ pub fn undescribed(setups: &[Setup]) -> Examined {
/// Matched on the suffix so a fifth vendor works rather than being a silent
/// miss.
///
/// **Two of the six that declare `plugin` have no manifest at all, and saying
/// "the rest" was wrong.** An OpenCode plugin is a JavaScript or TypeScript
/// module — `plugins/<name>.js`, one file exporting functions — and a Pi
/// extension is a package. Neither carries `name` or `description` for a
/// product to read, so there is nothing here to check and no arm to add: a
/// module's identity is its filename and its behaviour is its exports.
/// **Two of the six plugin-kind projections have no manifest at all, and
/// saying "the rest" was wrong.** An OpenCode plugin is a JavaScript or
/// TypeScript module — `plugins/<name>.js`, one file exporting functions — and
/// a Pi extension is a package (its `ProjectionKind::Package`, counted here
/// because it answers the same question). Neither carries `name` or
/// `description` for a product to read, so there is nothing here to check and
/// no arm to add: a module's identity is its filename and its behaviour is its
/// exports.
///
/// That is a real difference rather than a gap, and it is written down because
/// the first version of this comment claimed the manifest shapes covered every
Expand Down Expand Up @@ -931,8 +933,9 @@ pub struct Setup {
/// -- and those three are exactly what a consumer renders on the surface
/// that precedes an install. `full-auto`'s description runs to 3312
/// characters on one harness and is safety context, not marketing copy;
/// `sources` is what `check_authored_keys.py` reads to source every key a
/// setup writes. The provenance chain ran through a file no digest held.
/// `sources` is what `check_authored_keys.py` -- not shipped in the
/// rendered trees -- reads to source every key a setup writes. The provenance
/// chain ran through a file no digest held.
///
/// Proved rather than reasoned: a description was rewritten and the whole
/// gate stayed clean.
Expand Down
4 changes: 3 additions & 1 deletion crates/harness-runtime/src/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ pub struct Harness {
///
/// Measured 2026-08-31 across the seven: twelve of them, in five harnesses.
/// A person's keybindings under one, a plugin directory under another, and
/// a `select minimal` took both.
/// a `select minimal` took both. Recounted 2026-09-30: seventeen in the
/// same five harnesses -- claude's four further namespaces and pi's three
/// were added as the audits reached them.
///
/// They stay **owned**, which is the point: a backup still captures them,
/// the identity still hashes them so drift is visible, and `remove` still
Expand Down
22 changes: 21 additions & 1 deletion crates/harness-runtime/src/surfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ fn never_touch_is_disclaimed(harness: &Harness, baseline: &Value, found: &mut Ve
if harness.native_namespaces.contains(name) {
found.push(format!("{name:?} is claimed and disclaimed"));
}
// When the block itself is absent, `disagreements` already names that
// one absence; repeating it once per disclaimed name adds noise, not
// information.
let recorded = baseline.get(BLOCK).is_none()
|| baseline
.get("never_touch")
.and_then(Value::as_array)
.is_some_and(|list| list.iter().any(|entry| entry.as_str() == Some(name)));
if !recorded {
found.push(format!(
"{name:?} is disclaimed by {} and the baseline's never_touch \
list does not record it -- a disclaimer without a measured \
record is one a reader cannot check",
harness.provider_id
));
}
}
}

Expand Down Expand Up @@ -1504,7 +1520,11 @@ mod tests {
"source": "this provider's own contract",
},
],
}
},
// A disclaimer without a measured record is one a reader cannot
// check: every real baseline records the names its declaration
// disclaims at the top level, so the fixture does too.
"never_touch": TEST.never_touch,
})
}

Expand Down
16 changes: 13 additions & 3 deletions crates/opencode-setup-system/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ pub const OPENCODE: Harness = Harness {
"commands",
"plugins",
],
// Five names the product reads and this provider does not own, each
// measured 2026-08-31 by running the 1.18.25 binary against a
// temporary home. Declared so `status` can say what it cannot decide.
// Six names the product reads and this provider does not own -- the first
// five measured 2026-08-31 by running the 1.18.25 binary against a
// temporary home, `tui.jsonc` added when the 1.18.33 binary's loader showed
// both spellings. Declared so `status` can say what it cannot decide.
shadowing_names: &[
Shadow {
name: "opencode.jsonc",
Expand All @@ -81,6 +82,15 @@ pub const OPENCODE: Harness = Harness {
and keeps the later, so this one wins: with both present \
`debug config` returned the JSONC file's value",
},
Shadow {
name: "tui.jsonc",
over: "tui.json",
effect: "the TUI loader iterates `fileInDirectory`'s \
[tui.json, tui.jsonc] candidates and merges each in turn, \
so the JSONC spelling applies last and wins -- measured in \
the pinned 1.18.33 binary, where the name is built by a \
`${o}.jsonc` template no fixed-string search can see",
},
Shadow {
name: "skill",
over: "skills",
Expand Down
12 changes: 8 additions & 4 deletions references/opencode-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
"shape": "file",
"source": "https://opencode.ai/docs/tui",
"evidence": "page",
"note": "keybinds, theme, attention and sounds, deliberately separate from opencode.json, which the same documentation describes as server and runtime behaviour; the setting route stays opencode.json\n\n**Searched in the product's own pinned bytes on 2026-08-29 and not found, which argues nothing either way.** Fixed-string, anchored to this product's configuration home -- the bare leaf name is in every one of these binaries and proves nothing, so only the anchored form counts. An invented path was searched in the same run and was also absent, so the search discriminates.\n\nThis row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep.\n\n**There is no `tui.jsonc`, and the symmetry was asserted before it was checked.** A review of this provider filed the JSONC alias for this file as a finding of equal standing with `opencode.json`'s. The 1.18.25 binary carries `tui.json` eight times and the JSONC spelling not once. The alias is real for the main configuration and absent for this one; a neighbour's property is not this file's.\n\n**Still `page` after a pass at it on 2026-08-31, and saying so rather than promoting it on a neighbour's evidence.** What was established: the product *writes* this file. A configuration carrying `theme`, `keybinds` or `tui` that has no `tui.json` beside it gets one written at `join(dirname(<config file>), \"tui.json\")`, and the config files include the ones in a resource directory -- so a consumer bundle setting a theme can make the product create a file this provider owns. What was **not** established is the read path from a target: it was looked for in the pinned bytes and not found, and a single `debug config` run with `theme` set did not produce the file either, so the migration needs something a read-only command does not reach. Two things absent is not one thing proven."
"note": "keybinds, theme, attention and sounds, deliberately separate from opencode.json, which the same documentation describes as server and runtime behaviour; the setting route stays opencode.json\n\n**Searched in the product's own pinned bytes on 2026-08-29 and not found, which argues nothing either way.** Fixed-string, anchored to this product's configuration home -- the bare leaf name is in every one of these binaries and proves nothing, so only the anchored form counts. An invented path was searched in the same run and was also absent, so the search discriminates.\n\nThis row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep.\n\n**`tui.jsonc` exists, and the earlier paragraph here that denied it measured wrong.** The 1.18.25 check counted fixed strings: `tui.json` appears, `tui.jsonc` does not -- because the binary never spells it. Re-measured 2026-09-28 against the pinned 1.18.33 artifact (`sha256:149a676b\u2026`, the digest this table pins): `ConfigPaths.projectFiles` walks `targets:[`${o}.jsonc`,`${o}.json`]` and `fileInDirectory` returns `[join(dir,`${t}.json`),join(dir,`${t}.jsonc`)]`, which is what the global `tui` load and each `.opencode`-directory load iterate. The JSONC spelling is built by the same template mechanism this note already describes for the directory half -- the search missed it for the reason given one paragraph up. The declined row carries the consequence: both spellings are real, this provider owns one.\n\n**Still `page` after a pass at it on 2026-08-31, and saying so rather than promoting it on a neighbour's evidence.** What was established: the product *writes* this file. A configuration carrying `theme`, `keybinds` or `tui` that has no `tui.json` beside it gets one written at `join(dirname(<config file>), \"tui.json\")`, and the config files include the ones in a resource directory -- so a consumer bundle setting a theme can make the product create a file this provider owns. What was **not** established is the read path from a target: it was looked for in the pinned bytes and not found, and a single `debug config` run with `theme` set did not produce the file either, so the migration needs something a read-only command does not reach. Two things absent is not one thing proven."
}
],
"declined": [
Expand Down Expand Up @@ -254,7 +254,7 @@
},
{
"path": "managed-config",
"reason": "Not a path in the target, and named without an extension for that reason: the managed configuration directory is a **system** path, one per operating system, and every recorded path here is relative to the target.\n\n * Linux — `/etc/opencode/`\n * macOS — `/Library/Application Support/opencode/`\n * Windows — `%ProgramData%\\\\opencode`\n\nAll three are in the 1.18.25 bundle's own emitted source, in one switch: `case\"darwin\": return \"/Library/Application Support/opencode\"; case\"win32\": return join(process.env.ProgramData||\"C:\\\\ProgramData\",\"opencode\"); default: return \"/etc/opencode\"`. macOS additionally reads a managed plist from the `ai.opencode.managed` preference domain, deployable by MDM, which the same bundle carries as `parseManagedPlist` and `managedPreferences`.\n\n**It bears on the `full-auto` posture**, the same way the managed policy does on the harness next door. An administrator's `opencode.json` there is loaded at the highest priority tier and overrides everything, so this provider can install, verify and restore a permissive posture cleanly on a managed machine and change nothing about what the product actually permits. The setup is not wrong and the target is not wrong; a higher layer wins.\n\nRecorded and never touched: it needs root or Administrator to write, it is not under the configuration home this provider is given, and a provider that edited an organisation's policy would be doing the one thing this estate refuses everywhere else.\n\nThe user configuration home is **not** per-OS, which is why only this row is. The same bundle resolves it as `XDG_CONFIG_HOME || ~/.config` joined with the application name, with no platform branch at all.",
"reason": "Not a path in the target, and named without an extension for that reason: the managed configuration directory is a **system** path, one per operating system, and every recorded path here is relative to the target.\n\n * Linux \u2014 `/etc/opencode/`\n * macOS \u2014 `/Library/Application Support/opencode/`\n * Windows \u2014 `%ProgramData%\\\\opencode`\n\nAll three are in the 1.18.25 bundle's own emitted source, in one switch: `case\"darwin\": return \"/Library/Application Support/opencode\"; case\"win32\": return join(process.env.ProgramData||\"C:\\\\ProgramData\",\"opencode\"); default: return \"/etc/opencode\"`. macOS additionally reads a managed plist from the `ai.opencode.managed` preference domain, deployable by MDM, which the same bundle carries as `parseManagedPlist` and `managedPreferences`.\n\n**It bears on the `full-auto` posture**, the same way the managed policy does on the harness next door. An administrator's `opencode.json` there is loaded at the highest priority tier and overrides everything, so this provider can install, verify and restore a permissive posture cleanly on a managed machine and change nothing about what the product actually permits. The setup is not wrong and the target is not wrong; a higher layer wins.\n\nRecorded and never touched: it needs root or Administrator to write, it is not under the configuration home this provider is given, and a provider that edited an organisation's policy would be doing the one thing this estate refuses everywhere else.\n\nThe user configuration home is **not** per-OS, which is why only this row is. The same bundle resolves it as `XDG_CONFIG_HOME || ~/.config` joined with the application name, with no platform branch at all.",
"source": "https://opencode.ai/docs/config; measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256"
},
{
Expand Down Expand Up @@ -356,7 +356,7 @@
"version": "1.18.33",
"verified_at": "2026-09-29T21:25:11+00:00"
},
"setup_catalogue_digest": "sha256:d0cf7ef9be83eba56d3d496069de6a5eba3e9f19cc7b27690c2c7860f59ef602",
"setup_catalogue_digest": "sha256:7af5d88d0f5d51510d0b496fa95e84cc27b308be0fdbd3e3a1efca30eaac7e2c",
"previous_software_artifacts": {
"command": "opencode",
"shape": "gzip-tar",
Expand Down Expand Up @@ -413,5 +413,9 @@
"control": "zzq-invented-surface-nobody-ships",
"control_hits": 0,
"method": "Every namespace this harness declares, global and scoped, searched as a fixed string in the artifact the current pin names, digest verified before reading; both the declared path and its last segment counted, because a product that composes a path by joining segments never spells the whole of it. A count here is a lower bound and not a routing proof -- what it answers is whether a surface vanished from the product under a version bump, which five of the seven took on 2026-09-02. The invented control was searched in the same run and found nowhere, so the search discriminates."
}
},
"never_touch": [
"auth.json",
"cache"
]
}
3 changes: 2 additions & 1 deletion scripts/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,8 @@ def owned_surfaces_named_by_the_product(binary: str, prefix: Path, info: dict) -

**It reports and never promotes.** Writing an `evidence` value still takes
somebody recording what they measured, and `tools/derive_evidence.py --check`
refuses a value stronger than a row's own prose supports. Two instruments
-- which is not shipped in this tree -- refuses a
value stronger than a row's own prose supports. Two instruments
with one opinion between them would be one instrument.

**Both inputs are asked of their owner rather than copied.** The namespaces
Expand Down
Loading
Loading