lonca-policy 0.2.2

Lonca's deterministic decision layer: the pure decide() function, state replay, Cedar policies and human keys
Documentation
# What Lonca knows about each agent harness, as data. A repository adds or
# replaces one under `[harnesses.<name>]` in lonca.toml, with a signed policy
# update: a new harness needs no new Lonca. See docs/guide/runners.md.

[claude-code]
# `before_tool_mcp`: PreToolUse fires for MCP tools too (matcher "*"), seen in
# a run; see "A session whose hooks do not run (#190)" in docs/decisions.md.
events = { session_start = "SessionStart", before_tool = "PreToolUse", after_tool = "PostToolUse", stop = "Stop", before_tool_mcp = true }
deny = { hookSpecificOutput = { hookEventName = "PreToolUse", permissionDecision = "deny", permissionDecisionReason = "{reason}" } }
context = { hookSpecificOutput = { hookEventName = "SessionStart", additionalContext = "{context}" } }
tool_context = { hookSpecificOutput = { hookEventName = "PostToolUse", additionalContext = "{context}" } }
stop_deny = { decision = "block", reason = "{reason}" }

[claude-code.setup]
flags = ["--mcp-config", { file = ".mcp.json" }, "--settings", { file = ".claude/settings.local.json" }]

# Claude Code makes /tmp/claude-<uid> whatever TMPDIR says, and exits when the
# sandbox keeps it out of /tmp. Its settings go to $HOME/.claude, the run's own.
[claude-code.setup.run_env]
CLAUDE_CODE_TMPDIR = "{tmp}"

[[claude-code.setup.files]]
path = ".mcp.json"
format = "json"
content = { mcpServers = { lonca = { command = "lonca", args = ["mcp"], env = { LONCA_SOCKET = "{socket}", LONCA_WORKSPACE = "{workspace}" } } } }

[[claude-code.setup.files]]
path = ".claude/settings.local.json"
format = "json"

[claude-code.setup.files.content]
enabledMcpjsonServers = ["lonca"]
env = { LONCA_SOCKET = "{socket}" }
permissions = { allow = ["mcp__lonca__*"] }

[claude-code.setup.files.content.hooks]
SessionStart = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook", onFailure = "block" }] }]
PreToolUse = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook", onFailure = "block" }] }]
PostToolUse = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook", onFailure = "block" }] }]
# Never "block": a failure here must end the turn, not keep it going.
Stop = [{ hooks = [{ type = "command", command = "lonca hook --fail-open" }] }]

# What makes a session on this harness `hooks` or `hooks_sandbox`. A harness
# gets this table once a run has shown that its hooks hold: see "Harness
# levels" in docs/decisions.md.
[claude-code.locked]
file = { macos = "/Library/Application Support/ClaudeCode/managed-settings.json", linux = "/etc/claude-code/managed-settings.json" }
format = "json"
hook_lists = ["/hooks/PreToolUse", "/hooks/PostToolUse"]
hook_fields = { onFailure = "block" }
hooks = [{ at = "/allowManagedHooksOnly", equals = true }]
sandbox = [
  { at = "/sandbox/enabled", equals = true },
  { at = "/sandbox/failIfUnavailable", equals = true },
  { at = "/sandbox/allowUnsandboxedCommands", equals = false },
  { at = "/sandbox/network/httpProxyPort", is = "proxy_port" },
  { at = "/sandbox/excludedCommands", is = "empty" },
]

[claude-code.tools]
Read = { kind = "read", fields = ["file_path"] }
NotebookRead = { kind = "read", fields = ["notebook_path"] }
Glob = { kind = "read", fields = ["path"] }
Grep = { kind = "read", fields = ["path"] }
LS = { kind = "read", fields = ["path"] }
Write = { kind = "write", fields = ["file_path"] }
Edit = { kind = "write", fields = ["file_path"] }
MultiEdit = { kind = "write", fields = ["file_path"] }
NotebookEdit = { kind = "write", fields = ["notebook_path"] }
Bash = { kind = "shell", fields = ["command"] }
PowerShell = { kind = "shell", fields = ["command"] }
WebFetch = { kind = "fetch", fields = ["url"] }
WebSearch = { kind = "search" }
Agent = { kind = "inert" }
AskUserQuestion = { kind = "inert" }
EnterPlanMode = { kind = "inert" }
ExitPlanMode = { kind = "inert" }
Skill = { kind = "inert" }
Task = { kind = "inert" }
TaskCreate = { kind = "inert" }
TaskGet = { kind = "inert" }
TaskList = { kind = "inert" }
TaskOutput = { kind = "inert" }
TaskStop = { kind = "inert" }
TaskUpdate = { kind = "inert" }
TodoWrite = { kind = "inert" }
ToolSearch = { kind = "inert" }

