You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/04 14:43:38 UTC

(casbin-gateway) branch master updated (ce2028b -> 1c61862)

This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


    from ce2028b  fix(sessions): read every agent's transcript and key it to its own file
     new 2ab0fdf  feat(permissions): extend hook enforcement to cursor, gemini-cli and qwen-code
     new 4842d0f  fix(web): explain what each probe asked and what a grade means
     new 216573a  fix(agents): terminate a timed-out hook instead of asking it to exit
     new 1c61862  feat(permissions): extend hook enforcement to hermes-agent and windsurf

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 agenthook/decide.go                                | 122 +++++++++++++++++----
 .../link_other.go => agenthook/exit_unix.go        |   9 +-
 .../daemon_windows.go => agenthook/exit_windows.go |  24 ++--
 agenthook/hook.go                                  |  35 +++---
 agentpatch/cursor.go                               |   8 +-
 agentpatch/hermes_agent.go                         |  14 ++-
 agentpatch/hermes_observer.py                      |  56 +++++++++-
 agentpatch/hermes_plugin.yaml                      |   4 +-
 agentpatch/hook_groups.go                          |  15 ++-
 agentpatch/settings_hooks.go                       |  11 +-
 agentpatch/windsurf.go                             |   8 +-
 web/src/components/AgentGridCard.tsx               |   1 +
 web/src/components/AuthenticityOverview.tsx        |  70 ++++++++++--
 web/src/components/AuthenticityScore.tsx           |  33 +++++-
 web/src/components/OnboardingChecklist.tsx         |  75 ++++++++++---
 web/src/components/ProbeCaseList.tsx               |  44 +++++++-
 web/src/components/ProviderAuditCard.tsx           |  32 +++++-
 web/src/lib/authenticity.ts                        |  21 ++++
 web/src/locales/en/data.json                       |   5 +
 web/src/locales/zh/data.json                       |   5 +
 web/src/pages/AuthenticityPage.tsx                 |   8 +-
 web/src/pages/HomePage.tsx                         |   9 +-
 22 files changed, 506 insertions(+), 103 deletions(-)
 copy agentconfig/link_other.go => agenthook/exit_unix.go (77%)
 copy util/daemon_windows.go => agenthook/exit_windows.go (54%)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 02/04: fix(web): explain what each probe asked and what a grade means

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 4842d0fbcfda8e7f021fc274bcd7ffc7d9660a72
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 22:39:13 2026 +0800

    fix(web): explain what each probe asked and what a grade means
---
 web/src/components/AgentGridCard.tsx        |  1 +
 web/src/components/AuthenticityOverview.tsx | 70 ++++++++++++++++++++++++---
 web/src/components/AuthenticityScore.tsx    | 33 ++++++++++++-
 web/src/components/OnboardingChecklist.tsx  | 75 ++++++++++++++++++++++-------
 web/src/components/ProbeCaseList.tsx        | 44 +++++++++++++++--
 web/src/components/ProviderAuditCard.tsx    | 32 ++++++++++--
 web/src/lib/authenticity.ts                 | 21 ++++++++
 web/src/locales/en/data.json                |  5 ++
 web/src/locales/zh/data.json                |  5 ++
 web/src/pages/AuthenticityPage.tsx          |  8 ++-
 web/src/pages/HomePage.tsx                  |  9 +++-
 11 files changed, 269 insertions(+), 34 deletions(-)

diff --git a/web/src/components/AgentGridCard.tsx b/web/src/components/AgentGridCard.tsx
index 607415a..eef44d0 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -170,6 +170,7 @@ export function AgentGridCard({
   busy,
   runBusy,
   onEnable,
+  onLocated,
   onToggleRunning,
   onTogglePatch,
 }: {
diff --git a/web/src/components/AuthenticityOverview.tsx b/web/src/components/AuthenticityOverview.tsx
index 3580fc9..7098304 100644
--- a/web/src/components/AuthenticityOverview.tsx
+++ b/web/src/components/AuthenticityOverview.tsx
@@ -17,16 +17,17 @@ import {Link} from "react-router-dom";
 import {ShieldCheck} from "lucide-react";
 import i18next from "i18next";
 
+import * as ProbeCaseBackend from "@/backend/ProbeCaseBackend";
 import * as ProviderBackend from "@/backend/ProviderBackend";
-import {ScoreBadge, ScoreDial} from "@/components/AuthenticityScore";
+import {GradeScaleTip, ScoreBadge, ScoreDial} from "@/components/AuthenticityScore";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Card, CardContent} from "@/components/ui/card";
 import {cn} from "@/lib/utils";
-import {gradeStyleOf, probeFindings, summarizeAuthenticity} from "@/lib/authenticity";
+import {checkTitle, gradeStyleOf, probeFindings, summarizeAuthenticity} from "@/lib/authenticity";
 import {providerIdOf} from "@/lib/providers";
-import type {Provider, ProviderProbe, ProviderProbeMode} from "@/types";
+import type {ProbeCase, Provider, ProviderProbe, ProviderProbeMode} from "@/types";
 
 /** How often the scores are read again while the page is open. */
 const refreshMs = 20000;
@@ -39,18 +40,62 @@ function fill(key: string, values: Record<string, string | number>) {
   );
 }
 
+/**
+ * What a row says about itself in one plain sentence, so a state nobody has
+ * clicked into still reads as something. A row that failed names the cases it
+ * failed: "F" alone is a letter, not a finding.
+ */
+function stateOf(probe: ProviderProbe | undefined, cases: ProbeCase[]) {
+  if (!probe) {
+    return i18next.t("audit:Not probed yet");
+  }
+  if (!probe.ok) {
+    return i18next.t("audit:The probe could not run");
+  }
+  const {alerts, warnings} = probeFindings(probe);
+  const names = (checks: typeof alerts) => checks.map(check => checkTitle(check, cases)).join(", ");
+  if (alerts.length > 0) {
+    return fill("audit:Cases that failed", {names: names(alerts)});
+  }
+  if (warnings.length > 0) {
+    return fill("audit:Cases that were flagged", {names: names(warnings)});
+  }
+  return i18next.t(gradeStyleOf(probe.grade).label);
+}
+
 /** One provider's line: who it is, what it scored, and what failed if anything. */
