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: 3 additions & 1 deletion docs/inference/set-up-sub-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Use these paths inside the sandbox when you adapt an OpenClaw sub-agent setup:
| Path | Purpose |
|---|---|
| `/sandbox/.openclaw/openclaw.json` | OpenClaw config, including `models.providers`, `agents.defaults`, and `agents.list`. |
| `/sandbox/.openclaw/.config-hash` | Hash for `openclaw.json`. Keep it in sync after manual config edits so OpenClaw can detect the updated config. |
| `/sandbox/.openclaw/.config-hash` | Hash for `openclaw.json`. Keep it in sync after manual config edits so OpenClaw can detect the updated config. From the default mutable posture, the next `shields up` synthesizes a missing hash from `openclaw.json`. |
| `/sandbox/.openclaw/agents/<agent-id>/agent/auth-profiles.json` | Per-agent provider credentials. Use this when a sub-agent calls an auxiliary provider directly. |
| `/sandbox/.openclaw/workspace/` | Writable shared workspace path for files the primary agent passes to the sub-agent. |
| `/tmp/gateway.log` | OpenClaw gateway log. Use it to confirm config reloads and diagnose sub-agent failures. |
Expand Down Expand Up @@ -121,6 +121,8 @@ Do not commit `/tmp/openclaw.updated.json` or any other file that contains a rea
Upload the patched config and refresh the hash.
In the default mutable state, this keeps the local hash consistent but does not make it tamper-proof.
Use NemoClaw runtime controls when the sandbox needs a hardened config posture after the manual edit.
From the default mutable posture, `shields up` regenerates a stale hash and synthesizes a missing hash.
Keep the refresh step so OpenClaw detects the update immediately.

```bash
docker exec --user root "$SANDBOX_CTR" chmod 644 /sandbox/.openclaw/openclaw.json
Expand Down
9 changes: 9 additions & 0 deletions docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ Use this recovery path only when losing the state that could not be backed up is
When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down window for backup and recreation.
A detached auto-lock timer remains the recovery authority until NemoClaw commits a successful shields-up state, including when the host rebuild process exits unexpectedly.

<AgentOnly variant="openclaw">
If a failed shields transition on a sandbox from an older NemoClaw release quarantined the OpenClaw config, the bytes are preserved as `/sandbox/.openclaw/.nemoclaw-rejected-openclaw.json-<random>` rather than deleted.
Upgrade the NemoClaw CLI before rebuilding because an older CLI restages the older in-container guard.
To preserve settings, copy the quarantine file out of the container before `rebuild --yes`.
After the rebuild, inspect that copy and reapply required settings with the host-side `config set` command.
To discard the quarantined settings, upgrade the CLI and run `rebuild --yes` to create a known-good baseline.
Sandboxes with the updated guard report quarantine filenames and synthesize a missing `.config-hash` only during `shields up` from the default mutable posture.
</AgentOnly>

<AgentOnly variant="hermes">
For an older Hermes image that predates sealed shields transitions, only the rebuild workflow may use the descriptor-safe compatibility transition needed to archive and replace the sandbox.
That transition verifies the strict and compatibility hashes and publishes fresh config inodes before changing their lock posture, while ordinary `shields up` and `shields down` commands continue to refuse the older protocol.
Expand Down
25 changes: 25 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,31 @@ If the sandbox still cannot start or reports that no baseline is available, rebu
$$nemoclaw <name> rebuild
```

### `shields up` or `shields down` fails after `.config-hash` was removed

`/sandbox/.openclaw/.config-hash` is the integrity sidecar for `openclaw.json`; deleting it during a manual config edit removes the file the shields guard captures alongside the config.
From the default mutable posture, `$$nemoclaw <name> shields up` regenerates a stale hash from the current `openclaw.json` bytes.
On sandboxes with the updated guard, the same command synthesizes a truly absent `.config-hash` under the frozen tree.
Only a truly absent file is repaired; an unexpected file type at that name still fails closed.
`$$nemoclaw <name> shields down` does not synthesize the hash: with the file missing it fails closed without modifying the config.
If shields are already up, another `shields up` also fails closed when the hash is missing.
Do not use mutable-posture synthesis to recover a locked sandbox.

Sandboxes created by an older NemoClaw release keep the older guard baked into the container image, where a missing `.config-hash` makes the transition fail closed and quarantine `openclaw.json` by renaming it to `.nemoclaw-rejected-openclaw.json-<random>` in the same directory.
The config bytes are preserved, not deleted.
Upgrade the NemoClaw CLI before either recovery path because an older CLI restages the older guard.
To preserve the quarantined settings, copy the file to the host before rebuilding:

```bash
docker exec <container> ls -a /sandbox/.openclaw
docker cp <container>:/sandbox/.openclaw/<quarantine-file> ./openclaw.json.recovered
$$nemoclaw <name> rebuild --yes
```

