Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
634e682
fix(shields): verify config lock and fail hard on re-lock failure
ericksoa Apr 18, 2026
eec9189
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 18, 2026
537a717
fix(shields): parse lsattr flags field and verify timer re-lock
ericksoa Apr 18, 2026
3630270
fix(shields): treat immutable bit as best-effort on unsupported fs
ericksoa Apr 19, 2026
499731e
docs(shields): clarify chattr +i is best-effort on config file
ericksoa Apr 19, 2026
596f372
fix(shields): independent unlock ops, timer dir verification, timer h…
ericksoa Apr 19, 2026
11aa705
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 19, 2026
4defd49
fix(e2e): increase timeouts for token-rotation and shields auto-restore
ericksoa Apr 19, 2026
e3b7cc9
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 19, 2026
c43a37d
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 20, 2026
e963103
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 20, 2026
e445f42
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 20, 2026
893e7a6
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 20, 2026
6e7ca4b
fix(shields): address review feedback — dedup lock logic, fix state gaps
ericksoa Apr 20, 2026
81582d1
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 21, 2026
b8f2695
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 21, 2026
51b9e51
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 21, 2026
b044d86
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 21, 2026
41646f5
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 21, 2026
753c7ca
Merge branch 'main' into fix/shields-relock-verification
ericksoa Apr 21, 2026
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
36 changes: 34 additions & 2 deletions src/lib/shields-timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@
// restores the captured policy snapshot.
//
// Usage (internal — called by shields.ts via fork()):
// node shields-timer.js <sandbox-name> <snapshot-path> <restore-at-iso>
// node shields-timer.js <sandbox-name> <snapshot-path> <restore-at-iso> <config-path> <config-dir>

const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const { run } = require("./runner");
const { buildPolicySetCommand } = require("./policies");

const STATE_DIR = path.join(process.env.HOME ?? "/tmp", ".nemoclaw", "state");
const AUDIT_FILE = path.join(STATE_DIR, "shields-audit.jsonl");
const K3S_CONTAINER = "openshell-cluster-nemoclaw";

const [sandboxName, snapshotPath, restoreAtIso] = process.argv.slice(2);
const [sandboxName, snapshotPath, restoreAtIso, configPath, configDir] = process.argv.slice(2);
const STATE_FILE = path.join(STATE_DIR, `shields-${sandboxName}.json`);
const restoreAtMs = new Date(restoreAtIso).getTime();
const delayMs = Math.max(0, restoreAtMs - Date.now());
Expand All @@ -26,6 +28,14 @@ if (!sandboxName || !snapshotPath || !restoreAtIso || isNaN(restoreAtMs)) {
process.exit(1);
}

function kubectlExec(cmd) {
execFileSync("docker", [
"exec", K3S_CONTAINER,
"kubectl", "exec", "-n", "openshell", sandboxName, "-c", "agent", "--",
...cmd,
], { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 });
}

