Clear stale yolo approval state on no-launch reruns (#6868)
* Clear stale yolo approval state on no-launch reruns The no-launch session config dir is deliberately reused across runs, but the config writers only ever added the --yolo auto-approval settings and never removed them. After one --yolo --no-launch run, every later run without --yolo kept OpenClaw's tools.exec security=full/ask=off policy plus exec-approvals.json, and OpenCode's permission allow block, so tool execution stayed silently pre-approved. Non-yolo runs now reset that state: OpenClaw drops the exec policy keys and the yolo defaults in exec-approvals.json (approvals OpenClaw itself recorded are kept; the file is removed when only the yolo payload is left), and OpenCode drops the permission block. Launch mode is untouched since it already uses an ephemeral temp dir. * Strip only yolo-written values on non-yolo cleanup Match each field against the exact value the yolo path writes before removing it, so a stricter exec policy, approvals defaults set by the user or the OpenClaw UI, and deny/ask OpenCode permission entries all survive a plain no-launch rerun. An unparseable exec-approvals.json is left in place, matching how an unparseable config is handled. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Write a prompting policy on non-yolo instead of deleting to a permissive default OpenClaw and OpenCode both treat an omitted policy as permissive: OpenClaw's effective exec policy for an unset tools.exec is security=full/ask=off on the gateway host, and OpenCode defaults an unset permission to allow. So clearing the yolo values on a non-yolo run did not restore prompting, it fell back to those permissive defaults and left tool execution auto-approved. A non-yolo run now writes an explicit prompting policy: OpenClaw gets security=allowlist/ask=on-miss (verified to prompt even with the approvals file removed, since the stricter of config and approvals wins), and OpenCode gets edit/bash/webfetch=ask. Only a permissive/yolo value is tightened; a stricter deny (or an ask the user set) is preserved, and the yolo approvals defaults are still stripped. The file-edit CI path opts opencode/openclaw into --yolo, since those agents now prompt by default and the headless test needs auto-approval. * Respect existing exec mode, sandbox/node host, and global permission rules on non-yolo reset The non-yolo reset for openclaw/opencode assumed an omitted policy was the permissive yolo default and rewrote it, which corrupted or weakened stricter setups it should have preserved: - OpenClaw tools.exec.mode is the normalized policy knob and cannot be combined with explicit security/ask (OpenClaw rejects the whole config), so writing security+ask alongside a mode:deny/ask policy both broke the config and relaxed it. Leave a mode-based policy untouched. - host=sandbox defaults to security=deny and host=node routes to a paired node; neither is written by --yolo (which only writes host=gateway). Treating the missing security as full and popping host broadened those into gateway/auto exec. Only rewrite a gateway-routed permissive policy, and never pop a non-gateway host. - OpenCode permission can be a string ("deny") or a {"*": ...} catch-all. The old code dropped a string form and overrode a catch-all by writing per-tool ask, weakening a stricter user rule. Now a string is left in place, a catch-all governs absent tools, and only an effective allow is tightened. - The non-yolo ask policy only lived in OPENCODE_CONFIG, which loads below project opencode.json, so a project config allowing edit/bash/webfetch still auto-approved. Carry the ask policy in OPENCODE_CONFIG_CONTENT (above project config) too, symmetric to how yolo carries its allow. Also harden the openclaw path against a malformed non-dict tools value. Adds tests for mode/sandbox/node hosts, string and catch-all permissions, and the inline ask policy over a project config. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope non-yolo resets to the exact yolo fingerprint and preserve granular denies OpenClaw: reset only the exact host=gateway/security=full/ask=off policy --yolo writes, so an omitted or host=auto/sandbox/node policy (which can resolve to a sandbox security=deny default) is no longer broadened to allowlist/on-miss, and a deliberate tools.exec.mode is left alone (OpenClaw never migrates our security/ask write into a mode). OpenCode: carry a granular object or a deny inline verbatim so a per-tool user rule is not collapsed to a blanket ask, but floor any object that grants allow anywhere to the string ask (which fully replaces a project object) so no inline allow pattern can leak through into a silent auto-approve on a non-yolo session. * Stop overriding project config on non-yolo; require full approvals fingerprint The non-yolo OpenCode reset carried a session permission in OPENCODE_CONFIG_CONTENT, which outranks the project opencode.json we cannot read. That inline override could not correctly reflect the project: it weakened a project deny to a prompt, mishandled global string rules, leaked through a granular object's permissive default when no catch-all was present, collapsed an object with an allow (losing its deny), and missed per-agent permissions. All of these stem from forcing a value over an unknown project config. A non-yolo run now only undoes what --yolo wrote: it flips our own explicit per-tool allow back to ask in our config file and carries no permission inline, so the project's own permissions are honored as written. Clearing our persisted yolo state is the actual fix; --yolo still carries its allow inline so it works over a project config. OpenClaw approvals cleanup now strips the yolo defaults only when the full fingerprint (security=full, ask=off, askFallback=full) is present, so a mixed user policy that merely shares askFallback=full (whose omitted default is deny) is kept intact. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
08226c2475
commit
69f8e0b228
3 changed files with 389 additions and 18 deletions
|
|
@ -983,7 +983,9 @@ def _session_config(agent: str, launch: bool):
|
|||
else:
|
||||
# Never wipe this dir: a previously printed recipe may still be running
|
||||
# an agent whose sessions/state live here, and every config writer
|
||||
# merges idempotently into an existing home anyway.
|
||||
# merges idempotently into an existing home anyway. Writers must also
|
||||
# reset any state a previous run's flags left behind (--yolo especially),
|
||||
# since files here outlive the invocation that wrote them.
|
||||
path = _agents_config_root() / agent
|
||||
path.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
||||
yield path
|
||||
|
|
@ -1043,6 +1045,62 @@ def write_openclaw_config(
|
|||
{"version": 1, "defaults": {"security": "full", "ask": "off", "askFallback": "full"}},
|
||||
)
|
||||
typer.echo(f"Updated {approvals}")
|
||||
else:
|
||||
# The no-launch config dir is reused across runs, so a previous --yolo run may
|
||||
# have left auto-approval state behind. OpenClaw treats an omitted exec policy as
|
||||
# security=full, ask=off on the gateway host, so deleting the keys would keep
|
||||
# auto-approval on: a non-yolo run must WRITE a prompting policy. Only a
|
||||
# permissive/yolo policy is replaced; a stricter one set by hand survives.
|
||||
tools = config.get("tools")
|
||||
exec_policy = tools.get("exec") if isinstance(tools, dict) else None
|
||||
exec_policy = exec_policy if isinstance(exec_policy, dict) else {}
|
||||
# Match ONLY the exact fingerprint --yolo writes (host=gateway, security=full,
|
||||
# ask=off, all explicit, no mode); anything else is left untouched. host=auto or an
|
||||
# omitted host resolves to security=deny under an active sandbox, so treating those
|
||||
# as the permissive gateway default would broaden a fresh sandboxed config from
|
||||
# deny to allowlist. host=node and host=sandbox are user-set (--yolo only writes
|
||||
# gateway). tools.exec.mode is OpenClaw's normalized knob (it cannot be combined
|
||||
# with security/ask, and OpenClaw never rewrites our security/ask write into it),
|
||||
# so a mode is always a deliberate user policy; never clobber it.
|
||||
permissive = (
|
||||
"mode" not in exec_policy
|
||||
and exec_policy.get("host") == "gateway"
|
||||
and exec_policy.get("security") == "full"
|
||||
and exec_policy.get("ask") == "off"
|
||||
)
|
||||
if permissive:
|
||||
exec_policy = _subdict(_subdict(config, "tools"), "exec")
|
||||
exec_policy.pop("host", None) # routing only; defaults to the gateway host
|
||||
exec_policy["security"] = "allowlist" # only allowlisted commands skip approval
|
||||
exec_policy["ask"] = "on-miss" # prompt on every non-allowlisted command
|
||||
# Drop the yolo defaults from the host approvals file (a stricter default set by
|
||||
# the user or OpenClaw is kept). With a prompting tools.exec the stricter of the
|
||||
# two layers wins, so an omitted approvals default still prompts.
|
||||
approvals = path.parent / "exec-approvals.json"
|
||||
if approvals.exists():
|
||||
state = _read_json_object(approvals)
|
||||
if state is not None:
|
||||
defaults = state.get("defaults")
|
||||
# Strip the defaults only when they are exactly the yolo fingerprint; a
|
||||
# user-managed mixed policy that merely shares a field (e.g. askFallback=full,
|
||||
# whose omitted default is deny) must be kept intact.
|
||||
yolo_defaults = (("security", "full"), ("ask", "off"), ("askFallback", "full"))
|
||||
is_yolo = isinstance(defaults, dict) and all(
|
||||
defaults.get(k) == v for k, v in yolo_defaults
|
||||
)
|
||||
if is_yolo:
|
||||
for k, _ in yolo_defaults:
|
||||
del defaults[k]
|
||||
if not defaults:
|
||||
del state["defaults"]
|
||||
if set(state) <= {"version"}:
|
||||
# Nothing left but our own yolo payload: remove it.
|
||||
approvals.unlink()
|
||||
typer.echo(f"Removed {approvals}")
|
||||
else:
|
||||
# Keep approvals OpenClaw itself recorded; only the yolo defaults go.
|
||||
_write_private_json(approvals, state)
|
||||
typer.echo(f"Updated {approvals}")
|
||||
if json.dumps(config, sort_keys = True) != before:
|
||||
_write_private_json(path, config)
|
||||
typer.echo(f"Updated {path}")
|
||||
|
|
@ -1054,7 +1112,7 @@ def write_opencode_config(
|
|||
model: dict,
|
||||
path: Path,
|
||||
yolo: bool = False,
|
||||
) -> None:
|
||||
) -> dict:
|
||||
config = _read_json_object(path)
|
||||
if config is None:
|
||||
typer.echo(
|
||||
|
|
@ -1062,7 +1120,7 @@ def write_opencode_config(
|
|||
"yourself, or move the file aside and re-run.",
|
||||
err = True,
|
||||
)
|
||||
return
|
||||
return {}
|
||||
before = json.dumps(config, sort_keys = True)
|
||||
config.setdefault("$schema", "https://opencode.ai/config.json")
|
||||
model_entry = {"name": model["id"]}
|
||||
|
|
@ -1087,13 +1145,32 @@ def write_opencode_config(
|
|||
compaction = _subdict(config, "compaction")
|
||||
compaction["auto"] = True
|
||||
compaction["reserved"] = max(1, window // 10)
|
||||
tools = ("edit", "bash", "webfetch")
|
||||
if yolo:
|
||||
# OpenCode has no --yolo flag; auto-approve is the config `permission` block
|
||||
# (singular). Allow the prompting tools so tool calls don't block on the TUI.
|
||||
config["permission"] = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
||||
# (singular). Allow the prompting tools so tool calls don't block on the TUI. This
|
||||
# rides inline (OPENCODE_CONFIG_CONTENT) so --yolo works even over a project config.
|
||||
session_permission = {t: "allow" for t in tools}
|
||||
config["permission"] = dict(session_permission)
|
||||
else:
|
||||
# Undo only what --yolo wrote: our yolo sets an explicit per-tool "allow" for these
|
||||
# three tools, so flip exactly those explicit allows back to "ask". A "deny"/"ask",
|
||||
# a granular object, a string, or a "*" catch-all is the user's own rule and is left
|
||||
# untouched. We do NOT carry a permission inline for a non-yolo session: since
|
||||
# OPENCODE_CONFIG_CONTENT outranks the project opencode.json we cannot read, any
|
||||
# value forced there would override the user's project rules (weakening a project
|
||||
# deny, or auto-approving through a granular object's permissive default). Clearing
|
||||
# our own persisted yolo state is the fix; the project's own permissions are honored.
|
||||
session_permission: dict = {}
|
||||
permission = config.get("permission")
|
||||
if isinstance(permission, dict):
|
||||
for tool in tools:
|
||||
if permission.get(tool) == "allow":
|
||||
permission[tool] = "ask"
|
||||
if json.dumps(config, sort_keys = True) != before:
|
||||
_write_private_json(path, config)
|
||||
typer.echo(f"Updated {path}")
|
||||
return session_permission
|
||||
|
||||
|
||||
def write_hermes_config(base: str, model: dict, path: Path) -> None:
|
||||
|
|
@ -1379,14 +1456,15 @@ def opencode(
|
|||
# OPENCODE_CONFIG is an overlay (loaded between the user's global and project
|
||||
# configs), so this adds the Unsloth provider/model for the session without
|
||||
# changing the user's default model. Key lives in the config, not the env.
|
||||
write_opencode_config(base, key, entry, config_path, yolo = yolo)
|
||||
# A project's own opencode.json outranks OPENCODE_CONFIG, so the session model
|
||||
# pin (and --yolo permissions) would silently lose to a repo config. Carry the
|
||||
# settings that must win in OPENCODE_CONFIG_CONTENT, which outranks project
|
||||
# config; the API key stays in the private file, never in the printed env.
|
||||
session_permission = write_opencode_config(base, key, entry, config_path, yolo = yolo)
|
||||
# A project's own opencode.json outranks OPENCODE_CONFIG, so the session model pin
|
||||
# would silently lose to a repo config. Carry it in OPENCODE_CONFIG_CONTENT, which
|
||||
# outranks project config; the API key stays in the private file, never the env.
|
||||
# Only --yolo carries a permission here (its allow must win over a project config);
|
||||
# a non-yolo session returns no permission, so the project's own rules are honored.
|
||||
inline_config: dict = {"model": f"unsloth/{entry['id']}"}
|
||||
if yolo:
|
||||
inline_config["permission"] = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
||||
if session_permission:
|
||||
inline_config["permission"] = session_permission
|
||||
env = {
|
||||
"OPENCODE_CONFIG": str(config_path),
|
||||
"OPENCODE_CONFIG_CONTENT": json.dumps(inline_config),
|
||||
|
|
|
|||
|
|
@ -446,7 +446,10 @@ def test_opencode_inline_config_beats_project_config(fake_studio):
|
|||
assert "sk-unsloth" not in content_line # key stays in the private file
|
||||
|
||||
|
||||
def test_opencode_inline_config_omits_permissions_without_yolo(fake_studio):
|
||||
def test_opencode_inline_config_omits_permission_without_yolo(fake_studio):
|
||||
# A non-yolo session carries no permission inline. OPENCODE_CONFIG_CONTENT outranks the
|
||||
# project opencode.json we cannot read, so forcing any value there would override the
|
||||
# user's project rules; clearing our own config is the fix, and the inline pins the model.
|
||||
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
content_line = next(
|
||||
|
|
@ -455,7 +458,8 @@ def test_opencode_inline_config_omits_permissions_without_yolo(fake_studio):
|
|||
inline = json.loads(
|
||||
shlex.split(content_line.removeprefix("export OPENCODE_CONFIG_CONTENT="))[0]
|
||||
)
|
||||
assert inline == {"model": f"unsloth/{MODEL['id']}"}
|
||||
assert inline["model"] == f"unsloth/{MODEL['id']}"
|
||||
assert "permission" not in inline
|
||||
|
||||
|
||||
def test_https_loopback_never_auto_serves(fake_studio, monkeypatch):
|
||||
|
|
@ -1676,9 +1680,31 @@ def test_no_yolo_opencode_has_no_permission_block(fake_studio, tmp_path):
|
|||
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
|
||||
# A non-yolo run on a fresh config writes no permission block; it only flips a prior
|
||||
# --yolo run's explicit allow back to ask (see the yolo-then-plain test below).
|
||||
assert "permission" not in config
|
||||
|
||||
|
||||
def test_no_yolo_opencode_flips_prior_yolo_allow_to_ask(fake_studio, tmp_path):
|
||||
# The core reset: a --yolo run wrote explicit per-tool allow; a later non-yolo run
|
||||
# must flip exactly those back to ask so nothing stays auto-approved.
|
||||
yolo = CliRunner().invoke(start.start_app, ["opencode", "--yolo", "--no-launch"])
|
||||
assert yolo.exit_code == 0, yolo.output
|
||||
config_path = tmp_path / "agents" / "opencode" / "opencode.json"
|
||||
assert json.loads(config_path.read_text())["permission"] == {
|
||||
"edit": "allow",
|
||||
"bash": "allow",
|
||||
"webfetch": "allow",
|
||||
}
|
||||
plain = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
|
||||
assert plain.exit_code == 0, plain.output
|
||||
assert json.loads(config_path.read_text())["permission"] == {
|
||||
"edit": "ask",
|
||||
"bash": "ask",
|
||||
"webfetch": "ask",
|
||||
}
|
||||
|
||||
|
||||
def test_yolo_openclaw_writes_exec_policy(fake_studio, tmp_path):
|
||||
result = CliRunner().invoke(start.start_app, ["openclaw", "--yolo", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
|
@ -1691,12 +1717,17 @@ def test_yolo_openclaw_writes_exec_policy(fake_studio, tmp_path):
|
|||
assert approvals["defaults"] == {"security": "full", "ask": "off", "askFallback": "full"}
|
||||
|
||||
|
||||
def test_no_yolo_openclaw_has_no_exec_policy(fake_studio, tmp_path):
|
||||
def test_no_yolo_openclaw_leaves_fresh_config_untouched(fake_studio, tmp_path):
|
||||
# A fresh non-yolo run only undoes state a prior --yolo wrote; with no yolo
|
||||
# fingerprint present it must not synthesize an exec policy. An omitted policy can
|
||||
# resolve to a sandbox default of security=deny, so writing allowlist here would
|
||||
# BROADEN it. The reset is scoped to the exact yolo write, verified by the
|
||||
# yolo-then-plain round trip below.
|
||||
result = CliRunner().invoke(start.start_app, ["openclaw", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
state = tmp_path / "agents" / "openclaw"
|
||||
config = json.loads((state / "openclaw.json").read_text())
|
||||
assert "exec" not in config.get("tools", {}) # no auto-approve policy without --yolo
|
||||
assert "exec" not in config.get("tools", {})
|
||||
assert not (state / "exec-approvals.json").exists()
|
||||
|
||||
|
||||
|
|
@ -1719,6 +1750,260 @@ def test_write_openclaw_config_yolo_unit(tmp_path):
|
|||
}
|
||||
|
||||
|
||||
def test_no_launch_rerun_clears_stale_opencode_yolo_permissions(fake_studio, tmp_path):
|
||||
# The no-launch config dir is reused across runs, so a --yolo run persists its
|
||||
# auto-approve settings; a later run without --yolo must strip them, not leave
|
||||
# tool execution silently pre-approved.
|
||||
yolo = CliRunner().invoke(start.start_app, ["opencode", "--yolo", "--no-launch"])
|
||||
assert yolo.exit_code == 0, yolo.output
|
||||
config_path = tmp_path / "agents" / "opencode" / "opencode.json"
|
||||
assert "permission" in json.loads(config_path.read_text())
|
||||
plain = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
|
||||
assert plain.exit_code == 0, plain.output
|
||||
config = json.loads(config_path.read_text())
|
||||
# The yolo allow policy is replaced by a prompting one, not deleted (which would
|
||||
# revert to OpenCode's permissive "allow" default).
|
||||
assert config["permission"] == {"edit": "ask", "bash": "ask", "webfetch": "ask"}
|
||||
# The session provider survives the cleanup.
|
||||
assert "unsloth" in config["provider"]
|
||||
|
||||
|
||||
def test_no_launch_rerun_clears_stale_openclaw_yolo_state(fake_studio, tmp_path):
|
||||
yolo = CliRunner().invoke(start.start_app, ["openclaw", "--yolo", "--no-launch"])
|
||||
assert yolo.exit_code == 0, yolo.output
|
||||
state = tmp_path / "agents" / "openclaw"
|
||||
assert (state / "exec-approvals.json").exists()
|
||||
plain = CliRunner().invoke(start.start_app, ["openclaw", "--no-launch"])
|
||||
assert plain.exit_code == 0, plain.output
|
||||
config = json.loads((state / "openclaw.json").read_text())
|
||||
# The yolo policy is replaced by a prompting one, not deleted (which would revert
|
||||
# to OpenClaw's permissive default), and the yolo approvals file is gone.
|
||||
assert config["tools"]["exec"] == {"security": "allowlist", "ask": "on-miss"}
|
||||
assert not (state / "exec-approvals.json").exists()
|
||||
# The session provider survives the cleanup.
|
||||
assert "unsloth" in config["models"]["providers"]
|
||||
|
||||
|
||||
def test_write_openclaw_config_yolo_then_plain_unit(tmp_path):
|
||||
path = tmp_path / "openclaw.json"
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
# A plain rerun replaces the yolo policy with a prompting one (deleting it would
|
||||
# fall back to OpenClaw's permissive default) and removes the yolo approvals file.
|
||||
assert config["tools"]["exec"] == {"security": "allowlist", "ask": "on-miss"}
|
||||
assert not (path.parent / "exec-approvals.json").exists()
|
||||
|
||||
|
||||
def test_write_opencode_config_yolo_then_plain_unit(tmp_path):
|
||||
path = tmp_path / "opencode.json"
|
||||
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
|
||||
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
# A plain rerun replaces the yolo allow policy with a prompting one.
|
||||
assert config["permission"] == {"edit": "ask", "bash": "ask", "webfetch": "ask"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_keeps_runtime_approvals(tmp_path):
|
||||
# OpenClaw records its own entries in exec-approvals.json (OPENCLAW_STATE_DIR is
|
||||
# this dir); the non-yolo reset drops only the yolo defaults, not those.
|
||||
path = tmp_path / "openclaw.json"
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
|
||||
approvals = path.parent / "exec-approvals.json"
|
||||
state = json.loads(approvals.read_text())
|
||||
state["agents"] = {"main": {"allowlist": ["git status"]}}
|
||||
approvals.write_text(json.dumps(state))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
remaining = json.loads(approvals.read_text())
|
||||
assert "defaults" not in remaining
|
||||
assert remaining["agents"] == {"main": {"allowlist": ["git status"]}}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_keeps_mixed_approval_defaults(tmp_path):
|
||||
# A mixed user-managed defaults block that only shares a field with the yolo payload
|
||||
# (here askFallback=full, whose omitted default is deny) is not stale yolo state, so a
|
||||
# non-yolo run leaves it intact rather than stripping the shared field.
|
||||
path = tmp_path / "openclaw.json"
|
||||
approvals = path.parent / "exec-approvals.json"
|
||||
mixed = {
|
||||
"version": 1,
|
||||
"defaults": {"security": "allowlist", "ask": "on-miss", "askFallback": "full"},
|
||||
}
|
||||
approvals.write_text(json.dumps(mixed))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
assert json.loads(approvals.read_text()) == mixed
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_leaves_partial_policy_untouched(tmp_path):
|
||||
# A policy that lacks the full yolo fingerprint (here no host and no security) is not
|
||||
# our --yolo write, so a non-yolo run leaves it as-is rather than assuming ask=off
|
||||
# means permissive: an omitted host/security can resolve to a sandbox deny default.
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": {"timeout": 30, "ask": "off"}}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"timeout": 30, "ask": "off"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_leaves_no_permissive_values(tmp_path):
|
||||
# The whole point of the reset: after a yolo run, a plain run must leave neither the
|
||||
# config nor the approvals file at OpenClaw's permissive (security=full, ask=off)
|
||||
# default, or exec still auto-approves.
|
||||
path = tmp_path / "openclaw.json"
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
exec_policy = json.loads(path.read_text())["tools"]["exec"]
|
||||
assert exec_policy.get("security") != "full"
|
||||
assert exec_policy.get("ask") != "off"
|
||||
assert not (path.parent / "exec-approvals.json").exists()
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_preserves_stricter_exec_policy(tmp_path):
|
||||
# A policy that doesn't carry the yolo values (for example stricter security or
|
||||
# prompting turned on) was not written by --yolo and must survive a plain run.
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": {"security": "deny", "ask": "on"}}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"security": "deny", "ask": "on"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_preserves_stricter_approval_defaults(tmp_path):
|
||||
# exec-approvals.json defaults that don't match the yolo payload (stricter
|
||||
# settings from the user or the OpenClaw UI) are kept, and the file stays.
|
||||
path = tmp_path / "openclaw.json"
|
||||
approvals = path.parent / "exec-approvals.json"
|
||||
approvals.write_text(
|
||||
json.dumps({"version": 1, "defaults": {"security": "allowlist", "ask": "on"}})
|
||||
)
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
state = json.loads(approvals.read_text())
|
||||
assert state["defaults"] == {"security": "allowlist", "ask": "on"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_leaves_unparseable_approvals(tmp_path):
|
||||
# An unreadable approvals file is left in place rather than deleted, matching
|
||||
# how an unparseable config is handled.
|
||||
path = tmp_path / "openclaw.json"
|
||||
approvals = path.parent / "exec-approvals.json"
|
||||
approvals.write_text("{not json")
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
assert approvals.read_text() == "{not json"
|
||||
|
||||
|
||||
def test_opencode_non_yolo_flips_only_explicit_allow(tmp_path):
|
||||
# Only a tool explicitly set to "allow" (what --yolo writes) is flipped to "ask". A
|
||||
# deny/ask a user set is kept, and an absent tool is not added.
|
||||
path = tmp_path / "opencode.json"
|
||||
path.write_text(json.dumps({"permission": {"edit": "allow", "bash": "deny", "read": "ask"}}))
|
||||
session = start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["permission"] == {"edit": "ask", "bash": "deny", "read": "ask"}
|
||||
assert session == {} # a non-yolo session carries no permission inline
|
||||
|
||||
|
||||
def test_opencode_non_yolo_leaves_string_permission(tmp_path):
|
||||
# A global string rule ("deny") is a user-managed catch-all; leave it untouched and
|
||||
# carry no inline override.
|
||||
path = tmp_path / "opencode.json"
|
||||
path.write_text(json.dumps({"permission": "deny"}))
|
||||
session = start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
assert json.loads(path.read_text())["permission"] == "deny"
|
||||
assert session == {}
|
||||
|
||||
|
||||
def test_opencode_non_yolo_leaves_catch_all_and_flips_explicit_allow(tmp_path):
|
||||
# A "*" catch-all is the user's own rule, never something --yolo writes (yolo sets
|
||||
# explicit per-tool allow), so it is left intact; an explicit per-tool "allow" is still
|
||||
# flipped to "ask", but an absent tool inheriting the catch-all is not touched.
|
||||
path = tmp_path / "opencode.json"
|
||||
path.write_text(json.dumps({"permission": {"*": "allow", "bash": "allow"}}))
|
||||
session = start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
assert json.loads(path.read_text())["permission"] == {"*": "allow", "bash": "ask"}
|
||||
assert session == {}
|
||||
|
||||
|
||||
def test_opencode_non_yolo_leaves_granular_object(tmp_path):
|
||||
# A granular object value is a user rule (yolo only ever writes a plain "allow" string),
|
||||
# so it is left in the file verbatim and never carried inline.
|
||||
path = tmp_path / "opencode.json"
|
||||
obj = {"read *": "deny", "git *": "ask"}
|
||||
path.write_text(json.dumps({"permission": {"bash": dict(obj)}}))
|
||||
session = start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
assert json.loads(path.read_text())["permission"]["bash"] == obj
|
||||
assert session == {}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_leaves_mode_policy(tmp_path):
|
||||
# tools.exec.mode is OpenClaw's normalized knob and cannot be combined with explicit
|
||||
# security/ask (the config is rejected), so a mode-based policy must be left as-is.
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": {"mode": "deny"}}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"mode": "deny"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_preserves_sandbox_host(tmp_path):
|
||||
# host=sandbox defaults to security=deny (stricter than the gateway "full" default),
|
||||
# so a non-yolo run must not treat the missing security as permissive nor pop host
|
||||
# (which would broaden routing to the gateway).
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": {"host": "sandbox"}}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"host": "sandbox"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_preserves_node_host(tmp_path):
|
||||
# host=node routes to a paired node and is only ever set by the user (--yolo writes
|
||||
# host=gateway), so a non-yolo run must not pop it and reroute to the gateway.
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": {"host": "node"}}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"host": "node"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_preserves_auto_host_permissive(tmp_path):
|
||||
# host=auto (or omitted) with security=full/ask=off is NOT the --yolo write: under an
|
||||
# active sandbox, auto resolves to security=deny. --yolo only ever writes host=gateway,
|
||||
# so the reset must not treat auto/None as the permissive gateway default and broaden a
|
||||
# sandboxed deny to allowlist.
|
||||
for exec_policy in (
|
||||
{"host": "auto", "security": "full", "ask": "off"},
|
||||
{"security": "full", "ask": "off"},
|
||||
):
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": dict(exec_policy)}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == exec_policy
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_resets_only_gateway_yolo_fingerprint(tmp_path):
|
||||
# The reset fires on exactly the host=gateway + security=full + ask=off write --yolo
|
||||
# makes, and nothing else.
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(
|
||||
json.dumps({"tools": {"exec": {"host": "gateway", "security": "full", "ask": "off"}}})
|
||||
)
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"security": "allowlist", "ask": "on-miss"}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_preserves_full_mode(tmp_path):
|
||||
# OpenClaw never normalizes our security=full/ask=off yolo write into mode:"full"
|
||||
# (verified against the binary: doctor --fix and config get leave security/ask as-is),
|
||||
# so a mode:"full" is always a deliberate user policy, not stale yolo state; leave it.
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(json.dumps({"tools": {"exec": {"mode": "full"}}}))
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["tools"]["exec"] == {"mode": "full"}
|
||||
|
||||
|
||||
def test_yolo_command_flags_unmapped_agent_is_empty():
|
||||
# Config-based agents (and any typo) must yield no flag, not a KeyError.
|
||||
assert start._yolo_command_flags("opencode", True) == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue