Skip to content

fix(guardian): review the PR as data, never run its code beside the keys - #731

Merged
zaebee merged 5 commits into
mainfrom
fix/730-guardian-trusted-checkout
Oct 22, 2026
Merged

zaebee merged 5 commits into
mainfrom
fix/730-guardian-trusted-checkout

Conversation

@zaebee

@zaebee zaebee commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #730.

Problem

/guardian review checked the PR out at the repository root. With that checkout, the job ran the PR's own lockfile (uv sync), the PR's scripts/guardian_review.py and, with GUARDIAN_EVIDENCE=1, uv run mypy/ruff under the PR's config. All of this happened while ANTHROPIC_API_KEY, MISTRAL_API_KEY and GEMINI_API_KEY were in the environment.

The write-access check limits who triggers a review, not whose code runs. The repository is public.

Change

  • Workflow:
    • the default branch is checked out at the root, and all tooling runs from there;
    • the PR is checked out into pr/ with persist-credentials: false and is only diffed, read and parsed (cgis ingest pr/src);
    • the only step that runs in pr/ is git fetch;
    • GUARDIAN_EVIDENCE is no longer passed.
  • guardian_review.py --repo-root:
    • the flag names the reviewed checkout, and the review fingerprint is still computed from this repository;
    • it refuses a path that is not a directory, and refuses GUARDIAN_EVIDENCE=1 together with a foreign checkout;
    • both refusals happen before any provider is built.
  • Tests:
    • tests/unit/test_guardian_workflow.py pins the workflow shape; 4 of its 5 tests fail on the old workflow;
    • 4 new cases in test_guardian_review_script.py; the collector-root case was seen failing with the wiring reverted.
  • Docstring: the evidence.py docstring no longer claims that guardian.yml runs the evidence pass.

The same flag is what reviewing another repository (owner-api) needs.

Behaviour change

The GUARDIAN_EVIDENCE=1 repository variable is now ignored by this workflow. The evidence pass only fed the skeptic, which is off in production.

A second, intended consequence. A PR that changes guardian itself (its prompt, finder, collector or scripts) is now reviewed by the guardian on main, not by its own new code. Its behaviour can be checked locally beforehand, the way the owner-api trial was run, or on the next PR after merge. The workflow YAML was already taken from the default branch for issue_comment events, so only the scripts change here, and they are exactly the path the keys leaked through.

🤖 Generated with Claude Code

`/guardian review` checked the PR out at the root and ran its lockfile, its
scripts and (with GUARDIAN_EVIDENCE=1) its checkers while the provider keys
were in the environment. The write-access check limits who triggers a review,
not whose code runs, and the repository is public.

- The job now checks out the default branch for all tooling and the PR into
  pr/, which is only diffed, read and parsed.
- guardian_review.py gains --repo-root for the reviewed checkout; the review
  fingerprint is still computed from this repository. It refuses
  GUARDIAN_EVIDENCE=1 with a foreign checkout, and the workflow no longer
  passes the flag.
- tests/unit/test_guardian_workflow.py pins the shape: PR in its own
  directory without credentials, default branch at the root, only git runs
  in pr/, and the one step holding secrets reviews pr/ without evidence.

The same flag is what reviewing another repository (owner-api) needs.

Closes #730

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a --repo-root option to guardian_review.py to safely review foreign pull requests as data rather than executing their code, addressing security concerns regarding untrusted code execution alongside provider keys. It also adds comprehensive unit tests, including workflow validation tests in test_guardian_workflow.py. The review feedback suggests optimizing path resolution in reviewed_root by resolving project_root once, and improving the robustness of the workflow tests by using safe dictionary access instead of direct indexing to avoid potential KeyError or TypeError exceptions.

Comment thread scripts/guardian_review.py Outdated
Comment thread tests/unit/test_guardian_workflow.py Outdated
Comment thread tests/unit/test_guardian_workflow.py Outdated
…ghter

Review of #731 found that reading the PR as data was not yet enough: the
collector followed symlinks. A PR replacing CONTRIBUTING.md (read on every
review) with a link to /proc/self/environ would put the provider keys in the
prompt, and a prompt injection could carry them into the public comment.

- collector.contained(): read_file, collect_full_files and the drift
  patterns.yaml read only paths that resolve inside the checkout; links that
  stay inside still work, as with `cgis ingest`.
- test_guardian_workflow.py now fails on each regression the review tried:
  `git fetch && uv sync` in pr/, `uv sync --directory pr`,
  `python pr/scripts/...`, a dropped working-directory, job-level
  GUARDIAN_EVIDENCE, and the PR back at the root. Gemini: safe `.get("with")`.
- guardian_review.reviewed_root resolves this repository once (Gemini).
- Comments that said production runs GUARDIAN_EVIDENCE=1 or that CI ingests
  ./src now say what is true since #730; the metrics restore notes that it
  fetches anonymously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zaebee

