Tools
Exec Fix
Fix: OpenClaw exec blocked by GitHub Identity ACL, plus a per-directory exec approval gate plugin
Install
openclaw plugins install --link
Configuration Example
// github-exec-launcher.js
async function privateProfileStat(profileDir) {
const stat = await fs.lstat(profileDir);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(GITHUB_EXEC_CREDENTIAL_UNAVAILABLE);
if (process.platform === "win32") {
const permissions = await inspectPathPermissions(profileDir);
if (!permissions.ok || permissions.source !== "windows-acl" || permissions.ownerTrusted !== true ||
permissions.groupReadable || permissions.worldReadable ||
permissions.groupWritable || permissions.worldWritable) throw new Error(GITHUB_EXEC_CREDENTIAL_UNAVAILABLE);
}
...
}
README
# OpenClaw: exec blocked by GitHub Identity ACL + per-directory exec gate
Two independent bugs made `exec` fail on every host, plus a plugin that implements
a per-directory approval policy OpenClaw does not ship.
Environment: OpenClaw `2026.9.5`, Windows 10.0.26200, Node 26.2.0.
---
## Symptom
Every `exec` call failed, on every host, with:
```
GitHub Identity credential is unavailable or insecure. Reconnect or change GitHub Identity, then retry.
```
`openclaw approvals get` showed a healthy policy and zero allowlist entries, so
approvals looked like the culprit. They were not.
---
## Root cause 1 — GitHub Identity ACL (the real blocker)
Source: `dist/agents/github-exec-launcher.js`.
For `host=gateway` exec, OpenClaw validates the selected `tools.github` profile
immediately before **every** process launch and refuses to run if that profile is
missing, tokenless, or insecure. There is deliberately no native-keyring fallback:
```js
// github-exec-launcher.js
async function privateProfileStat(profileDir) {
const stat = await fs.lstat(profileDir);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(GITHUB_EXEC_CREDENTIAL_UNAVAILABLE);
if (process.platform === "win32") {
const permissions = await inspectPathPermissions(profileDir);
if (!permissions.ok || permissions.source !== "windows-acl" || permissions.ownerTrusted !== true ||
permissions.groupReadable || permissions.worldReadable ||
permissions.groupWritable || permissions.worldWritable) throw new Error(GITHUB_EXEC_CREDENTIAL_UNAVAILABLE);
}
...
}
```
The profile on disk was fine — a valid `gho_…` token in `hosts.yml`. The problem was
the **ACL**, which was inherited from the parent directory:
```
BUILTIN\Administrators FullControl (inherited)
NT AUTHORITY\SYSTEM FullControl (inherited)
NT AUTHORITY\Authenticated Users Modify, Synchronize (inherited)
BUILTIN\Users ReadAndExecute (inherited)
```
`@openclaw/fs-safe` classifies `BUILTIN\Users` and `Authenticated Users` as **world**
(`WORLD_PRINCIPALS` in `permissions-windows.js`), so `worldReadable` / `worldWritable`
were true and the launcher threw.
### Fix
Restrict the credential root to the owner and SYSTEM, and let inheritance propagate:
```powershell
icacls "<openclaw-home>\.openclaw\credentials\github" /inheritance:r `
/grant:r "$env:USERDOMAIN\$env:USERNAME:(OI)(CI)F" `
/grant:r "*S-1-5-18:(OI)(CI)F"
```
Result:
```
NT AUTHORITY\SYSTEM FullControl (inherited)
<user> FullControl (inherited)
```
which the inspector reports as `acl=trusted-only`, `ownerTrusted=true`,
`world=--`, `group=--`.
### Trap that costs time
Do **not** add `/T` together with `(OI)(CI)F`. On **files**, the `(OI)(CI)` flags
make the ACE inherit-only, so the files become unreadable by everyone and every
open fails with `EPERM`. Either let inheritance propagate from the directory (as
above), or use plain `F` for files. OpenClaw's own
`formatIcaclsResetCommand` already does this correctly:
```js
const grant = opts.isDir ? "(OI)(CI)F" : "F";
```
### Testing note
Path-based `inspectPathPermissions()` returning OK is **not** sufficient. The gate
that actually matters for `readSecureFile()` on Windows is the descriptor-based
`inspectSecureWindowsDescriptor({ fd, ... })`. Verify with a real read, not just an
ACL query:
```js
const { readSecureFile } = await import("@openclaw/fs-safe/secure-file");
await readSecureFile({ filePath: "<profile>/hosts.yml", trust: { trustedDirs: ["<profile>"] },
io: { maxBytes: 65536, timeoutMs: 5000 } });
```
---
## Root cause 2 — no approval route (secondary)
`commands.ownerAllowFrom` was empty. Telegram `execApprovals` is unset, which means
`"auto"`, and auto mode needs at least one **resolvable approver**. With no approver
there is no delivery route, so `askFallback: deny` denied every command that
required approval.
Telegram *does* support native exec approvals and same-chat `/approve` — the
channel was never the problem.
Fix:
```
commands.ownerAllowFrom: ["telegram:<user-id>"]
approvals.plugin: { enabled: true, mode: "session" }
```
---
## The requested policy: silent on `D:`, approval on `C:`
OpenClaw has **no per-directory exec policy**. There is no `allowedRoots` /
`denyPaths` knob under `tools.exec`; the surface is only `mode`, `host`, `ask`,
`allowlist`, and `safeBins`.
Two pieces:
**1. Open the base policy** so in-scope commands run without prompting:
```bash
openclaw exec-policy preset yolo
```
which sets `tools.exec.mode=full`, `tools.exec.host=gateway`, and the host approvals
document to `security=full, ask=off, askFallback=full`.
**2. Gate the out-of-scope drive** with a `before_tool_call` hook that returns
`requireApproval` when the call targets `C:` — see [`plugin/`](plugin).
```js
api.on("before_tool_call", (event) => {
if (!GATED_TOOLS.includes(event.toolName)) return;
const hits = [...paramsHitC(event.params), ...cHits(event.derivedPaths)];
if (hits.length === 0) return;
return { requireApproval: { title: `C: drive ${event.toolName}`, description: ..., severity: "warning",
allowedDecisions: ["allow-once", "deny"] } };
}, { matcher: GATED_TOOLS, priority: 50 });
```
`event.derivedPaths` exists only for `apply_patch` (`deriveToolParams` returns `{}`
for every other tool), so parameters are scanned recursively for string values
instead of relying on a known param name.
---
## Plugin loading gotchas
These cost the most time, so they are worth recording.
| Attempt | Result |
| --- | --- |
| Standalone `.ts` file in `plugins.load.paths` | Listed as `enabled`, but the module is **never imported**. `plugins inspect` warns: `Adding it to plugins.allow lets it load`. |
| Add the id to `plugins.allow` | `plugins.allow` is an **exclusive** allowlist. Enabled plugins dropped 44 → 3. Unusable here. |
| Real plugin package + `plugins install --link` | Works. `Trust: reason=origin-path`, loads at startup, `plugins.allow` stays `[]`. |
```bash
openclaw plugins install --link "<openclaw-home>\.openclaw\plugins\drive-policy" \
--force --accept-capabilities
```
Other findings:
- `plugins list` reports `hookCount: 0` for **every** plugin, including hook-heavy
bundled ones. It is not a useful signal.
- A hook-only plugin needs no `contracts` entry in its manifest; `activation.onStartup: true`
is sufficient (see the bundled `active-memory` plugin, which declares none).
- A standalone config-path plugin cannot resolve the bare specifier
`openclaw/plugin-sdk/plugin-entry` (no `node_modules` up-tree). A junction works
for local testing, but the installed-package route is the correct fix.
---
## Verification
Each case run through the Gateway with an isolated session:
```bash
openclaw agent --agent main --session-key agent:main:<case> --message "<prompt>" --json
```
| Case | Result |
| --- | --- |
| `exec` on `D:` (`echo`) | ok, `failures=0` |
| `exec` on `C:` (`dir C:\Windows`) | `failures=1`, blocked |
| `write` to `D:` | ok, file created |
| `write` to `C:` | blocked, file never created |
| after `openclaw gateway restart` | `D:` ok, `C:` still gated |
Blocked calls return:
```
Plugin approval unavailable: non-interactive CLI runs have no approval-capable initiating surface.
```
That is expected for headless CLI runs: no approval surface exists, so the request
fails closed. From a chat channel the approval card is delivered to the owner DM and
can be resolved with the buttons or `/approve <id> allow-once`.
Self-check for the matcher:
```bash
node plugin/matcher.test.mjs
# ok: 11 gated, 7 silent, params scan ok
```
---
## Limitations
- The `C:` check is **heuristic text matching, not a sandbox**. It is evadable via
computed drive letters, `cd` followed by relative paths, and UNC/WSL paths.
Upgrade path: OS-level ACLs, or a separate restricted Windows account for the
Gateway process.
- Non-interactive CLI runs cannot resolve a plugin approval, so out-of-scope
commands from `openclaw agent` are denied outright rather than prompted.
- Per-agent overrides still win: an agent with its own `tools.exec.mode: "allowlist"`
stays strict regardless of the global preset.
---
## Files
```
plugin/
package.json plugin package metadata
openclaw.plugin.json manifest (activation.onStartup)
index.mjs before_tool_call hook
matcher.mjs pure path-matching helpers
matcher.test.mjs assert-based self-check
```
## License
MIT
tools
Comments
Sign in to leave a comment