-function ProviderScore({provider, probe}: {provider: Provider; probe?: ProviderProbe}) {
+function ProviderScore({
+  provider,
+  probe,
+  cases,
+}: {
+  provider: Provider;
+  probe?: ProviderProbe;
+  /** The suite as it stands now, which is what names a case in the reader's
+   * language rather than by the English it was stored under. */
+  cases: ProbeCase[];
+}) {
   const {alerts, warnings} = probeFindings(probe);
   const name = provider.displayName || provider.name;
 
   return (
     <Link
       to={`/authenticity?tab=report&provider=${encodeURIComponent(providerIdOf(provider))}`}
-      className="hover:border-foreground/20 hover:bg-accent/40 flex min-w-0 items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors"
+      className="hover:border-foreground/20 hover:bg-accent/40 flex min-w-0 items-start gap-2 rounded-lg border px-2.5 py-1.5 transition-colors"
     >
-      <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={name} size={16} />
-      <span className="truncate text-xs">{name}</span>
+      <ProviderIcon
+        icon={provider.icon}
+        baseUrl={provider.baseUrl}
+        alt={name}
+        size={16}
+        className="mt-0.5"
+      />
+      <div className="flex min-w-0 flex-col">
+        <span className="truncate text-xs">{name}</span>
+        <span className="text-muted-foreground line-clamp-2 text-[11px] leading-snug">
+          {stateOf(probe, cases)}
+        </span>
+      </div>
       <div className="ml-auto flex shrink-0 items-center gap-1.5">
         {alerts.length > 0 ? (
           <Badge variant="danger">{fill("audit:Failed cases", {count: alerts.length})}</Badge>
@@ -80,6 +125,7 @@ export function AuthenticityOverview({
   className?: string;
 }) {
   const [probes, setProbes] = React.useState<ProviderProbe[]>([]);
+  const [cases, setCases] = React.useState<ProbeCase[]>([]);
   const [mode, setMode] = React.useState<ProviderProbeMode>("auto");
   const [loaded, setLoaded] = React.useState(false);
 
@@ -102,6 +148,14 @@ export function AuthenticityOverview({
     return () => clearInterval(interval);
   }, []);
 
+  // Read once: the suite names the cases a row failed, and it does not change
+  // while a page is open.
+  React.useEffect(() => {
+    ProbeCaseBackend.getProbeCases()
+      .then(res => setCases(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, []);
+
   const configured = providers.filter(provider => provider.status !== "disabled");
   const byProvider = new Map(probes.map(probe => [probe.provider, probe]));
   const scored = configured
@@ -134,6 +188,7 @@ export function AuthenticityOverview({
             <div className="flex items-center gap-1.5">
               <ShieldCheck className="text-muted-foreground size-3.5" />
               <span className="text-sm font-medium">{i18next.t("audit:Authenticity")}</span>
+              <GradeScaleTip />
             </div>
             <span className="text-xs">
               {summary.graded === 0
@@ -167,6 +222,7 @@ export function AuthenticityOverview({
                 key={providerIdOf(entry.provider)}
                 provider={entry.provider}
                 probe={entry.probe}
+                cases={cases}
               />
             ))}
           </div>
diff --git a/web/src/components/AuthenticityScore.tsx b/web/src/components/AuthenticityScore.tsx
index ca46aed..f1b196f 100644
--- a/web/src/components/AuthenticityScore.tsx
+++ b/web/src/components/AuthenticityScore.tsx
@@ -12,14 +12,45 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {HelpCircle} from "lucide-react";
 import i18next from "i18next";
 
 import {cn} from "@/lib/utils";
-import {formatScore, gradeLetter, gradeStyleOf} from "@/lib/authenticity";
+import {formatScore, gradeLetter, gradeRanges, gradeStyleOf, gradeStyles} from "@/lib/authenticity";
 import {Badge} from "@/components/ui/badge";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import type {ProbeGrade, ProviderProbe} from "@/types";
 
+/**
+ * Which score buys which letter. A letter on its own is a verdict nobody can
+ * check, so the scale sits behind the question mark next to every grade.
+ */
+export function GradeScaleTip({className}: {className?: string}) {
+  return (
+    <SimpleTooltip
+      className="max-w-xs"
+      title={
+        <div className="flex flex-col gap-1">
+          <span className="font-medium">{i18next.t("audit:How a score becomes a letter")}</span>
+          {gradeRanges().map(entry => (
+            <div key={entry.grade} className="flex items-center gap-2">
+              <span className="w-3 shrink-0 font-semibold">{entry.grade}</span>
+              <span className="w-14 shrink-0 tabular-nums opacity-80">{`${entry.low}–${entry.high}`}</span>
+              <span>{i18next.t(gradeStyles[entry.grade].label)}</span>
+            </div>
+          ))}
+          <span className="opacity-80">{i18next.t("audit:Score is the weighted share")}</span>
+        </div>
+      }
+    >
+      <HelpCircle
+        className={cn("text-muted-foreground size-3.5 shrink-0 cursor-help", className)}
+        aria-label={i18next.t("audit:How a score becomes a letter")}
+      />
+    </SimpleTooltip>
+  );
+}
+
 /**
  * The score as a dial. The number is the weighted share of the test cases that
  * could be measured which answered the way the vendor's own API documents, so
diff --git a/web/src/components/OnboardingChecklist.tsx b/web/src/components/OnboardingChecklist.tsx
index 859fffb..94b7c09 100644
--- a/web/src/components/OnboardingChecklist.tsx
+++ b/web/src/components/OnboardingChecklist.tsx
@@ -33,9 +33,13 @@ function readDismissed(): boolean {
   }
 }
 
-function persistDismissed() {
+function writeDismissed(dismissed: boolean) {
   try {
-    localStorage.setItem(dismissedStorageKey, "true");
+    if (dismissed) {
+      localStorage.setItem(dismissedStorageKey, "true");
+    } else {
+      localStorage.removeItem(dismissedStorageKey);
+    }
   } catch {
     // Private-mode storage failures must not take the page down.
   }
@@ -48,13 +52,21 @@ interface Step {
   to: string;
 }
 
+export interface Onboarding {
+  steps: Step[];
+  /** Whether the card is on screen, which is what the reopen entry hangs off. */
+  open: boolean;
+  dismiss: () => void;
+  reopen: () => void;
+}
+
 /**
- * The three-step path from an empty machine to seeing traffic flow, taken
- * from the README's "Send an agent's traffic through Gateway" section. It
- * disappears once every step is done, and stays gone across reloads once
- * dismissed by hand.
+ * The three-step path from an empty machine to seeing traffic flow, taken from
+ * the README's "Send an agent's traffic through Gateway" section. It hides
+ * itself once every step is done and stays hidden once dismissed, so the state
+ * is held here rather than inside the card: something has to offer it back.
  */
-export function OnboardingChecklist({
+export function useOnboarding({
   providers,
   agents,
   stats,
@@ -62,8 +74,12 @@ export function OnboardingChecklist({
   providers: Provider[];
   agents: Agent[];
   stats: LlmAgentStat[];
-}) {
+}): Onboarding {
   const [dismissed, setDismissed] = React.useState(readDismissed);
+  // Set by the reopen entry, which shows the card again even where every step
+  // is done: someone asking for the guide is asking to read it, not to be told
+  // there is nothing left to do.
+  const [reopened, setReopened] = React.useState(false);
 
   const steps: Step[] = [
     {
@@ -85,16 +101,36 @@ export function OnboardingChecklist({
       to: "/llm-records",
     },
   ];
-  const doneCount = steps.filter(step => step.done).length;
 
-  if (dismissed || doneCount === steps.length) {
-    return null;
-  }
-
-  const dismiss = () => {
-    persistDismissed();
-    setDismissed(true);
+  return {
+    steps: steps,
+    open: reopened || (!dismissed && !steps.every(step => step.done)),
+    dismiss: () => {
+      writeDismissed(true);
+      setDismissed(true);
+      setReopened(false);
+    },
+    reopen: () => {
+      writeDismissed(false);
+      setDismissed(false);
+      setReopened(true);
+    },
   };
+}
+
+/** The entry that brings the card back, for whoever closed it. */
+export function OnboardingButton({onboarding}: {onboarding: Onboarding}) {
+  return (
+    <Button variant="ghost" onClick={onboarding.reopen}>
+      <ListChecks />
+      {i18next.t("agent:Getting started")}
+    </Button>
+  );
+}
+
+export function OnboardingChecklist({onboarding}: {onboarding: Onboarding}) {
+  const steps = onboarding.steps;
+  const doneCount = steps.filter(step => step.done).length;
 
   return (
     <Card className="gap-0 py-0 shadow-xs">
@@ -110,7 +146,12 @@ export function OnboardingChecklist({
             </div>
             <Progress value={(doneCount / steps.length) * 100} className="h-1.5" />
           </div>
-          <Button variant="ghost" size="icon-sm" onClick={dismiss} aria-label={i18next.t("general:Dismiss")}>
+          <Button
+            variant="ghost"
+            size="icon-sm"
+            onClick={onboarding.dismiss}
+            aria-label={i18next.t("general:Dismiss")}
+          >
             <X />
           </Button>
         </div>
diff --git a/web/src/components/ProbeCaseList.tsx b/web/src/components/ProbeCaseList.tsx
index 36c4b98..cf1b25b 100644
--- a/web/src/components/ProbeCaseList.tsx
+++ b/web/src/components/ProbeCaseList.tsx
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
+import {useLocation} from "react-router-dom";
 import {ChevronDown, FlaskConical, Pencil, Plus, RotateCcw, Trash2} from "lucide-react";
 import i18next from "i18next";
 
@@ -301,6 +302,7 @@ function CaseParams({probeCase}: {probeCase: ProbeCase}) {
 function CaseCard({
   probeCase,
   share,
+  lit,
   onEdit,
   onDelete,
   onToggle,
@@ -309,15 +311,30 @@ function CaseCard({
   probeCase: ProbeCase;
   /** What this case is worth of everything weighted, as a percentage. */
   share: number;
+  /** Set when a report tile linked here, which opens the case and lights it. */
+  lit: boolean;
   onEdit: () => void;
   onDelete: () => void;
   onToggle: (enabled: boolean) => void;
   busy: boolean;
 }) {
-  const [open, setOpen] = React.useState(false);
+  const [open, setOpen] = React.useState(lit);
+
+  React.useEffect(() => {
+    if (lit) {
+      setOpen(true);
+    }
+  }, [lit]);
 
   return (
-    <Card className={cn(!probeCase.enabled && "opacity-60")}>
+    <Card
+      data-probe-case={probeCase.name}
+      className={cn(
+        "scroll-mt-20 transition-shadow",
+        !probeCase.enabled && "opacity-60",
+        lit && "ring-primary/60 ring-2",
+      )}
+    >
       <CardContent className="flex flex-col gap-3 p-4">
         <div className="flex flex-wrap items-center gap-2">
           <span className="text-sm font-medium">{caseTitle(probeCase)}</span>
@@ -654,7 +671,7 @@ function CaseDialog({
  * every case can be reweighted, turned off, rewritten or added to, because the
  * questions worth asking of a reseller are not the same everywhere.
  */
-export function ProbeCaseList({onChanged}: {onChanged?: () => void}) {
+export function ProbeCaseList({focus = "", onChanged}: {focus?: string; onChanged?: () => void}) {
   const [cases, setCases] = React.useState<ProbeCase[] | null>(null);
   const [draft, setDraft] = React.useState<ProbeCase | null>(null);
   const [creating, setCreating] = React.useState(false);
@@ -677,6 +694,26 @@ export function ProbeCaseList({onChanged}: {onChanged?: () => void}) {
     load();
   }, [load]);
 
+  // A case linked to from a report tile is scrolled to and lit for a moment,
+  // otherwise the click lands on a long list that looks unchanged.
+  const [lit, setLit] = React.useState("");
+  // Keyed on the navigation, so clicking the same tile twice scrolls again.
+  const navigation = useLocation().key;
+
+  const loaded = cases !== null;
+
+  React.useEffect(() => {
+    if (focus === "" || !loaded) {
+      return;
+    }
+    document
+      .querySelector(`[data-probe-case="${CSS.escape(focus)}"]`)
+      ?.scrollIntoView({behavior: "smooth", block: "start"});
+    setLit(focus);
+    const off = setTimeout(() => setLit(""), 2500);
+    return () => clearTimeout(off);
+  }, [focus, navigation, loaded]);
+
   const done = () => {
     load();
     onChanged?.();
@@ -796,6 +833,7 @@ export function ProbeCaseList({onChanged}: {onChanged?: () => void}) {
               key={probeCase.name}
               probeCase={probeCase}
               share={weighted === 0 ? 0 : (probeCase.weight / weighted) * 100}
+              lit={lit === probeCase.name}
               busy={busy === probeCase.name}
               onEdit={() => {
                 setCreating(false);
diff --git a/web/src/components/ProviderAuditCard.tsx b/web/src/components/ProviderAuditCard.tsx
index 51acf71..a29e651 100644
--- a/web/src/components/ProviderAuditCard.tsx
+++ b/web/src/components/ProviderAuditCard.tsx
@@ -13,18 +13,19 @@
 // limitations under the License.
 
 import * as React from "react";
+import {Link} from "react-router-dom";
 import {Stethoscope} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
-import {ScoreDial} from "@/components/AuthenticityScore";
+import {GradeScaleTip, ScoreDial} from "@/components/AuthenticityScore";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Card, CardContent} from "@/components/ui/card";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
-import {checkTitle, gradeStyleOf, probeFindings} from "@/lib/authenticity";
+import {checkQuestion, checkTitle, gradeStyleOf, probeFindings} from "@/lib/authenticity";
 import {formatCost, formatTokens} from "@/lib/usage";
 import type {
   LlmAuditCheck,
@@ -78,12 +79,18 @@ function fill(key: string, values: Record<string, string | number>) {
 /** One measurement, whichever side it was measured from. */
 function CheckTile({
   title,
+  question,
+  caseName,
   value,
   detail,
   level,
   weight,
 }: {
   title: React.ReactNode;
+  /** What was asked. An answer read without its question explains nothing. */
+  question?: string;
+  /** The case behind the question, which the question links to. */
+  caseName?: string;
   value: React.ReactNode;
   detail: React.ReactNode;
   level: LlmAuditLevel;
@@ -103,6 +110,20 @@ function CheckTile({
           </SimpleTooltip>
         ) : null}
       </div>
+      {question ? (
+        caseName ? (
+          <SimpleTooltip title={i18next.t("audit:See how this is judged")}>
+            <Link
+              to={`/authenticity?tab=cases&case=${encodeURIComponent(caseName)}`}
+              className="hover:text-foreground text-foreground/75 text-xs leading-snug underline decoration-dotted underline-offset-2"
+            >
+              {question}
+            </Link>
+          </SimpleTooltip>
+        ) : (
+          <span className="text-foreground/75 text-xs leading-snug">{question}</span>
+        )
+      ) : null}
       <span className={cn("text-lg font-semibold tabular-nums", style.value)}>{value}</span>
       <span className="text-muted-foreground text-xs leading-snug">{detail}</span>
     </div>
@@ -343,7 +364,10 @@ function ScoreHeadline({probe}: {probe: ProviderProbe}) {
     <div className="bg-card flex items-center gap-4 rounded-lg border p-3">
       <ScoreDial probe={probe} size={84} />
       <div className="flex min-w-0 flex-col gap-1">
-        <span className="text-sm font-semibold">{i18next.t(style.label)}</span>
+        <div className="flex items-center gap-1.5">
+          <span className="text-sm font-semibold">{i18next.t(style.label)}</span>
+          <GradeScaleTip />
+        </div>
         <span className="text-muted-foreground text-xs leading-snug">{i18next.t(style.verdict)}</span>
         <span className="text-muted-foreground text-xs">
           {fill("audit:Score from cases", {
@@ -438,6 +462,8 @@ function ProbeSection({
               <CheckTile
                 key={`${check.case}-${check.key}-${index}`}
                 title={check.case ? checkTitle(check, cases) : probeTitle(check.key)}
+                question={checkQuestion(check, cases)}
+                caseName={check.case}
                 value={probeValue(check)}
                 detail={probeDetail(check, probe)}
                 level={check.level}
diff --git a/web/src/lib/authenticity.ts b/web/src/lib/authenticity.ts
index eafbf69..4a38f99 100644
--- a/web/src/lib/authenticity.ts
+++ b/web/src/lib/authenticity.ts
@@ -80,6 +80,18 @@ export const gradeStyles: Record<
   },
 };
 
+/**
+ * Every grade as the range of scores it is cut from, which is the one thing a
+ * letter cannot say about itself.
+ */
+export function gradeRanges() {
+  return gradeFloors.map((entry, index) => ({
+    grade: entry.grade,
+    low: entry.floor,
+    high: index === 0 ? 100 : gradeFloors[index - 1].floor - 1,
+  }));
+}
+
 export function gradeStyleOf(grade: ProbeGrade | undefined) {
   return gradeStyles[grade ?? "unknown"] ?? gradeStyles.unknown;
 }
@@ -177,6 +189,15 @@ export function caseMethod(probeCase: ProbeCase) {
   return localized(probeCase, "method");
 }
 
+/**
+ * The question a check was drawn from, so a tile can show what was asked and
+ * not only what came back. Empty for a report older than the stored suite.
+ */
+export function checkQuestion(check: ProbeCheck, cases: ProbeCase[]) {
+  const known = cases.find(probeCase => probeCase.name === check.case);
+  return known ? caseQuestion(known) : "";
+}
+
 /**
  * What a check was called. The name stored on the check is what the case was
  * called when it ran, which is what an old report has to keep saying; the live
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index b9c168b..d16564b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -612,6 +612,8 @@
     "Case dialog detail": "A case is one question and one way of judging the answer. What it is worth in the score is the weight, relative to the other cases measured on the same run.",
     "Case name hint": "The id this case is stored and reported under. It cannot be changed later.",
     "Cases description": "The suite every probe runs. Each case names the question it puts to the upstream and how the answer is judged, because a score whose method is not published is not evidence. Reweight one, turn it off, rewrite it, or add a question of your own.",
+    "Cases that failed": "Did not pass: {names}",
+    "Cases that were flagged": "Worth a look: {names}",
     "Channel audit": "Channel audit",
     "Check": "Check",
     "Check hint": "Which engine runs the case: what it sends and how it reads the answer.",
@@ -663,6 +665,7 @@
     "Hidden instructions": "Hidden instructions",
     "Hidden none": "Nothing stands above this request but what this gateway sent.",
     "Honored": "Honored",
+    "How a score becomes a letter": "How a score becomes a letter",
     "Identity alias": "Asked for {asked} and {answered} answered, which is the model this vendor documents that name as pointing at.",
     "Identity different": "Asked for {asked} and the answer named {answered}.",
     "Identity has no request": "This case sends nothing of its own: it reads the model name off the first answer of the run.",
@@ -747,7 +750,9 @@
     "Right": "Right",
     "Run this case": "Run this case",
     "Score from cases": "{measured} of {total} cases measured",
+    "Score is the weighted share": "The score is the weighted share of the cases that could be measured which answered the way the vendor's own API documents.",
     "Second request after": "Second request after",
+    "See how this is judged": "See what this case asks and how the answer is judged",
     "See the evidence": "See the evidence",
     "Self match": "It says it is {vendor}'s: {answer}",
     "Self other": "Sold as a model of {vendor}, it says it is {other}'s: {answer}",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index aa0fef4..e4cd473 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -612,6 +612,8 @@
     "Case dialog detail": "一条用例就是一个问题加一套判定方法。它在评分中占多少由权重决定,与同一次探测中其他被测用例相比。",
     "Case name hint": "用例存储和上报时用的 ID,之后不能修改。",
     "Cases description": "每次探测都会跑这套用例。每条用例都写明它向上游提了什么问题、答案怎么判定——评分方法不公开就算不上证据。你可以改权重、停用、重写,也可以自己加一条。",
+    "Cases that failed": "没通过:{names}",
+    "Cases that were flagged": "值得看一眼:{names}",
     "Channel audit": "渠道体检",
     "Check": "检查项",
     "Check hint": "由哪个引擎来跑:决定发什么请求、怎么读答案。",
@@ -663,6 +665,7 @@
     "Hidden instructions": "隐藏指令",
     "Hidden none": "这次请求上面没有本网关之外的任何东西。",
     "Honored": "生效",
+    "How a score becomes a letter": "分数和等级怎么对应",
     "Identity alias": "请求的是 {asked},回答的是 {answered},而这正是该厂商在文档里写明这个名字指向的模型。",
     "Identity different": "请求的是 {asked},返回里写的是 {answered}。",
     "Identity has no request": "这条用例不单独发请求:它读的是本次探测第一个回答里的模型名。",
@@ -747,7 +750,9 @@
     "Right": "答对",
     "Run this case": "运行这条用例",
     "Score from cases": "{total} 条用例中测得 {measured} 条",
+    "Score is the weighted share": "分数是可测到的用例里、按厂商自己 API 文档作答的那部分的加权占比。",
     "Second request after": "第二次请求间隔",
+    "See how this is judged": "看看这道题问的是什么、怎么判对错",
     "See the evidence": "查看证据",
     "Self match": "它说自己出自 {vendor}:{answer}",
     "Self other": "卖的是 {vendor} 的模型,它却说自己出自 {other}:{answer}",
diff --git a/web/src/pages/AuthenticityPage.tsx b/web/src/pages/AuthenticityPage.tsx
index 6b6121a..7e00ba1 100644
--- a/web/src/pages/AuthenticityPage.tsx
+++ b/web/src/pages/AuthenticityPage.tsx
@@ -41,6 +41,8 @@ export default function AuthenticityPage({account}: {account: Account}) {
   const tab: AuthenticityTab = searchParams.get("tab") === "cases" ? "cases" : "report";
   // Set when the overview above was clicked, so the report opens at that card.
   const focus = searchParams.get("provider") ?? "";
+  // Set when a report tile was clicked, so the suite opens at the case it asks.
+  const caseFocus = searchParams.get("case") ?? "";
   const setTab = (next: AuthenticityTab) =>
     setSearchParams(
       previous => {
@@ -80,7 +82,11 @@ export default function AuthenticityPage({account}: {account: Account}) {
         </TabsList>
       </Tabs>
 
-      {tab === "report" ? <ProviderAuditPanel owner={account.name} focus={focus} /> : <ProbeCaseList />}
+      {tab === "report" ? (
+        <ProviderAuditPanel owner={account.name} focus={focus} />
+      ) : (
+        <ProbeCaseList focus={caseFocus} />
+      )}
     </PageContainer>
   );
 }
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index faca348..d52c8b7 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -24,7 +24,7 @@ import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {AuthenticityOverview} from "@/components/AuthenticityOverview";
 import {AgentGridCard} from "@/components/AgentGridCard";
-import {OnboardingChecklist} from "@/components/OnboardingChecklist";
+import {OnboardingButton, OnboardingChecklist, useOnboarding} from "@/components/OnboardingChecklist";
 import {EmptyState, ErrorState} from "@/components/shared/empty-state";
 import {Loading} from "@/components/shared/loading";
 import {UnauthorizedResult} from "@/components/shared/misc";
@@ -189,6 +189,10 @@ export default function HomePage({account}: {account: Account}) {
     return () => clearInterval(interval);
   }, [isAdmin]);
 
+  // Held here rather than inside the card, so the header can offer the guide
+  // back to whoever closed it.
+  const onboarding = useOnboarding({providers: providers, agents: agents, stats: stats});
+
   if (!isAdmin) {
     return <UnauthorizedResult />;
   }
@@ -214,6 +218,7 @@ export default function HomePage({account}: {account: Account}) {
         description={account.hostname}
         actions={
           <>
+            {loaded && !onboarding.open ? <OnboardingButton onboarding={onboarding} /> : null}
             <Button asChild variant="ghost">
               <Link to="/agents">
                 <Table2 />
@@ -225,7 +230,7 @@ export default function HomePage({account}: {account: Account}) {
         }
       />
 
-      {loaded ? <OnboardingChecklist providers={providers} agents={agents} stats={stats} /> : null}
+      {loaded && onboarding.open ? <OnboardingChecklist onboarding={onboarding} /> : null}
 
       {/* With no agents to show, the failure is the whole page and is rendered
           below instead; here it sits above the cards that are still on screen. */}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 04/04: feat(permissions): extend hook enforcement to hermes-agent and windsurf

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 1c61862d731f73bb34436584f99ebae30914ac4c
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 22:43:30 2026 +0800

    feat(permissions): extend hook enforcement to hermes-agent and windsurf
---
 agenthook/decide.go           | 59 +++++++++++++++++++++++++++++++++++--------
 agenthook/hook.go             | 33 +++++++++++++-----------
 agentpatch/hermes_agent.go    | 14 ++++++++--
 agentpatch/hermes_observer.py | 56 ++++++++++++++++++++++++++++++++++++++--
 agentpatch/hermes_plugin.yaml |  4 +--
 agentpatch/windsurf.go        |  8 ++++--
 6 files changed, 140 insertions(+), 34 deletions(-)

diff --git a/agenthook/decide.go b/agenthook/decide.go
index b86433d..53c314f 100644
--- a/agenthook/decide.go
+++ b/agenthook/decide.go
@@ -22,6 +22,7 @@ package agenthook
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"io"
 	"net/http"
 	"time"
@@ -49,34 +50,61 @@ var preToolEvents = map[string][]string{
 	// Cursor asks separately for a shell command and an MCP call, and neither
 	// carries a tool name the general event would have given.
 	"cursor": {"preToolUse", "beforeShellExecution", "beforeMCPExecution"},
+	// Cascade names an action rather than a tool, and asks about each kind of
+	// action separately.
+	"windsurf": {"pre_run_command", "pre_write_code", "pre_mcp_tool_use"},
+}
+
+// eventName is what the agent calls the event, read from the field it puts it
+// in. Cascade is the one that names it something else.
+func eventName(agentID string, event map[string]any) string {
+	if agentID == "windsurf" {
+		return stringValue(event["agent_action_name"])
+	}
+	return stringValue(event["hook_event_name"])
 }
 
 // preToolEvent reports whether this event is one the agent is waiting on, and
 // the tool it is about, named the way the permissions catalogue names tools.
 func preToolEvent(agentID string, event map[string]any) (string, bool) {
-	name := stringValue(event["hook_event_name"])
+	name := eventName(agentID, event)
 	if name == "" || !listed(preToolEvents[agentID], name) {
 		return "", false
 	}
 
 	switch name {
-	case "beforeShellExecution":
+	case "beforeShellExecution", "pre_run_command":
 		// Only the command is carried, and running one is the whole of what
 		// this event is.
 		return "shell", true
+	case "pre_write_code":
+		// Cascade edits a file in place, so this is the edit switch rather than
+		// the one for creating a file.
+		return "edit_file", true
 	case "beforeMCPExecution":
-		server := stringValue(event["mcp_server_name"])
-		tool := stringValue(event["tool_name"])
-		if server == "" {
-			return tool, tool != ""
+		return mcpToolName(stringValue(event["mcp_server_name"]), stringValue(event["tool_name"]))
+	case "pre_mcp_tool_use":
+		info, _ := event["tool_info"].(map[string]any)
+		if info == nil {
+			info = map[string]any{}
 		}
-		return "mcp__" + server + "__" + tool, true
+		return mcpToolName(stringValue(info["mcp_server_name"]), stringValue(info["mcp_tool_name"]))
 	}
 
 	tool := stringValue(event["tool_name"])
 	return tool, tool != ""
 }
 
+// mcpToolName is how an MCP call is named where the agent reports the server
+// and the tool apart: the permissions catalogue reads the server out of the
+// combined name.
+func mcpToolName(server string, tool string) (string, bool) {
+	if server == "" {
+		return tool, tool != ""
+	}
+	return "mcp__" + server + "__" + tool, true
+}
+
 func listed(values []string, name string) bool {
 	for _, value := range values {
 		if value == name {
@@ -125,10 +153,14 @@ func allowed(url string, token string, request map[string]any) (bool, string) {
 	return false, answer.Data.Reason
 }
 
-// writeDenial tells the agent to refuse the call, in the shape it reads. The
-// three schemas below are the agents' own, and an agent Gateway has no schema
+// blockedExitCode is what Cascade reads a refusal as: it takes no answer on
+// stdout, and blocks the action on this code alone.
+const blockedExitCode = 2
+
+// writeDenial tells the agent to refuse the call, in the shape it reads, and
+// answers with the exit code that goes with it. An agent Gateway has no shape
 // for is left alone: a hook that only observes must print nothing at all.
-func writeDenial(out io.Writer, agentID string, reason string) {
+func writeDenial(out io.Writer, errOut io.Writer, agentID string, reason string) int {
 	var answer map[string]any
 	switch agentID {
 	case "claude-code", "qwen-code":
@@ -143,8 +175,13 @@ func writeDenial(out io.Writer, agentID string, reason string) {
 		answer = map[string]any{"decision": "deny", "reason": reason}
 	case "cursor":
 		answer = map[string]any{"permission": "deny", "user_message": reason, "agent_message": reason}
+	case "windsurf":
+		// Cascade shows what the hook wrote on stderr and blocks on the code.
+		_, _ = fmt.Fprintln(errOut, reason)
+		return blockedExitCode
 	default:
-		return
+		return 0
 	}
 	_ = json.NewEncoder(out).Encode(answer)
+	return 0
 }
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 0950340..1a88142 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -52,13 +52,14 @@ func ServeIfInvoked() {
 	}
 	timer := time.AfterFunc(hookLifetime, terminateSelf)
 	defer timer.Stop()
-	_ = Run(os.Args[2:], os.Stdin, os.Stdout)
-	os.Exit(0)
+	code, _ := Run(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)
+	os.Exit(code)
 }
 
 // Run reads one hook event, decides it where the agent is waiting on a verdict,
-// and reports its normalized record.
-func Run(args []string, input io.Reader, output io.Writer) error {
+// and reports its normalized record. The exit code it answers with is the one
+// the agent reads: some take a refusal on stdout, Cascade takes it as a code.
+func Run(args []string, input io.Reader, output io.Writer, errOutput io.Writer) (int, error) {
 	flags := flag.NewFlagSet(Subcommand, flag.ContinueOnError)
 	flags.SetOutput(io.Discard)
 	managed := flags.Bool("casbin-gateway-agent-monitor", false, "Gateway-managed monitor hook")
@@ -69,47 +70,49 @@ func Run(args []string, input io.Reader, output io.Writer) error {
 	ingestToken := flags.String("ingest-token", "", "credential presented to the Gateway record endpoint")
 	decisionURL := flags.String("decision-url", "", "Gateway endpoint that decides a tool call")
 	if err := flags.Parse(args); err != nil {
-		return err
+		return 0, err
 	}
 	normalize, known := normalizers[*agentID]
 	if !*managed || !known {
-		return fmt.Errorf("unsupported hook agent %q", *agentID)
+		return 0, fmt.Errorf("unsupported hook agent %q", *agentID)
 	}
 
 	decoder := json.NewDecoder(io.LimitReader(input, maxHookInput))
 	decoder.UseNumber()
 	var event map[string]any
 	if err := decoder.Decode(&event); err != nil {
-		return err
+		return 0, err
 	}
 	// The verdict comes before the record: the agent is held up until this hook
-	// answers, and the report is best effort either way.
+	// answers, and the report is best effort either way, so nothing below may
+	// lose the code the verdict decided.
+	code := 0
 	if tool, ok := preToolEvent(*agentID, event); ok {
 		request := map[string]any{
 			"agent":      *agentID,
 			"tool":       tool,
-			"sessionKey": firstString(event, "session_id", "conversation_id"),
+			"sessionKey": firstString(event, "session_id", "conversation_id", "trajectory_id"),
 			"toolUseId":  stringValue(event["tool_use_id"]),
 		}
 		if allow, reason := allowed(*decisionURL, *ingestToken, request); !allow {
-			writeDenial(output, *agentID, reason)
+			code = writeDenial(output, errOutput, *agentID, reason)
 		}
 	}
 
 	record := normalize(event, *agentPath, time.Now())
 	if record == nil || *recordsURL == "" {
-		return nil
+		return code, nil
 	}
 	if *owner != "" {
 		record.User = *owner
 	}
 	body, err := json.Marshal(record)
 	if err != nil {
-		return err
+		return code, err
 	}
 	request, err := http.NewRequest(http.MethodPost, *recordsURL, bytes.NewReader(body))
 	if err != nil {
-		return err
+		return code, err
 	}
 	request.Header.Set("Content-Type", "application/json")
 	if *ingestToken != "" {
@@ -117,9 +120,9 @@ func Run(args []string, input io.Reader, output io.Writer) error {
 	}
 	response, err := (&http.Client{Timeout: reportTimeout}).Do(request)
 	if err != nil {
-		return err
+		return code, err
 	}
-	return response.Body.Close()
+	return code, response.Body.Close()
 }
 
 // normalizers maps each agent that reports through a command hook to the
diff --git a/agentpatch/hermes_agent.go b/agentpatch/hermes_agent.go
index 3f3704a..1007876 100644
--- a/agentpatch/hermes_agent.go
+++ b/agentpatch/hermes_agent.go
@@ -70,6 +70,7 @@ type hermesObserverConfig struct {
 	Owner             string `json:"owner"`
 	PluginVersion     string `json:"pluginVersion"`
 	RecordsURL        string `json:"recordsUrl"`
+	DecisionURL       string `json:"decisionUrl"`
 	AgentPath         string `json:"agentPath"`
 	User              string `json:"user"`
 	IngestToken       string `json:"ingestToken"`
@@ -157,12 +158,16 @@ func (p hermesPatcher) Unpatch(target Target) error {
 
 // PatchNotice is the copy the agents table shows before and after the button,
 // so the Web UI does not need a branch of its own for how Hermes loads plugins.
+// Decides: the plugin's pre_tool_call callback returns a block directive, which
+// Hermes honours by refusing the call and handing the message to the model.
+func (hermesPatcher) Decides() bool { return true }
+
 func (hermesPatcher) PatchNotice(patched bool) (string, string) {
 	if patched {
-		return "Removes the behaviour observer from the default Hermes profile and drops its entry from plugins.enabled. Nothing else in config.yaml changes.",
+		return "Removes the behaviour observer from the default Hermes profile and drops its entry from plugins.enabled, and with it the check before each tool call. Nothing else in config.yaml changes.",
 			"Restart running Hermes processes to unload it."
 	}
-	return "Installs a behaviour observer with redacted tool inputs in the default Hermes profile and adds it to plugins.enabled. Named profiles are left alone.",
+	return "Installs a behaviour observer with redacted tool inputs in the default Hermes profile and adds it to plugins.enabled. It also refuses a tool call this agent's permissions do not allow; an agent nobody has restricted is never held up. Named profiles are left alone.",
 		"Restart running Hermes processes to load it."
 }
 
@@ -264,11 +269,16 @@ func renderHermesObserverConfig(target Target, token string) ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
+	decision, err := decisionURL()
+	if err != nil {
+		return nil, err
+	}
 	data, err := json.MarshalIndent(hermesObserverConfig{
 		SchemaVersion:     hermesConfigSchema,
 		Owner:             "casbin-gateway",
 		PluginVersion:     hermesPluginVersion,
 		RecordsURL:        url,
+		DecisionURL:       decision,
 		AgentPath:         target.Path,
 		User:              target.Owner,
 		IngestToken:       token,
diff --git a/agentpatch/hermes_observer.py b/agentpatch/hermes_observer.py
index b0032a0..4ff6cf0 100644
--- a/agentpatch/hermes_observer.py
+++ b/agentpatch/hermes_observer.py
@@ -3,6 +3,11 @@
 This module intentionally never serializes prompts, responses, reasoning,
 tool results, or subagent text. Tool arguments are redacted and bounded before
 hook callbacks enqueue records without waiting for the Gateway HTTP endpoint.
+
+One callback is not an observer: pre_tool_call asks Gateway whether the call is
+allowed and returns a block directive when it is not. That question is the only
+thing in here a session waits on, and anything that goes wrong with it allows
+the call.
 """
 
 from __future__ import annotations
@@ -104,6 +109,10 @@ _AGENT_PATH = str(_CONFIG.get("agentPath") or "")
 _USER = str(_CONFIG.get("user") or "")
 _INGEST_TOKEN = str(_CONFIG.get("ingestToken") or "")
 _INGEST_TOKEN_HEADER = str(_CONFIG.get("ingestTokenHeader") or "")
+_DECISION_URL = str(_CONFIG.get("decisionUrl") or "")
+# The tool call waits on this, so a Gateway that is slow to answer is treated as
+# one that is not running.
+_DECIDE_TIMEOUT = 2.0
 
 
 def _text(value: Any, limit: int = 512) -> str:
@@ -433,8 +442,50 @@ def _tool_record(action: str, outcome: str, values: dict[str, Any]) -> dict[str,
     return {key: value for key, value in record.items() if value not in ("", None)}
 
 
-def _on_pre_tool_call(**values: Any) -> None:
+def _decide(tool_name: str, session_id: str, tool_call_id: str) -> str:
+    """Ask Gateway whether a tool call may go ahead, answering with the reason
+    it may not. Every failure answers "" - a plugin that cannot get a verdict
+    must not stop Hermes working."""
+    if not _DECISION_URL or not tool_name:
+        return ""
+    try:
+        body = json.dumps(
+            {
+                "agent": "hermes-agent",
+                "tool": tool_name,
+                "sessionKey": session_id,
+                "toolUseId": tool_call_id,
+            }
+        ).encode("utf-8")
+        request = urllib.request.Request(
+            _DECISION_URL, data=body, headers={"Content-Type": "application/json"}
+        )
+        if _INGEST_TOKEN and _INGEST_TOKEN_HEADER:
+            request.add_header(_INGEST_TOKEN_HEADER, _INGEST_TOKEN)
+        with urllib.request.urlopen(request, timeout=_DECIDE_TIMEOUT) as response:
+            answer = json.loads(response.read().decode("utf-8"))
+    except Exception:
+        return ""
+
+    if not isinstance(answer, dict) or answer.get("status") != "ok":
+        return ""
+    data = answer.get("data")
+    if not isinstance(data, dict) or data.get("allow") is not False:
+        return ""
+    return str(data.get("reason") or f"the permissions of this agent do not allow {tool_name}")
+
+
+def _on_pre_tool_call(**values: Any) -> dict[str, Any] | None:
     _enqueue(_tool_record("call", "attempted", values))
+    refusal = _decide(
+        _text(values.get("tool_name")),
+        _text(values.get("session_id")),
+        _text(values.get("tool_call_id")),
+    )
+    # Hermes vetoes the call on this directive and hands the message to the
+    # model; any other return value is ignored, which is what leaves every
+    # other callback here an observer.
+    return {"action": "block", "message": refusal} if refusal else None
 
 
 def _on_post_tool_call(**values: Any) -> None:
@@ -528,7 +579,8 @@ _HOOKS = {
 
 
 def register(ctx: Any) -> None:
-    """Register observational hooks. Return values are deliberately ignored."""
+    """Register the hooks. Every return value is ignored but pre_tool_call's,
+    which is the one Hermes reads a block directive from."""
     _start_sender()
     for name, callback in _HOOKS.items():
         ctx.register_hook(name, callback)
diff --git a/agentpatch/hermes_plugin.yaml b/agentpatch/hermes_plugin.yaml
index 83951cc..d830ac3 100644
--- a/agentpatch/hermes_plugin.yaml
+++ b/agentpatch/hermes_plugin.yaml
@@ -1,6 +1,6 @@
 name: casbin-gateway-agent-monitor
-version: "1.1.0"
-description: "Behaviour audit with redacted tool inputs for Casbin Gateway."
+version: "1.2.0"
+description: "Behaviour audit with redacted tool inputs, and a permission check before each tool call, for Casbin Gateway."
 author: Casbin
 kind: standalone
 # "provides_hooks" is the key Hermes' manifest parser reads; the callbacks
diff --git a/agentpatch/windsurf.go b/agentpatch/windsurf.go
index 478d89d..9c008be 100644
--- a/agentpatch/windsurf.go
+++ b/agentpatch/windsurf.go
@@ -110,11 +110,15 @@ func (p windsurfPatcher) Status(target Target) (Status, error) {
 	return Status{Patched: true, Detail: "Cascade hooks active"}, nil
 }
 
+// Decides: Cascade waits on its pre_ hooks and blocks the action when one exits
+// with code 2, which is how a refusal is written there.
+func (windsurfPatcher) Decides() bool { return true }
+
 func (windsurfPatcher) PatchNotice(patched bool) (string, string) {
 	if patched {
-		return "Removes Gateway's audit-only Cascade hooks.", "Restart Windsurf to stop running them."
+		return "Removes Gateway's Cascade hooks, and with them the check before a command, an edit or an MCP call.", "Restart Windsurf to stop running them."
 	}
-	return "Installs audit-only Cascade hooks. They observe events and never block an action.",
+	return "Installs Gateway's Cascade hooks. They observe events, and refuse a command, an edit or an MCP call this agent's permissions do not allow; an agent nobody has restricted is never held up.",
 		"Restart Windsurf to load them."
 }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 03/04: fix(agents): terminate a timed-out hook instead of asking it to exit

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 216573a0b4f261f269b6c5b0756211d198e9656e
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 22:41:25 2026 +0800

    fix(agents): terminate a timed-out hook instead of asking it to exit
---
 agenthook/exit_unix.go    | 25 +++++++++++++++++++++++++
 agenthook/exit_windows.go | 32 ++++++++++++++++++++++++++++++++
 agenthook/hook.go         |  2 +-
 3 files changed, 58 insertions(+), 1 deletion(-)

diff --git a/agenthook/exit_unix.go b/agenthook/exit_unix.go
new file mode 100644
index 0000000..d45c956
--- /dev/null
+++ b/agenthook/exit_unix.go
@@ -0,0 +1,25 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+
+package agenthook
+
+import "os"
+
+// terminateSelf ends this process. Nothing unwinds a process on the way out
+// here, so the ordinary exit is already the immediate one.
+func terminateSelf() {
+	os.Exit(0)
+}
diff --git a/agenthook/exit_windows.go b/agenthook/exit_windows.go
new file mode 100644
index 0000000..3be36b0
--- /dev/null
+++ b/agenthook/exit_windows.go
@@ -0,0 +1,32 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agenthook
+
+import (
+	"os"
+	"syscall"
+)
+
+// terminateSelf ends this process without unwinding it. os.Exit hands over to
+// the Windows loader, which a thread stuck there holds up for good: that is
+// how a hook whose event never arrives outlives its deadline by hours instead
+// of by nothing. There is nothing here to unwind - the hook writes no file and
+// holds no lock - so the deadline takes the process down outright.
+func terminateSelf() {
+	if handle, err := syscall.GetCurrentProcess(); err == nil {
+		_ = syscall.TerminateProcess(handle, 0)
+	}
+	os.Exit(0)
+}
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 5586984..0950340 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -50,7 +50,7 @@ func ServeIfInvoked() {
 	if len(os.Args) < 2 || os.Args[1] != Subcommand {
 		return
 	}
-	timer := time.AfterFunc(hookLifetime, func() { os.Exit(0) })
+	timer := time.AfterFunc(hookLifetime, terminateSelf)
 	defer timer.Stop()
 	_ = Run(os.Args[2:], os.Stdin, os.Stdout)
 	os.Exit(0)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 01/04: feat(permissions): extend hook enforcement to cursor, gemini-cli and qwen-code

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 2ab0fdf52e2a4d5b7ae0c91da978b8c56ee82d6a
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 22:26:14 2026 +0800

    feat(permissions): extend hook enforcement to cursor, gemini-cli and qwen-code
---
 agenthook/decide.go          | 81 +++++++++++++++++++++++++++++++++-----------
 agenthook/hook.go            |  2 +-
 agentpatch/cursor.go         |  8 +++--
 agentpatch/hook_groups.go    | 15 +++++++-
 agentpatch/settings_hooks.go | 11 ++++--
 5 files changed, 90 insertions(+), 27 deletions(-)

diff --git a/agenthook/decide.go b/agenthook/decide.go
index 4c4d4e6..b86433d 100644
--- a/agenthook/decide.go
+++ b/agenthook/decide.go
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// This file is the blocking half of the hook. A pre-tool event asks Gateway
-// whether the call may go ahead, and an agent that speaks a refusal writes it
-// on stdout in its own words. Everything else about the hook stays an
+// This file is the blocking half of the hook. The event an agent fires before
+// it runs a tool asks Gateway whether the call may go ahead, and a refusal is
+// written on stdout in that agent's own words. Every other event stays an
 // observation.
 
 package agenthook
@@ -40,19 +40,52 @@ type decision struct {
 	Reason string `json:"reason"`
 }
 
-// preToolEvent reports whether this event is the one asked before a tool runs,
-// by the name each agent gives it, and the tool it is about.
+// preToolEvents are the events each agent fires before running a tool, and can
+// be told to refuse. An agent absent from here only observes.
+var preToolEvents = map[string][]string{
+	"claude-code": {"PreToolUse"},
+	"qwen-code":   {"PreToolUse"},
+	"gemini-cli":  {"BeforeTool"},
+	// Cursor asks separately for a shell command and an MCP call, and neither
+	// carries a tool name the general event would have given.
+	"cursor": {"preToolUse", "beforeShellExecution", "beforeMCPExecution"},
+}
+
+// preToolEvent reports whether this event is one the agent is waiting on, and
+// the tool it is about, named the way the permissions catalogue names tools.
 func preToolEvent(agentID string, event map[string]any) (string, bool) {
-	if agentID != "claude-code" {
+	name := stringValue(event["hook_event_name"])
+	if name == "" || !listed(preToolEvents[agentID], name) {
 		return "", false
 	}
-	if name, _ := event["hook_event_name"].(string); name != "PreToolUse" {
-		return "", false
+
+	switch name {
+	case "beforeShellExecution":
+		// Only the command is carried, and running one is the whole of what
+		// this event is.
+		return "shell", true
+	case "beforeMCPExecution":
+		server := stringValue(event["mcp_server_name"])
+		tool := stringValue(event["tool_name"])
+		if server == "" {
+			return tool, tool != ""
+		}
+		return "mcp__" + server + "__" + tool, true
 	}
-	tool, _ := event["tool_name"].(string)
+
+	tool := stringValue(event["tool_name"])
 	return tool, tool != ""
 }
 
+func listed(values []string, name string) bool {
+	for _, value := range values {
+		if value == name {
+			return true
+		}
+	}
+	return false
+}
+
 // allowed asks Gateway whether this tool call may go ahead. Anything that goes
 // wrong - no endpoint, no answer, a body that will not parse - allows the call:
 // a hook that cannot get a verdict must not stop the agent from working.
@@ -92,18 +125,26 @@ func allowed(url string, token string, request map[string]any) (bool, string) {
 	return false, answer.Data.Reason
 }
 
-// writeDenial tells the agent to refuse the call, in the shape it reads. Claude
-// Code takes a permission decision off the hook's stdout; an agent with no such
-// shape is left alone, since a hook that only observes must print nothing.
+// writeDenial tells the agent to refuse the call, in the shape it reads. The
+// three schemas below are the agents' own, and an agent Gateway has no schema
+// for is left alone: a hook that only observes must print nothing at all.
 func writeDenial(out io.Writer, agentID string, reason string) {
-	if agentID != "claude-code" {
+	var answer map[string]any
+	switch agentID {
+	case "claude-code", "qwen-code":
+		answer = map[string]any{
+			"hookSpecificOutput": map[string]any{
+				"hookEventName":            "PreToolUse",
+				"permissionDecision":       "deny",
+				"permissionDecisionReason": reason,
+			},
+		}
+	case "gemini-cli":
+		answer = map[string]any{"decision": "deny", "reason": reason}
+	case "cursor":
+		answer = map[string]any{"permission": "deny", "user_message": reason, "agent_message": reason}
+	default:
 		return
 	}
-	_ = json.NewEncoder(out).Encode(map[string]any{
-		"hookSpecificOutput": map[string]any{
-			"hookEventName":            "PreToolUse",
-			"permissionDecision":       "deny",
-			"permissionDecisionReason": reason,
-		},
-	})
+	_ = json.NewEncoder(out).Encode(answer)
 }
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 112d82b..5586984 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -88,7 +88,7 @@ func Run(args []string, input io.Reader, output io.Writer) error {
 		request := map[string]any{
 			"agent":      *agentID,
 			"tool":       tool,
-			"sessionKey": stringValue(event["session_id"]),
+			"sessionKey": firstString(event, "session_id", "conversation_id"),
 			"toolUseId":  stringValue(event["tool_use_id"]),
 		}
 		if allow, reason := allowed(*decisionURL, *ingestToken, request); !allow {
diff --git a/agentpatch/cursor.go b/agentpatch/cursor.go
index 991d41c..c77fc00 100644
--- a/agentpatch/cursor.go
+++ b/agentpatch/cursor.go
@@ -48,6 +48,10 @@ func (p cursorPatcher) AgentId() string { return p.id }
 
 func (cursorPatcher) Supported() bool { return true }
 
+// Decides: Cursor waits on preToolUse, beforeShellExecution and
+// beforeMCPExecution, and a hook that answers "deny" stops the call.
+func (cursorPatcher) Decides() bool { return true }
+
 func (p cursorPatcher) Patch(target Target) error {
 	stateMutex.Lock()
 	defer stateMutex.Unlock()
@@ -132,10 +136,10 @@ func (p cursorPatcher) Status(target Target) (Status, error) {
 
 func (cursorPatcher) PatchNotice(patched bool) (string, string) {
 	if patched {
-		return "Removes Gateway's audit-only Cursor hooks. The editor and the CLI share them, so both stop reporting.",
+		return "Removes Gateway's Cursor hooks. The editor and the CLI share them, so both stop reporting and stop being checked.",
 			"Restart any Cursor session that is already running."
 	}
-	return "Installs audit-only Cursor hooks. They observe events and never block an action; the CLI reports fewer of them than the editor.",
+	return "Installs Gateway's Cursor hooks. They observe events, and refuse a shell command, an MCP call or a tool call this agent's permissions do not allow; the CLI reports fewer events than the editor, and an agent nobody has restricted is never held up.",
 		"Restart any Cursor session that is already running."
 }
 
diff --git a/agentpatch/hook_groups.go b/agentpatch/hook_groups.go
index ba1de52..c171959 100644
--- a/agentpatch/hook_groups.go
+++ b/agentpatch/hook_groups.go
@@ -135,11 +135,16 @@ func hookArgs(agentId string, target Target) ([]string, error) {
 	if err != nil {
 		return nil, err
 	}
+	decision, err := decisionURL()
+	if err != nil {
+		return nil, err
+	}
 	return []string{
 		agenthook.Subcommand,
 		agenthook.OwnershipFlag,
 		"--agent", agentId,
 		"--records-url", url,
+		"--decision-url", decision,
 		"--agent-path", target.Path,
 		"--user", target.Owner,
 		"--ingest-token", token,
@@ -191,7 +196,15 @@ func commandIsCurrent(command string) bool {
 	if err != nil {
 		return false
 	}
-	return strings.Contains(command, quoteArg(executable)) && strings.Contains(command, url)
+	// A hook written before Gateway could decide a tool call carries no
+	// decision endpoint, and calling it current would leave it observing for
+	// good.
+	decision, err := decisionURL()
+	if err != nil {
+		return false
+	}
+	return strings.Contains(command, quoteArg(executable)) &&
+		strings.Contains(command, url) && strings.Contains(command, decision)
 }
 
 // argsAreCurrent is commandIsCurrent for an agent whose configuration keeps the
diff --git a/agentpatch/settings_hooks.go b/agentpatch/settings_hooks.go
index 77969b7..85e54b4 100644
--- a/agentpatch/settings_hooks.go
+++ b/agentpatch/settings_hooks.go
@@ -28,7 +28,7 @@ import (
 // hold up the session.
 const hookTimeoutMs = 5000
 
-// settingsHookPatcher installs Gateway's audit-only command hooks into one
+// settingsHookPatcher installs Gateway's command hooks into one
 // agent's settings.json. The Gemini CLI and the forks that took its
 // configuration all spell hooks the same way — events keyed by name, each an
 // array of groups holding command handlers — and differ only in where the file
@@ -46,6 +46,11 @@ func (p settingsHookPatcher) AgentId() string { return p.agentId }
 
 func (settingsHookPatcher) Supported() bool { return true }
 
+// Decides: both CLIs written with this patcher wait on their pre-tool event -
+// the Gemini CLI's BeforeTool and Qwen Code's PreToolUse - and honour a refusal
+// written on the hook's stdout.
+func (settingsHookPatcher) Decides() bool { return true }
+
 func (p settingsHookPatcher) Patch(target Target) error {
 	stateMutex.Lock()
 	defer stateMutex.Unlock()
@@ -126,9 +131,9 @@ func (p settingsHookPatcher) Status(target Target) (Status, error) {
 func (p settingsHookPatcher) PatchNotice(patched bool) (string, string) {
 	restart := "Restart any " + p.name + " session that is already running."
 	if patched {
-		return "Removes Gateway's audit-only " + p.name + " hooks.", restart
+		return "Removes Gateway's " + p.name + " hooks, and with them the check before each tool call.", restart
 	}
-	return "Installs audit-only " + p.name + " hooks. Hooks observe events and never block an action.", restart
+	return "Installs Gateway's " + p.name + " hooks. They observe events, and refuse a tool call this agent's permissions do not allow; an agent nobody has restricted is never held up.", restart
 }
 
 func (p settingsHookPatcher) settingsPath(target Target) (string, error) {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org