[codex]
events = { session_start = "SessionStart", before_tool = "PreToolUse", after_tool = "PostToolUse", stop = "Stop" }
deny = { hookSpecificOutput = { hookEventName = "PreToolUse", permissionDecision = "deny", permissionDecisionReason = "{reason}" } }
context = { hookSpecificOutput = { hookEventName = "SessionStart", additionalContext = "{context}" } }
tool_context = { hookSpecificOutput = { hookEventName = "PostToolUse", additionalContext = "{context}" } }
stop_deny = { decision = "block", reason = "{reason}" }

# Codex skips a project's .codex/ until a person trusts the directory, so a
# file in a new workspace does nothing: everything goes in as flags.
[codex.setup]
note = "Codex runs hooks only after a person reviews them with /hooks, or with --dangerously-bypass-hook-trust, which also runs any other hook you have unreviewed. Without them the rules and the MCP server still work."
flags = [
  "-c", "mcp_servers.lonca.command=lonca",
  "-c", "mcp_servers.lonca.args=[\"mcp\"]",
  "-c", "mcp_servers.lonca.cwd={workspace}",
  "-c", "mcp_servers.lonca.env.LONCA_SOCKET={socket}",
  "-c", "mcp_servers.lonca.env.LONCA_WORKSPACE={workspace}",
  "-c", "mcp_servers.lonca.default_tools_approval_mode=approve",
  "-c", "developer_instructions={rules}",
  "-c", "hooks.SessionStart=[{hooks=[{type=\"command\",command=\"lonca hook --harness codex\"}]}]",
  "-c", "hooks.PreToolUse=[{hooks=[{type=\"command\",command=\"lonca hook --harness codex\"}]}]",
  "-c", "hooks.PostToolUse=[{hooks=[{type=\"command\",command=\"lonca hook --harness codex\"}]}]",
  "-c", "hooks.Stop=[{hooks=[{type=\"command\",command=\"lonca hook --harness codex --fail-open\"}]}]",
]

[codex.setup.env]
LONCA_SOCKET = "{socket}"

[codex.tools]
Bash = { kind = "shell", fields = ["command"] }
apply_patch = { kind = "write", fields = ["command"], headers = ["*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "] }
Write = { kind = "write", fields = ["command"], headers = ["*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "] }
Edit = { kind = "write", fields = ["command"], headers = ["*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "] }
view_image = { kind = "read", fields = ["path"] }
spawn_agent = { kind = "inert" }
update_plan = { kind = "inert" }

[gemini]
events = { session_start = "SessionStart", before_tool = "BeforeTool", after_tool = "AfterTool", stop = "AfterAgent" }
deny = { decision = "deny", reason = "{reason}" }
context = { hookSpecificOutput = { hookEventName = "SessionStart", additionalContext = "{context}" } }
tool_context = { hookSpecificOutput = { hookEventName = "AfterTool", additionalContext = "{context}" } }
stop_deny = { decision = "deny", reason = "{reason}" }
mcp_prefix = "mcp_"
mcp_separator = "_"

[gemini.setup.env]
LONCA_SOCKET = "{socket}"
GEMINI_CLI_TRUST_WORKSPACE = "true"

[[gemini.setup.files]]
path = ".gemini/settings.json"
format = "json"

[gemini.setup.files.content]
mcpServers = { lonca = { command = "lonca", args = ["mcp"], cwd = "{workspace}", env = { LONCA_SOCKET = "{socket}", LONCA_WORKSPACE = "{workspace}" }, trust = true } }

[gemini.setup.files.content.hooks]
SessionStart = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook --harness gemini" }] }]
BeforeTool = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook --harness gemini" }] }]
AfterTool = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook --harness gemini" }] }]
AfterAgent = [{ matcher = "*", hooks = [{ type = "command", command = "lonca hook --harness gemini --fail-open" }] }]

[gemini.tools]
read_file = { kind = "read", fields = ["file_path"] }
read_many_files = { kind = "read", fields = ["include"] }
list_directory = { kind = "read", fields = ["dir_path"] }
glob = { kind = "read", fields = ["dir_path"] }
grep_search = { kind = "read", fields = ["dir_path"] }
write_file = { kind = "write", fields = ["file_path"] }
replace = { kind = "write", fields = ["file_path"] }
run_shell_command = { kind = "shell", fields = ["command"] }
web_fetch = { kind = "fetch", fields = ["url", "prompt"] }
google_web_search = { kind = "search" }
activate_skill = { kind = "inert" }
ask_user = { kind = "inert" }
enter_plan_mode = { kind = "inert" }
exit_plan_mode = { kind = "inert" }
write_todos = { kind = "inert" }

[opencode]
events = { session_start = "session_start", before_tool = "before_tool", after_tool = "after_tool", stop = "stop" }
deny = { deny = "{reason}" }
context = { context = "{context}" }
stop_deny = { resume = "{reason}" }
mcp_prefix = ""
mcp_separator = "_"

