Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3cff55a
context: bind a local graph to its revision and fail closed on everyt…
jeffhuber Sep 12, 2026
fe08a2b
context: register context-graph in the CLI command registry assertion
jeffhuber Sep 12, 2026
a011eb4
context: enforce the provider's network boundary in the kernel
jeffhuber Sep 12, 2026
b60a1af
context: pin the sandbox classifier on hosts with no mechanism
jeffhuber Sep 12, 2026
c4f6bfe
context: close the provider interface, the Git boundary, and three li…
jeffhuber Sep 12, 2026
9f3099a
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 2026
9bfe023
context: require completion evidence, refuse stale provider state, bo…
jeffhuber Sep 12, 2026
d700564
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 2026
86b124b
context-graph: discard the provider's streams instead of buffering them
jeffhuber Sep 12, 2026
f063f52
context-graph: require the restricted extraction, bound packing, and …
jeffhuber Sep 12, 2026
28d5d1d
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 2026
826b105
context-graph: give each provider-launch fixture its own artifact path
jeffhuber Sep 12, 2026
1b7ead5
context-graph: bind committed objects, contain overruns, bound what runs
jeffhuber Sep 12, 2026
5648560
context-graph: prove isolation at the listener, resolve state, bound …
jeffhuber Sep 12, 2026
a01054c
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 2026
de10e30
context: bound the census stream and stop the provider on cancellation
jeffhuber Sep 12, 2026
0d865ff
context: stop the provider group on a normal exit, not only a failure
jeffhuber Sep 12, 2026
1f87ba8
context: name the macOS tmp link without spelling the forbidden path
jeffhuber Sep 12, 2026
72071ad
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 2026
04e2806
context: keep Code Mower private state out of the tracked census
jeffhuber Sep 12, 2026
5326e83
context: confine the provider's filesystem, not only its sockets
jeffhuber Sep 12, 2026
92d9810
context: probe the boundary from inside it, and stand in for it at la…
jeffhuber Sep 12, 2026
5d7c9db
context: expose the loader's own spelling, not only what it resolves to
jeffhuber Sep 12, 2026
074df1c
context: walk the state root from the filesystem root, and prove the …
jeffhuber Sep 12, 2026
ae64f84
context: let the Seatbelt profile map the runtime executable, not onl…
jeffhuber Sep 12, 2026
644e4e5
context: keep graph state mutations descriptor-relative and let dyld …
jeffhuber Sep 12, 2026
a53351c
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 2026
e568537
Expose the provider's own base interpreter, not this process's
jeffhuber Sep 12, 2026
741fcbc
Narrow the runtime exposure and resolve the provider once
jeffhuber Sep 12, 2026
dad9aac
Merge origin/main into claude/913-graph-lifecycle
jeffhuber Sep 12, 2026
259f09d
context-graph: derive state identity from the checkout root
jeffhuber Sep 12, 2026
9d36b28
Merge origin/main (26135c6) into claude/913-graph-lifecycle
jeffhuber Sep 12, 2026
14768aa
Check the provider install against the pin before extraction
jeffhuber Sep 12, 2026
1149b76
Merge remote-tracking branch 'origin/main' into claude/913-graph-life…
jeffhuber Sep 12, 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
81 changes: 81 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,87 @@ jobs:
--timeout 240
--json

graph_containment:
# The local-graph boundary, against the real kernel mechanism rather than a
# stand-in for one. The unit suite skips these when a host offers no
# mechanism, which is right for a laptop and useless as coverage: this job
# installs bubblewrap and sets CODE_MOWER_REQUIRE_CONTAINMENT, which turns
# that skip into a failure.
name: graph containment
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.12"

- name: Install bubblewrap
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
test -x /usr/bin/bwrap

# Ubuntu 24.04 restricts unprivileged user namespaces by default, which
# is what bubblewrap needs to build a mount namespace without being
# setuid. Enabling it is a property of this runner, not of the product:
# a host that refuses keeps refusing builds, which is the fail-closed
# posture. Reported rather than asserted, so the suite below is what
# decides the job.
- name: Allow unprivileged user namespaces
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 || true
bwrap --unshare-net --dev-bind / / /bin/true \
&& echo "bwrap: namespaces available" \
|| echo "bwrap: refused a namespace on this runner"

