bash-write-targets 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hao li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: bash-write-targets
3
+ Version: 0.1.0
4
+ Summary: Statically list the file-write targets of a bash command (redirects, tee, -o flags, cp/mv destinations)
5
+ Author: hao li
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/bash-write-targets
8
+ Keywords: bash,shell,static-analysis,security,ai-agents,audit
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # bash-write-targets
15
+
16
+ Give it a bash command string; get back the list of files it may write to —
17
+ before anything runs.
18
+
19
+ ```bash
20
+ $ targets 'make 2>&1 | tee build.log; curl -s -o app.tar.gz https://example.com/app'
21
+ build.log
22
+ app.tar.gz
23
+ ```
24
+
25
+ Stdlib-only. Static analysis — the command is **never executed**.
26
+
27
+ ## Why
28
+
29
+ AI coding agents run a lot of bash. Before a command executes, an auditor
30
+ (like a PreToolUse hook) wants to know: *which files could this touch?*
31
+ Grepping for `>` misses `tee`, `curl -o`, `sed -i`, `dd of=`, `cp`/`mv`
32
+ destinations, and writes hidden inside `$(...)`.
33
+
34
+ `bash-write-targets` answers that one question, for one command, with no
35
+ dependencies.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install bash-write-targets
41
+ ```
42
+
43
+ Requires Python 3.9+.
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ targets '<bash command>' # one target per line
49
+ targets --json '<bash command>' # JSON array, for hooks and scripts
50
+ ```
51
+
52
+ Examples:
53
+
54
+ ```bash
55
+ $ targets 'echo hello > out.txt'
56
+ out.txt
57
+
58
+ $ targets 'cat > note.txt <<EOF
59
+ hello > world
60
+ EOF'
61
+ note.txt
62
+
63
+ $ targets 'grep -r foo . | sort -o sorted.txt; mv sorted.txt final.txt'
64
+ sorted.txt
65
+ final.txt
66
+
67
+ $ targets 'sed -i "s/a/b/g" config.ini && dd if=/dev/zero of=disk.img bs=1M count=1'
68
+ config.ini
69
+ disk.img
70
+ ```
71
+
72
+ Covered: `>` `>>` `>|` `&>` `&>>` `2>` `2>>` `<>`, heredocs (bodies are
73
+ stripped, not parsed), `$(...)` and backticks (scanned recursively), `tee`
74
+ (including `-a`), `cp`/`mv`/`install` destinations, `sed -i`, `dd of=`,
75
+ `truncate`, `touch`, `shred`, `zip` archives, and `-o`/`--output`-style
76
+ flags for `curl`, `wget`, `gcc`/`cc`/`g++`/`clang`, `sort`, `pandoc`,
77
+ `psql`, and `tar -f`.
78
+
79
+ Deliberately skipped (not guessed at): `/dev/null`, fd duplications
80
+ (`2>&1`), unresolvable `$VAR` expansions, bare globs, and input-side `<`
81
+ redirections.
82
+
83
+ ## Differentiation
84
+
85
+ This project exists because
86
+ [agent-guard](https://github.com/hahahahahahahahah6/agent-guard) says so —
87
+ literally. Its README's *Honest limitations* section documents the
88
+ situation:
89
+
90
+ > **Bash write-target extraction is static and approximate.** It is
91
+ > shlex-based, so heavy quoting, `eval`, command substitution building
92
+ > paths at runtime, and `python3 -c "open(...).write(...)"` are known gaps
93
+ > — the target list is conservative by design (a missed target is a miss,
94
+ > never a crash). ... **This is the documented frontier for a future
95
+ > version, not a finished parser.**
96
+
97
+ `bash-write-targets` is that future version, as a standalone tool:
98
+
99
+ - **Deeper than the inline heuristic.** agent-guard's hook extracts
100
+ targets inline with a shallow shlex pass, good enough for a hot-path
101
+ block decision. This package goes further: heredoc-aware stripping,
102
+ recursive command-substitution scanning, and command-aware operand
103
+ parsing (`tee`, `sed -i` script-vs-file disambiguation, `cp -t`,
104
+ `--output=` forms).
105
+ - **Reusable anywhere.** A PreToolUse hook, a CI audit step, or a human
106
+ double-checking a pasted command — one `targets` call, one line per
107
+ file, plus `--json` for machines.
108
+ - **Safe by construction.** Pure static parsing over the command string.
109
+ Nothing is executed, no subshell is spawned, no file is touched —
110
+ auditing a destructive command cannot itself be destructive.
111
+
112
+ ## Honest limitations
113
+
114
+ - Static, not proof. `eval`, paths assembled at runtime, and
115
+ `python3 -c "open(p).write(x)"` are known gaps — the list is
116
+ conservative by design.
117
+ - `$VAR` and bare globs are skipped rather than resolved.
118
+ - Network sinks (e.g. `curl --data` POSTing somewhere) are out of scope:
119
+ this tool lists *file* write targets.
120
+ - A missed target is a miss, never a crash: malformed input yields fewer
121
+ results, not an exception.
122
+
123
+ ## License
124
+
125
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,112 @@
1
+ # bash-write-targets
2
+
3
+ Give it a bash command string; get back the list of files it may write to —
4
+ before anything runs.
5
+
6
+ ```bash
7
+ $ targets 'make 2>&1 | tee build.log; curl -s -o app.tar.gz https://example.com/app'
8
+ build.log
9
+ app.tar.gz
10
+ ```
11
+
12
+ Stdlib-only. Static analysis — the command is **never executed**.
13
+
14
+ ## Why
15
+
16
+ AI coding agents run a lot of bash. Before a command executes, an auditor
17
+ (like a PreToolUse hook) wants to know: *which files could this touch?*
18
+ Grepping for `>` misses `tee`, `curl -o`, `sed -i`, `dd of=`, `cp`/`mv`
19
+ destinations, and writes hidden inside `$(...)`.
20
+
21
+ `bash-write-targets` answers that one question, for one command, with no
22
+ dependencies.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install bash-write-targets
28
+ ```
29
+
30
+ Requires Python 3.9+.
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ targets '<bash command>' # one target per line
36
+ targets --json '<bash command>' # JSON array, for hooks and scripts
37
+ ```
38
+
39
+ Examples:
40
+
41
+ ```bash
42
+ $ targets 'echo hello > out.txt'
43
+ out.txt
44
+
45
+ $ targets 'cat > note.txt <<EOF
46
+ hello > world
47
+ EOF'
48
+ note.txt
49
+
50
+ $ targets 'grep -r foo . | sort -o sorted.txt; mv sorted.txt final.txt'
51
+ sorted.txt
52
+ final.txt
53
+
54
+ $ targets 'sed -i "s/a/b/g" config.ini && dd if=/dev/zero of=disk.img bs=1M count=1'
55
+ config.ini
56
+ disk.img
57
+ ```
58
+
59
+ Covered: `>` `>>` `>|` `&>` `&>>` `2>` `2>>` `<>`, heredocs (bodies are
60
+ stripped, not parsed), `$(...)` and backticks (scanned recursively), `tee`
61
+ (including `-a`), `cp`/`mv`/`install` destinations, `sed -i`, `dd of=`,
62
+ `truncate`, `touch`, `shred`, `zip` archives, and `-o`/`--output`-style
63
+ flags for `curl`, `wget`, `gcc`/`cc`/`g++`/`clang`, `sort`, `pandoc`,
64
+ `psql`, and `tar -f`.
65
+
66
+ Deliberately skipped (not guessed at): `/dev/null`, fd duplications
67
+ (`2>&1`), unresolvable `$VAR` expansions, bare globs, and input-side `<`
68
+ redirections.
69
+
70
+ ## Differentiation
71
+
72
+ This project exists because
73
+ [agent-guard](https://github.com/hahahahahahahahah6/agent-guard) says so —
74
+ literally. Its README's *Honest limitations* section documents the
75
+ situation:
76
+
77
+ > **Bash write-target extraction is static and approximate.** It is
78
+ > shlex-based, so heavy quoting, `eval`, command substitution building
79
+ > paths at runtime, and `python3 -c "open(...).write(...)"` are known gaps
80
+ > — the target list is conservative by design (a missed target is a miss,
81
+ > never a crash). ... **This is the documented frontier for a future
82
+ > version, not a finished parser.**
83
+
84
+ `bash-write-targets` is that future version, as a standalone tool:
85
+
86
+ - **Deeper than the inline heuristic.** agent-guard's hook extracts
87
+ targets inline with a shallow shlex pass, good enough for a hot-path
88
+ block decision. This package goes further: heredoc-aware stripping,
89
+ recursive command-substitution scanning, and command-aware operand
90
+ parsing (`tee`, `sed -i` script-vs-file disambiguation, `cp -t`,
91
+ `--output=` forms).
92
+ - **Reusable anywhere.** A PreToolUse hook, a CI audit step, or a human
93
+ double-checking a pasted command — one `targets` call, one line per
94
+ file, plus `--json` for machines.
95
+ - **Safe by construction.** Pure static parsing over the command string.
96
+ Nothing is executed, no subshell is spawned, no file is touched —
97
+ auditing a destructive command cannot itself be destructive.
98
+
99
+ ## Honest limitations
100
+
101
+ - Static, not proof. `eval`, paths assembled at runtime, and
102
+ `python3 -c "open(p).write(x)"` are known gaps — the list is
103
+ conservative by design.
104
+ - `$VAR` and bare globs are skipped rather than resolved.
105
+ - Network sinks (e.g. `curl --data` POSTing somewhere) are out of scope:
106
+ this tool lists *file* write targets.
107
+ - A missed target is a miss, never a crash: malformed input yields fewer
108
+ results, not an exception.
109
+
110
+ ## License
111
+
112
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bash-write-targets"
7
+ version = "0.1.0"
8
+ description = "Statically list the file-write targets of a bash command (redirects, tee, -o flags, cp/mv destinations)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "hao li" }]
13
+ keywords = ["bash", "shell", "static-analysis", "security", "ai-agents", "audit"]
14
+
15
+ [project.scripts]
16
+ targets = "bash_write_targets.cli:main"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/hahahahahahahahah6/bash-write-targets"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """bash-write-targets: statically list the files a bash command may write to."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,39 @@
1
+ """CLI: targets '<bash command>' -> list the files the command may write to."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .extract import extract
9
+
10
+
11
+ def main(argv=None):
12
+ parser = argparse.ArgumentParser(
13
+ prog="targets",
14
+ description=(
15
+ "Statically list the file-write targets of a bash command "
16
+ "(redirections, tee, -o/--output flags, cp/mv destinations, ...). "
17
+ "The command is never executed."
18
+ ),
19
+ )
20
+ parser.add_argument("command", help="bash command string to audit")
21
+ parser.add_argument(
22
+ "--json", action="store_true", help="print targets as a JSON array"
23
+ )
24
+ parser.add_argument(
25
+ "--version", action="version", version="%(prog)s " + __version__
26
+ )
27
+ args = parser.parse_args(argv)
28
+
29
+ targets = extract(args.command)
30
+ if args.json:
31
+ print(json.dumps(targets))
32
+ else:
33
+ for t in targets:
34
+ print(t)
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ sys.exit(main())
@@ -0,0 +1,340 @@
1
+ """Static extraction of file-write targets from a bash command string.
2
+
3
+ Never executes anything. Conservative by design: a missed target is a miss,
4
+ never a crash.
5
+
6
+ Covered:
7
+ * redirections: >, >>, >|, &>, &>>, 2>, 2>>, <>
8
+ * heredocs (<<EOF): bodies are stripped so their contents are not mistaken
9
+ for targets
10
+ * command substitution: $(...) and `...` are scanned recursively
11
+ * tee (all operands, -a/--append aware)
12
+ * cp / mv / install (destination operand; --target-directory aware)
13
+ * sed -i (in-place operands)
14
+ * dd of=FILE, truncate, touch, shred, zip (archive operand)
15
+ * -o / --output style flags for curl, wget, gcc/cc/g++/clang, sort,
16
+ pandoc, psql, tar -f/--file
17
+
18
+ Known gaps (documented, not guessed at):
19
+ * eval and paths built at runtime
20
+ * unresolvable $VAR expansions (skipped, not guessed)
21
+ * bare globs (skipped)
22
+ * /dev/null (skipped)
23
+ * python3 -c "open(...).write(...)" style embedded writes
24
+ * network sinks (e.g. curl --data posting to a URL)
25
+ """
26
+
27
+ import re
28
+ import shlex
29
+
30
+ _SEPARATORS = {"|", "||", "&", "&&", ";", "(", ")", "\n"}
31
+ _PREFIX_COMMANDS = {"sudo", "env", "command", "time", "nohup"}
32
+
33
+ _FD_WORD = re.compile(r"^&?\d+$")
34
+ _ASSIGN_WORD = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
35
+ _DD_OF = re.compile(r"^of=(.+)$")
36
+ _GLOB_CHARS = re.compile(r"[*?\[]")
37
+ _VAR_CHARS = re.compile(r"[$]")
38
+
39
+ # command -> output flags whose next token (or --flag=value) names a file
40
+ KNOWN_OUTPUT_FLAGS = {
41
+ "curl": ("-o", "--output"),
42
+ "wget": ("-O", "--output-document"),
43
+ "gcc": ("-o",),
44
+ "cc": ("-o",),
45
+ "g++": ("-o",),
46
+ "clang": ("-o",),
47
+ "clang++": ("-o",),
48
+ "sort": ("-o", "--output"),
49
+ "pandoc": ("-o", "--output"),
50
+ "psql": ("-o", "--output"),
51
+ "tar": ("-f", "--file"),
52
+ }
53
+
54
+
55
+ def _valid_target(word):
56
+ """Decide whether a candidate word is a plausible file path."""
57
+ if not word:
58
+ return False
59
+ if word == "/dev/null":
60
+ return False
61
+ if word == "-": # stdout/stdin placeholder
62
+ return False
63
+ if _FD_WORD.match(word):
64
+ return False
65
+ if _VAR_CHARS.search(word):
66
+ return False # unresolvable at static time; skip, don't guess
67
+ if _GLOB_CHARS.search(word):
68
+ return False # bare globs are ambiguous; skip, don't guess
69
+ return True
70
+
71
+
72
+ def _strip_heredocs(text):
73
+ """Remove heredoc bodies so their contents are not parsed as commands."""
74
+ pattern = re.compile(r"<<-?[ \t]*(?P<q>['\"]?)(?P<word>[A-Za-z0-9_]+)(?P=q)")
75
+ out = text
76
+ while True:
77
+ m = pattern.search(out)
78
+ if not m:
79
+ return out
80
+ word = m.group("word")
81
+ term = re.compile(r"^" + re.escape(word) + r"[ \t]*$", re.M)
82
+ tm = term.search(out, m.end())
83
+ end = tm.end() if tm else len(out)
84
+ out = out[: m.start()] + " " + out[end:]
85
+
86
+
87
+ def _extract_substitutions(text):
88
+ """Pull out $(...) and `...` spans; return (outer_text, [inner, ...])."""
89
+ inners = []
90
+ out = []
91
+ i, n = 0, len(text)
92
+ while i < n:
93
+ if text.startswith("$(", i):
94
+ depth, j = 1, i + 2
95
+ in_single = in_double = False
96
+ while j < n and depth:
97
+ c = text[j]
98
+ if in_single:
99
+ if c == "'":
100
+ in_single = False
101
+ elif in_double:
102
+ if c == '"':
103
+ in_double = False
104
+ elif c == "\\":
105
+ j += 1
106
+ else:
107
+ if c == "'":
108
+ in_single = True
109
+ elif c == '"':
110
+ in_double = True
111
+ elif c == "(":
112
+ depth += 1
113
+ elif c == ")":
114
+ depth -= 1
115
+ j += 1
116
+ if depth == 0:
117
+ inners.append(text[i + 2 : j - 1])
118
+ out.append(" ")
119
+ i = j
120
+ else: # unbalanced; keep scanning char by char
121
+ out.append(text[i])
122
+ i += 1
123
+ elif text[i] == "`":
124
+ j = i + 1
125
+ while j < n and text[j] != "`":
126
+ if text[j] == "\\":
127
+ j += 1
128
+ j += 1
129
+ if j < n:
130
+ inners.append(text[i + 1 : j])
131
+ out.append(" ")
132
+ i = j + 1
133
+ else:
134
+ out.append(text[i])
135
+ i += 1
136
+ else:
137
+ out.append(text[i])
138
+ i += 1
139
+ return "".join(out), inners
140
+
141
+
142
+ def _tokenize(text):
143
+ """shlex tokenize with shell punctuation kept as separate tokens."""
144
+ try:
145
+ lexer = shlex.shlex(text, posix=True, punctuation_chars="|&;()<>\n")
146
+ except TypeError: # very old Python without punctuation_chars
147
+ lexer = shlex.shlex(text, posix=True)
148
+ lexer.whitespace_split = True
149
+ raw = list(lexer)
150
+ # merge doubled punctuation chars: >>, <<, &&, ||
151
+ merged = []
152
+ for tok in raw:
153
+ if merged and tok in (">", "<", "&", "|") and merged[-1] == tok:
154
+ merged[-1] = tok * 2
155
+ else:
156
+ merged.append(tok)
157
+ return merged
158
+
159
+
160
+ def _redir_op(tok):
161
+ """Normalize a token to a redirection operator, or None.
162
+
163
+ Leading fd digits are stripped: '2>' -> '>'.
164
+ """
165
+ m = re.match(r"^(\d*)(>>?|<<-?|>&|&>>?|>\||<>)$", tok)
166
+ if not m:
167
+ return None
168
+ return m.group(2)
169
+
170
+
171
+ def _scan_tokens(tokens, add):
172
+ i, n = 0, len(tokens)
173
+ at_start = True
174
+ cmd = None
175
+ flags = []
176
+ operands = []
177
+ pending_value = None # flag waiting for its value (name, kind)
178
+ target_dir = None
179
+
180
+ def finish_segment():
181
+ nonlocal cmd, flags, operands, pending_value, target_dir
182
+ if cmd == "tee":
183
+ for op in operands:
184
+ add(op)
185
+ elif cmd in ("cp", "mv", "install"):
186
+ if target_dir is not None:
187
+ add(target_dir)
188
+ elif len(operands) >= 2:
189
+ add(operands[-1])
190
+ elif cmd == "sed":
191
+ if any(
192
+ f == "-i" or f.startswith("-i") or f == "--in-place"
193
+ for f in flags
194
+ ):
195
+ files = operands
196
+ # without -e/-f the first operand is the sed script, not a file
197
+ if files and not any(
198
+ f == "-e" or f == "-f" or f.startswith("-e") or f.startswith("--expression")
199
+ for f in flags
200
+ ):
201
+ files = files[1:]
202
+ for op in files:
203
+ add(op)
204
+ elif cmd == "dd":
205
+ for t in operands + flags:
206
+ m = _DD_OF.match(t)
207
+ if m:
208
+ add(m.group(1))
209
+ elif cmd in ("truncate", "touch", "shred"):
210
+ for op in operands:
211
+ add(op)
212
+ elif cmd == "zip":
213
+ if operands:
214
+ add(operands[0])
215
+ cmd, flags, operands, pending_value, target_dir = (
216
+ None, [], [], None, None,
217
+ )
218
+
219
+ def consume_flag_value():
220
+ nonlocal i, pending_value, target_dir
221
+ name, kind = pending_value
222
+ pending_value = None
223
+ if i < n and tokens[i] not in _SEPARATORS and _redir_op(tokens[i]) is None:
224
+ if kind == "output":
225
+ add(tokens[i])
226
+ elif kind == "targetdir":
227
+ target_dir = tokens[i]
228
+ # kind == "skip" (e.g. truncate -s): value consumed, not a target
229
+ i += 1
230
+
231
+ while i < n:
232
+ tok = tokens[i]
233
+
234
+ if pending_value is not None:
235
+ consume_flag_value()
236
+ continue
237
+
238
+ if tok in _SEPARATORS:
239
+ finish_segment()
240
+ at_start = True
241
+ i += 1
242
+ continue
243
+
244
+ op = _redir_op(tok)
245
+ if op is not None:
246
+ nxt = tokens[i + 1] if i + 1 < n else None
247
+ if op in ("<<", "<<-"):
248
+ i += 2 # heredoc bodies already stripped; skip delimiter
249
+ continue
250
+ if op == "<":
251
+ i += 2 if nxt not in (None, "(") else 1 # input side, not a target
252
+ continue
253
+ # output redirections: >, >>, >|, &>, &>>, <>
254
+ if nxt == "(":
255
+ i += 1 # process substitution >(...); not a file target
256
+ continue
257
+ if nxt == "&":
258
+ # >&2 / 2>&1 style fd duplication; only a real path counts
259
+ tgt = tokens[i + 2] if i + 2 < n else None
260
+ if (
261
+ tgt is not None
262
+ and tgt not in _SEPARATORS
263
+ and _redir_op(tgt) is None
264
+ and not _FD_WORD.match(tgt)
265
+ ):
266
+ add(tgt)
267
+ i += 3
268
+ continue
269
+ if nxt is None or nxt in _SEPARATORS or _redir_op(nxt) is not None:
270
+ i += 1 # malformed; skip
271
+ continue
272
+ add(nxt)
273
+ i += 2
274
+ continue
275
+
276
+ if at_start:
277
+ if _ASSIGN_WORD.match(tok):
278
+ i += 1 # FOO=bar env assignment
279
+ continue
280
+ if tok in _PREFIX_COMMANDS:
281
+ i += 1
282
+ continue
283
+ cmd = tok.split("/")[-1]
284
+ at_start = False
285
+ i += 1
286
+ continue
287
+
288
+ # operand / flag position for the current command
289
+ if tok.startswith("-") and tok != "-":
290
+ flags.append(tok)
291
+ if cmd in KNOWN_OUTPUT_FLAGS and tok in KNOWN_OUTPUT_FLAGS[cmd]:
292
+ if "=" in tok:
293
+ _, _, value = tok.partition("=")
294
+ add(value)
295
+ else:
296
+ pending_value = (tok, "output")
297
+ elif "=" in tok and tok.split("=", 1)[0] in (
298
+ "--target-directory",
299
+ "--output",
300
+ "--file",
301
+ "--output-document",
302
+ ):
303
+ _, _, value = tok.partition("=")
304
+ if tok.startswith("--target-directory"):
305
+ target_dir = value
306
+ else:
307
+ add(value)
308
+ elif cmd == "truncate" and tok in ("-s", "--size"):
309
+ pending_value = (tok, "skip")
310
+ elif cmd in ("cp", "mv", "install") and tok in ("-t", "--target-directory"):
311
+ pending_value = (tok, "targetdir")
312
+ i += 1
313
+ continue
314
+
315
+ if _ASSIGN_WORD.match(tok) and cmd != "dd":
316
+ i += 1 # VAR=x in operand position is not a file
317
+ continue
318
+ operands.append(tok)
319
+ i += 1
320
+
321
+ finish_segment()
322
+
323
+
324
+ def extract(command):
325
+ """Return the ordered, deduplicated list of files the command may write.
326
+
327
+ Pure static analysis; the command is never executed.
328
+ """
329
+ targets = []
330
+
331
+ def add(word):
332
+ if _valid_target(word) and word not in targets:
333
+ targets.append(word)
334
+
335
+ outer, inners = _extract_substitutions(_strip_heredocs(command))
336
+ for inner in inners:
337
+ for t in extract(inner):
338
+ add(t)
339
+ _scan_tokens(_tokenize(outer), add)
340
+ return targets
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: bash-write-targets
3
+ Version: 0.1.0
4
+ Summary: Statically list the file-write targets of a bash command (redirects, tee, -o flags, cp/mv destinations)
5
+ Author: hao li
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/bash-write-targets
8
+ Keywords: bash,shell,static-analysis,security,ai-agents,audit
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # bash-write-targets
15
+
16
+ Give it a bash command string; get back the list of files it may write to —
17
+ before anything runs.
18
+
19
+ ```bash
20
+ $ targets 'make 2>&1 | tee build.log; curl -s -o app.tar.gz https://example.com/app'
21
+ build.log
22
+ app.tar.gz
23
+ ```
24
+
25
+ Stdlib-only. Static analysis — the command is **never executed**.
26
+
27
+ ## Why
28
+
29
+ AI coding agents run a lot of bash. Before a command executes, an auditor
30
+ (like a PreToolUse hook) wants to know: *which files could this touch?*
31
+ Grepping for `>` misses `tee`, `curl -o`, `sed -i`, `dd of=`, `cp`/`mv`
32
+ destinations, and writes hidden inside `$(...)`.
33
+
34
+ `bash-write-targets` answers that one question, for one command, with no
35
+ dependencies.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install bash-write-targets
41
+ ```
42
+
43
+ Requires Python 3.9+.
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ targets '<bash command>' # one target per line
49
+ targets --json '<bash command>' # JSON array, for hooks and scripts
50
+ ```
51
+
52
+ Examples:
53
+
54
+ ```bash
55
+ $ targets 'echo hello > out.txt'
56
+ out.txt
57
+
58
+ $ targets 'cat > note.txt <<EOF
59
+ hello > world
60
+ EOF'
61
+ note.txt
62
+
63
+ $ targets 'grep -r foo . | sort -o sorted.txt; mv sorted.txt final.txt'
64
+ sorted.txt
65
+ final.txt
66
+
67
+ $ targets 'sed -i "s/a/b/g" config.ini && dd if=/dev/zero of=disk.img bs=1M count=1'
68
+ config.ini
69
+ disk.img
70
+ ```
71
+
72
+ Covered: `>` `>>` `>|` `&>` `&>>` `2>` `2>>` `<>`, heredocs (bodies are
73
+ stripped, not parsed), `$(...)` and backticks (scanned recursively), `tee`
74
+ (including `-a`), `cp`/`mv`/`install` destinations, `sed -i`, `dd of=`,
75
+ `truncate`, `touch`, `shred`, `zip` archives, and `-o`/`--output`-style
76
+ flags for `curl`, `wget`, `gcc`/`cc`/`g++`/`clang`, `sort`, `pandoc`,
77
+ `psql`, and `tar -f`.
78
+
79
+ Deliberately skipped (not guessed at): `/dev/null`, fd duplications
80
+ (`2>&1`), unresolvable `$VAR` expansions, bare globs, and input-side `<`
81
+ redirections.
82
+
83
+ ## Differentiation
84
+
85
+ This project exists because
86
+ [agent-guard](https://github.com/hahahahahahahahah6/agent-guard) says so —
87
+ literally. Its README's *Honest limitations* section documents the
88
+ situation:
89
+
90
+ > **Bash write-target extraction is static and approximate.** It is
91
+ > shlex-based, so heavy quoting, `eval`, command substitution building
92
+ > paths at runtime, and `python3 -c "open(...).write(...)"` are known gaps
93
+ > — the target list is conservative by design (a missed target is a miss,
94
+ > never a crash). ... **This is the documented frontier for a future
95
+ > version, not a finished parser.**
96
+
97
+ `bash-write-targets` is that future version, as a standalone tool:
98
+
99
+ - **Deeper than the inline heuristic.** agent-guard's hook extracts
100
+ targets inline with a shallow shlex pass, good enough for a hot-path
101
+ block decision. This package goes further: heredoc-aware stripping,
102
+ recursive command-substitution scanning, and command-aware operand
103
+ parsing (`tee`, `sed -i` script-vs-file disambiguation, `cp -t`,
104
+ `--output=` forms).
105
+ - **Reusable anywhere.** A PreToolUse hook, a CI audit step, or a human
106
+ double-checking a pasted command — one `targets` call, one line per
107
+ file, plus `--json` for machines.
108
+ - **Safe by construction.** Pure static parsing over the command string.
109
+ Nothing is executed, no subshell is spawned, no file is touched —
110
+ auditing a destructive command cannot itself be destructive.
111
+
112
+ ## Honest limitations
113
+
114
+ - Static, not proof. `eval`, paths assembled at runtime, and
115
+ `python3 -c "open(p).write(x)"` are known gaps — the list is
116
+ conservative by design.
117
+ - `$VAR` and bare globs are skipped rather than resolved.
118
+ - Network sinks (e.g. `curl --data` POSTing somewhere) are out of scope:
119
+ this tool lists *file* write targets.
120
+ - A missed target is a miss, never a crash: malformed input yields fewer
121
+ results, not an exception.
122
+
123
+ ## License
124
+
125
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/bash_write_targets/__init__.py
5
+ src/bash_write_targets/cli.py
6
+ src/bash_write_targets/extract.py
7
+ src/bash_write_targets.egg-info/PKG-INFO
8
+ src/bash_write_targets.egg-info/SOURCES.txt
9
+ src/bash_write_targets.egg-info/dependency_links.txt
10
+ src/bash_write_targets.egg-info/entry_points.txt
11
+ src/bash_write_targets.egg-info/top_level.txt
12
+ tests/test_targets.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ targets = bash_write_targets.cli:main
@@ -0,0 +1 @@
1
+ bash_write_targets
@@ -0,0 +1,138 @@
1
+ """Smoke tests for bash-write-targets. Run: python3 tests/test_targets.py"""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import sys
7
+ from contextlib import redirect_stdout
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+
11
+ from bash_write_targets import cli # noqa: E402
12
+ from bash_write_targets.extract import extract # noqa: E402
13
+
14
+
15
+ def check(name, command, expected):
16
+ got = extract(command)
17
+ assert got == expected, f"{name}: for {command!r} got {got!r}, want {expected!r}"
18
+
19
+
20
+ def test_simple_redirect():
21
+ check("redirect", "echo hello > out.txt", ["out.txt"])
22
+
23
+
24
+ def test_append_redirect():
25
+ check("append", "echo hello >> log.txt", ["log.txt"])
26
+
27
+
28
+ def test_stderr_redirect():
29
+ check("stderr", "cmd 2> err.log", ["err.log"])
30
+
31
+
32
+ def test_combined_redirect():
33
+ check("combined", "cmd &> all.log", ["all.log"])
34
+
35
+
36
+ def test_fd_duplication_skipped():
37
+ check("fddup", "cmd > out.txt 2>&1", ["out.txt"])
38
+ check("fddup2", "cmd >&2", [])
39
+
40
+
41
+ def test_dev_null_skipped():
42
+ check("devnull", "cmd > /dev/null 2>&1", [])
43
+
44
+
45
+ def test_tee():
46
+ check("tee", "make 2>&1 | tee build.log", ["build.log"])
47
+ check("tee-multi", "cmd | tee -a a.log b.log", ["a.log", "b.log"])
48
+
49
+
50
+ def test_output_flags():
51
+ check("curl-o", "curl -s -o page.html https://example.com", ["page.html"])
52
+ check("curl-long", "curl --output=dl.bin https://example.com/x", ["dl.bin"])
53
+ check("wget", "wget -O data.zip https://example.com/d", ["data.zip"])
54
+ check("gcc", "gcc -O2 -o app main.c", ["app"])
55
+
56
+
57
+ def test_copy_move():
58
+ check("cp", "cp -r src/ dst/", ["dst/"])
59
+ check("mv", "mv old.txt new.txt", ["new.txt"])
60
+
61
+
62
+ def test_sed_inplace():
63
+ check("sed-i", "sed -i 's/a/b/' file.txt", ["file.txt"])
64
+
65
+
66
+ def test_dd_truncate_touch():
67
+ check("dd", "dd if=/dev/zero of=disk.img bs=1M count=10", ["disk.img"])
68
+ check("truncate", "truncate -s 0 app.log", ["app.log"])
69
+ check("touch", "touch newfile.txt", ["newfile.txt"])
70
+
71
+
72
+ def test_heredoc_body_not_parsed():
73
+ check(
74
+ "heredoc",
75
+ "cat > note.txt <<EOF\nsome > text >> here\nEOF",
76
+ ["note.txt"],
77
+ )
78
+
79
+
80
+ def test_command_substitution_recursive():
81
+ check(
82
+ "subst",
83
+ "echo $(date > inner.txt) > outer.txt",
84
+ ["inner.txt", "outer.txt"],
85
+ )
86
+
87
+
88
+ def test_var_and_glob_skipped():
89
+ check("var", "echo hi > $OUTFILE", [])
90
+ check("glob", "echo hi > *.log", [])
91
+
92
+
93
+ def test_pipeline_segments():
94
+ check(
95
+ "pipeline",
96
+ "grep foo in.txt | sort -o sorted.txt; mv sorted.txt final.txt",
97
+ ["sorted.txt", "final.txt"],
98
+ )
99
+
100
+
101
+ def test_quoted_filename():
102
+ check("quoted", 'echo x > "my file.txt"', ["my file.txt"])
103
+
104
+
105
+ def test_no_writes():
106
+ check("nowrite", "ls -la | grep foo", [])
107
+
108
+
109
+ def test_cli_text_output():
110
+ buf = io.StringIO()
111
+ with redirect_stdout(buf):
112
+ rc = cli.main(["echo hi > cli.txt"])
113
+ assert rc == 0
114
+ assert buf.getvalue().strip() == "cli.txt"
115
+
116
+
117
+ def test_cli_json_output():
118
+ buf = io.StringIO()
119
+ with redirect_stdout(buf):
120
+ rc = cli.main(["--json", "cmd | tee j1.log j2.log"])
121
+ assert rc == 0
122
+ assert json.loads(buf.getvalue()) == ["j1.log", "j2.log"]
123
+
124
+
125
+ if __name__ == "__main__":
126
+ tests = [
127
+ v for k, v in sorted(globals().items()) if k.startswith("test_")
128
+ ]
129
+ failed = 0
130
+ for t in tests:
131
+ try:
132
+ t()
133
+ print(f"PASS {t.__name__}")
134
+ except AssertionError as e:
135
+ failed += 1
136
+ print(f"FAIL {t.__name__}: {e}")
137
+ print(f"{len(tests) - failed}/{len(tests)} passed")
138
+ sys.exit(1 if failed else 0)