function appendAudit(entry) {
try {
fs.appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", { mode: 0o600 });
Expand Down Expand Up @@ -103,6 +113,28 @@ setTimeout(() => {
process.exit(1);
}

// Re-lock config file (each operation independent)
if (configPath) {
const lockErrors = [];
try { kubectlExec(["chmod", "444", configPath]); } catch { lockErrors.push("chmod 444"); }
try { kubectlExec(["chown", "root:root", configPath]); } catch { lockErrors.push("chown file"); }
if (configDir) {
try { kubectlExec(["chmod", "755", configDir]); } catch { lockErrors.push("chmod dir"); }
try { kubectlExec(["chown", "root:root", configDir]); } catch { lockErrors.push("chown dir"); }
}
try { kubectlExec(["chattr", "+i", configPath]); } catch { lockErrors.push("chattr +i"); }

if (lockErrors.length > 0) {
appendAudit({
action: "shields_auto_restore_lock_warning",
sandbox: sandboxName,
timestamp: now,
restored_by: "auto_timer",
warning: `Some lock operations failed: ${lockErrors.join(", ")}`,
});
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Audit
appendAudit({
action: "shields_auto_restore",
Expand Down
98 changes: 85 additions & 13 deletions src/lib/shields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ function kubectlExec(sandboxName: string, cmd: string[]): void {
], { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 });
}

function kubectlExecCapture(sandboxName: string, cmd: string[]): string {
return execFileSync("docker", [
"exec", K3S_CONTAINER,
"kubectl", "exec", "-n", "openshell", sandboxName, "-c", "agent", "--",
...cmd,
], { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }).toString().trim();
}

// Re-export for tests and external consumers
const MAX_TIMEOUT_SECONDS = MAX_SECONDS;
const DEFAULT_TIMEOUT_SECONDS = DEFAULT_SECONDS;
Expand Down Expand Up @@ -144,6 +152,70 @@ function unlockAgentConfig(sandboxName: string, target: { configPath: string; co
}
}

// ---------------------------------------------------------------------------
// Config lock — shared between shields-up, auto-restore timer, and rollback
//
// Each operation runs independently so a single failure does not skip the
// rest. After all attempts, we verify the actual on-disk state and throw
// if the config is not properly locked.
// ---------------------------------------------------------------------------
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function lockAgentConfig(sandboxName: string, target: { configPath: string; configDir: string }): void {
const errors: string[] = [];

try { kubectlExec(sandboxName, ["chmod", "444", target.configPath]); }
catch { errors.push("chmod 444 config file"); }

try { kubectlExec(sandboxName, ["chown", "root:root", target.configPath]); }
catch { errors.push("chown root:root config file"); }

try { kubectlExec(sandboxName, ["chmod", "755", target.configDir]); }
catch { errors.push("chmod 755 config dir"); }

try { kubectlExec(sandboxName, ["chown", "root:root", target.configDir]); }
catch { errors.push("chown root:root config dir"); }

try { kubectlExec(sandboxName, ["chattr", "+i", target.configPath]); }
catch { errors.push("chattr +i config file"); }

if (errors.length > 0) {
console.error(` Some lock operations failed: ${errors.join(", ")}`);
}

// Verify the lock actually took effect
const issues: string[] = [];
try {
const perms = kubectlExecCapture(sandboxName, ["stat", "-c", "%a %U:%G", target.configPath]);
const [mode, owner] = perms.split(" ");
if (!/^4[0-4][0-4]$/.test(mode)) issues.push(`file mode=${mode} (expected 444)`);
if (owner !== "root:root") issues.push(`file owner=${owner} (expected root:root)`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
issues.push(`file stat failed: ${msg}`);
}

try {
const dirPerms = kubectlExecCapture(sandboxName, ["stat", "-c", "%a %U:%G", target.configDir]);
const [dirMode, dirOwner] = dirPerms.split(" ");
if (dirMode !== "755") issues.push(`dir mode=${dirMode} (expected 755)`);
if (dirOwner !== "root:root") issues.push(`dir owner=${dirOwner} (expected root:root)`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
issues.push(`dir stat failed: ${msg}`);
}

try {
const attrs = kubectlExecCapture(sandboxName, ["lsattr", "-d", target.configPath]);
if (!attrs.includes("i")) issues.push("immutable bit not set");
} catch {
// lsattr may not be available on all images — skip
}

if (issues.length > 0) {
throw new Error(`Config not locked: ${issues.join(", ")}`);
}
}

// ---------------------------------------------------------------------------
// shields down
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -250,7 +322,7 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void {
const actualScript = fs.existsSync(timerScriptJs) ? timerScriptJs : timerScript;

try {
const child = fork(actualScript, [sandboxName, snapshotPath, restoreAt.toISOString()], {
const child = fork(actualScript, [sandboxName, snapshotPath, restoreAt.toISOString(), target.configPath, target.configDir], {
detached: true,
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
Expand All @@ -273,13 +345,11 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void {
const message = err instanceof Error ? err.message : String(err);
console.error(` Cannot start auto-restore timer: ${message}`);
console.error(" Rolling back — restoring policy from snapshot...");
run(buildPolicySetCommand(snapshotPath, sandboxName), { ignoreError: true });
try {
run(buildPolicySetCommand(snapshotPath, sandboxName), { ignoreError: true });
kubectlExec(sandboxName, ["chmod", "444", target.configPath]);
kubectlExec(sandboxName, ["chown", "root:root", target.configPath]);
kubectlExec(sandboxName, ["chattr", "+i", target.configPath]);
lockAgentConfig(sandboxName, target);
} catch {
// Best effort rollback
console.error(" Warning: Rollback re-lock could not be verified. Check config manually.");
}
saveShieldsState(sandboxName, {
shieldsDown: false,
Expand Down Expand Up @@ -351,16 +421,18 @@ function shieldsUp(sandboxName: string): void {
// 2b. Re-lock config file to read-only.
// Restore the Dockerfile's original permissions and immutable bit.
// Uses kubectl exec to bypass Landlock (same as shields down).
// Each operation runs independently and the result is verified.
// If verification fails, shields remain DOWN — we do not lie about state.
const target = resolveAgentConfig(sandboxName);
console.log(` Locking ${target.agentName} config (${target.configPath})...`);
try {
kubectlExec(sandboxName, ["chmod", "444", target.configPath]);
kubectlExec(sandboxName, ["chown", "root:root", target.configPath]);
kubectlExec(sandboxName, ["chmod", "755", target.configDir]);
kubectlExec(sandboxName, ["chown", "root:root", target.configDir]);
kubectlExec(sandboxName, ["chattr", "+i", target.configPath]);
} catch {
console.error(" Warning: Could not re-lock config file.");
lockAgentConfig(sandboxName, target);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(` ERROR: ${message}`);
console.error(" Shields remain DOWN — manual intervention required.");
console.error(` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`);
process.exit(1);
}

// 3. Calculate duration
Expand Down
Loading