Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
67b00b8
fix: members validate and repair stop rejecting the refs Path creates
jeremymcs Oct 4, 2026
110fc45
fix: allow same-wave file overlap between dependency-ordered tasks
jeremymcs Oct 4, 2026
f6f2067
fix: guard hook precision for read-only commands
jeremymcs Oct 4, 2026
84b97f4
fix: dashboard probe resolves the pinned runtime via the project laun…
jeremymcs Oct 4, 2026
8566cca
feat: record runtime hotfix provenance on the declaration
jeremymcs Oct 4, 2026
a8ef38b
fix: dispatch footguns from the 1.4.0 multi-repo field report
jeremymcs Oct 4, 2026
ff53ba1
chore: sync generated skill resource copies
jeremymcs Oct 4, 2026
6bbe1b2
fix: review findings on the 353 branch
jeremymcs Oct 4, 2026
dcd7816
no-mistakes(review): fix guard fail-opens and remaining 353 review fi…
jeremymcs Oct 4, 2026
bcd1008
no-mistakes(review): deny bare and substituted parameters in read-onl…
jeremymcs Oct 4, 2026
dea8952
no-mistakes(review): allow git parameters only inside double-quoted s…
jeremymcs Oct 4, 2026
711beb6
no-mistakes(review): fail closed on unrecognized quoting in git param…
jeremymcs Oct 4, 2026
afc0252
no-mistakes(review): deny git parameters inside wrapped shell strings
jeremymcs Oct 4, 2026
f3b1e02
no-mistakes(review): accept only plain name expansions in git paramet…
jeremymcs Oct 4, 2026
336d266
no-mistakes(review): deny parameters in command-valued git options
jeremymcs Oct 4, 2026
38e91ab
no-mistakes(review): match denied git options by abbreviated prefix
jeremymcs Oct 4, 2026
e2fa010
no-mistakes(review): keep exact --text allowed under git option prefi…
jeremymcs Oct 4, 2026
f7540e8
no-mistakes(document): docs: align guard, worktree pin, provenance, p…
jeremymcs Oct 4, 2026
87f028d
fix: close zsh flag and escaped-marker gaps in the git parameter exce…
jeremymcs Oct 4, 2026
49482a9
no-mistakes(review): narrow zsh flag detector and cover git -C paths
jeremymcs Oct 4, 2026
f1d5619
fix: deny git -c keys whose value git executes
jeremymcs Oct 4, 2026
e1c03e2
no-mistakes(review): close executed git config keys, persisted form, …
jeremymcs Oct 4, 2026
f5dc873
no-mistakes(review): parse git config modes; .git/config append stays…
jeremymcs Oct 4, 2026
b65eabe
no-mistakes(review): deny gpg program keys and redirected git config …
jeremymcs Oct 4, 2026
24c1c2b
no-mistakes(review): allow redirected git config reads, keep digit se…
jeremymcs Oct 4, 2026
30e8bdd
no-mistakes(review): treat only one ASCII digit as redirection file d…
jeremymcs Oct 4, 2026
0697288
no-mistakes(review): deny non-literal git config arguments
jeremymcs Oct 4, 2026
afc43fa
no-mistakes(review): require literal git subcommand, deny comments in…
jeremymcs Oct 4, 2026
cc2620b
no-mistakes(review): deny joined continuations and expanding git opti…
jeremymcs Oct 4, 2026
bfcd6dc
no-mistakes(review): deny unresolved git option words and credential …
jeremymcs Oct 4, 2026
27cc609
no-mistakes(review): deny git behind programs the guard does not model
jeremymcs Oct 4, 2026
37ab072
no-mistakes(review): scope git wrapper denial to exec-prefix programs
jeremymcs Oct 4, 2026
71e75f8
no-mistakes(review): unwrap command runners so git checks reach payload
jeremymcs Oct 4, 2026
181c357
no-mistakes(review): deny file-valued runner options and non-literal …
jeremymcs Oct 5, 2026
8e793c8
no-mistakes(review): parse runner short options as getopt, deny non-l…
jeremymcs Oct 5, 2026
9b36e3a
no-mistakes(review): deny pager.<cmd> config keys that git executes
jeremymcs Oct 5, 2026
b764036
no-mistakes(document): Name interactive.diffFilter in HOOKS.md execut…
jeremymcs Oct 5, 2026
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
14 changes: 12 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,10 @@ resolved Git common directory and primary path, so separate clones and
primary folders do not share a location.

