Hubzoid
Guides

Restrict a tool

Put a sensitive tool behind a permission, keep its credentials in restricted/.env, grant it to people and workflow accounts in the Console or CLI and confirm every call in Activity.

This guide gates a ledger lookup in a finance agent so that only the people you choose, including the accounts your workflows run as, can call it. By the end, a signed-in person with the grant can use the tool, everyone else cannot, and each call appears in the Console with who made it and why it was allowed or refused.

When to use this

Use a restricted tool when a tool reads or changes something that not every user of the agent should reach, such as payroll data, refunds or a production system. Tools that anyone who can open the agent may use belong in tools_local/ instead. The model behind the rules is explained in Identity and access.

Before you start

  • The hub uses managed access. A hub created with hubzoid init becomes managed when the configured owner first signs in. Otherwise, on a fresh install, run hubzoid access bootstrap --admin you@example.org --authoritative ./finance once. A hub that still uses Open WebUI groups is migrated first, as described in Access administration.
  • Sign-in is on in the chat app, so every person arrives with a verified email. See Authentication.
  • You can open the Console as an organization administrator, or hold manage_access in the agent. An agent administrator grants only capabilities they hold there themselves, so they also need ledger.

What you will build

AGENTS.md
ledger.py
payroll.py
.env
permissions.yaml
main.py
  1. restricted/ledger.py
  2. Permission ledger
  3. Grant in Console
  4. Checked at call
  5. Row in Activity
The file name becomes the permission. A grant opens it. Every call is checked and recorded.

Gate the tool

Write the tool in restricted/

A restricted tool is an ordinary @function_tool, the same as a tool in tools_local/. The difference is the folder. The file stem, in lowercase, is the permission, so every tool in restricted/ledger.py requires ledger.

finance/restricted/ledger.py
"""Ledger lookups. Every tool in this file requires the `ledger` permission."""
from __future__ import annotations

import os

import httpx
from agents import function_tool


@function_tool
def ledger_read(account: str, period: str) -> str:
    """Return ledger entries for one account and accounting period.

    Args:
        account: Ledger account code, for example "4000".
        period: Accounting period as YYYY-MM, for example "2026-08".

    Returns:
        The entries as JSON text.
    """
    # Read credentials inside the call. They are loaded from restricted/.env.
    base_url = os.environ["LEDGER_API_URL"]
    token = os.environ["LEDGER_API_TOKEN"]
    response = httpx.get(
        f"{base_url}/entries",
        params={"account": account, "period": period},
        headers={"Authorization": f"Bearer {token}"},
        timeout=20,
    )
    response.raise_for_status()
    return response.text

How files map to permissions:

  • One permission per file. Tools that need different permissions go in different files, for example restricted/payroll.py for a payroll permission.
  • Files whose names start with _ are skipped. Files that are not Python, such as restricted/.env, are ignored.
  • A restricted tool replaces a built-in or tools_local/ tool with the same name. If two restricted files define the same tool name, the first file in alphabetical order wins and the duplicate is logged.
  • A hub without a restricted/ folder has no restricted tools, and nothing about it changes.

Keep credentials in restricted/.env

Put the secrets the tool needs next to it:

finance/restricted/.env
LEDGER_API_URL=https://ledger.internal.example.org
LEDGER_API_TOKEN=paste-the-token-here

At startup Hubzoid loads the hub's .env, then restricted/.env, into the bridge process environment. A value in restricted/.env wins when both files set it. To keep these values in AWS Secrets Manager instead, name a secret with HUBZOID_RESTRICTED_SECRET_NAME in restricted/.env, and its keys win over the file. The tool reads the values from the environment when it runs, and the model only receives what the tool returns.

The agent's own file tools stay out of the folder. read_file refuses any path that resolves inside restricted/, whatever its capitalization. list_files leaves those files out of its listing, and grep_data refuses a search path inside the folder. On claude-local and codex-local models, the local CLI's own file and shell tools are switched off, so the agent works only through Hubzoid's tools. A .env line in .gitignore also matches restricted/.env, which keeps the file out of Git.