- name: Install package
run: python -m pip install -e .

- name: Real containment tests
env:
CODE_MOWER_REQUIRE_CONTAINMENT: "1"
run: python -m unittest discover -s tests -p test_context_graph_lifecycle.py -v

graph_containment_macos:
# The Seatbelt half of the same claim. The bubblewrap job above proves the
# Linux boundary and nothing whatever about this one, and macOS is the
# platform this tool is developed on: leaving the profile to be exercised
# only by whoever happens to run the suite on a laptop is how it stayed
# unexecuted. ``sandbox-exec`` ships with the OS, so there is nothing to
# install -- the job is the evidence that the profile runs at all.
#
# Required, like the bubblewrap job. macOS containment is a behaviour claim
# in ``docs/context-graph-lifecycle.md``, and a claim whose only check is
# allowed to be red is not being checked. A red result still carries the
# probe's own diagnosis of which candidate failed and what the launcher
# said, which is what turned the equivalent bubblewrap failure into a
# one-round fix.
name: graph containment (macOS)
runs-on: macos-latest
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.12"

- name: Confirm the system sandbox is present
run: test -x /usr/bin/sandbox-exec

- name: Install package
run: python -m pip install -e .

- name: Real containment tests
env:
CODE_MOWER_REQUIRE_CONTAINMENT: "1"
run: python -m unittest discover -s tests -p test_context_graph_lifecycle.py -v

package:
name: package
runs-on: ubuntu-latest
Expand Down
568 changes: 568 additions & 0 deletions docs/context-graph-lifecycle.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/context-provider-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,11 @@ package record, and the conditions an implementing change must meet. Nothing is
installed or required yet. Synthetic graph fixtures prove only the extension
point; they do not establish Graphify compatibility or make it a v1.3.1
dependency.

