The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
@@ -0,0 +1,21 @@
|
||||
// Lifted from OpenCode packages/opencode/src/permission/arity.ts (MIT, © 2025 opencode).
|
||||
import SPEC from "../../harness/permission/arity.json"
|
||||
// The human-meaningful prefix of a command, used to build "always allow" patterns: `git commit -m x` → `git commit`.
|
||||
export function prefix(tokens: string[]) {
|
||||
// Options before the subcommand do not count ("options never count", in the table's rules): `git -C dir
|
||||
// commit` is `git commit` — and an "always" for `git -C *` would cover every git command.
|
||||
if (tokens.length > 1 && tokens.slice(1).some((t) => t.startsWith("-")) && ARITY[tokens[0]!] !== undefined && ARITY[tokens[0]!]! > 1 && tokens[1]!.startsWith("-")) return tokens
|
||||
for (let len = tokens.length; len > 0; len--) {
|
||||
const prefix = tokens.slice(0, len).join(" ")
|
||||
const arity = ARITY[prefix]
|
||||
if (arity !== undefined) return tokens.slice(0, arity)
|
||||
}
|
||||
if (tokens.length === 0) return []
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
// The table is the harness spec's (harness/permission/arity.json), shared with LLeMbas. OpenCode
|
||||
// generated it with a prompt; its rules: each entry maps a command prefix to how many words define
|
||||
// the command, options never count, the longest matching prefix wins.
|
||||
const ARITY: Record<string, number> = SPEC.arity
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// A deliberately small shell reader: enough to split a command line into the simple commands
|
||||
// it runs and to know when it cannot be sure. It never needs to be a full parser, because every
|
||||
// case it does not understand falls back to asking.
|
||||
|
||||
export interface Split {
|
||||
/** Each simple command, trimmed, in order. */
|
||||
commands: string[]
|
||||
/** Why an `allow` rule may not be trusted for this line (command substitution, eval, redirection
|
||||
* to a file…). Empty when the split is clean. */
|
||||
unsafe: string[]
|
||||
}
|
||||
|
||||
const SAFE_REDIRECT = /^(\d?>&\d|\d?>\s*\/dev\/null|&>\s*\/dev\/null)$/
|
||||
|
||||
export function splitCommand(line: string): Split {
|
||||
const commands: string[] = []
|
||||
const unsafe = new Set<string>()
|
||||
let cur = ""
|
||||
let quote: "'" | '"' | null = null
|
||||
const flush = () => {
|
||||
const c = cur.trim()
|
||||
if (c) commands.push(c)
|
||||
cur = ""
|
||||
}
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!
|
||||
const next = line[i + 1]
|
||||
if (quote === "'") {
|
||||
cur += ch
|
||||
if (ch === "'") quote = null
|
||||
// A quoted string over several lines is harmless to bash, but it is how a line's real shape
|
||||
// gets hidden from a reader like this one: not trusted.
|
||||
else if (ch === "\n") unsafe.add("a quoted string across lines")
|
||||
continue
|
||||
}
|
||||
if (ch === "\\" && next !== undefined) {
|
||||
cur += ch + next
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (quote === '"') {
|
||||
cur += ch
|
||||
if (ch === '"') quote = null
|
||||
else if (ch === "`" || (ch === "$" && next === "(")) unsafe.add("command substitution")
|
||||
else if (ch === "\n") unsafe.add("a quoted string across lines")
|
||||
continue
|
||||
}
|
||||
// A comment runs to the end of the line, and bash reads nothing in it — a quote in a comment
|
||||
// must not open a quote here (it would hide the next line's command inside one).
|
||||
if (ch === "#" && (cur === "" || /\s$/.test(cur))) {
|
||||
while (i + 1 < line.length && line[i + 1] !== "\n") i++
|
||||
continue
|
||||
}
|
||||
// Quoting and expansion this reader does not follow: ANSI-C $'…' (backslash escapes a quote
|
||||
// there), ${…}, and here-documents (their lines are data, not commands).
|
||||
if (ch === "$" && next === "'") unsafe.add("$'…' quoting")
|
||||
if (ch === "$" && next === "{") unsafe.add("parameter expansion")
|
||||
if (ch === "<" && next === "<" && line[i + 2] !== "<" && line[i - 1] !== "<") unsafe.add("here-document")
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch
|
||||
cur += ch
|
||||
continue
|
||||
}
|
||||
if (ch === "`" || (ch === "$" && next === "(")) unsafe.add("command substitution")
|
||||
if ((ch === "<" || ch === ">") && next === "(") unsafe.add("process substitution")
|
||||
if (ch === "\n" || ch === ";") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if (ch === "&" && next === "&") {
|
||||
flush()
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === "|") {
|
||||
flush()
|
||||
if (next === "|") i++
|
||||
continue
|
||||
}
|
||||
if (ch === "&" && next !== ">" && line[i - 1] !== ">") {
|
||||
flush() // background
|
||||
continue
|
||||
}
|
||||
if (ch === ">") {
|
||||
// Capture the whole redirection to judge it: `2>&1` and `>/dev/null` are harmless.
|
||||
let j = i + 1
|
||||
if (line[j] === ">") j++
|
||||
if (line[j] === "&") j++
|
||||
while (line[j] === " ") j++
|
||||
while (j < line.length && !/[\s;&|]/.test(line[j]!)) j++
|
||||
const start = /\d|&/.test(line[i - 1] ?? "") ? i - 1 : i
|
||||
const redir = line.slice(start, j).replace(/\s+/g, "")
|
||||
if (!SAFE_REDIRECT.test(redir.replace(">>", ">"))) unsafe.add("redirection to a file")
|
||||
cur += line.slice(i, j)
|
||||
i = j - 1
|
||||
continue
|
||||
}
|
||||
cur += ch
|
||||
}
|
||||
if (quote) unsafe.add("unclosed quote")
|
||||
flush()
|
||||
for (const c of commands) {
|
||||
const head = words(c)[0] ?? ""
|
||||
if (["eval", "source", "."].includes(head)) unsafe.add(`\`${head}\``)
|
||||
if (["sh", "bash", "zsh", "dash", "ksh"].includes(head) && /\s-\w*c\b/.test(c)) unsafe.add("nested shell")
|
||||
}
|
||||
return { commands, unsafe: [...unsafe] }
|
||||
}
|
||||
|
||||
/** Shell words with quotes removed. Leading VAR=value assignments are skipped. */
|
||||
export function words(command: string): string[] {
|
||||
const out: string[] = []
|
||||
let cur = ""
|
||||
let quote: string | null = null
|
||||
let started = false
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i]!
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null
|
||||
else cur += ch
|
||||
continue
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (ch === "\\" && i + 1 < command.length) {
|
||||
cur += command[++i]
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (started) out.push(cur)
|
||||
cur = ""
|
||||
started = false
|
||||
continue
|
||||
}
|
||||
cur += ch
|
||||
started = true
|
||||
}
|
||||
if (started) out.push(cur)
|
||||
while (out.length > 1 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(out[0]!)) out.shift()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Permission evaluation: rules (OpenCode's shape — last match wins), modes (LLeMbas CLI's four),
|
||||
// and the hardline floor (Hermes) underneath everything.
|
||||
import { lstatSync, readlinkSync, realpathSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { basename, dirname, join, relative, resolve } from "node:path"
|
||||
import type { Action, Mode, PermissionConfig } from "../config/schema.ts"
|
||||
import { prefix } from "./arity.ts"
|
||||
import { splitCommand, words } from "./bash.ts"
|
||||
import { hardlineCommand, plainCommands, protectedPath, type HardlineRule } from "./hardline.ts"
|
||||
import { match } from "./wildcard.ts"
|
||||
import DEFAULTS from "../../harness/permission/defaults.json"
|
||||
import ARITY from "../../harness/permission/arity.json"
|
||||
|
||||
export interface Rule {
|
||||
permission: string
|
||||
pattern: string
|
||||
action: Action
|
||||
/** Added by an "always allow" answer: it never overrides a deny somebody wrote. */
|
||||
learned?: boolean
|
||||
/** Where it was written. A project's rule never loosens what the user's own global config says. */
|
||||
source?: "default" | "global" | "project"
|
||||
}
|
||||
|
||||
/** Every tool belongs to one class; modes are defined over classes. */
|
||||
export type ToolClass = "read" | "write" | "execute" | "interact"
|
||||
|
||||
export interface PermissionRequest {
|
||||
/** The permission key: read, edit, bash, glob, grep, list, ask_user, web_fetch… */
|
||||
permission: string
|
||||
class: ToolClass
|
||||
/** What the rules are matched against: project-relative paths, or the command line. */
|
||||
patterns: string[]
|
||||
/** Absolute paths the call touches (file tools). */
|
||||
paths?: string[]
|
||||
/** The raw command line (bash). */
|
||||
command?: string
|
||||
/** Asked every time, whatever the mode or the rules say — with this reason, and no "always"
|
||||
* answer (the settings tool loosening the mode). A deny still denies. */
|
||||
alwaysAsk?: string
|
||||
/** The "always allow" patterns to offer, when they are not the patterns themselves. */
|
||||
always?: string[]
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
action: Action
|
||||
reason: string
|
||||
/** Suggested "always allow" patterns for this request. */
|
||||
always: string[]
|
||||
}
|
||||
|
||||
// The rules every session starts from: the harness spec's (harness/permission/defaults.json).
|
||||
export const DEFAULT_RULES = DEFAULTS.rules as PermissionConfig
|
||||
|
||||
export function toRules(config: PermissionConfig, source?: Rule["source"]): Rule[] {
|
||||
const rules: Rule[] = []
|
||||
for (const [permission, v] of Object.entries(config)) {
|
||||
if (typeof v === "string") rules.push({ permission, pattern: "*", action: v, ...(source ? { source } : {}) })
|
||||
else for (const [pattern, action] of Object.entries(v)) rules.push({ permission, pattern, action, ...(source ? { source } : {}) })
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
function lookupRule(permission: string, pattern: string, rules: Rule[]): Rule | undefined {
|
||||
const hit = rules.findLast((r) => match(permission, r.permission) && match(pattern, r.pattern))
|
||||
// A project's rule (or an "always" answer) that is looser than what the user's global config says
|
||||
// for the same call gives way to it: the user's ask and deny are the floor a cloned repository
|
||||
// stands on, not something it can write over.
|
||||
if (hit && (hit.source === "project" || hit.learned)) {
|
||||
const own = rules.findLast((r) => r.source === "global" && !r.learned && match(permission, r.permission) && match(pattern, r.pattern))
|
||||
if (own && RANK[own.action] > RANK[hit.action]) return own
|
||||
}
|
||||
// "Always allow git push *" must not undo a configured "git push --force *": deny.
|
||||
if (hit?.learned) {
|
||||
const written = rules.findLast((r) => !r.learned && match(permission, r.permission) && match(pattern, r.pattern))
|
||||
if (written?.action === "deny") return written
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
/** Whether an allow would cover something a written rule denies (its pattern, read as text,
|
||||
* matches the allow): `git push *` covers `git push --force *`. */
|
||||
export function shadowsDeny(allow: Rule, rules: Rule[]): boolean {
|
||||
return rules.some((r) => !r.learned && r.action === "deny" && match(allow.permission, r.permission) && match(r.pattern, allow.pattern))
|
||||
}
|
||||
|
||||
function lookup(permission: string, pattern: string, rules: Rule[]): Action {
|
||||
return lookupRule(permission, pattern, rules)?.action ?? "ask"
|
||||
}
|
||||
|
||||
const RANK: Record<Action, number> = { allow: 0, ask: 1, deny: 2 }
|
||||
const strictest = (a: Action, b: Action): Action => (RANK[a] >= RANK[b] ? a : b)
|
||||
|
||||
export interface Context {
|
||||
mode: Mode
|
||||
rules: Rule[]
|
||||
hardline: HardlineRule[]
|
||||
/** Absolute project root; paths outside it are "external". */
|
||||
root: string
|
||||
/** Absolute plan directory: the one place plan mode may write. */
|
||||
planDir?: string
|
||||
/** The project directory (.agent): its config, agents, commands and skills are not edits. */
|
||||
projectDir?: string
|
||||
}
|
||||
|
||||
const inside = (p: string, dir: string) => p === dir || p.startsWith(dir.endsWith("/") ? dir : dir + "/")
|
||||
|
||||
/** A path as the filesystem will see it: symlinks in its longest existing part resolved. So a
|
||||
* link inside the project that points out of it is outside, and one to ~/.ssh is ~/.ssh. */
|
||||
export function realPath(p: string, depth = 0): string {
|
||||
const rest: string[] = []
|
||||
let head = p
|
||||
for (;;) {
|
||||
try {
|
||||
return join(realpathSync(head), ...rest)
|
||||
} catch {
|
||||
// A link whose target does not exist yet is still a link: writing through it creates the
|
||||
// target, wherever that is. Followed by hand, as the filesystem would.
|
||||
try {
|
||||
if (depth < 40 && lstatSync(head).isSymbolicLink()) return realPath(join(resolve(dirname(head), readlinkSync(head)), ...rest), depth + 1)
|
||||
} catch {}
|
||||
const up = dirname(head)
|
||||
if (up === head) return p
|
||||
rest.unshift(basename(head))
|
||||
head = up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Files edit mode does not change unasked: git's own (config and hooks run programs) and the
|
||||
* project's config, agents, commands and skills (they change what LLeMbas CLI itself does). */
|
||||
function guardedEdit(p: string, projectDir?: string): boolean {
|
||||
if (p.split("/").includes(".git")) return true
|
||||
if (!projectDir || !inside(p, projectDir)) return false
|
||||
const rel = relative(projectDir, p)
|
||||
return rel.endsWith("config.yaml") || /^(agents|commands|skills)(\/|$)/.test(rel)
|
||||
}
|
||||
|
||||
const DEVICES = /^\/dev\/(null|zero|stdin|stdout|stderr|tty|u?random)$/
|
||||
const PATTERN_FIRST = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"])
|
||||
|
||||
const MAX_GLOB = 500
|
||||
|
||||
/** What a glob names, as the shell would expand it — dotfiles included, to be safe. Undefined
|
||||
* when it names too many to judge one by one. */
|
||||
function expandGlob(abs: string): string[] | undefined {
|
||||
const parts = abs.split("/")
|
||||
const at = parts.findIndex((x) => /[*?[]/.test(x))
|
||||
const base = parts.slice(0, at).join("/") || "/"
|
||||
const out: string[] = []
|
||||
try {
|
||||
for (const f of new Bun.Glob(parts.slice(at).join("/")).scanSync({ cwd: base, dot: true, onlyFiles: false, followSymlinks: false })) {
|
||||
out.push(join(base, f))
|
||||
if (out.length > MAX_GLOB) return undefined
|
||||
}
|
||||
} catch {}
|
||||
return out.length ? out : [abs]
|
||||
}
|
||||
|
||||
/** The files a command names, absolute: its arguments that are not options (a search tool's
|
||||
* pattern excepted), what an input redirection reads, and what a glob expands to. A word that is
|
||||
* not really a path resolves inside the directory, harmlessly. `unsure`: a glob too wide to judge. */
|
||||
function commandPaths(commands: string[], cwd: string): { paths: string[]; unsure: boolean } {
|
||||
const out: string[] = []
|
||||
let unsure = false
|
||||
const add = (a: string) => {
|
||||
const home = a === "~" || a.startsWith("~/") ? join(homedir(), a.slice(1)) : a.replace(/^\$\{?HOME\}?(?=\/|$)/, homedir())
|
||||
const abs = resolve(cwd, home)
|
||||
if (!/[*?[]/.test(abs)) return void out.push(abs)
|
||||
const all = expandGlob(abs)
|
||||
if (all) out.push(...all)
|
||||
else unsure = true
|
||||
}
|
||||
for (const c of commands) {
|
||||
const w = words(c)
|
||||
// rg --files lists files: there is no pattern to skip, the first word is a path.
|
||||
let skipPattern = PATTERN_FIRST.has(basename(w[0] ?? "")) && !w.some((x) => x === "-e" || x.startsWith("--regexp") || x === "-f" || x === "--files")
|
||||
let afterRedirect = false
|
||||
let readNext = false
|
||||
for (const a of w.slice(1)) {
|
||||
// An input redirection reads a file just as an argument does: `cat < f`, `cat 0<f`.
|
||||
const input = /^\d*<(?![<>(])(.*)$/.exec(a)
|
||||
if (input) {
|
||||
if (input[1]) add(input[1])
|
||||
else readNext = true
|
||||
continue
|
||||
}
|
||||
if (readNext) {
|
||||
readNext = false
|
||||
add(a)
|
||||
continue
|
||||
}
|
||||
if (/^\d*>/.test(a) || a === "&>" || a === "&>>") {
|
||||
afterRedirect = true
|
||||
continue
|
||||
}
|
||||
if (afterRedirect) {
|
||||
afterRedirect = false
|
||||
continue
|
||||
}
|
||||
if (!a || a.startsWith("-") || DEVICES.test(a)) continue
|
||||
if (skipPattern) {
|
||||
skipPattern = false
|
||||
continue
|
||||
}
|
||||
add(a)
|
||||
}
|
||||
}
|
||||
return { paths: out, unsure }
|
||||
}
|
||||
|
||||
export function evaluate(req: PermissionRequest, ctx: Context): Decision {
|
||||
const d = evaluateRules(req, ctx)
|
||||
if (req.alwaysAsk && d.action !== "deny") return { action: "ask", reason: req.alwaysAsk, always: [] }
|
||||
return d
|
||||
}
|
||||
|
||||
function evaluateRules(req: PermissionRequest, ctx: Context): Decision {
|
||||
// 1. The floor.
|
||||
if (req.command) {
|
||||
const hit = hardlineCommand(req.command, ctx.hardline)
|
||||
if (hit) return { action: "deny", reason: `refused: ${hit.description} (hardline rule ${hit.id})`, always: [] }
|
||||
}
|
||||
// Where each path really is: a symlink is judged by what it points to.
|
||||
const root = realPath(ctx.root)
|
||||
const real = (req.paths ?? []).map(realPath)
|
||||
if (req.class === "write") {
|
||||
for (const [i, p] of real.entries()) {
|
||||
const guarded = protectedPath(req.paths![i]!) ?? protectedPath(p)
|
||||
if (guarded) return { action: "deny", reason: `refused: ${guarded} is never written by a tool`, always: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Rules. Every pattern is looked up; the strictest answer wins.
|
||||
let patterns = req.patterns
|
||||
let unsafe: string[] = []
|
||||
if (req.command !== undefined) {
|
||||
const split = splitCommand(req.command)
|
||||
patterns = split.commands.length ? split.commands : [req.command]
|
||||
unsafe = split.unsafe
|
||||
}
|
||||
let ruled: Action = "allow"
|
||||
for (const p of patterns.length ? patterns : ["*"]) ruled = strictest(ruled, lookup(req.permission, p, ctx.rules))
|
||||
// An allow cannot be trusted when the line does something the split cannot see.
|
||||
if (ruled === "allow" && unsafe.length) ruled = "ask"
|
||||
// A written deny is not walked past by another spelling: `sudo -u x git push`, `env A=1 git
|
||||
// push`, `timeout 5 git push` or `sh -c "git push"` is also judged as plain `git push`. Only a
|
||||
// deny is taken from the plain spelling; an allow there would loosen what the line asks.
|
||||
if (req.command !== undefined && ruled !== "deny")
|
||||
for (const p of plainCommands(req.command)) if (lookup(req.permission, p, ctx.rules) === "deny") ruled = "deny"
|
||||
|
||||
// A command is also judged by the files it names: `cat .env` reads .env, `cat ~/.ssh/id` is
|
||||
// outside the project, whatever the allow rule for `cat *` says.
|
||||
let named: string[] = []
|
||||
if (req.command !== undefined) {
|
||||
const c = commandPaths(patterns, real[0] ?? root)
|
||||
named = c.paths.map(realPath)
|
||||
if (c.unsure && ruled === "allow") ruled = "ask"
|
||||
}
|
||||
// Reading a file by another tool (grep, glob, list) or a command follows the read rules too.
|
||||
if (req.permission !== "read" && (req.class === "read" || req.command !== undefined))
|
||||
for (const p of [...(req.command === undefined ? real : []), ...named])
|
||||
if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
|
||||
// And read by where it really is: a link named notes.txt that points at .env reads .env.
|
||||
if (req.permission === "read")
|
||||
for (const p of real) if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
|
||||
|
||||
const external = [...real, ...named].filter((p) => !inside(p, root))
|
||||
let externalAction: Action = "allow"
|
||||
for (const p of external) externalAction = strictest(externalAction, lookup("external_directory", p, ctx.rules))
|
||||
|
||||
const always = alwaysPatterns(req, patterns)
|
||||
|
||||
if (ruled === "deny") return { action: "deny", reason: "denied by permission rules", always }
|
||||
if (externalAction === "deny") return { action: "deny", reason: `outside the project: ${external.join(", ")}`, always }
|
||||
|
||||
// 3. The mode.
|
||||
switch (ctx.mode) {
|
||||
case "auto":
|
||||
return { action: "allow", reason: "auto mode", always }
|
||||
|
||||
case "plan": {
|
||||
const planDir = ctx.planDir === undefined ? undefined : realPath(ctx.planDir)
|
||||
const planWrite =
|
||||
req.class === "write" && planDir !== undefined && inside(planDir, root) && real.length > 0 && real.every((p) => inside(p, planDir))
|
||||
if (planWrite) return { action: "allow", reason: "plan file", always }
|
||||
if (req.class === "read" || req.class === "interact") return withExternal(ruled, externalAction, external, always)
|
||||
if (ruled === "allow") return withExternal("allow", externalAction, external, always)
|
||||
return {
|
||||
action: "deny",
|
||||
reason: "plan mode: only reading and already-approved actions; write the plan under .agent/plans and call plan_submit",
|
||||
always,
|
||||
}
|
||||
}
|
||||
|
||||
case "edit": {
|
||||
// Files, named: a tool that touches no path (skill_manage, an MCP tool) is not file work,
|
||||
// whatever its class, and keeps asking.
|
||||
const projectDir = ctx.projectDir === undefined ? undefined : realPath(ctx.projectDir)
|
||||
const fileWork =
|
||||
(req.class === "read" || req.class === "write") &&
|
||||
req.command === undefined &&
|
||||
real.length > 0 &&
|
||||
!(req.class === "write" && real.some((p) => guardedEdit(p, projectDir)))
|
||||
// Edit mode lifts the catch-all `"*": ask`, not a rule somebody wrote for this tool
|
||||
// (reading `.env` still asks).
|
||||
const specificAsk = patterns.some((p) => {
|
||||
const r = lookupRule(req.permission, p, ctx.rules)
|
||||
return r?.action === "ask" && r.permission !== "*"
|
||||
})
|
||||
if (fileWork && external.length === 0 && !specificAsk) return { action: "allow", reason: "edit mode", always }
|
||||
return withExternal(ruled, externalAction, external, always)
|
||||
}
|
||||
|
||||
case "manual":
|
||||
return withExternal(ruled, externalAction, external, always)
|
||||
}
|
||||
}
|
||||
|
||||
function withExternal(ruled: Action, ext: Action, external: string[], always: string[]): Decision {
|
||||
const action = strictest(ruled, ext)
|
||||
const reason = action === "allow" ? "allowed by rules" : ext !== "allow" ? `outside the project: ${external.join(", ")}` : "needs approval"
|
||||
return { action, reason, always }
|
||||
}
|
||||
|
||||
/** Commands that run another command, or can: approving one with `*` would approve anything. */
|
||||
const EXACT_ONLY = new Set(ARITY.exact_only)
|
||||
|
||||
/** "Always allow" patterns: the arity prefix of each command (`git commit *`), or the exact paths.
|
||||
* None for a line of several commands where one runs others (`curl x | sh`, `find . | xargs rm`):
|
||||
* "always" would store `curl *` and `sh`, and from then on any `curl … | sh` ran unasked. */
|
||||
export function alwaysPatterns(req: PermissionRequest, patterns: string[]): string[] {
|
||||
if (req.always) return req.always
|
||||
if (req.command === undefined) return patterns
|
||||
if (patterns.length > 1 && patterns.some((c) => EXACT_ONLY.has(basename(words(c)[0] ?? "")))) return []
|
||||
const out = new Set<string>()
|
||||
for (const c of patterns) {
|
||||
const w = words(c)
|
||||
if (EXACT_ONLY.has(basename(w[0] ?? ""))) {
|
||||
out.add(c)
|
||||
continue
|
||||
}
|
||||
const head = prefix(w)
|
||||
if (!head.length) continue
|
||||
out.add(head.length < w.length ? `${head.join(" ")} *` : head.join(" "))
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// The floor: commands that are refused in every mode, auto included, and that no
|
||||
// project config can re-enable. Patterns ported from Hermes Agent tools/approval_detection.py
|
||||
// HARDLINE_PATTERNS (MIT, © 2025 Nous Research), plus LLeMbas CLI's own (force-push to main).
|
||||
import { homedir } from "node:os"
|
||||
import { basename, posix, resolve } from "node:path"
|
||||
import { paths } from "../config/paths.ts"
|
||||
import { splitCommand, words } from "./bash.ts"
|
||||
import SPEC from "../../harness/permission/hardline.json"
|
||||
|
||||
export interface HardlineRule {
|
||||
id: string
|
||||
description: string
|
||||
re: RegExp
|
||||
}
|
||||
|
||||
const rule = (id: string, description: string, src: string): HardlineRule => ({ id, description, re: new RegExp(src, SPEC.flags) })
|
||||
|
||||
// The rules themselves are the harness spec's (harness/permission/hardline.json), which LLeMbas
|
||||
// checks commands against too.
|
||||
export const BUILTIN_HARDLINE: HardlineRule[] = SPEC.rules.map((r) => rule(r.id, r.description, r.pattern))
|
||||
|
||||
export interface HardlineOptions {
|
||||
extra?: string[]
|
||||
disable?: string[]
|
||||
}
|
||||
|
||||
export function hardlineRules(opts: HardlineOptions = {}): HardlineRule[] {
|
||||
const disabled = new Set(opts.disable ?? [])
|
||||
const rules = BUILTIN_HARDLINE.filter((r) => !disabled.has(r.id))
|
||||
for (const [i, src] of (opts.extra ?? []).entries()) rules.push(rule(`extra-${i}`, `configured: ${src}`, src))
|
||||
return rules
|
||||
}
|
||||
|
||||
/** The rule a command line trips, if any. Checked on the raw line, so splitting cannot hide one,
|
||||
* and on each command in a plain spelling, so writing it differently cannot hide one either. */
|
||||
export function hardlineCommand(line: string, rules: HardlineRule[]): HardlineRule | undefined {
|
||||
for (const l of [line, ...plainCommands(line)]) {
|
||||
const hit = rules.find((r) => r.re.test(l))
|
||||
if (hit) return hit
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Commands that run the rest of their words as a command, and their options that take a value;
|
||||
// shell keywords; shells whose -c is a command line; git's options that take a value — the spec's.
|
||||
const WRAPPERS: Record<string, string[]> = SPEC.plain.wrappers
|
||||
const KEYWORDS = new Set(SPEC.plain.keywords)
|
||||
const SHELLS = new Set(SPEC.plain.shells)
|
||||
const GIT_VALUE = new Set(SPEC.plain.git_value_options)
|
||||
|
||||
function plainPath(a: string): string {
|
||||
const home = homedir()
|
||||
let p = a === "~" || a.startsWith("~/") ? home + a.slice(1) : a.replace(/^\$\{?HOME\}?(?=\/|$)/, home)
|
||||
if (!p.startsWith("/")) return a
|
||||
p = posix.normalize(p)
|
||||
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1)
|
||||
if (p === home) return "~"
|
||||
if (p.startsWith(home + "/")) return "~" + p.slice(home.length)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Each simple command of a line, rewritten plainly: no VAR= prefixes, wrappers (sudo -u x, env
|
||||
* -i, timeout 5, xargs…), quotes, escapes or program paths; paths normalised (`/etc/` is /etc,
|
||||
* the home directory is ~); `sh -c "…"` read as the line it runs. */
|
||||
export function plainCommands(line: string, depth = 0): string[] {
|
||||
if (depth > 3) return []
|
||||
const out: string[] = []
|
||||
// What a substitution runs is a command too: `echo $(git push)` runs `git push`. Read from
|
||||
// inside each $( … ) and ` … `, so neither a rule nor the floor is walked past by wrapping a
|
||||
// command in one.
|
||||
for (const inner of substitutions(line)) out.push(...plainCommands(inner, depth + 1))
|
||||
for (const c of splitCommand(line).commands) {
|
||||
let w = words(c).map((x) => x.replace(/^\(+/, "").replace(/\)+$/, "")).filter((x) => x !== "")
|
||||
for (;;) {
|
||||
while (w.length && (KEYWORDS.has(w[0]!) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]!))) w = w.slice(1)
|
||||
const prog = basename(w[0] ?? "")
|
||||
const takes = WRAPPERS[prog]
|
||||
if (!takes) break
|
||||
let i = 1
|
||||
while (i < w.length) {
|
||||
const a = w[i]!
|
||||
if (a === "--") {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if (prog === "env" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (prog === "env" && (a === "-S" || a.startsWith("--split-string"))) {
|
||||
const inner = a.includes("=") ? a.slice(a.indexOf("=") + 1) : w[i + 1]
|
||||
if (inner) out.push(...plainCommands(inner, depth + 1))
|
||||
i = w.length
|
||||
break
|
||||
}
|
||||
if (!a.startsWith("-")) break
|
||||
i += takes.includes(a) ? 2 : 1
|
||||
}
|
||||
// timeout's first word after its options is the duration.
|
||||
if (prog === "timeout" && i < w.length) i++
|
||||
w = w.slice(i)
|
||||
}
|
||||
if (!w.length) continue
|
||||
const prog = basename(w[0]!)
|
||||
let args = w.slice(1)
|
||||
if (SHELLS.has(prog)) {
|
||||
const flag = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a))
|
||||
if (flag >= 0 && args[flag + 1] !== undefined) out.push(...plainCommands(args[flag + 1]!, depth + 1))
|
||||
}
|
||||
if (prog === "git") {
|
||||
let i = 0
|
||||
while (i < args.length && args[i]!.startsWith("-")) i += GIT_VALUE.has(args[i]!) ? 2 : 1
|
||||
args = args.slice(i)
|
||||
}
|
||||
const quote = (x: string) => (/[\s;&|`$()<>'"]/.test(x) ? `'${x.replace(/'/g, "")}'` : x)
|
||||
out.push([prog, ...args.map(plainPath)].map(quote).join(" "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The command lines inside a line's $( … ) and ` … ` substitutions (outermost first; nested ones
|
||||
* are found when each is read in turn). Inside single quotes nothing is a substitution. */
|
||||
export function substitutions(line: string): string[] {
|
||||
const out: string[] = []
|
||||
let quote: "'" | '"' | null = null
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!
|
||||
if (quote === "'") {
|
||||
if (ch === "'") quote = null
|
||||
continue
|
||||
}
|
||||
if (ch === "\\") {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === "'" && !quote) {
|
||||
quote = "'"
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
quote = quote === '"' ? null : '"'
|
||||
continue
|
||||
}
|
||||
if (ch === "$" && line[i + 1] === "(" && line[i + 2] !== "(") {
|
||||
let depth = 0
|
||||
let j = i + 1
|
||||
for (; j < line.length; j++) {
|
||||
if (line[j] === "(") depth++
|
||||
else if (line[j] === ")" && --depth === 0) break
|
||||
}
|
||||
out.push(line.slice(i + 2, j))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (ch === "`") {
|
||||
const end = line.indexOf("`", i + 1)
|
||||
if (end === -1) break
|
||||
out.push(line.slice(i + 1, end))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml). */
|
||||
export function protectedPath(abs: string): string | undefined {
|
||||
const home = homedir()
|
||||
const guarded: [string, string][] = SPEC.protected_paths.map((g) => [
|
||||
g.path.startsWith("<config>/") ? resolve(paths.config, g.path.slice("<config>/".length)) : g.path.startsWith("~/") ? resolve(home, g.path.slice(2)) : g.path,
|
||||
g.label ?? g.path,
|
||||
])
|
||||
for (const [p, label] of guarded) if (abs === p || abs.startsWith(p + "/")) return label
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Lifted from OpenCode packages/opencode/src/util/wildcard.ts (MIT, © 2025 opencode).
|
||||
// `*` matches anything (including `/` and spaces), `?` one character. A pattern ending in
|
||||
// " *" also matches the bare command, so `ls *` matches both `ls` and `ls -la`.
|
||||
const cache = new Map<string, RegExp>()
|
||||
|
||||
export function match(str: string, pattern: string): boolean {
|
||||
let re = cache.get(pattern)
|
||||
if (!re) {
|
||||
let escaped = pattern
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".")
|
||||
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
|
||||
re = new RegExp("^" + escaped + "$", "s")
|
||||
cache.set(pattern, re)
|
||||
}
|
||||
return re.test(str.replaceAll("\\", "/"))
|
||||
}
|
||||
Reference in new issue
Block a user