zaebee commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

/guardian review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary: Checked the path containment in contained() against symlinks, '..' and loops, and it holds. Checked the --repo-root gating and the test fixtures, and they are consistent with the code. Checked the workflow guard regexes for ways to reach into pr/ and found no bypass of the listed forms.


🤖 claude-haiku-5-5 · 21,472 prompt + 6,839 completion = 28,311 tokens · graph 2/9 files (22%)

Comment thread .github/workflows/guardian.yml
Comment thread src/cgis/guardian/collector.py
Comment thread .github/workflows/guardian.yml
github-actions Bot added a commit that referenced this pull request Oct 22, 2026
zaebee and others added 2 commits October 22, 2026 09:22
…nymous fetch

From guardian's own review of this PR (Haiku, first production run):
- the drift path's refusal of a symlinked patterns.yaml had no test; it now
  has one, seen failing with the guard reverted;
- the base-branch fetch in pr/ is anonymous, which a private-repository
  copy of the job must replace with a token; said next to the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… collector

Re-review of #731:
- working-directory is compared as a path, so `./pr` or `pr/` counts as the
  PR checkout; no step may use a local action (`uses: ./...`) or point an
  action's working-directory at pr/; `--project ./pr` and `pushd pr` are
  caught too. The allowed mentions are anchored instead of space-suffixed,
  so `--repo-root=pr` reads as allowed. Each respelling was seen failing.
- collect_full_files skips a changed path that is not a file, and both
  readers replace undecodable bytes: a PR could otherwise end a review with
  IsADirectoryError or UnicodeDecodeError.
- read_file's docstring says it also refuses paths outside the checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zaebee

zaebee commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@zaebee

zaebee commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

/guardian review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary: Checked the symlink containment in contained() and its use in read_file, collect_full_files and collect_drift: links that leave the checkout return None and reads stay inside, and the new tests cover those cases. Checked the --repo-root and GUARDIAN_EVIDENCE refusal logic in reviewed_root, which handles the foreign-checkout case correctly. Checked the workflow guard regexes in test_guardian_workflow.py against the new run lines and found no false pass or false failure.


🤖 claude-haiku-5-5 · 23,169 prompt + 5,818 completion = 28,987 tokens · graph 2/9 files (22%)

- name: Checkout trusted tooling (default branch)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.repository.default_branch }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 [Logic Bug] — Root checkout drops credentials that the metrics save step may still rely on

The root checkout now sets persist-credentials: false, so any later step in this job that pushes to data/guardian-metrics with git and relied on the persisted token would fail, and the metrics history would stop updating; the save step is not in this diff, so this cannot be verified here.

Fix: Confirm the save-metrics step passes an explicit token for its push, or restore persist-credentials on the step that pushes, and add a test asserting that push step's credentials.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Same as the round-1 thread above: save-metrics is a separate job with its own checkout of main, default persist-credentials: true and contents: write. Only the review job's two checkouts drop credentials, and that job pushes nothing. No change.

source_root must match the ingest root used to build the graph DB (CI
runs `cgis ingest ./src`): changed-file paths are stripped of it before
lookup. project_root locates docs/ontology/patterns.yaml for drift.
runs `cgis ingest pr/src` with project_root `pr`): changed-file paths

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 [Library Contract] — source_root docstring changed to pr/src but no code path in the diff updates the stripping prefix

Changed-file paths from git are repo-relative and are stripped of source_root before graph lookup; if the caller still passes the old ingest root, FQN lookups may miss under the new pr/src ingest layout.

Fix: Verify the caller that constructs GraphContextCollector passes the root that matches the pr/src ingest, and add an integration test that exercises the workflow's ingest root.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified in the first independent review of this PR. cgis ingest pr/src run from the root and cgis ingest ./src run inside pr/ produce the same 3197 node ids. The collector, built with project_root=pr and the default source_root="src", strips src/ from repo-relative changed paths and found the graph for a changed file. The caller is guardian_review.py, which leaves source_root at its default. Only the docstring changed. No change.

from typing import Any

import pytest
import yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 [Test Coverage] — Test depends on PyYAML, which the diff does not add to dependencies

If PyYAML is not in the project's dev or guardian extras, the new workflow test fails at import in CI.

Fix: Confirm pyyaml is declared in the dev dependency group, or parse the workflow without it.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

PyYAML is a main dependency (pyyaml>=6.0.2, pyproject.toml:48), and CI ran these tests green. No change.

github-actions Bot added a commit that referenced this pull request Oct 22, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces security measures to treat pull requests as untrusted data rather than code, preventing the execution of untrusted scripts or checkers while provider keys are present. It adds a --repo-root option to specify the checkout directory, restricts evidence collection on foreign checkouts, and implements a path containment check (contained) to prevent symlink directory traversal attacks. Feedback on these changes suggests returning the resolved canonical path in the containment check and catching ValueError to handle invalid path characters robustly.