Secrets live in the process environment

Any tool code running in the bridge can read the environment. Agent child processes, such as the claude CLI and its MCP servers, do not inherit the values from restricted/.env, but hub tools do. Never return a credential in a tool result, and review hub tools that read os.environ. For a credential that must never share a process with the agent, run the integration as a separate service under a different operating system user and let the restricted tool call it.

Describe the capability

Without metadata, the Console shows the permission name in title case, such as "Ledger". Add identity/permissions.yaml to give each capability a label, a description and, where it matters, a sensitive flag:

finance/identity/permissions.yaml
ledger:
  label: Read ledger
  description: View accounting entries and balances.
payroll:
  label: Run payroll
  description: Process employee payroll and view salaries.
  sensitive: true

Prop

Type

Sensitivity is only what you declare here. Hubzoid never infers it from a name. The file labels restricted capabilities only: an entry for a built-in such as use_hub, manage_access or curator is ignored with a warning. Metadata for a name with no matching restricted/ file has no effect.

Restart and check

The bridge loads restricted tools when it starts, so restart the hub after adding or renaming a file. Then confirm the tools load:

hubzoid doctor ./finance
hubzoid run ./finance

hubzoid doctor reports the number of restricted tools and the permissions they use, or a failure with the import error when a file does not load. The Console reads the capability list from the files on every request, so the new capability appears there right away.