[opencode.tools]
read = { kind = "read", fields = ["path", "filePath"] }
list = { kind = "read", fields = ["path"] }
glob = { kind = "read", fields = ["path"] }
grep = { kind = "read", fields = ["path"] }
write = { kind = "write", fields = ["path", "filePath"] }
edit = { kind = "write", fields = ["path", "filePath"] }
apply_patch = { kind = "write", fields = ["patchText"], headers = ["*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "] }
patch = { kind = "write", fields = ["patchText"], headers = ["*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "] }
bash = { kind = "shell", fields = ["command"] }
shell = { kind = "shell", fields = ["command"] }
webfetch = { kind = "fetch", fields = ["url"] }
websearch = { kind = "search" }
codesearch = { kind = "search" }
task = { kind = "inert" }
subagent = { kind = "inert" }
todowrite = { kind = "inert" }
todoread = { kind = "inert" }
skill = { kind = "inert" }
question = { kind = "inert" }

# OpenCode has no command hooks: the plugin in setup calls `lonca hook`, with
# event names of our own. Its `execute` tool runs code, so it is left out of the
# table below: it taints, and a person approves what the session proposes.
[opencode.setup]
note = "OpenCode's execute tool runs code and is not described here, so a session that uses it is tainted and its changes need a person's approval. To decide otherwise, add it to [taint] safe_tools or to a profile of your own. `opencode run` waits for the end of its stdin before it starts: give it </dev/null."

[opencode.setup.env]
LONCA_SOCKET = "{socket}"

[[opencode.setup.files]]
path = "opencode.json"
format = "json"

[opencode.setup.files.content]
instructions = [".opencode/lonca-rules.md"]
permission = { "lonca_*" = "allow" }
mcp = { lonca = { type = "local", command = ["lonca", "mcp"], cwd = "{workspace}", enabled = true, timeout = 120000, environment = { LONCA_SOCKET = "{socket}", LONCA_WORKSPACE = "{workspace}" } } }

[[opencode.setup.files]]
path = ".opencode/lonca-rules.md"
format = "text"
content = "{rules}"

[[opencode.setup.files]]
path = ".opencode/plugins/lonca.js"
format = "text"
content = '''
import { spawnSync } from "node:child_process";

// Bridges OpenCode's plugin hooks to `lonca hook`, which reads them with the
// [opencode] profile. A failure blocks the tool call.
export default {
  id: "lonca",
  async setup(ctx) {
    const directory = ctx.location.directory;
    const hook = (event) => {
      const r = spawnSync("lonca", ["hook", "--harness", "opencode"], {
        cwd: directory,
        input: JSON.stringify({ ...event, cwd: directory }),
        encoding: "utf8",
      });
      if (r.error) throw new Error(`lonca hook: ${r.error.message}`);
      if (r.status !== 0) throw new Error(r.stderr || `lonca hook exited ${r.status}`);
      const out = r.stdout.trim();
      const answer = out ? JSON.parse(out) : {};
      if (answer.deny) throw new Error(answer.deny);
    };
    const started = new Set();
    await ctx.tool.hook("execute.before", (event) => {
      if (!started.has(event.sessionID)) {
        started.add(event.sessionID);
        hook({ hook_event_name: "session_start", session_id: event.sessionID });
      }
      hook({ hook_event_name: "before_tool", tool_name: event.tool, tool_input: event.input, session_id: event.sessionID });
    });
    await ctx.tool.hook("execute.after", (event) => {
      hook({ hook_event_name: "after_tool", tool_name: event.tool, tool_input: event.input, session_id: event.sessionID });
    });
    // What this hook leaves in the session's inbox goes on in the same turn,
    // so `opencode run` stays open. A turn a person interrupted and one that
    // failed do not get here. Only a session that used a tool can hold work, a
    // subagent's is its parent's to deliver, and a session waiting for one has
    // not stopped. A failure ends the turn.
    const stopped = (sessionID) => {
      const r = spawnSync("lonca", ["hook", "--harness", "opencode", "--fail-open"], {
        cwd: directory,
        input: JSON.stringify({ hook_event_name: "stop", session_id: sessionID, cwd: directory }),
        encoding: "utf8",
      });
      const out = (r.stdout ?? "").trim();
      return out ? JSON.parse(out).resume : undefined;
    };
    await ctx.session.hook("experimental.idle", async ({ sessionID, background }) => {
      try {
        if (!started.has(sessionID)) return;
        if (background.some((b) => b.sessionID === sessionID && b.kind !== "shell")) return;
        if ((await ctx.session.get({ sessionID })).parentID) return;
        const text = stopped(sessionID);
        if (text) await ctx.session.synthetic({ sessionID, text, metadata: { source: "lonca" } });
      } catch (e) {
        console.error(`lonca: ${e}`);
      }
    });
  },
};
'''