The lifecycle around such a provider — exact pin, immutable tracked-file
materialization, scrubbed environment, an OS sandbox that denies the provider
the network, private 0700 state, atomic generations,
and `code-mower context-graph build/refresh/status/remove/doctor` — is
described in [Local repository graph](context-graph-lifecycle.md). It builds the
evidence side; `context_graph` still decides whether a delivered packet's
citations may be used.
5 changes: 4 additions & 1 deletion docs/current-state-and-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ participant. Start local and code-only:
[issue #876](https://github.com/codemower-ai/code-mower/issues/876) with an
adopt decision;
- add a provider registry and multiple context attachments per session;
- build and refresh graphs with commit/freshness validation;
- build and refresh graphs with commit/freshness validation — delivered by
`code-mower context-graph`, described in the
[lifecycle record](context-graph-lifecycle.md), which closes
[issue #913](https://github.com/codemower-ai/code-mower/issues/913);
- consume a pinned structured JSON contract;
- generate bounded impact, dependency, symbol, and related-test packets; and
- deliver the same packet shape to Claude, Codex, and Devin.
Expand Down
10 changes: 10 additions & 0 deletions docs/graphify-evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ are engineering conditions on the adapter, not requests for a decision.
accounted for: an incremental run's completion is not treated as proof the
graph is complete.

Conditions 1, 2, 3, 5 and 7 are implemented by the build/refresh/status/remove
lifecycle in
[Local repository graph: revision-bound lifecycle](context-graph-lifecycle.md)
(issue #913): an exact pin with a verified artifact digest, private 0700 state
outside every checkout, a manifest that binds full commit and tree with build
time, opt-in acquisition with no default dependency, and a `partial`
completeness state that refuses to read a fast incremental repeat as a complete
graph. Conditions 4 and 6 belong to the retrieval adapter, which does not exist
yet.

## Boundary

Graphify stays out of v1.3.1 and does not block Coworker's 1.3.0 or 1.3.1
Expand Down
3 changes: 3 additions & 0 deletions src/code_mower/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def _source_checkout_install_spec() -> str:
from . import controller as code_mower_controller
from . import code_mower_calibration
from . import code_mower_context_packs
from . import context_graph_command
from . import code_mower_merge
from . import code_mower_telemetry
from . import config as code_mower_config
Expand Down Expand Up @@ -447,6 +448,7 @@ def _local_llm_main(argv: list[str]) -> int:
"cloud": "Export or upload sanitized benchmark metadata.",
"config": "Validate or inspect a Code Mower config.",
"context": "Record local external planning context manifests.",
"context-graph": "Build, refresh, inspect, or remove a local repository graph.",
"context-packs": "Build selective surrounding-file context packs.",
"controller": "Compute supervised-pilot dispatch and merge-policy decisions.",
"coderabbit-cli": "Run a CodeRabbit CLI informational lane.",
Expand Down Expand Up @@ -595,6 +597,7 @@ def _top_level_help(show_all: bool) -> str:
"cloud": code_mower_cloud.main,
"config": _config_main,
"context": code_mower_work_orders.context_main,
"context-graph": context_graph_command.main,
"context-packs": code_mower_context_packs.main,
"controller": code_mower_controller.main,
"coderabbit-cli": coderabbit_cli_audit_pr.main,
Expand Down
167 changes: 167 additions & 0 deletions src/code_mower/context_graph_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""``code-mower context-graph``: build, refresh, inspect and remove a local graph.

The lifecycle in ``context_graph_lifecycle`` is deliberately not wired into any
default path. This command is how an operator opts in, one checkout at a time,
and it asks for everything explicitly rather than discovering it: the provider
pin comes from a file the operator names, and the indexer executable comes from
an install the operator already made. Nothing here downloads, installs, or
resolves a provider.

Output is metadata only -- revisions, digests, counts, and states. No indexed
content, provider output, or local path of the private state directory is
printed unless the operator asks for it with ``--show-local-paths``.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from . import context_graph_lifecycle as lifecycle
from .context_contract import ContextError
from .context_store import strict_json

MAX_PIN_BYTES = 8192


def _load_pin(path: Path | None) -> lifecycle.GraphifyPin | None:
if path is None:
return None
try:
# Bounded at the stream, not after the fact: a bound checked on bytes
# already in memory is not a bound on what the file can cost to read.
with path.open("rb") as stream:
raw = stream.read(MAX_PIN_BYTES + 1)
except OSError:
raise ContextError("local graph provider pin file is unreadable") from None
if len(raw) > MAX_PIN_BYTES:
raise ContextError("local graph provider pin file exceeds its bound")
return lifecycle.load_pin(strict_json(raw))


def _require_pin(path: Path | None) -> lifecycle.GraphifyPin:
pin = _load_pin(path)
if pin is None:
raise ContextError("building a local graph requires an exact provider pin")
return pin


def _emit(payload: dict, *, as_json: bool, text: str) -> None:
if as_json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print(text, end="")


def main(argv=None) -> int:
parser = argparse.ArgumentParser(
prog="code-mower context-graph",
description="Manage an optional revision-bound local repository graph.",
)
sub = parser.add_subparsers(dest="command", required=True)
build = sub.add_parser("build", help="Build and publish a generation for the current revision")
refresh = sub.add_parser("refresh", help="Explicitly rebuild and atomically publish a new generation")
status = sub.add_parser("status", help="Report whether the published generation may be used")
remove = sub.add_parser("remove", help="Delete this checkout's private local graph state")
doctor = sub.add_parser("doctor", help="Check the local graph posture without building anything")

for command in (build, refresh, status, remove, doctor):
command.add_argument("--repo-path", type=Path, default=Path.cwd(), help="Checkout to bind")
command.add_argument("--state-dir", type=Path, help="Private state root; defaults to the context store")
command.add_argument("--json", action="store_true", help="Emit a machine-readable summary")
for command in (build, refresh, status, doctor):
command.add_argument("--revision", default="HEAD", help="Revision to bind, for example a commit or tag")
for command in (build, refresh, doctor):
command.add_argument("--pin-file", type=Path, help="JSON file naming one exact provider release")
for command in (build, refresh):
command.add_argument("--indexer", required=True, help="Path to the already-installed pinned provider CLI")
command.add_argument("--keep-previous", action="store_true",
help="Retain superseded generations instead of pruning them")
status.add_argument("--allow-partial", action="store_true",
help="Treat a provider-declared partial build as usable")
remove.add_argument("--show-local-paths", action="store_true", help="Include the private state path in output")

args = parser.parse_args(argv)
try:
if args.command in ("build", "refresh"):
pin = _require_pin(args.pin_file)
if args.command == "build" and lifecycle.graph_status(
args.repo_path, root=args.state_dir, revision=args.revision
).usable:
# ``build`` is the first-time verb. A usable generation already
# binds this revision, so rebuilding it is ``refresh`` -- an
# explicit choice, never something ``build`` does by surprise.
raise ContextError("a current generation already binds this revision; use refresh to rebuild")
manifest = lifecycle.build_graph(
args.repo_path,
pin=pin,
# The pin goes to the adapter as well as to the build: the
# adapter checks the install it is about to run against it, so
# a manifest never records a release nobody confirmed was
# installed.
indexer=lifecycle.subprocess_indexer(
args.indexer, repository=args.repo_path, pin=pin
),
root=args.state_dir,
revision=args.revision,
keep_previous=args.keep_previous,
)
# The published state is the manifest's, not this command's to
# assume: a provider that admitted an incomplete run has published
# a generation ``status`` will call ``partial`` and refuse, and
# printing ``current`` here would describe it as usable for exactly
# as long as it took the operator to ask again.
complete = manifest.completeness == lifecycle.COMPLETE
published = lifecycle.GenerationStatus(
state="current" if complete else "partial",
generation=manifest.generation,
manifest=manifest,
detail="" if complete else "local graph build was incomplete; refresh it",
)
summary = {"status": "published", "usable": published.usable, **manifest.shareable_summary()}
_emit(summary, as_json=args.json, text=lifecycle.render_status_text(published))
# Publishing an unusable generation is a reportable condition, not
# a crash: exit non-zero for the same reason ``status`` does, so a
# script does not have to re-ask to find out what it just built.
return 0 if published.usable else 1
if args.command == "status":
report = lifecycle.graph_status(
args.repo_path,
root=args.state_dir,
revision=args.revision,
require_complete=not args.allow_partial,
)
_emit(report.shareable_summary(), as_json=args.json, text=lifecycle.render_status_text(report))
# A non-current graph is a normal, reportable condition, not a
# command failure; exit 1 so a script can branch on usability.
return 0 if report.usable else 1
if args.command == "remove":
state = lifecycle.GraphStateRoot(args.repo_path, root=args.state_dir)
path = str(state.path) if args.show_local_paths else None
removed = lifecycle.remove_graph(args.repo_path, root=args.state_dir)
payload = {"schema": "code_mower.contextGraphRemove.v1", "removed": removed}
if path is not None:
payload["path"] = path
_emit(payload, as_json=args.json,
text=("Removed local graph state.\n" if removed else "No local graph state to remove.\n"))
return 0
report = lifecycle.doctor_report(
args.repo_path, pin=_load_pin(args.pin_file), root=args.state_dir, revision=args.revision
)
lines = [f"Local graph doctor: {report['status']}"]
lines.extend(f" [{check['status']}] {check['check']}: {check['message']}" for check in report["checks"])
_emit(report, as_json=args.json, text="\n".join(lines) + "\n")
return 0 if report["status"] != "fail" else 1
except ContextError as error:
print(f"local graph unavailable: {error}", file=sys.stderr)
return 1
except Exception:
# Never let a provider or filesystem failure surface indexed content.
print("local graph unavailable; verify the pin, the checkout and the private state directory", file=sys.stderr)
return 1


if __name__ == "__main__": # pragma: no cover - direct invocation
raise SystemExit(main())
Loading
Loading