Grant access in the Console

  1. Open the Console at /portal/ (locally http://localhost:3080/portal/) and choose Agents, then the agent, then the Access tab.
  2. For someone already listed, select Edit access on their row. For an existing user without access to this agent, open them under People and select Add an agent. For someone new, select Add user and enter their name, email and a password you type or generate. Where Google sign-in is set up, Google sign-in only creates the account with no password. Nothing is emailed.
  3. Open Restricted tools and tick Read ledger. Use this agent is already ticked and stays ticked while any other capability is selected, because every capability includes entry to the agent.
  4. Select Review changes. The review lists who, the agent, and the capabilities being added and removed, and marks sensitive ones.
  5. Select Save change (with a count when there is more than one), or Create account for a new user. The whole change set is saved in one transaction, and the Console confirms with "Access updated", or "User added" for a new user, whose password is shown once to copy and share with them yourself.

An agent's Access tab listing each person with their capabilities and account status, with Add user above the list

The review step listing the person, the agent and the capabilities being added before saving

The permission check changes on the next call. The agent list in the chat app follows within about 30 seconds. Every screen in this flow is described in Agents and access.

Grant access from the CLI

The same store is available on the server. Commands take the hub folder as their last argument and use its name as the agent, or a different agent with --hub.

# Grant. use_hub is added automatically.
hubzoid grant priya@example.org ledger ./finance

# Show every permission a subject effectively holds in the agent
hubzoid access check priya@example.org ./finance

# List every grant in one agent
hubzoid access list ./finance --hub finance

# Remove one permission, or remove use_hub to take away everything in the agent
hubzoid revoke priya@example.org ledger ./finance
hubzoid revoke priya@example.org use_hub ./finance

hubzoid access check prints a line such as priya@example.org in finance: ledger, use_hub. CLI changes are recorded with the operating system user as the actor and appear in Activity as a server operator. The CLI writes the permission name exactly as you type it, while the Console offers only capabilities that exist in the agent, so check the spelling against the file name.

Unlike the Console, hubzoid grant also accepts an email that has no account yet. The access applies when someone first signs in with that email, for example through single sign-on.

Grant a workflow

Workflows and markdown tasks run as an ordinary account, and that account's grants decide what they may call. The account is the first of:

  1. run_as on the @workflow declaration, or in the markdown task's frontmatter
  2. HUBZOID_WORKFLOW_USER, from the hub's settings, then the deployment's
  3. on a hub managed in the Console, the owner recorded at setup

Grant that account ledger like any person, with Edit access in the Console or hubzoid grant. It must have signed in once and hold Use this agent. For shared automation, create an ordinary account for it, such as reports@example.org. hubzoid schedule list ./finance and the Runs as column in the Console show who each workflow runs as.

When the workflow calls the agent with hub.call_agent, the agent acts as the run's account on the workflow surface, so the same guard decides and logs the call. A workflow can also check the grant before doing work:

finance/workflows/monthly_close/main.py
from hubzoid import hub, workflow


@workflow(schedule="0 6 1 * *", timezone="Asia/Kolkata", run_as="reports@example.org")
def monthly_close():
    if not hub.user.can("ledger"):
        raise RuntimeError(f"{hub.user.email} needs the ledger permission")
    return hub.call_agent("Summarize last month's ledger entries for account 4000.")

Grants to the older workflow:monthly_close or workflow:md:<task> service identities are kept, but runs that act as an account do not use them. The Console shows them as Legacy service identity and cannot add new ones. Grant the permission to the account instead, then revoke the old grant.

If you set HUBZOID_RESTRICTED_SURFACES, keep workflow in the list, or these calls are refused with surface:workflow.

Test allowed and denied calls

Test with real identities. hubzoid test runs with no signed-in person, so it cannot reach a restricted tool. It is useful to confirm the rest of the agent works.

  1. Sign in to the chat app as the person you granted and ask: "Show the ledger entries for account 4000 in 2026-08." The agent calls ledger_read, and a row with the decision allow and the reason grant is written.
  2. Sign in as someone who holds only Use this agent. The tool is hidden from them on every runtime, so the agent answers without it and no row is written. A call that reaches the tool another way is refused with an access denied message, and a no-grant row is written.
  3. Sign in as someone with no grant in the agent. Their chat requests to it are refused. In a gateway deployment the agent also leaves their model list after the next visibility sync.

To test the workflow, start it by hand with hubzoid schedule run ./finance monthly_close. Its tool calls are recorded with the account it runs as, reports@example.org, and the surface workflow.

Read the decision in Activity

Open the agent and choose the Activity tab, then Tool decisions. Each row reads as a sentence, such as "Priya used ledger_read in Finance Assistant via owui", with a green Allowed or red Denied tag. Filter by outcome, person, tool or channel, and select Details for the exact time, tool, channel and reason. The cross-agent view is the Activity page in the sidebar.

The Activity page showing access changes and tool decisions with allowed and denied outcomes

The same rows are available on the server:

hubzoid audit ./finance --user priya@example.org
hubzoid audit ./finance --denied --limit 100

Each line shows the time, the caller, ALLOW or DENY, the tool and the surface with the reason.

Troubleshooting

What you seeCauseWhat to do
Denied with no-grantThe subject lacks the permission, or a CLI grant used a different spellingCompare hubzoid access check with the file name in restricted/
Denied with surface:slack-dm, surface:whatsapp or surface:telegramThat surface is not in the restricted surfaces listAdd it to HUBZOID_RESTRICTED_SURFACES if every request there carries one verified person, then restart
Denied with surface:workflowHUBZOID_RESTRICTED_SURFACES was set without workflowAdd workflow back and restart
Denied with no-groupThe hub still uses legacy group accessMigrate the hub, see Access administration
Denied with blockedThe account is blocked, or its chat account is unavailableCheck the person under People
Denied with store-errorThe operational database could not be readCheck the database and the bridge logs
The capability is missing from the ConsoleThe file starts with _, is not in restricted/, or failed to importRun hubzoid doctor
A workflow's call is denied with no-grantThe account it runs as lacks the permission, or only an old workflow:* subject holds itCheck Runs as in the Console and grant that account
Add user and Edit access are disabledThe agent is tagged Legacy access and is read only in the ConsoleMigrate the hub first

Next steps

Read this page as Markdown

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.