@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 +8 -84
- package/bin/agent-guard +14 -10
- package/package.json +29 -8
- package/src/argv.ts +99 -59
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +107 -56
- package/src/guard.ts +32 -16
- package/src/links.ts +38 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +140 -20
- package/src/pipeline.ts +126 -14
- package/src/reasons.ts +6 -3
- package/src/record.ts +7 -1
- package/src/rules/appdata.ts +18 -23
- package/src/rules/credentials.ts +176 -33
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +23 -17
- 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.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
|
-
"
|
|
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,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
|
-
|
|
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]
|
|
21
|
-
while (i < w.length && ["builtin", "-", "noglob"].includes(w[i]
|
|
22
|
-
lastPrecommand = w[i]
|
|
23
|
-
w[i++]
|
|
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]
|
|
36
|
+
const name = basename(w[i]!.text);
|
|
29
37
|
switch (name) {
|
|
30
38
|
case "command":
|
|
31
|
-
if (w[i]
|
|
32
|
-
w[i++]
|
|
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]
|
|
39
|
-
w[i++]
|
|
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++]
|
|
52
|
+
w[i++]!.role = "precommand";
|
|
45
53
|
if (w[i]?.text === "--") i++;
|
|
46
54
|
break;
|
|
47
55
|
case "timeout":
|
|
48
|
-
w[i++]
|
|
49
|
-
while (w[i]?.text
|
|
50
|
-
if (["-s", "--signal", "-k", "--kill-after"].includes(w[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++]
|
|
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++]
|
|
94
|
+
w[i++]!.role = "precommand";
|
|
62
95
|
while (/^-[ioe]/.test(w[i]?.text ?? "")) {
|
|
63
|
-
if (/^-[ioe]$/.test(w[i]
|
|
96
|
+
if (/^-[ioe]$/.test(w[i]!.text)) i++;
|
|
64
97
|
i++;
|
|
65
98
|
}
|
|
66
99
|
break;
|
|
67
100
|
case "caffeinate":
|
|
68
|
-
w[i++]
|
|
101
|
+
w[i++]!.role = "precommand";
|
|
69
102
|
while (/^-[disumtw]$/.test(w[i]?.text ?? "")) {
|
|
70
|
-
if (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++]
|
|
76
|
-
while (w[i]?.text
|
|
77
|
-
if (["-f", "-o", "--format", "--output"].includes(w[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++]
|
|
83
|
-
while (w[i]?.text
|
|
84
|
-
if (["-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter", "--replace", "--max-args"].includes(w[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++]
|
|
122
|
+
w[i++]!.role = "precommand";
|
|
90
123
|
while (i < w.length) {
|
|
91
|
-
const arg = w[i]
|
|
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]
|
|
95
|
-
w[i + 1]
|
|
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]
|
|
111
|
-
w[i++]
|
|
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++]
|
|
148
|
+
w[i++]!.role = "precommand";
|
|
116
149
|
// --set, --list and --unset manage the store and run nothing.
|
|
117
|
-
if (w[i]?.text
|
|
118
|
-
if (w[i]) w[i]
|
|
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]
|
|
131
|
-
if (w[i]
|
|
132
|
-
const name =
|
|
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]
|
|
172
|
+
const arg = rest[n]!.text;
|
|
140
173
|
if (["-x", "-X", "--exec", "--exec-batch"].includes(arg)) {
|
|
141
|
-
rest[n]
|
|
142
|
-
children.push(
|
|
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++]
|
|
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]
|
|
157
|
-
if (rest[n + 1]) rest[++n]
|
|
158
|
-
} else if (arg.startsWith("-")) rest[n]
|
|
159
|
-
else if (pattern)
|
|
160
|
-
|
|
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]
|
|
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]
|
|
167
|
-
if (rest[n + 1]) rest[++n]
|
|
168
|
-
} else if (arg.startsWith("-")) rest[n]
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
}
|