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
53 changes: 53 additions & 0 deletions examples/claude-goalmode-hook-policy-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,59 @@ def capture_health_failure(command, **_kwargs):
assert decision("Bash", True, command="make test") == "allow", "non-destructive bash allowed"
assert decision("Read", True, file_path="/anywhere") == "allow", "read-only allowed"

# An event with no session cwd (what an unreadable event becomes in main)
# cannot be attributed to an armed project, so the gate must refuse it
# rather than defer: rc=1 with no decision is a non-blocking hook error to
# Claude Code, which then runs the tool with no gate at all.
assert goal_policy.decide({}).get("hookSpecificOutput", {}).get("permissionDecision") == "deny", \
"a cwd-less/unreadable event must be DENIED, not deferred"

# A non-read-only tool whose tool_input is not an object is unreadable the
# same way: decide must refuse it rather than crash on `ti.get` (rc=1 with
# no decision, which Claude Code reports and then runs the tool ungated).
goal_policy.active_context = lambda cwd: CTX
goal_policy.should_run = lambda *a, **k: True
non_object_input = goal_policy.decide(
{"cwd": "/proj", "tool_name": "Bash", "tool_input": "not-an-object"})
assert non_object_input.get("hookSpecificOutput", {}).get("permissionDecision") == "deny", \
"a non-object tool_input must be DENIED, not crash the gate (rc=1 fail-open)"
assert goal_policy.decide(
{"cwd": "/proj", "tool_name": "Read", "tool_input": "not-an-object"}
).get("hookSpecificOutput", {}).get("permissionDecision") == "allow", \
"read-only tools keep their unconditional allow"

# A decoded cwd can still be unusable as a path: an embedded NUL raises
# ValueError inside active_context, which would escape decide as the same
# rc=1/no-decision fail-open. The gate must refuse it instead.
assert goal_policy.decide(
{"cwd": "/bad\u0000cwd", "tool_name": "Bash", "tool_input": {"command": "ls"}}
).get("hookSpecificOutput", {}).get("permissionDecision") == "deny", \
"a session cwd that cannot be resolved as a path must be DENIED"

# `tool_input` is type-checked before any default: only absent/null gets {};
# a present falsy non-object (`[]`, `false`, `0`, `""`) is unreadable and
# refused, so an open gate cannot allow it as if it carried no input.
for falsy_input in ([], False, 0, ""):
assert goal_policy.decide(
{"cwd": "/proj", "tool_name": "Bash", "tool_input": falsy_input}
).get("hookSpecificOutput", {}).get("permissionDecision") == "deny", \
f"falsy non-object tool_input {falsy_input!r} must be DENIED, not coerced"
assert goal_policy.decide(
{"cwd": "/proj", "tool_name": "Bash"}
).get("hookSpecificOutput", {}).get("permissionDecision") == "allow", \
"an absent tool_input keeps the documented empty-input default"

# A decoded value the gate cannot use is refused the same way instead of
# crashing on `.lower()` / set membership or silently matching the scope.
assert goal_policy.decide(
{"cwd": "/proj", "tool_name": ["Bash"], "tool_input": {}}
).get("hookSpecificOutput", {}).get("permissionDecision") == "deny", \
"a non-string tool_name must be DENIED, not crash the gate"
assert goal_policy.decide(
{"cwd": "/proj", "tool_name": "Bash", "tool_input": {"command": ["rm", "-rf", "/"]}}
).get("hookSpecificOutput", {}).get("permissionDecision") == "deny", \
"a non-string command must be DENIED, not crash the gate"

print("claude-goalmode-hook-policy-smoke ok")
return 0

Expand Down
6 changes: 6 additions & 0 deletions loopx/claude_goal_mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ armed, for each tool call it:
before consulting the gate;
- otherwise consults `should_run` and **denies** the (non-read-only) tool when
`should_run` is `false`, or when the probe is unreachable (fail-closed);
- **denies** an event it cannot read at all (undecodable stdin, malformed or
non-object JSON, a missing or unusable session `cwd`, a missing/non-string
tool name, or - for a non-read-only tool - a non-object `tool_input` or a
`command` / `file_path` / `notebook_path` field that is present but not a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2, non-blocking] Clarify the allowed null/default exception. This wording says a present non-string command/file field is refused, but decide explicitly skips type refusal for None and retains its existing empty default. A real armed hook with command:null and an open unchanged quota answer returns allow in the original baseline, previous head and current head. Please say present non-null/non-string and describe the permitted absent/null defaults; keep the code behavior and legal-path regression. This is documentation precision, not a blocker or a request to widen enforcement.

