Skip to content

1nzr follow-up: onboarding, A2A bridge, ENTIRE veto, conductor bookbag persistence - #153

Open
branben wants to merge 1 commit into
school/1nzr-reorgfrom
school/1nzr-behaviour
Open

branben wants to merge 1 commit into
school/1nzr-reorgfrom
school/1nzr-behaviour

Conversation

@branben

@branben branben commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

What this is

This PR carries the behaviour changes that had been bundled into #152 (the 1nzr reorg). Splitting them out keeps #152 reorg-only.

school/1nzr-behaviour = #152's revert commit bb1b968 + a reapply commit d38dbfa. The tree is identical to school/1nzr-reorg at a2afda4739db1563a7eaa0874def21d10b8f3b52, so this PR's diff is exactly the behaviour that was removed from #152.

Stacked on #152. Merge #152 first, then retarget this PR to main.

Behaviour changes carried here

  • onboard.py: new root module. It generates ICM L0-L4 context, envit entries and project_verify.yaml for a target repo. Tests: tests/test_onboard.py.
  • issue_bridge.py --onboard CLI: passthrough flags (--onboard, --write, --out, --no-clone, --offline, --require-evidence) that dispatch to onboard.main().
  • a2a_bridge.py: new root module. It sends tasks to OpenHands over A2A and retrieves their answers. Tests: tests/test_a2a_bridge.py. It is not wired into the issue_bridge.py CLI on this branch.
  • ENTIRE CRITICAL/HIGH veto (sync path): issue_bridge.py injects a CRITICAL entire finding into adversarial_review and forces verdict=FAIL and score=0.0. A HIGH finding does the same when ENTIRE_HIGH_STRICT=1. The sync-sensor test in tests/test_issue_bridge.py now uses a CRITICAL finding and expects FAIL.
  • Conductor bookbag persistence: conductor.py writes entire_findings and entire_status to the bookbag with locked_update_bookbag, before _persist_acceptance reads them.
  • src/entire_review.py: renames qodo_replacement to entire_replacement. The CLI call changes from ["entire", "review", "--base", base, "--format", "text"] to [entire_path, "review", "--profile", "general", "--base", base].
  • review_packet.py::_sensor: normalizes findings when it is a list rather than an int.
  • Docs describing these features:
    • docs/setup.md: new section "Onboarding a new target repo".
    • docs/pipeline-explainer.md: Stage 7 rewritten to cover the CRITICAL veto, ENTIRE_HIGH_STRICT and --profile general.
    • campus.md: Entire wording updated for the veto, --profile general and the Qodo removal.
    • project_verify.yaml: the compile-step comment names onboard and a2a_bridge again. The command itself is the same as on 1nzr: reorganise flat root into src/{bridge,core,crew,review,sensors,tools} #152.

Known open review findings (NOT fixed yet)

  • The ENTIRE veto only sets adversarial_review. It does not reject canonical_packet or stop grading and PR publication.
  • conductor.py ignores locked_update_bookbag returning None on lock timeout, so acceptance proceeds without the veto.
  • onboard.py: the slug-vs-URL guard skips the online commit lookup for owner/repo sources.
  • onboard.py: existing lock entries are reused by basename alone.
  • onboard.py: the raw-text scan's setdefault with ref="HEAD" drops explicit [tool.uv.sources] pins.
  • onboard.py: _envit_store_path is not verified against the lock commit.
  • onboard.py: the generated yaml/json validate checks end in || true.
  • onboard.py: _collect_evidence always marks package_manager, so --require-evidence is a no-op.

Workflow note

.github/workflows/ci.yml lists onboard.py a2a_bridge.py in the ruff CORE list. That matches this branch but not #152 after the split. It was not changed because the integration token lacks workflow scope.

Verification

No tests were run for this split.

Summary by Sourcery

Add repository onboarding and A2A automation while enforcing critical pre-merge findings across synchronous and asynchronous review paths.

New Features:

  • Add repo-agnostic onboarding that generates ICM context, envit configuration, and verification artifacts from target-repository evidence.
  • Add an A2A bridge for dispatching tasks to OpenHands, polling runs, and retrieving agent responses.
  • Expose onboarding through the issue bridge CLI with report-only, write, offline, cloning, and evidence-gating options.

Bug Fixes:

  • Make CRITICAL Entire findings veto synchronous reviews and optionally escalate HIGH findings with ENTIRE_HIGH_STRICT.
  • Persist async Entire findings and status before conductor acceptance evaluates the bookbag.
  • Use the non-interactive Entire general profile and normalize sensor finding counts across input shapes.

Enhancements:

  • Update Entire integration terminology and documentation to describe veto behavior, general-profile execution, and removal of Qodo references.

Documentation:

  • Document onboarding workflows and generated artifacts.
  • Revise pipeline and campus documentation for Entire sensor enforcement and onboarding behavior.

Tests:

  • Add coverage for the A2A bridge and onboarding generation workflows.
  • Update sync Entire sensor tests to expect CRITICAL findings to fail review.

RetriggerView in GreptileConfidence Score: 0/5

Do not merge until onboarding writes stay inside the checkout and the broken refresh, verification, and run-result paths are fixed.

Findings

  1. P1 Security Repo links overwrite outside files ▶
  2. P1 Custom settings disappear on refresh ▶
  3. P1 Generated Python checks cannot run ▶
  4. P1 Backslashes break context refreshes ▶
  5. P1 Existing runs appear missing ▶
  6. P1 Cancelled tasks report success ▶
  7. P2 Answers lose later text parts ▶
  8. P2 Refreshes duplicate document titles ▶
  9. P2 Missing credentials reach the server ▶
Fix with agent prompt
### Issue 1
onboard.py:1494-1495
If a target checkout contains a `project_verify.yaml` symlink to a writable file outside the checkout, `--write` follows it and replaces that file with generated YAML. Reject symlinks and paths that resolve outside the chosen output directory before writing any artifacts, including paths beneath `.envit`.

**How this was verified:** The default output is the target checkout, and the artifact writes follow its paths without checking where they resolve.

### Issue 2
onboard.py:1494-1495
`--write` replaces existing `project_verify.yaml` and `envit.json` files instead of preserving their hand-written settings. Custom verify commands disappear, and dependency skill paths and picks become generated defaults. This contradicts the documented promise that reruns preserve hand-written content. Merge the generated settings into existing files, or require a separate overwrite option.

### Issue 3
onboard.py:1389-1392
`generate_project_verify` emits `pytest` and `ruff` commands, but school-core's fixed `verifyShell` provides neither tool. The normal bridge path filters their “command not found” failures as infrastructure noise. These commands also suppress the Python compile fallback because they already claim the `python` language. Provision the tools and their dependencies, or retain a Python check the shell can run.

### Issue 4
onboard.py:1466
`pattern.sub` reads `new_block` as a replacement template, not literal text. The block includes README prose and raw `package.json` commands. If either contains `\d` or `\s`, the second onboarding run fails with a bad-escape error. Other escapes can silently change the output. Use a callable replacement to preserve the generated text exactly.

```suggestion
        return pattern.sub(lambda _: new_block, text, count=1)
```

### Issue 5
a2a_bridge.py:102-104
`poll_run_status` searches only five runs and immediately raises if the requested ID is absent. An older run requested with `--poll-only` or `--read-only`, or a running task displaced by newer runs, can exist outside that page. Fetch the requested run directly or search later pages before declaring it missing.

### Issue 6
a2a_bridge.py:230-232
`poll_run_status` returns `CANCELLED` runs, but the full pipeline rejects only `FAILED`. A cancelled run can therefore print an earlier assistant message and exit successfully, making unfinished work look complete. Require `COMPLETED` before reading a successful answer and return a failure for cancellation.

```suggestion
        if run.get("status") != "COMPLETED":
            print(
                f"Run {run.get('status')}: {run.get('error_detail', 'unknown')}",
                file=sys.stderr,
            )
            return 1
```

### Issue 7
a2a_bridge.py:155-157
`extract_last_assistant_text` returns from inside the content loop, so a message with multiple text parts loses everything after the first part. Join all text parts from the latest assistant message before returning, and add a multipart test.

### Issue 8
onboard.py:1461-1466
The generators put each title before `BEGIN ONBOARD-GENERATED`. This replacement leaves the old title in place, then inserts the entire new document with another title. Every refresh adds one more heading. Put generated titles inside the fence or replace only the fenced portion of the new document, and test several reruns for unchanged title counts.

### Issue 9
a2a_bridge.py:214-215
`main` never calls `require_secrets`, so the CLI sends requests with empty credentials when setup is incomplete. Users get a remote request error instead of being told which credentials are missing. Call the check before making requests and add a CLI test that confirms missing credentials stop before dispatch.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds onboarding and a standalone OpenHands bridge. It also changes Entire review arguments, applies sync review vetoes, saves async findings before acceptance, and accepts list-shaped sensor findings.

  • Operators can generate agent guidance and verification commands for a target repo.
  • Critical Entire findings can now stop a review from passing.
  • A2A bridge sends tasks to OpenHands and returns assistant answers.
  • The Entire sensor uses a fixed profile and labels its result as Entire.
  • Review packets count raw findings lists as well as numeric totals.

Reviews (1) · Last reviewed commit: "feat(1nzr): reapply behaviour changes sp..." · Reviewed by Greptile

Restores, on top of the #152 revert (bb1b968), the behaviour changes that
were bundled into the reorg. The resulting tree is identical to
school/1nzr-reorg at a2afda4.

- onboard.py + tests/test_onboard.py
- a2a_bridge.py + tests/test_a2a_bridge.py
- issue_bridge.py: --onboard CLI passthrough
- issue_bridge.py: ENTIRE CRITICAL/HIGH veto on the sync path (+ test)
- conductor.py: bookbag persistence of entire_findings/entire_status
- src/entire_review.py: entire_replacement rename, --profile general,
  --format text dropped
- review_packet.py: _sensor list/int findings normalization
- docs/setup.md, docs/pipeline-explainer.md, campus.md, project_verify.yaml
  comment describing the above
@sourcery-ai

sourcery-ai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This stacked behavior PR adds evidence-based repository onboarding and an independent OpenHands A2A bridge, exposes onboarding through the issue bridge, and strengthens Entire integration by enforcing CRITICAL vetoes—with optional HIGH strictness—across sync and async review flows. It also updates generated verification/context documentation and normalizes sensor result handling.

Sequence diagram for onboarding a target repository

