@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/MANIFEST +18 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +15 -8
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +157 -41
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +12 -6
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +596 -157
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +3 -0
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +3 -3
  38. package/docs/contract.md +77 -31
  39. package/docs/environment.md +58 -24
  40. package/docs/evidence.md +49 -19
  41. package/docs/gate.md +71 -21
  42. package/docs/hosts.md +65 -52
  43. package/docs/layers.md +1 -1
  44. package/docs/operations.md +67 -24
  45. package/docs/research.md +1 -1
  46. package/docs/skills.md +31 -8
  47. package/docs/status-line.md +33 -23
  48. package/docs/testing.md +48 -17
  49. package/docs/vision.md +6 -2
  50. package/hooks/lang/en.json +5 -2
  51. package/hooks/lang/tr.json +3 -2
  52. package/hooks/projects-posttooluse.py +11 -3
  53. package/hooks/projects-stop.py +1 -1
  54. package/hooks/tezgah_context.py +40 -23
  55. package/hooks/tezgah_gate.py +404 -106
  56. package/hooks/tezgah_integrity.py +367 -97
  57. package/hooks/tezgah_lessons.py +5 -2
  58. package/hooks/tezgah_paths.py +17 -6
  59. package/hooks/tezgah_policy.py +13 -5
  60. package/hooks/tezgah_skill_pick.py +1 -1
  61. package/hooks/tezgah_snapshot.py +6 -3
  62. package/hooks/tezgah_stopspec.py +2 -1
  63. package/hosts/amp/hook.py +1 -1
  64. package/hosts/amp/tezgah-plugin.ts.in +13 -3
  65. package/hosts/antigravity/hook.py +1 -1
  66. package/hosts/codex/hook.py +1 -1
  67. package/hosts/commandcode/hook.py +1 -1
  68. package/hosts/copilot/hook.py +1 -1
  69. package/hosts/cursor/hook.py +2 -1
  70. package/hosts/droid/hook.py +1 -1
  71. package/hosts/gemini/hook.py +1 -1
  72. package/hosts/goose/hook.py +1 -1
  73. package/hosts/omp/hook.py +1 -1
  74. package/hosts/omp/tezgah-hook.ts.in +127 -35
  75. package/hosts/opencode/plugins/tezgah.js +209 -63
  76. package/hosts/qwen/hook.py +1 -1
  77. package/output-styles/tezgah.md +1 -1
  78. package/package.json +6 -3
  79. package/packaging/build.sh +4 -4
  80. package/packaging/install.sh +11 -0
  81. package/packaging/upgrade.sh +3 -0
  82. package/skills/ai-research/LICENSE +21 -0
  83. package/skills/design-contract/SKILL.md +27 -6
  84. package/skills/design-library/LICENSE +21 -0
  85. package/skills/plan-add/render_table.py +7 -0
  86. package/skills/pm-frameworks/LICENSE +21 -0
  87. package/skills/rl-env/LICENSE +201 -0
  88. package/skills/tezgah-contract/SKILL.md +2 -2
  89. package/third_party/open-code-review/LICENSE +201 -0
  90. package/third_party/open-code-review/README.md +15 -0
  91. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  92. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  93. package/workflows/graph-map.js +14 -5
@@ -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, ENV_WORD, GIT_VALUE_OPTS,
105
- HEREDOC_RISK, HOOKS_KEY, PATCH_FILE,
106
- PLAIN_TAIL, SECRET_PREFIXED, STEP_KINDS,
107
- WRITE_TOOLS, _blank_heredocs, _heredocs, bind_session,
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*:|generated with|made with|"
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`. `gh api` writes comments/reviews,
246
- # and `gh pr merge` lands a commit, so both count as writes.
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: `git add %s` stages a credential file, so every "
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
- return bool(WRITE_CMD.search(c) and ATTRIB.search(c))
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-])--title(?:=|\s+|(?=[\"']))")
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
- """The credential file a `git add` in this command names, or None."""
965
- if SECRET_FILE is None or "add" not in command:
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
- if args[i:i + 1] != ["add"]:
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
- for arg in args[i + 1:]:
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 (not arg.startswith("-") and SECRET_FILE.match(name)
979
- and name.lower() not in SECRET_TEMPLATES):
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, cwd, base)
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
- CONTROL_COPIERS = frozenset(("cp", "mv", "ln", "install", "rsync"))
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: `~`, `$HOME` and the XDG variables expanded
2854
- the way the shell would, relative to `cwd`, its directory resolved and its
2855
- last component kept. A hook file the installer symlinked into a host's
2856
- config is still that host's file, and the checkout it points at is not."""
2857
- path = os.path.expandvars(os.path.expanduser(str(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 = os.path.expandvars(os.path.expanduser(str(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 _registration(real):
2924
- """'shared' for a file tezgah writes entries into beside the user's,
2925
- 'owned' for a hook file that is wholly tezgah's, else None. A Claude
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 tezgah's hook wiring. A shell write is not read "
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 (`_past_wrappers`)
3260
- and an interpreter's options before the script it runs."""
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 HOOKS_KEY in (a.lower() for a in rest):
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, or every repository at once
3346
- mine = any(a in ("--global", "--system") for a in head) or any(
3347
- _under(os.path.realpath(where), top) for top in _tops(origin))
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
- return shell_control(" ".join(args), cwd, depth + 1, origin)
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 path in write_paths(inp):
3497
- label = control_target(path, cwd, remove=path in deletes)
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 "tezgah's hook wiring"
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
- bind_session(session_id) # the switch latch: off() below answers for this session
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
- note(session_id, BEGAN_KIND, command or (write_paths(inp) or [""])[0],
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 lesson the ledger retires with
3927
- # `|| enforced_by: <slug>` (tezgah_lessons.lines) leaves the injected
3928
- # block only while its rule is armed, so `piped` brings its lesson back under
3929
- # `verify-off`. `tests/test_lessons.py` holds this map to the `_deny` literals.
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}