Access
Grants decide who may act at all, and access decides what runs without asking, through modes, rules, approvals, the approval card and an audit log.
Access is the user's or operator's setting for what a runtime runs without asking, and capability grants decide before that who may act at all. Packs set neither (their Policy only says what a command is), so they cannot grant themselves anything. Pick a mode and a few rules for daily work, and give a multi-tenant product real grants.
Modes#
| Mode | Reads | Writes | Writes with confirm: true | Writes with confirm: "always" |
|---|---|---|---|---|
manual | ask | ask | ask | ask |
auto (default) | run | run | ask | ask |
run | run | run | run | ask |
Use manual for a new pack or sensitive work, auto for daily work and run
for unattended jobs such as CI.
RFC 0006
proposes access, and the reference runtime and hcpx implement it.
hcpx mode # show the current mode and where it comes from
hcpx mode manual # for everything
hcpx mode run --pack transit # for one pack
HCP_MODE=run hcpx exec … # for one process (CI)Confirm "always"#
A command marked "confirm": "always" in policy.json asks in every mode,
including run. Use it for anything that moves money, deletes data for good or
reaches outside the company; hcpx check suggests it (rule P007) for commands
that pay or transfer. Only an allow rule a person wrote naming the command
exactly lets it run without a question, and a wildcard allow rule never does.
Rules#
Rules refine the mode for one command, a whole pack or everything:
hcpx allow ledger.transfer --when payee=demo-transit --max amount=5 --note "fares"
hcpx ask gh.* # always ask for anything in the gh pack
hcpx deny notes.delete # never, whatever the mode
hcpx deny '*' --op write # read-only everywhere
hcpx rules # list, with index and scope
hcpx rules rm 0--when key=value requires an argument to equal a value and --max key=n caps
a numeric one, and a rule whose conditions fail does not apply. A matching deny
rule refuses the command (denied_by_rule). Otherwise the most specific
matching ask or allow rule decides: an exact command beats pack.*, which beats
*, a rule with conditions beats one without, and ask wins a tie. With no
matching rule, the mode decides.
Approvals#
When a command asks, nothing runs. The result carries code: "confirm_required",
a preview of the exact command and an approval_id:
{
"ok": false,
"code": "confirm_required",
"preview": { "command": "transfer", "args": { "amount": "2.80", "payee": "demo-transit" },
"cli": "ledger transfer 2.80 --to demo-transit --confirm" },
"approval_id": "5ea7eb29",
"access": { "mode": "auto", "reason": "Policy confirm \"always\": every mode asks", "approvals": "agent" }
}approvals | An ask is satisfied by |
|---|---|
agent (default) | The agent passing confirm=true, after the user agreed in the conversation, or a person running hcpx approve <id> |
human | Only a person running hcpx approve <id>. confirm=true from an agent does not count |
hcpx approvals # requests waiting for a person
hcpx approve 5ea7eb29 # yes, once
hcpx reject 5ea7eb29
HCP_APPROVALS=human … # or "approvals": "human" in access.jsonAn approval covers one run of exactly that command with exactly those arguments for 15 minutes, so the agent reruns it unchanged. Changing any argument, even the amount by a cent, needs a new approval.
Settings and who may change them#
| Layer | File | May |
|---|---|---|
| User | $HCP_HOME/access.json (~/.hcp/access.json) | Loosen or tighten |
| Environment | HCP_MODE, HCP_APPROVALS | Loosen or tighten, for one process |
| Project | .hcp/access.json in the repository | Only tighten: a stricter mode, ask and deny rules, human approvals |
The project file only tightens because anyone who can open a pull request can
write it. hcpx access prints the effective settings, where each comes from,
and anything ignored because it would loosen access.
{
"mode": "auto",
"packs": { "transit": { "mode": "run" } },
"approvals": "human",
"rules": [
{ "match": "ledger.transfer", "decision": "allow",
"when": { "args": { "payee": "demo-transit" }, "max": { "amount": 5 } }, "note": "fares" },
{ "match": "notes.delete", "decision": "deny" }
]
}Keeping agents out of access#
The access commands (hcpx approve, reject, mode, allow, ask, deny,
rules rm) refuse with human_only inside an agent, meaning through hcpx mcp
or with HCP_AGENT=1 set. hcpx adopt claude sets that variable for Claude
Code's shell and adds deny rules in .claude/settings.json for those commands
and for edits to the HCP store, and hcpx adopt codex prints it for Codex's
shell_environment_policy.
This stops mistakes and prompt injection that go through HCP. On a single-user machine the agent runs as you, so one with unrestricted access to your files can still get past it; for a hard boundary, run the runtime under its own account or as a service, or use a host sandbox that cannot write to the HCP store.
What the card shows#
A host shows a person a small typed view of what will happen, with the exact
command under it. The runtime builds the view from the pack and the call's real
arguments, never from text the model wrote, so an agent cannot show one thing
and run another. res.approval.view is one of:
kind | Fields | Drawn as |
|---|---|---|
message | from, to[], cc[], channel, subject, body | A letter |
payment | amount, currency, to, from, memo | The figure first |
event | action (create, update, cancel), title, when, until, where, attendees[] | An event slip; a cancel is struck through |
removal | what, id, count, irreversible | What goes, and whether it can come back |
publish | target, what, version | Where it goes |
record | action, entity, id, other fields | The record |
fields | any | Ruled rows |
Every view also has a title in words ("Pay 2.80 EUR to demo-transit"), rest
for the arguments it did not use, shown as rows so nothing stays hidden, and
signals, the facts in the values a person should weigh, such as "Cannot be
undone", "Goes to 7 people", "Tells 3 people it is cancelled" or "Goes live".
The request's reason_code (confirm_always, confirm, mode_manual,
rule_ask) lets a UI word the reason without parsing sentences. hcpx preview
shows the card before anything runs:
hcpx preview ledger transfer -- amount=2.80 payee=demo-transitLedger · Pay a saved payee. Refused until a person confirms the amount and payee.
Pay demo-transit
· Moves money
amount 2.80
currency EUR
to demo-transit
from Demo account DEMO-0001
$ ledger transfer 2.80 --to demo-transit --confirmA pack can declare the view for a command in surface.json:
"transfer": {
"description": "Pay a saved payee. Refused until a person confirms the amount and payee.",
"cli": ["ledger", "transfer", "{{amount}}", "--to", "{{payee}}", "--memo", "{{memo}}", "--confirm"],
"preview": {
"kind": "payment",
"title": "Pay {{payee}}",
"fields": { "amount": "{{amount}}", "currency": "EUR", "to": "{{payee}}", "from": "Demo account DEMO-0001", "memo": "{{memo}}" }
}
}Without a preview, the runtime infers the kind from the command and argument
names: send with to and subject is a message, transfer with amount a
payment, calendar cancel an event, remove a removal. The inference and the
signals are data (DEFAULT_VIEW_RULES, DEFAULT_SIGNAL_RULES), and a host adds
its own ahead of them with Hcp.create({ views: { rules, signals } }). A
declared preview always wins, so declare one for the commands that matter.
Command lines#
Hosts that run commands as text, such as an agent's shell line, parse it with
argsFromArgv(pack, command, argv), where argv is what follows the command's
own words. The declared arguments decide the parse: --node-id=n-7 and
--node-id n-7 both fill node_id, {{placeholders}} in cli[] take their
positions, an argument with "x-position": 0 takes the first bare word, and
undeclared --switches stay switches.
Declaring args for every command that asks keeps the card exact. hcpx check
warns (M024) when a preview names an unknown kind or a placeholder that is not an
argument, and suggests the exact preview JSON (M025) for a confirm command
without one, which hcpx wrap writes into the draft for every command it marks
confirm. Because the card is built from their arguments, the HCP Skill tells
agents to pass the whole action in named arguments, one risky command per call.
Answers a host can offer#
Every approval request carries options, the answers a host may put next to the
card. The runtime computes them, and the host sends back only the kind a person
picked, never a rule of its own.
RFC 0007
proposes them.
kind | Means | Offered |
|---|---|---|
allow_once | Run this call, exactly as shown, once | Always |
reject_once | Do not run it | Always |
allow_session | Run it now, and let the same command run without asking for the rest of the session | Only where a lasting yes cannot widen what the person saw |
allow_session carries the rule it stands for, an allow rule with
note: "session":
| The command asks because | The rule |
|---|---|
The mode or an ask rule (no confirm in its Policy) | allow on <pack>.<command> |
Its Policy says confirm, and the card is a message | allow on <pack>.<command> only while the arguments the card took to, cc, and channel from keep the same values; about names them in words |
No confirm: "always" command gets allow_session, and neither does a removal,
payment, publish or event card, because each of those calls is its own decision.
The view's sources name the arguments behind each field, which lets a message
rule bind the audience the person saw. The SDK computes the options and the rule
but stores no session rules: a host that offers allow_session keeps the rule
in its access settings until the session ends, never lets the agent set one, and
still lets a deny rule win over it.
In your own runtime#
Any runtime gets access, with or without the CLI, from settings passed to
Hcp.create or by wrapping its driver:
import { Hcp, withAccess, gateCommand } from '@harnesscontextprotocol/runtime'
// A proprietary driver gets modes, rules, approvals, and audit in one line.
const hcp = await Hcp.create({
runtime: myDriver,
access: { mode: 'auto', approvals: 'human' },
audit: (event) => log.write(event),
})
const res = await hcp.exec('ledger', 'transfer', { amount: '2.80', payee: 'demo-transit' })
if (res.code === 'confirm_required') {
showApprovalCard(res.approval) // pack, summary, arguments with descriptions, CLI line, why
}
// After the person presses Approve in your UI:
await hcp.exec('ledger', 'transfer', { amount: '2.80', payee: 'demo-transit' }, {
approval: { by: 'human', id: uiApprovalId },
})res.approval carries everything a host needs to ask a person: pack_name,
command, summary, operation, confirm, args (each with its
description from the pack), cli, reason, reason_code, mode,
approvals, the view, the options and, when the runtime keeps approvals,
approval_id and expires_at. A host that runs commands its own way, for
example from a command line an agent wrote, calls
gateCommand({ pack, verb_key, args, access }) and gets allow, ask (with the
same request) or deny. approval: { by: 'human' } is the host's statement that
its own UI collected a person's yes for exactly this call, so agents never set
it.
hcpx commands shows each command's access (allow, ask, deny) under the
current settings, so an agent plans around an ask before it hits one. The Skill
tells agents to follow the hint of a confirm_required result, never to run
the access commands, and to stop on denied_by_rule.
Authorization#
Grants decide whether a Principal may cause an effect at all. The runtime grants
through its own RBAC, and packs never embed user allow-lists, role names or API
keys, so the same pack runs in a local demo and in a multi-tenant product. A
runtime grants at least these five capabilities, per pack_id or per
verb_key, and a multi-tenant runtime must not collapse read, write and connect
into one boolean:
| Capability | Gates | Typical grant scope |
|---|---|---|
pack.install | Enabling a pack for a tenant | Admin |
pack.connect | Establishing or refreshing Binding (apart from write) | The account owner |
verb.exec.read | Commands with operation: read | Broad |
verb.exec.write | Commands with operation: write | Narrow; often per verb_key |
sense.enable | Delivering declared triggers into a sink | Per pack |
The command's land decides which scope the runtime checks for the Principal:
| Land | Runtime must check |
|---|---|
personal | The Principal's own scope |
brain | Project membership; tenant membership alone is not enough. project_id must be set |
local | The sandbox working directory. Not a tenant |
The reference SDK's grant shape is what its default authoriser understands, and
HCP does not require it as a wire format. If you omit grants, Hcp.create
falls back to permissiveGrants(), which is fine for a local demo and wrong for
anything multi-tenant.
type HcpGrant = {
capability: 'pack.install' | 'pack.connect' | 'verb.exec.read' | 'verb.exec.write' | 'sense.enable'
pack_id?: string // omit → any pack
verb_keys?: string[] // omit → any command in the pack
}[
{ "capability": "verb.exec.read", "pack_id": "mail" },
{ "capability": "verb.exec.write", "pack_id": "mail", "verb_keys": ["mail.send"] },
{ "capability": "pack.connect", "pack_id": "mail" }
]The authoriser runs inside the exec pipeline,
after the Principal's scope is resolved and before the confirm step where modes,
rules and approvals apply. Pass authorize to the runtime driver to replace the
default:
import { defaultAuthorize } from '@harnesscontextprotocol/sdk'
defaultAuthorize(
{ principal: { id: 'u1', kind: 'human' }, tenant_id: 't1', project_id: null, grants },
{ pack_id: 'mail', verb_key: 'mail.send', operation: 'write', land: 'personal', kind: 'exec' },
)
// { allow: true } | { allow: false, reason: 'missing verb.exec.write for mail.mail.send' }Errors lists every denial reason. Role table schemas, row-level security policies, collection names, IdP vendor APIs and host paths belong to adopter profiles, since the protocol fixes only the five axes and the evaluation order.
Audit#
Every write is attributed with this tuple, and reads should be too:
Principal + Tenant (+ Project if land = brain) + Binding identity + pack_id + verb_key + operationThe reference runtime writes each decision as one line in
$HCP_HOME/audit.jsonl: time, Principal, project, pack, command, read or write,
the decision and its reason, the mode, who approved (agent, human with approval
id, rule, or mode), whether it ran, and a SHA-256 of the arguments. The line
keeps argument names and never their values.
hcpx audit --limit 20
hcpx audit --pack ledger --json