The first allocation pins the workspace location in
`<git-common-dir>/gsd-path/workspaces/<workspace-id>.json`. Later environment
changes do not move it. Existing sibling worktrees remain at their original
`<git-common-dir>/gsd-path/workspaces/<workspace-id>.json`. Environment
changes do not move it while a linked worktree remains. When a sidecar is
retired and no linked worktree remains, Path removes the pin, and the next
allocation reads `GSD_PATH_WORKTREE_ROOT` again. Existing sibling worktrees remain at their original
paths and retain the same recovery and cleanup checks. These folders contain
active work and evidence; they are not a disposable cache. Relocation needs
a separate migration; do not edit placement records or move folders manually.
Expand Down Expand Up @@ -397,6 +399,14 @@ the supplied package's runtime and its compatible status launcher. Review those
configuration changes before committing them. Existing versions remain available
for other projects and branches.

To record where a patched runtime came from, add `--runtime-provenance-source`,
`--runtime-provenance-patch`, or `--runtime-provenance-note` to
`--runtime-upgrade`. The values are written to the `provenance` object
(`source`, `patch_ref`, `note`) in `.gsd-path/runtime.json`. Without a patch
value, a source root that is a Git checkout records its `HEAD` commit. A later
upgrade without these flags keeps the object only when the digest is unchanged.
Provenance is a record only; it does not change the digest or runtime validation.