sequenceDiagram
    actor Operator
    participant IssueBridge
    participant Onboard
    participant TargetRepo
    participant GitHub
    participant Filesystem

    Operator->>IssueBridge: --onboard --repo owner/repo
    IssueBridge->>Onboard: main(onboard_argv)
    Onboard->>TargetRepo: clone_repo(repo)
    Onboard->>TargetRepo: _collect_evidence(root, info)
    opt online metadata
        Onboard->>GitHub: _github_repo_info(slug)
        GitHub-->>Onboard: repository metadata
    end
    Onboard->>Onboard: generate_claude_md()
    Onboard->>Onboard: generate_context_md()
    Onboard->>Onboard: generate_project_verify()
    opt --write
        Onboard->>Filesystem: write_artifacts()
        Filesystem-->>Onboard: generated L0-L4 artifacts
    end
    Onboard-->>IssueBridge: report and exit status
    IssueBridge-->>Operator: onboarding report
Loading

Sequence diagram for A2A task dispatch and answer retrieval

sequenceDiagram
    actor Operator
    participant A2ABridge
    participant OpenHandsWebhook
    participant AutomationAPI
    participant AgentServer

    Operator->>A2ABridge: --task task_text
    A2ABridge->>A2ABridge: require_secrets()
    A2ABridge->>OpenHandsWebhook: dispatch(webhook_url, secret, task)
    OpenHandsWebhook-->>A2ABridge: run_id
    loop until terminal status
        A2ABridge->>AutomationAPI: poll_run_status(..., run_id)
        AutomationAPI-->>A2ABridge: run status
    end
    A2ABridge->>AgentServer: read_agent_answer(..., conversation_id)
    AgentServer-->>A2ABridge: assistant answer
    A2ABridge-->>Operator: print answer
Loading

Sequence diagram for Entire veto in sync review

sequenceDiagram
    participant IssueBridge
    participant Entire
    participant AdversarialReview
    participant ReviewPacket

    IssueBridge->>Entire: run_entire_review(worktree_path, base_branch)
    Entire->>Entire: subprocess.run(entire_path, review, --profile general, --base)
    Entire-->>IssueBridge: findings
    alt CRITICAL finding or ENTIRE_HIGH_STRICT=1 with HIGH finding
        IssueBridge->>AdversarialReview: append entire_finding
        IssueBridge->>AdversarialReview: set verdict FAIL
        IssueBridge->>AdversarialReview: set score 0.0
    end
    IssueBridge->>ReviewPacket: create(...)
    ReviewPacket->>ReviewPacket: _sensor(value)
    ReviewPacket-->>IssueBridge: normalized finding count
Loading

Sequence diagram for async Entire findings and acceptance

sequenceDiagram
    participant Conductor
    participant Entire
    participant Bookbag
    participant AcceptanceGate

    Conductor->>Entire: run_entire_review(...)
    Entire-->>Conductor: entire_findings, entire_status
    Conductor->>Bookbag: locked_update_bookbag(..., entire_findings, entire_status)
    Bookbag-->>Conductor: persisted sensor result
    Conductor->>AcceptanceGate: _persist_acceptance(...)
    AcceptanceGate->>Bookbag: read_bookbag(...)
    Bookbag-->>AcceptanceGate: entire findings and status
    alt CRITICAL finding
        AcceptanceGate-->>Conductor: accepted = false
    else ENTIRE_HIGH_STRICT=1 and HIGH finding
        AcceptanceGate-->>Conductor: accepted = false
    else no veto finding
        AcceptanceGate-->>Conductor: evaluate judge verdicts and scores
    end
Loading

File-Level Changes

Change Details Files
Add evidence-driven repository onboarding that generates agent context, dependency manifests/locks, verification commands, and exposes the workflow through the issue bridge.
  • Inspect local or cloned repositories and derive metadata, languages, paths, tooling, dependencies, and routing from repository evidence.
  • Generate or refresh L0–L2 documents, envit artifacts, dependency symlinks, and project_verify.yaml with report-only, offline, cloning, and evidence-gated modes.
  • Add extensive onboarding generation, locking, rendering, and CLI test coverage.
onboard.py
issue_bridge.py
tests/test_onboard.py
docs/setup.md
project_verify.yaml
Add a standalone A2A integration for dispatching OpenHands tasks, polling automation runs, and retrieving assistant responses.
  • Sign webhook requests with HMAC and require runtime-provided secrets.
  • Implement terminal-status polling, timeout/error handling, and conversation event extraction.
  • Cover dispatch, polling, timeout, route, response, and secret behavior with an in-process HTTP test server.
a2a_bridge.py
tests/test_a2a_bridge.py
Turn Entire critical findings into review vetoes across synchronous and asynchronous paths, while supporting optional HIGH escalation.
  • Inject CRITICAL findings, or HIGH findings under ENTIRE_HIGH_STRICT=1, into the sync adversarial review and force FAIL with score zero.
  • Persist Entire findings and status to the conductor bookbag before acceptance evaluation.
  • Update the Entire invocation to use the resolved executable and non-interactive general profile.
issue_bridge.py
conductor.py
src/entire_review.py
tests/test_issue_bridge.py
docs/pipeline-explainer.md
campus.md
Harden review packet sensor normalization and update the Entire result contract.
  • Accept list-, tuple-, set-, and integer-shaped finding values when building review packets.
  • Rename qodo_replacement metadata to entire_replacement and update CLI reporting.
