Skip to content

fix(launch): a marker in prose is not a block, and the dev container comes up - #49

Merged
protonspy merged 29 commits into
mainfrom
fix/launch-defects
Sep 16, 2026
Merged

protonspy merged 29 commits into
mainfrom
fix/launch-defects

Conversation

@protonspy

Copy link
Copy Markdown
Owner

A run of scc launch claude on Windows fell back to the host unsandboxed and warned about a malformed block in this repository's own entry file. Both causes ship to every scaffolded workspace, and both fail in the direction that looks like success.

Plan: plans/launch-defects.md.

What was wrong

A marker written in prose was read as a block. internal/mdblock located its marker-delimited region with strings.Index over the raw document, so a file that documents the markers was read as a file carrying the block. This repository's own CLAUDE.md names RTK's opening and closing markers in one sentence, which made rtkBlockPresent true — scc launch skipped the RTK wiring it believed was already done — and left scc rtk standing ready to splice over that sentence. It also names scc's CodeGraph opener alone, which was reported malformed, so that block was never written at all. rtk.ForeignBlock had the same defect for Headroom's marker, sending the reader to headroom unwrap for a block nobody wrote.

The dev container could not come up. postCreateCommand ran git config --global --add safe.directory, and ~/.gitconfig is the read-only bind that carries your identity in, so git failed with Device or resource busy. That failed postCreate, which failed devcontainer up, which dropped the launch back onto the host — a run that reported a sandbox and started the agent outside one.

What changed

mdscan.Mask blanks fenced blocks and inline code spans to spaces, preserving every byte offset and leaving HTML comments intact, because the markers a caller looks for here are HTML comments. One span helper in mdblock measures over it and slices out of the original, so four methods cannot disagree about where the block is.

