Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified api/vendor/agentfox-0.3.1-py3-none-any.whl
Binary file not shown.
2 changes: 1 addition & 1 deletion dashboard/app/(product)/app/approvals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async function Pending({ approvals }: { approvals: any[] }) {
})}
</div>
<div className="k-muted" style={{ fontSize: "var(--t-micro)" }}>
{String(a.reason || "").split(/(?<=\.)\s/)[0]} · expires <Countdown at={a.expires_at} />
{String(a.reason || "").split(/(?<=\.)\s/)[0]} · expires <Countdown at={a.expires_at} /> · <span className="k-mono" title="The id the agent quoted to the user">{a.id}</span>
{a.trace_id && (
<>
{" · "}
Expand Down
22 changes: 17 additions & 5 deletions dashboard/app/(product)/app/policies/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ export default async function NewRule({ searchParams }: { searchParams: Promise<
<Header back={{ href: "/app/policies", label: "Policies" }} title={sp.agent ? `Add rule for ${sp.agent}` : "Add rule"} />
<Tabs items={tabs.map((t) => ({ ...t, href: href("/app/policies/new", { ...keep, from: t.key }) }))} active={tab} />
{tab === "run" && sp.run && <FromRun id={sp.run} />}
{tab === "describe" && (
<Card>
<DescribeRule agent={sp.agent} />
</Card>
)}
{tab === "describe" && <Describe agent={sp.agent} />}
{tab === "custom" && (
<Card>
<CustomRule agent={sp.agent} />
Expand Down Expand Up @@ -151,3 +147,19 @@ async function ToolAccess({ agent }: { agent?: string }) {
</Card>
);
}

/** Approval limits, offering the agent's own tools (or every declared tool) as answers. */
async function Describe({ agent }: { agent?: string }) {
const [access, all] = await Promise.all([
agent ? safeApi<any>(`/api/agents/${encodeURIComponent(agent)}/access`, null) : Promise.resolve(null),
safeApi<any>("/api/tools", { tools: [] }),
]);
const tools: string[] = access
? Array.from(new Set([...(access.capabilities || []).map((c: any) => c.tool_key), ...(access.tried || []).map((t: any) => t.tool_key)])).filter((k) => !k.includes("*"))
: (all.tools || []).map((t: any) => t.key).filter((k: string) => !k.startsWith("redteam."));
return (
<Card>
<DescribeRule agent={agent} tools={tools.slice(0, 24)} />
</Card>
);
}
3 changes: 2 additions & 1 deletion dashboard/app/api/agents/[slug]/access/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ export const dynamic = "force-dynamic";
/** Grant a tool to an agent, or change its existing grant. */
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { tool_key, requires_approval, max_taint, constraints, actions } = await req.json();
const { tool_key, requires_approval, max_taint, constraints, actions, impact } = await req.json();
return proxyJson(`/api/agents/${encodeURIComponent(slug)}/access`, "POST", {
tool_key,
requires_approval: Boolean(requires_approval),
max_taint: max_taint || "user",
constraints: constraints || {},
...(actions ? { actions } : {}),
...(impact ? { impact } : {}),
});
}
1 change: 1 addition & 0 deletions dashboard/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3200,6 +3200,7 @@ textarea.k-input { height: auto; padding: 8px 11px; line-height: 1.5; resize: ve
.k-steps button.active .k-step-n { background: var(--text); color: var(--panel); }
.k-steps button.done .k-step-n { background: var(--viz-allowed); color: var(--panel); }
.k-form > .k-seg { align-self: flex-start; }
.k-select-sm { padding: 2px 22px 2px 8px; font-size: var(--t-micro); border-radius: var(--r-pill); }
.k-code { margin: 0; max-height: 520px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-2); background: var(--panel-2); font-size: var(--t-small); line-height: 1.5; }
.k-details { margin-top: 14px; }
.k-details > summary { cursor: pointer; color: var(--muted); font-size: var(--t-small); margin-bottom: 10px; }
Expand Down
29 changes: 23 additions & 6 deletions dashboard/components/product/agent/AccessEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type Cap = {
requires_approval: boolean;
max_taint: string;
constraints: Record<string, any>;
tool: { name: string; impact: string } | null;
tool: { name: string; impact: string; impact_source?: string } | null;
usage: { calls: number; blocked: number; held: number; last_used: string | null };
};

Expand Down Expand Up @@ -173,9 +173,25 @@ export function AccessEditor({
<span className="sub k-mono">{c.tool_key}</span>
</td>
<td className="tight">
<span className={`k-pill k-pill-${impactTone(c.tool?.impact)}`}>
{IMPACT[c.tool?.impact || ""] || (c.tool_key.includes("*") ? "Mixed" : "Not declared")}
</span>
{c.tool_key.includes("*") ? (
<span className="k-pill k-pill-neutral">Mixed</span>
) : (
<select
className={`k-select k-select-sm k-pill-${impactTone(c.tool?.impact)}`}
aria-label={`Risk of ${c.tool_key}`}
title={c.tool?.impact_source === "inferred" ? "Guessed from the name. Confirm or change it." : undefined}
value={c.tool?.impact || ""}
onChange={(e) => save(c.tool_key, { ...payload(c, {}), impact: e.target.value })}
>
{!c.tool && <option value="">Not declared</option>}
{Object.entries(IMPACT).map(([k, label]) => (
<option key={k} value={k}>
{label}
{c.tool?.impact === k && c.tool?.impact_source === "inferred" ? " (guess)" : ""}
</option>
))}
</select>
)}
</td>
<td className="tight">
<div className="k-seg" role="group" aria-label="Permission">
Expand Down Expand Up @@ -233,7 +249,8 @@ export function AccessEditor({
</span>
)}
</td>
<td className="tight muted">
{/* Relative time differs between the server render and the browser by a minute. */}
<td className="tight muted" suppressHydrationWarning>
{unused.includes(c.id) ? <span className="k-pill k-pill-outline">Unused</span> : ago(c.usage.last_used)}
</td>
<td className="tight" style={{ paddingRight: 16 }}>
Expand All @@ -260,7 +277,7 @@ export function AccessEditor({
<li key={t.tool_key}>
<div className="k-list-main">
<span className="k-mono">{t.tool_key}</span>
<span className="muted">
<span className="muted" suppressHydrationWarning>
Refused {t.count}× · last {ago(t.last)}
</span>
</div>
Expand Down
4 changes: 3 additions & 1 deletion dashboard/components/product/agent/ProtectWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ export function ProtectWizard({

/** First-run choice: the protection's default, never below what every agent already gets. */
function startLevel(p: Protection): string {
if (!p.graded) return p.default === "off" ? "off" : "on";
// A protection whose model is not installed here would check nothing.
if (p.needs && p.needs_installed === false) return "off";
if (!p.graded) return p.default === "off" && !p.inherited ? "off" : "on";
if (p.inherited && RANK[p.inherited] > RANK[p.default]) return p.inherited;
return p.default;
}
43 changes: 37 additions & 6 deletions dashboard/components/product/policies/DescribeRule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,19 @@ const EXAMPLE =
* executable rule (shown back in plain terms), or to a specific question when the
* wording leaves something open. Added rules start watching.
*/
export function DescribeRule({ agent }: { agent?: string }) {
export function DescribeRule({ agent, tools = [] }: { agent?: string; tools?: string[] }) {
const router = useRouter();
const [text, setText] = useState("");
const [result, setResult] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [added, setAdded] = useState<string[]>([]);

const compile = async () => {
const compile = async (source: string = text) => {
setBusy(true);
setError("");
try {
setResult(await callJson("/api/business/compile", "POST", { text }));
setResult(await callJson("/api/business/compile", "POST", { text: source }));
} catch (e: any) {
setError(e.message);
} finally {
Expand Down Expand Up @@ -84,7 +84,7 @@ export function DescribeRule({ agent }: { agent?: string }) {
aria-label="Describe the rule"
/>
<div className="k-pills" style={{ gap: 8 }}>
<button className="k-btn-primary" disabled={busy || !text.trim()} onClick={compile}>
<button className="k-btn-primary" disabled={busy || !text.trim()} onClick={() => compile()}>
{busy && !result ? "Reading…" : "Create rule"}
</button>
{!text && (
Expand Down Expand Up @@ -134,8 +134,29 @@ export function DescribeRule({ agent }: { agent?: string }) {
<div key={i} className="k-preview" style={{ maxWidth: "none" }}>
<strong>{q.question}</strong>
<span className="k-muted">“{q.source}”</span>
{q.options?.length > 0 && <span className="k-muted">Options: {q.options.join(" · ")}</span>}
<span className="k-muted" style={{ fontSize: "var(--t-micro)" }}>Reword the sentence above to answer it, then create again.</span>
{toolSubject(q.question) && tools.length > 0 ? (
<div className="k-pills" style={{ gap: 6, flexWrap: "wrap" }}>
{tools.map((t) => (
<button
key={t}
className="k-btn k-mono"
disabled={busy}
onClick={() => {
const next = text.replace(new RegExp(escape(toolSubject(q.question)!), "gi"), t);
setText(next);
compile(next);
}}
>
{t}
</button>
))}
</div>
) : (
<>
{q.options?.length > 0 && <span className="k-muted">Options: {q.options.join(" · ")}</span>}
<span className="k-muted" style={{ fontSize: "var(--t-micro)" }}>Reword the sentence above to answer it, then create again.</span>
</>
)}
</div>
))}
{result.ignored?.length > 0 && (
Expand All @@ -151,3 +172,13 @@ export function DescribeRule({ agent }: { agent?: string }) {
</div>
);
}

/** "Which tool handles compensation?" → "compensation": the word to replace with a tool. */
function toolSubject(question: string): string | null {
const m = /^Which tool handles (.+?)\?$/i.exec(question || "");
return m ? m[1] : null;
}

function escape(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
6 changes: 6 additions & 0 deletions dashboard/lib/generated/reference/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,12 @@
"type": "array",
"required": false,
"description": ""
},
{
"name": "impact",
"type": "string",
"required": false,
"description": ""
}
]
}
Expand Down
Binary file not shown.
8 changes: 8 additions & 0 deletions src/agentfox/apps/gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ def _key_rotation_status() -> dict[str, str]:
return {"token_encryption": "unknown", "audit_signing": "unknown"}


#: The inline routes that call a model provider on the caller's behalf.
MODEL_ROUTES = ("/v1/chat/completions", "/v1/messages")


def create_app() -> FastAPI:
# Before anything else, and here rather than in `lifespan`: a serverless host may
# never run the lifespan, and a process that is going to refuse should refuse
Expand Down Expand Up @@ -249,6 +253,10 @@ async def degradation_gate(request: Request, call_next):
# thousand is a blip, and a fraction over failures alone cannot tell them apart.
observe_governed_request()
events = check_services()
if not request.url.path.startswith(MODEL_ROUTES):
# Guard, MCP and trace routes never call the model: a provider outage is
# no reason to stop checking the traffic of teams that call it themselves.
events = [e for e in events if e.control != "model_provider"]
blocking = [e for e in events if e.verdict == "block"]
if blocking:
event = blocking[0]
Expand Down
41 changes: 40 additions & 1 deletion src/agentfox/apps/gateway/routes/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from agentfox.platform.identity import ensure_identity
from agentfox.platform.identity.service import grant_capability, revoke_capability
from agentfox.platform.ledger import chain
from agentfox.platform.registry.impact import infer_impact
from agentfox.platform.registry.service import impact_source_of, upsert_tool

router = APIRouter(prefix="/api/agents", tags=["access"])

Expand All @@ -47,7 +49,12 @@ def _capability_json(c: Capability, tools: dict[str, Tool]) -> dict[str, Any]:
"max_taint": c.max_taint,
"granted_by": c.granted_by,
"expires_at": c.expires_at.isoformat() if c.expires_at else None,
"tool": {"name": tool.name, "impact": tool.impact, "description": tool.description}
"tool": {
"name": tool.name,
"impact": tool.impact,
"impact_source": impact_source_of(tool),
"description": tool.description,
}
if tool
else None,
}
Expand Down Expand Up @@ -136,6 +143,35 @@ class AccessIn(BaseModel):
max_taint: str = "user"
constraints: dict[str, Any] = Field(default_factory=dict)
actions: list[str] = Field(default_factory=lambda: ["*"])
#: The tool's risk, when the operator chose one: read, write, high_impact or
#: irreversible. Absent, an undeclared tool is registered with a cautious guess.
impact: str | None = None


IMPACTS = ("read", "write", "high_impact", "irreversible")


def _declare(session: Session, key: str, impact: str | None, actor: str) -> None:
"""Make sure the registry knows the tool being granted.

A grant for a tool the registry has never seen used to leave every call held by
`tool.not_declared` — the operator clicked Allow and nothing was allowed. So the
tool is registered here: with the impact the operator chose, or a cautious guess
from its name, marked as a guess for them to confirm.
"""
if "*" in key:
return
known = session.scalar(select(Tool).where(Tool.key == key))
if impact is not None:
upsert_tool(session, key, impact=impact, actor=actor, name=known.name if known else key)
elif known is None:
upsert_tool(
session,
key,
impact=infer_impact(key, cautious=True),
impact_source="inferred",
actor=actor,
)


@router.post("/{slug}/access", status_code=201)
Expand All @@ -148,7 +184,10 @@ def set_access(
"""Grant a tool, or change the existing grant for it (one grant per tool key)."""
if payload.max_taint not in TAINT_ORDER:
raise HTTPException(400, f"max_taint must be one of {', '.join(TAINT_ORDER)}")
if payload.impact is not None and payload.impact not in IMPACTS:
raise HTTPException(400, f"impact must be one of {', '.join(IMPACTS)}")
agent = get_agent_or_404(session, slug)
_declare(session, payload.tool_key, payload.impact, user.email or user.id)
identity = ensure_identity(session, agent)
existing = next((c for c in identity.capabilities if c.tool_key == payload.tool_key), None)
if existing:
Expand Down
Loading
Loading