Skip to content

Link repositories into a workspace

A threat that crosses a service boundary belongs to neither repository on its own. The gateway knows it proxies a refund; the billing service knows it debits a ledger; only together do they describe the risk.

You will link two repositories into a workspace, produce a report from each, and merge them into one model. Everything here was run against GuardLink 2.2.0.

  • GuardLink installed. See Install GuardLink.
  • Two repositories you can commit to. This page uses gateway and billing.
  • Each one initialised with guardlink init, and annotated. link-project reports a repository with no prior setup as auto-initialized, but what it writes there is .guardlink/workspace.yaml and agent instruction files: no config.json and no definitions.js. Run guardlink init in each repository as well.

Each repository stays self-contained. It parses, validates, and gates on its own model exactly as before. The workspace is a second pass over the reports those repositories publish.

flowchart TB
    G["gateway<br/>its own annotations"] --> GR["guardlink report --format json"]
    B["billing<br/>its own annotations"] --> BR["guardlink report --format json"]
    GR --> M(["guardlink merge"])
    BR --> M
    M --> W["one model across both,<br/>plus a workspace dashboard"]
    M --> U["cross-repo references<br/>it could not resolve"]

    class W,U emphasis

Nothing about the per-repo loop changes. That matters for adoption: a service team keeps its own gate, and the workspace is assembled from what they already publish rather than from a central checkout of everything.

  1. Point link-project at the repositories and name the workspace.

    Terminal window
    npx guardlink link-project gateway billing -w payments
    Linking 2 repos into workspace "payments"...
    ⚡ gateway — auto-initialized (no prior guardlink setup)
    ⚡ billing — auto-initialized (no prior guardlink setup)
    ✓ gateway — workspace.yaml written, agent files updated
    ✓ billing — workspace.yaml written, agent files updated
    ✓ Linked 2 repo(s) into "payments"
    ↻ Updated 4 agent instruction file(s)
    Next steps:
    1. Review and commit .guardlink/workspace.yaml in each repo
    2. Add "guardlink report --format json -o guardlink-report.json" to each repo's CI
    3. Use "guardlink merge" to combine reports into a unified dashboard
  2. Read what it wrote. Each repository gets its own copy, naming itself and its siblings:

    gateway/.guardlink/workspace.yaml
    workspace: payments
    this_repo: gateway
    repos:
    - name: gateway
    - name: billing

    this_repo is what makes the file repository-specific, so the two copies are not identical and both are committed.

A cross-repo reference is written #<repo>.<tag>, taking the repository name from workspace.yaml. The gateway records that its edge flows into billing’s ledger:

gateway/.guardlink/annotations/src/routes.js.gal
@source file:src/routes.js line:1 symbol:handleRefund
@flows #edge -> #billing.ledger via POST./billing/refund
@exposes #edge to #unauth-refund [critical] -- "No authorization check before proxying"

Each repository publishes a JSON report. In CI that is a step; here it is one command each.

  1. Produce a report per repository.

    Terminal window
    (cd gateway && npx guardlink report . --format json -o guardlink-report.json)
    (cd billing && npx guardlink report . --format json -o guardlink-report.json)
  2. Merge them.

    Terminal window
    npx guardlink merge gateway/guardlink-report.json billing/guardlink-report.json -w payments --summary-only
    Merging 2 report(s)...
    ✓ payments — 2/2 repos loaded
    7 annotations | 2 assets | 1 threats | 1 controls
    1 mitigations | 1 exposures | 1 unmitigated
    0 confirmed | 1 flows | 0 refs resolved | 2 unresolved
    0 unreadable annotation(s) (0 error(s))
    ⚠ Tag "#billing.ledger" referenced in gateway but not defined in any repo (prefix suggests repo "billing" but no definition found)
    ⚠ Tag "#gateway.unauth-refund" referenced in billing but not defined in any repo (prefix suggests repo "gateway" but no definition found)

    Truncated after the console summary. --summary-only then prints the same figures as a markdown summary, with a table per section and a list of the unresolved references, and ends with the advisory line below.

The two counters at the end of the fourth line are the ones to watch. refs resolved is how much of your cross-repo wiring joined up. unresolved is how much did not, and the warning under it names the repository the prefix pointed at, so you can tell a typo from a genuinely missing definition. confirmed is counted separately from unmitigated because an @accepts silences a reproduced exploit nowhere in the product, and the unreadable annotation(s) line is the denominator for all of it: a repository whose annotations did not parse is not a repository with nothing wrong.

--summary-only prints the text and skips the dashboard. Without it, merge also writes workspace-dashboard.html, a self-contained page you can open directly or publish from CI. --json <file> writes the merged model, and --diff-against <file> compares against a previous merge for a weekly summary. Advisory by default, and --strict exits 1 on an unmitigated exposure, a reproduced @confirmed exploit, or a report it could not read:

Advisory — nothing here failed the build. Run with --strict to gate on it.

guardlink estate reads back the file merge --json wrote, so the estate answer and the merge’s own totals come from one computation rather than two:

Terminal window
npx guardlink estate merged.json
payments — estate risk, from 2/2 repo report(s)
merged 2026-10-08T06:29:33.643Z
Repositories
⚠ gateway — 1 open, 0 confirmed
✓ billing — 0 open, 0 confirmed
1 open exposure(s) — 1 critical
gateway
[critical] #edge → #unauth-refund
gateway/src/routes.js:1 No authorization check before proxying

Truncated after the open list. The output continues with the merge’s warnings under From the merge:, a note on tag ownership, and the advisory line.

It says how much of the estate it read first, because “0 open” over 0 of 4 repositories is the same vacuous green one level up — an unexpanded glob and a pipeline that never uploaded both reach it. --strict exits 1 on an estate that read nothing. It also refuses a per-repo threat-model.json: both are model-shaped JSON, and a lenient reader would answer “0 repos, 0 open” from a file describing one repository.

Two repositories that each declare the same tag are not an error. The merge picks one as authoritative and says which:

⚠️ Tag "inj" defined in svc-a (owner) and also in: svc-b

Worth reading rather than ignoring. Two services that both define #inj usually mean two teams described the same threat separately, and the merged model will carry one description for both.

The pattern the tool suggests, and the one the counters are built for:

Terminal window
# in each service repository, on merge to the default branch
npx guardlink validate .
npx guardlink report . --format json -o guardlink-report.json # publish this artifact
# in the workspace repository, on a schedule
npx guardlink merge reports/*.json -w payments \
--json merged.json --diff-against last-week.json

Keep each service’s own gate as it is. The workspace pass answers a different question, and a service team should not have its build broken by a sibling’s annotations.

Both halves ship as workflow files you can copy rather than transcribe: examples/ci/per-repo-report.yml goes in each service repository, and examples/ci/workspace-merge.yml goes in the one that publishes the dashboard. Their README covers the personal access token the merge job needs to read artifacts across repositories.