review_packet.py
src/entire_review.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 26 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="issue_bridge.py" line_range="2694-2713" />
<code_context>
+            _entire_critical = [
</code_context>
<issue_to_address>
**Critical findings do not reject runs**

When a sync review has an authoritative canonical packet and Entire reports a CRITICAL finding or a strict HIGH finding, the sync path changes only `adversarial_review`, leaving the accepted `canonical_packet` unchanged. Later grading and publication use that packet, so the run continues despite the finding.

Update or invalidate the authoritative packet when the sync finding requires rejection, so downstream grading and publication reject the run.
</issue_to_address>

### Comment 2
<location path="conductor.py" line_range="658-664" />
<code_context>
+        # CRITICAL entire finding could not veto acceptance. Write them here,
+        # repo-namespaced to match read_bookbag/_persist_acceptance.
+        try:
+            locked_update_bookbag(
+                leaf.bead,
+                repo=target_repo or "__global__",
+                lock_timeout=10.0,
+                entire_findings=entire_findings,
+                entire_status=entire_status,
+            )
+        except Exception as e:  # noqa: BLE001
+            print(f"  ⚠ Entire review result persist failed: {e}")
</code_context>
<issue_to_address>
**Acceptance misses sensor findings**

When the bookbag lock cannot be acquired within the timeout while the Entire result is being persisted, `locked_update_bookbag` returns `None` without raising, and the caller proceeds to `_persist_acceptance` with the Entire findings unwritten. The acceptance gate can therefore accept work with a CRITICAL finding.

Check the update result and prevent acceptance from proceeding unless the Entire findings were persisted or otherwise included in the gate.
</issue_to_address>

### Comment 3
<location path="a2a_bridge.py" line_range="215" />
<code_context>
+            parser.error("--task is required for full pipeline")
+
+        print(f"Dispatching task: {args.task}", file=sys.stderr)
+        resp = dispatch(WEBHOOK_URL, WEBHOOK_SECRET, args.task)
+        print(f"Dispatch response: {resp}", file=sys.stderr)
+
</code_context>
<issue_to_address>
**Missing credentials reach requests**

When either `A2A_WEBHOOK_SECRET` or `A2A_SESSION_KEY` is unset, `main` does not call `require_secrets` before dispatch or polling, so the module-level empty values reach the requests. The bridge reports an authentication failure instead of the documented configuration error, and an endpoint accepting empty credentials receives unauthenticated requests.

Call `require_secrets` before dispatching or polling.

Also at `a2a_bridge.py:33-34`, `a2a_bridge.py:216`, `a2a_bridge.py:226`, `a2a_bridge.py:248-249`.
</issue_to_address>

### Comment 4
<location path="onboard.py" line_range="516" />
<code_context>
+    readme = ""
+    for name in ("README.md", "README.rst", "README.txt", "readme.md"):
+        if name in files:
+            readme = _read_text(root / name)
+            break
+    if not readme:
</code_context>
<issue_to_address>
**Repository symlinks escape checkout**

When an onboarded repository contains a symlink at a read or generated-output path and the operator runs onboarding, a malicious checkout can make `_read_text` follow a tracked README symlink to a host file and copy its contents into generated artifacts; symlinks at output paths such as `CLAUDE.md` also make `write_text` overwrite files outside the checkout. Reject symlinks and verify resolved paths stay under the intended root before reading or writing.

Reject symlinks and ensure each resolved input and output path remains inside its intended checkout or output root.

Also at `onboard.py:1486`.
</issue_to_address>

### Comment 5
<location path="onboard.py" line_range="1585" />
<code_context>
+    elif clone:
+        from src.core.repo_reader import clone_repo
+        repo_path = clone_repo(repo)
+        slug = repo
+    else:
+        raise OnboardError(
</code_context>
<issue_to_address>
**Onboarding triggers arbitrary requests**

When the selected repository has a JavaScript manifest and a lockfile referencing an attacker-controlled or internal URL, `onboard_repo` calls `clone_repo`, which automatically installs JavaScript dependencies for repositories with `package.json`; that installer allows network access and consumes repository-controlled lockfile URLs. A malicious repository can therefore make onboarding contact attacker-chosen or internal hosts. Skip dependency installation during evidence collection or restrict its network destinations.

Inspect repositories without installing dependencies, or restrict dependency installation to explicitly approved hosts.
</issue_to_address>

### Comment 6
<location path="a2a_bridge.py" line_range="28-31" />
<code_context>
+    "http://localhost:18001/api/automation/v1/events/"
+    "46d09c5c-7d7d-585f-8c37-a6b45a3de738/hermes-test"
+)
+API_BASE = "http://localhost:18001/api/automation"
+AUTOMATION_ID = "5e872472-b4cb-43f0-a3f4-30b57fcc4203"
+ORG_ID = "46d09c5c-7d7d-585f-8c37-a6b45a3de738"
+AGENT_SERVER_URL = "http://localhost:18000"
+
+WEBHOOK_SECRET = os.environ.get("A2A_WEBHOOK_SECRET", "")
</code_context>
<issue_to_address>
**Session key sent over HTTP**

When an untrusted local process can receive traffic on either configured port, `poll_run_status` and `read_agent_answer` send `A2A_SESSION_KEY` to localhost services over plaintext HTTP; a local process that captures the configured port can read the key and reuse it to access the API. Use an authenticated encrypted transport or otherwise ensure the local endpoint cannot be impersonated.

Use TLS with endpoint verification for these requests, or restrict the service and client to an authenticated local transport that prevents port impersonation.
</issue_to_address>

### Comment 7
<location path="onboard.py" line_range="940" />
<code_context>
+    lines.append("## What this repo is")
+    lines.append("")
+    if ev.readme_summary:
+        lines.append(ev.readme_summary)
+    elif info.description:
+        lines.append(info.description)
</code_context>
<issue_to_address>
**Repository text becomes agent instructions**

When a target repository's README contains instructions aimed at agents and an agent later consumes the generated `CLAUDE.md`, `onboard_repo` copies the target README summary directly into generated `CLAUDE.md`; a malicious repository owner can place prompt-injection instructions in that prose, which downstream coding agents then consume as project guidance. Treat the copied text as untrusted or keep it out of instruction-bearing content.

Keep repository-authored prose explicitly isolated and labeled as untrusted reference material rather than embedding it as agent guidance.
</issue_to_address>

### Comment 8
<location path="onboard.py" line_range="821" />
<code_context>
+        match = re.match(r"^(npm|pnpm|yarn|bun)@", str(declared))
+        if match:
+            ev.package_manager = match.group(1)
+    ev.generated.add("package_manager")
+
+    # package.json scripts (per sub-project)
</code_context>
<issue_to_address>
**Empty checkouts pass evidence gate**

When `--require-evidence` is used on an empty or metadata-free checkout, `_collect_evidence` unconditionally adds `package_manager` to `generated`, so `Evidence.detected` is true even when no repository facts were found. Onboarding writes boilerplate instead of refusing the checkout.

Add `package_manager` to `generated` only when evidence supports it, and ensure `detected` reflects actual repository facts.
</issue_to_address>

### Comment 9
<location path="onboard.py" line_range="1495" />
<code_context>
+    written.append(str(out_root / L1_FILE))
+
+    (out_root / PROJECT_VERIFY).write_text(
+        generate_project_verify(ev, info, stamp, sha), encoding="utf-8")
+    written.append(str(out_root / PROJECT_VERIFY))
+
</code_context>
<issue_to_address>
**Custom verification settings disappear**

When the target already has hand-maintained verification commands or envit settings, `write_artifacts` overwrites `project_verify.yaml` and envit files with generated content. Custom checks or curated settings that the generator does not rediscover are lost.

Preserve existing hand-maintained settings when writing generated verification and envit files.

Also at `onboard.py:1517-1520`.
</issue_to_address>

### Comment 10
<location path="onboard.py" line_range="1515-1531" />
<code_context>
+    written.append(str(agents_path))
+
+    if ev.dependency_repos:
+        manifest = build_envit_manifest(ev, info)
+        (out_root / ENVIT_MANIFEST).write_text(_json_dump(manifest), encoding="utf-8")
+        written.append(str(out_root / ENVIT_MANIFEST))
+        lock = build_envit_lock(ev, info, out_root)
+        (out_root / ENVIT_LOCK).write_text(_json_dump(lock), encoding="utf-8")
+        written.append(str(out_root / ENVIT_LOCK))
+        repos_root = envit_dir / "repos"
+        repos_root.mkdir(parents=True, exist_ok=True)
+        for dep in ev.envit_entries:
+            link = repos_root / dep["name"]
+            if link.is_symlink() or link.exists():
+                continue
+            store = _envit_store_path(dep)
+            if store is not None:
+                link.symlink_to(store, target_is_directory=True)
+                written.append(str(link))
+
+    return written
+
</code_context>
<issue_to_address>
**Removed dependencies remain declared**

When a later onboarding run finds no dependencies, or previously declared dependencies have been removed, `write_artifacts` skips writing empty envit declarations and does not remove obsolete declarations or repo symlinks. Existing files therefore continue to describe dependencies that the current evidence no longer finds.

Write empty envit declarations when there are no dependencies and remove obsolete generated declarations and symlinks.
</issue_to_address>

### Comment 11
<location path="onboard.py" line_range="1379-1378" />
<code_context>
+#!/usr/bin/env python3
+"""A2A bridge: dispatch → poll → read for OpenHands automation webhook.
+
+Stdlib only. CLI:
+    python3 a2a_bridge.py --task "<text>"
</code_context>
<issue_to_address>
**Generated checks lack required tools**

When the target declares scripts or Python checks whose tools or dependencies are not provisioned in the hermetic verification shell, `generate_project_verify` emits commands that the shell cannot run because their tools or dependencies are unavailable. The verify gate reports infrastructure failures instead of checking the target.

Only emit checks whose tools and dependencies are provisioned, or provision them in the verification shell.

Also at `onboard.py:1376-1378`, `onboard.py:1390-1394`.
</issue_to_address>

### Comment 12
<location path="onboard.py" line_range="185" />
<code_context>
+    "yaml": [
+        {"name": "yaml-validate",
+         "cmd": "python3 -c \"import yaml, sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]\" "
+                "$(find . -name '*.yaml' -o -name '*.yml' | grep -v node_modules | head -5) 2>&1 || true",
+         "cwd": ".", "languages": "yaml"},
+    ],
</code_context>
<issue_to_address>
**Malformed files pass validation**

When a generated YAML or JSON validator selects a malformed file, the generated validation commands end in `|| true`, converting parse failures to success, so `verify_gate` reports invalid files as passing checks.

Remove the success-forcing fallback so parser failures produce a failing check.

Also at `onboard.py:186`, `onboard.py:192`.
</issue_to_address>

### Comment 13
<location path="src/entire_review.py" line_range="202" />
<code_context>
     try:
         result = subprocess.run(
-            ["entire", "review", "--base", base_branch, "--format", "text"],
+            [entire_path, "review", "--profile", "general", "--base", base_branch],
             cwd=worktree_path,
             capture_output=True,
</code_context>
<issue_to_address>
**Failed sensor appears to pass**

When the Entire CLI exits nonzero and its output contains no parseable findings, `run_entire_review` does not check the subprocess return code; when the CLI exits nonzero without parseable findings, the later severity calculation returns `status: "pass"` with `error: None`, so operators see a successful review despite the sensor failing.

Treat a nonzero return code or timeout as `status: "error"` and expose the failure instead of deriving a pass from an empty findings list.
</issue_to_address>

### Comment 14
<location path="a2a_bridge.py" line_range="103-104" />
<code_context>
+    # Route contract (router.py:104 prefix="/v1", router.py:690 route,
+    # mounted at app.py:300 under /api/automation) — NO "automations" segment.
+    url = (
+        f"{api_base}/v1/{automation_id}/runs?limit=5"
+    )
+    headers = {
+        "X-Session-API-Key": session_key,
</code_context>
<issue_to_address>
**Older runs are reported missing**

When more than five newer runs appear before the requested run in the list response, `poll_run_status` searches only the five runs returned by the list request and raises when the requested ID is absent. The bridge stops polling even though the run is still active or completed.

Use pagination or another lookup that can find the requested run beyond the five newest results.
</issue_to_address>

### Comment 15
<location path="onboard.py" line_range="1310-1317" />
<code_context>
+                }
+
+    # 2. git ls-remote (only for non-HEAD refs; HEAD resolves locally below)
+    url = source
+    if "github.com/" in source and not source.startswith(("http", "git@")):
+        url = f"https://github.com/{source}.git"
+    if url.startswith("http") or url.startswith("git@"):
+        want = ref if ref != "HEAD" else "HEAD"
+        spec = f"refs/tags/{want}" if (ref != "HEAD" and not _looks_like_sha(ref)) else want
+        for candidate in (spec, want):
+            out = _gh(["api", f"repos/{source}/commits/{candidate}"], timeout=20)
+            if out:
+                try:
</code_context>
<issue_to_address>
**Slug dependencies remain unpinned**

When a dependency uses the `owner/repo` source form with a non-SHA ref and no reusable lock entry, `_resolve_lock_entry` leaves `url` as the slug, so its URL-scheme guard skips the GitHub API lookup. The generated lock has a null commit and marks the dependency unresolved even when the repository is available.

Resolve `owner/repo` sources through the GitHub API as well as URL-form sources.
</issue_to_address>

### Comment 16
<location path="onboard.py" line_range="1317" />
<code_context>
+        want = ref if ref != "HEAD" else "HEAD"
+        spec = f"refs/tags/{want}" if (ref != "HEAD" and not _looks_like_sha(ref)) else want
+        for candidate in (spec, want):
+            out = _gh(["api", f"repos/{source}/commits/{candidate}"], timeout=20)
+            if out:
+                try:
</code_context>
<issue_to_address>
**Offline runs still contact GitHub**

When `--offline --write` is used with an unresolved dependency that has no reusable lock entry, `build_envit_lock` calls `_resolve_lock_entry`, which invokes `_gh` without honoring offline mode. Onboarding therefore makes GitHub requests and can incur repeated network timeouts.

Thread offline mode into lock resolution and avoid network lookups when it is enabled.

Also at `onboard.py:1594`.
</issue_to_address>

### Comment 17
<location path="onboard.py" line_range="1301-1302" />
<code_context>
+        except (OSError, json.JSONDecodeError):
+            continue
+        for locked in data.get("repos") or []:
+            if str(locked.get("name")) == name or locked.get("source", "").endswith(source):
+                return {
+                    "name": name,
+                    "source": str(locked.get("source") or source),
</code_context>
<issue_to_address>
**Wrong repository lock entry reused**

When a lockfile contains another repository with the same basename as the dependency, `_resolve_lock_entry` treats a matching basename as the same repository and can copy the other owner’s commit into this dependency’s lock entry. The generated lock then associates a commit with the wrong source.

Match reusable lock entries by the full repository source rather than basename alone.

Also at `onboard.py:1524-1525`.
</issue_to_address>

### Comment 18
<location path="onboard.py" line_range="684" />
<code_context>
+        slug = slug.rstrip("/")
+        if "/" not in slug or slug.count("/") != 1:
+            return
+        found.setdefault(slug, {"source": slug, "ref": ref, "via": via})
+
+    # 1a. pyproject git sources
</code_context>
<issue_to_address>
**Declared dependency pins are discarded**

When a pyproject GitHub dependency has an explicit uv `rev` or `tag`, the raw-text scan inserts the dependency with `ref="HEAD"` before the structured uv-source scan reads its explicit revision or tag. `_add` keeps the first entry, so the generated manifest and lock can lose the declared pin.

Give structured dependency declarations precedence over raw-text discoveries, or update an existing entry when a more specific ref is found.

Also at `onboard.py:691-703`.
</issue_to_address>

### Comment 19
<location path="onboard.py" line_range="1466" />
<code_context>
+        re.S,
+    )
+    if pattern.search(text):
+        return pattern.sub(new_block, text, count=1)
+    return text.rstrip("\n") + "\n\n" + new_block
+
</code_context>
<issue_to_address>
**Generated headings accumulate**

When onboarding writes generated files a second time or later, `_replace_generated_block` replaces only the fenced section, but its replacement includes content before the marker, such as the document heading. Each rerun leaves the old heading and inserts another.

Replace only the generated section, or pass a replacement that excludes content before its marker.

Also at `onboard.py:1486`, `onboard.py:1491`, `onboard.py:1510`.
</issue_to_address>

### Comment 20
<location path="onboard.py" line_range="1466" />
<code_context>
+        re.S,
+    )
+    if pattern.search(text):
+        return pattern.sub(new_block, text, count=1)
+    return text.rstrip("\n") + "\n\n" + new_block
+
</code_context>
<issue_to_address>
**Backslashes break generated refresh**

When a rerun replaces a generated block whose repo-derived content contains a backslash escape or group-reference sequence, `re.sub` interprets `new_block` as a replacement template, so a backslash sequence from repo-derived text is parsed as a replacement escape or group reference. A generated README summary containing such a sequence raises an exception on refresh instead of updating the file.

Pass a callable replacement such as `lambda _: new_block` so the generated text is inserted literally.
</issue_to_address>

### Comment 21
<location path="onboard.py" line_range="179" />
<code_context>
+         "languages": "typescript"},
+    ],
+    "javascript": [
+        {"name": "node-check", "cmd": "node --check \"$(find . -name '*.js' -not -path './node_modules/*' | head -1)\" 2>&1",
+         "cwd": ".", "languages": "javascript"},
+    ],
</code_context>
<issue_to_address>
**JavaScript fallback misses source files**

When a repo has `.mjs` or `.jsx` files but no `.js` file and no declared gate command, the JavaScript fallback searches only for `*.js`, so its filename substitution is empty for these repos and `node --check` fails without checking the source.

Include the detected JavaScript extensions in the fallback file search.

Also at `onboard.py:109`, `onboard.py:180`.
</issue_to_address>

### Comment 22
<location path="onboard.py" line_range="1675" />
<code_context>
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="onboard",
+        description="Generate ICM L0-L4 context + envit + project_verify for a target repo.",
</code_context>
<issue_to_address>
**Documented dry-run flag is rejected**

When a caller runs the documented `--dry-run` command, the documented `onboard --dry-run` invocation reaches `argparse` without a registered `--dry-run` option and exits with a usage error, so callers cannot use the advertised command.

Register `--dry-run` as an alias for the default report-only behavior or remove it from the usage documentation.
</issue_to_address>

### Comment 23
<location path="onboard.py" line_range="1579" />
<code_context>
+    sha = short_sha()
+
+    local = Path(repo).expanduser()
+    if local.exists() and (local / ".git").exists():
+        repo_path = local.resolve()
+        slug = _remote_slug(repo_path) or repo
</code_context>
<issue_to_address>
**Local worktrees are rejected**

When `--repo` points to a Git worktree and `--no-clone` is used, or cloning is otherwise selected, `onboard_repo` recognizes a local checkout only when `.git` is a directory. In a Git worktree `.git` is a file, so `--no-clone` rejects the checkout and the default clone path treats the local path as a GitHub slug; onboarding a valid local worktree fails.

Recognize a checkout by running `git -C <path> rev-parse --show-toplevel` or by accepting either a `.git` file or directory.
</issue_to_address>

### Comment 24
<location path="a2a_bridge.py" line_range="157" />
<code_context>
+        if isinstance(content, list):
+            for part in content:
+                if part.get("type") == "text":
+                    return part.get("text")
+    return None
+
</code_context>
<issue_to_address>
**Assistant answers lose later text**

When the newest assistant event contains more than one text part, `extract_last_assistant_text` returns on the first text part of the newest assistant event, so when that event contains multiple text parts the bridge prints only the first and truncates the answer.

Collect and join all text parts from the newest assistant event before returning the answer.
</issue_to_address>

### Comment 25
<location path="tests/test_a2a_bridge.py" line_range="168" />
<code_context>
+#!/usr/bin/env python3
+"""A2A bridge: dispatch → poll → read for OpenHands automation webhook.
+
+Stdlib only. CLI:
+    python3 a2a_bridge.py --task "<text>"
</code_context>
<issue_to_address>
**Wrong run can pass polling test**

When A poll response contains a terminal run with an ID different from the requested run ID, `test_returns_terminal_run` checks status and conversation ID, not that the returned run has the requested ID. Since the fake response contains only the requested `run-1`, a poller that returns any terminal run—even when the requested ID is absent—still passes this test.

Add a response containing a different terminal run and assert that polling reports the requested run as missing rather than returning that run.
</issue_to_address>

### Comment 26
<location path="onboard.py" line_range="691-692" />
<code_context>
+        text = _read_text(root / manifest)
+        if not text:
+            continue
+        for match in re.finditer(r"github\.com[/:]([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", text):
+            _add(match.group(1), via=f"{manifest}")
+        if tomllib is not None:
+            try:
</code_context>
<issue_to_address>
**Metadata URLs become dependencies**

When a scanned file contains a GitHub URL that is not a dependency declaration, such as a project homepage URL or comment, the raw GitHub URL scan treats any matching URL in a `pyproject.toml` or requirements file as a dependency, even when it is only a repository/homepage link or comment. `_infer_dependencies` then writes that repo into the generated envit manifest as a dependency, polluting the target’s dependency rows with unrelated repos or itself.

Only add URLs parsed from dependency declarations and exclude the target repository itself.

Also at `onboard.py:710-712`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 26 findings to address first, and the bridge never calls require_secrets, so it can send an empty-key HMAC and blank session header; if the local service accepts those credentials, the request can launch an automation run and its downstream actions. Reverting removes the bridge, but it would not undo runs or edits already triggered.

Blocking findings: issue_bridge.py:2713, conductor.py:664, a2a_bridge.py:215, onboard.py:516, onboard.py:1585, and 21 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread issue_bridge.py
Comment on lines +2694 to +2713
_entire_critical = [
f for f in ((entire_review or {}).get("findings") or [])
if str((f or {}).get("severity", "")).upper() == "CRITICAL"
]
if not _entire_critical and os.getenv("ENTIRE_HIGH_STRICT") == "1":
_entire_critical = [
f for f in ((entire_review or {}).get("findings") or [])
if str((f or {}).get("severity", "")).upper() == "HIGH"
]
if _entire_critical:
for f in _entire_critical:
adversarial_review.setdefault("findings", []).append({
"section": "pre-merge-sensor",
"issue_class": "entire_finding",
"severity": "CRITICAL",
"citation": f"{f.get('file')}:{f.get('line')}",
"description": (f.get("message") or "")[:500],
"suggestion": "Fix the issue the pre-merge sensor flagged.",
})
adversarial_review["verdict"] = "FAIL"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Critical findings do not reject runs

When a sync review has an authoritative canonical packet and Entire reports a CRITICAL finding or a strict HIGH finding, the sync path changes only adversarial_review, leaving the accepted canonical_packet unchanged. Later grading and publication use that packet, so the run continues despite the finding.

Update or invalidate the authoritative packet when the sync finding requires rejection, so downstream grading and publication reject the run.

Prompt for AI agents
In `issue_bridge.py` at lines 2694-2713:

**Critical findings do not reject runs**

When a sync review has an authoritative canonical packet and Entire reports a CRITICAL finding or a strict HIGH finding, the sync path changes only `adversarial_review`, leaving the accepted `canonical_packet` unchanged. Later grading and publication use that packet, so the run continues despite the finding.

Update or invalidate the authoritative packet when the sync finding requires rejection, so downstream grading and publication reject the run.

Comment thread conductor.py
Comment on lines +658 to +664
locked_update_bookbag(
leaf.bead,
repo=target_repo or "__global__",
lock_timeout=10.0,
entire_findings=entire_findings,
entire_status=entire_status,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Acceptance misses sensor findings

When the bookbag lock cannot be acquired within the timeout while the Entire result is being persisted, locked_update_bookbag returns None without raising, and the caller proceeds to _persist_acceptance with the Entire findings unwritten. The acceptance gate can therefore accept work with a CRITICAL finding.

Check the update result and prevent acceptance from proceeding unless the Entire findings were persisted or otherwise included in the gate.

Prompt for AI agents
In `conductor.py` at lines 658-664:

**Acceptance misses sensor findings**

When the bookbag lock cannot be acquired within the timeout while the Entire result is being persisted, `locked_update_bookbag` returns `None` without raising, and the caller proceeds to `_persist_acceptance` with the Entire findings unwritten. The acceptance gate can therefore accept work with a CRITICAL finding.

Check the update result and prevent acceptance from proceeding unless the Entire findings were persisted or otherwise included in the gate.

Comment thread a2a_bridge.py
parser.error("--task is required for full pipeline")

print(f"Dispatching task: {args.task}", file=sys.stderr)
resp = dispatch(WEBHOOK_URL, WEBHOOK_SECRET, args.task)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Missing credentials reach requests

When either A2A_WEBHOOK_SECRET or A2A_SESSION_KEY is unset, main does not call require_secrets before dispatch or polling, so the module-level empty values reach the requests. The bridge reports an authentication failure instead of the documented configuration error, and an endpoint accepting empty credentials receives unauthenticated requests.

Call require_secrets before dispatching or polling.

Also at a2a_bridge.py:33-34, a2a_bridge.py:216, a2a_bridge.py:226, a2a_bridge.py:248-249.

Prompt for AI agents
In `a2a_bridge.py` at line 215:

**Missing credentials reach requests**

When either `A2A_WEBHOOK_SECRET` or `A2A_SESSION_KEY` is unset, `main` does not call `require_secrets` before dispatch or polling, so the module-level empty values reach the requests. The bridge reports an authentication failure instead of the documented configuration error, and an endpoint accepting empty credentials receives unauthenticated requests.

Call `require_secrets` before dispatching or polling.

Also at `a2a_bridge.py:33-34`, `a2a_bridge.py:216`, `a2a_bridge.py:226`, `a2a_bridge.py:248-249`.

Comment thread onboard.py
readme = ""
for name in ("README.md", "README.rst", "README.txt", "readme.md"):
if name in files:
readme = _read_text(root / name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Repository symlinks escape checkout

When an onboarded repository contains a symlink at a read or generated-output path and the operator runs onboarding, a malicious checkout can make _read_text follow a tracked README symlink to a host file and copy its contents into generated artifacts; symlinks at output paths such as CLAUDE.md also make write_text overwrite files outside the checkout. Reject symlinks and verify resolved paths stay under the intended root before reading or writing.

Reject symlinks and ensure each resolved input and output path remains inside its intended checkout or output root.

Also at onboard.py:1486.

Prompt for AI agents
In `onboard.py` at line 516:

**Repository symlinks escape checkout**

When an onboarded repository contains a symlink at a read or generated-output path and the operator runs onboarding, a malicious checkout can make `_read_text` follow a tracked README symlink to a host file and copy its contents into generated artifacts; symlinks at output paths such as `CLAUDE.md` also make `write_text` overwrite files outside the checkout. Reject symlinks and verify resolved paths stay under the intended root before reading or writing.

Reject symlinks and ensure each resolved input and output path remains inside its intended checkout or output root.

Also at `onboard.py:1486`.

Comment thread onboard.py
elif clone:
from src.core.repo_reader import clone_repo
repo_path = clone_repo(repo)
slug = repo

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Onboarding triggers arbitrary requests

When the selected repository has a JavaScript manifest and a lockfile referencing an attacker-controlled or internal URL, onboard_repo calls clone_repo, which automatically installs JavaScript dependencies for repositories with package.json; that installer allows network access and consumes repository-controlled lockfile URLs. A malicious repository can therefore make onboarding contact attacker-chosen or internal hosts. Skip dependency installation during evidence collection or restrict its network destinations.

Inspect repositories without installing dependencies, or restrict dependency installation to explicitly approved hosts.

Prompt for AI agents
In `onboard.py` at line 1585:

**Onboarding triggers arbitrary requests**

When the selected repository has a JavaScript manifest and a lockfile referencing an attacker-controlled or internal URL, `onboard_repo` calls `clone_repo`, which automatically installs JavaScript dependencies for repositories with `package.json`; that installer allows network access and consumes repository-controlled lockfile URLs. A malicious repository can therefore make onboarding contact attacker-chosen or internal hosts. Skip dependency installation during evidence collection or restrict its network destinations.

Inspect repositories without installing dependencies, or restrict dependency installation to explicitly approved hosts.

Comment thread onboard.py


def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Documented dry-run flag is rejected

When a caller runs the documented --dry-run command, the documented onboard --dry-run invocation reaches argparse without a registered --dry-run option and exits with a usage error, so callers cannot use the advertised command.

Register --dry-run as an alias for the default report-only behavior or remove it from the usage documentation.

Prompt for AI agents
In `onboard.py` at line 1675:

**Documented dry-run flag is rejected**

When a caller runs the documented `--dry-run` command, the documented `onboard --dry-run` invocation reaches `argparse` without a registered `--dry-run` option and exits with a usage error, so callers cannot use the advertised command.

Register `--dry-run` as an alias for the default report-only behavior or remove it from the usage documentation.

Comment thread onboard.py
sha = short_sha()

local = Path(repo).expanduser()
if local.exists() and (local / ".git").exists():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Local worktrees are rejected

When --repo points to a Git worktree and --no-clone is used, or cloning is otherwise selected, onboard_repo recognizes a local checkout only when .git is a directory. In a Git worktree .git is a file, so --no-clone rejects the checkout and the default clone path treats the local path as a GitHub slug; onboarding a valid local worktree fails.

Recognize a checkout by running git -C <path> rev-parse --show-toplevel or by accepting either a .git file or directory.

Prompt for AI agents
In `onboard.py` at line 1579:

**Local worktrees are rejected**

When `--repo` points to a Git worktree and `--no-clone` is used, or cloning is otherwise selected, `onboard_repo` recognizes a local checkout only when `.git` is a directory. In a Git worktree `.git` is a file, so `--no-clone` rejects the checkout and the default clone path treats the local path as a GitHub slug; onboarding a valid local worktree fails.

Recognize a checkout by running `git -C <path> rev-parse --show-toplevel` or by accepting either a `.git` file or directory.

Comment thread a2a_bridge.py
if isinstance(content, list):
for part in content:
if part.get("type") == "text":
return part.get("text")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Assistant answers lose later text

When the newest assistant event contains more than one text part, extract_last_assistant_text returns on the first text part of the newest assistant event, so when that event contains multiple text parts the bridge prints only the first and truncates the answer.

Collect and join all text parts from the newest assistant event before returning the answer.

Prompt for AI agents
In `a2a_bridge.py` at line 157:

**Assistant answers lose later text**

When the newest assistant event contains more than one text part, `extract_last_assistant_text` returns on the first text part of the newest assistant event, so when that event contains multiple text parts the bridge prints only the first and truncates the answer.

Collect and join all text parts from the newest assistant event before returning the answer.

Comment thread tests/test_a2a_bridge.py
)
self.assertEqual(run["status"], "COMPLETED")
self.assertEqual(run["conversation_id"], "conv-1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Wrong run can pass polling test

When A poll response contains a terminal run with an ID different from the requested run ID, test_returns_terminal_run checks status and conversation ID, not that the returned run has the requested ID. Since the fake response contains only the requested run-1, a poller that returns any terminal run—even when the requested ID is absent—still passes this test.

Add a response containing a different terminal run and assert that polling reports the requested run as missing rather than returning that run.

Prompt for AI agents
In `tests/test_a2a_bridge.py` at line 168:

**Wrong run can pass polling test**

When A poll response contains a terminal run with an ID different from the requested run ID, `test_returns_terminal_run` checks status and conversation ID, not that the returned run has the requested ID. Since the fake response contains only the requested `run-1`, a poller that returns any terminal run—even when the requested ID is absent—still passes this test.

Add a response containing a different terminal run and assert that polling reports the requested run as missing rather than returning that run.

Comment thread onboard.py
Comment on lines +691 to +692
for match in re.finditer(r"github\.com[/:]([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", text):
_add(match.group(1), via=f"{manifest}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Metadata URLs become dependencies

When a scanned file contains a GitHub URL that is not a dependency declaration, such as a project homepage URL or comment, the raw GitHub URL scan treats any matching URL in a pyproject.toml or requirements file as a dependency, even when it is only a repository/homepage link or comment. _infer_dependencies then writes that repo into the generated envit manifest as a dependency, polluting the target’s dependency rows with unrelated repos or itself.

Only add URLs parsed from dependency declarations and exclude the target repository itself.

Also at onboard.py:710-712.

Prompt for AI agents
In `onboard.py` at lines 691-692:

**Metadata URLs become dependencies**

When a scanned file contains a GitHub URL that is not a dependency declaration, such as a project homepage URL or comment, the raw GitHub URL scan treats any matching URL in a `pyproject.toml` or requirements file as a dependency, even when it is only a repository/homepage link or comment. `_infer_dependencies` then writes that repo into the generated envit manifest as a dependency, polluting the target’s dependency rows with unrelated repos or itself.

Only add URLs parsed from dependency declarations and exclude the target repository itself.

Also at `onboard.py:710-712`.

Comment thread onboard.py
Comment on lines +1494 to +1495
(out_root / PROJECT_VERIFY).write_text(
generate_project_verify(ev, info, stamp, sha), encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Repo links overwrite outside files

If a target checkout contains a project_verify.yaml symlink to a writable file outside the checkout, --write follows it and replaces that file with generated YAML. Reject symlinks and paths that resolve outside the chosen output directory before writing any artifacts, including paths beneath .envit.

How this was verified: The default output is the target checkout, and the artifact writes follow its paths without checking where they resolve.

Prompt To Fix With AI
This is a comment left during a code review.
Path: onboard.py
Line: 1494-1495

Comment:
**Repo links overwrite outside files**

If a target checkout contains a `project_verify.yaml` symlink to a writable file outside the checkout, `--write` follows it and replaces that file with generated YAML. Reject symlinks and paths that resolve outside the chosen output directory before writing any artifacts, including paths beneath `.envit`.

**How this was verified:** The default output is the target checkout, and the artifact writes follow its paths without checking where they resolve.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread onboard.py
Comment on lines +1494 to +1495
(out_root / PROJECT_VERIFY).write_text(
generate_project_verify(ev, info, stamp, sha), encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Custom settings disappear on refresh

--write replaces existing project_verify.yaml and envit.json files instead of preserving their hand-written settings. Custom verify commands disappear, and dependency skill paths and picks become generated defaults. This contradicts the documented promise that reruns preserve hand-written content. Merge the generated settings into existing files, or require a separate overwrite option.

Prompt To Fix With AI
This is a comment left during a code review.
Path: onboard.py
Line: 1494-1495

Comment:
**Custom settings disappear on refresh**

`--write` replaces existing `project_verify.yaml` and `envit.json` files instead of preserving their hand-written settings. Custom verify commands disappear, and dependency skill paths and picks become generated defaults. This contradicts the documented promise that reruns preserve hand-written content. Merge the generated settings into existing files, or require a separate overwrite option.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread onboard.py
Comment on lines +1389 to +1392
if "pytest" in text or (ev.root / cwd / "tests").is_dir():
_add(f"{cwd}:pytest", "pytest -q", cwd, ["python"])
if "[tool.ruff]" in text:
_add(f"{cwd}:ruff", "ruff check .", cwd, ["python"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Generated Python checks cannot run

generate_project_verify emits pytest and ruff commands, but school-core's fixed verifyShell provides neither tool. The normal bridge path filters their “command not found” failures as infrastructure noise. These commands also suppress the Python compile fallback because they already claim the python language. Provision the tools and their dependencies, or retain a Python check the shell can run.

Prompt To Fix With AI
This is a comment left during a code review.
Path: onboard.py
Line: 1389-1392

Comment:
**Generated Python checks cannot run**

`generate_project_verify` emits `pytest` and `ruff` commands, but school-core's fixed `verifyShell` provides neither tool. The normal bridge path filters their “command not found” failures as infrastructure noise. These commands also suppress the Python compile fallback because they already claim the `python` language. Provision the tools and their dependencies, or retain a Python check the shell can run.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread onboard.py
re.S,
)
if pattern.search(text):
return pattern.sub(new_block, text, count=1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backslashes break context refreshes

pattern.sub reads new_block as a replacement template, not literal text. The block includes README prose and raw package.json commands. If either contains \d or \s, the second onboarding run fails with a bad-escape error. Other escapes can silently change the output. Use a callable replacement to preserve the generated text exactly.

Suggested change
return pattern.sub(new_block, text, count=1)
return pattern.sub(lambda _: new_block, text, count=1)
Prompt To Fix With AI
This is a comment left during a code review.
Path: onboard.py
Line: 1466

Comment:
**Backslashes break context refreshes**

`pattern.sub` reads `new_block` as a replacement template, not literal text. The block includes README prose and raw `package.json` commands. If either contains `\d` or `\s`, the second onboarding run fails with a bad-escape error. Other escapes can silently change the output. Use a callable replacement to preserve the generated text exactly.

```suggestion
        return pattern.sub(lambda _: new_block, text, count=1)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread a2a_bridge.py
Comment on lines +102 to +104
url = (
f"{api_base}/v1/{automation_id}/runs?limit=5"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Existing runs appear missing

poll_run_status searches only five runs and immediately raises if the requested ID is absent. An older run requested with --poll-only or --read-only, or a running task displaced by newer runs, can exist outside that page. Fetch the requested run directly or search later pages before declaring it missing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: a2a_bridge.py
Line: 102-104

Comment:
**Existing runs appear missing**

`poll_run_status` searches only five runs and immediately raises if the requested ID is absent. An older run requested with `--poll-only` or `--read-only`, or a running task displaced by newer runs, can exist outside that page. Fetch the requested run directly or search later pages before declaring it missing.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread a2a_bridge.py
Comment on lines +230 to +232
if run.get("status") == "FAILED":
print(f"Run failed: {run.get('error_detail', 'unknown')}", file=sys.stderr)
return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cancelled tasks report success

poll_run_status returns CANCELLED runs, but the full pipeline rejects only FAILED. A cancelled run can therefore print an earlier assistant message and exit successfully, making unfinished work look complete. Require COMPLETED before reading a successful answer and return a failure for cancellation.

Suggested change
if run.get("status") == "FAILED":
print(f"Run failed: {run.get('error_detail', 'unknown')}", file=sys.stderr)
return 1
if run.get("status") != "COMPLETED":
print(
f"Run {run.get('status')}: {run.get('error_detail', 'unknown')}",
file=sys.stderr,
)
return 1
Prompt To Fix With AI
This is a comment left during a code review.
Path: a2a_bridge.py
Line: 230-232

Comment:
**Cancelled tasks report success**

`poll_run_status` returns `CANCELLED` runs, but the full pipeline rejects only `FAILED`. A cancelled run can therefore print an earlier assistant message and exit successfully, making unfinished work look complete. Require `COMPLETED` before reading a successful answer and return a failure for cancellation.

```suggestion
        if run.get("status") != "COMPLETED":
            print(
                f"Run {run.get('status')}: {run.get('error_detail', 'unknown')}",
                file=sys.stderr,
            )
            return 1
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread a2a_bridge.py
Comment on lines +155 to +157
for part in content:
if part.get("type") == "text":
return part.get("text")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Answers lose later text parts

extract_last_assistant_text returns from inside the content loop, so a message with multiple text parts loses everything after the first part. Join all text parts from the latest assistant message before returning, and add a multipart test.

Prompt To Fix With AI
This is a comment left during a code review.
Path: a2a_bridge.py
Line: 155-157

Comment:
**Answers lose later text parts**

`extract_last_assistant_text` returns from inside the content loop, so a message with multiple text parts loses everything after the first part. Join all text parts from the latest assistant message before returning, and add a multipart test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread onboard.py
Comment on lines +1461 to +1466
pattern = re.compile(
r"<!-- BEGIN ONBOARD-GENERATED:.*?(?:<!-- END ONBOARD-GENERATED -->\n?)",
re.S,
)
if pattern.search(text):
return pattern.sub(new_block, text, count=1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refreshes duplicate document titles

The generators put each title before BEGIN ONBOARD-GENERATED. This replacement leaves the old title in place, then inserts the entire new document with another title. Every refresh adds one more heading. Put generated titles inside the fence or replace only the fenced portion of the new document, and test several reruns for unchanged title counts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: onboard.py
Line: 1461-1466

Comment:
**Refreshes duplicate document titles**

The generators put each title before `BEGIN ONBOARD-GENERATED`. This replacement leaves the old title in place, then inserts the entire new document with another title. Every refresh adds one more heading. Put generated titles inside the fence or replace only the fenced portion of the new document, and test several reruns for unchanged title counts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread a2a_bridge.py
Comment on lines +214 to +215
print(f"Dispatching task: {args.task}", file=sys.stderr)
resp = dispatch(WEBHOOK_URL, WEBHOOK_SECRET, args.task)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing credentials reach the server

main never calls require_secrets, so the CLI sends requests with empty credentials when setup is incomplete. Users get a remote request error instead of being told which credentials are missing. Call the check before making requests and add a CLI test that confirms missing credentials stop before dispatch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: a2a_bridge.py
Line: 214-215

Comment:
**Missing credentials reach the server**

`main` never calls `require_secrets`, so the CLI sends requests with empty credentials when setup is incomplete. Users get a remote request error instead of being told which credentials are missing. Call the check before making requests and add a CLI test that confirms missing credentials stop before dispatch.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, and 21 other issues

Security issues:

  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="issue_bridge.py" line_range="2713-2714" />
<code_context>
+                        "description": (f.get("message") or "")[:500],
+                        "suggestion": "Fix the issue the pre-merge sensor flagged.",
+                    })
+                adversarial_review["verdict"] = "FAIL"
+                adversarial_review["score"] = 0.0
+
             # Merge verify-gate failures into the review as CRITICAL findings.
</code_context>
<issue_to_address>
**Critical findings do not veto publication**

When the sync path receives a CRITICAL Entire finding for an accepted authoritative packet, `adversarial_review` changes to FAIL, but `canonical_packet.accepted` remains true and the later grading and PR-publication path continues, so work with a critical finding can still be graded or published as accepted.

Apply the Entire veto to the authoritative packet or stop downstream grading and publication when the finding is veto-worthy.
</issue_to_address>

### Comment 2
<location path="src/entire_review.py" line_range="202" />
<code_context>
     try:
         result = subprocess.run(
-            ["entire", "review", "--base", base_branch, "--format", "text"],
+            [entire_path, "review", "--profile", "general", "--base", base_branch],
             cwd=worktree_path,
             capture_output=True,
</code_context>
<issue_to_address>
**Failed Entire reviews pass**

When the Entire subprocess exits nonzero, times out, or raises and its output has no parsed findings, `run_entire_review` derives status from parsed findings without treating the subprocess failure as a sensor error, so it returns `status: pass` and callers see a clean review.

Return a failed review status and error when the Entire subprocess does not complete successfully.
</issue_to_address>

### Comment 3
<location path="onboard.py" line_range="185" />
<code_context>
+    "yaml": [
+        {"name": "yaml-validate",
+         "cmd": "python3 -c \"import yaml, sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]\" "
+                "$(find . -name '*.yaml' -o -name '*.yml' | grep -v node_modules | head -5) 2>&1 || true",
+         "cwd": ".", "languages": "yaml"},
+    ],
</code_context>
<issue_to_address>
**Malformed files pass validation**

When a generated YAML, JSON, or Markdown validation command encounters invalid input, the generated validation command ends with `|| true`, converting parser failures or unreadable files into successful checks, so the verify gate reports malformed files as passing.

Remove the success-forcing fallback and preserve validator failures as a nonzero command result.

Also at `onboard.py:186`, `onboard.py:191-192`, `onboard.py:198`.
</issue_to_address>

### Comment 4
<location path="onboard.py" line_range="1301-1302" />
<code_context>
+        except (OSError, json.JSONDecodeError):
+            continue
+        for locked in data.get("repos") or []:
+            if str(locked.get("name")) == name or locked.get("source", "").endswith(source):
+                return {
+                    "name": name,
+                    "source": str(locked.get("source") or source),
</code_context>
<issue_to_address>
**Same-name dependencies resolve incorrectly**

When distinct dependency repositories from different owners share a basename, `_resolve_lock_entry` can reuse a lock entry based only on the basename, and materialized links also use that basename; the generated lock or context can therefore point at the wrong repository.

Match lock entries by full repository source and use distinct link paths for repositories with the same basename.

Also at `onboard.py:1503-1505`, `onboard.py:1524-1530`.
</issue_to_address>

### Comment 5
<location path="onboard.py" line_range="1310-1313" />
<code_context>
+                }
+
+    # 2. git ls-remote (only for non-HEAD refs; HEAD resolves locally below)
+    url = source
+    if "github.com/" in source and not source.startswith(("http", "git@")):
+        url = f"https://github.com/{source}.git"
+    if url.startswith("http") or url.startswith("git@"):
+        want = ref if ref != "HEAD" else "HEAD"
+        spec = f"refs/tags/{want}" if (ref != "HEAD" and not _looks_like_sha(ref)) else want
</code_context>
<issue_to_address>
**Dependency locks lack resolved commits**

When a dependency uses the `owner/repo` source form, has no reusable lock entry, and its ref is not a SHA, `_resolve_lock_entry` leaves the slug unchanged, so its URL check skips the GitHub lookup and writes `commit: null`; the generated lock does not pin the dependency.

Normalize `owner/repo` sources to a GitHub URL before the lookup, or perform the lookup directly from the slug.
</issue_to_address>

### Comment 6
<location path="onboard.py" line_range="1547-1549" />
<code_context>
+    base = Path.home() / ".envit" / "store" / "checkouts" / "github.com" / owner / name
+    if not base.is_dir():
+        return None
+    for child in sorted(base.iterdir()):
+        if child.is_dir() and not child.name.endswith(".git"):
+            return child
+    return None
+
</code_context>
<issue_to_address>
**Dependency links expose the wrong revision**

When the envit store has multiple checkouts or an existing link targets a checkout different from the lock commit, `_envit_store_path` selects the first checkout without checking its commit, and the writer can leave an existing link untouched; the generated context can expose a revision other than the one declared in the lock.

Select or refresh each dependency link only after verifying that its checkout matches the generated lock commit.

Also at `onboard.py:1524-1530`.
</issue_to_address>

### Comment 7
<location path="conductor.py" line_range="658-664" />
<code_context>
+        # CRITICAL entire finding could not veto acceptance. Write them here,
+        # repo-namespaced to match read_bookbag/_persist_acceptance.
+        try:
+            locked_update_bookbag(
+                leaf.bead,
+                repo=target_repo or "__global__",
+                lock_timeout=10.0,
+                entire_findings=entire_findings,
+                entire_status=entire_status,
+            )
+        except Exception as e:  # noqa: BLE001
+            print(f"  ⚠ Entire review result persist failed: {e}")
</code_context>
<issue_to_address>
**Unlocked teacher write erases sensor result**

When the teacher reads the bookbag before the sensor update and saves its copy afterward, the conductor's locked sensor update can race with the teacher's `update_bookbag`: the teacher reads the bag before the sensor write, then writes its stale copy afterward and drops `entire_findings` and `entire_status`. Acceptance then reads a bag without the finding and can pass it.

Make all bookbag writers use the same lock-protected update path so the sensor fields cannot be lost.
</issue_to_address>

### Comment 8
<location path="onboard.py" line_range="1317" />
<code_context>
+        want = ref if ref != "HEAD" else "HEAD"
+        spec = f"refs/tags/{want}" if (ref != "HEAD" and not _looks_like_sha(ref)) else want
+        for candidate in (spec, want):
+            out = _gh(["api", f"repos/{source}/commits/{candidate}"], timeout=20)
+            if out:
+                try:
</code_context>
<issue_to_address>
**Offline onboarding still calls GitHub**

When `--offline --write` is used for a repo with dependencies that have no reusable lock entries, `--offline` affects metadata lookup but is not passed to `build_envit_lock`; writing artifacts can therefore call `_gh` to resolve dependency commits. An offline run with dependencies and no reusable lock still attempts GitHub API requests and emits unresolved locks if those requests fail.

Propagate offline mode to lock resolution and skip all GitHub lookups when it is enabled.

Also at `onboard.py:1519-1520`.
</issue_to_address>

### Comment 9
<location path="a2a_bridge.py" line_range="215" />
<code_context>
+            parser.error("--task is required for full pipeline")
+
+        print(f"Dispatching task: {args.task}", file=sys.stderr)
+        resp = dispatch(WEBHOOK_URL, WEBHOOK_SECRET, args.task)
+        print(f"Dispatch response: {resp}", file=sys.stderr)
+
</code_context>
<issue_to_address>
**Missing credentials fail remotely**

When either required A2A environment variable is unset, `main` does not call `require_secrets`, so `dispatch` and later requests use empty credentials and fail at the service request instead of reporting the missing configuration clearly.

Call `require_secrets` before dispatching or making requests.
</issue_to_address>

### Comment 10
<location path="onboard.py" line_range="691-684" />
<code_context>
+        text = _read_text(root / manifest)
+        if not text:
+            continue
+        for match in re.finditer(r"github\.com[/:]([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", text):
+            _add(match.group(1), via=f"{manifest}")
+        if tomllib is not None:
</code_context>
<issue_to_address>
**Explicit dependency pins are discarded**

When a pyproject GitHub dependency has an explicit uv `rev` or `tag`, the raw-text scan inserts the dependency with `ref="HEAD"` first, and the later parsed entry cannot replace it because `_add` uses `setdefault`; the generated manifest and lock lose the declared pin.

Let the parsed uv source replace the raw-text entry when it provides an explicit `rev` or `tag`.

Also at `onboard.py:684-685`, `onboard.py:692-703`.
</issue_to_address>

### Comment 11
<location path="onboard.py" line_range="1515-1518" />
<code_context>
+    written.append(str(agents_path))
+
+    if ev.dependency_repos:
+        manifest = build_envit_manifest(ev, info)
+        (out_root / ENVIT_MANIFEST).write_text(_json_dump(manifest), encoding="utf-8")
+        written.append(str(out_root / ENVIT_MANIFEST))
+        lock = build_envit_lock(ev, info, out_root)
+        (out_root / ENVIT_LOCK).write_text(_json_dump(lock), encoding="utf-8")
+        written.append(str(out_root / ENVIT_LOCK))
</code_context>
<issue_to_address>
**Removed dependencies remain declared**

When a later onboarding run discovers no dependencies after dependencies were previously declared, `write_artifacts` skips rewriting the envit files when there are no dependencies, leaving the old manifest and lock in place; the generated context and durable dependency declaration disagree.

Write empty or refreshed envit files and remove stale dependency links when no dependencies remain.

Also at `onboard.py:1524-1530`.
</issue_to_address>

### Comment 12
<location path="onboard.py" line_range="1516-1519" />
<code_context>
+
+    if ev.dependency_repos:
+        manifest = build_envit_manifest(ev, info)
+        (out_root / ENVIT_MANIFEST).write_text(_json_dump(manifest), encoding="utf-8")
+        written.append(str(out_root / ENVIT_MANIFEST))
+        lock = build_envit_lock(ev, info, out_root)
+        (out_root / ENVIT_LOCK).write_text(_json_dump(lock), encoding="utf-8")
+        written.append(str(out_root / ENVIT_LOCK))
+        repos_root = envit_dir / "repos"
</code_context>
<issue_to_address>
**Manifest and lock can diverge**

When onboarding stops or lock generation or writing fails after the manifest is written, `write_artifacts` writes the manifest before completing the lockfile, leaving a new declaration alongside an old or missing lock; later consumers can use inconsistent dependency state.

Stage the manifest and lock together and replace the existing files only after both are ready.
</issue_to_address>

### Comment 13
<location path="onboard.py" line_range="822" />
<code_context>
+#!/usr/bin/env python3
+"""A2A bridge: dispatch → poll → read for OpenHands automation webhook.
+
+Stdlib only. CLI:
+    python3 a2a_bridge.py --task "<text>"
</code_context>
<issue_to_address>
**Empty checkouts pass evidence checks**

When `--require-evidence` is used on a checkout with no concrete repo-derived facts, `_collect_evidence` unconditionally adds `package_manager` to `generated`, making `Evidence.detected` true and allowing boilerplate generation without evidence.

Add the package-manager marker only when repository evidence supports it.

Also at `onboard.py:821`.
</issue_to_address>

### Comment 14
<location path="onboard.py" line_range="1486" />
<code_context>
+
+    claude = generate_claude_md(info, ev, stamp, sha)
+    (out_root / L0_FILE).write_text(
+        _replace_generated_block(_read_text(out_root / L0_FILE), claude), encoding="utf-8")
+    written.append(str(out_root / L0_FILE))
+
</code_context>
<issue_to_address>
**Generated documents gain duplicate titles**

When onboarding writes `CLAUDE.md` or `CONTEXT.md` more than once, the generated title sits before the replacement marker, while the replacement block includes another title; reruns preserve the old title and add duplicate document content.

Place the title inside the replaced block or exclude it from the replacement content.

Also at `onboard.py:914`, `onboard.py:1466`, `onboard.py:1491`.
</issue_to_address>

### Comment 15
<location path="a2a_bridge.py" line_range="230-231" />
<code_context>
+        )
+        print(f"Run status: {run.get('status')}", file=sys.stderr)
+
+        if run.get("status") == "FAILED":
+            print(f"Run failed: {run.get('error_detail', 'unknown')}", file=sys.stderr)
+            return 1
+
</code_context>
<issue_to_address>
**Cancelled runs can return answers**

When a cancelled run has a conversation with assistant text, when a run is `CANCELLED` but has a conversation containing assistant text, the full pipeline checks only for `FAILED`, and `--read-only` checks no terminal status at all. Both paths read and print that conversation as a successful answer.

Require `COMPLETED` before reading or returning an answer, and return a failure for `FAILED` or `CANCELLED` in both modes.

Also at `a2a_bridge.py:257-265`.
</issue_to_address>

### Comment 16
<location path="onboard.py" line_range="179-180" />
<code_context>
+         "languages": "typescript"},
+    ],
+    "javascript": [
+        {"name": "node-check", "cmd": "node --check \"$(find . -name '*.js' -not -path './node_modules/*' | head -1)\" 2>&1",
+         "cwd": ".", "languages": "javascript"},
+    ],
+    "yaml": [
</code_context>
<issue_to_address>
**JavaScript fallback misses modules**

When a target has JavaScript evidence only in `.mjs` files and no project-specific verify command, when a repo uses `.mjs` files but has no `.js` file, `_LANG_VERIFY` still emits the JavaScript fallback, but its `find` searches only for `*.js`. The generated check runs with no source file and fails instead of validating the repo's JavaScript.

Search the JavaScript extensions the evidence collector recognizes, and handle the case where no matching source file exists.
</issue_to_address>

### Comment 17
<location path="onboard.py" line_range="1495-1496" />
<code_context>
+    written.append(str(out_root / L1_FILE))
+
+    (out_root / PROJECT_VERIFY).write_text(
+        generate_project_verify(ev, info, stamp, sha), encoding="utf-8")
+    written.append(str(out_root / PROJECT_VERIFY))
+
+    if not write_envit:
</code_context>
<issue_to_address>
**Onboarding overwrites existing verify commands**

When the target already has hand-maintained verify entries not reproduced by the generator, `write_artifacts` writes the generated command list directly to `project_verify.yaml` instead of preserving or merging existing entries, so commands maintained by the target repo but not rediscovered by onboarding disappear from the verify gate.

Preserve user-owned entries or require an explicit overwrite option before replacing an existing verify manifest.
</issue_to_address>

### Comment 18
<location path="onboard.py" line_range="1486-1487" />
<code_context>
+
+    claude = generate_claude_md(info, ev, stamp, sha)
+    (out_root / L0_FILE).write_text(
+        _replace_generated_block(_read_text(out_root / L0_FILE), claude), encoding="utf-8")
+    written.append(str(out_root / L0_FILE))
+
+    context = generate_context_md(info, ev, stamp, sha)
</code_context>
<issue_to_address>
**Symlinks overwrite outside files**

When an operator runs `onboard --write` on a checkout containing a symlink at a generated-artifact path, `write_artifacts` calls `Path.write_text` on fixed artifact paths without checking whether an existing path is a symlink. A target repository author can include a symlink such as `project_verify.yaml` pointing outside the checkout; when an operator runs onboarding with `--write`, the generated file follows that link and overwrites the external target.

Reject symlinked artifact paths or write through safely created temporary files using no-follow semantics.
</issue_to_address>

### Comment 19
<location path="onboard.py" line_range="940" />
<code_context>
+    lines.append("## What this repo is")
+    lines.append("")
+    if ev.readme_summary:
+        lines.append(ev.readme_summary)
+    elif info.description:
+        lines.append(info.description)
</code_context>
<issue_to_address>
**README injects agent instructions**

When a target repository's README summary contains instructions aimed at agents and an agent later consumes the generated `CLAUDE.md`, `generate_claude_md` inserts the repository-controlled README summary as ordinary text in the agent-facing `CLAUDE.md`. A repository author can place model-directed instructions in that summary, which downstream agents may treat as trusted project guidance and follow during work.

Clearly delimit repository-derived prose as untrusted reference material and instruct consuming agents not to treat it as policy or commands.
</issue_to_address>

### Comment 20
<location path="tests/test_onboard.py" line_range="515" />
<code_context>
+        empty = tmp_path / "empty"
+        empty.mkdir()
+        with pytest.raises(OnboardError):
+            onboard_repo(str(empty), write=True, clone=False, require_evidence=True)
+
+    def test_cli_report_only_is_exit_zero(self, tmp_path, capsys):
</code_context>
<issue_to_address>
**Evidence guard is bypassed**

When the empty directory has no `.git` entry and cloning is disabled, `test_requires_evidence_refuses_empty_repo` passes at lines 514–515 because `onboard_repo` rejects the directory as not a checkout before reaching the evidence check; removing `require_evidence` enforcement would still leave this test green.

Make the fixture an empty git checkout so the call reaches the evidence check.
</issue_to_address>

### Comment 21
<location path="tests/test_a2a_bridge.py" line_range="131" />
<code_context>
+    def test_posts_signed_body_and_parses_response(self):
+        with FakeServer() as base:
+            resp = bridge.dispatch(f"{base}/dispatch", "test-secret", "do a thing")
+        self.assertTrue(resp["received"])
+        self.assertEqual(resp["runs_created"], ["run-1"])
+
</code_context>
<issue_to_address>
**A2A request inputs go unchecked**

When the bridge posts wrong task JSON or requests a different conversation ID, `test_posts_signed_body_and_parses_response` (lines 128–132) and `test_reads_agent_answer` (258–261) still pass: POST checks only a signature over any bytes, and GET returns the answer for any `/events` path.

Assert the decoded task body and the exact conversation-specific request path.

Also at `tests/test_a2a_bridge.py:261`.
</issue_to_address>

### Comment 22
<location path="src/entire_review.py" line_range="201-205" />
<code_context>
        result = subprocess.run(
            [entire_path, "review", "--profile", "general", "--base", base_branch],
            cwd=worktree_path,
            capture_output=True,
            text=True,
            timeout=120,
        )
</code_context>
<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

Sourcery assessment

Approval pending. 22 findings to address first.

Blocking findings: issue_bridge.py:2714, src/entire_review.py:202, onboard.py:185, onboard.py:1302, onboard.py:1313, and 17 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread issue_bridge.py
Comment on lines +2713 to +2714
adversarial_review["verdict"] = "FAIL"
adversarial_review["score"] = 0.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Critical findings do not veto publication

When the sync path receives a CRITICAL Entire finding for an accepted authoritative packet, adversarial_review changes to FAIL, but canonical_packet.accepted remains true and the later grading and PR-publication path continues, so work with a critical finding can still be graded or published as accepted.

Apply the Entire veto to the authoritative packet or stop downstream grading and publication when the finding is veto-worthy.

Prompt for AI agents
In `issue_bridge.py` at lines 2713-2714:

**Critical findings do not veto publication**

When the sync path receives a CRITICAL Entire finding for an accepted authoritative packet, `adversarial_review` changes to FAIL, but `canonical_packet.accepted` remains true and the later grading and PR-publication path continues, so work with a critical finding can still be graded or published as accepted.

Apply the Entire veto to the authoritative packet or stop downstream grading and publication when the finding is veto-worthy.

Comment thread src/entire_review.py
try:
result = subprocess.run(
["entire", "review", "--base", base_branch, "--format", "text"],
[entire_path, "review", "--profile", "general", "--base", base_branch],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Failed Entire reviews pass

When the Entire subprocess exits nonzero, times out, or raises and its output has no parsed findings, run_entire_review derives status from parsed findings without treating the subprocess failure as a sensor error, so it returns status: pass and callers see a clean review.

Return a failed review status and error when the Entire subprocess does not complete successfully.

Prompt for AI agents
In `src/entire_review.py` at line 202:

**Failed Entire reviews pass**

When the Entire subprocess exits nonzero, times out, or raises and its output has no parsed findings, `run_entire_review` derives status from parsed findings without treating the subprocess failure as a sensor error, so it returns `status: pass` and callers see a clean review.

Return a failed review status and error when the Entire subprocess does not complete successfully.

Comment thread onboard.py
"yaml": [
{"name": "yaml-validate",
"cmd": "python3 -c \"import yaml, sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]\" "
"$(find . -name '*.yaml' -o -name '*.yml' | grep -v node_modules | head -5) 2>&1 || true",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Malformed files pass validation

When a generated YAML, JSON, or Markdown validation command encounters invalid input, the generated validation command ends with || true, converting parser failures or unreadable files into successful checks, so the verify gate reports malformed files as passing.

Remove the success-forcing fallback and preserve validator failures as a nonzero command result.

Also at onboard.py:186, onboard.py:191-192, onboard.py:198.

Prompt for AI agents
In `onboard.py` at line 185:

**Malformed files pass validation**

When a generated YAML, JSON, or Markdown validation command encounters invalid input, the generated validation command ends with `|| true`, converting parser failures or unreadable files into successful checks, so the verify gate reports malformed files as passing.

Remove the success-forcing fallback and preserve validator failures as a nonzero command result.

Also at `onboard.py:186`, `onboard.py:191-192`, `onboard.py:198`.

Comment thread onboard.py
Comment on lines +1301 to +1302
if str(locked.get("name")) == name or locked.get("source", "").endswith(source):
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Same-name dependencies resolve incorrectly

When distinct dependency repositories from different owners share a basename, _resolve_lock_entry can reuse a lock entry based only on the basename, and materialized links also use that basename; the generated lock or context can therefore point at the wrong repository.

Match lock entries by full repository source and use distinct link paths for repositories with the same basename.

Also at onboard.py:1503-1505, onboard.py:1524-1530.

Prompt for AI agents
In `onboard.py` at lines 1301-1302:

**Same-name dependencies resolve incorrectly**

When distinct dependency repositories from different owners share a basename, `_resolve_lock_entry` can reuse a lock entry based only on the basename, and materialized links also use that basename; the generated lock or context can therefore point at the wrong repository.

Match lock entries by full repository source and use distinct link paths for repositories with the same basename.

Also at `onboard.py:1503-1505`, `onboard.py:1524-1530`.

Comment thread onboard.py
Comment on lines +1310 to +1313
url = source
if "github.com/" in source and not source.startswith(("http", "git@")):
url = f"https://github.com/{source}.git"
if url.startswith("http") or url.startswith("git@"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Dependency locks lack resolved commits

When a dependency uses the owner/repo source form, has no reusable lock entry, and its ref is not a SHA, _resolve_lock_entry leaves the slug unchanged, so its URL check skips the GitHub lookup and writes commit: null; the generated lock does not pin the dependency.

Normalize owner/repo sources to a GitHub URL before the lookup, or perform the lookup directly from the slug.

Prompt for AI agents
In `onboard.py` at lines 1310-1313:

**Dependency locks lack resolved commits**

When a dependency uses the `owner/repo` source form, has no reusable lock entry, and its ref is not a SHA, `_resolve_lock_entry` leaves the slug unchanged, so its URL check skips the GitHub lookup and writes `commit: null`; the generated lock does not pin the dependency.

Normalize `owner/repo` sources to a GitHub URL before the lookup, or perform the lookup directly from the slug.

Comment thread onboard.py
Comment on lines +1486 to +1487
_replace_generated_block(_read_text(out_root / L0_FILE), claude), encoding="utf-8")
written.append(str(out_root / L0_FILE))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Symlinks overwrite outside files

When an operator runs onboard --write on a checkout containing a symlink at a generated-artifact path, write_artifacts calls Path.write_text on fixed artifact paths without checking whether an existing path is a symlink. A target repository author can include a symlink such as project_verify.yaml pointing outside the checkout; when an operator runs onboarding with --write, the generated file follows that link and overwrites the external target.

Reject symlinked artifact paths or write through safely created temporary files using no-follow semantics.

Prompt for AI agents
In `onboard.py` at lines 1486-1487:

**Symlinks overwrite outside files**

When an operator runs `onboard --write` on a checkout containing a symlink at a generated-artifact path, `write_artifacts` calls `Path.write_text` on fixed artifact paths without checking whether an existing path is a symlink. A target repository author can include a symlink such as `project_verify.yaml` pointing outside the checkout; when an operator runs onboarding with `--write`, the generated file follows that link and overwrites the external target.

Reject symlinked artifact paths or write through safely created temporary files using no-follow semantics.

Comment thread onboard.py
lines.append("## What this repo is")
lines.append("")
if ev.readme_summary:
lines.append(ev.readme_summary)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · README injects agent instructions

When a target repository's README summary contains instructions aimed at agents and an agent later consumes the generated CLAUDE.md, generate_claude_md inserts the repository-controlled README summary as ordinary text in the agent-facing CLAUDE.md. A repository author can place model-directed instructions in that summary, which downstream agents may treat as trusted project guidance and follow during work.

Clearly delimit repository-derived prose as untrusted reference material and instruct consuming agents not to treat it as policy or commands.

Prompt for AI agents
In `onboard.py` at line 940:

**README injects agent instructions**

When a target repository's README summary contains instructions aimed at agents and an agent later consumes the generated `CLAUDE.md`, `generate_claude_md` inserts the repository-controlled README summary as ordinary text in the agent-facing `CLAUDE.md`. A repository author can place model-directed instructions in that summary, which downstream agents may treat as trusted project guidance and follow during work.

Clearly delimit repository-derived prose as untrusted reference material and instruct consuming agents not to treat it as policy or commands.

Comment thread tests/test_onboard.py
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(OnboardError):
onboard_repo(str(empty), write=True, clone=False, require_evidence=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Evidence guard is bypassed

When the empty directory has no .git entry and cloning is disabled, test_requires_evidence_refuses_empty_repo passes at lines 514–515 because onboard_repo rejects the directory as not a checkout before reaching the evidence check; removing require_evidence enforcement would still leave this test green.

Make the fixture an empty git checkout so the call reaches the evidence check.

Prompt for AI agents
In `tests/test_onboard.py` at line 515:

**Evidence guard is bypassed**

When the empty directory has no `.git` entry and cloning is disabled, `test_requires_evidence_refuses_empty_repo` passes at lines 514–515 because `onboard_repo` rejects the directory as not a checkout before reaching the evidence check; removing `require_evidence` enforcement would still leave this test green.

Make the fixture an empty git checkout so the call reaches the evidence check.

Comment thread tests/test_a2a_bridge.py
def test_posts_signed_body_and_parses_response(self):
with FakeServer() as base:
resp = bridge.dispatch(f"{base}/dispatch", "test-secret", "do a thing")
self.assertTrue(resp["received"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · A2A request inputs go unchecked

When the bridge posts wrong task JSON or requests a different conversation ID, test_posts_signed_body_and_parses_response (lines 128–132) and test_reads_agent_answer (258–261) still pass: POST checks only a signature over any bytes, and GET returns the answer for any /events path.

Assert the decoded task body and the exact conversation-specific request path.

Also at tests/test_a2a_bridge.py:261.

Prompt for AI agents
In `tests/test_a2a_bridge.py` at line 131:

**A2A request inputs go unchecked**

When the bridge posts wrong task JSON or requests a different conversation ID, `test_posts_signed_body_and_parses_response` (lines 128–132) and `test_reads_agent_answer` (258–261) still pass: POST checks only a signature over any bytes, and GET returns the answer for any `/events` path.

Assert the decoded task body and the exact conversation-specific request path.

Also at `tests/test_a2a_bridge.py:261`.

Comment thread src/entire_review.py
Comment on lines 201 to 205
result = subprocess.run(
["entire", "review", "--base", base_branch, "--format", "text"],
[entire_path, "review", "--profile", "general", "--base", base_branch],
cwd=worktree_path,
capture_output=True,
text=True,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (python.lang.security.audit.dangerous-subprocess-use-audit): Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

Source: opengrep

This branch has not been deployed

No deployments
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.

2 participants