@r1z4x/tezgah 1.13.0 → 1.14.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/CHANGELOG.md +175 -1
- package/MANIFEST +18 -0
- package/NOTICE +152 -0
- package/README.de.md +41 -20
- package/README.es.md +39 -19
- package/README.fr.md +41 -20
- package/README.md +15 -8
- package/README.tr.md +39 -21
- package/bin/codegen +9 -2
- package/bin/consult +6 -0
- package/bin/tezgah-adhd +3 -0
- package/bin/tezgah-agents +3 -0
- package/bin/tezgah-capture +3 -0
- package/bin/tezgah-design +329 -54
- package/bin/tezgah-docs +157 -41
- package/bin/tezgah-dsh +1 -1
- package/bin/tezgah-gate +7 -2
- package/bin/tezgah-import-ai-research +16 -4
- package/bin/tezgah-index +3 -0
- package/bin/tezgah-lessons +13 -4
- package/bin/tezgah-mcp +4 -0
- package/bin/tezgah-migrate +3 -0
- package/bin/tezgah-ocr-review +2 -1
- package/bin/tezgah-optimize +4 -1
- package/bin/tezgah-pony +3 -0
- package/bin/tezgah-research +12 -6
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +596 -157
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +3 -0
- package/bin/tezgah.js +10 -3
- package/commands/adhd.md +1 -1
- package/docs/README.md +19 -8
- package/docs/architecture.md +2 -2
- package/docs/citations-baseline.json +11 -17
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +77 -31
- package/docs/environment.md +58 -24
- package/docs/evidence.md +49 -19
- package/docs/gate.md +71 -21
- package/docs/hosts.md +65 -52
- package/docs/layers.md +1 -1
- package/docs/operations.md +67 -24
- package/docs/research.md +1 -1
- package/docs/skills.md +31 -8
- package/docs/status-line.md +33 -23
- package/docs/testing.md +48 -17
- package/docs/vision.md +6 -2
- package/hooks/lang/en.json +5 -2
- package/hooks/lang/tr.json +3 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +1 -1
- package/hooks/tezgah_context.py +40 -23
- package/hooks/tezgah_gate.py +404 -106
- package/hooks/tezgah_integrity.py +367 -97
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +17 -6
- package/hooks/tezgah_policy.py +13 -5
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stopspec.py +2 -1
- package/hosts/amp/hook.py +1 -1
- package/hosts/amp/tezgah-plugin.ts.in +13 -3
- package/hosts/antigravity/hook.py +1 -1
- package/hosts/codex/hook.py +1 -1
- package/hosts/commandcode/hook.py +1 -1
- package/hosts/copilot/hook.py +1 -1
- package/hosts/cursor/hook.py +2 -1
- package/hosts/droid/hook.py +1 -1
- package/hosts/gemini/hook.py +1 -1
- package/hosts/goose/hook.py +1 -1
- package/hosts/omp/hook.py +1 -1
- package/hosts/omp/tezgah-hook.ts.in +127 -35
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +1 -1
- package/output-styles/tezgah.md +1 -1
- package/package.json +6 -3
- package/packaging/build.sh +4 -4
- package/packaging/install.sh +11 -0
- package/packaging/upgrade.sh +3 -0
- package/skills/ai-research/LICENSE +21 -0
- package/skills/design-contract/SKILL.md +27 -6
- package/skills/design-library/LICENSE +21 -0
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +2 -2
- package/third_party/open-code-review/LICENSE +201 -0
- package/third_party/open-code-review/README.md +15 -0
- package/third_party/open-code-review/prompts/main_task_system.md +26 -0
- package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
- package/workflows/graph-map.js +14 -5
package/hooks/tezgah_gate.py
CHANGED
|
@@ -89,6 +89,7 @@ Rules, all only inside a tezgah root:
|
|
|
89
89
|
removes it: every other switch is one of the files it protects.
|
|
90
90
|
Adapters translate the returned reason into their own permission envelope.
|
|
91
91
|
"""
|
|
92
|
+
import fnmatch
|
|
92
93
|
import hashlib
|
|
93
94
|
import json
|
|
94
95
|
import os
|
|
@@ -97,14 +98,18 @@ import shlex
|
|
|
97
98
|
import subprocess
|
|
98
99
|
import sys
|
|
99
100
|
import time
|
|
101
|
+
from glob import iglob
|
|
100
102
|
|
|
101
103
|
import tezgah_store
|
|
102
104
|
from tezgah_extension import DEFAULT_TIMEOUT_MS, EXT_DIR, MAX_WALK, call_envelope
|
|
103
105
|
|
|
104
|
-
from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, COMMIT_STUCK_SHORTS,
|
|
107
|
+
COMMIT_VALUE_SHORTS, GIT_VALUE_OPTS,
|
|
108
|
+
HEREDOC_RISK, HOOKS_KEYS, PATCH_FILE,
|
|
109
|
+
PLAIN_TAIL, SECRET_PREFIXED, SHELL_KEYWORDS, STEP_KINDS,
|
|
110
|
+
WRITE_TOOLS, _abs_target, _blank_heredocs,
|
|
111
|
+
_commit_value_long, _heredocs, _shell_scripts,
|
|
112
|
+
bind_session, _option_value, _past_wrappers,
|
|
108
113
|
_shell_commands, _shell_lines, _shell_segments,
|
|
109
114
|
_turn_start,
|
|
110
115
|
_unquoted_backticks, call_id, cut, events,
|
|
@@ -215,7 +220,7 @@ BASH_SEARCH = re.compile(r"(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)")
|
|
|
215
220
|
# ponytail: "Drafted with Cursor" passes, and "built using OpenAI, Anthropic and
|
|
216
221
|
# others" is denied once; the deny names the reason, so one rephrase clears it.
|
|
217
222
|
_CREDIT = (
|
|
218
|
-
r"co-authored-by\s
|
|
223
|
+
r"co-authored-by\s*[:=]|generated with|made with|"
|
|
219
224
|
r"(?:generated|written|authored|built|assisted|drafted|produced|composed)"
|
|
220
225
|
r"\s+by\s+(?:(?:an?|the)\s+)?"
|
|
221
226
|
r"(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|"
|
|
@@ -242,17 +247,18 @@ ATTRIB_LINE = re.compile(r"(?im)^[\s>#*/<!+-]*(?:" + _CREDIT + r")")
|
|
|
242
247
|
EDIT_TEXT = ("content", "new_string", "newString", "new_str", "file_text",
|
|
243
248
|
"patch", "text")
|
|
244
249
|
# the write subcommand may sit behind git's global options: `git -c k=v commit`,
|
|
245
|
-
# `git -C dir commit`, `git --no-pager commit
|
|
246
|
-
#
|
|
250
|
+
# `git -C dir commit`, `git --no-pager commit`, and git or gh may be named by
|
|
251
|
+
# its path (`/usr/bin/git commit`, audit B-012). `gh api` writes
|
|
252
|
+
# comments/reviews, and `gh pr merge` lands a commit, so both count as writes.
|
|
247
253
|
# The `gh` subcommands that write to the remote, so this rule counts them as
|
|
248
254
|
# write commands: a PR or an issue comment is a message posted to a service
|
|
249
255
|
# under the workspace's name.
|
|
250
256
|
GH_SUBCOMMANDS = r"(?:create|edit|comment|review|merge|close)"
|
|
251
257
|
WRITE_CMD = re.compile(
|
|
252
|
-
r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
258
|
+
r"(?:^|[|;&]\s*|\s)(?:\S*/)?git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
253
259
|
r"(?:commit|merge|tag|notes)\b|"
|
|
254
|
-
r"(?:^|[|;&]\s*|\s)gh\s+api\b|"
|
|
255
|
-
r"(?:^|[|;&]\s*|\s)gh\s+(?:pr|issue|release)\s+" + GH_SUBCOMMANDS + r"\b",
|
|
260
|
+
r"(?:^|[|;&]\s*|\s)(?:\S*/)?gh\s+api\b|"
|
|
261
|
+
r"(?:^|[|;&]\s*|\s)(?:\S*/)?gh\s+(?:pr|issue|release)\s+" + GH_SUBCOMMANDS + r"\b",
|
|
256
262
|
re.I)
|
|
257
263
|
|
|
258
264
|
# --- secret: a credential on its way into a file ----------------------------
|
|
@@ -365,7 +371,7 @@ SECRET_FIXTURE = re.compile(r"(?:^|/)tests?/(?:.+/)?fixtures/|\.example$", re.I)
|
|
|
365
371
|
SECRET_PUBLISHED = frozenset((
|
|
366
372
|
"2cafc0970149a84f3b9e62eaf169f36f59907a3b3e31f7b82e68c69cd27f7326",))
|
|
367
373
|
SECRET_ADD_DENY = (
|
|
368
|
-
"Credential file add denied:
|
|
374
|
+
"Credential file add denied: `%s` stages a credential file, so every "
|
|
369
375
|
"value in it lands in the repository's history. Keep it out of git (list it "
|
|
370
376
|
"in .gitignore) and commit a `.env.example` that carries the names without "
|
|
371
377
|
"the values.")
|
|
@@ -408,15 +414,30 @@ ATTRIB_DENY = (
|
|
|
408
414
|
"use it or describe real behavior is fine; crediting it as author is not.")
|
|
409
415
|
|
|
410
416
|
|
|
411
|
-
def attribution(command):
|
|
417
|
+
def attribution(command, depth=0):
|
|
412
418
|
"""True when a git/gh write command carries an AI/model credit.
|
|
413
419
|
|
|
414
420
|
The whole command text is read unanchored, so a credit inside a `-m` value is
|
|
415
421
|
caught wherever it sits. A message that *discusses* the forms is denied too,
|
|
416
422
|
and that is the known cost: the deny names the forms, so one rephrase clears
|
|
417
|
-
it (see the note on `_CREDIT`).
|
|
423
|
+
it (see the note on `_CREDIT`). The script of a `bash -c` or an `eval` is
|
|
424
|
+
read the same way (`_shell_scripts`, audit B-012)."""
|
|
418
425
|
c = str(command or "")
|
|
419
|
-
|
|
426
|
+
# a quoted or backslashed program (`"/usr/bin/git" commit`, `\git commit`)
|
|
427
|
+
# is still git, and a trailer key configured on the line names the credit
|
|
428
|
+
# its `--trailer 'x=...'` carries (review 148); `git config` storing that
|
|
429
|
+
# key credits every later `--trailer` (review 148 r2)
|
|
430
|
+
bare = re.sub(r"[\"'\\]", "", c)
|
|
431
|
+
credit = TRAILER_CREDIT.search(bare)
|
|
432
|
+
if (WRITE_CMD.search(c) or WRITE_CMD.search(bare)) and (
|
|
433
|
+
ATTRIB.search(c) or credit) or credit and CONFIG_CMD.search(bare):
|
|
434
|
+
return True
|
|
435
|
+
return depth < 3 and any(attribution(s, depth + 1) for s in _shell_scripts(c))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
TRAILER_CREDIT = re.compile(r"trailer\.\S+\.key(?:\s*=\s*|\s+)co-authored-by\b", re.I)
|
|
439
|
+
CONFIG_CMD = re.compile(r"(?:^|[|;&]\s*|\s)(?:\S*/)?git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
440
|
+
r"config\b")
|
|
420
441
|
|
|
421
442
|
|
|
422
443
|
def attribution_edit(inp):
|
|
@@ -450,16 +471,30 @@ def attribution_edit(inp):
|
|
|
450
471
|
# draws for its own forms.
|
|
451
472
|
#
|
|
452
473
|
# `git commit -F <file>` is read from the file, resolved against the call's cwd.
|
|
474
|
+
# A branch made by `git worktree add -b`, renamed or copied by `git branch
|
|
475
|
+
# -m/-M/-c/-C` (its new name: the last of the two), a tag `git tag` makes and a
|
|
476
|
+
# remote branch a push refspec names (`HEAD:refs/heads/<name>`) are created
|
|
477
|
+
# identifiers too (audit B-027).
|
|
453
478
|
# ponytail: a file that cannot be read (a missing path, an unreadable one, a
|
|
454
479
|
# binary) is skipped rather than guessed at - the ceiling is named here, and the
|
|
455
480
|
# other commit routes (`--fill`, `-m` used twice) are read for their first
|
|
456
|
-
# argument only.
|
|
481
|
+
# argument only; a tag whose options come before a value-taking `-m` is not read.
|
|
457
482
|
BRANCH_NEW = re.compile(
|
|
458
483
|
r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
459
|
-
r"(?:checkout\s+(?:-\S+\s+)*(?:-b|-B|--branch)\s
|
|
484
|
+
r"(?:checkout\s+(?:-\S+\s+)*(?:(?:-b|-B|--branch)\s+|-[bB](?=[^\s;&|]))"
|
|
460
485
|
r"|switch\s+(?:-\S+\s+)*(?:-c|-C|--create)\s+"
|
|
486
|
+
r"|worktree\s+add\s+(?:-\S+\s+)*(?:-b|-B)\s+"
|
|
487
|
+
r"|branch\s+(?:-\S+\s+)*(?:-m|-M|-c|-C|--move|--copy)\s+"
|
|
488
|
+
r"(?:[^\s;&|-][^\s;&|]*\s+(?=[^\s;&|-]))?"
|
|
461
489
|
r"|branch\s+(?!-))"
|
|
462
490
|
r"([^\s;&|]+)")
|
|
491
|
+
TAG_NEW = re.compile(
|
|
492
|
+
r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
493
|
+
r"tag\s+(?:(?:-[afs]+|--annotate|--sign|--force)\s+)*([^\s;&|-][^\s;&|]*)")
|
|
494
|
+
PUSH_CMD = re.compile(
|
|
495
|
+
r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*push\b([^|;&]*)")
|
|
496
|
+
# `git push` options that take the next word, so it is not the remote
|
|
497
|
+
PUSH_VALUE_OPTS = frozenset(("-o", "--push-option", "--receive-pack", "--exec"))
|
|
463
498
|
COMMIT_MSG = re.compile(
|
|
464
499
|
r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*commit\b([^|;&]*)")
|
|
465
500
|
# The flag and its value, which may be one token, a quoted string, or `=`-joined.
|
|
@@ -472,7 +507,7 @@ MSG_FLAG = re.compile(r"(?<![\w-])(?:--message|-[A-Za-z]*m)(?:=|\s+|(?=[\"']))")
|
|
|
472
507
|
MSG_FILE = re.compile(r"(?<![\w-])(?:--file|-[A-Za-z]*F)(?:=|\s+|(?=[\"']))")
|
|
473
508
|
GH_NEW = re.compile(
|
|
474
509
|
r"(?:^|[|;&(]\s*|\s)gh\s+(pr|issue)\s+create\b([^|;&]*)", re.I)
|
|
475
|
-
TITLE_FLAG = re.compile(r"(?<![\w-])
|
|
510
|
+
TITLE_FLAG = re.compile(r"(?<![\w-])(?:--title|-t)(?:=|\s+|(?=[\"']))")
|
|
476
511
|
# How much of a commit-message file is read: the subject is its first line, so a
|
|
477
512
|
# file past this bound loses its tail and not its point.
|
|
478
513
|
MSG_READ = 64 * 1024
|
|
@@ -517,6 +552,25 @@ def created_texts(command, cwd):
|
|
|
517
552
|
can say which identifier it is about."""
|
|
518
553
|
c = str(command or "")
|
|
519
554
|
out = [("branch name", m.group(1)) for m in BRANCH_NEW.finditer(c)]
|
|
555
|
+
out += [("tag name", m.group(1)) for m in TAG_NEW.finditer(c)]
|
|
556
|
+
for m in PUSH_CMD.finditer(c):
|
|
557
|
+
words, remote = m.group(1).split(), True
|
|
558
|
+
for k, word in enumerate(words):
|
|
559
|
+
# the first positional is the remote (`origin`, a URL, an
|
|
560
|
+
# scp-style `host:u/repo.git`), never a refspec; `src:dst` after it
|
|
561
|
+
# names the remote ref and `:dst` deletes it
|
|
562
|
+
if word.startswith("-") or k and words[k - 1] in PUSH_VALUE_OPTS:
|
|
563
|
+
continue
|
|
564
|
+
if remote:
|
|
565
|
+
remote = False
|
|
566
|
+
continue
|
|
567
|
+
src, colon, dst = word.partition(":")
|
|
568
|
+
if colon and src:
|
|
569
|
+
dst = re.sub(r"^refs/(?:heads|tags)/", "", dst)
|
|
570
|
+
if dst:
|
|
571
|
+
out.append(("remote branch name", dst))
|
|
572
|
+
out += [("branch name", n) for n in _created_by_words(c, "branch")]
|
|
573
|
+
out += [("tag name", n) for n in _created_by_words(c, "tag")]
|
|
520
574
|
for m in COMMIT_MSG.finditer(c):
|
|
521
575
|
rest = m.group(1)
|
|
522
576
|
subject = _flag_value(rest, MSG_FLAG)
|
|
@@ -533,6 +587,65 @@ def created_texts(command, cwd):
|
|
|
533
587
|
return out
|
|
534
588
|
|
|
535
589
|
|
|
590
|
+
# `git tag`'s list, delete and verify modes create nothing
|
|
591
|
+
TAG_READS = re.compile(r"-[A-Za-z]*[dlvn]\w*|--(?:list|delete|verify|contains|"
|
|
592
|
+
r"no-contains|merged|no-merged|points-at|sort|format|column)\b.*")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _created_by_words(command, kind):
|
|
596
|
+
"""The branch or tag names a git segment creates, read word by word where
|
|
597
|
+
the patterns above miss them (review 148): `worktree add <path>` names a
|
|
598
|
+
new branch after the path when no `-b` and no commit-ish follow, `-b` may
|
|
599
|
+
come after the path, `branch -m -f old new` puts a flag between, and `git
|
|
600
|
+
tag -m msg name` / `-am msg name` give the message before the name."""
|
|
601
|
+
out = []
|
|
602
|
+
for words in _shell_segments(command):
|
|
603
|
+
words = _past_wrappers(words)
|
|
604
|
+
if not words or os.path.basename(words[0]) != "git":
|
|
605
|
+
continue
|
|
606
|
+
i = 1
|
|
607
|
+
while i < len(words) and words[i].startswith("-"):
|
|
608
|
+
i += 2 if words[i] in GIT_VALUE_OPTS else 1
|
|
609
|
+
sub, args = words[i:i + 1], words[i + 1:]
|
|
610
|
+
if kind == "branch" and sub == ["worktree"] and args[:1] == ["add"]:
|
|
611
|
+
positional, named, j = [], None, 1
|
|
612
|
+
while j < len(args):
|
|
613
|
+
if args[j] in ("-b", "-B") and j + 1 < len(args):
|
|
614
|
+
named, j = args[j + 1], j + 2
|
|
615
|
+
elif re.match(r"-[bB].", args[j]): # stuck: `-bname`
|
|
616
|
+
named, j = args[j][2:], j + 1
|
|
617
|
+
elif args[j] == "--reason":
|
|
618
|
+
j += 2
|
|
619
|
+
elif args[j].startswith("-"):
|
|
620
|
+
if args[j] in ("-d", "--detach"):
|
|
621
|
+
positional = None
|
|
622
|
+
break
|
|
623
|
+
j += 1
|
|
624
|
+
else:
|
|
625
|
+
positional.append(args[j])
|
|
626
|
+
j += 1
|
|
627
|
+
if named:
|
|
628
|
+
out.append(named)
|
|
629
|
+
elif positional and len(positional) == 1:
|
|
630
|
+
out.append(os.path.basename(positional[0].rstrip("/")))
|
|
631
|
+
elif kind == "branch" and sub == ["branch"] and any(
|
|
632
|
+
a in ("-m", "-M", "-c", "-C", "--move", "--copy") for a in args):
|
|
633
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
634
|
+
if positional:
|
|
635
|
+
out.append(positional[-1])
|
|
636
|
+
elif kind == "tag" and sub == ["tag"] and not any(TAG_READS.fullmatch(a) for a in args):
|
|
637
|
+
j = 0
|
|
638
|
+
while j < len(args):
|
|
639
|
+
hit = _option_value(args[j], ("-m", "-F", "-u", "--message", "--file",
|
|
640
|
+
"--local-user", "--cleanup"))
|
|
641
|
+
if args[j].startswith("-"):
|
|
642
|
+
j += 2 if hit and not hit[1] else 1
|
|
643
|
+
continue
|
|
644
|
+
out.append(args[j])
|
|
645
|
+
break
|
|
646
|
+
return out
|
|
647
|
+
|
|
648
|
+
|
|
536
649
|
def lang_reason(command, cwd):
|
|
537
650
|
"""A deny reason when this command would create a non-English identifier,
|
|
538
651
|
else None. The decision is tezgah_lang's (a word list is not a language
|
|
@@ -960,9 +1073,17 @@ def _secret_printer(text):
|
|
|
960
1073
|
or UNEXPANDED.search(program)))
|
|
961
1074
|
|
|
962
1075
|
|
|
1076
|
+
# The git subcommands that stage a file they name: `stage` is `add`, a commit
|
|
1077
|
+
# pathspec stages its files, and `update-index --add` adds one (audit B-028).
|
|
1078
|
+
STAGING = frozenset(("add", "stage", "commit", "update-index"))
|
|
1079
|
+
|
|
1080
|
+
|
|
963
1081
|
def _secret_add(command):
|
|
964
|
-
"""
|
|
965
|
-
|
|
1082
|
+
"""`git <sub> <file>` for the staging command (STAGING) in this command that
|
|
1083
|
+
names a credential file, or None. A commit's option values (`-m .env`) are
|
|
1084
|
+
no pathspec."""
|
|
1085
|
+
if SECRET_FILE is None or not re.search(r"\b(?:add|stage|commit|update-index)\b",
|
|
1086
|
+
command):
|
|
966
1087
|
return None
|
|
967
1088
|
for words in _shell_segments(command):
|
|
968
1089
|
program, args = _program(words)
|
|
@@ -971,13 +1092,26 @@ def _secret_add(command):
|
|
|
971
1092
|
i = 0
|
|
972
1093
|
while i < len(args) and args[i].startswith("-"):
|
|
973
1094
|
i += 2 if args[i] in GIT_VALUE_OPTS else 1
|
|
974
|
-
|
|
1095
|
+
sub, rest = (args[i] if i < len(args) else None), args[i + 1:]
|
|
1096
|
+
if sub not in STAGING or sub == "update-index" and "--add" not in rest:
|
|
975
1097
|
continue
|
|
976
|
-
|
|
1098
|
+
value_next = False
|
|
1099
|
+
for arg in rest:
|
|
1100
|
+
if value_next:
|
|
1101
|
+
value_next = False
|
|
1102
|
+
continue
|
|
1103
|
+
if arg.startswith("-"):
|
|
1104
|
+
if sub == "commit" and _commit_value_long(arg):
|
|
1105
|
+
value_next = True
|
|
1106
|
+
elif sub == "commit" and not arg.startswith("--"):
|
|
1107
|
+
k = next((k for k, ch in enumerate(arg[1:], 1)
|
|
1108
|
+
if ch in COMMIT_VALUE_SHORTS), 0)
|
|
1109
|
+
value_next = bool(k) and k == len(arg) - 1 \
|
|
1110
|
+
and arg[k] not in COMMIT_STUCK_SHORTS
|
|
1111
|
+
continue
|
|
977
1112
|
name = os.path.basename(arg.rstrip("/"))
|
|
978
|
-
if
|
|
979
|
-
|
|
980
|
-
return arg
|
|
1113
|
+
if SECRET_FILE.match(name) and name.lower() not in SECRET_TEMPLATES:
|
|
1114
|
+
return "git %s %s" % (sub, arg)
|
|
981
1115
|
return None
|
|
982
1116
|
|
|
983
1117
|
|
|
@@ -2006,11 +2140,14 @@ WORKSPACE_FOLDED = ("tracked by the project here, committed with the project's o
|
|
|
2006
2140
|
|
|
2007
2141
|
def workspace_reason(inp, cwd, base):
|
|
2008
2142
|
"""A deny reason when this write lands in a root `plans/`, `research/` or
|
|
2009
|
-
`analysis/` the project does not track, else None.
|
|
2143
|
+
`analysis/` the project does not track, else None. A shell redirect target
|
|
2144
|
+
resolves past the line's `cd`/`pushd` (`_shell_dir`, audit B-070)."""
|
|
2010
2145
|
if tezgah_task is None:
|
|
2011
2146
|
return None
|
|
2147
|
+
here = _shell_dir(inp, cwd) if inp.get("command") or inp.get("cmd") else cwd
|
|
2012
2148
|
for path in write_paths(inp):
|
|
2013
|
-
rel = tezgah_task.relative(path,
|
|
2149
|
+
rel = tezgah_task.relative(os.path.join(here, os.path.expanduser(path)),
|
|
2150
|
+
cwd, base)
|
|
2014
2151
|
seg = rel.split("/", 1)[0] if rel and "/" in rel else None
|
|
2015
2152
|
if seg not in WORKSPACE_KINDS:
|
|
2016
2153
|
continue
|
|
@@ -2775,6 +2912,11 @@ HELD = "a directory that holds tezgah's control state"
|
|
|
2775
2912
|
# A word the shell expands into names (a glob or a brace): directly under the
|
|
2776
2913
|
# open plans it names every plan, not one.
|
|
2777
2914
|
GLOB_CHARS = re.compile(r"[*?\[{]")
|
|
2915
|
+
# omp's device URLs whose call the extension records as the call it carries
|
|
2916
|
+
# (hosts/omp/tezgah-hook.ts.in `DEVICE`): the gate still reads the device
|
|
2917
|
+
# `write`'s body, and writes no `began` for it, since no result answers it
|
|
2918
|
+
# under that name (plan 153).
|
|
2919
|
+
ROUTED_DEVICE = re.compile(r"^xd://(?:mcp__[^/?#]+|ast_edit)$", re.I)
|
|
2778
2920
|
|
|
2779
2921
|
|
|
2780
2922
|
def _refusing(label):
|
|
@@ -2807,12 +2949,14 @@ HOOK_HOSTS = frozenset(("codex", "cursor", "omp", "qwen", "droid", "commandcode"
|
|
|
2807
2949
|
# target, except for the copiers, whose last one is (`cp switch /tmp` reads it).
|
|
2808
2950
|
# `sed`/`perl` count only with an in-place flag. `sqlite3` rewrites the cache
|
|
2809
2951
|
# stores, rows of `tezgah.db`, as `rm` would remove the files they were; its
|
|
2810
|
-
# SQL argument names no protected path.
|
|
2952
|
+
# SQL argument names no protected path. `mkfifo`/`mknod` make a file that exists
|
|
2953
|
+
# (a switch is read by its existence) and `ditto` is macOS's copier (audit B-029).
|
|
2811
2954
|
CONTROL_PROGRAMS = frozenset(("touch", "rm", "rmdir", "unlink", "shred",
|
|
2812
2955
|
"truncate", "chmod", "chown", "chgrp", "mv",
|
|
2813
2956
|
"mkdir", "tee", "cp", "ln", "install", "rsync",
|
|
2814
|
-
"dd", "sed", "perl", "sqlite3"
|
|
2815
|
-
|
|
2957
|
+
"dd", "sed", "perl", "sqlite3", "ditto", "mkfifo",
|
|
2958
|
+
"mknod"))
|
|
2959
|
+
CONTROL_COPIERS = frozenset(("cp", "mv", "ln", "install", "rsync", "ditto"))
|
|
2816
2960
|
# The programs that remove, disable or move a path. A directory that holds
|
|
2817
2961
|
# protected state, a repository's husky hooks and the open plans (evidence) are
|
|
2818
2962
|
# judged for these only: husky's own setup writes `.husky/pre-commit` with a
|
|
@@ -2826,12 +2970,6 @@ OPTION_TARGETS = {"cp": _INTO, "mv": _INTO, "ln": _INTO, "install": _INTO,
|
|
|
2826
2970
|
"wget": ("-O", "--output-document"), "unzip": ("-d",)}
|
|
2827
2971
|
INTERPRETERS = frozenset(("python", "python3", "sh", "bash", "zsh", "dash"))
|
|
2828
2972
|
SHELL_NAMES = frozenset(("sh", "bash", "zsh", "dash", "ksh"))
|
|
2829
|
-
# Words that open a compound command: the command after one is read as if it
|
|
2830
|
-
# were not there (`if touch x; then`, `! rm x`, `do touch x`). `[`/`]` are not
|
|
2831
|
-
# here: the test command is skipped in `_program`, so the command after it in
|
|
2832
|
-
# an `if` line is read.
|
|
2833
|
-
SHELL_KEYWORDS = frozenset(("if", "then", "elif", "else", "do", "while",
|
|
2834
|
-
"until", "!", "{", "time"))
|
|
2835
2973
|
SHELL_OPS = re.compile(r"^[;&|()<>]+$")
|
|
2836
2974
|
# The cache subtrees whose rows the rules read: the cache database with its WAL
|
|
2837
2975
|
# files (the evidence ledger, the used-kind marks, the switch baselines of
|
|
@@ -2849,12 +2987,26 @@ OWNED_KEYS = ("statusLine", "attribution", "disableAllHooks", "hooksConfig")
|
|
|
2849
2987
|
PATCH_DELETE = re.compile(r"(?m)^\*\*\* Delete File: (\S.*?)\s*$")
|
|
2850
2988
|
|
|
2851
2989
|
|
|
2990
|
+
# `${VAR:-default}` and `${VAR-default}`: os.path.expandvars reads neither, so
|
|
2991
|
+
# `${XDG_CONFIG_HOME:-$HOME/.config}/tezgah` stayed a relative name (B-029)
|
|
2992
|
+
DEFAULT_VAR = re.compile(r"\$\{(\w+)(:?)-([^{}]*)\}")
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def _expanded(path):
|
|
2996
|
+
"""`path` with `${VAR:-default}`, `~`, `$HOME` and the XDG variables
|
|
2997
|
+
expanded the way the shell would."""
|
|
2998
|
+
def default(m):
|
|
2999
|
+
value = os.environ.get(m.group(1))
|
|
3000
|
+
return value if (value if m.group(2) else value is not None) else m.group(3)
|
|
3001
|
+
return os.path.expandvars(os.path.expanduser(DEFAULT_VAR.sub(default, str(path))))
|
|
3002
|
+
|
|
3003
|
+
|
|
2852
3004
|
def _real(path, cwd):
|
|
2853
|
-
"""`path` as an absolute path:
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
path =
|
|
3005
|
+
"""`path` as an absolute path: expanded (`_expanded`), relative to `cwd`,
|
|
3006
|
+
its directory resolved and its last component kept. A hook file the
|
|
3007
|
+
installer symlinked into a host's config is still that host's file, and
|
|
3008
|
+
the checkout it points at is not."""
|
|
3009
|
+
path = _expanded(path)
|
|
2858
3010
|
parent, name = os.path.split(os.path.normpath(os.path.join(cwd or os.sep, path)))
|
|
2859
3011
|
return os.path.join(os.path.realpath(parent), name)
|
|
2860
3012
|
|
|
@@ -2862,7 +3014,7 @@ def _real(path, cwd):
|
|
|
2862
3014
|
def _full(path, cwd):
|
|
2863
3015
|
"""`path` with every link resolved, the last component's too: a write
|
|
2864
3016
|
through a link lands on what it points at."""
|
|
2865
|
-
path =
|
|
3017
|
+
path = _expanded(path)
|
|
2866
3018
|
return os.path.realpath(os.path.join(cwd or os.sep, path))
|
|
2867
3019
|
|
|
2868
3020
|
|
|
@@ -2920,13 +3072,10 @@ def _held(origin):
|
|
|
2920
3072
|
return out
|
|
2921
3073
|
|
|
2922
3074
|
|
|
2923
|
-
def
|
|
2924
|
-
"""
|
|
2925
|
-
'
|
|
2926
|
-
`settings*.json` counts in the user's own `~/.claude` or in a project under
|
|
2927
|
-
a root, not in a fixture under /tmp or another temp HOME."""
|
|
3075
|
+
def _registration_files():
|
|
3076
|
+
"""(shared, owned): the files tezgah writes entries into beside the
|
|
3077
|
+
user's, and the hook files that are wholly tezgah's."""
|
|
2928
3078
|
claude = HOST_DIRS["claude"]
|
|
2929
|
-
name = os.path.basename(real)
|
|
2930
3079
|
shared = (os.path.join(HOST_DIRS["codex"], "hooks.json"),
|
|
2931
3080
|
os.path.join(HOST_DIRS["codex"], "config.toml"),
|
|
2932
3081
|
os.path.join(HOST_DIRS["cursor"], "hooks.json"),
|
|
@@ -2937,16 +3086,27 @@ def _registration(real):
|
|
|
2937
3086
|
os.path.join(HOST_DIRS["commandcode"], "settings.json"), os.path.join(HOST_DIRS["copilot"], "settings.json"), os.path.join(HOST_DIRS["gemini"], "settings.json"), os.path.join(HOST_DIRS["antigravity"], "settings.json"), os.path.join(os.path.dirname(HOST_DIRS["antigravity"]), "config", "hooks.json"), os.path.join(HOST_DIRS["goose"], "settings.json"),
|
|
2938
3087
|
os.path.join(claude, "plugins", "installed_plugins.json"),
|
|
2939
3088
|
os.path.join(claude, "plugins", "known_marketplaces.json"))
|
|
3089
|
+
owned = [os.path.join(HOST_DIRS["opencode"], d, "tezgah.js")
|
|
3090
|
+
for d in ("plugins", "plugin")]
|
|
3091
|
+
owned += [os.path.join(HOST_DIRS["omp"], "agent", "hooks", "pre", "tezgah-hook.ts"),
|
|
3092
|
+
os.path.join(HOST_DIRS["copilot"], "hooks", "tezgah.json")] + [os.path.join(HOST_DIRS["gemini"], "extensions", "tezgah", *p) for p in (("gemini-extension.json",), ("hooks", "hooks.json"))] + [os.path.join(GOOSE_PLUGIN, *p) for p in (("plugin.json",), ("hooks", "hooks.json"))] + [AMP_PLUGIN]
|
|
3093
|
+
return shared, owned
|
|
3094
|
+
|
|
3095
|
+
|
|
3096
|
+
def _registration(real):
|
|
3097
|
+
"""'shared' for a file tezgah writes entries into beside the user's,
|
|
3098
|
+
'owned' for a hook file that is wholly tezgah's, else None. A Claude
|
|
3099
|
+
`settings*.json` counts in the user's own `~/.claude` or in a project under
|
|
3100
|
+
a root, not in a fixture under /tmp or another temp HOME."""
|
|
3101
|
+
claude = HOST_DIRS["claude"]
|
|
3102
|
+
name = os.path.basename(real)
|
|
3103
|
+
shared, owned = _registration_files()
|
|
2940
3104
|
if (name in ("settings.json", "settings.local.json")
|
|
2941
3105
|
and ".claude" in real.split(os.sep)
|
|
2942
3106
|
and (os.path.dirname(real) == os.path.realpath(claude)
|
|
2943
3107
|
or root_for(real))) \
|
|
2944
3108
|
or real in {_real(p, None) for p in shared}:
|
|
2945
3109
|
return "shared"
|
|
2946
|
-
owned = [os.path.join(HOST_DIRS["opencode"], d, "tezgah.js")
|
|
2947
|
-
for d in ("plugins", "plugin")]
|
|
2948
|
-
owned += [os.path.join(HOST_DIRS["omp"], "agent", "hooks", "pre", "tezgah-hook.ts"),
|
|
2949
|
-
os.path.join(HOST_DIRS["copilot"], "hooks", "tezgah.json")] + [os.path.join(HOST_DIRS["gemini"], "extensions", "tezgah", *p) for p in (("gemini-extension.json",), ("hooks", "hooks.json"))] + [os.path.join(GOOSE_PLUGIN, *p) for p in (("plugin.json",), ("hooks", "hooks.json"))] + [AMP_PLUGIN]
|
|
2950
3110
|
cache = os.path.realpath(os.path.join(claude, "plugins", "cache"))
|
|
2951
3111
|
if real in {_real(p, None) for p in owned} or (
|
|
2952
3112
|
_under(real, cache) and "tezgah" in real[len(cache):].lower()):
|
|
@@ -2954,6 +3114,39 @@ def _registration(real):
|
|
|
2954
3114
|
return None
|
|
2955
3115
|
|
|
2956
3116
|
|
|
3117
|
+
def _git_config_files(origin):
|
|
3118
|
+
"""`~/.gitconfig`, `$XDG_CONFIG_HOME/git/config`, and each session
|
|
3119
|
+
checkout's `.git/config` and `.git/config.worktree`."""
|
|
3120
|
+
home = os.path.expanduser("~")
|
|
3121
|
+
files = [os.path.join(home, ".gitconfig"), os.path.join(
|
|
3122
|
+
os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config"), "git", "config")]
|
|
3123
|
+
for top in _tops(origin):
|
|
3124
|
+
files += [os.path.join(top, ".git", "config"),
|
|
3125
|
+
os.path.join(top, ".git", "config.worktree")]
|
|
3126
|
+
return files
|
|
3127
|
+
|
|
3128
|
+
|
|
3129
|
+
def _git_config(real, origin):
|
|
3130
|
+
"""True for a git config file whose `core.hooksPath` or `include.path`
|
|
3131
|
+
moves the hooks of the session's commits: `_git_config_files`, and a
|
|
3132
|
+
linked worktree's `.git/worktrees/*/config.worktree`. Judged by key like a
|
|
3133
|
+
shared registration file (`_shared_change`, audit B-002, review 148 #2)."""
|
|
3134
|
+
for top in _tops(origin):
|
|
3135
|
+
if os.path.basename(real) == "config.worktree" and _under(
|
|
3136
|
+
real, _real(os.path.join(top, ".git", "worktrees"), None)):
|
|
3137
|
+
return True
|
|
3138
|
+
return real in {_real(f, None) for f in _git_config_files(origin)}
|
|
3139
|
+
|
|
3140
|
+
|
|
3141
|
+
# The git config keys that change which hooks run: HOOKS_KEYS (`core.hooksPath`
|
|
3142
|
+
# and an include that can carry one) and `extensions.worktreeConfig`, which
|
|
3143
|
+
# makes git read `.git/config.worktree` (review 148 #1, #2).
|
|
3144
|
+
def _hooks_setting(word):
|
|
3145
|
+
"""True when a `git config` word names one of those keys."""
|
|
3146
|
+
key = word.lower()
|
|
3147
|
+
return key == "extensions.worktreeconfig" or bool(HOOKS_KEYS.fullmatch(key))
|
|
3148
|
+
|
|
3149
|
+
|
|
2957
3150
|
def _target_label(real, remove, origin):
|
|
2958
3151
|
"""`control_target` for one spelling of the path."""
|
|
2959
3152
|
name, parent = os.path.basename(real), os.path.dirname(real)
|
|
@@ -2973,7 +3166,7 @@ def _target_label(real, remove, origin):
|
|
|
2973
3166
|
and root_for(parent):
|
|
2974
3167
|
return "a repository's extension seam (`%s`)" % name
|
|
2975
3168
|
kind = _registration(real)
|
|
2976
|
-
if kind == "shared":
|
|
3169
|
+
if kind == "shared" or _git_config(real, origin):
|
|
2977
3170
|
return real
|
|
2978
3171
|
# an install tree's own `.tezgah/` is the private workspace of a checkout
|
|
2979
3172
|
# used as the install, not code the hooks run
|
|
@@ -3022,6 +3215,69 @@ def control_target(path, cwd, remove=False, origin=None):
|
|
|
3022
3215
|
return noted
|
|
3023
3216
|
|
|
3024
3217
|
|
|
3218
|
+
def _control_files(origin):
|
|
3219
|
+
"""(file, label) for each control file outside `_held` that
|
|
3220
|
+
`control_target` refuses or judges by key: tezgah's hook files and shared
|
|
3221
|
+
registrations (`_registration_files`), Claude's settings, the tezgah copies
|
|
3222
|
+
in Claude's plugin cache, and the git config files (`_git_config`). A tree
|
|
3223
|
+
rewrite cannot be judged by key, so a tree over one takes the whole file."""
|
|
3224
|
+
shared, owned = _registration_files()
|
|
3225
|
+
claude = HOST_DIRS["claude"]
|
|
3226
|
+
tops = _tops(origin)
|
|
3227
|
+
wiring = list(shared) + owned + [
|
|
3228
|
+
os.path.join(d, n) for d in [claude] + [os.path.join(t, ".claude") for t in tops]
|
|
3229
|
+
for n in ("settings.json", "settings.local.json")]
|
|
3230
|
+
cache = os.path.join(claude, "plugins", "cache")
|
|
3231
|
+
wiring += [p for depth in ("*", os.path.join("*", "*"))
|
|
3232
|
+
for p in iglob(os.path.join(cache, depth))
|
|
3233
|
+
if "tezgah" in os.path.basename(p).lower()]
|
|
3234
|
+
git = _git_config_files(origin) + [
|
|
3235
|
+
p for t in tops for p in iglob(os.path.join(t, ".git", "worktrees", "*",
|
|
3236
|
+
"config.worktree"))]
|
|
3237
|
+
return ([(_real(p, None), "tezgah's hook wiring") for p in wiring]
|
|
3238
|
+
+ [(_real(p, None), "the repository's git hooks (`core.hooksPath` / "
|
|
3239
|
+
"`include.path`)") for p in git])
|
|
3240
|
+
|
|
3241
|
+
|
|
3242
|
+
def _tree_label(path, cwd):
|
|
3243
|
+
"""The control label of a glob or directory `path` that reaches control
|
|
3244
|
+
state, else None: its fixed prefix's own label, HELD for a protected
|
|
3245
|
+
directory (`_held`) under the prefix, or the label of a control file
|
|
3246
|
+
(`_control_files`) under the directory or matched by the glob.
|
|
3247
|
+
|
|
3248
|
+
omp's ast_edit takes directories and globs in `paths` (review 153 F2, round
|
|
3249
|
+
2 #2), and reading one as the tree it names only refuses more. A target
|
|
3250
|
+
behind a dot entry (`.git/hooks` from the repository root, `.config/tezgah`
|
|
3251
|
+
from HOME) counts only when the glob spells a dot entry past its prefix: a
|
|
3252
|
+
tree walk skips them. ponytail: fnmatch, where `*` crosses `/`, and a brace
|
|
3253
|
+
glob takes every control file under its prefix."""
|
|
3254
|
+
path = str(path or "").strip()
|
|
3255
|
+
glob = GLOB_CHARS.search(path)
|
|
3256
|
+
if not path or (not glob and not os.path.isdir(_full(path, cwd))):
|
|
3257
|
+
return None
|
|
3258
|
+
head = path[:glob.start()] if glob else path
|
|
3259
|
+
prefix = head[:head.rfind(os.sep) + 1] if glob else head
|
|
3260
|
+
label = control_target(prefix or ".", cwd)
|
|
3261
|
+
if _refusing(label):
|
|
3262
|
+
return label
|
|
3263
|
+
root = _full(prefix or ".", cwd)
|
|
3264
|
+
tail = path[len(prefix):] if glob else ""
|
|
3265
|
+
dotted = glob is not None and (os.sep + ".") in os.sep + path[glob.start():]
|
|
3266
|
+
|
|
3267
|
+
def walked(target):
|
|
3268
|
+
rel = os.path.relpath(target, root)
|
|
3269
|
+
return _under(target, root) and (dotted or not any(
|
|
3270
|
+
part.startswith(".") for part in rel.split(os.sep)))
|
|
3271
|
+
|
|
3272
|
+
if any(walked(held) for held in _held(cwd)):
|
|
3273
|
+
return HELD
|
|
3274
|
+
for target, label in _control_files(cwd):
|
|
3275
|
+
if walked(target) and (not glob or "{" in tail
|
|
3276
|
+
or fnmatch.fnmatchcase(target, os.path.join(root, tail))):
|
|
3277
|
+
return label
|
|
3278
|
+
return None
|
|
3279
|
+
|
|
3280
|
+
|
|
3025
3281
|
def _names_tezgah(value):
|
|
3026
3282
|
return "tezgah" in json.dumps(value, sort_keys=True).lower()
|
|
3027
3283
|
|
|
@@ -3099,8 +3355,27 @@ def _after_text(inp, current):
|
|
|
3099
3355
|
return text
|
|
3100
3356
|
|
|
3101
3357
|
|
|
3358
|
+
def _git_hook_keys(text):
|
|
3359
|
+
"""The `core.hooksPath` and `include.path` / `includeIf.path` entries of a
|
|
3360
|
+
git config text, in order: the settings that change which hooks run."""
|
|
3361
|
+
out, section = [], ""
|
|
3362
|
+
for line in (text or "").splitlines():
|
|
3363
|
+
s = line.strip()
|
|
3364
|
+
m = re.match(r"\[\s*([^\]\s\"]+)", s)
|
|
3365
|
+
if m:
|
|
3366
|
+
section = m.group(1).lower()
|
|
3367
|
+
s = s[s.find("]") + 1:].strip() if "]" in s else ""
|
|
3368
|
+
key, eq, value = s.partition("=")
|
|
3369
|
+
if eq and (section, key.strip().lower()) in (
|
|
3370
|
+
("core", "hookspath"), ("include", "path"), ("includeif", "path"),
|
|
3371
|
+
("extensions", "worktreeconfig")):
|
|
3372
|
+
out.append((section, key.strip().lower(), value.strip()))
|
|
3373
|
+
return out
|
|
3374
|
+
|
|
3375
|
+
|
|
3102
3376
|
def _shared_change(real, inp):
|
|
3103
|
-
"""True when this write changes a tezgah-owned entry of a shared file
|
|
3377
|
+
"""True when this write changes a tezgah-owned entry of a shared file, or
|
|
3378
|
+
a hooks entry of a git config file (`_git_config`)."""
|
|
3104
3379
|
try:
|
|
3105
3380
|
with open(real, encoding="utf-8") as fh:
|
|
3106
3381
|
current = fh.read()
|
|
@@ -3109,6 +3384,8 @@ def _shared_change(real, inp):
|
|
|
3109
3384
|
after = _after_text(inp, current)
|
|
3110
3385
|
if after is None:
|
|
3111
3386
|
return True
|
|
3387
|
+
if os.path.basename(real) in ("config", ".gitconfig", "config.worktree"):
|
|
3388
|
+
return _git_hook_keys(current) != _git_hook_keys(after)
|
|
3112
3389
|
if real.endswith(".toml"):
|
|
3113
3390
|
return _toml_owned(current) != _toml_owned(after)
|
|
3114
3391
|
return sorted(_owned(_parsed(current))) != sorted(_owned(_parsed(after)))
|
|
@@ -3119,7 +3396,7 @@ def _shell_label(label):
|
|
|
3119
3396
|
to it is refused whole."""
|
|
3120
3397
|
if not os.path.isabs(label):
|
|
3121
3398
|
return label
|
|
3122
|
-
return ("%s, which holds
|
|
3399
|
+
return ("%s, which holds hook wiring. A shell write is not read "
|
|
3123
3400
|
"key by key, so it is refused whole; an edit that leaves tezgah's "
|
|
3124
3401
|
"entries as they are is not" % label)
|
|
3125
3402
|
|
|
@@ -3221,43 +3498,16 @@ def _shell_words(command):
|
|
|
3221
3498
|
return out
|
|
3222
3499
|
|
|
3223
3500
|
|
|
3224
|
-
# The wrappers `_program` reads past, by basename, with the options that take
|
|
3225
|
-
# the next word as their value; `timeout` also takes its duration.
|
|
3226
|
-
WRAPPER_VALUE_OPTS = {"sudo": ("-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U"),
|
|
3227
|
-
"env": ("-u", "--unset", "-C", "--chdir"),
|
|
3228
|
-
"nice": ("-n", "--adjustment"), "nohup": (), "exec": ("-a",),
|
|
3229
|
-
"time": ("-f", "-o"), "command": (),
|
|
3230
|
-
"timeout": ("-s", "--signal", "-k", "--kill-after"),
|
|
3231
|
-
"stdbuf": ("-i", "-o", "-e")}
|
|
3232
3501
|
# An interpreter's options that take the next word as their value, and the ones
|
|
3233
3502
|
# that make the program its own text rather than a script (`python3 -c`, `-m`).
|
|
3234
3503
|
INTERPRETER_VALUE_OPTS = ("-X", "-W", "-o", "+o", "-O")
|
|
3235
3504
|
|
|
3236
3505
|
|
|
3237
|
-
def _past_wrappers(words):
|
|
3238
|
-
"""`words` past compound keywords, env assignments and the wrappers
|
|
3239
|
-
(WRAPPER_VALUE_OPTS, by basename, with their options)."""
|
|
3240
|
-
i = 0
|
|
3241
|
-
while i < len(words):
|
|
3242
|
-
word = words[i]
|
|
3243
|
-
wrapper = os.path.basename(word)
|
|
3244
|
-
if ENV_WORD.match(word) or word in SHELL_KEYWORDS:
|
|
3245
|
-
i += 1
|
|
3246
|
-
continue
|
|
3247
|
-
if wrapper not in WRAPPER_VALUE_OPTS:
|
|
3248
|
-
break
|
|
3249
|
-
i += 1
|
|
3250
|
-
while i < len(words) and words[i].startswith("-"):
|
|
3251
|
-
i += 2 if words[i] in WRAPPER_VALUE_OPTS[wrapper] else 1
|
|
3252
|
-
if wrapper == "timeout" and i < len(words):
|
|
3253
|
-
i += 1 # the duration
|
|
3254
|
-
return words[i:]
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
3506
|
def _program(words):
|
|
3258
3507
|
"""(program basename without `.py`, its arguments) for one simple command,
|
|
3259
|
-
past compound keywords, env assignments, the wrappers
|
|
3260
|
-
|
|
3508
|
+
past compound keywords, env assignments, the wrappers
|
|
3509
|
+
(tezgah_integrity._past_wrappers, the one reader the shortcut rule uses
|
|
3510
|
+
too) and an interpreter's options before the script it runs."""
|
|
3261
3511
|
words = _past_wrappers(words)
|
|
3262
3512
|
if words and words[0] == "[":
|
|
3263
3513
|
# the test command: its operands are not a program, and the command
|
|
@@ -3336,15 +3586,27 @@ def _git_change(args, cwd, origin):
|
|
|
3336
3586
|
return _shell_label(label)
|
|
3337
3587
|
noted = noted or label
|
|
3338
3588
|
return noted
|
|
3339
|
-
if sub == "config" and
|
|
3589
|
+
if sub == "config" and any(_hooks_setting(a) for a in rest):
|
|
3340
3590
|
head = rest[:rest.index("--")] if "--" in rest else rest
|
|
3591
|
+
# `-f`/`--file` names the file written, wherever the cwd is (review
|
|
3592
|
+
# 148 r2 #1): that file is judged, and its name is no positional
|
|
3593
|
+
given = None
|
|
3594
|
+
for k, a in enumerate(head):
|
|
3595
|
+
m = re.fullmatch(r"(?:-f|--file=?)(.*)", a, re.S)
|
|
3596
|
+
if m and (m.group(1) or k + 1 < len(head)):
|
|
3597
|
+
given = m.group(1) or head[k + 1]
|
|
3598
|
+
head = head[:k] + head[k + (1 if m.group(1) else 2):]
|
|
3599
|
+
break
|
|
3341
3600
|
positional = [a for a in head if not a.startswith("-")]
|
|
3342
3601
|
if any(a.startswith(("--get", "--list")) or a == "-l" for a in head) \
|
|
3343
3602
|
or positional[:1] in (["get"], ["list"]):
|
|
3344
3603
|
return None
|
|
3345
|
-
# the session's own checkout
|
|
3346
|
-
|
|
3347
|
-
|
|
3604
|
+
# the named file, else the session's own checkout or every repository
|
|
3605
|
+
if given is not None:
|
|
3606
|
+
mine = _git_config(_real(given, where), origin)
|
|
3607
|
+
else:
|
|
3608
|
+
mine = any(a in ("--global", "--system") for a in head) or any(
|
|
3609
|
+
_under(os.path.realpath(where), top) for top in _tops(origin))
|
|
3348
3610
|
if mine and (len(positional) > 1 or any(a.startswith("--unset") for a in head)):
|
|
3349
3611
|
return "the repository's git hooks"
|
|
3350
3612
|
return None
|
|
@@ -3364,7 +3626,9 @@ def _command_change(words, cwd, depth, origin):
|
|
|
3364
3626
|
"""The control-plane label one simple command changes, or None."""
|
|
3365
3627
|
program, args = _program(words)
|
|
3366
3628
|
if program == "eval" and depth < 3:
|
|
3367
|
-
|
|
3629
|
+
# a leading `--` only ends eval's options (review m3)
|
|
3630
|
+
return shell_control(" ".join(args[1:] if args[:1] == ["--"] else args),
|
|
3631
|
+
cwd, depth + 1, origin)
|
|
3368
3632
|
if program in SHELL_NAMES and depth < 3:
|
|
3369
3633
|
# `-c`, or any flag cluster holding it (`-lc`, `-ec`); a `--` after it
|
|
3370
3634
|
# ends the options and the script is the word after that
|
|
@@ -3486,21 +3750,44 @@ def shell_control(command, cwd, depth=0, origin=None):
|
|
|
3486
3750
|
return noted
|
|
3487
3751
|
|
|
3488
3752
|
|
|
3753
|
+
# A forge MCP server's tools, by the server's name as the host spells the tool
|
|
3754
|
+
# (`mcp__github__create_or_update_file`, omp's `mcp__github_...`).
|
|
3755
|
+
# ponytail: a forge server registered under another name is read as local.
|
|
3756
|
+
REMOTE_MCP = re.compile(r"mcp__(?:github|gitlab|gitea|bitbucket)_", re.I)
|
|
3757
|
+
|
|
3758
|
+
|
|
3489
3759
|
def control_reason(t, inp, cwd):
|
|
3490
3760
|
"""The control-plane label this call changes, or None (see CONTROL_DENY).
|
|
3491
|
-
A CONTROL_EVIDENCE label comes back only when nothing in the call refuses.
|
|
3761
|
+
A CONTROL_EVIDENCE label comes back only when nothing in the call refuses.
|
|
3762
|
+
Beside the write path, an MCP call's `path`, `file_path` and `destination`
|
|
3763
|
+
fields are read (a move's target), and its `source` as a removal (a move
|
|
3764
|
+
takes it away, review 148 #3). A forge server's tool (REMOTE_MCP: GitHub,
|
|
3765
|
+
GitLab, Gitea, Bitbucket) writes into a remote repository, never under the
|
|
3766
|
+
local cwd; the server name decides, never a `repo` field the caller can add
|
|
3767
|
+
to any call (review 148 r2 #2). Only an `edit` omp's bridge derived from
|
|
3768
|
+
an `ast_edit` (`via: "ast_edit"`) is read as the tree it may name
|
|
3769
|
+
(`_tree_label`, review 153 round 2 #3): the field only adds a refusal."""
|
|
3492
3770
|
if t in BASH_TOOLS:
|
|
3493
3771
|
return shell_control(inp.get("command") or inp.get("cmd"), cwd)
|
|
3772
|
+
if REMOTE_MCP.match(str(t)):
|
|
3773
|
+
return None
|
|
3494
3774
|
deletes = set(PATCH_DELETE.findall(str(inp.get("patch") or "")))
|
|
3775
|
+
source = inp.get("source")
|
|
3776
|
+
if isinstance(source, str) and source.strip():
|
|
3777
|
+
deletes.add(source.strip())
|
|
3495
3778
|
noted = None
|
|
3496
|
-
for
|
|
3497
|
-
|
|
3779
|
+
named = [inp.get(k) for k in ("path", "file_path", "destination", "source")]
|
|
3780
|
+
for path in dict.fromkeys(write_paths(inp) + [p.strip() for p in named
|
|
3781
|
+
if isinstance(p, str) and p.strip()]):
|
|
3782
|
+
label = (control_target(path, cwd, remove=path in deletes)
|
|
3783
|
+
or (_tree_label(path, cwd) if inp.get("via") == "ast_edit" else None))
|
|
3498
3784
|
if label and not os.path.isabs(label):
|
|
3499
3785
|
if _refusing(label):
|
|
3500
3786
|
return label
|
|
3501
3787
|
noted = noted or label
|
|
3502
3788
|
elif label and _shared_change(label, inp):
|
|
3503
|
-
return "
|
|
3789
|
+
return ("the repository's git hooks (`core.hooksPath` / `include.path`)"
|
|
3790
|
+
if _git_config(label, cwd) else "tezgah's hook wiring")
|
|
3504
3791
|
return noted
|
|
3505
3792
|
|
|
3506
3793
|
|
|
@@ -3599,7 +3886,7 @@ def _deny(session_id, rule, reason, tool=None, inp=None, workspace=None,
|
|
|
3599
3886
|
return reason
|
|
3600
3887
|
|
|
3601
3888
|
|
|
3602
|
-
def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
3889
|
+
def decision(tool, inp, cwd, session_id=None, record=True, agent=None, parent=None):
|
|
3603
3890
|
"""A deny reason for this call, or None to let it pass.
|
|
3604
3891
|
|
|
3605
3892
|
`record=False` is the dry run `tezgah-gate check` and the MCP
|
|
@@ -3610,8 +3897,10 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
3610
3897
|
|
|
3611
3898
|
`agent` is the host's subagent id when the call came from one (Claude's
|
|
3612
3899
|
`agent_id`): the repeat ceilings count that agent's own attempts, so one
|
|
3613
|
-
sibling's failures are not spent as another's.
|
|
3614
|
-
|
|
3900
|
+
sibling's failures are not spent as another's. `parent` is the session that
|
|
3901
|
+
spawned this one, as the host named it (B-036): a host adapter that bound
|
|
3902
|
+
it already may leave it out, since a rebind of the same session keeps it."""
|
|
3903
|
+
bind_session(session_id, parent) # the switch latch: off() below answers for this session
|
|
3615
3904
|
if not record:
|
|
3616
3905
|
return _dry_decision(tool, inp, cwd, session_id, agent)
|
|
3617
3906
|
if off("pretooluse-off"):
|
|
@@ -3626,13 +3915,19 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
3626
3915
|
# hook writes the second row; a refusal below answers it with its `deny`.
|
|
3627
3916
|
# Through `note`, so the dry run writes none. `check` is decided here, from
|
|
3628
3917
|
# the whole command: the stored detail is cut at DETAIL_MAX.
|
|
3629
|
-
if t in WRITE_TOOLS or t in BASH_TOOLS
|
|
3918
|
+
if (t in WRITE_TOOLS or t in BASH_TOOLS) and not ROUTED_DEVICE.match(
|
|
3919
|
+
str(inp.get("path") or "").strip()):
|
|
3630
3920
|
digest = call_id(tool, inp)
|
|
3631
3921
|
if digest:
|
|
3632
3922
|
command = str(inp.get("command") or inp.get("cmd") or "")
|
|
3633
|
-
|
|
3923
|
+
# the file it writes, as the race rule matches it: another
|
|
3924
|
+
# session's `began` row nothing answered yet is a write in flight
|
|
3925
|
+
# (writers_elsewhere, audit B-069)
|
|
3926
|
+
paths = write_paths(inp)
|
|
3927
|
+
note(session_id, BEGAN_KIND, command or (paths or [""])[0],
|
|
3634
3928
|
id=digest, tool=str(tool or "").strip() or None, workspace=base,
|
|
3635
|
-
check=1 if t in BASH_TOOLS and verify_command(command, cwd) else None
|
|
3929
|
+
check=1 if t in BASH_TOOLS and verify_command(command, cwd) else None,
|
|
3930
|
+
target=_abs_target(paths[0], cwd) if paths else None)
|
|
3636
3931
|
# Claude's Task names the agent `subagent_type`; omp's task names it `agent`,
|
|
3637
3932
|
# on the call (flat shape) or on each `tasks[]` item (batch shape).
|
|
3638
3933
|
# A shell tool's `args` can be a list (Command Code splits argv there): only
|
|
@@ -3651,9 +3946,11 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
3651
3946
|
# file this one protects (control_reason). No switch but `pretooluse-off`
|
|
3652
3947
|
# removes it, and that one is among the files. A CONTROL_EVIDENCE target is
|
|
3653
3948
|
# not refused: the call goes on to the rules below, and a call they let
|
|
3654
|
-
# through leaves a `disarm` row beside its `began` row.
|
|
3949
|
+
# through leaves a `disarm` row beside its `began` row. An MCP write-class
|
|
3950
|
+
# call's path fields are judged the same way (audit B-011, owner answer
|
|
3951
|
+
# OQ-07).
|
|
3655
3952
|
evidence = None
|
|
3656
|
-
if t in WRITE_TOOLS + BASH_TOOLS:
|
|
3953
|
+
if t in WRITE_TOOLS + BASH_TOOLS or mcp_class(tool) == "write":
|
|
3657
3954
|
what = control_reason(t, inp, cwd)
|
|
3658
3955
|
if _refusing(what):
|
|
3659
3956
|
return _deny(session_id, "control", CONTROL_DENY % what, tool, inp,
|
|
@@ -3923,14 +4220,15 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
|
3923
4220
|
|
|
3924
4221
|
# Every rule slug `decision` records a refusal under (`_deny`'s second argument),
|
|
3925
4222
|
# with the kill switch that disarms it beside `pretooluse-off`, which disarms
|
|
3926
|
-
# them all; None means only that one. A
|
|
3927
|
-
# `|| enforced_by: <slug>` (tezgah_lessons.lines)
|
|
3928
|
-
# block only while its rule is armed, so `piped` brings its
|
|
3929
|
-
# `verify-off`. `tests/test_lessons.py` holds this map to the
|
|
4223
|
+
# them all, or a tuple when either of several does; None means only that one. A
|
|
4224
|
+
# lesson the ledger retires with `|| enforced_by: <slug>` (tezgah_lessons.lines)
|
|
4225
|
+
# leaves the injected block only while its rule is armed, so `piped` brings its
|
|
4226
|
+
# lesson back under `verify-off`. `tests/test_lessons.py` holds this map to the
|
|
4227
|
+
# `_deny` literals.
|
|
3930
4228
|
DENY_RULES = {"control": None, "explorer": None, "shortcut": "verify-off",
|
|
3931
4229
|
"piped": "verify-off",
|
|
3932
4230
|
"attribution": None, "lang": "lang-off", "race": None,
|
|
3933
|
-
"lease": "orchestrate-off",
|
|
4231
|
+
"lease": ("orchestrate-off", "slices-off"),
|
|
3934
4232
|
"task": "task-off", "workspace": "workspace-off", "secret": None,
|
|
3935
4233
|
"plan": None, "order": "verify-off", "loop": "verify-off",
|
|
3936
4234
|
"retry": "verify-off", "drift": "reminder-off", "repo-rule": None}
|