feat(gsd): add directory safeguards for system/home paths (#1053)
* feat(gsd): add directory safeguards to prevent running in system/home paths GSD previously had no protection against being launched from dangerous directories like $HOME, /, /usr, or /etc. This adds layered validation: - Blocked system paths (hard stop): /, /usr, /etc, /var, $HOME, tmpdir, etc. - High entry count heuristic (>200 entries triggers confirmation dialog) - Symlink resolution via realpathSync to prevent bypass - Integrated at three chokepoints: projectRoot(), showSmartEntry(), bootstrapGsdDirectory() Includes 19 tests covering all blocked categories, boundary conditions, and the assertSafeDirectory throw/return behavior. * fix: make directory safeguard tests cross-platform (Windows CI) - Skip Unix-specific blocked path tests on Windows (/, /usr, /etc, etc.) - Add Windows-specific blocked path tests (C:\, C:\Windows) - Use platform-appropriate path separator in trailing slash test - Fix root path normalization for Windows drive letters (C:\ not C:)
This commit is contained in:
parent
ce1ad35706
commit
45bff3456c
6 changed files with 449 additions and 1 deletions
38
.plans/directory-safeguards.md
Normal file
38
.plans/directory-safeguards.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Directory Safeguards Plan
|
||||
|
||||
## Problem
|
||||
GSD had zero protection against being launched from dangerous directories like `$HOME`, `/`, `/usr`, `/etc`, etc. Running `gsd init` from these locations would create `.gsd/` and write planning files into system directories.
|
||||
|
||||
## Solution
|
||||
Added a `validate-directory.ts` module with layered safeguards:
|
||||
|
||||
### Layer 1: Blocked system paths (hard stop)
|
||||
- Filesystem roots: `/`, `/usr`, `/bin`, `/sbin`, `/etc`, `/var`, `/dev`, `/proc`, `/sys`, `/boot`, `/lib`, `/lib64`
|
||||
- macOS: `/System`, `/Library`, `/Applications`, `/Volumes`, `/private`
|
||||
- Windows: `C:\`, `C:\Windows`, `C:\Program Files`
|
||||
- User's `$HOME` directory itself (subdirs are fine)
|
||||
- System temp directory root (`os.tmpdir()`)
|
||||
|
||||
### Layer 2: High entry count heuristic (warning)
|
||||
- Directories with >200 top-level entries trigger a confirmation dialog
|
||||
- User can override if they really want to proceed
|
||||
|
||||
### Layer 3: Symlink resolution
|
||||
- All paths are resolved through `realpathSync()` before checking
|
||||
- Prevents bypassing via symlinks (e.g., `ln -s / ~/myproject`)
|
||||
|
||||
## Integration Points
|
||||
1. `projectRoot()` in `commands.ts` — gateway for all `/gsd` subcommands (throws on blocked)
|
||||
2. `showSmartEntry()` in `guided-flow.ts` — smart entry wizard (shows error/confirmation UI)
|
||||
3. `bootstrapGsdDirectory()` in `init-wizard.ts` — final safety check before writing files (throws on blocked)
|
||||
|
||||
## Test Coverage
|
||||
19 tests covering:
|
||||
- All blocked path categories (/, /usr, /etc, /var, /usr/local/bin)
|
||||
- Home directory (with and without trailing slash)
|
||||
- Temp directory root
|
||||
- Normal project directories (pass)
|
||||
- Empty directories (pass)
|
||||
- 200-entry boundary (pass) vs 210-entry (warning)
|
||||
- assertSafeDirectory throw behavior
|
||||
- Trailing slash normalization
|
||||
|
|
@ -16,6 +16,7 @@ import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
|
|||
import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js";
|
||||
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, dispatchDirectPhase } from "./auto.js";
|
||||
import { resolveProjectRoot } from "./worktree.js";
|
||||
import { assertSafeDirectory, validateDirectory } from "./validate-directory.js";
|
||||
import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js";
|
||||
import {
|
||||
getGlobalGSDPreferencesPath,
|
||||
|
|
@ -72,7 +73,9 @@ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined,
|
|||
|
||||
/** Resolve the effective project root, accounting for worktree paths. */
|
||||
function projectRoot(): string {
|
||||
return resolveProjectRoot(process.cwd());
|
||||
const root = resolveProjectRoot(process.cwd());
|
||||
assertSafeDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
export function registerGSDCommand(pi: ExtensionAPI): void {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitig
|
|||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
import { detectProjectState } from "./detection.js";
|
||||
import { showProjectInit, offerMigration } from "./init-wizard.js";
|
||||
import { assertSafeDirectory, validateDirectory } from "./validate-directory.js";
|
||||
import { showConfirm } from "../shared/mod.js";
|
||||
import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js";
|
||||
import { debugLog } from "./debug-logger.js";
|
||||
|
|
@ -1071,6 +1072,22 @@ export async function showSmartEntry(
|
|||
): Promise<void> {
|
||||
const stepMode = options?.step;
|
||||
|
||||
// ── Directory safety check — refuse to operate in system/home dirs ───
|
||||
const dirCheck = validateDirectory(basePath);
|
||||
if (dirCheck.severity === "blocked") {
|
||||
ctx.ui.notify(dirCheck.reason!, "error");
|
||||
return;
|
||||
}
|
||||
if (dirCheck.severity === "warning") {
|
||||
const proceed = await showConfirm(ctx, {
|
||||
title: "GSD — Unusual Directory",
|
||||
message: dirCheck.reason!,
|
||||
confirmLabel: "Continue anyway",
|
||||
declineLabel: "Cancel",
|
||||
});
|
||||
if (!proceed) return;
|
||||
}
|
||||
|
||||
// ── Detection preamble — run before any bootstrap ────────────────────
|
||||
if (!existsSync(join(basePath, ".gsd"))) {
|
||||
const detection = detectProjectState(basePath);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { showNextAction } from "../shared/mod.js";
|
|||
import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
|
||||
import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import { assertSafeDirectory } from "./validate-directory.js";
|
||||
import type { ProjectDetection, ProjectSignals } from "./detection.js";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -434,6 +435,9 @@ function bootstrapGsdDirectory(
|
|||
prefs: ProjectPreferences,
|
||||
signals: ProjectSignals,
|
||||
): void {
|
||||
// Final safety check before writing any files
|
||||
assertSafeDirectory(basePath);
|
||||
|
||||
const gsd = gsdRoot(basePath);
|
||||
mkdirSync(join(gsd, "milestones"), { recursive: true });
|
||||
|
||||
|
|
|
|||
222
src/resources/extensions/gsd/tests/validate-directory.test.ts
Normal file
222
src/resources/extensions/gsd/tests/validate-directory.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Unit tests for GSD Directory Validation — safeguards against dangerous directories.
|
||||
*
|
||||
* Exercises validateDirectory() and assertSafeDirectory() with:
|
||||
* - Blocked system paths (/, /usr, /etc, $HOME, C:\Windows)
|
||||
* - Temp directory root
|
||||
* - Normal project directories (should pass)
|
||||
* - Directories with many entries (warning heuristic)
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir, homedir, platform } from "node:os";
|
||||
import { validateDirectory, assertSafeDirectory } from "../validate-directory.ts";
|
||||
|
||||
const isWindows = platform() === "win32";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
`gsd-validate-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── Blocked system paths (Unix) ─────────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: root filesystem is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
assert.ok(result.reason?.includes("system directory"));
|
||||
});
|
||||
|
||||
test("validateDirectory: /usr is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/usr");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
test("validateDirectory: /etc is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/etc");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
test("validateDirectory: /var is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/var");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
test("validateDirectory: /usr/local/bin is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/usr/local/bin");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
// ─── Blocked system paths (Windows) ──────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: C:\\ is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => {
|
||||
const result = validateDirectory("C:\\");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
assert.ok(result.reason?.includes("system directory"));
|
||||
});
|
||||
|
||||
test("validateDirectory: C:\\Windows is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => {
|
||||
const result = validateDirectory("C:\\Windows");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
// ─── Home directory (cross-platform) ─────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: home directory itself is blocked", () => {
|
||||
const result = validateDirectory(homedir());
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
assert.ok(result.reason?.includes("home directory"));
|
||||
});
|
||||
|
||||
test("validateDirectory: home directory with trailing slash is blocked", () => {
|
||||
const sep = isWindows ? "\\" : "/";
|
||||
const result = validateDirectory(homedir() + sep);
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
test("validateDirectory: subdirectory of home is NOT blocked", () => {
|
||||
const dir = makeTempDir("home-subdir");
|
||||
try {
|
||||
const result = validateDirectory(dir);
|
||||
assert.equal(result.severity, "ok");
|
||||
assert.equal(result.safe, true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Temp directory root ─────────────────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: temp directory root is blocked", () => {
|
||||
const result = validateDirectory(tmpdir());
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
assert.ok(result.reason?.includes("temp directory"));
|
||||
});
|
||||
|
||||
// ─── Normal project directories ──────────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: normal project directory is safe", () => {
|
||||
const dir = makeTempDir("normal-project");
|
||||
try {
|
||||
writeFileSync(join(dir, "package.json"), "{}");
|
||||
mkdirSync(join(dir, "src"));
|
||||
const result = validateDirectory(dir);
|
||||
assert.equal(result.safe, true);
|
||||
assert.equal(result.severity, "ok");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validateDirectory: empty directory is safe", () => {
|
||||
const dir = makeTempDir("empty");
|
||||
try {
|
||||
const result = validateDirectory(dir);
|
||||
assert.equal(result.safe, true);
|
||||
assert.equal(result.severity, "ok");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── High entry count heuristic ──────────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: directory with >200 entries triggers warning", () => {
|
||||
const dir = makeTempDir("many-entries");
|
||||
try {
|
||||
for (let i = 0; i < 210; i++) {
|
||||
writeFileSync(join(dir, `file-${i.toString().padStart(4, "0")}.txt`), "");
|
||||
}
|
||||
const result = validateDirectory(dir);
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "warning");
|
||||
assert.ok(result.reason?.includes("210 entries"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validateDirectory: directory with exactly 200 entries is safe", () => {
|
||||
const dir = makeTempDir("boundary-entries");
|
||||
try {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
writeFileSync(join(dir, `file-${i.toString().padStart(4, "0")}.txt`), "");
|
||||
}
|
||||
const result = validateDirectory(dir);
|
||||
assert.equal(result.safe, true);
|
||||
assert.equal(result.severity, "ok");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── assertSafeDirectory ─────────────────────────────────────────────────────────
|
||||
|
||||
test("assertSafeDirectory: throws for blocked directories", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
assert.throws(
|
||||
() => assertSafeDirectory("/"),
|
||||
(err: Error) => err.message.includes("system directory"),
|
||||
);
|
||||
});
|
||||
|
||||
test("assertSafeDirectory: throws for home directory", () => {
|
||||
assert.throws(
|
||||
() => assertSafeDirectory(homedir()),
|
||||
(err: Error) => err.message.includes("home directory"),
|
||||
);
|
||||
});
|
||||
|
||||
test("assertSafeDirectory: returns result for warnings (does not throw)", () => {
|
||||
const dir = makeTempDir("assert-warning");
|
||||
try {
|
||||
for (let i = 0; i < 210; i++) {
|
||||
writeFileSync(join(dir, `file-${i.toString().padStart(4, "0")}.txt`), "");
|
||||
}
|
||||
const result = assertSafeDirectory(dir);
|
||||
assert.equal(result.severity, "warning");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("assertSafeDirectory: returns ok for safe directories", () => {
|
||||
const dir = makeTempDir("assert-safe");
|
||||
try {
|
||||
const result = assertSafeDirectory(dir);
|
||||
assert.equal(result.severity, "ok");
|
||||
assert.equal(result.safe, true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Trailing slash normalization ────────────────────────────────────────────────
|
||||
|
||||
test("validateDirectory: handles paths with trailing slashes", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/usr/");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
|
||||
test("validateDirectory: handles paths with multiple trailing slashes", { skip: isWindows ? "Unix-only test" : undefined }, () => {
|
||||
const result = validateDirectory("/etc///");
|
||||
assert.equal(result.safe, false);
|
||||
assert.equal(result.severity, "blocked");
|
||||
});
|
||||
164
src/resources/extensions/gsd/validate-directory.ts
Normal file
164
src/resources/extensions/gsd/validate-directory.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* GSD Directory Validation — Safeguards against running in dangerous directories.
|
||||
*
|
||||
* Prevents GSD from creating .gsd/ structures in system paths, home directories,
|
||||
* or other locations where writing project scaffolding would be harmful.
|
||||
*/
|
||||
|
||||
import { realpathSync, readdirSync } from "node:fs";
|
||||
import { homedir, platform, tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DirectoryValidationResult {
|
||||
/** Whether the directory is safe for GSD operations */
|
||||
safe: boolean;
|
||||
/** Severity: "blocked" = hard stop, "warning" = user can override */
|
||||
severity: "ok" | "blocked" | "warning";
|
||||
/** Human-readable reason if not safe */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ─── Blocked Paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Paths where GSD must never create .gsd/ — no override possible. */
|
||||
const UNIX_BLOCKED_PATHS = new Set([
|
||||
"/",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/usr",
|
||||
"/usr/bin",
|
||||
"/usr/sbin",
|
||||
"/usr/lib",
|
||||
"/usr/local",
|
||||
"/usr/local/bin",
|
||||
"/etc",
|
||||
"/var",
|
||||
"/var/tmp",
|
||||
"/dev",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/boot",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
// macOS-specific
|
||||
"/System",
|
||||
"/Library",
|
||||
"/Applications",
|
||||
"/Volumes",
|
||||
"/private",
|
||||
"/private/var",
|
||||
"/private/etc",
|
||||
"/private/tmp",
|
||||
]);
|
||||
|
||||
const WINDOWS_BLOCKED_PATHS = new Set([
|
||||
"C:\\",
|
||||
"C:\\Windows",
|
||||
"C:\\Windows\\System32",
|
||||
"C:\\Program Files",
|
||||
"C:\\Program Files (x86)",
|
||||
]);
|
||||
|
||||
// ─── Core Validation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate whether a directory is safe for GSD to operate in.
|
||||
*
|
||||
* Checks in order:
|
||||
* 1. Blocked system paths (hard stop)
|
||||
* 2. Home directory itself (hard stop)
|
||||
* 3. Temp directory root (hard stop)
|
||||
* 4. High entry count heuristic (warning)
|
||||
*/
|
||||
export function validateDirectory(dirPath: string): DirectoryValidationResult {
|
||||
// Resolve to absolute + follow symlinks so aliases can't bypass checks
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = realpathSync(resolve(dirPath));
|
||||
} catch {
|
||||
// If we can't resolve, use the raw resolved path
|
||||
resolved = resolve(dirPath);
|
||||
}
|
||||
|
||||
// Normalize trailing slashes for consistent comparison.
|
||||
// Special cases: "/" → "/" (not ""), "C:\" → "C:\" (not "C:")
|
||||
let normalized = resolved.replace(/[/\\]+$/, "");
|
||||
if (normalized === "") {
|
||||
normalized = "/";
|
||||
} else if (/^[A-Za-z]:$/.test(normalized)) {
|
||||
normalized = normalized + "\\";
|
||||
}
|
||||
|
||||
// ── Check 1: Blocked system paths ──────────────────────────────────────
|
||||
const blockedPaths = platform() === "win32" ? WINDOWS_BLOCKED_PATHS : UNIX_BLOCKED_PATHS;
|
||||
if (blockedPaths.has(normalized)) {
|
||||
return {
|
||||
safe: false,
|
||||
severity: "blocked",
|
||||
reason: `Refusing to run in system directory: ${normalized}. GSD must be run inside a project directory.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 2: Home directory itself (not subdirs) ───────────────────────
|
||||
let resolvedHome: string;
|
||||
try {
|
||||
resolvedHome = realpathSync(resolve(homedir())).replace(/[/\\]+$/, "");
|
||||
} catch {
|
||||
resolvedHome = resolve(homedir()).replace(/[/\\]+$/, "");
|
||||
}
|
||||
|
||||
if (normalized === resolvedHome) {
|
||||
return {
|
||||
safe: false,
|
||||
severity: "blocked",
|
||||
reason: `Refusing to run in your home directory (${normalized}). GSD must be run inside a project directory, not $HOME.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 3: Temp directory root ───────────────────────────────────────
|
||||
let resolvedTmp: string;
|
||||
try {
|
||||
resolvedTmp = realpathSync(resolve(tmpdir())).replace(/[/\\]+$/, "");
|
||||
} catch {
|
||||
resolvedTmp = resolve(tmpdir()).replace(/[/\\]+$/, "");
|
||||
}
|
||||
|
||||
if (normalized === resolvedTmp) {
|
||||
return {
|
||||
safe: false,
|
||||
severity: "blocked",
|
||||
reason: `Refusing to run in the system temp directory (${normalized}). Use a project subdirectory instead.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 4: Suspiciously large directory (heuristic warning) ──────────
|
||||
try {
|
||||
const entries = readdirSync(normalized);
|
||||
if (entries.length > 200) {
|
||||
return {
|
||||
safe: false,
|
||||
severity: "warning",
|
||||
reason: `This directory has ${entries.length} entries, which suggests it may not be a project directory. Are you sure you want to initialize GSD here?`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Can't read directory — let downstream handle the error
|
||||
}
|
||||
|
||||
return { safe: true, severity: "ok" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a directory is safe for GSD operations.
|
||||
* Throws with a descriptive message if the directory is blocked.
|
||||
* Returns the validation result for warnings (caller decides how to handle).
|
||||
*/
|
||||
export function assertSafeDirectory(dirPath: string): DirectoryValidationResult {
|
||||
const result = validateDirectory(dirPath);
|
||||
if (result.severity === "blocked") {
|
||||
throw new Error(result.reason);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue