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
4 changes: 4 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ Done when:
executable basename, grant scope, and grant time. The actor derives a match
or one near miss from one store snapshot pass. Raw paths, command text,
arguments, secrets, prompts, and session events never receive trace data.
- [x] The approval runbook documents the complete parser-fact-to-policy flow,
bounded status and finite-loop examples, partial candidate coverage, and a
trace-first operator diagnostic procedure. The source-generated fixture
replay pins current D02, D10, and D14 coordinator behavior.
- [ ] The bundled safe catalog removes every executable whose accepted
arguments can write, delete, execute code, or mutate a remote service through
executable argv interpretation. Redirect, parser-owned path/provider, and
Expand Down
243 changes: 217 additions & 26 deletions docs/runbooks/tool-approval-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ commands must run without approval.
### Headless mode

Headless mode (`netclaw chat -p "prompt"`) cannot ask for approval — there is no
interactive user. Approval-gated tools are **automatically denied** in headless
mode. If you need unrestricted shell in headless scripts, explicitly set
`shell_execute` to `Auto`:
interactive user. Netclaw still applies hard deny, path policy, trust zones, and
stored grants. If any candidate remains uncovered and would need a
prompt, the call is denied. The reviewed-safe catalog alone does not grant a
headless call; approval-exempt side effects can still pass. For unrestricted
shell in headless scripts, explicitly set `shell_execute` to `Auto`:

```json
{
Expand All @@ -100,26 +102,44 @@ mode. If you need unrestricted shell in headless scripts, explicitly set

When the agent calls a tool in `Approval` mode:

1. The system extracts a **command pattern** (e.g., `git push` from
`git push origin main`).
1. The system extracts a **command pattern** (for example,
`git push origin main` from that exact call).
2. It checks the **approval cache** — has this pattern been approved before?
3. If not approved, the channel posts an approval prompt:
3. If a clean reusable shell call in an ordinary directory remains uncovered,
the channel posts the default five-choice prompt:
```
🔒 Tool approval required
> shell_execute: git push origin main
Pattern: git push
Pattern: git push origin main

Reply with:
A) Approve once
B) Approve always
C) Deny
A) Once
B) This chat
C) Always here
D) Always anywhere
E) Deny
```
4. The tool execution pauses until the user responds. Other tool calls in the
same batch continue running independently.
5. Based on the response:
- **Approve once** — command executes; approval valid for this session only
- **Approve always** — command executes; pattern persisted to disk
- **Deny** — command returns "Command denied by user" to the LLM
- **Once** — the exact blocked call retries once; no grant is saved
- **This chat** — the covered phrase remains valid anywhere in this session
- **Always here** — a folder-scoped grant is saved to disk
- **Always anywhere** — a global phrase grant is saved to disk
- **Deny** — the call returns "Command denied by user" to the LLM

The policy can remove reusable choices when a command has no clean phrase. It
also removes `Always here` for a shallow root, session scratch, and non-shell
tools that have no directory scope. See [When the prompt offers fewer
buttons](#when-the-prompt-offers-fewer-buttons).

### When the prompt offers fewer buttons

`Once` and `Deny` are the fail-closed choices. Netclaw omits `This chat` and
the persistent choices when the shell parser cannot produce a clean reusable
phrase for every uncovered command occurrence. It also omits `Always here`
when no safe directory scope can be stored. This rule prevents a one-time
decision from becoming broader reusable authority.

### Command patterns

Expand Down Expand Up @@ -172,8 +192,10 @@ path-scoped patterns (for example,

### Read-only verbs skip the prompt

Demonstrably read-only verbs auto-run with no prompt when invoked inside a
trusted zone (`session_dir`, or `project_dir` for Personal/Team). The bundled
In an interactive session, demonstrably read-only verbs auto-run with no prompt
when invoked inside a trusted zone (`session_dir`, or `project_dir` for
Personal/Team). In a headless session, this catalog does not grant authority;
the call still needs an exact one-time, session, or persistent grant. The bundled
safe-verb lists (`safe-verbs.linux.json`, `safe-verbs.windows.json`) cover file
readers (for example `ls`, `grep`, and `cat` on Bash; `Get-ChildItem`,
`Get-Content`, and `Select-String` on PowerShell), system/info verbs (`date`,
Expand All @@ -188,9 +210,130 @@ code review — the agent cannot extend its own auto-pass surface. A compound
command auto-runs only when every clause is a safe verb; a single mutating
clause makes the whole command prompt.

### How parser facts become one policy result

ShellSyntaxTree describes shell syntax. It does not grant authority. Netclaw
projects those facts into one immutable call-local view and then decides which
authority, if any, covers each command occurrence.

| Step | Input | Output | Owner |
|------|-------|--------|-------|
| Preflight and syntax analysis | Original tool call, shell environment, initial cwd, audience, and run scope | ShellSyntaxTree facts followed by hard deny, path deny, approval mode, or auto allow | ShellSyntaxTree and Netclaw |
| Policy projection | Syntax facts plus the unchanged approval context | Stable call-local candidate IDs and immutable scope facts | Netclaw |
| Grant match | Candidates plus one session/persistent store snapshot | One typed match or one bounded near miss per candidate | Approval actor |
| Coverage | Grant matches, reviewed-safe policy, and an exact one-time retry | One coverage result per candidate | Netclaw |
| Completion | All candidate coverage results | `Allowed`, `RequiresApproval`, or `Denied` | Netclaw |

The coordinator does not rewrite the original command. A prompt and an eventual
execution still refer to the exact tool call the model authored. Candidate IDs
exist only for that call; they are not durable grant IDs.

The coordinator returns one closed result shape:

| Outcome | Consumer data | Next action |
|---------|---------------|-------------|
| `Allowed` | One allow reason and any stored matches that helped cover the call | Execute the original tool call |
| `RequiresApproval` | A narrowed approval context plus any partial stored matches | Prompt only for the uncovered candidates |
| `Denied` | One stable deny reason | Return the denial without execution or prompt |

The important value-domain rules are:

- Effective `Exact` and `FiniteSet` path values pass through `ToolPathPolicy`.
- An `Exact` or `FiniteSet` `AuthoredFileSystemValue` also passes through
`ToolPathPolicy`. This is the strong parser fact used for a finite loop path.
- `AuthoredPathShape` is lexical evidence only. A slash-shaped value may be a
repository slug, URL segment, image name, or other data, so shape alone never
creates filesystem authority.
- `IntegerRange` and `Concatenation` can prove bounded scalar data. They cannot
select an executable, justify a redirect, or create path authority.
- `Unknown`, incomplete control flow, a dynamic executable, an unresolved path,
or an unresolved redirect stays strict.

The result can compose. A three-part command can use a stored grant for one
candidate and reviewed-safe policy for the other two. Netclaw prompts only for
the candidates that remain uncovered.

#### Example: bounded status data in a compound command

Input:

```bash
gh run view 123456 --repo example/project --log-failed --verbose 2>&1 \
| head -200; echo "---EXIT $?---"
```

With a trusted `/work` scope and no stored grants, the facts and result are:

| Candidate | Parser fact | Coverage |
|-----------|-------------|----------|
| `gh run view` | Complete Bash occurrence with canonical tokens | Reviewed-safe policy |
| `head` | Complete occurrence under the real trusted root | Reviewed-safe policy |
| `echo` | Argument is `Concatenation(Exact, IntegerRange(0..255), Exact)` and is not a path | Approval-exempt side effect |

Output: `Allowed`. The status expansion is bounded data; it does not make the
command complex and it does not add filesystem authority.

If the call is outside every trusted root, `gh run view` and `head` remain
uncovered. When an eligible Personal or Team call names a directory that the
session can declare, Netclaw can first return a `set_working_directory`
correction to the agent. Otherwise the uncovered candidates require approval.
The bare `echo` side effect does not become a reusable prompt choice.

#### Example: a finite Bash loop over known files

Input:

```bash
for f in src/A.cs src/B.cs; do cat /work/$f; done
```

ShellSyntaxTree reports one complete `cat` occurrence. Its effective value is
unknown because Bash can apply runtime word transforms, but its stronger
authored filesystem value is:

```text
FiniteSet("/work/src/A.cs", "/work/src/B.cs")
```

Netclaw checks both values through `ToolPathPolicy`. If both paths are allowed
and a stored grant or reviewed-safe policy covers `cat`, the output is
`Allowed`. A protected path is denied before grant coverage. A non-protected
external path stays exact, but it is not reviewed-safe merely because it is
finite; it needs a folder or global grant that matches, or it requires approval. A
runtime iterator, active glob, or command substitution does not receive this
finite fact.

#### Example: stored mutation grants compose with reviewed-safe readers

Input:

```bash
cd /work && git fetch upstream feature/update 2>&1 | tail -2 \
&& echo "===REMOTE TIP===" && git rev-parse FETCH_HEAD \
&& git log --oneline -3 FETCH_HEAD \
&& echo "===HAS FIX?===" \
&& git show FETCH_HEAD:src/App/App.csproj | grep -n "ProtocolPackage" \
; echo "exit: $?"
```

With explicit global grants for `cd`, `git fetch`, `git rev-parse`, `git log`,
and `git show`, the actor covers those exact candidates. It returns `NoGrant`
for `tail` and `grep`; reviewed-safe policy then covers both under `/work`.
Each `echo` occurrence is an approval-exempt side effect, including the final
bounded `Concatenation` that contains `$?`. The final output is `Allowed` with
reason `AllCandidatesCovered`. No single grant covers the compound command.

#### Example: a folder grant near miss

Suppose the store has a Bash token-prefix grant for `git status` under
`/work/project-a`, but the call runs under `/work/project-b`. The actor returns
an `OutsideDirectory` near miss. The candidate remains uncovered, so the final
output is `RequiresApproval`. A same-verb grant is diagnostic evidence, not
authority for a peer directory.

### Persistent approvals

"Approve always" decisions are stored in
Persistent decisions are stored in
`~/.netclaw/config/tool-approvals.json`:

```json
Expand Down Expand Up @@ -292,15 +435,19 @@ Custom patterns are added to the defaults — they don't replace them.

| Channel | Supports approval? | Rendering |
|---------|-------------------|-----------|
| Slack | Yes | Text prompt with ABC options (Block Kit buttons planned) |
| Slack | Yes | Block Kit buttons with text-compatible option labels |
| Discord | Yes | Native buttons with text-compatible option labels |
| Mattermost | Yes | Interactive buttons with text-compatible option labels |
| TUI (`netclaw chat`) | Yes | Inline prompt |
| SignalR (web client) | Yes | Inline prompt |
| Headless (`netclaw chat -p`) | No — auto-deny | N/A |
| Reminders | No — auto-deny | N/A |
| Webhooks | No — auto-deny | N/A |
| Headless (`netclaw chat -p`) | No — deny uncovered calls | N/A |
| Reminders | No — deny uncovered calls | N/A |
| Webhooks | No — deny uncovered calls | N/A |

If a channel doesn't support approval, the tool is immediately denied with
reason `channel_does_not_support_approval`.
If a channel doesn't support approval, an uncovered call that would require a
prompt is denied with reason `channel_does_not_support_approval`. A stored
grant or an approval-exempt side effect can still cover the call. The
reviewed-safe catalog alone does not grant unattended authority.

## Diagnostics

Expand All @@ -317,8 +464,9 @@ Tool audit entries include `ApprovalDecision` and `ApprovalPattern` fields when
a tool goes through the approval flow. Later calls that are satisfied by a
session or persistent approval are audited as `PreviouslyApproved`; the pattern
includes the matched source and scope so operators can tell which grant applied.
Near-miss diagnostics are also emitted to the daemon log when a same-verb
persistent shell grant exists but does not match the candidate directory/cwd.
Near-miss diagnostics are also emitted to the daemon log when a persistent
shell grant almost matches but differs by token phrase, shell, absent folder
scope, directory containment, or symlink safety.

```
Tool executed: shell_execute (approved, pattern=git push)
Expand All @@ -327,11 +475,54 @@ Tool denied: shell_execute (denied_by_user, pattern=docker rm)
Tool denied: shell_execute (timed_out, pattern=kubectl apply)
```

Each shell decision also emits ordered `Shell policy trace:` rows to the daemon
log. A row contains only enum facts, a call-local candidate ID, a bounded and
redacted executable basename, coverage kind, scope relation, and grant time. It
does not contain the full command, arguments, raw paths, tokens, redirects,
secrets, or model content. The trace never enters the prompt or session journal.

Example for the compound status command above:

