@loophubs/agent-guard 0.1.0 → 0.2.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 +8 -84
- package/bin/agent-guard +14 -10
- package/package.json +29 -8
- package/src/argv.ts +66 -58
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +66 -45
- package/src/guard.ts +32 -16
- package/src/links.ts +25 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +64 -17
- package/src/pipeline.ts +9 -7
- package/src/reasons.ts +4 -3
- package/src/record.ts +5 -1
- package/src/rules/appdata.ts +10 -18
- package/src/rules/credentials.ts +63 -26
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +9 -15
- package/tsconfig.json +26 -0
- package/src/mvdan-sh.d.ts +0 -92
package/README.md
CHANGED
|
@@ -1,91 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Guard
|
|
2
2
|
|
|
3
|
-
`agent-guard` is a macOS pre-tool
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
## What it does
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
11
|
+
To install the package and register and verify a runtime, follow [the setup guide](docs/setup.md).
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
[GitHub Releases](https://github.com/LoopHubs/agent-guard/releases) describe version-specific changes to what the guard blocks or allows.
|
|
19
14
|
|
|
20
|
-
|
|
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
|
|
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
|
-
#
|
|
24
|
-
#
|
|
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
|
|
31
|
-
#
|
|
32
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.2.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
|
-
"
|
|
8
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
24
|
-
"
|
|
44
|
+
"engines": {
|
|
45
|
+
"bun": ">=1.4"
|
|
25
46
|
}
|
|
26
47
|
}
|
package/src/argv.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { basename, resolve } from "node:path";
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
2
|
+
|
|
3
|
+
import { expandHome } from "./paths";
|
|
4
|
+
import type { Command, Word } from "./record";
|
|
5
|
+
import { searchRoles } from "./search-roles";
|
|
5
6
|
|
|
6
7
|
const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
7
8
|
const codePrograms = new Set(["python", "python3", "node", "bun", "deno", "ruby", "perl", "php", "osascript", "lua"]);
|
|
@@ -17,82 +18,82 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
17
18
|
let lastPrecommand = "";
|
|
18
19
|
// Bash reads an assignment after nocorrect as an argument; zsh does not.
|
|
19
20
|
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]
|
|
21
|
-
while (i < w.length && ["builtin", "-", "noglob"].includes(w[i]
|
|
22
|
-
lastPrecommand = w[i]
|
|
23
|
-
w[i++]
|
|
21
|
+
while (i < w.length && (assignment(w[i]!) || w[i]!.raw === "nocorrect")) w[i++]!.role = "precommand";
|
|
22
|
+
while (i < w.length && ["builtin", "-", "noglob"].includes(w[i]!.text)) {
|
|
23
|
+
lastPrecommand = w[i]!.text;
|
|
24
|
+
w[i++]!.role = "precommand";
|
|
24
25
|
}
|
|
25
26
|
// builtin runs nothing when the next word is not a builtin.
|
|
26
27
|
if (lastPrecommand === "builtin" && !zshBuiltins.has(w[i]?.text ?? "")) i = w.length;
|
|
27
28
|
wrappers: while (i < w.length) {
|
|
28
|
-
const name = basename(w[i]
|
|
29
|
+
const name = basename(w[i]!.text);
|
|
29
30
|
switch (name) {
|
|
30
31
|
case "command":
|
|
31
|
-
if (w[i]
|
|
32
|
-
w[i++]
|
|
33
|
-
while (["-p", "--"].includes(w[i]?.text)) i++;
|
|
32
|
+
if (w[i]!.text !== "command") break wrappers;
|
|
33
|
+
w[i++]!.role = "precommand";
|
|
34
|
+
while (["-p", "--"].includes(w[i]?.text ?? "")) i++;
|
|
34
35
|
// command -v only looks the name up.
|
|
35
|
-
if (["-v", "-V"].includes(w[i]?.text)) i = w.length;
|
|
36
|
+
if (["-v", "-V"].includes(w[i]?.text ?? "")) i = w.length;
|
|
36
37
|
break;
|
|
37
38
|
case "exec":
|
|
38
|
-
if (!shell || w[i]
|
|
39
|
-
w[i++]
|
|
40
|
-
while (["-c", "-l"].includes(w[i]?.text)) i++;
|
|
39
|
+
if (!shell || w[i]!.text !== "exec") break wrappers;
|
|
40
|
+
w[i++]!.role = "precommand";
|
|
41
|
+
while (["-c", "-l"].includes(w[i]?.text ?? "")) i++;
|
|
41
42
|
if (w[i]?.text === "-a") i += 2;
|
|
42
43
|
break;
|
|
43
44
|
case "nohup":
|
|
44
|
-
w[i++]
|
|
45
|
+
w[i++]!.role = "precommand";
|
|
45
46
|
if (w[i]?.text === "--") i++;
|
|
46
47
|
break;
|
|
47
48
|
case "timeout":
|
|
48
|
-
w[i++]
|
|
49
|
-
while (w[i]?.text
|
|
50
|
-
if (["-s", "--signal", "-k", "--kill-after"].includes(w[i]
|
|
49
|
+
w[i++]!.role = "precommand";
|
|
50
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
51
|
+
if (["-s", "--signal", "-k", "--kill-after"].includes(w[i]!.text)) i++;
|
|
51
52
|
i++;
|
|
52
53
|
}
|
|
53
54
|
i++; // duration
|
|
54
55
|
break;
|
|
55
56
|
case "nice":
|
|
56
|
-
w[i++]
|
|
57
|
+
w[i++]!.role = "precommand";
|
|
57
58
|
if (w[i]?.text === "-n" || w[i]?.text === "--adjustment") i += 2;
|
|
58
59
|
else if (/^(-n|--adjustment=|-\d)/.test(w[i]?.text ?? "")) i++;
|
|
59
60
|
break;
|
|
60
61
|
case "stdbuf":
|
|
61
|
-
w[i++]
|
|
62
|
+
w[i++]!.role = "precommand";
|
|
62
63
|
while (/^-[ioe]/.test(w[i]?.text ?? "")) {
|
|
63
|
-
if (/^-[ioe]$/.test(w[i]
|
|
64
|
+
if (/^-[ioe]$/.test(w[i]!.text)) i++;
|
|
64
65
|
i++;
|
|
65
66
|
}
|
|
66
67
|
break;
|
|
67
68
|
case "caffeinate":
|
|
68
|
-
w[i++]
|
|
69
|
+
w[i++]!.role = "precommand";
|
|
69
70
|
while (/^-[disumtw]$/.test(w[i]?.text ?? "")) {
|
|
70
|
-
if (w[i]
|
|
71
|
+
if (w[i]!.text === "-t" || w[i]!.text === "-w") i++;
|
|
71
72
|
i++;
|
|
72
73
|
}
|
|
73
74
|
break;
|
|
74
75
|
case "time":
|
|
75
|
-
w[i++]
|
|
76
|
-
while (w[i]?.text
|
|
77
|
-
if (["-f", "-o", "--format", "--output"].includes(w[i]
|
|
76
|
+
w[i++]!.role = "precommand";
|
|
77
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
78
|
+
if (["-f", "-o", "--format", "--output"].includes(w[i]!.text)) i++;
|
|
78
79
|
i++;
|
|
79
80
|
}
|
|
80
81
|
break;
|
|
81
82
|
case "xargs":
|
|
82
|
-
w[i++]
|
|
83
|
-
while (w[i]?.text
|
|
84
|
-
if (["-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter", "--replace", "--max-args"].includes(w[i]
|
|
83
|
+
w[i++]!.role = "precommand";
|
|
84
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
85
|
+
if (["-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter", "--replace", "--max-args"].includes(w[i]!.text)) i++;
|
|
85
86
|
i++;
|
|
86
87
|
}
|
|
87
88
|
break;
|
|
88
89
|
case "env":
|
|
89
|
-
w[i++]
|
|
90
|
+
w[i++]!.role = "precommand";
|
|
90
91
|
while (i < w.length) {
|
|
91
|
-
const arg = w[i]
|
|
92
|
+
const arg = w[i]!.text;
|
|
92
93
|
if (arg === "-C") {
|
|
93
94
|
if (w[i + 1]) {
|
|
94
|
-
cmd.cwd = resolve(cmd.cwd, w[i + 1]
|
|
95
|
-
w[i + 1]
|
|
95
|
+
cmd.cwd = resolve(cmd.cwd, w[i + 1]!.text);
|
|
96
|
+
w[i + 1]!.role = "precommand";
|
|
96
97
|
}
|
|
97
98
|
i += 2;
|
|
98
99
|
} else if (["-u", "-P"].includes(arg)) i += 2;
|
|
@@ -107,15 +108,15 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
107
108
|
break;
|
|
108
109
|
case "repeat":
|
|
109
110
|
// zsh's repeat COUNT runs the rest; bash parses it as a plain command.
|
|
110
|
-
if (!shell || w[i]
|
|
111
|
-
w[i++]
|
|
111
|
+
if (!shell || w[i]!.text !== "repeat") break wrappers;
|
|
112
|
+
w[i++]!.role = "precommand";
|
|
112
113
|
i++;
|
|
113
114
|
continue;
|
|
114
115
|
case "envchain":
|
|
115
|
-
w[i++]
|
|
116
|
+
w[i++]!.role = "precommand";
|
|
116
117
|
// --set, --list and --unset manage the store and run nothing.
|
|
117
|
-
if (w[i]?.text
|
|
118
|
-
if (w[i]) w[i]
|
|
118
|
+
if (w[i]?.text?.startsWith("-")) i = w.length;
|
|
119
|
+
if (w[i]) w[i]!.role = "namespace";
|
|
119
120
|
i++;
|
|
120
121
|
break;
|
|
121
122
|
default:
|
|
@@ -127,25 +128,30 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
127
128
|
cmd.shell = shell;
|
|
128
129
|
if (i >= w.length) return { children, code };
|
|
129
130
|
cmd.program = i;
|
|
130
|
-
w[i]
|
|
131
|
-
if (w[i]
|
|
132
|
-
const name = basename(w[i]
|
|
131
|
+
w[i]!.role = "program";
|
|
132
|
+
if (w[i]!.text.startsWith("=") && w[i]!.text.length > 1) w[i]!.text = w[i]!.text.slice(1);
|
|
133
|
+
const name = basename(w[i]!.text);
|
|
133
134
|
const rest = w.slice(i + 1);
|
|
134
135
|
if (["rg", "grep", "ag", "ack"].includes(name)) {
|
|
135
136
|
searchRoles(cmd, rest, name);
|
|
136
137
|
} else if (name === "fd") {
|
|
137
138
|
let pattern = true;
|
|
138
139
|
for (let n = 0; n < rest.length; n++) {
|
|
139
|
-
const arg = rest[n]
|
|
140
|
+
const arg = rest[n]!.text;
|
|
140
141
|
if (["-x", "-X", "--exec", "--exec-batch"].includes(arg)) {
|
|
141
|
-
rest[n]
|
|
142
|
-
children.push(
|
|
142
|
+
rest[n]!.role = "option";
|
|
143
|
+
children.push(
|
|
144
|
+
rest
|
|
145
|
+
.slice(n + 1)
|
|
146
|
+
.map((word) => word.raw)
|
|
147
|
+
.join(" "),
|
|
148
|
+
);
|
|
143
149
|
break;
|
|
144
150
|
} else if ((/^(--search-path|--base-directory)(=|$)/.test(arg) || arg === "-C") && (arg.includes("=") || rest[n + 1])) {
|
|
145
151
|
const separate = !arg.includes("=");
|
|
146
152
|
const base = arg === "-C" || arg.startsWith("--base-directory");
|
|
147
|
-
if (separate) rest[n++]
|
|
148
|
-
const path = rest[n]
|
|
153
|
+
if (separate) rest[n++]!.role = "option";
|
|
154
|
+
const path = rest[n]!;
|
|
149
155
|
path.role = "path";
|
|
150
156
|
path.value = separate ? path.text : arg.slice(arg.indexOf("=") + 1);
|
|
151
157
|
if (base) {
|
|
@@ -153,24 +159,26 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
153
159
|
cmd.cwd = resolve(cmd.cwd, path.value);
|
|
154
160
|
}
|
|
155
161
|
} else if (["-E", "-e", "-t", "-d", "--exclude", "--extension", "--type", "--max-depth"].includes(arg)) {
|
|
156
|
-
rest[n]
|
|
157
|
-
if (rest[n + 1]) rest[++n]
|
|
158
|
-
} else if (arg.startsWith("-")) rest[n]
|
|
159
|
-
else if (pattern)
|
|
160
|
-
|
|
162
|
+
rest[n]!.role = "option";
|
|
163
|
+
if (rest[n + 1]) rest[++n]!.role = "optarg";
|
|
164
|
+
} else if (arg.startsWith("-")) rest[n]!.role = "option";
|
|
165
|
+
else if (pattern) {
|
|
166
|
+
rest[n]!.role = "pattern";
|
|
167
|
+
pattern = false;
|
|
168
|
+
} else rest[n]!.role = "path";
|
|
161
169
|
}
|
|
162
170
|
} else if (name === "du") {
|
|
163
171
|
for (let n = 0; n < rest.length; n++) {
|
|
164
|
-
const arg = rest[n]
|
|
172
|
+
const arg = rest[n]!.text;
|
|
165
173
|
if (["-d", "-I", "-B", "-t", "--max-depth", "--exclude", "--block-size", "--threshold"].includes(arg) || /^-[^-]+[dIBt]$/.test(arg)) {
|
|
166
|
-
rest[n]
|
|
167
|
-
if (rest[n + 1]) rest[++n]
|
|
168
|
-
} else if (arg.startsWith("-")) rest[n]
|
|
174
|
+
rest[n]!.role = "option";
|
|
175
|
+
if (rest[n + 1]) rest[++n]!.role = "optarg";
|
|
176
|
+
} else if (arg.startsWith("-")) rest[n]!.role = "option";
|
|
169
177
|
}
|
|
170
178
|
} else if (shellPrograms.has(name)) {
|
|
171
179
|
const flag = rest.findIndex((a) => shellCodeFlag.test(a.text));
|
|
172
180
|
if (flag >= 0) {
|
|
173
|
-
if (rest[flag + 1]) rest[flag + 1]
|
|
181
|
+
if (rest[flag + 1]) rest[flag + 1]!.role = "code";
|
|
174
182
|
children.push(rest[flag + 1]?.text ?? "");
|
|
175
183
|
}
|
|
176
184
|
} else if (name === "eval" && (shell || cmd.wrappers.includes("command"))) {
|
|
@@ -178,7 +186,7 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
178
186
|
} else if (codePrograms.has(name)) {
|
|
179
187
|
const flag = rest.findIndex((a) => /^(-[ceE]|--eval)$/.test(a.text));
|
|
180
188
|
if (flag >= 0) {
|
|
181
|
-
if (rest[flag + 1]) rest[flag + 1]
|
|
189
|
+
if (rest[flag + 1]) rest[flag + 1]!.role = "code";
|
|
182
190
|
code.push(rest[flag + 1]?.text ?? "");
|
|
183
191
|
}
|
|
184
192
|
}
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
26
|
-
|
|
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) :
|
|
56
|
+
return req.runtime === "claude" ? claudeWorkflowRules(req) : [];
|
|
44
57
|
}
|