← Back to Plugins
Tools

Exec Fix

naelrudd By naelrudd 👁 136 views ▲ 0 votes

Fix: OpenClaw exec blocked by GitHub Identity ACL, plus a per-directory exec approval gate plugin

GitHub

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

Loading comments...