@lef237/hagumi 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/memory.ts ADDED
@@ -0,0 +1,132 @@
1
+ // What hagumi remembers (docs/design/memory.md): facts about the user and their projects in
2
+ // ~/.hagumi/memory/memory.md, and how hagumi itself speaks in persona.md beside it. A line comes from the user's
3
+ // own words, or, when it may come from a file or a command output, from the user's approval. The directory is
4
+ // a git repository, and each line is one commit whose trailers say where it came from. The user may edit the
5
+ // files by hand.
6
+ import { appendFile, mkdir, writeFile } from "node:fs/promises"
7
+ import { basename, dirname, join } from "node:path"
8
+ import { paths } from "./app"
9
+ import type { ToolCall } from "./conversation"
10
+ import type { LogEntry } from "./log"
11
+
12
+ // "tools" when tool output had come into the conversation before the fact was proposed:
13
+ // the fact may then come from a file or a command rather than from the user.
14
+ export type Origin = "user" | "tools"
15
+ // Whom or what a fact is about: "self" is how hagumi speaks, and goes to the persona.
16
+ export type About = "user" | "project" | "self"
17
+ export type Proposal = { about: About; fact: string; origin: Origin }
18
+ export type Provenance = { about: About; session: string; origin: Origin; workspace: string }
19
+
20
+ // Facts are kept only where their history can be kept too.
21
+ export const gitAvailable = Bun.which("git") !== null
22
+
23
+ // Memory goes into the prompt of every session, so it is kept small; the persona, which rarely changes,
24
+ // smaller still.
25
+ export const memoryLimit = 2000
26
+ export const personaLimit = 500
27
+
28
+ // The persona starts from a small seed, written on first use so that it can be read and changed,
29
+ // and recorded as the first step of its growth.
30
+ export async function seedPersona(seed: string, path = paths.persona): Promise<void> {
31
+ if (await Bun.file(path).exists()) return
32
+ const dir = dirname(path)
33
+ await mkdir(dir, { recursive: true })
34
+ await writeFile(path, seed)
35
+ if (!gitAvailable) return
36
+ await git(dir, "init", "--quiet")
37
+ await git(dir, "add", basename(path))
38
+ await git(dir, "commit", "--quiet", "--message", "persona: seed")
39
+ }
40
+
41
+ // Read once when a session starts, so that the system prompt stays the same throughout the session.
42
+ export async function readMemory(path = paths.memory): Promise<string> {
43
+ const file = Bun.file(path)
44
+ return (await file.exists()) ? (await file.text()).trim() : ""
45
+ }
46
+
47
+ // Characters as people count them, rather than UTF-16 code units.
48
+ export function size(text: string): number {
49
+ return [...text].length
50
+ }
51
+
52
+ // Where a fact is kept, and how much that file may hold.
53
+ export function placeFor(about: About, files: { memory: string; persona: string }): { path: string; limit: number } {
54
+ return about === "self" ? { path: files.persona, limit: personaLimit } : { path: files.memory, limit: memoryLimit }
55
+ }
56
+
57
+ // Whether one more fact fits within the limit. What the user wrote by hand is kept even past it;
58
+ // only new facts are refused.
59
+ export async function fits(fact: string, path = paths.memory, limit = memoryLimit): Promise<boolean> {
60
+ return size(await readMemory(path)) + size(`\n- ${fact}`) <= limit
61
+ }
62
+
63
+ // The latest changes, newest first, as "date subject".
64
+ export async function memoryHistory(path = paths.memory, count = 5): Promise<string[]> {
65
+ const dir = dirname(path)
66
+ if (!(await Bun.file(join(dir, ".git", "HEAD")).exists())) return []
67
+ const log = await git(dir, "log", `-${count}`, "--date=format:%Y-%m-%d %H:%M", "--format=%ad %s")
68
+ return log ? log.split("\n") : []
69
+ }
70
+
71
+ // The line a proposal adds to memory.md. Memory is shared by every project, so a fact about the project
72
+ // is marked with the name of the working directory, unless the model already named it there.
73
+ export function factFrom(input: Record<string, unknown>, workspace: string): string {
74
+ const fact = String(input.fact ?? "").replace(/\s+/g, " ").trim()
75
+ const project = basename(workspace)
76
+ return fact && input.about === "project" && !fact.includes(project) ? `${project}: ${fact}` : fact
77
+ }
78
+
79
+ // The facts proposed during the last turn, read back from the session log.
80
+ // A fact the user has already decided on in this session is not asked again.
81
+ export function proposals(entries: LogEntry[], workspace: string): Proposal[] {
82
+ const turnStart = entries.findLastIndex((entry) => entry.kind === "user")
83
+ const accepted = new Set(entries.flatMap((entry) => (entry.kind === "tool_result" && entry.ok ? [entry.callId] : [])))
84
+ const asked = new Set(entries.flatMap((entry) => (entry.kind === "memory" ? [entry.fact] : [])))
85
+ const found: Proposal[] = []
86
+ let origin: Origin = "user"
87
+ entries.forEach((entry, index) => {
88
+ if (entry.kind === "tool_result" && entry.name !== "remember") origin = "tools"
89
+ if (index < turnStart || entry.kind !== "assistant") return
90
+ for (const call of entry.toolCalls as ToolCall[]) {
91
+ if (call.name !== "remember" || !accepted.has(call.id)) continue
92
+ const input = JSON.parse(call.arguments)
93
+ const fact = factFrom(input, workspace)
94
+ if (asked.has(fact)) continue
95
+ asked.add(fact)
96
+ found.push({ about: input.about, fact, origin })
97
+ }
98
+ })
99
+ return found
100
+ }
101
+
102
+ // Adds one fact as one commit. Edits made by hand since the last commit are committed first,
103
+ // so that each commit says who made the change.
104
+ export async function addMemory(fact: string, provenance: Provenance, path = paths.memory): Promise<void> {
105
+ const dir = dirname(path)
106
+ await mkdir(dir, { recursive: true })
107
+ await git(dir, "init", "--quiet")
108
+ if (await git(dir, "status", "--porcelain")) {
109
+ await git(dir, "add", "--all")
110
+ await git(dir, "commit", "--quiet", "--message", "edit: by hand")
111
+ }
112
+ const file = Bun.file(path)
113
+ const text = (await file.exists()) ? await file.text() : ""
114
+ await appendFile(path, `${text && !text.endsWith("\n") ? "\n" : ""}- ${fact}\n`)
115
+ await git(dir, "add", basename(path))
116
+ const trailers = [`Session: ${provenance.session}`, `Origin: ${provenance.origin}`, `Workspace: ${provenance.workspace}`]
117
+ const subject = `${provenance.about === "self" ? "persona" : "remember"}: ${fact}`
118
+ await git(dir, "commit", "--quiet", "--message", subject, "--message", trailers.join("\n"))
119
+ }
120
+
121
+ // hagumi commits as itself, whatever the user's git settings say about names and signing.
122
+ async function git(dir: string, ...args: string[]): Promise<string> {
123
+ const identity = ["-c", "user.name=hagumi", "-c", "user.email=hagumi@localhost", "-c", "commit.gpgsign=false"]
124
+ const child = Bun.spawn(["git", "-C", dir, ...identity, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
125
+ const [stdout, stderr, exitCode] = await Promise.all([
126
+ new Response(child.stdout).text(),
127
+ new Response(child.stderr).text(),
128
+ child.exited,
129
+ ])
130
+ if (exitCode !== 0) throw new Error(`git ${args[0]}: ${stderr.trim()}`)
131
+ return stdout.trim()
132
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,45 @@
1
+ // The user's rules for commands. A denied command never runs; an allowed one runs without asking;
2
+ // anything else is asked about. Deny wins over allow.
3
+ import { basename } from "node:path"
4
+ import { analyze } from "./shell"
5
+
6
+ export type CommandRules = { allow: string[]; deny: string[] }
7
+
8
+ export type Verdict = { kind: "ask" } | { kind: "allow"; rules: string[] } | { kind: "deny"; rule: string }
9
+
10
+ // A command line is allowed only when every program it runs is allowed and nothing in it is opaque;
11
+ // it is denied when any program it runs, however deeply nested, is denied.
12
+ export function judge(line: string, rules: CommandRules): Verdict {
13
+ const { commands, opaque } = analyze(line)
14
+ for (const argv of commands) {
15
+ const rule = rules.deny.find((candidate) => denies(wordsOf(candidate), argv))
16
+ if (rule) return { kind: "deny", rule }
17
+ }
18
+ if (opaque || commands.length === 0) return { kind: "ask" }
19
+
20
+ const matched = commands.map((argv) => rules.allow.find((candidate) => allows(wordsOf(candidate), argv)))
21
+ if (matched.some((rule) => rule === undefined)) return { kind: "ask" }
22
+ return { kind: "allow", rules: [...new Set(matched as string[])] }
23
+ }
24
+
25
+ function wordsOf(rule: string): string[] {
26
+ return rule.trim().split(/\s+/)
27
+ }
28
+
29
+ // An allow rule is a prefix of the arguments: `git status` allows `git status -s`, but not `git -C x status`.
30
+ function allows([program, ...args]: string[], argv: string[]): boolean {
31
+ return argv[0] === program && args.every((word, index) => argv[index + 1] === word)
32
+ }
33
+
34
+ // A deny rule is generous: its words after the program may appear anywhere, in order.
35
+ // `git push` denies `git -C x push origin` too.
36
+ function denies([program, ...args]: string[], argv: string[]): boolean {
37
+ if (basename(argv[0] ?? "") !== basename(program ?? "")) return false
38
+ let from = 1
39
+ for (const word of args) {
40
+ const at = argv.indexOf(word, from)
41
+ if (at < 0) return false
42
+ from = at + 1
43
+ }
44
+ return true
45
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,43 @@
1
+ // The system prompt, in named sections. It holds nothing that changes within a session,
2
+ // so providers can keep it cached.
3
+ import type { Language } from "./config"
4
+
5
+ const speech: Record<Language, string> = {
6
+ auto: "Reply in the language the user writes in.",
7
+ ja: "Speak Japanese, unless the user writes in another language.",
8
+ en: "Speak English, unless the user writes in another language.",
9
+ }
10
+
11
+ // When the board is kept: told in the prompt and in the tool, since some models keep it only when told when.
12
+ export const boardRule =
13
+ "Whenever the work moves, as you start or finish something or a new task comes up, update the board with the board tool in the same turn."
14
+
15
+ // The persona follows the identity, since it rarely changes; memory and the board come last, since they change
16
+ // most.
17
+ export function systemPrompt(options: {
18
+ workspace: string
19
+ name: string
20
+ language: Language
21
+ persona: string
22
+ memory: string
23
+ board: string
24
+ }): string {
25
+ return [
26
+ `<identity>You are ${options.name}, a coding partner working with the user in their terminal.</identity>`,
27
+ ...(options.persona
28
+ ? [`<persona>\nHow you speak. It grows through your conversations with the user:\n${options.persona}\n</persona>`]
29
+ : []),
30
+ `<workspace>The working directory is ${options.workspace}. ` +
31
+ "The read, write, and edit tools can access only files inside it. " +
32
+ "Bash can also read files outside it, except blocked credential locations; it can write to the workspace and temporary directories. " +
33
+ "Keep file reads within the working directory unless the user explicitly requests an outside path. " +
34
+ "Credential blocking does not cover every sensitive file.</workspace>",
35
+ `<language>${speech[options.language]}</language>`,
36
+ ...(options.memory
37
+ ? [`<memory>\nFacts about the user and your work together, kept from earlier conversations:\n${options.memory}\n</memory>`]
38
+ : []),
39
+ ...(options.board
40
+ ? [`<board>\nThe board of this project as this session began; the first item under To do comes next. ${boardRule}\n${options.board}\n</board>`]
41
+ : []),
42
+ ].join("\n")
43
+ }
@@ -0,0 +1,85 @@
1
+ // Providers are data. One that speaks a known wire needs no new code.
2
+ import { paths, userAgent } from "./app"
3
+ import { accessToken, listModels } from "./chatgpt"
4
+ import { Problem } from "./i18n"
5
+ import { readJson, writeSecret } from "./store"
6
+ import { type Wire, type WireName, wires } from "./wire"
7
+
8
+ export type Provider = {
9
+ id: string
10
+ baseUrl: string
11
+ auth: "chatgpt" | "api-key"
12
+ // The wire its models speak, and the models that speak another one.
13
+ wire: WireName
14
+ models?: Record<string, WireName>
15
+ // The header carrying hagumi's session id, which providers use for routing and prompt caching.
16
+ sessionHeader: string
17
+ }
18
+
19
+ export const providers: Record<string, Provider> = {
20
+ chatgpt: {
21
+ id: "chatgpt",
22
+ baseUrl: "https://api.openai.com/v1",
23
+ auth: "chatgpt",
24
+ wire: "openai-responses",
25
+ sessionHeader: "session-id",
26
+ },
27
+ // Open to coding agents that send their own User-Agent and a stable session id: https://dev.opencode.ai/v2/docs/console/go/
28
+ "opencode-go": {
29
+ id: "opencode-go",
30
+ baseUrl: "https://opencode.ai/zen/go/v1",
31
+ auth: "api-key",
32
+ wire: "openai-chat",
33
+ models: {
34
+ "grok-4.7": "openai-responses",
35
+ "grok-4.6": "openai-responses",
36
+ "gpt-6-luna": "openai-responses",
37
+ "gpt-5.6-luna": "openai-responses",
38
+ "muse-spark-1.3-contributor": "openai-responses",
39
+ "muse-spark-1.2-contributor": "openai-responses",
40
+ "claude-haiku-5-5": "anthropic-messages",
41
+ "minimax-m3": "anthropic-messages",
42
+ "minimax-m2.7": "anthropic-messages",
43
+ "qwen3.8-max": "anthropic-messages",
44
+ "qwen3.8-flash": "anthropic-messages",
45
+ "qwen3.7-plus": "anthropic-messages",
46
+ },
47
+ sessionHeader: "x-opencode-session",
48
+ },
49
+ }
50
+
51
+ export type ModelChoice = { ref: string; provider: Provider; model: string; wire: Wire }
52
+
53
+ // Models are named `<provider>/<model>`, such as `opencode-go/kimi-k3`.
54
+ export function chooseModel(ref: string): ModelChoice {
55
+ const slash = ref.indexOf("/")
56
+ const provider = slash > 0 ? providers[ref.slice(0, slash)] : undefined
57
+ const model = ref.slice(slash + 1)
58
+ if (!provider || !model) throw new Problem("unknownModel", ref)
59
+ return { ref, provider, model, wire: wires[provider.models?.[model] ?? provider.wire] }
60
+ }
61
+
62
+ export async function credential(provider: Provider): Promise<string> {
63
+ if (provider.auth === "chatgpt") return accessToken()
64
+ const saved = await readJson<{ apiKey: string }>(paths.auth(provider.id))
65
+ if (!saved) throw new Problem("notSignedIn", provider.id)
66
+ return saved.apiKey
67
+ }
68
+
69
+ export function isSignedIn(provider: Provider): Promise<boolean> {
70
+ return Bun.file(paths.auth(provider.id)).exists()
71
+ }
72
+
73
+ export function saveApiKey(provider: Provider, apiKey: string): Promise<void> {
74
+ return writeSecret(paths.auth(provider.id), { apiKey })
75
+ }
76
+
77
+ export async function modelIds(provider: Provider): Promise<string[]> {
78
+ if (provider.auth === "chatgpt") return (await listModels(await accessToken())).map((model) => model.slug)
79
+ const response = await fetch(`${provider.baseUrl}/models`, {
80
+ headers: { "User-Agent": userAgent, Authorization: `Bearer ${await credential(provider)}` },
81
+ })
82
+ if (!response.ok) throw new Error(`GET /models failed: ${response.status} ${await response.text()}`)
83
+ const { data } = (await response.json()) as { data: { id: string }[] }
84
+ return data.map((model) => model.id)
85
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,170 @@
1
+ // Commands run inside the operating system's sandbox: Seatbelt on macOS, bubblewrap on Linux.
2
+ // They may write only to the workspace and temporary directories, cannot reach the network,
3
+ // cannot access known credential locations, and see few environment variables. Other OS-readable files,
4
+ // including ones outside the workspace, remain readable.
5
+ import { existsSync, lstatSync } from "node:fs"
6
+ import { realpath } from "node:fs/promises"
7
+ import { homedir, tmpdir } from "node:os"
8
+ import { delimiter, isAbsolute, join, relative, sep } from "node:path"
9
+ import { storageHome } from "./app"
10
+ import { analyze } from "./shell"
11
+
12
+ // What a command may touch, decided once for every platform.
13
+ export type Limits = {
14
+ workspace: string
15
+ writable: string[]
16
+ // Inside the writable places, yet kept read-only: git runs hooks and reads its config from here.
17
+ protected: string[]
18
+ secrets: string[]
19
+ }
20
+
21
+ // How one platform's sandbox turns the limits into a command line.
22
+ type Backend = { available(): boolean; wrap(command: string, limits: Limits): string[] }
23
+
24
+ const seatbelt: Backend = {
25
+ available: () => existsSync("/usr/bin/sandbox-exec"),
26
+ wrap: (command, limits) => ["/usr/bin/sandbox-exec", "-p", seatbeltProfile(limits), "/bin/bash", "-c", command],
27
+ }
28
+
29
+ const bubblewrap: Backend = {
30
+ // bwrap needs unprivileged user namespaces, which some systems turn off; trying it once tells.
31
+ available: () => Bun.which("bwrap") !== null && Bun.spawnSync(["bwrap", "--ro-bind", "/", "/", "true"]).exitCode === 0,
32
+ wrap: (command, limits) => [...bubblewrapArgs(limits), "/bin/bash", "-c", command],
33
+ }
34
+
35
+ const backend = { darwin: seatbelt, linux: bubblewrap }[process.platform as string]
36
+
37
+ // Where no sandbox is available, commands do not run at all.
38
+ export const sandboxAvailable = backend?.available() ?? false
39
+
40
+ // Known homes of credentials, hagumi's own included. A guard against accidents, not a complete list.
41
+ export const secretPaths = [
42
+ ".hagumi",
43
+ ".ssh",
44
+ ".aws",
45
+ ".gnupg",
46
+ ".kube",
47
+ ".docker",
48
+ ".azure",
49
+ ".config/gh",
50
+ ".config/gcloud",
51
+ ".netrc",
52
+ ".npmrc",
53
+ ".codex",
54
+ ".claude",
55
+ ].map((path) => join(homedir(), path)).concat(storageHome)
56
+
57
+ // Enough for tools to work; API keys and tokens in the environment stay behind.
58
+ const passedEnvironment = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR"]
59
+
60
+ export type CommandResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }
61
+
62
+ export async function runSandboxed(options: {
63
+ command: string
64
+ workspace: string
65
+ timeoutSeconds: number
66
+ secrets?: string[]
67
+ }): Promise<CommandResult> {
68
+ if (!backend || !sandboxAvailable) throw new Error("No sandbox is available on this system")
69
+ // Sandboxes match real paths, so symbolic links such as /tmp -> /private/tmp are resolved first.
70
+ const workspace = await realpath(options.workspace)
71
+ const limits: Limits = {
72
+ workspace,
73
+ writable: [...new Set([workspace, await realpath(tmpdir())])],
74
+ protected: [join(workspace, ".git", "hooks"), join(workspace, ".git", "config")],
75
+ secrets: await Promise.all([...new Set([...secretPaths, ...(options.secrets ?? [])])].map(real)),
76
+ }
77
+ const child = Bun.spawn(backend.wrap(options.command, limits), {
78
+ cwd: workspace,
79
+ env: {
80
+ ...Object.fromEntries(passedEnvironment.flatMap((name) => (process.env[name] ? [[name, process.env[name]]] : []))),
81
+ PATH: await searchPath([...limits.writable, "/private/tmp", "/dev"], options.command),
82
+ },
83
+ stdin: "ignore",
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ timeout: options.timeoutSeconds * 1000,
87
+ })
88
+ const [stdout, stderr, exitCode] = await Promise.all([
89
+ new Response(child.stdout).text(),
90
+ new Response(child.stderr).text(),
91
+ child.exited,
92
+ ])
93
+ return { exitCode, stdout, stderr, timedOut: child.signalCode !== null }
94
+ }
95
+
96
+ // Bare command names must not select executables the model can replace. Check directories and executable links;
97
+ // relative entries would also change their meaning after cd. Local tools can still run by an explicit path.
98
+ async function searchPath(writable: string[], command: string): Promise<string> {
99
+ const outside = (target: string) => writable.every((root) => {
100
+ const fromRoot = relative(root, target)
101
+ return fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)
102
+ })
103
+ const programs = [...new Set(analyze(command).commands.flatMap(([program]) => program && !program.includes("/") ? [program] : []))]
104
+ const entries = await Promise.all((process.env.PATH ?? "/usr/bin:/bin").split(delimiter).map(async (entry) => {
105
+ if (!isAbsolute(entry)) return undefined
106
+ const target = await realpath(entry).catch(() => undefined)
107
+ if (!target || !outside(target)) return undefined
108
+ const executables = await Promise.all(programs.map((program) => existingPath(join(target, program)))).catch(() => undefined)
109
+ return executables?.every((executable) => executable === undefined || outside(executable)) ? target : undefined
110
+ }))
111
+ const path = [...new Set(entries.filter((entry) => entry !== undefined))].join(delimiter)
112
+ if (!path) throw new Error("PATH has no command directories outside the writable locations")
113
+ return path
114
+ }
115
+
116
+ // A dangling executable link is unsafe too: its target could be created inside the workspace before execution.
117
+ async function existingPath(path: string): Promise<string | undefined> {
118
+ try { lstatSync(path) } catch { return undefined }
119
+ return realpath(path)
120
+ }
121
+
122
+ // The real path where it exists; a path that does not exist yet has nothing to resolve.
123
+ function real(path: string): Promise<string> {
124
+ return realpath(path).catch(() => path)
125
+ }
126
+
127
+ // Later rules win: everything is allowed, then network and writes are taken away, then a few writes come back.
128
+ // macOS keeps temporary files under /private/tmp too, and /dev must stay writable for /dev/null.
129
+ export function seatbeltProfile(limits: Omit<Limits, "workspace">): string {
130
+ const rule = (verdict: string, operation: string, targets: string[]) =>
131
+ targets.length > 0 ? [`(${verdict} ${operation} ${targets.map((p) => `(subpath ${JSON.stringify(p)})`).join(" ")})`] : []
132
+ return [
133
+ "(version 1)",
134
+ "(allow default)",
135
+ "(deny network*)",
136
+ "(deny file-write*)",
137
+ ...rule("allow", "file-write*", [...limits.writable, "/private/tmp", "/dev"]),
138
+ ...rule("deny", "file-write*", limits.protected),
139
+ ...rule("deny", "file-read*", limits.secrets),
140
+ ...rule("deny", "file-write*", limits.secrets),
141
+ ].join("\n")
142
+ }
143
+
144
+ // Later mounts cover earlier ones: the whole system read-only, then the writable places, then the protected
145
+ // ones read-only again, then the secrets covered by emptiness. The network is a fresh one with nothing in it.
146
+ export function bubblewrapArgs(limits: Limits): string[] {
147
+ return [
148
+ "bwrap",
149
+ "--ro-bind", "/", "/",
150
+ "--dev", "/dev",
151
+ "--proc", "/proc",
152
+ ...limits.writable.flatMap((path) => ["--bind", path, path]),
153
+ ...limits.protected.flatMap((path) => ["--ro-bind-try", path, path]),
154
+ ...limits.secrets.flatMap(hidden),
155
+ "--unshare-net",
156
+ "--unshare-pid",
157
+ "--new-session",
158
+ "--die-with-parent",
159
+ "--chdir", limits.workspace,
160
+ ]
161
+ }
162
+
163
+ // A secret directory becomes an empty one and a secret file an empty file; what does not exist needs no cover.
164
+ function hidden(path: string): string[] {
165
+ try {
166
+ return lstatSync(path).isDirectory() ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path]
167
+ } catch {
168
+ return []
169
+ }
170
+ }