Comment on lines +80 to +84
try:
if path.resolve().is_relative_to(root.resolve()):
return path
except (OSError, RuntimeError): # a loop of links
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To improve robustness and prevent potential issues with unresolved relative paths or symlinks in downstream components (such as DriftScorer), consider resolving the path once and returning the resolved canonical path. Additionally, catching ValueError is recommended to handle cases where path operations might fail due to invalid characters (e.g., embedded null bytes on certain platforms).

Suggested change
try:
if path.resolve().is_relative_to(root.resolve()):
return path
except (OSError, RuntimeError): # a loop of links
return None
try:
resolved = path.resolve()
if resolved.is_relative_to(root.resolve()):
return resolved
except (OSError, RuntimeError, ValueError): # a loop of links or invalid path
return None

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Applied in 0a2bddf. contained() returns the resolved path it checked, and also catches ValueError. Tests: test_contained_returns_the_resolved_path and test_read_file_refuses_a_path_with_a_null_byte. Both were seen failing first.

… null byte

From Gemini's second review of #731. The caller now reads the resolved path
that passed the check rather than a spelling that resolves again, and a path
with a null byte (ValueError from resolve) reads as absent instead of
crashing the review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@zaebee
zaebee merged commit 113ff81 into main Oct 22, 2026
4 checks passed
@zaebee
zaebee deleted the fix/730-guardian-trusted-checkout branch October 22, 2026 09:44
zaebee added a commit that referenced this pull request Oct 22, 2026
* ci(guardian): a reusable review workflow other repositories call

The review job moves to .github/workflows/guardian-reusable.yml
(`on: workflow_call`). guardian.yml becomes its first caller, at the commit
the event ran on; its save-metrics and rate jobs are unchanged.

The workflow takes these inputs: pr_number, tooling_ref (the
codegraph-brain commit whose tooling runs), source_root, runner (JSON
runs-on) and save_metrics. Secrets and vars are the caller's, so an
organisation can set them once.

The shape from #731 and #734 is kept:
- tooling is codegraph-brain at tooling_ref, and the PR is data in pr/;
- only one fixed git fetch runs there, now authenticated with the
  caller's GITHUB_TOKEN for this one command, so private repositories work;
- the app token is scoped to the caller's repository and to
  pull-requests: write.
The write-access check lives in the reusable job, so no caller can drop it.

guardian_review.py gains --source-root, which refuses an absolute path or
`..`. The workflow tests now pin the reusable file and the caller.

Closes #739

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(guardian): let a caller in another org pass the secrets, and tighten the job

Review of #740:
- `secrets: inherit` stops at the organisation, so an Ownima caller of this
  user-owned workflow got no keys and could not pass any. The four secrets
  are now declared under workflow_call (optional), and the header shows the
  explicit form, the Actions policy the caller needs, and the
  same-SHA pin for uses: and tooling_ref.
- A step fails fast when tooling_ref predates the workflow (no
  --source-root in the script), instead of on an unknown flag.
- The base fetch takes the token from git's environment config, not argv,
  scoped to github.com and masked as actions/checkout masks it.
- The job refuses an event other than issue_comment with a plain message.
- save_metrics says it reads codegraph-brain's own branch.
- Tests: the pr/ step's commands and env are pinned exactly (the old
  no-secret check no longer looked at github.token); the validation step's
  own shell runs for five cases; the restore's `if`, the declared secrets,
  the app token's owner, the caller's whole `if` and an absolute
  --source-root are covered. Each new pin was seen failing on its mutation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(guardian): refuse a backslash in source_root

From Gemini's review of #740: a backslash has no use in a repository path and
would hide a `..` on a platform that treats it as a separator. Refused in
guardian_review.py and in the workflow's own check, with tests for `..\\x`,
`C:\\x` and `a/../..`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(guardian): one source_root rule in shell and Python, portable base64

From the second review round of #740 (guardian and Gemini):
- The workflow's shell check and relative_source_root now apply the same
  rule. `..` counts only as a path component (`my..pkg` is fine); an
  absolute path, a drive (C:/x), a backslash or any whitespace is refused.
  Whitespace is refused, not stripped, because the ingest gets the raw
  value. An empty value (the whole repository) is accepted by both.
- The credential is encoded with `base64 | tr -d '\n'` rather than the
  GNU-only `-w0`, since the runner input allows self-hosted machines.
- Tests: normalisation of '', '.', './src', 'src/'; refusals of whitespace
  and drives; the real ContextCollector built with source_root; the shell
  check for my..pkg, a/.., ' src', '' and ./src.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(guardian): name the two steps that read the app token

From guardian's third review of #740: "only the two posting steps" read as
excluding the review step, which is the one that posts the inline review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

ci(guardian): the review job runs PR code while provider keys are in its environment

1 participant