After the rebuild, inspect `./openclaw.json.recovered` and reapply required settings with the host-side `config set` command.
Do not overwrite the regenerated `openclaw.json` with an unreviewed quarantine copy.
To discard the quarantined settings, upgrade the CLI and run `$$nemoclaw <name> rebuild --yes` without copying the file.

</AgentOnly>

<AgentOnly variant="openclaw,hermes">
Expand Down
89 changes: 74 additions & 15 deletions scripts/openclaw-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3264,6 +3264,48 @@ def _preflight_restart(opened: OpenConfig, identity: Identity) -> None:
_assert_config_binding(opened)


_hash_synthesized = False


def _write_hash_record(opened: OpenConfig, config_data: bytes, identity: Identity) -> None:
digest = hashlib.sha256(config_data).hexdigest()
_force_replace_bytes(
opened,
".config-hash",
f"{digest} openclaw.json\n".encode("ascii"),
identity,
)


def _repair_absent_hash_for_lock(opened: OpenConfig, identity: Identity) -> None:
"""Synthesize a truly absent .config-hash before lock-from-mutable capture.

On lock-from-mutable the canonical pair is regenerated from openclaw.json
bytes regardless of the stored hash content, so an absent sidecar carries
less signal than the tolerated stale-content case. The repair fires only on
true ENOENT under the frozen tree: a planted symlink, directory, fifo, or
hardlink at the name is seen as existing and falls through to the existing
fail-closed rejections.
"""

global _hash_synthesized
try:
os.stat(".config-hash", dir_fd=opened.config_fd, follow_symlinks=False)
return
except FileNotFoundError:
pass
_verify_dir_posture(
opened.config_fd,
opened.config_path,
identity.root_uid,
identity.root_gid,
0o700,
)
config = _snapshot_file(opened, "openclaw.json")
_write_hash_record(opened, config.data, identity)
_hash_synthesized = True


def _force_fail_closed_lock(opened: OpenConfig, identity: Identity) -> list[str]:
errors: list[str] = []
targets: tuple[FileSnapshot, FileSnapshot] | None = None
Expand Down Expand Up @@ -3291,22 +3333,37 @@ def _force_fail_closed_lock(opened: OpenConfig, identity: Identity) -> list[str]
except Exception as force_exc:
errors.append(f"forced fresh pair: {force_exc}")
else:
# No bounded pair could be captured. Sever each canonical path
# rather than retaining an attacker-held writable inode.
for name in CONFIG_FILES:
try:
os.rename(
name,
f".nemoclaw-rejected-{name.lstrip('.')}-{secrets.token_hex(16)}",
src_dir_fd=opened.config_fd,
dst_dir_fd=opened.config_fd,
published = False
try:
config = _snapshot_file(opened, "openclaw.json")
_force_replace_bytes(opened, "openclaw.json", config.data, identity)
_write_hash_record(opened, config.data, identity)
_snapshot_pair(opened)
published = True
except Exception as publish_exc:
errors.append(f"config-only publish: {publish_exc}")
if not published:
# No bounded config could be republished. Sever each canonical
# path rather than retaining an attacker-held writable inode.
for name in CONFIG_FILES:
rejected = (
f".nemoclaw-rejected-{name.lstrip('.')}-{secrets.token_hex(16)}"
)
except FileNotFoundError:
# A concurrently absent canonical name is already severed.
pass
except Exception as file_exc:
errors.append(f"{name}: {file_exc}")
os.fsync(opened.config_fd)
try:
os.rename(
name,
rejected,
src_dir_fd=opened.config_fd,
dst_dir_fd=opened.config_fd,
)
except FileNotFoundError:
# A concurrently absent canonical name is already severed.
pass
except Exception as file_exc:
errors.append(f"{name}: {file_exc}")
else:
errors.append(f"{name}: quarantined as {rejected}")
os.fsync(opened.config_fd)
try:
_commit_locked_dirs(opened, identity)
except Exception as exc:
Expand Down Expand Up @@ -3385,6 +3442,7 @@ def _transition(
freeze_started = True
_freeze(opened, identity)
_settle_pending_transaction_for_lock(opened, identity)
_repair_absent_hash_for_lock(opened, identity)
source = _snapshot_raw_pair(opened)
targets, _digest = _canonical_targets(source, identity, locked=True)
_install_stored_pair(opened, targets)
Expand Down Expand Up @@ -4200,6 +4258,7 @@ def main(argv: list[str] | None = None) -> int:
"files": list(CONFIG_FILES),
"chattrApplied": False,
**({"configSha256": new_digest} if new_digest is not None else {}),
**({"hashSynthesized": True} if _hash_synthesized else {}),
**({"recovery": recovery} if recovery is not None else {}),
**(
{"originalLocked": original_locked}
Expand Down
53 changes: 53 additions & 0 deletions src/lib/shields/openclaw-config-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,57 @@ describe("OpenClaw top-config guard host wiring", () => {
expect.stringContaining("capability probe failed"),
]);
});

it("sanitizes non-printable bytes and caps oversized guard issue text", () => {
const result: PrivilegedExecResult = {
status: 1,
signal: null,
stdout: [
JSON.stringify({
type: "issue",
code: "transition-failed\u001b[31m",
path: `${OPENCLAW_CONFIG_DIR}/openclaw.json\u0007`,
detail: `quarantined as .nemoclaw-rejected-openclaw.json-abc\u0000\u001b]0;title\u0007${"x".repeat(4096)}`,
}),
JSON.stringify({ type: "result", action: "lock", status: "failed" }),
].join("\n"),
stderr: "",
};

const issues = parseOpenClawConfigGuardOutput("lock", result).issues;

expect(issues[0]).toContain("[transition-failed");
expect(issues[0]).toContain("quarantined as .nemoclaw-rejected-openclaw.json-abc");
expect(issues[0]).not.toMatch(/[^\x20-\x7e]/);
expect(issues[0]?.length).toBeLessThan(2500);
});

it("propagates the guard's synthesized-hash marker on a successful lock", () => {
const synthesized: PrivilegedExecResult = {
status: 0,
signal: null,
stdout: `${JSON.stringify({
type: "result",
action: "lock",
status: "ok",
configDir: OPENCLAW_CONFIG_DIR,
files: ["openclaw.json", ".config-hash"],
chattrApplied: false,
hashSynthesized: true,
})}\n`,
stderr: "",
};
const plain: PrivilegedExecResult = {
status: 0,
signal: null,
stdout: `${success("lock")}\n`,
stderr: "",
};

const parsed = parseOpenClawConfigGuardOutput("lock", synthesized);

expect(parsed.issues).toEqual([]);
expect(parsed.hashSynthesized).toBe(true);
expect(parseOpenClawConfigGuardOutput("lock", plain).hashSynthesized).toBeUndefined();
});
});
21 changes: 16 additions & 5 deletions src/lib/shields/openclaw-config-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type GuardSummary = {
files?: string[];
chattrApplied?: boolean;
configSha256?: string;
hashSynthesized?: boolean;
recovery?: string;
originalLocked?: boolean;
};
Expand All @@ -72,6 +73,7 @@ export type OpenClawConfigGuardResult = {
issues: string[];
chattrApplied: boolean;
configSha256?: string;
hashSynthesized?: boolean;
recovery?: string;
originalLocked?: boolean;
};
Expand Down Expand Up @@ -102,6 +104,13 @@ function stringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}