A new machine needs the selected runtime installed before status and guards can
run. `gsd-path --runtime-restore --project /absolute/project --source-root
/path/to/matching/package` restores that exact version without changing project
Expand Down
93 changes: 88 additions & 5 deletions HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ See [UPDATE.md](UPDATE.md).
source trees containing directory symlinks are denied; use literal directory
paths and copy directory links separately
- shell commands that reference the archive unless the whole command is a
recognized standalone read or a single-command invocation of the bundled
recognized standalone read, a pipeline in which each segment is a recognized
read, or a single-command invocation of the bundled
`pipeline_state.py` / `archive_milestone.py` helper, resolved to a regular file
inside the verified runtime selected by `.gsd-path/runtime.json` (legacy projects use `.gsd-path/runtime/` beside `guard_hook.py`
in the repository layout), using exactly `python` or `python3` with optional
Expand All @@ -162,11 +163,93 @@ See [UPDATE.md](UPDATE.md).
- deletion or move commands outside a single simple segment, including command
chains, pipes, newlines, grouping, directory changes, and command substitution
- deletion or move commands with any argument outside the literal-path character
set: ASCII letters, digits, `.`, `_`, `-`, and `/` (plus a drive prefix and
backslashes on Windows). One matching pair of surrounding quotes is allowed;
spaces, parameters, wildcards, braces, and tilde paths are denied even when
quoted. This also applies to `find -delete` and supported destructive aliases
set: ASCII letters, digits, `.`, `_`, `-`, `/`, and ASCII spaces inside one
shell unit (plus a drive prefix and backslashes on Windows). One matching
pair of surrounding quotes is allowed; parameters, wildcards, braces, and
tilde paths are denied even when quoted. This also applies to
`find -delete` and supported destructive aliases
- destructive Git commands nested in supported shell and command wrappers
- Git arguments that contain a shell parameter. Only plain `$name` and
`${name}` expansions pass, and only inside plain double-quoted spans of
read-only subcommands (`status`, `log`, `diff`, `show`, and the other
inspection commands) that the guard parses directly, such as
`git log --format="$t %H"` or `git show "HEAD:$f"`, because only double
quoting prevents word-splitting into options. A parameter outside double
quotes (`$x`, `HEAD:$f`, `--format=$x`, `"a b"$x`) is denied because its
expansion can split into a write option such as `--output`. Every other
parameter form (`${=x}`, `${(z)x}`, `${x:-y}`, `$x[@]`, `$1`, `$@`, `$*`,
`$#`, and the unbraced zsh flags `$=x`, `$~x`, `$^x`, `$+x`) is denied even
inside double quotes, because it can expand to more than one word; a
backslash-escaped marker (`"\$t"`) is likewise denied, because the shell
executes the literal text while the argument still shows the bare marker.
The same four zsh flags are denied in a `git -C` directory. A literal `$`
that starts no expansion (a regex anchor such as `--grep='fix$'`) passes.
A `-c`/`--config` key whose value git executes as a program —
`core.fsmonitor`, editors and pagers, `core.sshCommand`,
`core.askPass`, `filter.*` clean/smudge/process, `diff.*`
textconv/external/command, `merge.*` drivers, `core.gitProxy`,
`interactive.diffFilter`,
`remote.*` uploadpack/receivepack, `gpg.program` and `gpg.*.program`, `credential.helper` and
`credential.*.helper`, each `pager.<cmd>` key — is denied with a literal
value as with a parameter, because git runs it during an otherwise
read-only subcommand. A disabling form of such a key passes: the bare key,
an empty value, or `false`. A `--config-env` form is always denied, because
its value comes from the environment. A key through which git loads code —
`core.hooksPath`, `include.path`, `includeIf.*.path`, `url.*.insteadOf`,
`url.*.pushInsteadOf` — is denied with each value. `git config` is denied
when it sets one of these keys; to read or unset the key stays allowed.
A redirection at the end of a read (`git config <key> 2>/dev/null`) is not
a value. A separate digit before a redirection (`5 >/dev/null`), a word of two
or more digits (`12>/dev/null`) and a quoted operator are values, so they
make a set.
Each `git config` argument must be a plain literal word, bare or in one
pair of quotes: a backslash, mixed quotes, an unquoted glob (`*`, `?`, `[`),
an unquoted brace list or range (`{a,b}`, `{1..3}`) or a line continuation
in the word or one that joins it to the next word is denied, because the shell can make a different
argument list from it.
An unquoted word that starts with `#` among the `git config` arguments
is denied, because the shell reads it and the words after it as a comment.
The git subcommand word must be a plain literal word in each git command:
`git {config,...}` and a subcommand split by a line continuation are denied.
A program that runs the command in its arguments (`nice`, `nohup`,
`timeout`, `sudo`, `watch`, `time`, `env`, `stdbuf`, `setsid`) is
unwrapped: the guard skips the options of the program and checks the
command that it runs, so `nice git -c core.fsmonitor=/abs/h.sh status` is
denied by the git rule and `sudo apt install git` passes. A runner
option that writes, edits or enters a file or directory by itself is
denied (`time -o`, `sudo -e`, `sudo -D`, `sudo -R`, `watch -s`). The
`timeout` duration and the `watch` interval must be literal numbers, and
each runner option word must be a literal word; only a separate option
value can be a parameter in double quotes (`sudo -u "$USER" ls`). A short
option word is read as getopt reads it, so the attached value in
`sudo -upostgres psql` is not taken as more options. With an archive working directory a command runner is not
unwrapped and is denied as an unknown program. Other command
runners (`doas`, `ionice`, `noglob`) are not unwrapped, and `watch` with
the command in one quoted word is not checked.
Each git global option word, and the value word of `-c`, `-C`,
`--git-dir`, `--work-tree`, `--namespace` and `--config-env`, must resolve
to one word: a backslash, an unquoted glob or an unquoted brace list or
range in it is denied (`git -c {core.fsmonitor=/abs/h.sh,status}`,
`git -{p,c} core.fsmonitor=/abs/h.sh status`). A parameter in such a value
is denied also when no subcommand follows (`git -c $x`). Plain quoted parts
pass (`git -c user.name='A B' log`).
`git config` section renames and removals (`--rename-section`,
`--remove-section`) and `--edit` are denied, because a rename can move a
key into an executed section and `--edit` starts an editor.
Command substitution output, a parameter that starts an
argument, and a parameter in the value of an option that git writes to or
runs as a command (`--output`, `--upload-pack`, `--receive-pack`, `--exec`,
`-c`, `--config-env`, or a `<transport>::<address>` remote) are denied in
every form. These option names match by prefix (`--upload-pa`, `--exe`),
because git accepts abbreviated long options; in archive context the same
prefix match applies to `--output`, `--ext-diff`, and `--textconv`. The
exact read-only option `--text` is not an abbreviation and stays allowed. Any
other quoting construct in the command (`$'...'`, `$"..."`, quotes inside `${...}`, quotes or
backslashes in a comment, here-documents and here-strings) disables the
exception, so every parameter in a git argument of that command is denied.
The exception applies only to commands the guard parses directly, never
inside wrapped shell strings (`eval`, `bash -c`, `sh -c`, PowerShell or
`cmd` command strings), where the outer shell expands the parameter first
- archive glob/brace expansions and execution-capable read options such as
`rg --pre`
- direct write, edit, and patch tool calls targeting `.project/STATE.md`,
Expand Down
20 changes: 15 additions & 5 deletions daemon/gsd_daemon/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,15 +623,12 @@ def tree_mtime(path: Union[str, Path]) -> Optional[float]:
return latest


