|
| 1 | +# 0.7.x Verifier Enforcement Implementation Plan (bounded execution) |
| 2 | + |
| 3 | +Status: committed implementation plan. The bounded-execution rule is normative in |
| 4 | +`spec.md` section 12 as of 0.7.0; the reference and hosted verifiers do not yet |
| 5 | +emit its findings. This plan sequences that enforcement. Until it lands, the |
| 6 | +`action.exec-unbounded`, `action.exec-opaque`, `exec-sha256.mismatch`, |
| 7 | +`exec-sha256.transitive-unpinned`, and `exec-sha256.unverified` ids are defined |
| 8 | +but not emitted, and bounded-execution compliance is self-asserted. |
| 9 | + |
| 10 | +This plan is non-normative. It does not change the conformance contract; it |
| 11 | +describes how to make the reference verifier check what the 0.7.0 spec already |
| 12 | +requires. |
| 13 | + |
| 14 | +## Goal |
| 15 | + |
| 16 | +Emit the five bounded-execution findings from the reference verifier |
| 17 | +(`scripts/guidecheck_verify.py`) and the hosted verifier (`api/verify.py`, |
| 18 | +`scripts/guidecheck_hosted_anchors.py`), scoped by the decidability limits the |
| 19 | +adversarial pass established, so that a guide invoking an unbounded in-repo script |
| 20 | +fails Level 3 while honest guides and exempt dependency installers stay |
| 21 | +conformant. |
| 22 | + |
| 23 | +## Design constraints carried from the adversarial pass |
| 24 | + |
| 25 | +These are the corrections that shaped the 0.7.0 spec text and must shape the |
| 26 | +implementation. They are recorded in `threat-register.md` under |
| 27 | +"Runtime-indirection and transitive execution". |
| 28 | + |
| 29 | +1. Classify by invocation shape, not program head. `make`, `npm`, `pip`, `cargo`, |
| 30 | + and `go` stay in `_PACKAGE_TOOLS` for class detection (they are correctly |
| 31 | + `code-executing`), but the exempt-versus-bound decision is separate and keys on |
| 32 | + the subcommand and argv shape. `npm install` is exempt; `npm run <script>` is |
| 33 | + bound. |
| 34 | +2. Block only the decidable case. Reserve blocking `action.exec-unbounded` for a |
| 35 | + named local script file invoked without a pin: a path-qualified executable |
| 36 | + (`./x`, `../x`, `/abs/x`) or an interpreter with a local script-file argument |
| 37 | + (`bash scripts/setup.sh`, `sh ./install`). These are already detected as |
| 38 | + `code-executing` by `_segment_runs_code` as of the 0.6.x detection work. |
| 39 | +3. Do not block the undecidable middle. `make <target>`, `npm run <script>`, |
| 40 | + `pip install .`, `python -m <module>`, and `cargo`/`go build` with an in-repo |
| 41 | + build hook are bound per the spec, but the verifier cannot statically tell a |
| 42 | + first-party module or a malicious recipe from a benign one. Per spec section 19 |
| 43 | + (best-effort, allowlist-bounded), the verifier emits an informational finding |
| 44 | + for these rather than a false-positive block. This is a deliberate, documented |
| 45 | + under-enforcement: the verifier is weaker than the spec on the ambiguous middle, |
| 46 | + and human review remains required. |
| 47 | +4. The inline carve-out is absolute. An action whose executed instructions are in |
| 48 | + the `command` field (an inline `-c`/`-e`/`-E`/`--eval`/`--exec` program, or a |
| 49 | + bare-program interpreter such as `awk`/`perl`) is bounded by definition and is |
| 50 | + never flagged. The tokenizer is quote-naive, so the trigger is the presence of |
| 51 | + an external artifact reference, never content inspection of an inline program. |
| 52 | +5. `exec-opaque` is a control only for the exempt class. On an exempt installer it |
| 53 | + is `action.exec-opaque` (warning) and fully conformant. On a bound artifact it |
| 54 | + is `action.exec-unbounded` (blocking): a bound artifact is always inlinable or |
| 55 | + pinnable, so opaque there is a bypass. |
| 56 | +6. A pin is worth nothing unverified. `exec-sha256` can only be verified where the |
| 57 | + verifier can read the artifact. Local-file mode cannot fetch, so it always |
| 58 | + reports `exec-sha256.unverified` (info) and renders the pin as declared, not |
| 59 | + satisfied. Hosted mode reads the artifact through the github.com repository-file |
| 60 | + channel and recomputes. |
| 61 | + |
| 62 | +## Building blocks already in place |
| 63 | + |
| 64 | +Landed under the 0.6.x line (commits on `main`): |
| 65 | + |
| 66 | +- `_segment_runs_code` recognizes path-qualified executables, script-extension |
| 67 | + program names, interpreters with a path-qualified positional, and container |
| 68 | + `build`/`run`/`exec`/`start` (and `compose up|run|build`). |
| 69 | +- `_NET_TOOLS` includes DNS clients and `/dev/tcp`; `command.fetch-execute` fires |
| 70 | + on `dig TXT | bash` as on `curl | sh`. |
| 71 | +- `_command_head`, `_invoked_path_token`, `_is_path_arg`, `_container_runs_code`, |
| 72 | + `_command_segments`, `_PACKAGE_TOOLS`, `_INTERPRETERS`, `_CODE_FLAGS`, |
| 73 | + `_SCRIPT_EXTENSION`. |
| 74 | +- False-positive guards proven: `docker ps`, `docker version`, `sed`, `jq`, |
| 75 | + `hostname`, `.venv/bin/python`, `python3 --version`, `git clone`. |
| 76 | + |
| 77 | +## Work items |
| 78 | + |
| 79 | +### Phase 1: bound-versus-exempt classifier |
| 80 | + |
| 81 | +Add `classify_exec_target(command) -> str` returning one of `inline`, |
| 82 | +`bound-script`, `exempt-installer`, `ambiguous`, `none`: |
| 83 | + |
| 84 | +- `inline`: any segment head is an interpreter with an inline-code flag |
| 85 | + (`_CODE_FLAGS`) or a bare-program interpreter. Bounded; no finding. |
| 86 | +- `bound-script`: a path-qualified executable, or an interpreter whose positional |
| 87 | + argument is a local script file (matches `_SCRIPT_EXTENSION` or `_is_path_arg`). |
| 88 | + Decidable; subject to the pin-or-inline requirement. |
| 89 | +- `exempt-installer`: head plus subcommand in a new `_DEPENDENCY_INSTALLERS` |
| 90 | + set (`npm ci`, `npm install` with no `run`, `pnpm install`, `yarn install`, |
| 91 | + `pip install -r`, `pip install <named-non-local>`, `bundle install`, |
| 92 | + `cargo build` with no `build.rs`, `go build` with no invoked `go generate`), plus |
| 93 | + committed bootstrap wrappers whose basename is `gradlew`, `mvnw`, `configure`, or |
| 94 | + `autogen.sh`. |
| 95 | +- `ambiguous`: `make <target>`, `npm run <script>`, `just <recipe>`, |
| 96 | + `pip install .`/`-e .`, `python -m <module>`, `go generate`, `docker build`, |
| 97 | + and `cargo`/`go build` when an in-repo build hook is present. Not blocked. |
| 98 | +- `none`: not code-executing. |
| 99 | + |
| 100 | +Publish `_DEPENDENCY_INSTALLERS` as the spec-level exempt table in |
| 101 | +`verifier-conformance.md` so independent verifiers agree, rather than leaving it a |
| 102 | +private constant. |
| 103 | + |
| 104 | +### Phase 2: field parsing |
| 105 | + |
| 106 | +Extend the action-block parser to read `exec-sha256` and `exec-opaque`: |
| 107 | + |
| 108 | +- `exec-sha256`: must be 64 lowercase hex characters, else a malformed-field |
| 109 | + finding (reuse `action-block.malformed`). |
| 110 | +- `exec-opaque`: value must be `acknowledged`; require a `notes` rationale on the |
| 111 | + same action; else a malformed-field finding. |
| 112 | + |
| 113 | +### Phase 3: local emission |
| 114 | + |
| 115 | +In `check_actions`/`check_command`, for each code-executing action: |
| 116 | + |
| 117 | +- `bound-script` with no `exec-sha256` and no `exec-opaque` -> `action.exec-unbounded` |
| 118 | + (error). |
| 119 | +- `exec-opaque` on a `bound-script` -> `action.exec-unbounded` (error). |
| 120 | +- `exec-opaque` on an `exempt-installer` -> `action.exec-opaque` (warning). |
| 121 | +- `exec-sha256` present -> `exec-sha256.unverified` (info) in local-file mode, and |
| 122 | + suppress `action.exec-unbounded` (the pin is declared). |
| 123 | +- `ambiguous` -> at most an info finding; never blocking. |
| 124 | +- `inline`, `exempt-installer` with no opaque, `none` -> no bounded-execution |
| 125 | + finding. |
| 126 | + |
| 127 | +Inlining (spec option a) needs no detection: a guide satisfies it by replacing the |
| 128 | +artifact invocation with inline action blocks, so there is simply no bound-script |
| 129 | +action to flag. An action that both inlines a copy and re-invokes the artifact |
| 130 | +still has the bound-script invocation and is flagged, which is the intended guard. |
| 131 | + |
| 132 | +Remove the "not yet emitted" note from the `finding-ids.md` "Bounded execution" |
| 133 | +section and the `verifier-conformance.md` section 19 addition for the ids the |
| 134 | +local verifier now emits (`action.exec-unbounded`, `action.exec-opaque`, |
| 135 | +`exec-sha256.unverified`). |
| 136 | + |
| 137 | +### Phase 4: hosted exec-sha256 verification |
| 138 | + |
| 139 | +In the hosted path, when an action declares `exec-sha256` and the artifact is |
| 140 | +reachable through the github.com repository-file channel |
| 141 | +(`scripts/guidecheck_hosted_anchors.py`): |
| 142 | + |
| 143 | +- recompute the artifact SHA-256; on divergence emit `exec-sha256.mismatch` |
| 144 | + (error). |
| 145 | +- byte-scan the fetched artifact for further in-repo invocations (`source`/`.`, |
| 146 | + `bash x.sh`, an interpreter with a script argument, `make`, `npm run`); require |
| 147 | + each to be inlined or pinned, else `exec-sha256.transitive-unpinned` (error). |
| 148 | +- account for the added fetches against the hosted per-request fetch budget |
| 149 | + (currently seven in `api/verify.py`); cap artifact reads per request and prefer |
| 150 | + deduplication. Decide whether to raise the budget or bound the number of pinned |
| 151 | + artifacts verified per request. |
| 152 | + |
| 153 | +Local-file mode never reaches this phase; it always reports |
| 154 | +`exec-sha256.unverified`. |
| 155 | + |
| 156 | +### Phase 5: corpus and example ripple |
| 157 | + |
| 158 | +- Example guides that invoke a named local script become `action.exec-unbounded`. |
| 159 | + `examples/` currently has `.venv/bin/python prompter_kit.py ...` in the |
| 160 | + PrompterKit example; add `exec-sha256` pins (or restructure to inline) and |
| 161 | + regenerate the affected fixtures. Confirm no other tracked guide invokes a named |
| 162 | + local script (the self-guide uses `python -m venv` and `sed`/`jq`, which are not |
| 163 | + bound-script shapes). |
| 164 | +- Add fixtures: valid (a pinned leaf artifact, `exec-opaque` on an exempt |
| 165 | + installer), invalid (`action.exec-unbounded` for `bash scripts/setup.sh` and |
| 166 | + `./scripts/setup.sh`, `exec-opaque` on a bound artifact, and in hosted-replay |
| 167 | + form `exec-sha256.mismatch` and `exec-sha256.transitive-unpinned`). |
| 168 | +- Generate each `expected.json` from real verifier output, as the DNS and |
| 169 | + local-script fixtures were built. |
| 170 | + |
| 171 | +### Phase 6: Change 3 (separate decision) |
| 172 | + |
| 173 | +Promoting the unambiguous class-understatement warnings |
| 174 | +(`action-block.class.code-executing-missing`, `network.command-implies-networked`) |
| 175 | +from warning to blocking at Level 3 and above is a separable conformance change |
| 176 | +with a large corpus ripple (every under-declaration fixture and any adopter guide |
| 177 | +that understates a class). Decide it on its own, with its own fixture pass; it is |
| 178 | +not a prerequisite for Phases 1 through 5, because `action.exec-unbounded` keys off |
| 179 | +detected code execution, not the declared class. |
| 180 | + |
| 181 | +## Decidability policy, stated plainly |
| 182 | + |
| 183 | +- Block: a named local script file invoked without a pin; `exec-opaque` on a bound |
| 184 | + artifact. |
| 185 | +- Warn: `exec-opaque` on an exempt installer. |
| 186 | +- Info: `exec-sha256.unverified`; the ambiguous middle. |
| 187 | +- Never flag: inline programs; exempt installers. |
| 188 | + |
| 189 | +The verifier under-enforces the spec on the ambiguous middle by design. A guide |
| 190 | +that hides a payload in a `make` recipe or a first-party `-m` module is |
| 191 | +non-conformant under section 12 but is not mechanically blocked, because the |
| 192 | +verifier cannot decide that shape without executing or fully reading the project. |
| 193 | +This is the section 19 best-effort posture, and it is why conformance is not |
| 194 | +safety and human review remains required. |
| 195 | + |
| 196 | +## Risks |
| 197 | + |
| 198 | +- Ambiguous-middle under-enforcement (verifier weaker than the spec). Documented |
| 199 | + above and in `threat-register.md`; not a silent gap. |
| 200 | +- Example ripple and notional pins in fixtures (pins the local verifier reports as |
| 201 | + unverified anyway). |
| 202 | +- Hosted fetch budget pressure from artifact reads. |
| 203 | +- Quote-naive tokenizer: never content-inspect an inline program to decide the |
| 204 | + carve-out. |
| 205 | + |
| 206 | +## Test strategy |
| 207 | + |
| 208 | +- Unit tests in `scripts/test_parser_edge_cases.py` for `classify_exec_target` |
| 209 | + across every shape in Phase 1 plus the false-positive guards. |
| 210 | +- One fixture per emitted finding, `expected.json` generated from real output. |
| 211 | +- `make test` green: `eval`, `verify-fixtures`, `validate-contracts`, |
| 212 | + `check-guide-artifacts`, and the finding-id contract check |
| 213 | + (`check_reference_verifier.py`) which requires every emitted id to be documented |
| 214 | + in `finding-ids.md`. |
| 215 | + |
| 216 | +## Suggested release sequencing |
| 217 | + |
| 218 | +- 0.7.1: Phases 1 through 3 and 5 (local enforcement: blocking |
| 219 | + `action.exec-unbounded` for named scripts, `exec-opaque` handling, |
| 220 | + `exec-sha256.unverified`, fixtures, example pins). Drop the "not yet emitted" |
| 221 | + caveat for the locally emitted ids. |
| 222 | +- 0.7.2: Phase 4 (hosted `exec-sha256` verification and transitive-closure |
| 223 | + scanning). |
| 224 | +- Change 3: separate decision, separate release. |
0 commit comments