From 4a75c2e3d202a6bbe2c8934f33479541c37459a3 Mon Sep 17 00:00:00 2001 From: Robin Schulz Date: Sat, 18 Jul 2026 18:35:29 +0200 Subject: [PATCH 1/6] Introduce gitleaks and dependabot configs and secret scan workflow --- .github/dependabot.yml | 27 +++++++++++++++++++++++++++ .github/workflows/secret-scan.yml | 26 ++++++++++++++++++++++++++ .gitleaks.toml | 5 +++++ 3 files changed, 58 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .gitleaks.toml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a7b32b7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +# Dependabot: security + version updates for AgentForge WebUI. +# Docs: https://docs.github.com/en/code-security/dependabot +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "javascript" + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "chore(ci)" diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..9543262 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,26 @@ +# Optional in-repo secret scan (gitleaks). Complements the GitGuardian GitHub App +# check that already runs on pull requests. +name: Secret Scan + +on: + pull_request: + branches: [main, master] + workflow_dispatch: + +concurrency: + group: secret-scan-${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..8c824f2 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,5 @@ +# Gitleaks config for AgentForge WebUI +title = "AgentForge WebUI" + +[extend] +useDefault = true From d69527061f24435b49d1e74af29953aa85c878aa Mon Sep 17 00:00:00 2001 From: Robin Schulz Date: Sat, 18 Jul 2026 18:44:53 +0200 Subject: [PATCH 2/6] Bump up gitleaks action node version --- .github/workflows/secret-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 9543262..4338235 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -21,6 +21,6 @@ jobs: fetch-depth: 0 - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 + uses: gitleaks/gitleaks-action@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 5aface0e6c61db5502f3de4d4c1c9793031cae71 Mon Sep 17 00:00:00 2001 From: Robin Schulz Date: Sun, 19 Jul 2026 17:41:50 +0200 Subject: [PATCH 3/6] =?UTF-8?q?-=20Add=20command=20permission=20profiles?= =?UTF-8?q?=20UI=20(apply=20YAML/blank/builtins,=20save-as,=20delete)=20-?= =?UTF-8?q?=20Fix=20multi-line=20pattern/command=20list=20textareas=20coll?= =?UTF-8?q?apsing=20on=20Enter=20-=20Update=20CHANGELOG=20and=20README=20f?= =?UTF-8?q?or=20AgentForge=20=E2=89=A5=200.13.0=20-=20Bump=20up=20app=20ve?= =?UTF-8?q?rsion=20to=20`0.3.0`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++ README.md | 10 +- package-lock.json | 2 +- package.json | 2 +- src/components/CommandPermissionsModal.jsx | 209 +++++++++++++++++++-- src/hooks/useCommandPermissions.js | 98 +++++++++- 6 files changed, 308 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e85487e..7107b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.3.0] - 2026-07-19 + +Requires [AgentForge](https://github.com/bulletinmybeard/agent-forge) **≥ 0.13.0** for command permission profiles (`/api/permissions/profiles/*`). Command-permissions overrides (`/api/permissions/commands/*`) remain as in 0.12. + +### Added + +- **Command permission profiles** in the Command Permissions modal: list/apply YAML baseline, blank slate, builtin (`tight` / `open`) and user-saved profiles; save current policy as a named profile; delete user profiles (`useCommandPermissions` + `/api/permissions/profiles`) + +### Fixed + +- Pattern/command list textareas: Enter no longer collapses multi-line editing (draft lines keep trailing empty rows; blanks are stripped only on save/compare/validate) + ## [0.2.0] - 2026-07-18 Requires [AgentForge](https://github.com/bulletinmybeard/agent-forge) **≥ 0.12.0** for the command-permissions API (`/api/permissions/commands/*`). diff --git a/README.md b/README.md index 2923dfb..e8ff181 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Vite](https://img.shields.io/badge/Vite-8-646cff.svg?logo=vite&logoColor=white)](https://vitejs.dev/) [![Biome](https://img.shields.io/badge/lint%2Fformat-Biome-60a5fa.svg?logo=biome&logoColor=white)](https://biomejs.dev/) -[![Requires AgentForge](https://img.shields.io/badge/requires-AgentForge%200.12.0%2B-blueviolet)](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.12.0) +[![Requires AgentForge](https://img.shields.io/badge/requires-AgentForge%200.13.0%2B-blueviolet)](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.13.0) > [!NOTE] > **Experimental!** @@ -15,15 +15,15 @@ AgentForge WebUI is a React SPA for [AgentForge](https://github.com/bulletinmybe It's a pure frontend: it streams the agent's think > act > observe loop over the backend's `/ws/chat` WebSocket and calls its REST API for sessions, uploads, memory, and configs. It does nothing on its own — a running AgentForge backend is required! > [!IMPORTANT] -> **Backend version:** WebUI **0.2.0+** needs [AgentForge **v0.12.0**](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.12.0) or newer. -> The **Command Permissions** modal and other features depend on APIs and schema shipped in that release (`/api/permissions/commands/*`, session `source` filtering, etc.). Older backends will fail those endpoints (e.g. 404). +> **Backend version:** WebUI **0.3.0+** needs [AgentForge **v0.13.0**](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.13.0) or newer. +> The **Command Permissions** modal (overrides + **profiles**) depends on APIs and schema from that release (`/api/permissions/commands/*`, `/api/permissions/profiles/*`, session `source` filtering, etc.). Older backends will fail those endpoints (e.g. 404). ## Features - Streaming chat over the `/ws/chat` WebSocket, with the full think > act > observe event stream rendered live - Mode picker for the `@mode` prefixes (chat, docs, search, agent, sql, logs, discover, pipeline, review, research, coding, scheduler, monitor, connectors, and custom agents), each with its own colour - Per-event message cards: routing, config, tool calls, confirm + secret dialogs, results, summaries, errors, search metadata, discovery, research, scheduler/monitor jobs, file diffs, agent warning/recovery/retry/escalation, model fallback, and session compaction -- **Command Permissions** modal: manage shell/SSH allowlist, denylist, and confirm policy (runtime overrides on the AgentForge backend) +- **Command Permissions** modal: manage shell/SSH allowlist, denylist, and confirm policy (runtime overrides on the AgentForge backend), plus named **profiles** (YAML baseline, blank slate, builtins `tight`/`open`, save-as / delete user profiles) - Connectors UI: connect and manage multi-account Google (Gmail, Drive, BigQuery, YouTube), GitLab, and GitHub connections, with per-connection product/permission display and an in-place read/write toggle - Canvas workspace for pinned snippets, results, and queries - Bookmarks: save tool-call sets and agent answers from any run, fuzzy-searchable in a modal @@ -38,7 +38,7 @@ It's a pure frontend: it streams the agent's think > act > observe loop over the Prerequisites: - **Node 20.19+** (`engines` in `package.json`) -- A running [AgentForge](https://github.com/bulletinmybeard/agent-forge) backend **[v0.12.0](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.12.0) or newer** (`scripts/deploy-local.sh` brings the stack up with the web service on `:8200`) +- A running [AgentForge](https://github.com/bulletinmybeard/agent-forge) backend **[v0.13.0](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.13.0) or newer** (`scripts/deploy-local.sh` brings the stack up with the web service on `:8200`) ```bash npm install diff --git a/package-lock.json b/package-lock.json index e8a2336..fd349d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "agent-forge-webui", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5c4a3cf..deaa5bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-forge-webui", - "version": "0.2.0", + "version": "0.3.0", "description": "React SPA front-end for AgentForge — every mode, tool call, and connector in one chat UI", "type": "module", "engines": { diff --git a/src/components/CommandPermissionsModal.jsx b/src/components/CommandPermissionsModal.jsx index f0e9b16..268cbf7 100644 --- a/src/components/CommandPermissionsModal.jsx +++ b/src/components/CommandPermissionsModal.jsx @@ -47,19 +47,32 @@ const policyFromBundle = (bundle) => { return clonePolicy(src); }; -const linesToArray = (text) => - text - .split("\n") - .map((s) => s.trim()) +const linesToArrayDraft = (text) => text.split("\n").map((s) => s.replace(/\s+$/, "")); + +const cleanList = (arr) => + (arr || []) + .map((s) => String(s).trim()) .filter(Boolean); +const sanitizePolicy = (policy) => ({ + mode: policy?.mode || "confirm", + allowed_commands: cleanList(policy?.allowed_commands), + allowed_patterns: cleanList(policy?.allowed_patterns), + blocked_patterns: cleanList(policy?.blocked_patterns), +}); + const arrayToLines = (arr) => (arr || []).join("\n"); -const policiesEqual = (a, b) => - a.mode === b.mode && - JSON.stringify(a.allowed_commands) === JSON.stringify(b.allowed_commands) && - JSON.stringify(a.allowed_patterns) === JSON.stringify(b.allowed_patterns) && - JSON.stringify(a.blocked_patterns) === JSON.stringify(b.blocked_patterns); +const policiesEqual = (a, b) => { + const x = sanitizePolicy(a); + const y = sanitizePolicy(b); + return ( + x.mode === y.mode && + JSON.stringify(x.allowed_commands) === JSON.stringify(y.allowed_commands) && + JSON.stringify(x.allowed_patterns) === JSON.stringify(y.allowed_patterns) && + JSON.stringify(x.blocked_patterns) === JSON.stringify(y.blocked_patterns) + ); +}; const PolicyPreview = ({ title, policy, hint }) => (
@@ -120,7 +133,7 @@ const ToolPanel = ({ }; const handleListField = (field, text) => { - onChange({ ...local, [field]: linesToArray(text) }); + onChange({ ...local, [field]: linesToArrayDraft(text) }); }; const hasOverride = bundle?.override != null; @@ -243,8 +256,20 @@ const ToolPanel = ({ }; export default function CommandPermissionsModal({ open, onClose }) { - const { data, loading, error, saveOverrides, resetOverrides, validate, refresh } = - useCommandPermissions(); + const { + data, + profiles, + activeProfileId, + loading, + error, + saveOverrides, + resetOverrides, + validate, + refresh, + applyProfile, + saveProfile, + deleteProfile, + } = useCommandPermissions(); const [activeTab, setActiveTab] = useState("shell"); const [local, setLocal] = useState({ shell: emptyPolicy(), ssh: emptyPolicy() }); @@ -254,6 +279,8 @@ export default function CommandPermissionsModal({ open, onClose }) { const [testCommands, setTestCommands] = useState({ shell: "", ssh: "" }); const [verdicts, setVerdicts] = useState({ shell: null, ssh: null }); const [validating, setValidating] = useState(false); + const [profileBusy, setProfileBusy] = useState(false); + const [saveAsName, setSaveAsName] = useState(""); useEffect(() => { if (open) void refresh(); @@ -292,7 +319,10 @@ export default function CommandPermissionsModal({ open, onClose }) { setSaving(true); setSaveError(null); try { - await saveOverrides({ shell: local.shell, ssh: local.ssh }); + await saveOverrides({ + shell: sanitizePolicy(local.shell), + ssh: sanitizePolicy(local.ssh), + }); onClose(); } catch (e) { setSaveError(e.message); @@ -320,7 +350,7 @@ export default function CommandPermissionsModal({ open, onClose }) { setValidating(true); setVerdicts((prev) => ({ ...prev, [activeTab]: null })); try { - const result = await validate(activeTab, cmd, local[activeTab]); + const result = await validate(activeTab, cmd, sanitizePolicy(local[activeTab])); setVerdicts((prev) => ({ ...prev, [activeTab]: result })); } catch (e) { setVerdicts((prev) => ({ @@ -332,6 +362,64 @@ export default function CommandPermissionsModal({ open, onClose }) { } }; + const PRESET_YAML = "__yaml__"; + const PRESET_BLANK = "__blank__"; + + const handleApplyProfile = async (profileId) => { + setProfileBusy(true); + setSaveError(null); + try { + await applyProfile(profileId || PRESET_YAML); + } catch (e) { + setSaveError(e.message); + } finally { + setProfileBusy(false); + } + }; + + const selectProfileValue = useMemo(() => { + if (activeProfileId === PRESET_BLANK) return PRESET_BLANK; + if (activeProfileId && profiles.some((p) => p.id === activeProfileId)) { + return activeProfileId; + } + return PRESET_YAML; + }, [activeProfileId, profiles]); + + const handleSaveAsProfile = async () => { + const id = saveAsName.trim(); + if (!id) return; + setProfileBusy(true); + setSaveError(null); + try { + await saveOverrides({ + shell: sanitizePolicy(local.shell), + ssh: sanitizePolicy(local.ssh), + }); + await saveProfile(id, { + description: `Saved from UI ${new Date().toISOString().slice(0, 10)}`, + fromCurrent: true, + }); + setSaveAsName(""); + } catch (e) { + setSaveError(e.message); + } finally { + setProfileBusy(false); + } + }; + + const handleDeleteProfile = async (profileId) => { + if (!profileId) return; + setProfileBusy(true); + setSaveError(null); + try { + await deleteProfile(profileId); + } catch (e) { + setSaveError(e.message); + } finally { + setProfileBusy(false); + } + }; + if (!open) return null; const activeBundle = data?.[activeTab]; @@ -407,6 +495,99 @@ export default function CommandPermissionsModal({ open, onClose }) { {data && activeBundle && ( <> + + {/* Named profiles (Claude/Grok-style presets) */} +
+
+ + Profiles + + {selectProfileValue === PRESET_YAML && ( + active: YAML baseline + )} + {selectProfileValue === PRESET_BLANK && ( + active: blank slate + )} + {selectProfileValue !== PRESET_YAML && + selectProfileValue !== PRESET_BLANK && + activeProfileId && ( + + active: {activeProfileId} + + )} +
+

+ Apply a full shell/SSH preset.{" "} + YAML baseline clears runtime overrides + (config only). Blank slate starts empty + lists for a new profile. Builtins:{" "} + tight /{" "} + open. +

+
+ + setSaveAsName(e.target.value)} + placeholder="name for Save as…" + className="w-36 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1.5 text-xs + text-gray-200 placeholder:text-gray-600 focus:outline-none focus:border-indigo-500" + /> + +
+ {profiles.some((p) => !p.builtin) && ( +
+ {profiles + .filter((p) => !p.builtin) + .map((p) => ( + + ))} +
+ )} +
+ { setLoading(true); try { - const res = await fetch("/api/permissions/commands"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - setData(await res.json()); + const [cmdRes, profRes] = await Promise.all([ + fetch("/api/permissions/commands"), + fetch("/api/permissions/profiles"), + ]); + if (!cmdRes.ok) throw new Error(`HTTP ${cmdRes.status}`); + setData(await cmdRes.json()); + if (profRes.ok) { + const body = await profRes.json(); + setProfiles(body.profiles || []); + setActiveProfileId(body.active_profile_id || null); + } else { + setProfiles([]); + setActiveProfileId(null); + } setError(null); } catch (e) { setError(e.message); @@ -56,9 +69,86 @@ export default function useCommandPermissions() { return res.json(); }, []); + const applyProfile = useCallback( + async (profileId) => { + // Synthetic presets: __yaml__ (config baseline), __blank__ (empty override) + const id = profileId || "__yaml__"; + const res = await fetch(`/api/permissions/profiles/${encodeURIComponent(id)}/apply`, { + method: "POST", + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${res.status}`); + } + await refresh(); + }, + [refresh], + ); + + const saveProfile = useCallback( + async (profileId, { description = "", fromCurrent = true, shell, ssh } = {}) => { + const body = { + description, + from_current_overrides: fromCurrent, + }; + if (!fromCurrent) { + if (shell) body.shell = shell; + if (ssh) body.ssh = ssh; + } + const res = await fetch(`/api/permissions/profiles/${encodeURIComponent(profileId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${res.status}`); + } + await refresh(); + }, + [refresh], + ); + + const deleteProfile = useCallback( + async (profileId) => { + const res = await fetch(`/api/permissions/profiles/${encodeURIComponent(profileId)}`, { + method: "DELETE", + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${res.status}`); + } + await refresh(); + }, + [refresh], + ); + + const clearActiveProfile = useCallback(async () => { + const res = await fetch("/api/permissions/profiles/active", { method: "DELETE" }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${res.status}`); + } + await refresh(); + }, [refresh]); + useEffect(() => { void refresh(); }, [refresh]); - return { data, loading, error, refresh, saveOverrides, resetOverrides, validate }; + return { + data, + profiles, + activeProfileId, + loading, + error, + refresh, + saveOverrides, + resetOverrides, + validate, + applyProfile, + saveProfile, + deleteProfile, + clearActiveProfile, + }; } From eae57dbf9f404eb92cf354ab761c3f95336ee58e Mon Sep 17 00:00:00 2001 From: Robin Schulz Date: Sun, 19 Jul 2026 17:43:03 +0200 Subject: [PATCH 4/6] Apply the same Dependabot changes as for AgentForge --- .github/dependabot.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a7b32b7..2b98214 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,23 +5,39 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "weekly" + interval: "monthly" day: "monday" - open-pull-requests-limit: 10 + open-pull-requests-limit: 2 labels: - "dependencies" - "javascript" commit-message: prefix: "chore(deps)" + groups: + npm-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + npm-major: + patterns: + - "*" + update-types: + - "major" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" + interval: "monthly" day: "monday" - open-pull-requests-limit: 5 + open-pull-requests-limit: 1 labels: - "dependencies" - "ci" commit-message: prefix: "chore(ci)" + groups: + github-actions: + patterns: + - "*" From 6878480fd49ab0a1cdaa66c7839ddcee56d90589 Mon Sep 17 00:00:00 2001 From: Robin Schulz Date: Sun, 19 Jul 2026 17:44:13 +0200 Subject: [PATCH 5/6] run linting/formatting --- src/components/CommandPermissionsModal.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/CommandPermissionsModal.jsx b/src/components/CommandPermissionsModal.jsx index 268cbf7..3d9361a 100644 --- a/src/components/CommandPermissionsModal.jsx +++ b/src/components/CommandPermissionsModal.jsx @@ -49,10 +49,7 @@ const policyFromBundle = (bundle) => { const linesToArrayDraft = (text) => text.split("\n").map((s) => s.replace(/\s+$/, "")); -const cleanList = (arr) => - (arr || []) - .map((s) => String(s).trim()) - .filter(Boolean); +const cleanList = (arr) => (arr || []).map((s) => String(s).trim()).filter(Boolean); const sanitizePolicy = (policy) => ({ mode: policy?.mode || "confirm", @@ -538,7 +535,8 @@ export default function CommandPermissionsModal({ open, onClose }) { YAML baseline (config){selectProfileValue === PRESET_YAML ? " · active" : ""} {profiles.map((p) => (