diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3e23642..9681b7f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,6 +17,9 @@ jobs: with: node-version: "22" + - name: Configure git for private deps + run: git config --global url."https://x-access-token:${{ github.token }}@github.com/".insteadOf "git+ssh://git@github.com/" + - name: Install deps run: npm ci @@ -26,6 +29,9 @@ jobs: - name: Validate pack manifests run: node scripts/validate-packs.js + - name: Security scan + run: node scripts/security-scan.js + - name: Test build scripts run: node --test scripts/*.test.js diff --git a/Makefile b/Makefile index 3605ffe..2ef8d77 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: sync-pack-spec validate validate-packs validate-all validate-manifests build-api generate contracts test clean +.PHONY: sync-pack-spec validate validate-packs scan-packs validate-all validate-manifests build-api generate contracts test clean # Optional: path to sealed pack YAMLs (private repo). # Set PRIVATE_PACKS_DIR to include sealed packs in the build. @@ -55,8 +55,15 @@ validate-packs: sync-pack-spec generate @PRIVATE_PACKS_DIR=$(PRIVATE_PACKS_DIR) node scripts/validate-packs.js @echo "Done." +# Security scan all pack YAML files (DD-147). +# Runs SINJ prompt injection detection and cross-pack clone detection. +scan-packs: + @echo "Security scanning pack manifests..." + @node scripts/security-scan.js + @echo "Done." + # Validate everything. -validate-all: validate validate-packs +validate-all: validate validate-packs scan-packs # Validate manifests in sibling repos. # Canonical filename: stallari-plugin.yaml diff --git a/package-lock.json b/package-lock.json index 03294f5..807a902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,21 @@ "": { "dependencies": { "yaml": "^2.7.0" + }, + "devDependencies": { + "stallari-secops-scanner": "github:Groupthink-dev/stallari-secops-scanner" + } + }, + "node_modules/stallari-secops-scanner": { + "version": "0.2.0", + "resolved": "git+ssh://git@github.com/Groupthink-dev/stallari-secops-scanner.git#7904c42835fe89a94e1448fa8e5ded8de5210987", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.7.0" + }, + "bin": { + "stallari-secops-scanner": "dist/cli.js" } }, "node_modules/yaml": { diff --git a/package.json b/package.json index 7ab350b..507a3da 100644 --- a/package.json +++ b/package.json @@ -6,5 +6,8 @@ }, "dependencies": { "yaml": "^2.7.0" + }, + "devDependencies": { + "stallari-secops-scanner": "github:Groupthink-dev/stallari-secops-scanner" } } diff --git a/plugins/packs/interactive-brokers.yaml b/plugins/packs/interactive-brokers.yaml new file mode 100644 index 0000000..d14c996 --- /dev/null +++ b/plugins/packs/interactive-brokers.yaml @@ -0,0 +1,348 @@ +pack: "1.7" +name: interactive-brokers +version: "1.0.0" +description: >- + Portfolio analysis and market scanning for Interactive Brokers — read-only + brokerage intelligence with pack-local financial guardrails. +icon: /icons/interactive-brokers.svg +author: + name: piersdd + url: https://github.com/piersdd +licence: MIT +visibility: open +author_type: community +readiness: beta +pricing: null +min_stallari: "0.66" + +data: + reads: [brokerage] + writes: [vault] + stores: nothing + phones_home: false + +# ── Guardrails (DD-145 §G) ──────────────────────────────────────────── +# +# Pack-local guardrails constrain this pack's agent regardless of trust +# tier. These are enforced by the harness at dispatch time — appended +# after platform guardrails (from stallari-core), before the agent prompt. +# +# Even though this pack is read-only, financial data handling warrants +# explicit guardrails: no credential leakage into notes, no financial +# advice framing, and vault scope confinement. + +guardrails: + version: "1.0.0" + min_harness: "0.66.0.0" + reviewed: "2026-04-19" + rules: + - id: fin-001 + category: data-handling + severity: high + scope: [community, verified, certified] + added: "2026-04-19" + rule: > + All output must include a disclaimer that this is automated + analysis, not financial advice, and that past performance does + not predict future results. Never recommend specific trades — + present data and analysis, the user makes their own decisions. + + - id: scope-001 + category: vault + severity: high + scope: [community, verified, certified] + added: "2026-04-19" + rule: > + Only create vault notes under the configured vault_prefix path + (default: spaces/Systems/Areas/Algo trading/). Never write + brokerage data, portfolio snapshots, or market scans outside + this path. + + - id: scope-002 + category: exfiltration + severity: critical + scope: [community, verified, certified] + added: "2026-04-19" + rule: > + Never include account numbers, API keys, gateway URLs, or + session tokens in vault notes, notification bodies, or summary + fields. Use the account alias from config.account_alias + instead of raw account identifiers. + +# ── Service requirements ────────────────────────────────────────────── + +requires: + services: + - service: brokerage + operations: + - status + - accounts + - positions + - portfolio_summary + - orders + - contract_search + - quote + +recommends: + services: + - service: brokerage + operations: + - cash_balances + - pnl + - contract_detail + - historical + - order_status + - trades + - scanner_params + - scanner_run + - service: vault + operations: + - read + - search + - create + - append + - service: notifications + operations: [send] + +# ── Plugin suggestions ──────────────────────────────────────────────── + +suggested_plugins: + - service: brokerage + required: true + note: "Requires IB Client Portal Gateway running locally." + plugins: + - name: ib-blade-mcp + title: "Interactive Brokers" + author_type: first-party + note: "21 tools. Gateway isolation — IB credentials never touch the MCP." + +# ── User configuration ─────────────────────────────────────────────── + +config: + user_inputs: + - key: vault_prefix + type: string + label: Vault path prefix + description: "Vault path for portfolio notes, scan results, and reports." + default: "spaces/Systems/Areas/Algo trading/" + required: false + - key: account_alias + type: string + label: Account alias + description: "Human-readable alias for the IB account (used in notes instead of account number)." + default: "IB-Primary" + required: false + - key: scan_markets + type: choice + label: Market scope + description: "Which markets to scan." + choices: [asx-only, us-only, asx-and-us] + default: asx-and-us + required: false + +# ── Agent ───────────────────────────────────────────────────────────── +# +# DD-146: At community tier this agent is namespaced to +# interactive-brokers/ib-analyst at compile time. User sees a consent +# prompt on first dispatch. + +agents: + ib-analyst: + role: operator + model_preference: + - claude-sonnet-4-6 + prompt: | + You are a brokerage analyst for the Interactive Brokers pack. You + have read-only access to an IB Client Portal Gateway — you observe, + analyse, and report. You never place orders or modify positions. + + Market scope: {{config.scan_markets}} + Account alias: {{config.account_alias}} + Vault prefix: {{config.vault_prefix}} + + Capabilities: + - Portfolio analysis: positions, NAV, P&L, cash balances, margin + - Market data: quotes, historical bars, contract search + - Scanners: top gainers, most active, high IV, and custom scans + - Order monitoring: open orders, recent fills, execution quality + + Safety rules: + - This pack is STRICTLY read-only. You MUST NOT call any gated + operations: place_order, confirm_order, modify_order, + cancel_order, or order_preview. + - If the user asks to place a trade, explain that this pack is + analysis-only and suggest they use the IB plugin directly + with write mode enabled. + - When showing account data, use {{config.account_alias}} + instead of raw account numbers. + - All analysis is informational — never frame output as + financial advice or trade recommendations. + + Output style: + - Currency amounts with currency code (e.g. USD 1,234.56) + - Percentages to 2 decimal places + - Use tables for portfolio and position summaries + - Always note which data is real-time vs delayed + +# ── Skills ──────────────────────────────────────────────────────────── + +skills: + + - name: portfolio-snapshot + agent: ib-analyst + description: Portfolio overview — NAV, positions, P&L, cash, and margin utilisation + category: report + graph: react-extract + services_used: + - service: brokerage + operations: [portfolio_summary, positions, cash_balances, pnl] + trigger: + on_demand: true + outputs: + - type: report + description: "Portfolio snapshot with positions and P&L" + - type: vault_note + description: "Portfolio snapshot saved to vault" + prompt: | + Generate a portfolio snapshot for account {{config.account_alias}}. + + 1. Call {{brokerage.portfolio_summary}} for NAV, cash, unrealised + P&L, buying power, and margin utilisation. + 2. Call {{brokerage.positions}} for open positions with P&L, + market value, cost basis, and daily change. + 3. Call {{brokerage.cash_balances}} for cash by currency. + 4. Call {{brokerage.pnl}} for daily and unrealised P&L breakdown. + + Present as: + + **Portfolio — {{config.account_alias}}** + + | Metric | Value | + |--------|-------| + | NAV | USD X | + | Cash | USD X | + | Unrealised P&L | +/- USD X | + | Margin used | X% | + | Buying power | USD X | + + **Positions** (sorted by market value descending): + + | Symbol | Qty | Avg Cost | Last | Mkt Value | P&L | % Change | + |--------|-----|----------|------|-----------|-----|----------| + + Group by asset class (equity, options, futures, forex) if mixed. + + If vault service is available, save to + {{config.vault_prefix}}snapshots/YYYY-MM-DD.md with frontmatter: + kind: report + report_type: portfolio-snapshot + account: {{config.account_alias}} + llm_generated: true + + - name: market-scan + agent: ib-analyst + description: Run a market scanner — top gainers, most active, high IV, or custom scan + category: report + graph: react + services_used: + - service: brokerage + operations: [scanner_params, scanner_run, quote, contract_search] + inputs: + - key: scan_type + type: choice + label: Scanner type + description: "Which market scanner to run" + choices: [top-gainers, top-losers, most-active, high-iv, hot-by-volume] + default: most-active + required: false + - key: instrument + type: choice + label: Instrument type + description: "Asset class to scan" + choices: [stocks, options, futures] + default: stocks + required: false + trigger: + on_demand: true + outputs: + - type: report + description: "Scanner results with quotes" + prompt: | + Run an IB market scanner and present the results. + + Scanner: {{config.scan_type}} + Instrument: {{config.instrument}} + Markets: {{config.scan_markets}} + + 1. Call {{brokerage.scanner_params}} to verify the requested + scanner type is available. + 2. Call {{brokerage.scanner_run}} with appropriate filters for + the market scope (ASX exchange codes for asx-only, US for + us-only, both for asx-and-us). + 3. For the top 10 results, call {{brokerage.quote}} for live + pricing detail. + + Present as: + + **{{config.scan_type}} — {{config.instrument}}** ({{config.scan_markets}}) + + | # | Symbol | Name | Last | Change | % Chg | Volume | + |---|--------|------|------|--------|-------|--------| + + Add brief commentary on notable patterns (sector clustering, + unusual volume, news-driven moves if apparent from price action). + + - name: morning-brief + agent: ib-analyst + description: Daily portfolio brief — overnight changes, open orders, and market movers + category: digest + graph: react-extract + services_used: + - service: brokerage + operations: [portfolio_summary, positions, pnl, orders, scanner_run] + trigger: + schedule: "0 7 * * 1-5" + outputs: + - type: report + description: "Daily morning brief" + - type: notification + description: "Morning brief summary notification" + prompt: | + Generate a morning portfolio brief for {{config.account_alias}}. + Keep it scannable — this runs daily before market open. + + 1. {{brokerage.portfolio_summary}} — NAV and overnight change + 2. {{brokerage.pnl}} — unrealised P&L + 3. {{brokerage.positions}} — positions with largest overnight + moves (top 3 gainers, top 3 losers by % change) + 4. {{brokerage.orders}} — any open/pending orders + 5. {{brokerage.scanner_run}} — top 5 pre-market movers + (if scanner supports pre-market data) + + Format: + + **Morning Brief — {{config.account_alias}}** + + **Portfolio:** NAV $X (+/- $Y overnight) + **Open orders:** N pending + + **Biggest movers in your portfolio:** + - AAPL: +2.3% ($X) + - ... + + **Market movers (pre-market):** + 1. TICKER +X% — description + 2. ... + + Set notification_body to a one-line NAV + biggest mover summary. + Set notification_priority to 2. + Set notification_category to "digest". + +# ── Workflows ───────────────────────────────────────────────────────── + +workflows: + - name: morning-brief + description: Daily pre-market portfolio brief + schedule: "0 7 * * 1-5" + steps: + - skill: morning-brief diff --git a/plugins/scan-exceptions/cloudflare-edge-platform.json b/plugins/scan-exceptions/cloudflare-edge-platform.json new file mode 100644 index 0000000..37f0730 --- /dev/null +++ b/plugins/scan-exceptions/cloudflare-edge-platform.json @@ -0,0 +1,6 @@ +[ + { + "rule_id": "SINJ-003", + "justification": "Infrastructure pack — skill prompts legitimately reference Cloudflare API URLs and tunnel endpoints" + } +] diff --git a/scripts/security-scan.js b/scripts/security-scan.js new file mode 100644 index 0000000..f6bfdda --- /dev/null +++ b/scripts/security-scan.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * DD-147 P5 — Security scan all pack YAML files. + * + * Runs SINJ prompt injection rules and cross-pack clone detection + * (trigram Jaccard similarity) against all packs in plugins/packs/. + * + * Usage: node scripts/security-scan.js + * Exit codes: 0=pass, 1=fail (critical/high), 2=warn (medium/low). + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { scanPackYAML, buildCorpusFromPacks } from "stallari-secops-scanner"; + +const ROOT = resolve(import.meta.dirname, ".."); +const PACKS_DIR = join(ROOT, "plugins", "packs"); +const EXCEPTIONS_DIR = join(ROOT, "plugins", "scan-exceptions"); + +/** + * Load per-pack scan exceptions from plugins/scan-exceptions/{name}.json. + * Returns empty array if no exceptions file exists. + */ +async function loadExceptions(packName) { + try { + const raw = await readFile(join(EXCEPTIONS_DIR, `${packName}.json`), "utf-8"); + return JSON.parse(raw); + } catch { + return []; + } +} + +async function main() { + const files = (await readdir(PACKS_DIR)) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .sort(); + + if (files.length === 0) { + console.log(" No pack files found."); + return; + } + + // Load all pack content + const packs = []; + for (const file of files) { + const yaml = await readFile(join(PACKS_DIR, file), "utf-8"); + packs.push({ name: file.replace(/\.(yaml|yml)$/, ""), yaml }); + } + + // Build corpus from all packs for cross-pack clone detection + const corpus = buildCorpusFromPacks(packs); + + // Scan each pack + let overallResult = "pass"; + let totalFindings = 0; + let totalCloneFindings = 0; + + for (const { name, yaml } of packs) { + const exceptions = await loadExceptions(name); + let result; + try { + result = scanPackYAML(yaml, { corpus, threats: [], exceptions }); + } catch (err) { + console.log(` FAIL ${name}: parse error: ${err.message}`); + overallResult = "fail"; + totalFindings++; + continue; + } + + const activeClones = result.clone_findings.filter((f) => !f.suppressed); + const icon = + result.result === "pass" + ? "PASS" + : result.result === "fail" + ? "FAIL" + : "WARN"; + console.log( + ` ${icon} ${name} — ${result.findings.length} SINJ, ${activeClones.length} clone`, + ); + + for (const f of result.findings) { + console.log( + ` ${f.severity.toUpperCase()} [${f.rule_id}] ${f.location}: ${f.message}`, + ); + } + for (const cf of activeClones) { + console.log( + ` ${cf.severity.toUpperCase()} [${cf.rule_id}] ${cf.location}: ${cf.message}`, + ); + } + + totalFindings += result.findings.length; + totalCloneFindings += activeClones.length; + + if (result.result === "fail") overallResult = "fail"; + else if (result.result === "warn" && overallResult === "pass") + overallResult = "warn"; + } + + console.log( + `\n ${files.length} pack(s) scanned. ${totalFindings} SINJ + ${totalCloneFindings} clone finding(s).\n`, + ); + + if (overallResult === "fail") process.exit(1); + if (overallResult === "warn") process.exit(2); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});