@appchy/jarvis 0.1.36 → 0.1.39

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.
Files changed (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10104 -37174
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38718
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. package/dist/env.json +0 -1
@@ -0,0 +1,217 @@
1
+ """Finishing a session cleanly — what only a machine knows, and what to do about it.
2
+
3
+ **It COMPUTES and never WRITES.** A handoff is prose about judgement: what is actually
4
+ left, what is risky, what the next session must not assume. A tool that writes one
5
+ produces something plausible and wrong, which is worse than blank because it reads as
6
+ considered. So this reports, the agent writes, and the person approves. It must not
7
+ edit governance for the same reason — a tool that quietly rewrites a rule to make its
8
+ own check pass is the worst available version of this.
9
+
10
+ Everything here is a thing a session cannot reliably remember and a machine can simply
11
+ answer. The sharpest is the first: work that is not in git. Three tasks have been
12
+ completed on this board with their implementation in no commit anywhere, one of them
13
+ the task about that exact failure.
14
+ """
15
+ from pathlib import Path
16
+
17
+ from . import git, kickoff, report
18
+ from .model import scan
19
+ from .tree import cli, find_work_root
20
+
21
+
22
+ def cmd_wrap(cfg: dict, args=None, repo=None) -> int:
23
+ """Print the wrap brief: what a machine knows, then what the session must do."""
24
+ args = args or {}
25
+ root = find_work_root()
26
+ repo = Path(repo) if repo is not None else root.parent
27
+ out = ["wrap — finish this session cleanly. What only a machine knows, first:"]
28
+ out += _uncommitted(repo)
29
+ out += _in_flight(root)
30
+ out += _governance_touched(repo)
31
+ out += _misaligned(root)
32
+ out.append("")
33
+ out += _steps(cfg, root, args.get("task"))
34
+ print("\n".join(out))
35
+ return 0
36
+
37
+
38
+ def _uncommitted(repo) -> list:
39
+ """Work that exists only on this disk — the one finding worth interrupting for.
40
+
41
+ Said as a refusal to round down. "3 files uncommitted" reads as routine; a session
42
+ that has just been told its work would be lost does not skim.
43
+ """
44
+ code, out, _ = git._git(repo, "status", "--porcelain", "--untracked-files=all")
45
+ if code != 0:
46
+ return [" code in git: cannot tell — this is not a git repo, or git did not "
47
+ "answer. Check it yourself before you walk away."]
48
+ paths = [line[3:] for line in out.splitlines() if len(line) > 3]
49
+ # The board writes itself, so its own churn is not somebody's unsaved work.
50
+ theirs = [p for p in paths if not p.startswith("work/")]
51
+ if not theirs:
52
+ ahead = _unpushed(repo)
53
+ if ahead:
54
+ return [f" code in git: committed, and {ahead} commit(s) are NOT PUSHED — "
55
+ f"they exist on this machine only."]
56
+ return [" code in git: yes — nothing uncommitted outside the board."]
57
+ listed = "\n ".join(sorted(theirs)[:20])
58
+ more = f"\n … and {len(theirs) - 20} more" if len(theirs) > 20 else ""
59
+ return [f" YOUR WORK IS NOT IN GIT — {len(theirs)} file(s) changed and "
60
+ f"uncommitted. Committing them is step 0, before any of the below:"
61
+ f"\n {listed}{more}"]
62
+
63
+
64
+ def _unpushed(repo) -> int:
65
+ """Commits this clone holds that its upstream does not, or 0 when there is no
66
+ upstream to compare against — which is not the same as nothing to push, and is
67
+ reported as nothing rather than guessed at."""
68
+ code, out, _ = git._git(repo, "rev-list", "--count", "@{upstream}..HEAD")
69
+ try:
70
+ return int(out.strip()) if code == 0 else 0
71
+ except ValueError:
72
+ return 0
73
+
74
+
75
+ def _in_flight(root) -> list:
76
+ """Items the board still says somebody is on.
77
+
78
+ A session that walks away from one leaves the board claiming a person who is not
79
+ there — which is the state every other session then plans around.
80
+ """
81
+ live = [t for v in scan(root)["versions"] for t in v.all_tasks()
82
+ if t.status == "in-progress"]
83
+ if not live:
84
+ return [" in progress: nothing — the board claims nobody is on anything."]
85
+ named = "\n ".join(f"{t.name} — {t.title}" for t in live)
86
+ return [f" in progress: {len(live)} item(s). Move what finished, park what did "
87
+ f"not — the bucket IS the status:\n {named}"]
88
+
89
+
90
+ def _governance_touched(repo) -> list:
91
+ """Which descriptions of the world this run changed.
92
+
93
+ Governance and specs are what go stale first: a rule, a feature spec or an
94
+ architecture doc that moved makes everything citing it possibly wrong, and nothing
95
+ else in a session notices.
96
+
97
+ "This run" is everything the world has not seen yet — the working tree plus any
98
+ commit ahead of the upstream. Deliberately not the session's own commits: a run
99
+ that picked up yesterday's unfinished edit is still the run responsible for it.
100
+ """
101
+ seen = set()
102
+ code, out, _ = git._git(repo, "status", "--porcelain", "--untracked-files=all")
103
+ if code == 0:
104
+ seen.update(line[3:] for line in out.splitlines() if len(line) > 3)
105
+ code, out, _ = git._git(repo, "diff", "--name-only", "@{upstream}..HEAD")
106
+ if code == 0:
107
+ seen.update(p for p in out.splitlines() if p.strip())
108
+
109
+ docs = sorted(p for p in seen if p.startswith("work/")
110
+ and p.endswith(".md")
111
+ and "/versions/" not in p and "/backlog/" not in p)
112
+ if not docs:
113
+ return [" descriptions of the world: none changed by this run."]
114
+ listed = "\n ".join(docs[:15])
115
+ more = f"\n … and {len(docs) - 15} more" if len(docs) > 15 else ""
116
+ return [f" descriptions of the world CHANGED by this run — whatever cites these "
117
+ f"may now be stale, and a rule added without `enforced_by:` is a wish:"
118
+ f"\n {listed}{more}"]
119
+
120
+
121
+ def _misaligned(root) -> list:
122
+ """The tree's own misalignments, from the sweep that already knows them.
123
+
124
+ `align` is report-only and exits 0, which is why nothing makes anybody look at it.
125
+ Wrap is the moment somebody looks.
126
+ """
127
+ try:
128
+ findings = report.misalignments(root)
129
+ except (OSError, ValueError):
130
+ return []
131
+ loud = [f for f in findings if f[0] in ("error", "warn")]
132
+ if not loud:
133
+ return [" alignment: clean."]
134
+ shown = "\n ".join(f"{cls}: {msg}" for _, cls, msg in loud[:8])
135
+ more = f"\n … `{cli()} align` has the rest" if len(loud) > 8 else ""
136
+ return [f" alignment: {len(loud)} thing(s) the tree disagrees with itself about. "
137
+ f"Fix what this session caused; the rest is somebody's to pick up:"
138
+ f"\n {shown}{more}"]
139
+
140
+
141
+ def _steps(cfg: dict, root, task) -> list:
142
+ """What the session does with all of that. The order matters: the handoff is
143
+ written while the reasoning is still in the room, and the successor is started
144
+ last, from a file that is already current."""
145
+ out = [
146
+ "Then, in order — report what you did, and say plainly where there was "
147
+ "nothing to do:",
148
+ "",
149
+ f" 1. THE HANDOFF — `{cli()} handoff <task>`. Fill it so a session that has "
150
+ "never seen this work continues without re-deriving anything. `**Status:**` "
151
+ "and `**NEXT:**` stay the top two lines: they are what gets read, and step 5 "
152
+ "lifts them straight into the next session's prompt. Write down the dead ends, "
153
+ "the thing that was not where it looked, the constraint found halfway — the "
154
+ "code is in git and the reasoning is not.",
155
+ "",
156
+ " 2. BRING THE DOCS TO CURRENT. A brief describing a design you abandoned is "
157
+ "worse than no brief, because the next session builds against it. The task's "
158
+ "criteria, the epic's plan where it settled something a sibling now inherits, "
159
+ "the owning feature spec and its `state:` if the evidence moved, and any "
160
+ "durable rule this work established — in the domain or system that owns it.",
161
+ "",
162
+ f" 3. MOVE WHAT FINISHED — `{cli()} move <task> complete`, with what shipped "
163
+ "and what it deliberately leaves out. If the gate refuses, the refusal is the "
164
+ "truth: run the verify commands, or record what you saw with "
165
+ f"`{cli()} observed`. Never move a task the gate declined.",
166
+ "",
167
+ f" 4. PARK WHAT IS STILL OPEN — `{cli()} ask <task> --question \"…\"`. A "
168
+ "question carried in your head all session is one the next session has to "
169
+ "rediscover. Park it or answer it here; do not hand it across as a note.",
170
+ ]
171
+ out += _successor(cfg, root, task)
172
+ return out
173
+
174
+
175
+ def _successor(cfg: dict, root, task) -> list:
176
+ """The last step, which is a different step depending on what this repo has.
177
+
178
+ Telling a repo with no session starter to start one would be inventing a
179
+ capability; telling one that has a starter to leave a prompt for a person would be
180
+ withholding what is already wired up.
181
+ """
182
+ head = (f" 5. THE NEXT SESSION — `{cli()} kickoff <task>` prints the prompt. "
183
+ "Never write one by hand: a hand-written prompt has never opened a session "
184
+ "with the method actually loaded, which is why this is a command and not a "
185
+ "template.")
186
+ tail = ("Start it only if the task is still in progress and the person asked for "
187
+ "it in this session — wrapping finished work and spawning a session on the "
188
+ "next thing is their call, not a default of wrapping. Then "
189
+ f"`{cli()} session <task> --id <sessionId>` so the successor lands on the "
190
+ "board rather than in a line of prose.")
191
+ if not cfg["session"]["mcp"]:
192
+ return ["", head + " This repo names no session starter, so print the prompt "
193
+ "and stop — that is a mode, not a failure."]
194
+
195
+ ready = _prompt(root, task)
196
+ if ready:
197
+ return ["", head, "",
198
+ " The prompt, already derived — pass it VERBATIM, including the order "
199
+ "of its first two lines, and the repo root as `cwd`:", "",
200
+ " ---8<---", ready, " ---8<---", "", " " + tail]
201
+ return ["", head + " " + tail]
202
+
203
+
204
+ def _prompt(root, task) -> str:
205
+ """The kickoff prompt for `task`, or empty when there is no one obvious task.
206
+
207
+ Named rather than guessed: with several items in progress, picking one would be
208
+ the tool deciding what the session was about.
209
+ """
210
+ live = [t for v in scan(root)["versions"] for t in v.all_tasks()
211
+ if t.status == "in-progress" and (not task or t.name == task)]
212
+ if len(live) != 1:
213
+ return ""
214
+ try:
215
+ return kickoff.build_prompt(live[0], root)
216
+ except (OSError, ValueError, KeyError):
217
+ return ""
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse(Bash) — the floor under an unattended shift.
3
+
4
+ The harness's other hook is a sensor: it prints and decides nothing, which is the
5
+ right shape when a person is at the keyboard, because that person IS the floor
6
+ under a destructive command. On a schedule there is no such person, and the cost
7
+ of the failure is asymmetric in a way ordinary bugs are not — everything else the
8
+ harness guards against is recoverable by re-running, and `rm -rf` is not.
9
+
10
+ Three design rules, and they matter more than the pattern list:
11
+
12
+ **Fail open.** An unparseable command, an unknown binary, or a crash in here
13
+ allows. A guard that breaks sessions gets disabled, and a disabled guard protects
14
+ nothing.
15
+
16
+ **Block only the unrecoverable.** Every false positive spends the guard's
17
+ authority, and an authority-less guard is switched off by the next person it
18
+ annoys. `rg "DROP TABLE"` searches for a string; `psql -c "DROP TABLE"` drops a
19
+ table. This reads the command, it does not grep the line.
20
+
21
+ **Warn-but-allow is a real third state.** `--no-verify`, `npm publish`,
22
+ `terraform apply`, `kubectl delete` are all recoverable and all worth naming.
23
+
24
+ Ships INERT — `hooks.json` does not register it. A hook that narrows what runs on
25
+ someone's machine is their call to install, not the plugin's to assume.
26
+ """
27
+ import json
28
+ import re
29
+ import shlex
30
+ import sys
31
+
32
+ # --- unrecoverable: deny -----------------------------------------------------
33
+ # Each entry is (name, predicate over the argv list, what to do instead).
34
+ # Predicates take the parsed argv so a quoted string argument can never be
35
+ # mistaken for the operation it names, plus `stdin` — what an upstream command in
36
+ # the same pipeline feeds them, because for some binaries that IS the instruction.
37
+
38
+ #: Paths whose recursive removal is never a legitimate build step.
39
+ _FATAL_RM = re.compile(r"^(/|~|\$HOME|/\*|~/\*|\.\.?/?)$")
40
+ #: An unexpanded variable in an `rm -rf` target. `rm -rf "$DIR/build"` with DIR
41
+ #: unset is `rm -rf /build` — the single most common way this goes wrong.
42
+ _BARE_VAR = re.compile(r"^\$\{?\w+\}?/?\*?$")
43
+ _SQL_FATAL = re.compile(r"\b(drop\s+(table|database|schema)|truncate\s+table)\b", re.I)
44
+ _SQL_UNSCOPED = re.compile(r"\b(delete\s+from|update)\b(?!.*\bwhere\b)", re.I | re.S)
45
+ _PROTECTED = ("main", "master", "release", "production", "prod")
46
+
47
+
48
+ def _rm(argv, stdin=""):
49
+ if argv[0] != "rm":
50
+ return None
51
+ flags = "".join(a for a in argv[1:] if a.startswith("-"))
52
+ if not ("r" in flags and "f" in flags):
53
+ return None
54
+ for target in [a for a in argv[1:] if not a.startswith("-")]:
55
+ if _FATAL_RM.match(target):
56
+ return (f"`rm -rf {target}` removes the filesystem or the home "
57
+ f"directory.", "Name the directory explicitly.")
58
+ if _BARE_VAR.match(target):
59
+ return (f"`rm -rf {target}` — an unset variable makes this a path you "
60
+ f"did not mean.", "Expand it first and check it is non-empty.")
61
+ return None
62
+
63
+
64
+ def _git(argv, stdin=""):
65
+ if argv[0] != "git":
66
+ return None
67
+ sub = argv[1] if len(argv) > 1 else ""
68
+ rest = argv[2:]
69
+ joined = " ".join(rest)
70
+ if sub == "push" and any(f in rest for f in ("--force", "-f")):
71
+ if "--force-with-lease" in rest:
72
+ return None
73
+ return ("`git push --force` overwrites history someone else may have "
74
+ "pulled.", "Use `--force-with-lease`, which refuses if the remote "
75
+ "moved.")
76
+ if sub == "push" and any(b in rest for b in _PROTECTED) and "--delete" in rest:
77
+ return ("deleting a protected branch on the remote.", "Do it in the host's "
78
+ "UI, where it is undoable.")
79
+ if sub == "reset" and "--hard" in rest:
80
+ return ("`git reset --hard` discards every uncommitted change with no "
81
+ "reflog entry for the working tree.", "`git stash` keeps them.")
82
+ if sub == "clean" and re.search(r"-\w*f", joined) and re.search(r"-\w*d", joined):
83
+ return ("`git clean -fd` deletes untracked files permanently — including "
84
+ "the ones never added yet.", "`git clean -nd` lists them first.")
85
+ if sub == "checkout" and rest and rest[0] in (".", "--"):
86
+ return ("`git checkout .` discards every uncommitted change.",
87
+ "`git stash` keeps them.")
88
+ return None
89
+
90
+
91
+ def _sql(argv, stdin=""):
92
+ """A SQL client handed a statement. The binary is what distinguishes running it
93
+ from searching for it.
94
+
95
+ `stdin` is read as part of the statement because for a SQL client it IS one:
96
+ `echo "DROP TABLE users" | psql prod` drops the table exactly as the `-c` form
97
+ does, and looking only at the command line missed it. What flows in from a FILE
98
+ is still invisible here, and deliberately — `cat migration.sql | psql prod` is the
99
+ normal way to run a migration, and denying it would spend the guard's authority
100
+ on the case it is meant to serve."""
101
+ if argv[0] not in ("psql", "mysql", "sqlite3", "mongosh", "clickhouse-client"):
102
+ return None
103
+ body = " ".join(argv[1:] + ([stdin] if stdin else []))
104
+ if _SQL_FATAL.search(body):
105
+ return ("a DROP/TRUNCATE against a live database.",
106
+ "Run it in a migration that has a tested down-step.")
107
+ if _SQL_UNSCOPED.search(body):
108
+ return ("a DELETE or UPDATE with no WHERE clause — it rewrites every row.",
109
+ "Add a WHERE, or SELECT it first to see the blast radius.")
110
+ return None
111
+
112
+
113
+ def _fs(argv, stdin=""):
114
+ head = argv[0]
115
+ joined = " ".join(argv)
116
+ if head == "mkfs" or head.startswith("mkfs."):
117
+ return ("`mkfs` formats a device.", "There is no undo. Do this by hand.")
118
+ if head == "dd" and any(a.startswith("of=/dev/") for a in argv):
119
+ return ("`dd` writing straight to a device.", "There is no undo.")
120
+ if head == "chmod" and "777" in joined:
121
+ return ("`chmod 777` makes it world-writable.",
122
+ "755 for a directory, 644 for a file.")
123
+ if head in ("curl", "wget") and "|" in joined:
124
+ return ("piping a download into a shell runs code nobody has read.",
125
+ "Download it, read it, then run it.")
126
+ return None
127
+
128
+
129
+ DENY = (_rm, _git, _sql, _fs)
130
+
131
+ #: Recoverable, but worth saying out loud. Substring match on the command line is
132
+ #: fine here: the cost of a false positive is one extra line of output.
133
+ WARN = {
134
+ "--no-verify": "skips the pre-commit hooks this repo installed on purpose",
135
+ "npm publish": "publishes to the registry — a version number cannot be reused",
136
+ "pnpm publish": "publishes to the registry — a version number cannot be reused",
137
+ "terraform apply": "changes real infrastructure",
138
+ "terraform destroy": "tears down real infrastructure",
139
+ "kubectl delete": "removes a live resource",
140
+ "docker system prune": "removes every unused image, container and volume",
141
+ }
142
+
143
+
144
+ #: Operator characters that end one command and begin the next. `(`, `)`, `<` and
145
+ #: `>` are here so a redirect or a subshell cannot glue two commands into one argv.
146
+ _OPERATORS = set("|&;\n()<>")
147
+
148
+ #: Words that stand in FRONT of the real command. `xargs` earns its place the same
149
+ #: way `sudo` does: `… | xargs rm -rf ~` runs `rm -rf ~`, and reading `xargs` as the
150
+ #: command is how that got through.
151
+ _PREFIXES = ("sudo", "env", "time", "nohup", "xargs")
152
+
153
+
154
+ def _segments(command: str):
155
+ """The command as `(argv, pipes_into_something)` pairs — or None if unparseable.
156
+
157
+ **One tokenizer, and it is the shell's own.** This used to `re.split` on the
158
+ operators and then `shlex.split` each piece, which reads a quote as ordinary text
159
+ until it is too late: `psql -c "DROP TABLE users;"` was cut at the semicolon
160
+ INSIDE the string, both halves then failed to parse as unbalanced quotes, and the
161
+ command sailed past every rule. Measured — that exact line was allowed while the
162
+ same statement without its trailing semicolon was denied, and a trailing semicolon
163
+ is how SQL is normally written.
164
+
165
+ Tokenizing first, with the operators declared as operators, is what makes a quoted
166
+ `;` stay inside its argument and a real `;` end the command. `|` becomes a genuine
167
+ separator too, so what a pipe feeds is a command in its own right rather than a
168
+ tail nobody looked at.
169
+ """
170
+ lex = shlex.shlex(command, posix=True, punctuation_chars="();<>|&\n")
171
+ lex.whitespace_split = True
172
+ # Newline is an operator here, not blank space between words: `echo hi\nrm -rf ~`
173
+ # is two commands, and reading it as one leaves `rm` sitting in `echo`'s arguments
174
+ # where no rule looks for it.
175
+ lex.whitespace = " \t\r"
176
+ try:
177
+ tokens = list(lex)
178
+ except ValueError:
179
+ return None # unbalanced quotes — not ours to judge
180
+ out, current = [], []
181
+ for token in tokens:
182
+ if token and set(token) <= _OPERATORS:
183
+ if current:
184
+ out.append((current, token.startswith("|")))
185
+ current = []
186
+ else:
187
+ current.append(token)
188
+ if current:
189
+ out.append((current, False))
190
+ return out
191
+
192
+
193
+ def check(command: str):
194
+ """(decision, message) — `decision` is 'deny', 'warn' or None."""
195
+ # A compound command is checked segment by segment, so `cd x && rm -rf /` is
196
+ # not read as one harmless `cd`.
197
+ fed_by = ""
198
+ for argv, pipes_on in _segments(command) or []:
199
+ # What the previous stage of a pipeline produced, for the next one to read.
200
+ # Carried across the loop rather than looked up, so a rule sees its own input
201
+ # without knowing anything about the shape of the whole command line.
202
+ upstream, fed_by = fed_by, " ".join(argv[1:]) if pipes_on else ""
203
+ # `env A=b cmd`, `sudo cmd`, `time cmd`, `xargs cmd` — look past the prefix.
204
+ while argv and (argv[0] in _PREFIXES
205
+ or "=" in argv[0] and not argv[0].startswith("-")):
206
+ argv = argv[1:]
207
+ if not argv:
208
+ continue
209
+ # Whether this feeds another command is a property of the segment, and a rule
210
+ # reads it off the argv it is handed — which is what `curl … | sh` turns on.
211
+ probe = argv + ["|"] if pipes_on else argv
212
+ for rule in DENY:
213
+ try:
214
+ hit = rule(probe, upstream)
215
+ except Exception:
216
+ hit = None # fail open, always
217
+ if hit:
218
+ what, instead = hit
219
+ return "deny", f"blocked: {what}\nInstead: {instead}"
220
+ low = command.lower()
221
+ for needle, why in WARN.items():
222
+ if needle in low:
223
+ return "warn", f"note: `{needle}` {why}."
224
+ return None, ""
225
+
226
+
227
+ def main() -> int:
228
+ try:
229
+ payload = json.load(sys.stdin)
230
+ except (json.JSONDecodeError, ValueError):
231
+ return 0 # fail open
232
+ if payload.get("tool_name") != "Bash":
233
+ return 0
234
+ command = str((payload.get("tool_input") or {}).get("command", ""))
235
+ if not command.strip():
236
+ return 0
237
+
238
+ try:
239
+ decision, message = check(command)
240
+ except Exception:
241
+ return 0 # fail open on our own bug, always
242
+
243
+ if decision == "deny":
244
+ print(json.dumps({"hookSpecificOutput": {
245
+ "hookEventName": "PreToolUse",
246
+ "permissionDecision": "deny",
247
+ "permissionDecisionReason": message,
248
+ }}))
249
+ return 0
250
+ if decision == "warn":
251
+ print(message, file=sys.stderr)
252
+ return 0
253
+
254
+
255
+ if __name__ == "__main__":
256
+ try:
257
+ sys.exit(main())
258
+ except Exception:
259
+ sys.exit(0)