```text
Shell policy trace: stage=StoredGrantMatch outcome=Uncovered reason=NoGrant candidate_id=0 executable=gh coverage=Uncovered scope_relation=None grant_timestamp=(null)
Shell policy trace: stage=StoredGrantMatch outcome=Uncovered reason=NoGrant candidate_id=1 executable=head coverage=Uncovered scope_relation=None grant_timestamp=(null)
Shell policy trace: stage=ReviewedSafePolicy outcome=Covered reason=ApprovalExemptSideEffect candidate_id=2 executable=echo coverage=ReviewedSafePolicy scope_relation=None grant_timestamp=(null)
Shell policy trace: stage=ReviewedSafePolicy outcome=Covered reason=ReviewedSafePhrase candidate_id=0 executable=gh coverage=ReviewedSafePolicy scope_relation=UnderRealRoot grant_timestamp=(null)
Shell policy trace: stage=ReviewedSafePolicy outcome=Covered reason=ReviewedSafePhrase candidate_id=1 executable=head coverage=ReviewedSafePolicy scope_relation=UnderRealRoot grant_timestamp=(null)
Shell policy trace: stage=Completion outcome=Allow reason=SafeVerbInTrustedScope candidate_id=(null) executable=(null) coverage=(null) scope_relation=None grant_timestamp=(null)
```

Read a trace from the final row backward:

1. `Completion/RequiresApproval/UncoveredCandidates` means at least one
candidate lacked coverage.
2. Find that candidate ID in `StoredGrantMatch`. `NoGrant` means no same-call
grant matched. `TokenMismatch`, `ShellMismatch`, `OutsideDirectory`, or
`Symlink` explains a bounded near miss.
3. Check whether that ID has a `ReviewedSafePolicy` or `OneTimeApproval` row.
If it does, that stage supplied coverage after the grant check.
4. `Completion/Deny/InternalPolicyFailure` is not an ordinary approval case.
Treat it as a policy defect or malformed internal result; do not add a grant
to work around it.
5. `Trace/TraceTruncated/TraceLimitReached` means the diagnostic row cap was
reached. It never changes the authorization result.

Use the trace to classify a repeated prompt before a policy change:

- A valid same-phrase folder near miss usually indicates scope or cwd drift.
- `NoGrant` plus a reviewed-safe candidate outside a trusted root usually points
to project/scratch alignment.
- A complete candidate with no general safe proof is an expected approval.
- A command that should have a strong parser fact but remains unresolved is a
ShellSyntaxTree evidence gap, not a reason to add executable-specific Netclaw
parser logic.

## FAQ

**Q: Can I disable approval gates entirely?**
A: Yes. Either remove the `ApprovalPolicy` section from your audience profile,
or set all tools to `Auto` mode.
A: Yes. Set an exact `shell_execute` override to `Auto`. Removal alone does not
disable the Personal shell default. If the exact override is absent, the shell
stays fail-closed in `Approval` mode.

**Q: Can I require approval for MCP tools?**
A: Yes. Add the tool name to `ToolOverrides`:
Expand Down
18 changes: 11 additions & 7 deletions openspec/changes/structure-shell-approval-policy/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,11 @@ inputs, expected coverage, the ordered bounded trace, and final outcome for the
policy-owned acceptance cases. Tests load these structured fields directly;
they do not branch on Dxx IDs or derive expectations from prose.

The fixture's top-level defaults are executable inputs, not test conventions:
tool name, audience, approval mode, interactive capability, session identity
and safe root, project safe root, inherited cwd, and persistent-store status.
The fixture's top-level defaults are executable inputs, not test conventions.
They include tool, audience, approval mode, interactive capability, session,
safe roots, inherited cwd, store status, and a fixed clock. Parser facts use
command indexes. Policy rows use stable candidate
IDs because one parser occurrence can project a different policy cardinality.
Each case supplies its canonical shell environment, initial cwd, and every
stored grant includes its canonical shell tag. The exact executable cases are
D02, D03, D07, D08, D09, D10, D11, D14, D17, and D18.
Expand All @@ -346,12 +348,14 @@ Complete D03 example:

```text
Input: cd /tmp && gh api ... > slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log
Preflight: candidates C0=cd, C1=gh api, C2=wc, C3=head; intent=/tmp
Actor: C0=PersistentGlobal, C1=PersistentGlobal, C2/C3=Uncovered
Safe policy: C2=ReviewedSafePolicy, C3=ReviewedSafePolicy
Final: Allow(AllCandidatesCovered)
Preflight: unresolved approval scope; no reusable candidate rows
Trace: Completion/RequiresApproval/UncoveredCandidates
Final: RequiresApproval(IsMessy=true)
```

This current result remains explicit evidence debt. The fixture does not claim
that a future session-scratch correction or stronger parser fact already exists.

## Risks / Trade-offs

- **New token-prefix grants add authority.** They are token-boundary based,
Expand Down
Loading
Loading