@loophubs/agent-guard 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,91 +1,15 @@
1
- # agent-guard
1
+ # Agent Guard
2
2
 
3
- `agent-guard` is a macOS pre-tool hook for Claude Code, Codex, and Pi that blocks risky filesystem scans and credential reads. It helps avoid agent commands that traverse `~/Library` and trigger macOS App Data prompts recorded as `SystemPolicyAppDataDetailed`.
3
+ `agent-guard` is a macOS pre-tool guard for Claude Code, Codex, and Pi. It checks supported tool calls before execution and blocks risky filesystem scans and credential reads.
4
4
 
5
- ## Install
5
+ macOS can record App Data access as `SystemPolicyAppDataDetailed` when a command reads or enumerates another app's data under `~/Library`. Broad scans from the home directory can cross those containers and trigger repeated permission prompts.
6
6
 
7
- Install globally with npm or Bun (Bun 1.4 or newer must be available at runtime):
7
+ ## What it does
8
8
 
9
- ```sh
10
- npm install --global @loophubs/agent-guard
11
- # or
12
- bun add --global @loophubs/agent-guard
13
- command -v agent-guard
14
- ```
9
+ The guard blocks broad home and `~/Library` walks, protected App Data access, and recognizable attempts to display credentials. It allows project-scoped searches and named non-sensitive file reads; a clean guard result still leaves the runtime's own permissions in force. It covers only calls routed through a registered hook, not every tool or process.
15
10
 
16
- Use the absolute path printed by the last command in the examples below.
11
+ To install the package and register and verify a runtime, follow [the setup guide](docs/setup.md).
17
12
 