The dev container's safe.directory moves into the image, where nothing is read-only and the build runs as root — postCreate's non-root user cannot write /etc/gitconfig either, so neither scope works from there. It is scoped to /workspaces/* rather than *.

Coverage

scc check's test gate was failing on main at 73.6% against an 86% floor, which blocked every push. It now passes: 73.6% → 86.0%, entirely by adding tests. Three packages went from 0%; internal/git from 8.3% to 91%; internal/hooks's settings splice — the half that writes into a file scc does not own — from 0% to 87%.

No test installs anything or touches the real workspace: stand-in binaries are written into t.TempDir() with PATH replaced through t.Setenv, and the git tests point GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM at an empty file so a developer's own config cannot change what the suite measures.

Verification

  • go build ./..., go test ./..., golangci-lint run — green, 0 issues.
  • scc validate — no findings. scc check — no findings, 978 tests, 86% covered against the 86% floor.
  • scc launch claude --dry-run in this repository: no malformed-block warning, no false Headroom block, RTK reported as absent — which is the truth.

Reviews

  • scc-security-review: clean, no findings. It flagged safe.directory '*' as high on the first round — disabling git's CVE-2022-24765 ownership check for a container that forwards GH_TOKEN and mounts the agent's credential volume — and confirmed the narrowed /workspaces/* on the second: the Dev Containers CLI mounts there by default, and git's glob covers only the immediate level, not a .git planted inside a dependency.
  • scc-code-review: clean, one minor finding — a pre-existing lifecycle test built a git repository without turning commit signing off, which would fail non-interactively on a hardened runner. Fixed in dfb536e, along with the plan's ## Paths section, which named three of the four files this work touched.

A run of `scc launch claude` on Windows fell back to the host unsandboxed and
warned about a malformed block in this repo's own entry file. Both causes ship
to every scaffolded workspace and both fail in the direction that looks like
success.
Splice, Block, Version and Remove each found the markers with strings.Index
over the document itself, so a file that *documents* the markers was read as a
file carrying the block. This repository's own CLAUDE.md is the measured case:
it names RTK's opening and closing markers in one sentence, which made
rtkBlockPresent true and scc launch skip the RTK wiring it believed was already
done — and left scc rtk ready to splice over that sentence — and it names scc's
CodeGraph opener alone, which was reported malformed so the block was never
written at all.

mdscan.Mask blanks fenced blocks and inline code spans to spaces, preserving
every byte offset and leaving HTML comments intact, because the markers a caller
looks for here are HTML comments. One span helper in mdblock measures over it
and slices out of the original, so four methods cannot disagree about where the
block is. rtk.ForeignBlock had the same defect for Headroom's marker and takes
the same mask.

Mask covers fenced blocks and code spans only; a marker in a 4-space-indented
block or a blockquote still reads as live. Recorded as n-0010.
…d-only bind

postCreateCommand ran `git config --global --add safe.directory`, and
~/.gitconfig is the read-only bind that carries your identity in, so git failed
with "Device or resource busy". That failed postCreate, which failed
`devcontainer up`, which dropped scc launch back onto the host — a run that
reported a sandbox and started the agent outside one.

The setting moves into the image, where nothing is read-only and the build runs
as root: postCreate's non-root user cannot write /etc/gitconfig either, so
neither scope works from there. It is scoped to '/workspaces/*' rather than '*'
— git's ownership check is what stops a differently-owned .git from running its
own config or hooks (CVE-2022-24765), and this container forwards GH_TOKEN and
mounts the agent's credential volume.
… tests

All three were at 0%, and all three are the kind of code that fails silently:
a segment constant that grows a leading separator turns a workspace-relative
path absolute, a status line on the wrong stream corrupts every --json document,
and an argument vector that puts the agent's flags ahead of the agent hands them
to the CLI instead.

The integration tests drive install, lookup and daemon detection against a
stand-in binary on a replaced PATH, so no test shells out to the real npm or
installs a global package on whoever runs the suite.
…parsers

The package was at 8.3%: the diff parser had tests and everything that actually
shells out to git had none, which is most of it and all of the reasoning the
comments spend their length on. The distinctions now have a test each — merged
is ahead 0 and behind > 0 rather than "is an ancestor", a detached head answers
"" rather than "HEAD", core.hooksPath wins over .git/hooks, core.commentChar's
"auto" comes back verbatim, and Changed reads the working tree while skipping
binary and ignored files.

Each repository is built in a temp directory with GIT_CONFIG_GLOBAL and
GIT_CONFIG_SYSTEM pointed at an empty file, so a developer's own gpgsign,
hooksPath or diff.noprefix cannot change what the suite measures — the same
class of interference Changed's pinned prefixes exist for. gh is a stand-in
binary on a replaced PATH; no test needs a network or an account.
harness.go was at 0%, and it is the half of this package that writes into a file
scc does not own. The promises now have a test each: an install preserves every
key, every event and every entry it did not write; a remove leaves somebody
else's hook and drops the keys it emptied rather than littering empty arrays; a
file scc cannot parse is reported and left byte for byte, because reformatting
somebody's broken JSON destroys what they were in the middle of fixing.

Recognition by the exact command is pinned from both sides — `my-scc hooks run
stop` and `scc hooks run stop | tee log` are not scc's, and both were real
near-misses for the substring test this replaced.

On the git side, Look is covered for the one thing a report must never do, which
is write what it was asked to report on.
… the boundary

76.8% to 88.8%, and the parts that had nothing were the ones whose whole value is
a distinction: the delivery vocabulary is closed and terminal states are what
separate "still open" from "done with"; the seal is tamper-evidence, so an
edited approved plan has to report drift and a resealed one has to stop; the
schedule is one implementation behind --next, --ready and --blocked, so 1.9 has
to sort before 1.10 and Next has to agree with Ready.

Within is covered for what it exists for — `..` out of the workspace, and a
sibling whose name merely starts with the root's, which a prefix test gets wrong.
…placed PATH

Five packages shell out to a third party, and in each one the half that looks up,
versions and installs the binary had little or no coverage — rtk was at 32%,
because everything tested was the marker splice and nothing was the cargo build.

Each now drives those paths against a script the test writes and puts on PATH by
itself, so no test installs a global npm package, resolves a Python
distribution, or starts a Rust build on whoever runs the suite. The distinctions
that get tested are the ones these packages exist to make: a missing cargo is
reported as a missing toolchain rather than as a failed build, uv is chosen over
pip on a machine with both, and a build whose --help cannot answer means scc
passes no options rather than guessing at them.

gate's Load and Save are covered for the reason they read and write every
manifest: taking the first that has any makes a single write depend on which tool
the reader was standing in.
…s in

checks.go was at 0%: the eleventh validator's whole design is that its findings
are distinct rather than one "the gate failed" — a command that did not run, a
report that could not be read, a suite with no tests, and one that came up short
are four different things to go and do. Each now has a test, including that an
undecided gate says what to write and a declined one says nothing.

All() is covered for the ordering it assembles by, which is cost: the two options
are behind a flag because they run a compiler and go to the network, checks runs
before pr so a broken build reports before a round trip to the forge, and
WithoutAttribution drops exactly the one check the Stop hook cannot carry.
Two gaps, and the first is the contract itself: `--help` is 0 on every
subcommand, and nothing checked it across the surface — the defect it guards
against was 52 handlers routing flag.ErrHelp to ExitError at once. The sweep
runs every command's help, every resource's bare `help` positional, and every
unknown subcommand, which has to name what was typed because the caller is
usually an agent with no manual.

`scc check` with no subcommand is deliberately absent from those lists: it runs
the gates, which in this repository is the suite running the test.

The map reads now have a spec to read from, so trace follows a requirement out of
requirements.md and into the design, and every block `map blocks` prints is
checked to be an address `map show` actually accepts — an index of addresses that
do not resolve is worse than no index.
…es find adds over grep

patch's data-loss case is pinned from both ends: changing a task's methodology
has to re-emit its flags and its continuation, and an address that does not
resolve has to leave the file untouched rather than insert at a guess.

notes covers the questions a substring cannot answer — which tags exist, what
this project knows about a path, what is new — plus the tombstone, which is what
makes a spent number spent rather than reusable.
Four small gaps, each on something whose failure is silent. AtomicWrite is
covered for what it promises to leave behind, which is nothing — no temp file,
the whole content, the mode asked for. CarryOver is covered for why it exists:
`scc init` and `scc update` rebuild the manifest from scratch, so a re-run was
throwing away the delivery gate's commands and every key a newer scc wrote.

mdblock's Remove now has the tests Splice had, including that a marker written in
prose is not a block on that path either, and that CRLF survives both directions.
mdscan.Ticked is exported precisely so there is one answer to what a ticked box
looks like, and it had none.
A scope is one graph per directory rather than one graph filtered, so --json
over several roots is an array of sections — each carrying its root's own
document rather than a re-encoding of it, and degrading to a string when a root
printed something that is not JSON.

rtkInstallOK is one decision for init and launch both, precisely so there is not
an untested second copy; it now has a test per way it can refuse.
The v2 plan contract is six sections and no others, and nothing tested that a
seventh is a finding — which is the whole mechanism that caps a plan's size.

The seal's value is where it is checked: drift is not a validator finding, it is
refused before an edit is applied, because a harness that edited by hand and then
patched would otherwise have its edit resealed by the command that should have
reported it. Both halves are pinned, plus that reseal --force leaves the status
alone.
…mary

AttributionPR never returns an error, and the reasons are the ones nobody would
write a test for: no repository, no gh, no pull request. Each is now pinned,
because the failure mode is scc breaking validate in every workspace that is not
a git repository over a rule about git.

UpdatePlan.Writes had one subtlety and no test: an orphan writes without force,
since the file is left alone but its manifest entry goes — and a stale entry
sends the next update looking for a template that no longer exists.
…ry on a sealed plan

Every map read now has a test for the human form and the machine one, including
that the JSON is compact — the reader of that document is an agent paying by the
byte, and a third of the indented form was leading whitespace.

The container backend had no tests at all: the two reasons a Windows run ends up
on the host, and that it says so as the weaker boundary in the document as well
as in the line a person reads.

Discovery on an approved plan is pinned from both sides, including the case the
rollback catches — a ticked task cannot be struck out, because it was either
done or it was not.
…it reports

stale is the reason the Stop stage no longer re-indexes on every turn, and its
asymmetry had no test: it errs toward syncing, because an unnecessary index costs
a subprocess while a skipped one leaves the agent an index that answers
confidently about code that changed.

touchedHere falls back to the whole set when git cannot answer, which is the
direction that keeps the stage useful rather than silent, and relAll names the
manifest from the workspace root — against the working directory the same file
came back under two names.
Both steps exist to end in the agent starting anyway, and the reason is the whole
value — silence is how somebody spends a month wondering why the proxy never
seemed to help. Driven against the resolvers directly, because --json implies
install nothing and the install-shaped branches are unreachable from a plan-only
run.
…chors

CompareNumbers is a correction rather than a preference — sorted as text 1.10
comes before 1.9, so a loop asking for the next task got the tenth before the
ninth the moment a group grew past nine items — and it had no test of its own.

Search is pinned on the property that makes it usable: every hit comes back as an
address Find resolves, an empty query is no hits rather than the whole corpus,
and a malformed regex is an error rather than silence that reads as no matches.

Codewiki's slug rules each have a case now, including the heading that yields no
anchor at all, and a citation to the last line of a file with a trailing newline
resolves rather than reading as one past the end.
… hook gate

isLocalReference is the boundary of what the skills validator can check, and
everything outside it — an anchor, a URL, a path that escapes the skill — is
somebody else's to resolve; reporting one would be scc claiming to know about the
world outside the directory it is looking at.

The hook gate narrows outside a workspace, which is what lets the hooks be
installed in a repository that follows scc's rules without being one of its
workspaces — scc's own, for one. Without it every such repository would get
findings about a specs/ tree that does not exist.
map find's flags are the whole of what it offers over a grep, and each now has a
case — including that a malformed regex is an error rather than zero hits, which
would read as nothing matching.

The notes rollback had none: a refused first note has to take the log it just
seeded with it, or the command reports the note as not written and still changes
the workspace.
A spec name becomes a path segment, so it goes through SafeName first — without
it  walks out of the workspace — and a bare sync reconciles
every spec that recorded something rather than every directory under specs/.

--dry-run is pinned for the reason it exists: sync writes to every spec in the
workspace, and a flag that edited them anyway would be the one nobody expects to
change anything doing exactly that.
… word

A scope that matches nothing stops the run rather than falling back to the whole
workspace — indexing everything because a path was misspelled is an expensive way
to find out about a typo — and one tree missing out of two is named and skipped.

touchedHere is pinned against a real branch now, not only on its fallback: a
finding in a file this branch never opened is what the filter exists to drop.

helpWord rewrites a lone GNU bash, version 5.3.9(1)-release (x86_64-pc-cygwin)
These shell commands are defined internally.  Type `help' to see this list.
Type `help name' to find out more about the function `name'.
Use `info bash' to find out more about the shell in general.
Use `man -k' or `info' to find out more about commands not in this list.

A star (*) next to a name means that the command is disabled.

 ! PIPELINE                              history [-c] [-d offset] [n] or hist>
 job_spec [&]                            if COMMANDS; then COMMANDS; [ elif C>
 (( expression ))                        jobs [-lnprs] [jobspec ...] or jobs >
 . [-p path] filename [arguments]        kill [-s sigspec | -n signum | -sigs>
 :                                       let arg [arg ...]
 [ arg... ]                              local [option] name[=value] ...
 [[ expression ]]                        logout [n]
 alias [-p] [name[=value] ... ]          mapfile [-d delim] [-n count] [-O or>
 bg [job_spec ...]                       popd [-n] [+N | -N]
 bind [-lpsvPSVX] [-m keymap] [-f file>  printf [-v var] format [arguments]
 break [n]                               pushd [-n] [+N | -N | dir]
 builtin [shell-builtin [arg ...]]       pwd [-LPW]
 caller [expr]                           read [-Eers] [-a array] [-d delim] [>
 case WORD in [PATTERN [| PATTERN]...)>  readarray [-d delim] [-n count] [-O >
 cd [-L|[-P [-e]]] [-@] [dir]            readonly [-aAf] [name[=value] ...] o>
 command [-pVv] command [arg ...]        return [n]
 compgen [-V varname] [-abcdefgjksuv] >  select NAME [in WORDS ... ;] do COMM>
 complete [-abcdefgjksuv] [-pr] [-DEI]>  set [-abefhkmnptuvxBCEHPT] [-o optio>
 compopt [-o|+o option] [-DEI] [name .>  shift [n]
 continue [n]                            shopt [-pqsu] [-o] [optname ...]
 coproc [NAME] command [redirections]    source [-p path] filename [argument>
 declare [-aAfFgiIlnrtux] [name[=value>  suspend [-f]
 dirs [-clpv] [+N] [-N]                  test [expr]
 disown [-h] [-ar] [jobspec ... | pid >  time [-p] pipeline
 echo [-neE] [arg ...]                   times
 enable [-a] [-dnps] [-f filename] [na>  trap [-Plp] [[action] signal_spec ..>
 eval [arg ...]                          true
 exec [-cl] [-a name] [command [argume>  type [-afptP] name [name ...]
 exit [n]                                typeset [-aAfFgiIlnrtux] name[=value>
 export [-fn] [name[=value] ...] or ex>  ulimit [-SHabcdefiklmnpqrstuvxPRT] [>
 false                                   umask [-p] [-S] [mode]
 fc [-e ename] [-lnr] [first] [last] o>  unalias [-a] name [name ...]
 fg [job_spec]                           unset [-f] [-v] [-n] [name ...]
 for NAME [in WORDS ... ] ; do COMMAND>  until COMMANDS; do COMMANDS-2; done
 for (( exp1; exp2; exp3 )); do COMMAN>  variables - Names and meanings of so>
 function name { COMMANDS ; } or name >  wait [-fn] [-p var] [id ...]
 getopts optstring name [arg ...]        while COMMANDS; do COMMANDS-2; done
 hash [-lr] [-p pathname] [-dt] [name >  { COMMANDS ; }
 help [-dms] [pattern ...] and nothing else, because GNU bash, version 5.3.9(1)-release (x86_64-pc-cygwin)
These shell commands are defined internally.  Type `help' to see this list.
Type `help name' to find out more about the function `name'.
Use `info bash' to find out more about the shell in general.
Use `man -k' or `info' to find out more about commands not in this list.

A star (*) next to a name means that the command is disabled.

 ! PIPELINE                              history [-c] [-d offset] [n] or hist>
 job_spec [&]                            if COMMANDS; then COMMANDS; [ elif C>
 (( expression ))                        jobs [-lnprs] [jobspec ...] or jobs >
 . [-p path] filename [arguments]        kill [-s sigspec | -n signum | -sigs>
 :                                       let arg [arg ...]
 [ arg... ]                              local [option] name[=value] ...
 [[ expression ]]                        logout [n]
 alias [-p] [name[=value] ... ]          mapfile [-d delim] [-n count] [-O or>
 bg [job_spec ...]                       popd [-n] [+N | -N]
 bind [-lpsvPSVX] [-m keymap] [-f file>  printf [-v var] format [arguments]
 break [n]                               pushd [-n] [+N | -N | dir]
 builtin [shell-builtin [arg ...]]       pwd [-LPW]
 caller [expr]                           read [-Eers] [-a array] [-d delim] [>
 case WORD in [PATTERN [| PATTERN]...)>  readarray [-d delim] [-n count] [-O >
 cd [-L|[-P [-e]]] [-@] [dir]            readonly [-aAf] [name[=value] ...] o>
 command [-pVv] command [arg ...]        return [n]
 compgen [-V varname] [-abcdefgjksuv] >  select NAME [in WORDS ... ;] do COMM>
 complete [-abcdefgjksuv] [-pr] [-DEI]>  set [-abefhkmnptuvxBCEHPT] [-o optio>
 compopt [-o|+o option] [-DEI] [name .>  shift [n]
 continue [n]                            shopt [-pqsu] [-o] [optname ...]
 coproc [NAME] command [redirections]    source [-p path] filename [argument>
 declare [-aAfFgiIlnrtux] [name[=value>  suspend [-f]
 dirs [-clpv] [+N] [-N]                  test [expr]
 disown [-h] [-ar] [jobspec ... | pid >  time [-p] pipeline
 echo [-neE] [arg ...]                   times
 enable [-a] [-dnps] [-f filename] [na>  trap [-Plp] [[action] signal_spec ..>
 eval [arg ...]                          true
 exec [-cl] [-a name] [command [argume>  type [-afptP] name [name ...]
 exit [n]                                typeset [-aAfFgiIlnrtux] name[=value>
 export [-fn] [name[=value] ...] or ex>  ulimit [-SHabcdefiklmnpqrstuvxPRT] [>
 false                                   umask [-p] [-S] [mode]
 fc [-e ename] [-lnr] [first] [last] o>  unalias [-a] name [name ...]
 fg [job_spec]                           unset [-f] [-v] [-n] [name ...]
 for NAME [in WORDS ... ] ; do COMMAND>  until COMMANDS; do COMMANDS-2; done
 for (( exp1; exp2; exp3 )); do COMMAN>  variables - Names and meanings of so>
 function name { COMMANDS ; } or name >  wait [-fn] [-p var] [id ...]
 getopts optstring name [arg ...]        while COMMANDS; do COMMANDS-2; done
 hash [-lr] [-p pathname] [-dt] [name >  { COMMANDS ; }
 help [-dms] [pattern ...] is a perfectly
good plan name, note id or search term.
…e hook report

The three acts on a gate are distinct and had no test between them: set records,
skip declines — a word rather than an absent key — and clear puts it back to
undecided, which the validator reports where a declined gate is silence.

scc graph is a hard error without its binary, unlike the launch path, because the
whole command is the binary; and update reports a stale hook block without
rewriting it, since a yes about template files is not consent to edit .git.

This brings the suite to the 86% floor the delivery gate requires.
An early version of the usage sweep ran `scc plan new help` against the real
workspace rather than a temp one. The sweep no longer does; the file it left is
not an artifact of this work.
…th path

The lifecycle test built a repository without turning commit signing off, so on a
machine whose global config signs commits every commit in it would fail
non-interactively — an environment the test does not mean to assert about.

The plan's Paths section named three of the four files this work touched.
Within evaluated symlinks on the root and on the target, but a target that is not
on disk yet — the ordinary case for a file about to be written — fails
EvalSymlinks and stayed lexical while the root beside it was resolved. The two
were then no longer comparable, and a path plainly inside the workspace came back
as an escape.

CI found it where this machine could not: on macOS, where a temporary directory
is /var against a resolved /private/var, and on Windows, where a path handed over
with an 8.3 short name resolves to its long form on one side only.

Each side now resolves its deepest existing ancestor and re-attaches the rest.
The part that does not exist cannot be a symlink — there is nothing there to be
one — and the `..` this exists to catch is resolved away by Abs before any of it
runs, so the boundary is unchanged in the direction that matters.

The regression is pinned three ways, because the condition that produces it is
platform-shaped: a root reached through a symlink, a target that does not exist,
and resolveExisting on its own, which needs neither.
@protonspy
protonspy merged commit b74f1d2 into main Sep 16, 2026
4 checks passed
@protonspy
protonspy deleted the fix/launch-defects branch September 16, 2026 01:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant