// Concept
Guardrails and approvals
Halbert manages the machine it runs on: storage, services, networking, containers. The distance between a recommendation and an action on that machine is the whole safety posture, and it is held by five separate mechanisms that bind at five different moments — plus a sixth, further down, for work that runs while nobody is watching.
They are worth telling apart, because people generalise from one of them to all of them and are wrong in both directions — trusting a layer that is advisory, or expecting a confirmation that a different path never asks for.
1. Staging: the interface does not run things
Section titled “1. Staging: the interface does not run things”Commands staged from the interface are staged, never executed. A Run control beside a command in the transcript writes that command into the composer and stops, with the message unsent. Pressing Enter is yours, and deleting it is the cheap option.
That is the rule the rest of this page sits on: nothing you see offered has happened. Stage and approve a command is the procedure, including how to ask for a classification before you run anything.
2. Classification: every tool call is rated before it runs
Section titled “2. Classification: every tool call is rated before it runs”When Halbert uses a tool itself, the call is classified first — every call, not only shell commands. The rating decides what happens next. Six calls, put through the classifier on this machine:
| Tool call | Rating | Allowed | Confirm | Reason given |
|---|---|---|---|---|
run_command — df -h |
safe |
yes | no | File/disk info |
run_command — systemctl restart halbert-dashboard |
high |
yes | yes | Service management |
run_command — cat ~/.ssh/id_rsa |
high |
yes | yes | Reads a credential: id_rsa |
run_command — rm -rf / |
critical |
no | no | Blocked command pattern |
write_file — /etc/fstab |
high |
yes | yes | Write to sensitive path: /etc/ |
| an unregistered tool name | medium |
yes | no | Unknown tool |
Three things in that table are the design rather than the details.
An unknown tool is medium, not safe. Anything the framework does not recognise is treated as carrying some risk, so a tool added without a rule does not arrive unrated.
allowed=False is absolute. It is a refusal, not a strong warning: no confirmation path
overrides it. The same is true of a refusal on the grounds of who is speaking — when a turn is
being driven by someone other than the owner, a lowered role can refuse a call that would otherwise
merely need confirming.
A high rating stops the turn. The conversation pauses rather than asking in passing: the turn enters a waiting state, its status becomes blocked, and what you are shown is the command, its rating and the reason. For the service restart in the table above, the message composed for that pause is headed Execute command:, quotes the command, and ends Risk Level: HIGH / Reason: Service management.
Reading it is the point. The classifier matches text against patterns; it catches the obvious and cannot catch the novel. A command it calls safe can still be wrong for your machine, and a genuinely destructive command it does not recognise will be rated by shape rather than by effect. It is a net, not a proof.
3. The sandbox: what a command can reach once it runs
Section titled “3. The sandbox: what a command can reach once it runs”A command that passes the gate is wrapped before a process exists. On macOS that is the system’s
own sandbox, with a profile that allows execution and reads, and denies writes to the system
tree — /etc, /usr, /System, /Library, /bin, /sbin. Measured, inside the wrapper:
touch: /usr/halbert-probe: Operation not permittedtouch: /etc/halbert-probe: Operation not permitted4. Policy: a file you can write rules into
Section titled “4. Policy: a file you can write rules into”There is a policy layer under the tools, consulted when a tool with side effects is asked to apply something rather than simulate it. It ships empty:
$ python Halbert/main.py policy-show{ "default_allow": true, "tools": {}}So out of the box it permits everything and is a place rather than a constraint — rules per tool, optionally by hour, by user, by host. You can ask what it would decide without running anything:
$ python Halbert/main.py policy-eval --tool write_config --inputs inputs.json{ "tool": "write_config", "allow": true, "reason": ""}One behaviour is worth knowing: if evaluating the policy fails, the action is denied and the refusal is written to the audit log. A broken rules file stops work rather than waving it through.
5. Approvals: a queue, and what is not in it
Section titled “5. Approvals: a queue, and what is not in it”The approval queue holds proposals — actions Halbert worked out for itself, usually from a finding — each carrying its reasoning, a confidence, a risk level and the resources it would touch. Approving one records the decision and hands it back to the pipeline that produced it; a decision is final, and a second decision on the same request is refused.
What is not in the queue is most of what people assume. A command you staged is not queued; it is text in a composer. A tool call awaiting your confirmation is not queued either; it is a paused turn, and it lives or dies with that turn. The queue is for work the machine proposed while you were not watching.
$ python Halbert/main.py approval-listNo pending approval requestsScheduled work has its own guardrails
Section titled “Scheduled work has its own guardrails”Jobs that run on a schedule are the case where nobody is present to confirm anything, so they are
governed separately: a safe mode that skips every job while it is armed, per-job budgets for CPU,
memory, time and frequency, and a confidence threshold. python Halbert/main.py autonomy-status
prints all of it; the part that decides whether a job runs is:
Confidence Thresholds: Auto-execute: 80% Require approval: 50%Below 50%, a job is rejected outright and recorded as rejected. Above 80%, it runs.
Afterwards: the audit log
Section titled “Afterwards: the audit log”Tool executions and the refusals above are appended to a tamper-evident log, and it can be checked for edits, truncation and deleted shards:
$ python Halbert/main.py audit-verifyNo tampering detected since this log began. records checked: 1235 signed: 0 unsigned: 1235 All records are unsigned: this check confirms the chain is internally consistent, not who wrote it.The command states its own limit, which is the right habit for this whole page: the log and any key that would sign it live on the same machine, so the check proves nothing was altered underneath the running system — not that the log was never rewritten. An off-machine comparison is what would strengthen it, and none is performed today.
What is not there yet
Section titled “What is not there yet”- The middle confidence band executes. As above: between 50% and 80% the scheduler logs that approval was required and runs the job.
- The execute endpoint’s
forceflag does nothing. The request body forPOST /api/terminal/execcarries one, described as skipping the safety confirmation for pre-approved commands. Nothing reads it. There is no confirm-then-override path in the HTTP API: a blocked command is refused, full stop. - The sandbox is skipped when its binary is absent. macOS ships
sandbox-exec, so a Mac is always wrapped. On Linux the wrapper isbwrap(bubblewrap), a separate package: without it the command runs unwrapped, and the only trace is a warning in the log. - Two of the sandbox’s read denials do not match, as described above.
- Nothing records what you staged. There is no history of commands offered, run, or deleted.
Model locality and the secure tier is the same boundary drawn around text instead of actions. Read a finding is where most queued proposals come from.