18
- ## Register a runtime
13
+ [GitHub Releases](https://github.com/LoopHubs/agent-guard/releases) describe version-specific changes to what the guard blocks or allows.
19
14
 
20
- ### Claude Code
21
-
22
- Add this `PreToolUse` hook to `~/.claude/settings.json`, replacing the command path. It covers Claude Code's Bash and file-reading tools; [Claude Code's hook settings](https://code.claude.com/docs/en/hooks#hook-locations) describe the user-level location.
23
-
24
- ```json
25
- {
26
- "hooks": {
27
- "PreToolUse": [{
28
- "matcher": "Bash|Read|Edit|Write|Grep",
29
- "hooks": [{ "type": "command", "command": "/absolute/path/to/agent-guard --runtime claude", "timeout": 5 }]
30
- }]
31
- }
32
- }
33
- ```
34
-
35
- ### Codex
36
-
37
- Add this to `~/.codex/config.toml`, replacing the command path. [OpenAI's hook documentation](https://learn.chatgpt.com/docs/hooks) specifies `PreToolUse`, the Bash matcher, and `/hooks` trust review; open `/hooks` in Codex and trust the new hook before testing it.
38
-
39
- ```toml
40
- [[hooks.PreToolUse]]
41
- matcher = "^Bash$"
42
-
43
- [[hooks.PreToolUse.hooks]]
44
- type = "command"
45
- command = "/absolute/path/to/agent-guard --runtime codex"
46
- timeout = 5
47
- ```
48
-
49
- ### Pi
50
-
51
- Create `~/.pi/agent/extensions/agent-guard.ts` with this adapter, replacing the command path. [Pi's extension API](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md) loads files in that directory and lets `tool_call` block a tool before execution.
52
-
53
- ```ts
54
- import { spawnSync } from "node:child_process";
55
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
56
-
57
- const guard = "/absolute/path/to/agent-guard";
58
-
59
- export default function (pi: ExtensionAPI) {
60
- pi.on("tool_call", (event, ctx) => {
61
- if (!["bash", "read", "edit", "write", "grep"].includes(event.toolName)) return;
62
- const input = event.input as Record<string, unknown>;
63
- const toolInput = event.toolName === "bash" ? input : {
64
- ...input,
65
- file_path: input.path,
66
- };
67
- const result = spawnSync(guard, ["--runtime", "pi"], {
68
- cwd: ctx.cwd,
69
- input: JSON.stringify({ tool_name: event.toolName, tool_input: toolInput, cwd: ctx.cwd }),
70
- encoding: "utf8",
71
- timeout: 4500,
72
- });
73
- if (result.status === 0) return;
74
- return { block: true, reason: result.stderr?.trim() || result.error?.message || "agent-guard failed" };
75
- });
76
- }
77
- ```
78
-
79
- ## Verify it blocks
80
-
81
- From a project directory, ask the configured agent to run `du -sh ~/Library`. It should report the guard's denial **without running `du`**. You can check the package entry directly without reading `~/Library`:
82
-
83
- ```sh
84
- printf '%s\n' '{"tool_name":"Bash","tool_input":{"command":"du -sh ~/Library"}}' | agent-guard --runtime claude
85
- ```
86
-
87
- The direct check should exit 2 and print a `DENIED:` reason on stderr. A direct check confirms the package works; the agent check confirms that runtime loaded its hook.
88
-
89
- ## Limits
90
-
91
- The guard checks supported tool calls and recognizable shell commands; it does not sandbox the agent or cover every custom tool, dynamic shell expansion, or process outside a registered hook. Codex's example covers Bash calls, while the Pi adapter covers Bash, read, edit, write, and grep; other Pi tools need their own adapter handling. A broken or timed-out package entry denies a checked call, but a hook disabled or skipped by its runtime cannot inspect that call.
15
+ > **AI agents:** Read `docs/setup.md` first, then ask your user whether to install and register the guard. Do not install it or change configuration without consent.
package/bin/agent-guard CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/bin/zsh -f
2
2
  # PreToolUse entry for the agent guard:
3
3
  # agent-guard --runtime claude|codex|pi < event.json
4
- # Runs src/guard.ts from the package directory and passes its denial through
4
+ # Runs the Bun supervisor from the package directory and passes denials through
5
5
  # with exit 2. Exit 0 only means no objection; it never approves a call. Every
6
6
  # other outcome denies too, because Claude lets a hook's other failures and
7
7
  # timeouts through, Codex is assumed to, and nothing else stands between an
@@ -15,24 +15,28 @@ fail() {
15
15
  exit 2
16
16
  }
17
17
 
18
+ [[ $HOME == /* ]] || fail 'HOME is not an absolute path'
18
19
  pkg=${0:A:h:h}
19
20
  bun=$(command -v bun) || fail 'bun missing'
20
21
  guard=$pkg/src/guard.ts
21
22
  [[ -r $guard ]] || fail 'guard.ts missing'
23
+ runner=$pkg/src/runner.ts
24
+ [[ -r $runner ]] || fail 'runner.ts missing'
25
+ [[ -r $pkg/tsconfig.json ]] || fail 'tsconfig.json missing'
22
26
  event=$(/bin/cat)
23
- # bun reads bunfig.toml, tsconfig.json and .env from its working directory,
24
- # which is the agent's project, so a project file could preload code into the
25
- # guard or remap its parser. The guard runs from the package directory, with
26
- # the hook's directory passed as --cwd.
27
+ # Bun reads bunfig.toml and environment files from its working directory;
28
+ # the package config owns TypeScript paths. Pass the hook's directory as --cwd.
27
29
  hook_cwd=$PWD
28
30
  cd -q $pkg || fail 'package directory unreadable'
29
31
 
30
- # The deadline sits below every runtime's hook timeout, so an overrun denies
31
- # here instead of timing out as a pass. Reading the coprocess under the same
32
- # deadline keeps anything the guard started from holding this hook open.
33
- coproc { print -r -- "$event" | exec $bun $guard "$@" --cwd $hook_cwd 2>&1 }
32
+ # The runner kills the guard's process group. This outer deadline still denies
33
+ # if the runner itself does not return before the hook timeout.
34
+ coproc { print -r -- "$event" | exec $bun $runner "$@" --cwd $hook_cwd 2>&1 }
34
35
  pid=$!
35
- { zselect -t 300; kill -KILL $pid } >/dev/null 2>&1 &!
36
+ read -r -t 1 -p group || { kill -KILL $pid 2>/dev/null; fail 'supervisor did not report its process group' }
37
+ [[ $group == AGENT_GUARD_PGID=<-> ]] || { kill -KILL $pid 2>/dev/null; fail 'invalid supervisor process group' }
38
+ pgid=${group#AGENT_GUARD_PGID=}
39
+ { zselect -t 300; kill -KILL -- -$pgid; kill -KILL $pid } >/dev/null 2>&1 &!
36
40
  watchdog=$!
37
41
  read -r -t 3 -p reason
38
42
  wait $pid
package/package.json CHANGED
@@ -1,26 +1,47 @@
1
1
  {
2
2
  "name": "@loophubs/agent-guard",
3
- "version": "0.1.0",
4
- "type": "module",
3
+ "version": "0.3.0",
5
4
  "description": "Blocks filesystem scans and credential reads that macOS records as TCC App Data access, and points the agent at a safe alternative.",
5
+ "homepage": "https://github.com/LoopHubs/agent-guard#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/LoopHubs/agent-guard/issues"
8
+ },
6
9
  "license": "MIT",
7
- "engines": {
8
- "bun": ">=1.4"
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/LoopHubs/agent-guard.git"
9
13
  },
10
14
  "bin": {
11
15
  "agent-guard": "bin/agent-guard"
12
16
  },
13
17
  "files": [
14
18
  "bin",
15
- "src"
19
+ "src",
20
+ "tsconfig.json"
16
21
  ],
22
+ "type": "module",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test tests/",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "oxlint . && oxfmt --check .",
30
+ "format": "oxfmt .",
31
+ "check": "bun run lint && bun run typecheck && bun run test"
32
+ },
17
33
  "dependencies": {
18
34
  "mvdan-sh": "0.10.1"
19
35
  },
20
36
  "devDependencies": {
21
- "typescript": "6.0.3"
37
+ "@types/bun": "1.4.2",
38
+ "@types/mvdan-sh": "0.10.9",
39
+ "@types/node": "26.6.3",
40
+ "oxfmt": "0.71.0",
41
+ "oxlint": "1.86.0",
42
+ "typescript": "7.0.2"
22
43
  },
23
- "scripts": {
24
- "test": "bunx tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler --allowImportingTsExtensions tests/*.ts tests/fixtures/*.ts && bun test tests/"
44
+ "engines": {
45
+ "bun": ">=1.4"
25
46
  }
26
47
  }
package/src/argv.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  import { basename, resolve } from "node:path";
2
- import { expandHome } from "./paths.ts";
3
- import type { Command, Word } from "./record.ts";
4
- import { searchRoles } from "./search-roles.ts";
5
2
 
6
- const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
3
+ import { expandHome } from "./paths";
4
+ import type { Command, Word } from "./record";
5
+ import { searchRoles } from "./search-roles";
6
+
7
+ const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh"]);
7
8
  const codePrograms = new Set(["python", "python3", "node", "bun", "deno", "ruby", "perl", "php", "osascript", "lua"]);
8
9
  const zshBuiltins = new Set(["echo", "printf", "print", "export", "typeset", "declare", "set", "command", "eval", "source", "."]);
9
10
  const shellCodeFlag = /^-[a-z]*c[a-z]*$/;
11
+ const sudoValueOption = /^(-[A-Za-z]*[ughpCDRTrtU]|--(user|group|host|prompt|chdir|chroot|role|type|other-user|close-from|command-timeout))$/;
12
+
13
+ // egrep and fgrep are grep with a fixed matcher.
14
+ export function programName(text: string): string {
15
+ const name = basename(text);
16
+ return name === "egrep" || name === "fgrep" ? "grep" : name;
17
+ }
10
18
 
11
19
  export function resolveCommand(cmd: Command, home: string): { children: string[]; code: string[] } {
12
20
  const w = cmd.argv;
@@ -17,82 +25,107 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
17
25
  let lastPrecommand = "";
18
26
  // Bash reads an assignment after nocorrect as an argument; zsh does not.
19
27
  const assignment = (word: Word) => word.role === "assign" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word.raw);
20
- while (i < w.length && (assignment(w[i]) || w[i].raw === "nocorrect")) w[i++].role = "precommand";
21
- while (i < w.length && ["builtin", "-", "noglob"].includes(w[i].text)) {
22
- lastPrecommand = w[i].text;
23
- w[i++].role = "precommand";
28
+ while (i < w.length && (assignment(w[i]!) || w[i]!.raw === "nocorrect")) w[i++]!.role = "precommand";
29
+ while (i < w.length && ["builtin", "-", "noglob"].includes(w[i]!.text)) {
30
+ lastPrecommand = w[i]!.text;
31
+ w[i++]!.role = "precommand";
24
32
  }
25
33
  // builtin runs nothing when the next word is not a builtin.
26
34
  if (lastPrecommand === "builtin" && !zshBuiltins.has(w[i]?.text ?? "")) i = w.length;
27
35
  wrappers: while (i < w.length) {
28
- const name = basename(w[i].text);
36
+ const name = basename(w[i]!.text);
29
37
  switch (name) {
30
38
  case "command":
31
- if (w[i].text !== "command") break wrappers;
32
- w[i++].role = "precommand";
33
- while (["-p", "--"].includes(w[i]?.text)) i++;
39
+ if (w[i]!.text !== "command") break wrappers;
40
+ w[i++]!.role = "precommand";
41
+ while (["-p", "--"].includes(w[i]?.text ?? "")) i++;
34
42
  // command -v only looks the name up.
35
- if (["-v", "-V"].includes(w[i]?.text)) i = w.length;
43
+ if (["-v", "-V"].includes(w[i]?.text ?? "")) i = w.length;
36
44
  break;
37
45
  case "exec":
38
- if (!shell || w[i].text !== "exec") break wrappers;
39
- w[i++].role = "precommand";
40
- while (["-c", "-l"].includes(w[i]?.text)) i++;
46
+ if (!shell || w[i]!.text !== "exec") break wrappers;
47
+ w[i++]!.role = "precommand";
48
+ while (["-c", "-l"].includes(w[i]?.text ?? "")) i++;
41
49
  if (w[i]?.text === "-a") i += 2;
42
50
  break;
43
51
  case "nohup":
44
- w[i++].role = "precommand";
52
+ w[i++]!.role = "precommand";
45
53
  if (w[i]?.text === "--") i++;
46
54
  break;
47
55
  case "timeout":
48
- w[i++].role = "precommand";
49
- while (w[i]?.text.startsWith("-")) {
50
- if (["-s", "--signal", "-k", "--kill-after"].includes(w[i].text)) i++;
56
+ w[i++]!.role = "precommand";
57
+ while (w[i]?.text?.startsWith("-")) {
58
+ if (["-s", "--signal", "-k", "--kill-after"].includes(w[i]!.text)) i++;
51
59
  i++;
52
60
  }
53
61
  i++; // duration
54
62
  break;
55
63
  case "nice":
56
- w[i++].role = "precommand";
64
+ w[i++]!.role = "precommand";
57
65
  if (w[i]?.text === "-n" || w[i]?.text === "--adjustment") i += 2;
58
66
  else if (/^(-n|--adjustment=|-\d)/.test(w[i]?.text ?? "")) i++;
59
67
  break;
68
+ case "sudo":
69
+ case "doas":
70
+ w[i++]!.role = "precommand";
71
+ while (i < w.length && w[i]!.text !== "--" && (w[i]!.text.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]!.text))) {
72
+ if (sudoValueOption.test(w[i]!.text)) w[++i]!.role = "precommand";
73
+ w[i++]!.role = "precommand";
74
+ }
75
+ if (w[i]?.text === "--") w[i++]!.role = "precommand";
76
+ break;
77
+ case "script":
78
+ // script [-adkpqr] [-F pipe] [-t time] [file [command ...]]
79
+ w[i++]!.role = "precommand";
80
+ while (w[i]?.text?.startsWith("-")) {
81
+ if (["-F", "-t"].includes(w[i]!.text)) i++;
82
+ i++;
83
+ }
84
+ if (w[i]) w[i++]!.role = "precommand";
85
+ break;
86
+ case "arch":
87
+ w[i++]!.role = "precommand";
88
+ while (w[i]?.text?.startsWith("-")) {
89
+ if (["-e", "-d", "-arch"].includes(w[i]!.text)) i++;
90
+ i++;
91
+ }
92
+ break;
60
93
  case "stdbuf":
61
- w[i++].role = "precommand";
94
+ w[i++]!.role = "precommand";
62
95
  while (/^-[ioe]/.test(w[i]?.text ?? "")) {
63
- if (/^-[ioe]$/.test(w[i].text)) i++;
96
+ if (/^-[ioe]$/.test(w[i]!.text)) i++;
64
97
  i++;
65
98
  }
66
99
  break;
67
100
  case "caffeinate":
68
- w[i++].role = "precommand";
101
+ w[i++]!.role = "precommand";
69
102
  while (/^-[disumtw]$/.test(w[i]?.text ?? "")) {
70
- if (w[i].text === "-t" || w[i].text === "-w") i++;
103
+ if (w[i]!.text === "-t" || w[i]!.text === "-w") i++;
71
104
  i++;
72
105
  }
73
106
  break;
74
107
  case "time":
75
- w[i++].role = "precommand";
76
- while (w[i]?.text.startsWith("-")) {
77
- if (["-f", "-o", "--format", "--output"].includes(w[i].text)) i++;
108
+ w[i++]!.role = "precommand";
109
+ while (w[i]?.text?.startsWith("-")) {
110
+ if (["-f", "-o", "--format", "--output"].includes(w[i]!.text)) i++;
78
111
  i++;
79
112
  }
80
113
  break;
81
114
  case "xargs":
82
- w[i++].role = "precommand";
83
- while (w[i]?.text.startsWith("-")) {
84
- if (["-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter", "--replace", "--max-args"].includes(w[i].text)) i++;
115
+ w[i++]!.role = "precommand";
116
+ while (w[i]?.text?.startsWith("-")) {
117
+ if (["-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter", "--replace", "--max-args"].includes(w[i]!.text)) i++;
85
118
  i++;
86
119
  }
87
120
  break;
88
121
  case "env":
89
- w[i++].role = "precommand";
122
+ w[i++]!.role = "precommand";
90
123
  while (i < w.length) {
91
- const arg = w[i].text;
124
+ const arg = w[i]!.text;
92
125
  if (arg === "-C") {
93
126
  if (w[i + 1]) {
94
- cmd.cwd = resolve(cmd.cwd, w[i + 1].text);
95
- w[i + 1].role = "precommand";
127
+ cmd.cwd = resolve(cmd.cwd, w[i + 1]!.text);
128
+ w[i + 1]!.role = "precommand";
96
129
  }
97
130
  i += 2;
98
131
  } else if (["-u", "-P"].includes(arg)) i += 2;
@@ -107,15 +140,15 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
107
140
  break;
108
141
  case "repeat":
109
142
  // zsh's repeat COUNT runs the rest; bash parses it as a plain command.
110
- if (!shell || w[i].text !== "repeat") break wrappers;
111
- w[i++].role = "precommand";
143
+ if (!shell || w[i]!.text !== "repeat") break wrappers;
144
+ w[i++]!.role = "precommand";
112
145
  i++;
113
146
  continue;
114
147
  case "envchain":
115
- w[i++].role = "precommand";
148
+ w[i++]!.role = "precommand";
116
149
  // --set, --list and --unset manage the store and run nothing.
117
- if (w[i]?.text.startsWith("-")) i = w.length;
118
- if (w[i]) w[i].role = "namespace";
150
+ if (w[i]?.text?.startsWith("-")) i = w.length;
151
+ if (w[i]) w[i]!.role = "namespace";
119
152
  i++;
120
153
  break;
121
154
  default:
@@ -127,25 +160,30 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
127
160
  cmd.shell = shell;
128
161
  if (i >= w.length) return { children, code };
129
162
  cmd.program = i;
130
- w[i].role = "program";
131
- if (w[i].text.startsWith("=") && w[i].text.length > 1) w[i].text = w[i].text.slice(1);
132
- const name = basename(w[i].text);
163
+ w[i]!.role = "program";
164
+ if (w[i]!.text.startsWith("=") && w[i]!.text.length > 1) w[i]!.text = w[i]!.text.slice(1);
165
+ const name = programName(w[i]!.text);
133
166
  const rest = w.slice(i + 1);
134
167
  if (["rg", "grep", "ag", "ack"].includes(name)) {
135
168
  searchRoles(cmd, rest, name);
136
169
  } else if (name === "fd") {
137
170
  let pattern = true;
138
171
  for (let n = 0; n < rest.length; n++) {
139
- const arg = rest[n].text;
172
+ const arg = rest[n]!.text;
140
173
  if (["-x", "-X", "--exec", "--exec-batch"].includes(arg)) {
141
- rest[n].role = "option";
142
- children.push(rest.slice(n + 1).map((word) => word.raw).join(" "));
174
+ rest[n]!.role = "option";
175
+ children.push(
176
+ rest
177
+ .slice(n + 1)
178
+ .map((word) => word.raw)
179
+ .join(" "),
180
+ );
143
181
  break;
144
182
  } else if ((/^(--search-path|--base-directory)(=|$)/.test(arg) || arg === "-C") && (arg.includes("=") || rest[n + 1])) {
145
183
  const separate = !arg.includes("=");
146
184
  const base = arg === "-C" || arg.startsWith("--base-directory");
147
- if (separate) rest[n++].role = "option";
148
- const path = rest[n];
185
+ if (separate) rest[n++]!.role = "option";
186
+ const path = rest[n]!;
149
187
  path.role = "path";
150
188
  path.value = separate ? path.text : arg.slice(arg.indexOf("=") + 1);
151
189
  if (base) {
@@ -153,24 +191,26 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
153
191
  cmd.cwd = resolve(cmd.cwd, path.value);
154
192
  }
155
193
  } else if (["-E", "-e", "-t", "-d", "--exclude", "--extension", "--type", "--max-depth"].includes(arg)) {
156
- rest[n].role = "option";
157
- if (rest[n + 1]) rest[++n].role = "optarg";
158
- } else if (arg.startsWith("-")) rest[n].role = "option";
159
- else if (pattern) (rest[n].role = "pattern"), (pattern = false);
160
- else rest[n].role = "path";
194
+ rest[n]!.role = "option";
195
+ if (rest[n + 1]) rest[++n]!.role = "optarg";
196
+ } else if (arg.startsWith("-")) rest[n]!.role = "option";
197
+ else if (pattern) {
198
+ rest[n]!.role = "pattern";
199
+ pattern = false;
200
+ } else rest[n]!.role = "path";
161
201
  }
162
202
  } else if (name === "du") {
163
203
  for (let n = 0; n < rest.length; n++) {
164
- const arg = rest[n].text;
204
+ const arg = rest[n]!.text;
165
205
  if (["-d", "-I", "-B", "-t", "--max-depth", "--exclude", "--block-size", "--threshold"].includes(arg) || /^-[^-]+[dIBt]$/.test(arg)) {
166
- rest[n].role = "option";
167
- if (rest[n + 1]) rest[++n].role = "optarg";
168
- } else if (arg.startsWith("-")) rest[n].role = "option";
206
+ rest[n]!.role = "option";
207
+ if (rest[n + 1]) rest[++n]!.role = "optarg";
208
+ } else if (arg.startsWith("-")) rest[n]!.role = "option";
169
209
  }
170
210
  } else if (shellPrograms.has(name)) {
171
211
  const flag = rest.findIndex((a) => shellCodeFlag.test(a.text));
172
212
  if (flag >= 0) {
173
- if (rest[flag + 1]) rest[flag + 1].role = "code";
213
+ if (rest[flag + 1]) rest[flag + 1]!.role = "code";
174
214
  children.push(rest[flag + 1]?.text ?? "");
175
215
  }
176
216
  } else if (name === "eval" && (shell || cmd.wrappers.includes("command"))) {
@@ -178,7 +218,7 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
178
218
  } else if (codePrograms.has(name)) {
179
219
  const flag = rest.findIndex((a) => /^(-[ceE]|--eval)$/.test(a.text));
180
220
  if (flag >= 0) {
181
- if (rest[flag + 1]) rest[flag + 1].role = "code";
221
+ if (rest[flag + 1]) rest[flag + 1]!.role = "code";
182
222
  code.push(rest[flag + 1]?.text ?? "");
183
223
  }
184
224
  }
package/src/comments.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  // mvdan/sh 3.5 starts a comment at a # right after a quote or an expansion,
2
2
  // where bash and zsh keep the # in the word and run the rest of the line.
3
- import sh from "mvdan-sh";
3
+
4
4
  import type { Comment, File } from "mvdan-sh";
5
+ import sh from "mvdan-sh";
5
6
 
6
7
  export function misreadComment(file: File, bytes: Buffer): boolean {
7
8
  let misread = false;
8
9
  sh.syntax.Walk(file, (node) => {
9
10
  if (node && sh.syntax.NodeType(node) === "Comment") {
10
11
  const at = (node as Comment).Hash.Offset();
11
- misread ||= at > 0 && !" \t\n;&|()<>".includes(String.fromCharCode(bytes[at - 1]));
12
+ misread ||= at > 0 && !" \t\n;&|()<>".includes(String.fromCharCode(bytes[at - 1]!));
12
13
  }
13
14
  return !misread;
14
15
  });
package/src/core.ts CHANGED
@@ -1,16 +1,31 @@
1
- import { parseScript } from "./frontend.ts";
2
- import { linkedRequest } from "./links.ts";
3
- import { absPath } from "./paths.ts";
4
- import { reasons } from "./reasons.ts";
5
- import type { Request, Runtime, Tool } from "./record.ts";
6
- import { appdataRules } from "./rules/appdata.ts";
7
- import { credentialFilesystemRules, credentialRules } from "./rules/credentials.ts";
8
- import { claudeWorkflowRules, codexWorkflowRules } from "./rules/workflow.ts";
1
+ import { parseScript } from "./frontend";
2
+ import { linkedRequest } from "./links";
3
+ import { absPath } from "./paths";
4
+ import { reasons } from "./reasons";
5
+ import type { Request, Runtime, Tool } from "./record";
6
+ import { appdataRules } from "./rules/appdata";
7
+ import { credentialFilesystemRules, credentialRules } from "./rules/credentials";
8
+ import { claudeWorkflowRules } from "./rules/workflow";
9
9
 
10
10
  export function buildRequest(runtime: Runtime, tool: Tool, cwd: string, input: string, glob: string, home: string): Request {
11
- const req: Request = { runtime, tool, home, cwd: absPath(cwd || "/", "/", home), operation: "", target: "", searchRoot: "", glob, commands: [], uninspectable: [] };
11
+ const inputCwd = cwd || "/";
12
+ const req: Request = {
13
+ runtime,
14
+ tool,
15
+ home,
16
+ cwd: absPath(inputCwd, "/", home),
17
+ inputCwd,
18
+ pathInput: input,
19
+ operation: "",
20
+ target: "",
21
+ searchRoot: "",
22
+ glob,
23
+ commands: [],
24
+ uninspectable: [],
25
+ parseFailed: false,
26
+ };
12
27
  if (tool === "bash") {
13
- if (input) Object.assign(req, parseScript(input, req.cwd, home));
28
+ if (input) Object.assign(req, parseScript(input, inputCwd, home));
14
29
  } else if (tool === "grep") {
15
30
  req.operation = "search";
16
31
  req.searchRoot = absPath(input || req.cwd, req.cwd, home);
@@ -22,10 +37,8 @@ export function buildRequest(runtime: Runtime, tool: Tool, cwd: string, input: s
22
37
  }
23
38
 
24
39
  export function evaluate(req: Request): string | undefined {
25
- const denials = [
26
- ...appdataRules(req),
27
- ...credentialRules(req),
28
- ];
40
+ if (req.parseFailed) return reasons.syntax;
41
+ const denials = [...appdataRules(req), ...credentialRules(req)];
29
42
  // Deny lexically protected paths before asking the filesystem about links.
30
43
  if (!denials.length) {
31
44
  try {
@@ -40,5 +53,5 @@ export function evaluate(req: Request): string | undefined {
40
53
  }
41
54
 
42
55
  export function suggestions(req: Request): string[] {
43
- return req.runtime === "claude" ? claudeWorkflowRules(req) : req.runtime === "codex" ? codexWorkflowRules(req) : [];
56
+ return req.runtime === "claude" ? claudeWorkflowRules(req) : [];
44
57
  }