function printableExcerpt(value: string, maxLength: number): string {
return [...value.slice(0, maxLength)]
.map((character) => (/^[\x20-\x7e]$/.test(character) ? character : " "))
.join("")
.trim();
}

function schemaIssuePaths(payload: unknown): string[] {
if (!payload || typeof payload !== "object") return [];
const issues = (payload as { issues?: unknown }).issues;
Expand All @@ -111,10 +120,7 @@ function schemaIssuePaths(payload: unknown): string[] {
if (!issue || typeof issue !== "object") continue;
const path = (issue as { path?: unknown }).path;
if (typeof path !== "string") continue;
const sanitized = [...path.slice(0, 256)]
.map((character) => (/^[\x20-\x7e]$/.test(character) ? character : " "))
.join("")
.trim();
const sanitized = printableExcerpt(path, 256);
if (sanitized && !paths.includes(sanitized)) paths.push(sanitized);
}
return paths;
Expand Down Expand Up @@ -223,6 +229,7 @@ export function parseOpenClawConfigGuardOutput(
(record.files === undefined || stringArray(record.files)) &&
(record.chattrApplied === undefined || typeof record.chattrApplied === "boolean") &&
(record.configSha256 === undefined || typeof record.configSha256 === "string") &&
(record.hashSynthesized === undefined || typeof record.hashSynthesized === "boolean") &&
(record.recovery === undefined || typeof record.recovery === "string") &&
(record.originalLocked === undefined || typeof record.originalLocked === "boolean")
) {
Expand Down Expand Up @@ -283,14 +290,18 @@ export function parseOpenClawConfigGuardOutput(
return {
issues: [
...issues.map(
(issue) => `OpenClaw config guard ${action} [${issue.code}] ${issue.path}: ${issue.detail}`,
(issue) =>
`OpenClaw config guard ${action} [${printableExcerpt(issue.code, 64)}] ${printableExcerpt(issue.path, 256)}: ${printableExcerpt(issue.detail, 2048)}`,
),
...contractIssues,
],
chattrApplied: summary?.status === "ok" && summary.chattrApplied === true,
...(summary?.status === "ok" && summary.configSha256
? { configSha256: summary.configSha256 }
: {}),
...(summary?.status === "ok" && summary.hashSynthesized === true
? { hashSynthesized: true }
: {}),
...(summary?.status === "ok" && summary.recovery ? { recovery: summary.recovery } : {}),
...(summary?.status === "ok" && typeof summary.originalLocked === "boolean"
? { originalLocked: summary.originalLocked }
Expand Down
Loading
Loading