def _runtime_status(root: str) -> Optional[dict]:
runtime = Path(root) / ".gsd-path" / "runtime" / "pipeline_state.py"
if not runtime.is_file():
return None
def _run_status_command(command: List[str]) -> Optional[dict]:
try:
# The daemon's own interpreter: python3 is usually absent on Windows,
# where process start and git are also slow enough to need the margin.
result = subprocess_platform.run(
[sys.executable, "-B", str(runtime), "status", "--repo", root],
command,
capture_output=True,
encoding="utf-8",
errors="replace",
Expand All @@ -651,6 +648,19 @@ def _runtime_status(root: str) -> Optional[dict]:
return payload


def _runtime_status(root: str) -> Optional[dict]:
# Current installs pin a shared runtime and proxy status through the
# project launcher, which resolves the declaration exactly like the
# status command does. Unmigrated projects keep the per-project runtime.
launcher = Path(root) / ".gsd-path" / "status_runtime.py"
if launcher.is_file():
return _run_status_command([sys.executable, "-B", str(launcher), "--repo", root])
runtime = Path(root) / ".gsd-path" / "runtime" / "pipeline_state.py"
if not runtime.is_file():
return None
return _run_status_command([sys.executable, "-B", str(runtime), "status", "--repo", root])


def _activity_time(status: ProjectStatus) -> Optional[datetime]:
parsed = _parse_iso(status.last_activity_iso)
if parsed is not None:
Expand Down
20 changes: 20 additions & 0 deletions scripts/check_handoffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,20 @@ def _task_deps(task_text: str, task_id: str) -> List[str]:
return _inline_ids(rendered, "T", f"{task_id} deps")


def _dependency_reachable(graph: Dict[str, List[str]], start: str, target: str) -> bool:
seen: Set[str] = set()
pending = list(graph[start])
while pending:
task_id = pending.pop()
if task_id in seen:
continue
if task_id == target:
return True
seen.add(task_id)
pending.extend(graph[task_id])
return False


def _canonical_repo_path(value: str, label: str) -> str:
cleaned = value.strip().strip("`\"'")
path = PurePosixPath(cleaned)
Expand Down Expand Up @@ -1509,6 +1523,12 @@ def _validate_task_graph(
and _task_scalar(tasks[right], right, "status") == "done"
):
continue
# Dependency-ordered tasks serialize safely; only unordered
# same-wave overlap races on a shared file.
if _dependency_reachable(graph, left, right) or _dependency_reachable(
graph, right, left
):
continue
raise HandoffError(
f"same-wave file overlap between {left} and {right}: {', '.join(overlap)}"
)
Expand Down
14 changes: 7 additions & 7 deletions scripts/dispatch_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,14 +702,14 @@ def finish_task(primary: Path, state: Dict[str, object]) -> Dict[str, object]:
if not command:
raise DriverStop("task has no Verify command", task=task_id)
if state["mode"] == "serial":
# ponytail: sidecar reproduction is git plumbing that belongs in isolation.py as the
# inverse of clean_verify; lift it there when a second caller appears.
sidecar = state["sidecar"]
sidecar_path = Path(str(sidecar["worktree"]))
ensured = isolation.ensure_verify_sidecar(primary, base, str(sidecar["branch"]))
sidecar_path = Path(str(ensured["worktree"]))
tree = snapshot_tree(primary)
isolation.git_output(sidecar_path, "read-tree", "-u", "--reset", tree)
if isolation.git_output(sidecar_path, "write-tree") != tree:
raise DriverStop("sidecar reproduction differs from the primary task diff", task=task_id)
try:
isolation.reproduce_verify_sidecar(sidecar_path, tree)
except isolation.IsolationError as error:
raise DriverStop(str(error), task=task_id) from error
execution = run_verify(command, sidecar_path)
location = f"sidecar {sidecar['branch']}"
isolation.clean_verify(primary, sidecar_path, base, str(sidecar["branch"]))
Expand Down Expand Up @@ -2036,7 +2036,7 @@ def fix_tasks(primary: Path, options: argparse.Namespace) -> Dict[str, object]:
texts = contracts._task_texts(primary, options.project_dir)
groups = {group["locator"]: group for group in findings["groups"]}
today = now()[:10]
# Same-wave file overlap is forbidden, so repairs land in a newly appended wave.
# Unordered same-wave file overlap is rejected, so repairs land in a newly appended wave.
plan_path = project / "plan/PLAN.md"
plan_text = plan_path.read_text(encoding="utf-8")
depths, waves = contracts._plan_waves(plan_text)
Expand Down
Loading
Loading