string) rather than exiting with no decision, which Claude Code reports as a
non-blocking hook error and follows with an ungated tool call;
- when `should_run` is `true`: **allows** `Edit` / `Write` whose path is inside
`write_scope` and **denies** them outside it; gates `Bash` only by a
**denylist** of destructive commands (everything else is allowed); and **defers
Expand Down
110 changes: 97 additions & 13 deletions loopx/claude_goal_mode/hooks/goal_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@

Behavior (only while goal-mode is ARMED for the event's project):
- no goal here / goal-mode off -> {} (no-op; normal permission flow)
- event unreadable -> deny (FAIL-CLOSED)
(no usable session cwd, no usable tool_name)
- read-only/safe tools -> allow
- non-read-only tool, unreadable event value -> deny (FAIL-CLOSED)
(non-object tool_input, non-string command/file path)
- should_run == false (quota/gate closed) -> deny
- should_run probe unavailable (error) -> deny for non-read-only tools (FAIL-CLOSED)
- should_run == true:
Expand All @@ -31,6 +35,13 @@
Fail-closed: if the deterministic ``should_run`` probe cannot be reached, a
non-read-only tool is denied rather than allowed, so a flaky/broken control
plane pauses delivery instead of running ungated. Read-only tools stay allowed.
An event this hook cannot read joins that rule: ``rc=1`` with no decision is a
non-blocking hook error to Claude Code, which then continues the tool call with
no gate at all, so an unreadable event (undecodable stdin, malformed or
non-object JSON, no session cwd or one that cannot be resolved as a path, no
usable tool name, or - for a non-read-only tool - a non-object ``tool_input`` or
a non-string command/file path field) is answered with an explicit deny instead
of dying. Read-only tools keep their unconditional allow.

Identity-aware: when the active goal registered an agent (loopx
``coordination.registered_agents``), the context carries ``agent_id`` and this
Expand Down Expand Up @@ -83,6 +94,22 @@ def within(path: str, root: str) -> bool:
return False


def _usable_cwd(value: str) -> bool:
"""True if a decoded session cwd can be resolved as a filesystem path.

A string can still be unusable as a path: an embedded NUL makes
``Path.resolve`` raise ``ValueError``, and a lone surrogate raises
``UnicodeEncodeError`` (a ``ValueError``) against the filesystem encoding.
``active_context`` must never receive such a value, or the exception escapes
``decide`` as the rc=1/no-decision hook error the module docstring refuses.
"""
try:
Path(value).resolve()
except (OSError, RuntimeError, ValueError):
return False
return True


def _gh_prefix():
_exe = shutil.which("loopx")
return [_exe] if _exe else [__import__("sys").executable, "-m", "loopx.cli"]
Expand Down Expand Up @@ -152,11 +179,38 @@ def decide(ev: dict) -> dict:
(or {} for no-op). Reused by the CLI hook AND the Agent SDK in-process hook.

The hook is the whole gate (no OS sandbox): should_run (fail-closed) +
Edit/Write write_scope + a destructive-Bash denylist."""
Edit/Write write_scope + a destructive-Bash denylist. An event value this
gate cannot use - no usable session cwd or tool name, or a non-object
tool_input / non-string command or file path on a non-read-only tool - is
refused rather than deferred, because the event cannot be classified (the
module docstring states the fail-closed rule)."""
def d(decision, reason):
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason}}

