mirror of
https://github.com/GMGNAI/gmgn-skills.git
synced 2026-07-27 16:57:44 +00:00
fix(security): defend against prompt injection via token metadata
Address HackenProof report GMGNWM-143, where attacker-controlled token metadata could hijack an AI agent driving gmgn-cli into executing an unauthorized trade. Move guardrails from overridable SKILL.md text into code. - Add src/sanitize.ts: neutralize prompt-injection framing and hidden/control characters in API output (via printResult) and validate create-token metadata - Add src/confirm.ts: code-enforced human confirmation for financial writes (swap, multi-swap, order strategy create, cooking create) — reads a typed "yes" from /dev/tty; automation requires GMGN_ALLOW_AUTOMATED_TRADES=1 + --yes - Harden config.ts: tighten ~/.config/gmgn/.env to 0600 and warn if world-readable - Update SKILL.md files, Readme.md and Readme.zh.md to document the gate, the --yes flag, and untrusted-metadata handling Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+25
-7
@@ -2,6 +2,8 @@ import { Command } from "commander";
|
||||
import { OpenApiClient, CreateTokenParams } from "../client/OpenApiClient.js";
|
||||
import { getConfig } from "../config.js";
|
||||
import { exitOnError, printResult } from "../output.js";
|
||||
import { confirmTrade } from "../confirm.js";
|
||||
import { sanitizeMetadataField, validateMetadataUrl, MAX_DESCRIPTION_LEN, MAX_NAME_LEN } from "../sanitize.js";
|
||||
import { validateChain } from "../validate.js";
|
||||
|
||||
export function registerCookingCommands(program: Command): void {
|
||||
@@ -70,6 +72,7 @@ export function registerCookingCommands(program: Command): void {
|
||||
.option("--buy-trade-config <json>", "Buy-side trade config for CondMarket orders as JSON (TradeParam)")
|
||||
.option("--sell-trade-config <json>", "Sell-side trade config for auto-sell / pending_sell as JSON (TradeParam)")
|
||||
.option("--sell-configs <json>", "Auto-sell strategy list as JSON array (CookingSellConfig[])")
|
||||
.option("--yes", "Skip the interactive confirmation prompt (requires GMGN_ALLOW_AUTOMATED_TRADES=1)")
|
||||
.option("--raw", "Output raw JSON")
|
||||
.action(async (opts) => {
|
||||
if (!opts.image && !opts.imageUrl) {
|
||||
@@ -85,20 +88,23 @@ export function registerCookingCommands(program: Command): void {
|
||||
console.error(`[gmgn-cli] cooking create does not support robinhood, got "${opts.chain}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Validate/clean all free-text and link metadata before publishing. This
|
||||
// prevents the CLI from being used to mint tokens whose metadata carries a
|
||||
// prompt-injection payload aimed at other users' AI agents.
|
||||
const params: CreateTokenParams = {
|
||||
chain: opts.chain,
|
||||
dex: opts.dex,
|
||||
from_address: opts.from,
|
||||
name: opts.name,
|
||||
symbol: opts.symbol,
|
||||
name: sanitizeMetadataField(opts.name, "--name", MAX_NAME_LEN),
|
||||
symbol: sanitizeMetadataField(opts.symbol, "--symbol", MAX_NAME_LEN),
|
||||
buy_amt: opts.buyAmt,
|
||||
};
|
||||
if (opts.image) params.image = opts.image;
|
||||
if (opts.imageUrl) params.image_url = opts.imageUrl;
|
||||
if (opts.description) params.description = opts.description;
|
||||
if (opts.website) params.website = opts.website;
|
||||
if (opts.twitter) params.twitter = opts.twitter;
|
||||
if (opts.telegram) params.telegram = opts.telegram;
|
||||
if (opts.imageUrl) params.image_url = validateMetadataUrl(opts.imageUrl, "--image-url");
|
||||
if (opts.description) params.description = sanitizeMetadataField(opts.description, "--description", MAX_DESCRIPTION_LEN);
|
||||
if (opts.website) params.website = validateMetadataUrl(opts.website, "--website");
|
||||
if (opts.twitter) params.twitter = validateMetadataUrl(opts.twitter, "--twitter");
|
||||
if (opts.telegram) params.telegram = validateMetadataUrl(opts.telegram, "--telegram");
|
||||
if (opts.slippage != null) params.slippage = opts.slippage;
|
||||
if (opts.autoSlippage) params.auto_slippage = true;
|
||||
if (opts.fee) params.fee = opts.fee;
|
||||
@@ -130,6 +136,18 @@ export function registerCookingCommands(program: Command): void {
|
||||
if (opts.buyTradeConfig) params.buy_trade_config = JSON.parse(opts.buyTradeConfig);
|
||||
if (opts.sellTradeConfig) params.sell_trade_config = JSON.parse(opts.sellTradeConfig);
|
||||
if (opts.sellConfigs) params.sell_configs = JSON.parse(opts.sellConfigs);
|
||||
confirmTrade({
|
||||
action: "Create token",
|
||||
lines: [
|
||||
`Chain: ${params.chain}`,
|
||||
`Launchpad: ${params.dex}`,
|
||||
`Wallet: ${params.from_address}`,
|
||||
`Name: ${params.name}`,
|
||||
`Symbol: ${params.symbol}`,
|
||||
`Buy amount: ${params.buy_amt}`,
|
||||
],
|
||||
}, opts.yes);
|
||||
|
||||
const client = new OpenApiClient(getConfig(true));
|
||||
const data = await client.createToken(params).catch(exitOnError);
|
||||
printResult(data, opts.raw);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||
import { OpenApiClient, SwapParams, MultiSwapParams, StrategyCreateParams, StrategyCancelParams } from "../client/OpenApiClient.js";
|
||||
import { getConfig } from "../config.js";
|
||||
import { exitOnError, printResult } from "../output.js";
|
||||
import { confirmTrade } from "../confirm.js";
|
||||
import { validateAddress, validateChain, validatePercent, validatePositiveInt } from "../validate.js";
|
||||
|
||||
export function registerSwapCommands(program: Command): void {
|
||||
@@ -27,6 +28,7 @@ export function registerSwapCommands(program: Command): void {
|
||||
.option("--max-priority-fee-per-gas <amount>", "EIP-1559 max priority fee per gas (BSC / BASE / ETH)")
|
||||
.option("--condition-orders <json>", 'JSON array of take-profit/stop-loss conditions, e.g. \'[{"order_type":"profit_stop","side":"sell","price_scale":"150","sell_ratio":"100"}]\'; trace types: \'[{"order_type":"profit_stop_trace","side":"sell","price_scale":"150","sell_ratio":"100","drawdown_rate":"50"}]\'')
|
||||
.option("--sell-ratio-type <type>", "Sell ratio base: buy_amount (default) / hold_amount; only used with --condition-orders")
|
||||
.option("--yes", "Skip the interactive confirmation prompt (requires GMGN_ALLOW_AUTOMATED_TRADES=1)")
|
||||
.option("--raw", "Output raw JSON")
|
||||
.action(async (opts) => {
|
||||
if (opts.percent == null && !opts.amount) {
|
||||
@@ -68,6 +70,20 @@ export function registerSwapCommands(program: Command): void {
|
||||
}
|
||||
if (opts.sellRatioType) params.sell_ratio_type = opts.sellRatioType;
|
||||
|
||||
confirmTrade({
|
||||
action: "Swap",
|
||||
lines: [
|
||||
`Chain: ${params.chain}`,
|
||||
`Wallet: ${params.from_address}`,
|
||||
`Input token: ${params.input_token}`,
|
||||
`Output token: ${params.output_token}`,
|
||||
opts.percent != null
|
||||
? `Amount: ${opts.percent}% of balance`
|
||||
: `Amount: ${params.input_amount} (smallest unit)`,
|
||||
`Slippage: ${opts.autoSlippage ? "auto" : (params.slippage ?? "default")}`,
|
||||
],
|
||||
}, opts.yes);
|
||||
|
||||
const client = new OpenApiClient(getConfig(true));
|
||||
const data = await client.swap(params).catch(exitOnError);
|
||||
printResult(data, opts.raw);
|
||||
@@ -95,6 +111,7 @@ export function registerSwapCommands(program: Command): void {
|
||||
.option("--max-priority-fee-per-gas <amount>", "EIP-1559 max priority fee per gas (BSC / BASE / ETH)")
|
||||
.option("--condition-orders <json>", "JSON array of take-profit/stop-loss conditions attached to each successful wallet's swap")
|
||||
.option("--sell-ratio-type <type>", "Sell ratio base: buy_amount (default) / hold_amount; only used with --condition-orders")
|
||||
.option("--yes", "Skip the interactive confirmation prompt (requires GMGN_ALLOW_AUTOMATED_TRADES=1)")
|
||||
.option("--raw", "Output raw JSON")
|
||||
.action(async (opts) => {
|
||||
if (!opts.inputAmount && !opts.inputAmountBps && !opts.outputAmount) {
|
||||
@@ -140,6 +157,18 @@ export function registerSwapCommands(program: Command): void {
|
||||
catch { console.error("[gmgn-cli] --condition-orders must be valid JSON"); process.exit(1); }
|
||||
}
|
||||
if (opts.sellRatioType) params.sell_ratio_type = opts.sellRatioType;
|
||||
|
||||
confirmTrade({
|
||||
action: "Multi-wallet swap",
|
||||
lines: [
|
||||
`Chain: ${params.chain}`,
|
||||
`Wallets: ${params.accounts.length} (${params.accounts.join(", ")})`,
|
||||
`Input token: ${params.input_token}`,
|
||||
`Output token: ${params.output_token}`,
|
||||
`Slippage: ${opts.autoSlippage ? "auto" : (params.slippage ?? "default")}`,
|
||||
],
|
||||
}, opts.yes);
|
||||
|
||||
const client = new OpenApiClient(getConfig(true));
|
||||
const data = await client.multiSwap(params).catch(exitOnError);
|
||||
printResult(data, opts.raw);
|
||||
@@ -226,6 +255,7 @@ export function registerSwapCommands(program: Command): void {
|
||||
.option("--condition-orders <json>", "JSON array of condition sub-orders for smart_trade (must include a buy_low entry + TP/SL entries)")
|
||||
.option("--sell-param <json>", "JSON object of sell-side trade params used when a TP/SL condition fires (required for smart_trade)")
|
||||
.option("--buy-param <json>", "JSON object of buy-side trade params override for smart_trade")
|
||||
.option("--yes", "Skip the interactive confirmation prompt (requires GMGN_ALLOW_AUTOMATED_TRADES=1)")
|
||||
.option("--raw", "Output raw JSON")
|
||||
.action(async (opts) => {
|
||||
if (!opts.amountIn && !opts.amountInPercent) {
|
||||
@@ -275,6 +305,18 @@ export function registerSwapCommands(program: Command): void {
|
||||
try { params.buy_param = JSON.parse(opts.buyParam); }
|
||||
catch { console.error("[gmgn-cli] --buy-param must be valid JSON"); process.exit(1); }
|
||||
}
|
||||
confirmTrade({
|
||||
action: "Create strategy order",
|
||||
lines: [
|
||||
`Chain: ${params.chain}`,
|
||||
`Wallet: ${params.from_address}`,
|
||||
`Base token: ${params.base_token}`,
|
||||
`Quote token: ${params.quote_token}`,
|
||||
`Order type: ${params.order_type} / ${params.sub_order_type}`,
|
||||
`Amount: ${params.amount_in ?? `${params.amount_in_percent}%`}`,
|
||||
],
|
||||
}, opts.yes);
|
||||
|
||||
const client = new OpenApiClient(getConfig(true));
|
||||
const data = await client.createStrategyOrder(params).catch(exitOnError);
|
||||
printResult(data, opts.raw);
|
||||
|
||||
+26
-1
@@ -1,10 +1,35 @@
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { chmodSync, existsSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const GLOBAL_ENV_PATH = join(homedir(), ".config", "gmgn", ".env");
|
||||
|
||||
// The credential file holds a plaintext private key and API key. Before loading
|
||||
// it, make sure it is not readable by other users on the machine. If the file is
|
||||
// group/other-accessible we tighten it to 0600 and warn — this reduces the blast
|
||||
// radius of the plaintext-credential storage called out in the security review.
|
||||
function enforceCredentialFilePermissions(path: string): void {
|
||||
if (process.platform === "win32" || !existsSync(path)) return;
|
||||
try {
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
if (mode & 0o077) {
|
||||
chmodSync(path, 0o600);
|
||||
console.error(
|
||||
`[gmgn-cli] Warning: ${path} was accessible to other users (mode ${mode.toString(8)}). ` +
|
||||
`Permissions tightened to 600. Your GMGN private key is stored here in plaintext — ` +
|
||||
`keep this file private and consider a dedicated trading wallet with limited funds.`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if we cannot stat/chmod, fall through to normal loading.
|
||||
}
|
||||
}
|
||||
|
||||
enforceCredentialFilePermissions(GLOBAL_ENV_PATH);
|
||||
|
||||
// Load global config first (~/.config/gmgn/.env, takes precedence), then project .env (supplements only)
|
||||
loadDotenv({ path: join(homedir(), ".config", "gmgn", ".env"), override: true });
|
||||
loadDotenv({ path: GLOBAL_ENV_PATH, override: true });
|
||||
loadDotenv();
|
||||
|
||||
export interface Config {
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* confirm.ts — code-enforced human-in-the-loop gate for financial writes.
|
||||
*
|
||||
* Commands that move real funds (swap, multi-swap, token creation, strategy
|
||||
* order creation) must not execute on the say-so of an AI agent alone. A hijacked
|
||||
* agent — e.g. one that read a prompt-injection payload out of token metadata — can
|
||||
* emit any command line it wants, so a plain `--yes` flag is not a real barrier:
|
||||
* the injected instructions can just tell the agent to pass `--yes`.
|
||||
*
|
||||
* This gate enforces confirmation in CODE, not in a SKILL.md instruction:
|
||||
*
|
||||
* 1. Interactive terminal (default): we read a typed "yes" directly from the
|
||||
* controlling TTY (/dev/tty), NOT from stdin. An autonomous agent driving the
|
||||
* CLI over a pipe cannot answer this prompt, and no text in the agent's
|
||||
* context can satisfy it — a real human must be present at the keyboard.
|
||||
*
|
||||
* 2. Intentional automation: to run headless, the operator must BOTH pass
|
||||
* `--yes` AND set the environment variable GMGN_ALLOW_AUTOMATED_TRADES=1 in
|
||||
* their own shell, out of band. Requiring the env var (which the CLI never
|
||||
* sets and an injected instruction should not know to set) plus the flag makes
|
||||
* autonomous execution a deliberate, two-factor human decision.
|
||||
*
|
||||
* If neither path is satisfied, the trade is refused before any signature is made.
|
||||
*/
|
||||
|
||||
import { openSync, readSync, closeSync, existsSync } from "node:fs";
|
||||
|
||||
const AUTOMATION_ENV = "GMGN_ALLOW_AUTOMATED_TRADES";
|
||||
|
||||
export interface TradeSummary {
|
||||
action: string; // e.g. "Swap", "Create token", "Create strategy order"
|
||||
lines: string[]; // human-readable "Field: value" details
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce human confirmation for a financial write. Prints a summary, then either
|
||||
* reads an interactive "yes" from the TTY or verifies the explicit automation
|
||||
* opt-in. Aborts the process if confirmation is not obtained.
|
||||
*/
|
||||
export function confirmTrade(summary: TradeSummary, assumeYes: boolean): void {
|
||||
printSummary(summary);
|
||||
|
||||
const automationOptIn = process.env[AUTOMATION_ENV] === "1";
|
||||
|
||||
if (assumeYes) {
|
||||
if (automationOptIn) {
|
||||
console.error(
|
||||
`[gmgn-cli] Proceeding non-interactively (--yes + ${AUTOMATION_ENV}=1).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// --yes alone is deliberately NOT enough: an injected agent can pass it.
|
||||
abort(
|
||||
`--yes was supplied but ${AUTOMATION_ENV}=1 is not set in the environment. ` +
|
||||
`Non-interactive trade execution is disabled by default. If you truly intend ` +
|
||||
`to allow automated trades, set ${AUTOMATION_ENV}=1 in your own shell first.`
|
||||
);
|
||||
}
|
||||
|
||||
const answer = readFromTty(
|
||||
`\nType "yes" to confirm this ${summary.action.toLowerCase()}, anything else to cancel: `
|
||||
);
|
||||
|
||||
if (answer == null) {
|
||||
abort(
|
||||
`No interactive terminal available to confirm this ${summary.action.toLowerCase()}. ` +
|
||||
`Refusing to execute a financial transaction without human confirmation. ` +
|
||||
`For intentional automation, set ${AUTOMATION_ENV}=1 and pass --yes.`
|
||||
);
|
||||
}
|
||||
|
||||
if (answer.trim().toLowerCase() !== "yes") {
|
||||
abort("Confirmation not received. Transaction cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(summary: TradeSummary): void {
|
||||
const header = `⚠️ ${summary.action} — confirmation required`;
|
||||
console.error(`\n${header}`);
|
||||
console.error("-".repeat(header.length));
|
||||
for (const line of summary.lines) {
|
||||
console.error(` ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single line from the controlling terminal (/dev/tty), bypassing stdin so
|
||||
* a piped/automated caller cannot supply the answer. Returns null if no TTY is
|
||||
* available (e.g. headless CI, agent driving the CLI over a pipe).
|
||||
*/
|
||||
function readFromTty(prompt: string): string | null {
|
||||
const ttyPath = process.platform === "win32" ? "CONIN$" : "/dev/tty";
|
||||
if (process.platform !== "win32" && !existsSync(ttyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(ttyPath, "r");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
process.stderr.write(prompt);
|
||||
const buf = Buffer.alloc(1);
|
||||
let line = "";
|
||||
while (true) {
|
||||
let bytes = 0;
|
||||
try {
|
||||
bytes = readSync(fd, buf, 0, 1, null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (bytes === 0) break; // EOF
|
||||
const ch = buf.toString("utf8", 0, 1);
|
||||
if (ch === "\n") break;
|
||||
if (ch === "\r") continue;
|
||||
line += ch;
|
||||
}
|
||||
return line;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function abort(msg: string): never {
|
||||
console.error(`[gmgn-cli] ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
+8
-2
@@ -1,8 +1,14 @@
|
||||
import { sanitizeForOutput } from "./sanitize.js";
|
||||
|
||||
export function printResult(data: unknown, raw?: boolean): void {
|
||||
// Neutralize any attacker-controlled metadata (token name/symbol/description/
|
||||
// social links, on-chain URIs, etc.) before it is emitted and read by an AI
|
||||
// agent. Defends against indirect prompt injection via token metadata.
|
||||
const safe = sanitizeForOutput(data);
|
||||
if (raw) {
|
||||
console.log(JSON.stringify(data));
|
||||
console.log(JSON.stringify(safe));
|
||||
} else {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
console.log(JSON.stringify(safe, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user