Compare commits

...
Author SHA1 Message Date
george-keygraphandClaude Opus 5 cbf2019e7f docs: add a "Why 'Shannon'?" section to the README
Adds a short naming section under "What is Shannon?", directly after
"Why Shannon Exists", and syncs the same text into llms-full.txt.

Frames pentesting as an information problem, which is the actual link to
Claude Shannon, and closes on the "Hey Claude, run Shannon" line.

Actioned from the Aug 21 Shannon 3.0 launch planning session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:48:36 -07:00
george-keygraphandClaude Opus 5 692440135a docs: mark native Windows as community-supported, WSL2 behaves like Linux
Per the Aug 27 CI/CD integration sync: WSL2 stays fully supported (it
behaves like Linux); native Windows moves from "not supported" to
community-supported, meaning contributions are welcome but Keygraph does
not actively develop or test against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:19:30 -07:00
George Flores fabf56e574 Add files via upload 2026-08-31 10:19:30 -07:00
George Flores 85a29e2ea3 Delete assets/Shannon3GIF.gif 2026-08-31 10:19:30 -07:00
George Flores 82436dafe6 Rename Timeline 1.gif to Shannon3GIF.gif 2026-08-31 10:19:30 -07:00
George Flores 8a3fbc5286 Delete assets/Shannon3GIF.gif 2026-08-31 10:19:30 -07:00
George Flores d9748355e3 Add files via upload 2026-08-31 10:19:30 -07:00
George Flores 9a5f201f15 Rename Timeline 1.gif to Shannon3GIF.gif 2026-08-31 10:19:30 -07:00
George Flores 945fd2ce57 Delete assets/Shannon3GIF.gif 2026-08-31 10:19:30 -07:00
George Flores 4b26e677dd Add files via upload 2026-08-31 10:19:30 -07:00
George Flores c8ee22f2d1 Rename Shanon3GIF.gif to Shannon3GIF.gif 2026-08-31 10:19:30 -07:00
George Flores 529f6a7a95 Update README.md 2026-08-31 10:19:30 -07:00
George Flores 5a43c6127f Add files via upload 2026-08-31 10:19:30 -07:00
George Flores 9f7a349715 Update README.md 2026-08-31 10:19:30 -07:00
george-keygraphandClaude Opus 4.8 d017d74519 feat(cli): sunset wordmark and section hierarchy in terminal output
Add a section-chrome module (chrome.ts) and a log decorator
(log-render.ts) that give the CLI a coherent visual hierarchy beneath
the SHANNON splash, all drawn from the existing sunset ramp:

- splash.ts now sources the ramp from chrome.ts (one definition)
- start's launch info gets a solid-yellow rule under each section
  label, with aligned Label: values greyed
- streaming scan logs (logs, and start --follow) get one per-phase
  gutter bar that walks the ramp, dimmed timestamps, red on [ERROR],
  and the end-of-run summary framed in a corner panel

Named chrome.ts to avoid the existing ui.ts (spinner/step helpers).
Presentation only: the worker and workflow.log are untouched, the log
on disk stays plain text (tail/grep and the failure marker keep
working), and non-TTY / NO_COLOR output is byte-identical to before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 10:19:30 -07:00
10 changed files with 367 additions and 46 deletions

No files matched your search

+2
View File
@@ -5,3 +5,5 @@ credentials/
dist/
repos/
.turbo/
.DS_Store
+7 -1
View File
@@ -57,10 +57,16 @@ Thanks to tools like Claude Code and Cursor, your team ships code non-stop. But
Shannon closes that gap by providing on-demand, automated penetration testing that can run against every build or release.
### Why "Shannon"?
It's named after Claude Shannon, the father of information theory. At its core, pentesting is an information problem: every probe reduces uncertainty about a system's state. The best tools maximize the signal gained from every request, turning those bits of knowledge into an exploit path.
Also, we wanted you to be able to say, "Hey Claude, run Shannon" to find all the security flaws in your vibe-coded app.
## Shannon in Action
<p align="center">
<img src="assets/shannon-action.gif" alt="Shannon running an autonomous pentest" width="100%">
<img src="assets/Shannon3GIF.gif" alt="Shannon running an autonomous pentest" width="100%">
</p>
Sample penetration test reports from intentionally vulnerable applications, produced by Shannon Open Source:
+159
View File
@@ -0,0 +1,159 @@
/**
* Section chrome — the sunset-ramp hierarchy beneath the splash wordmark.
* (Distinct from ui.ts, which holds the spinner/step helpers.)
*
* Three treatments, one job each:
* rule() static blocks that print once — `start`, `status`, a log's header
* gutter() streaming output, where a section scrolls off the top of the screen
* panel() the single summary block at the end of a run
*
* Presentation only. None of this is ever written to workflow.log — the file on disk
* stays plain text so `tail`, `grep`, and the completion regex in commands/logs.ts keep
* working against it.
*/
import { supportsColor } from './tty.js';
/**
* Sunset ramp, yellow at the top row down to burnt orange at the base.
* The wordmark paints row i with stop i and edges it with stop i + 1; section chrome
* draws from the same seven stops so the hierarchy reads as one family.
* `xterm` is the 256-color approximation for terminals without 24-bit color.
*/
export const SUNSET: ReadonlyArray<{ rgb: readonly [number, number, number]; xterm: number }> = [
{ rgb: [247, 203, 45], xterm: 220 },
{ rgb: [246, 182, 38], xterm: 220 },
{ rgb: [245, 160, 32], xterm: 214 },
{ rgb: [242, 141, 28], xterm: 214 },
{ rgb: [238, 121, 24], xterm: 208 },
{ rgb: [231, 100, 21], xterm: 208 },
{ rgb: [222, 82, 19], xterm: 202 },
];
/** Half-block bar for streaming sections — the wordmark's █ at one eighth the weight. */
const BAR = '▌';
/** Columns reserved to the left of every section, matching the existing output grid. */
const INDENT = 2;
/** Rules stop here even in a wide terminal; a rule spanning 200 columns reads as a divider, not a header. */
const MAX_RULE = 64;
export interface Palette {
color: boolean;
RESET: string;
WHITE: string;
GRAY: string;
DIM: string;
RED: string;
/** The seven sunset stops, ready to emit. Empty strings when color is off. */
ramp: string[];
/** Ramp stop 0 — the solid yellow used for every static rule. */
YELLOW: string;
}
/**
* Build the escape set for the current terminal, degrading 24-bit → 256-color → bare text.
* Resolved per call rather than at import so NO_COLOR/FORCE_COLOR are honored whenever they land.
*/
export function palette(): Palette {
const color = supportsColor();
const truecolor = color && /truecolor|24bit/i.test(process.env.COLORTERM ?? '');
const ramp = SUNSET.map(({ rgb: [r, g, b], xterm }) => {
if (!color) return '';
return truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${xterm}m`;
});
return {
color,
RESET: color ? '\x1b[0m' : '',
WHITE: color ? '\x1b[1;97m' : '',
GRAY: color ? '\x1b[0;37m' : '',
DIM: color ? '\x1b[90m' : '',
RED: color ? '\x1b[0;31m' : '',
ramp,
YELLOW: ramp[0] ?? '',
};
}
/** Usable width, leaving the indent and a column of breathing room at the right edge. */
function columns(): number {
return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
}
/** Printed width of a string, ignoring any escapes already embedded in it. */
export function visibleWidth(text: string): number {
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching SGR escapes is the point
return text.replace(/\x1b\[[0-9;]*m/g, '').length;
}
/**
* Option A — a static section header: the existing label, then a solid yellow rule.
* The label keeps whatever case and punctuation it already had; only the rule is added.
* Degrades to an undecorated label when the terminal is too narrow to carry one.
*/
export function rule(label: string, indent = INDENT): string {
const { WHITE, YELLOW, RESET } = palette();
const pad = ' '.repeat(indent);
const width = Math.min(MAX_RULE, columns() - indent - 1);
const dashes = width - visibleWidth(label) - 1;
if (dashes < 2) return `${pad}${WHITE}${label}${RESET}`;
return `${pad}${WHITE}${label}${RESET} ${YELLOW}${'─'.repeat(dashes)}${RESET}`;
}
/**
* Grey the label half of an aligned `Label: value` line, leaving the value at default
* weight. Purely additive: the string's own characters are never rewritten, so alignment
* that was already correct stays correct.
*/
export function field(line: string): string {
const { GRAY, RESET } = palette();
const match = /^(\s*)([A-Za-z][A-Za-z ]*:)(\s*)(.*)$/.exec(line);
if (!match) return line;
return `${match[1]}${GRAY}${match[2]}${RESET}${match[3]}${match[4]}`;
}
/**
* Option C — one line of a streaming section, carrying the section's bar in the gutter.
* `stop` indexes the sunset ramp and wraps, so consecutive sections stay distinguishable
* however many a run produces.
*/
export function gutter(text: string, stop: number, indent = INDENT): string {
const { ramp, RESET } = palette();
const color = ramp[((stop % ramp.length) + ramp.length) % ramp.length] ?? '';
const pad = ' '.repeat(indent);
// Trailing space is dropped on empty lines so sections don't emit trailing whitespace.
return text ? `${pad}${color}${BAR}${RESET} ${text}` : `${pad}${color}${BAR}${RESET}`;
}
/**
* Option E — the framed summary block, used once at the end of a run.
* Falls back to a rule plus indented lines when the terminal is too narrow to hold the
* frame, since a box that wraps is worse than no box at all.
*/
export function panel(title: string, body: string[], indent = INDENT): string[] {
const { WHITE, YELLOW, RESET } = palette();
const pad = ' '.repeat(indent);
const titleWidth = visibleWidth(title);
const widest = body.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
const available = columns() - indent - 6;
const inner = Math.max(titleWidth + 1, widest);
if (available < inner || available < titleWidth + 3) {
return [rule(title, indent), '', ...body.map((line) => `${pad} ${line}`)];
}
const frame = (s: string): string => `${YELLOW}${s}${RESET}`;
const top = `${pad}${frame('╭─')} ${WHITE}${title}${RESET} ${frame(`${'─'.repeat(inner + 1 - titleWidth)}╮`)}`;
const bottom = `${pad}${frame(`╰${'─'.repeat(inner + 4)}╯`)}`;
const rows = body.map((line) => {
const fill = ' '.repeat(inner - visibleWidth(line));
return `${pad}${frame('│')} ${line}${fill} ${frame('│')}`;
});
return [top, ...rows, bottom];
}
+8 -2
View File
@@ -11,8 +11,10 @@ import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { watch } from 'chokidar';
import { field } from '../chrome.js';
import { fail } from '../errors.js';
import { getWorkspacesDir } from '../home.js';
import { LogRenderer } from '../log-render.js';
import { resolveRunFile } from '../paths.js';
import { resolveWorkflowId } from '../session.js';
import { waitForWorkflowClose } from '../temporal-client.js';
@@ -90,6 +92,9 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
let position = 0;
let done = false;
let sawFailure = false;
// Decorates the streamed log for the terminal; a pass-through when colour is off, so
// piped/redirected output stays byte-identical and the failure check still sees raw text.
const renderer = new LogRenderer();
const controller = new AbortController();
let watcher: ReturnType<typeof watch> | undefined;
@@ -99,7 +104,7 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
const { size } = fs.statSync(logFile);
if (size <= position) return;
const data = readRange(logFile, position, size);
process.stdout.write(data);
process.stdout.write(renderer.write(data));
position = size;
if (!sawFailure && FAILURE_MARKER.test(data)) {
sawFailure = true;
@@ -112,6 +117,7 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
function finish(): void {
if (done) return;
done = true;
process.stdout.write(renderer.end());
controller.abort();
if (watcher) {
watcher.close().finally(() => resolve({ sawFailure }));
@@ -165,7 +171,7 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
export function logs(workspaceId: string): void {
const logFile = resolveLogFile(workspaceId);
const workflowId = resolveWorkflowId(workspaceId);
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
console.error(stdoutIsTerminal() ? field(`Tailing scan log: ${logFile}`) : 'Tailing scan log');
let unreachable = false;
tailUntilComplete(logFile, {
+14 -13
View File
@@ -10,6 +10,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import * as p from '@clack/prompts';
import { field, rule } from '../chrome.js';
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
import { fail } from '../errors.js';
@@ -298,9 +299,9 @@ async function followScan(workspace: string, workspacesDir: string): Promise<nev
function printPreservedContainerHint(containerName: string): void {
console.log('');
console.log(` Worker container preserved: ${containerName}`);
console.log(` Inspect logs: docker logs ${containerName}`);
console.log(` Remove: docker rm ${containerName}`);
console.log(field(` Worker container preserved: ${containerName}`));
console.log(field(` Inspect logs: docker logs ${containerName}`));
console.log(field(` Remove: docker rm ${containerName}`));
console.log('');
}
@@ -312,19 +313,19 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
console.log('');
}
console.log(` Target: ${args.url}`);
console.log(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`);
console.log(` Workspace: ${workspace}`);
console.log(field(` Target: ${args.url}`));
console.log(field(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`));
console.log(field(` Workspace: ${workspace}`));
if (args.config) {
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
console.log(field(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`));
}
if (args.pipelineTesting) {
console.log(' Mode: Pipeline Testing');
console.log(field(' Mode: Pipeline Testing'));
}
const spec = resolveModelSpec();
if (typeof spec !== 'string') {
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
console.log(field(` Model: ${spec.providerId}:${spec.modelId}`));
}
if (!interactive) {
@@ -338,13 +339,13 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
if (!args.follow) {
const prefix = commandPrefix();
console.log('');
console.log(' Watch scan progress:');
console.log(` Live logs: ${prefix} logs ${workspace}`);
console.log(` Progress: ${prefix} status ${workspace}`);
console.log(rule('Watch scan progress:'));
console.log(field(` Live logs: ${prefix} logs ${workspace}`));
console.log(field(` Progress: ${prefix} status ${workspace}`));
}
console.log('');
console.log(' Report (when the scan finishes):');
console.log(rule('Report (when the scan finishes):'));
console.log(` ${reportPath}`);
console.log('');
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Decorates a tailed workflow.log for the terminal.
*
* The worker writes workflow.log as plain text and the CLI reads it back, so all of the
* chrome lives here on the read side. Nothing in this file changes what the log *says* —
* it adds the section treatments the plain file has no way to carry:
*
* the log header and RESUMED banner -> rule() (their ==== bars become the rule)
* everything between phases -> gutter() (one bar per phase, walking the ramp)
* the closing Scan COMPLETED block -> panel() (its ==== bars become the frame)
*
* When stdout is not a terminal the renderer is a pass-through and emits the file's bytes
* unchanged, so redirected logs, pipes, and CI keep grepping the same text they always did.
*/
import { field, gutter, palette, panel, rule } from './chrome.js';
/** The ==== bars that open and close a block; replaced by our own chrome. */
const BLOCK_BAR = /^={10,}\s*$/;
/** The ──── bar dividing a block's title from its body; replaced by the panel frame. */
const INNER_BAR = /^─{10,}\s*$/;
/** `[2026-08-26 17:04:11] ` — every streamed event line carries one. */
const TIMESTAMP = /^(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\])( .*)$/;
/** A phase transition opens a new gutter section. */
const PHASE_START = /^\[[^\]]*\] \[PHASE\] Starting: /;
/** Titles of the two banner blocks, which take the static rule treatment. */
const BANNER_TITLE = /^(Shannon Pentest - Scan Log|RESUMED)$/;
/** Title of the final block, which takes the panel treatment. Mirrors logs.ts's completion regex. */
const COMPLETION_TITLE = /^Scan (COMPLETED|FAILED)$/;
/** Dim the timestamp so the message reads first; errors take the semantic red, not a ramp colour. */
function colorizeEvent(line: string): string {
const { DIM, RED, RESET } = palette();
const match = TIMESTAMP.exec(line);
if (!match) return line;
const rest = match[2] ?? '';
const body = rest.includes('[ERROR]') ? `${RED}${rest}${RESET}` : rest;
return `${DIM}${match[1]}${RESET}${body}`;
}
type Mode = 'stream' | 'banner' | 'summary';
export class LogRenderer {
/** Bytes past the last newline, held until the rest of the line arrives. */
private carry = '';
private mode: Mode = 'stream';
/** Suppresses a second consecutive blank line; starts true so the stream can't open on one. */
private lastBlank = true;
/** Current sunset stop for the gutter bar; advanced by each phase transition. */
private stop = 0;
private summaryTitle = '';
private summaryBody: string[] = [];
private readonly passthrough: boolean;
constructor() {
this.passthrough = !palette().color;
}
/** Decorate a chunk of newly appended log text. Incomplete trailing lines are held back. */
write(chunk: string): string {
if (this.passthrough) return chunk;
const text = this.carry + chunk;
const lines = text.split('\n');
// The final element is whatever followed the last newline — possibly a partial line.
this.carry = lines.pop() ?? '';
const out: string[] = [];
for (const line of lines) {
out.push(...this.renderLine(line));
}
return this.emit(out);
}
/** Join rendered lines, dropping blank runs left behind by the bars we removed. */
private emit(lines: string[]): string {
const kept: string[] = [];
for (const line of lines) {
const blank = line === '';
if (blank && this.lastBlank) continue;
this.lastBlank = blank;
kept.push(line);
}
return kept.length ? `${kept.join('\n')}\n` : '';
}
/** Flush a held partial line and close an unterminated summary block. */
end(): string {
if (this.passthrough) return '';
const out: string[] = [];
if (this.carry) {
out.push(...this.renderLine(this.carry));
this.carry = '';
}
if (this.mode === 'summary') {
out.push(...this.closeSummary());
}
return this.emit(out);
}
private renderLine(raw: string): string[] {
// Strip the \r from CRLF logs so it never lands in the middle of a decorated line.
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
// Only a ==== bar closes the summary; its ──── divider is chrome we replace, not a terminator.
if (BLOCK_BAR.test(line)) {
return this.mode === 'summary' ? this.closeSummary() : [];
}
if (INNER_BAR.test(line)) return [];
if (COMPLETION_TITLE.test(line)) {
this.mode = 'summary';
this.summaryTitle = line;
this.summaryBody = [];
return [''];
}
if (BANNER_TITLE.test(line)) {
this.mode = 'banner';
return ['', rule(line)];
}
if (this.mode === 'summary') {
this.summaryBody.push(line);
return [];
}
if (this.mode === 'banner') {
// The banner runs until the first streamed event.
if (!TIMESTAMP.test(line)) {
return [line.trim() ? ` ${field(line)}` : ''];
}
this.mode = 'stream';
}
// A blank line separates sections; the bar resumes on the next line of content.
if (!line.trim()) return [''];
if (PHASE_START.test(line)) {
// Two stops per phase, not one: the 256-colour tier collapses the seven stops into
// four xterm colours, and a single step would give consecutive phases the same bar.
// Seven is odd, so a stride of two still visits every stop before repeating.
this.stop += 2;
}
return [gutter(colorizeEvent(line), this.stop)];
}
private closeSummary(): string[] {
const title = this.summaryTitle;
const body = [...this.summaryBody];
while (body.length && !body[body.length - 1]?.trim()) body.pop();
while (body.length && !body[0]?.trim()) body.shift();
this.mode = 'stream';
this.summaryTitle = '';
this.summaryBody = [];
const rows = body.map((line) => (line.trim() ? field(line) : ''));
return [...panel(title, rows), ''];
}
}
+2 -28
View File
@@ -3,7 +3,7 @@
* Color escapes are gated on terminal support; the Unicode art is always kept.
*/
import { supportsColor } from './tty.js';
import { palette } from './chrome.js';
/** SHANNON wordmark. Block glyphs take the row fill; box-drawing strokes take the deeper edge shade. */
const SHANNON = [
@@ -15,34 +15,8 @@ const SHANNON = [
'╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═══╝',
];
/**
* Sunset ramp, yellow at the top row down to burnt orange at the base.
* Wordmark row i is filled with stop i and edged with stop i + 1, so the
* box-drawing strokes read as a shadow one shade deeper than their row.
* `xterm` is the 256-color approximation for terminals without 24-bit color.
*/
const SUNSET: ReadonlyArray<{ rgb: readonly [number, number, number]; xterm: number }> = [
{ rgb: [247, 203, 45], xterm: 220 },
{ rgb: [246, 182, 38], xterm: 220 },
{ rgb: [245, 160, 32], xterm: 214 },
{ rgb: [242, 141, 28], xterm: 214 },
{ rgb: [238, 121, 24], xterm: 208 },
{ rgb: [231, 100, 21], xterm: 208 },
{ rgb: [222, 82, 19], xterm: 202 },
];
export function displaySplash(version?: string): void {
const color = supportsColor();
const truecolor = color && /truecolor|24bit/i.test(process.env.COLORTERM ?? '');
const RESET = color ? '\x1b[0m' : '';
const WHITE = color ? '\x1b[1;97m' : '';
const GRAY = color ? '\x1b[0;37m' : '';
const DIM = color ? '\x1b[90m' : '';
const ramp = SUNSET.map(({ rgb: [r, g, b], xterm }) => {
if (!color) return '';
return truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${xterm}m`;
});
const { color, RESET, WHITE, GRAY, DIM, ramp } = palette();
/** Color one wordmark row, emitting an escape only where the run changes. Spaces stay unpainted. */
const paint = (row: string, fill: string, edge: string): string => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 MiB

+1 -1
View File
@@ -4,7 +4,7 @@ This guide covers platform-specific notes and Docker networking behavior.
## Windows
Shannon on Windows is supported through WSL2. Native Windows, including Git Bash, is not supported.
Shannon on Windows is supported through WSL2, which behaves like Linux for everything below. Native Windows, including Git Bash, is community-supported: contributions are welcome, but Keygraph does not actively develop or test against it.
### Ensure WSL2
+7 -1
View File
@@ -66,6 +66,12 @@ Thanks to tools like Claude Code and Cursor, your team ships code non-stop. But
Shannon closes that gap by providing on-demand, automated penetration testing that can run against every build or release.
### Why "Shannon"?
It's named after Claude Shannon, the father of information theory. At its core, pentesting is an information problem: every probe reduces uncertainty about a system's state. The best tools maximize the signal gained from every request, turning those bits of knowledge into an exploit path.
Also, we wanted you to be able to say, "Hey Claude, run Shannon" to find all the security flaws in your vibe-coded app.
## Shannon in Action
<p align="center">
@@ -877,7 +883,7 @@ This guide covers platform-specific notes and Docker networking behavior.
## Windows
Shannon on Windows is supported through WSL2. Native Windows, including Git Bash, is not supported.
Shannon on Windows is supported through WSL2, which behaves like Linux for everything below. Native Windows, including Git Bash, is community-supported: contributions are welcome, but Keygraph does not actively develop or test against it.
### Ensure WSL2