guardlink 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +39 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +243 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +38 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/parser/parse-project.d.ts.map +1 -1
- package/dist/parser/parse-project.js +103 -0
- package/dist/parser/parse-project.js.map +1 -1
- package/dist/tui/commands.d.ts +3 -0
- package/dist/tui/commands.d.ts.map +1 -1
- package/dist/tui/commands.js +240 -1
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +17 -1
- package/dist/tui/index.js.map +1 -1
- package/dist/types/index.d.ts +39 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/workspace/index.d.ts +12 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +9 -0
- package/dist/workspace/index.js.map +1 -0
- package/dist/workspace/link.d.ts +91 -0
- package/dist/workspace/link.d.ts.map +1 -0
- package/dist/workspace/link.js +581 -0
- package/dist/workspace/link.js.map +1 -0
- package/dist/workspace/merge.d.ts +104 -0
- package/dist/workspace/merge.d.ts.map +1 -0
- package/dist/workspace/merge.js +752 -0
- package/dist/workspace/merge.js.map +1 -0
- package/dist/workspace/metadata.d.ts +34 -0
- package/dist/workspace/metadata.d.ts.map +1 -0
- package/dist/workspace/metadata.js +181 -0
- package/dist/workspace/metadata.js.map +1 -0
- package/dist/workspace/types.d.ts +134 -0
- package/dist/workspace/types.d.ts.map +1 -0
- package/dist/workspace/types.js +12 -0
- package/dist/workspace/types.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to GuardLink CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.0] — 2026-02-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Workspace**: Multi-repo workspace support — link N service repos into a unified threat model with cross-repo tag resolution, weekly diff tracking, and merged dashboards
|
|
13
|
+
- **Workspace**: `guardlink link-project <repos...> --workspace <name> --registry <url>` — scaffold workspace.yaml in each repo, auto-detect repo names from git/package.json/Cargo.toml, inject cross-repo context into agent instruction files
|
|
14
|
+
- **Workspace**: `guardlink link-project --add <repo> --from <existing>` — add a repo to an existing workspace with sibling auto-discovery
|
|
15
|
+
- **Workspace**: `guardlink link-project --remove <name> --from <existing>` — remove a repo from workspace, update all siblings found on disk
|
|
16
|
+
- **Workspace**: `guardlink merge <files...>` — merge N per-repo report JSONs into a unified MergedReport with tag registry, cross-repo reference resolution, stale/schema warnings, and aggregated stats
|
|
17
|
+
- **Workspace**: `--diff-against <prev.json>` flag on merge for week-over-week risk tracking (assets/threats/mitigations/exposures added/removed, risk trend, unresolved ref changes)
|
|
18
|
+
- **Workspace**: `-o <file>` dashboard HTML output + `--json <file>` merged JSON output + `--summary-only` text mode
|
|
19
|
+
- **CLI**: `guardlink report --format json` — JSON report output with metadata (repo, workspace, commit SHA, schema version)
|
|
20
|
+
- **TUI**: `/workspace` — show workspace config, sibling repos, registries
|
|
21
|
+
- **TUI**: `/link` — link repos with `--add`/`--remove` support
|
|
22
|
+
- **TUI**: `/merge` — merge reports with `--json`, `--diff-against`, `-o` flags
|
|
23
|
+
- **MCP**: `guardlink_workspace_info` tool — returns workspace name, this_repo identity, sibling tag prefixes, and cross-repo annotation rules for agents
|
|
24
|
+
- **Parser**: External reference detection — scans relationship annotations for tags with dot-prefix matching sibling repo names from workspace.yaml, populates `ThreatModel.external_refs`
|
|
25
|
+
- **Types**: `ExternalRef` interface, `ThreatModel.external_refs` field, `ReportMetadata` with repo/workspace/commit_sha/schema_version
|
|
26
|
+
- **CI**: `examples/ci/per-repo-report.yml` — per-repo workflow: validate on PRs (diff + SARIF + PR comment), generate + upload report JSON on push to main
|
|
27
|
+
- **CI**: `examples/ci/workspace-merge.yml` — weekly workspace merge workflow: download all repo artifacts, merge, dashboard, weekly diff, optional GitHub Pages + Slack
|
|
28
|
+
- **Docs**: `docs/WORKSPACE.md` — multi-repo setup guide, workspace.yaml spec, cross-repo annotation rules, merge behavior, CI integration, weekly workflow
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **MCP**: Server version bumped to 1.4.0
|
|
33
|
+
|
|
8
34
|
## [1.3.0] — 2026-02-27
|
|
9
35
|
|
|
10
36
|
### Added
|
package/README.md
CHANGED
|
@@ -151,6 +151,7 @@ GuardLink ships an MCP server and behavioral directives for AI coding agents. Af
|
|
|
151
151
|
| `guardlink_dashboard` | Generate HTML dashboard |
|
|
152
152
|
| `guardlink_sarif` | Export SARIF 2.1.0 |
|
|
153
153
|
| `guardlink_diff` | Compare threat model against a git ref |
|
|
154
|
+
| `guardlink_workspace_info` | Workspace config, sibling repos, tag prefixes for cross-repo annotations |
|
|
154
155
|
|
|
155
156
|
**Resources:** `guardlink://model`, `guardlink://definitions`, `guardlink://config`
|
|
156
157
|
|
|
@@ -179,6 +180,11 @@ GuardLink ships an MCP server and behavioral directives for AI coding agents. Af
|
|
|
179
180
|
| `guardlink clear [dir]` | Remove all annotations from source files (with `--dry-run` preview) |
|
|
180
181
|
| `guardlink sync [dir]` | Sync agent instruction files with current threat model |
|
|
181
182
|
| `guardlink unannotated [dir]` | List source files with no annotations |
|
|
183
|
+
| `guardlink link-project <repos...>` | Link repos into a shared workspace for cross-repo threat modeling |
|
|
184
|
+
| `guardlink link-project --add <repo>` | Add a repo to an existing workspace |
|
|
185
|
+
| `guardlink link-project --remove <name>` | Remove a repo from a workspace |
|
|
186
|
+
| `guardlink merge <files...>` | Merge per-repo report JSONs into a unified workspace dashboard |
|
|
187
|
+
| `guardlink report --format json` | Generate report JSON with metadata (repo, workspace, commit SHA) |
|
|
182
188
|
| `guardlink config` | Set AI provider and API key |
|
|
183
189
|
| `guardlink mcp` | Start MCP server for AI agent integration |
|
|
184
190
|
|
|
@@ -282,6 +288,10 @@ jobs:
|
|
|
282
288
|
|
|
283
289
|
See [`examples/github-action.yml`](examples/github-action.yml) for a full example with PR comments and SARIF upload.
|
|
284
290
|
|
|
291
|
+
### Multi-Repo CI
|
|
292
|
+
|
|
293
|
+
For workspace setups, GuardLink provides two additional workflow templates: a per-repo workflow that generates report JSON artifacts on every push, and a workspace merge workflow that runs weekly to combine all repos into a unified dashboard. See the [CI setup guide](examples/ci/README.md) for step-by-step instructions.
|
|
294
|
+
|
|
285
295
|
### What CI Catches
|
|
286
296
|
|
|
287
297
|
- **New route, no annotations:** `guardlink diff` shows "+1 endpoint, 0 mitigations" — the team sees the gap.
|
|
@@ -294,6 +304,35 @@ See [`examples/github-action.yml`](examples/github-action.yml) for a full exampl
|
|
|
294
304
|
|
|
295
305
|
---
|
|
296
306
|
|
|
307
|
+
## Multi-Repo Workspaces
|
|
308
|
+
|
|
309
|
+
In microservices architectures, a single repo only has part of the security picture. `PaymentService` is defined in `repo-payments`, exposed in `repo-gateway`, mitigated in `repo-auth-lib`. GuardLink workspaces link these repos so the threat model spans service boundaries.
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
# Link three repos into a workspace
|
|
313
|
+
guardlink link-project ./payment-svc ./auth-lib ./api-gateway \
|
|
314
|
+
--workspace acme-platform
|
|
315
|
+
|
|
316
|
+
# Each repo gets .guardlink/workspace.yaml + agent files updated with cross-repo context
|
|
317
|
+
# Agents now know about sibling services and use tag prefixes like #payment-svc.refund
|
|
318
|
+
|
|
319
|
+
# Generate per-repo JSON reports (in each repo or in CI)
|
|
320
|
+
guardlink report --format json -o guardlink-report.json
|
|
321
|
+
|
|
322
|
+
# Merge all reports into a unified dashboard
|
|
323
|
+
guardlink merge payment-svc.json auth-lib.json api-gateway.json \
|
|
324
|
+
-o dashboard.html --json merged.json
|
|
325
|
+
|
|
326
|
+
# Week-over-week diff for security leads
|
|
327
|
+
guardlink merge *.json --diff-against last-week.json --json merged.json
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Annotations reference sibling repos by tag prefix — `@flows #request from #api-gateway.router to #payment-svc.refund` — and these references resolve during merge. `guardlink validate` flags them as external refs locally, but they're expected and won't block CI.
|
|
331
|
+
|
|
332
|
+
For automated weekly dashboards, see the [CI setup guide](examples/ci/README.md). Full workspace documentation: [docs/WORKSPACE.md](docs/WORKSPACE.md).
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
297
336
|
## Real-World Results
|
|
298
337
|
|
|
299
338
|
We tested GuardLink + Claude Code on [vuln-node.js-express.js-app](https://github.com/SirAppSec/vuln-node.js-express.js-app), a deliberately vulnerable Express.js application with 37 documented vulnerability types.
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* guardlink mcp Start MCP server (stdio) for Claude Code, Cursor, etc.
|
|
19
19
|
* guardlink tui [dir] Interactive TUI with slash commands + AI chat
|
|
20
20
|
* guardlink gal Display GAL annotation language quick reference
|
|
21
|
+
* guardlink link-project <repos...> Link repos into a shared workspace
|
|
22
|
+
* guardlink merge <files...> Merge repo reports into unified dashboard
|
|
21
23
|
*
|
|
22
24
|
* @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve"
|
|
23
25
|
* @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design"
|
package/dist/cli/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG"}
|
package/dist/cli/index.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* guardlink mcp Start MCP server (stdio) for Claude Code, Cursor, etc.
|
|
19
19
|
* guardlink tui [dir] Interactive TUI with slash commands + AI chat
|
|
20
20
|
* guardlink gal Display GAL annotation language quick reference
|
|
21
|
+
* guardlink link-project <repos...> Link repos into a shared workspace
|
|
22
|
+
* guardlink merge <files...> Merge repo reports into unified dashboard
|
|
21
23
|
*
|
|
22
24
|
* @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve"
|
|
23
25
|
* @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design"
|
|
@@ -46,6 +48,7 @@ import { generateDashboardHTML } from '../dashboard/index.js';
|
|
|
46
48
|
import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt } from '../agents/index.js';
|
|
47
49
|
import { resolveConfig, saveProjectConfig, saveGlobalConfig, loadProjectConfig, loadGlobalConfig, maskKey, describeConfigSource } from '../agents/config.js';
|
|
48
50
|
import { getReviewableExposures, applyReviewAction, formatExposureForReview, summarizeReview } from '../review/index.js';
|
|
51
|
+
import { populateMetadata, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace } from '../workspace/index.js';
|
|
49
52
|
import gradient from 'gradient-string';
|
|
50
53
|
const program = new Command();
|
|
51
54
|
const ASCII_LOGO = `
|
|
@@ -266,9 +269,10 @@ program
|
|
|
266
269
|
.description('Generate a threat model report with Mermaid diagram')
|
|
267
270
|
.argument('[dir]', 'Project directory to scan', '.')
|
|
268
271
|
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
269
|
-
.option('-o, --output <file>', 'Write report to file
|
|
272
|
+
.option('-o, --output <file>', 'Write report to file')
|
|
273
|
+
.option('-f, --format <fmt>', 'Output format: md, json, or both (default: md)', 'md')
|
|
270
274
|
.option('--diagram-only', 'Output only the Mermaid diagram, no report wrapper')
|
|
271
|
-
.option('--json', 'Also output threat-model.json alongside the report')
|
|
275
|
+
.option('--json', 'Also output threat-model.json alongside the report (legacy; prefer --format)')
|
|
272
276
|
.action(async (dir, opts) => {
|
|
273
277
|
const root = resolve(dir);
|
|
274
278
|
const { model, diagnostics } = await parseProject({ root, project: opts.project });
|
|
@@ -278,9 +282,11 @@ program
|
|
|
278
282
|
printDiagnostics(errors);
|
|
279
283
|
console.error(`Fix errors above before generating report.\n`);
|
|
280
284
|
}
|
|
285
|
+
// Enrich with provenance metadata (git SHA, branch, workspace, schema version)
|
|
286
|
+
const enrichedModel = populateMetadata(model, root);
|
|
281
287
|
if (opts.diagramOnly) {
|
|
282
288
|
// Just output Mermaid
|
|
283
|
-
const mermaid = generateMermaid(
|
|
289
|
+
const mermaid = generateMermaid(enrichedModel);
|
|
284
290
|
if (opts.output) {
|
|
285
291
|
const { writeFile } = await import('node:fs/promises');
|
|
286
292
|
await writeFile(opts.output, mermaid + '\n');
|
|
@@ -289,19 +295,23 @@ program
|
|
|
289
295
|
else {
|
|
290
296
|
console.log(mermaid);
|
|
291
297
|
}
|
|
298
|
+
return;
|
|
292
299
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
300
|
+
const { writeFile } = await import('node:fs/promises');
|
|
301
|
+
const wantJson = opts.format === 'json' || opts.format === 'both' || opts.json;
|
|
302
|
+
const wantMd = opts.format === 'md' || opts.format === 'both' || opts.json;
|
|
303
|
+
if (wantMd) {
|
|
304
|
+
const report = generateReport(enrichedModel);
|
|
305
|
+
const mdFile = opts.output || (opts.format === 'md' ? 'threat-model.md' : 'threat-model.md');
|
|
306
|
+
await writeFile(resolve(root, mdFile), report + '\n');
|
|
307
|
+
console.error(`✓ Wrote threat model report to ${mdFile}`);
|
|
308
|
+
}
|
|
309
|
+
if (wantJson) {
|
|
310
|
+
const jsonFile = opts.output && opts.format === 'json'
|
|
311
|
+
? opts.output
|
|
312
|
+
: (opts.output || 'threat-model').replace(/\.md$/, '') + '.json';
|
|
313
|
+
await writeFile(resolve(root, jsonFile), JSON.stringify(enrichedModel, null, 2) + '\n');
|
|
314
|
+
console.error(`✓ Wrote threat model JSON to ${jsonFile} (schema v${enrichedModel.metadata?.schema_version})`);
|
|
305
315
|
}
|
|
306
316
|
});
|
|
307
317
|
// ─── diff ────────────────────────────────────────────────────────────
|
|
@@ -971,6 +981,224 @@ program
|
|
|
971
981
|
console.error(`✓ Dashboard generated: ${outFile}`);
|
|
972
982
|
console.error(` Open in browser to view. Toggle ☀️/🌙 for light/dark mode.`);
|
|
973
983
|
});
|
|
984
|
+
// ─── link-project ────────────────────────────────────────────────────
|
|
985
|
+
program
|
|
986
|
+
.command('link-project')
|
|
987
|
+
.description('Link repos into a shared workspace for cross-repo threat modeling')
|
|
988
|
+
.argument('[repos...]', 'Repo directories to link (fresh setup: 2+ paths)')
|
|
989
|
+
.option('-w, --workspace <n>', 'Workspace name (fresh link only)', 'workspace')
|
|
990
|
+
.option('-r, --registry <url>', 'GitHub/GitLab org base URL (e.g. github.com/unstructured)')
|
|
991
|
+
.option('--add <path>', 'Add a new repo to an existing workspace (provide path to new repo)')
|
|
992
|
+
.option('--remove <name>', 'Remove a repo from the workspace by name')
|
|
993
|
+
.option('--from <path>', 'Existing workspace repo to read config from (used with --add or --remove)')
|
|
994
|
+
.action(async (repos, opts) => {
|
|
995
|
+
let result;
|
|
996
|
+
if (opts.remove) {
|
|
997
|
+
// ── Remove mode: remove a repo from an existing workspace ──
|
|
998
|
+
if (!opts.from) {
|
|
999
|
+
console.error('⚠ --remove requires --from <existing-workspace-repo-path>');
|
|
1000
|
+
console.error(' Example: guardlink link-project --remove payment-svc --from ./api-gateway');
|
|
1001
|
+
process.exit(1);
|
|
1002
|
+
}
|
|
1003
|
+
console.error(`Removing "${opts.remove}" from workspace...`);
|
|
1004
|
+
console.error(` Reference repo: ${opts.from}`);
|
|
1005
|
+
console.error('');
|
|
1006
|
+
result = removeFromWorkspace({
|
|
1007
|
+
repoName: opts.remove,
|
|
1008
|
+
existingRepoPath: opts.from,
|
|
1009
|
+
});
|
|
1010
|
+
for (const name of result.updated) {
|
|
1011
|
+
console.error(` ↻ ${name} — workspace.yaml updated`);
|
|
1012
|
+
}
|
|
1013
|
+
for (const f of result.agentFilesUpdated) {
|
|
1014
|
+
if (f.includes('(cleaned)'))
|
|
1015
|
+
console.error(` 🧹 ${f}`);
|
|
1016
|
+
}
|
|
1017
|
+
for (const s of result.skipped) {
|
|
1018
|
+
console.error(` ✗ ${s.name} — ${s.reason}`);
|
|
1019
|
+
}
|
|
1020
|
+
console.error('');
|
|
1021
|
+
if (result.updated.length > 0) {
|
|
1022
|
+
console.error(`✓ Removed "${opts.remove}", updated ${result.updated.length} remaining repo(s)`);
|
|
1023
|
+
console.error('');
|
|
1024
|
+
console.error('Next steps:');
|
|
1025
|
+
console.error(' 1. Review and commit changes in each updated repo');
|
|
1026
|
+
}
|
|
1027
|
+
else if (result.skipped.length > 0) {
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else if (opts.add) {
|
|
1032
|
+
// ── Add mode: add a new repo to an existing workspace ──
|
|
1033
|
+
if (!opts.from) {
|
|
1034
|
+
console.error('⚠ --add requires --from <existing-workspace-repo-path>');
|
|
1035
|
+
console.error(' Example: guardlink link-project --add ./new-service --from ./api-gateway');
|
|
1036
|
+
process.exit(1);
|
|
1037
|
+
}
|
|
1038
|
+
console.error(`Adding repo to existing workspace...`);
|
|
1039
|
+
console.error(` New repo: ${opts.add}`);
|
|
1040
|
+
console.error(` From workspace: ${opts.from}`);
|
|
1041
|
+
console.error('');
|
|
1042
|
+
result = addToWorkspace({
|
|
1043
|
+
newRepoPath: opts.add,
|
|
1044
|
+
existingRepoPath: opts.from,
|
|
1045
|
+
registry: opts.registry,
|
|
1046
|
+
});
|
|
1047
|
+
// Report results
|
|
1048
|
+
for (const name of result.initialized) {
|
|
1049
|
+
console.error(` ⚡ ${name} — auto-initialized (no prior guardlink setup)`);
|
|
1050
|
+
}
|
|
1051
|
+
for (const name of result.linked) {
|
|
1052
|
+
console.error(` ✓ ${name} — linked to workspace`);
|
|
1053
|
+
}
|
|
1054
|
+
for (const name of result.updated) {
|
|
1055
|
+
console.error(` ↻ ${name} — workspace.yaml updated`);
|
|
1056
|
+
}
|
|
1057
|
+
for (const s of result.skipped) {
|
|
1058
|
+
console.error(` ✗ ${s.name} — skipped: ${s.reason}`);
|
|
1059
|
+
}
|
|
1060
|
+
console.error('');
|
|
1061
|
+
if (result.linked.length > 0 || result.updated.length > 0) {
|
|
1062
|
+
const total = result.linked.length + result.updated.length;
|
|
1063
|
+
console.error(`✓ ${result.linked.length} repo(s) added, ${result.updated.length} existing repo(s) updated`);
|
|
1064
|
+
if (result.agentFilesUpdated.length > 0) {
|
|
1065
|
+
console.error(` ↻ Updated ${result.agentFilesUpdated.length} agent instruction file(s)`);
|
|
1066
|
+
}
|
|
1067
|
+
// Warn about repos not found on disk
|
|
1068
|
+
// (they're in workspace.yaml but we couldn't locate their directory)
|
|
1069
|
+
console.error('');
|
|
1070
|
+
console.error('Next steps:');
|
|
1071
|
+
console.error(' 1. Review and commit changes in each updated repo');
|
|
1072
|
+
console.error(' 2. Add "guardlink report --format json" to the new repo\'s CI');
|
|
1073
|
+
console.error(' 3. Run "guardlink merge" with all repo reports');
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
console.error('✗ No repos were added. Check the paths provided.');
|
|
1077
|
+
process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
// ── Fresh link mode: link N repos together ──
|
|
1082
|
+
if (repos.length < 2) {
|
|
1083
|
+
console.error('⚠ Fresh linking requires at least 2 repo paths.');
|
|
1084
|
+
console.error(' To add a repo to an existing workspace, use:');
|
|
1085
|
+
console.error(' guardlink link-project --add <new-repo> --from <existing-repo>');
|
|
1086
|
+
process.exit(1);
|
|
1087
|
+
}
|
|
1088
|
+
console.error(`Linking ${repos.length} repos into workspace "${opts.workspace}"...`);
|
|
1089
|
+
console.error('');
|
|
1090
|
+
result = linkProject({
|
|
1091
|
+
workspace: opts.workspace,
|
|
1092
|
+
repoPaths: repos,
|
|
1093
|
+
registry: opts.registry,
|
|
1094
|
+
});
|
|
1095
|
+
for (const name of result.initialized) {
|
|
1096
|
+
console.error(` ⚡ ${name} — auto-initialized (no prior guardlink setup)`);
|
|
1097
|
+
}
|
|
1098
|
+
for (const name of result.linked) {
|
|
1099
|
+
console.error(` ✓ ${name} — workspace.yaml written, agent files updated`);
|
|
1100
|
+
}
|
|
1101
|
+
for (const s of result.skipped) {
|
|
1102
|
+
console.error(` ✗ ${s.name} — skipped: ${s.reason}`);
|
|
1103
|
+
}
|
|
1104
|
+
console.error('');
|
|
1105
|
+
if (result.linked.length > 0) {
|
|
1106
|
+
console.error(`✓ Linked ${result.linked.length} repo(s) into "${opts.workspace}"`);
|
|
1107
|
+
if (result.agentFilesUpdated.length > 0) {
|
|
1108
|
+
console.error(` ↻ Updated ${result.agentFilesUpdated.length} agent instruction file(s)`);
|
|
1109
|
+
}
|
|
1110
|
+
console.error('');
|
|
1111
|
+
console.error('Next steps:');
|
|
1112
|
+
console.error(' 1. Review and commit .guardlink/workspace.yaml in each repo');
|
|
1113
|
+
console.error(' 2. Add "guardlink report --format json -o guardlink-report.json" to each repo\'s CI');
|
|
1114
|
+
console.error(' 3. Use "guardlink merge" to combine reports into a unified dashboard');
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
console.error('✗ No repos were linked. Check the paths provided.');
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
// ─── merge ───────────────────────────────────────────────────────────
|
|
1123
|
+
program
|
|
1124
|
+
.command('merge')
|
|
1125
|
+
.description('Merge multiple repo report JSONs into a unified workspace threat model')
|
|
1126
|
+
.argument('<files...>', 'Report JSON file paths (glob supported)')
|
|
1127
|
+
.option('-o, --output <file>', 'Output file for merged dashboard HTML (default: workspace-dashboard.html)')
|
|
1128
|
+
.option('--json <file>', 'Also write merged report JSON to this file')
|
|
1129
|
+
.option('--diff-against <file>', 'Compare against a previous merged JSON for weekly summary')
|
|
1130
|
+
.option('-w, --workspace <name>', 'Workspace name (auto-detected from reports if not set)')
|
|
1131
|
+
.option('--summary-only', 'Print only the text summary, skip dashboard generation')
|
|
1132
|
+
.action(async (files, opts) => {
|
|
1133
|
+
const { writeFile, readFile } = await import('node:fs/promises');
|
|
1134
|
+
const { resolve: resolvePath } = await import('node:path');
|
|
1135
|
+
// Resolve file paths (support globs via shell expansion — files already expanded by shell)
|
|
1136
|
+
const resolvedFiles = files.map(f => resolvePath(f));
|
|
1137
|
+
if (resolvedFiles.length === 0) {
|
|
1138
|
+
console.error('✗ No report files provided.');
|
|
1139
|
+
process.exit(1);
|
|
1140
|
+
}
|
|
1141
|
+
console.error(`Merging ${resolvedFiles.length} report(s)...`);
|
|
1142
|
+
// Run merge
|
|
1143
|
+
const merged = await mergeReports(resolvedFiles, {
|
|
1144
|
+
workspace: opts.workspace,
|
|
1145
|
+
});
|
|
1146
|
+
const t = merged.totals;
|
|
1147
|
+
// Print summary to stderr
|
|
1148
|
+
console.error('');
|
|
1149
|
+
console.error(`✓ ${merged.workspace} — ${t.repos_loaded}/${t.repos} repos loaded`);
|
|
1150
|
+
console.error(` ${t.annotations} annotations | ${t.assets} assets | ${t.threats} threats | ${t.controls} controls`);
|
|
1151
|
+
console.error(` ${t.mitigations} mitigations | ${t.exposures} exposures | ${t.unmitigated_exposures} unmitigated`);
|
|
1152
|
+
console.error(` ${t.flows} flows | ${t.external_refs_resolved} refs resolved | ${t.external_refs_unresolved} unresolved`);
|
|
1153
|
+
// Print warnings
|
|
1154
|
+
for (const w of merged.warnings) {
|
|
1155
|
+
const icon = w.level === 'error' ? '✗' : w.level === 'warning' ? '⚠' : 'ℹ';
|
|
1156
|
+
console.error(` ${icon} ${w.message}`);
|
|
1157
|
+
}
|
|
1158
|
+
console.error('');
|
|
1159
|
+
// Write merged JSON
|
|
1160
|
+
if (opts.json) {
|
|
1161
|
+
const jsonPath = resolvePath(opts.json);
|
|
1162
|
+
await writeFile(jsonPath, JSON.stringify(merged, null, 2) + '\n');
|
|
1163
|
+
console.error(`✓ Wrote merged JSON to ${opts.json}`);
|
|
1164
|
+
}
|
|
1165
|
+
// Diff against previous
|
|
1166
|
+
if (opts.diffAgainst) {
|
|
1167
|
+
try {
|
|
1168
|
+
const prevRaw = await readFile(resolvePath(opts.diffAgainst), 'utf-8');
|
|
1169
|
+
const previous = JSON.parse(prevRaw);
|
|
1170
|
+
const diff = diffMergedReports(merged, previous);
|
|
1171
|
+
const diffMd = formatDiffSummary(diff, merged.workspace);
|
|
1172
|
+
// Write diff markdown
|
|
1173
|
+
const diffFile = (opts.json || 'workspace-merge').replace(/\.json$/, '') + '-weekly-diff.md';
|
|
1174
|
+
await writeFile(resolvePath(diffFile), diffMd + '\n');
|
|
1175
|
+
console.error(`✓ Wrote weekly diff to ${diffFile}`);
|
|
1176
|
+
// Also print a compact version to stderr
|
|
1177
|
+
const riskIcon = diff.risk_delta === 'increased' ? '🔴'
|
|
1178
|
+
: diff.risk_delta === 'decreased' ? '🟢' : '⚪';
|
|
1179
|
+
console.error(` ${riskIcon} Risk ${diff.risk_delta} since last merge`);
|
|
1180
|
+
if (diff.new_unmitigated > 0)
|
|
1181
|
+
console.error(` 🔴 +${diff.new_unmitigated} new unmitigated exposure(s)`);
|
|
1182
|
+
if (diff.resolved_unmitigated > 0)
|
|
1183
|
+
console.error(` 🟢 ${diff.resolved_unmitigated} exposure(s) now mitigated`);
|
|
1184
|
+
if (diff.mitigations_removed > 0)
|
|
1185
|
+
console.error(` ⚠️ ${diff.mitigations_removed} mitigation(s) removed`);
|
|
1186
|
+
console.error('');
|
|
1187
|
+
}
|
|
1188
|
+
catch (err) {
|
|
1189
|
+
console.error(`⚠ Could not diff against ${opts.diffAgainst}: ${err instanceof Error ? err.message : err}`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// Generate dashboard HTML (unless --summary-only)
|
|
1193
|
+
if (!opts.summaryOnly) {
|
|
1194
|
+
const html = generateDashboardHTML(merged.model);
|
|
1195
|
+
const outFile = opts.output || 'workspace-dashboard.html';
|
|
1196
|
+
await writeFile(resolvePath(outFile), html);
|
|
1197
|
+
console.error(`✓ Wrote workspace dashboard to ${outFile}`);
|
|
1198
|
+
}
|
|
1199
|
+
// Print full markdown summary to stdout (pipeable)
|
|
1200
|
+
console.log(formatMergeSummary(merged));
|
|
1201
|
+
});
|
|
974
1202
|
// ─── mcp ─────────────────────────────────────────────────────────────
|
|
975
1203
|
program
|
|
976
1204
|
.command('mcp')
|