# Claude Code feeds the session's working directory on the event; resolve the
# goal from THAT project's registry, and gate only while goal-mode is armed
# (the project's .claude/loop.md exists). Unrelated/unarmed projects -> no-op.
cwd = ev.get("cwd") or (ev.get("workspace") or {}).get("current_dir")
cwd = ev.get("cwd")
workspace = ev.get("workspace")
if not isinstance(cwd, str) or not cwd:
cwd = workspace.get("current_dir") if isinstance(workspace, dict) else None
if not isinstance(cwd, str) or not cwd:
Comment thread
loopx-agent marked this conversation as resolved.
# Every real PreToolUse event carries the session cwd. Without one this
# hook cannot tell an armed project from an unrelated one, so it cannot
# decide to stay out of the way: refusing is the same fail-closed rule
# the unreachable-should_run branch below applies. Unreadable events
# (see main) arrive here as an empty dict.
return d("deny", "unreadable PreToolUse event (no session cwd) - failing closed under goal-mode")
if not _usable_cwd(cwd):
# A decoded cwd can still be unusable as a path (embedded NUL, lone
# surrogate, OS-level resolution failure). active_context cannot
# attribute the event to a project then, so the event stays unreadable
# for this gate and is refused instead of raising out of decide.
return d("deny", "unreadable PreToolUse event (session cwd is not a usable path) - failing closed under goal-mode")

ctx = active_context(cwd)
if not ctx:
return {} # no goal here, or goal-mode off -> defer to normal flow
Expand All @@ -165,18 +219,43 @@ def decide(ev: dict) -> dict:
registry = ctx.get("registry")
agent_id = ctx.get("agent_id")
scope = ctx.get("write_scope") or []
tool = ev.get("tool_name", "")
ti = ev.get("tool_input", {}) or {}

def d(decision, reason):
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason}}
tool = ev.get("tool_name")
if not isinstance(tool, str) or not tool:
# Every real PreToolUse event names its tool; without a usable name the
# gate cannot apply its read-only/gated classification (and the set
# membership tests below would raise on an unhashable value).
return d("deny", "unreadable PreToolUse event (tool_name is not a usable string) - failing closed under goal-mode")
ti = ev.get("tool_input")
if ti is None:
# Only an absent/null field takes the empty-input default. Other falsy
# values (`[]`, `false`, `0`, `""`) are present non-objects and must
# stay visible to the check below instead of being hidden by `or {}`.
ti = {}

if tool in READONLY_TOOLS:
return d("allow", "read-only/safe tool under goal-mode")

if not isinstance(ti, dict):
# A tool_input that is present but not an object cannot be read: refuse
# the non-read-only tool instead of crashing on `ti.get` (same
# fail-closed rule as an unreachable `should_run` probe).
return d("deny", "unreadable PreToolUse event (tool_input is not an object) - failing closed under goal-mode")

# Every value the gate reads below must be readable before the probe is
# consulted, so an unreadable event is refused whether or not the gate is
# open (an absent/null field keeps its empty default).
if tool in WRITE_TOOLS:
file_path = ti.get("file_path")
notebook_path = ti.get("notebook_path")
if file_path is not None and not isinstance(file_path, str):
return d("deny", "unreadable PreToolUse event (tool_input.file_path is not a string) - failing closed under goal-mode")
if notebook_path is not None and not isinstance(notebook_path, str):
return d("deny", "unreadable PreToolUse event (tool_input.notebook_path is not a string) - failing closed under goal-mode")
elif tool == "Bash":
command = ti.get("command")
if command is not None and not isinstance(command, str):
return d("deny", "unreadable PreToolUse event (tool_input.command is not a string) - failing closed under goal-mode")

sr = should_run(registry, goal_id, agent_id) # True / False / None
if sr is False:
return d("deny", f"goal '{goal_id}' should_run=false (quota/gate closed)")
Expand All @@ -200,10 +279,15 @@ def d(decision, reason):


def main():
raw = sys.stdin.read() or "{}"
try:
ev = json.loads(raw)
except Exception:
# `ValueError` also covers `UnicodeDecodeError`: the strict stdin pin
# (stdio_utf8) never repairs a bad byte into a different event, so an
# event that cannot be read becomes the explicit deny in decide()
# instead of a traceback with no decision.
ev = json.loads(sys.stdin.read())
except ValueError:
ev = {}
if not isinstance(ev, dict):
ev = {}
result = decide(ev)
sys.stdout.write(json.dumps(result) if result else "{}")
Expand Down
Loading
Loading