@zalom/plastic 1.0.0-beta.24 → 1.0.0-beta.26

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.
@@ -10,15 +10,26 @@
10
10
  # reads never reach here.
11
11
  require "json"
12
12
  require_relative "lib/bridge"
13
+ require_relative "lib/lock"
13
14
 
14
15
  begin
15
16
  file_path = ARGV[0]
16
17
  exit 0 unless file_path && !file_path.empty?
17
18
  session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
18
19
 
19
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no lock held
20
- reason = Bridge.lock_gate_decision(bridge_data, file_path)
21
- exit 0 unless reason
20
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
21
+ reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
22
+ unless reason
23
+ # Allow path: refresh the lease for the session that holds this target's
24
+ # lock (owner or delegate). Best-effort, never blocks.
25
+ begin
26
+ dir = Bridge.intent_dir_for(file_path)
27
+ Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
28
+ rescue StandardError
29
+ # ignore
30
+ end
31
+ exit 0
32
+ end
22
33
 
23
34
  print JSON.generate(
24
35
  "hookSpecificOutput" => {
@@ -2,18 +2,20 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a).
6
- # Reads the tool call (JSON on stdin: tool_name + tool_input), computes
7
- # capabilities (QMD detect + freshness), delegates the decision to RetrievalGate,
8
- # and enforces:
9
- # ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
5
+ # PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a;
6
+ # ADVISORY since intent 108, D8). Reads the tool call (JSON on stdin: tool_name +
7
+ # tool_input), computes capabilities (QMD detect + freshness), and delegates the
8
+ # decision to RetrievalGate. It NEVER blocks: reads and searches always run
9
+ # (exit 0). A store content search that QMD could serve better gets a QMD hint
10
+ # via PreToolUse additionalContext on stdout. Hard gates guard writes, locks,
11
+ # and structure; never reads.
10
12
  # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
11
13
  # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
12
14
  # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
13
15
  #
14
- # Only CONTENT SEARCH over store markdown is gated; reads and structural ops are
15
- # allowed. Code navigation is a soft prompt mandate (UserPromptSubmit power-tools),
16
- # not enforced here, so this hook no longer detects Serena.
16
+ # Only CONTENT SEARCH over store markdown gets the hint; reads and structural
17
+ # ops stay silent. Code navigation is a soft prompt recommendation
18
+ # (UserPromptSubmit power-tools), not handled here.
17
19
  #
18
20
  # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
19
21
  # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
@@ -29,13 +31,13 @@ module RetrievalGateHook
29
31
  module_function
30
32
 
31
33
  # Pure-ish core: capabilities and reindex are injected so this is unit-testable
32
- # with no real qmd. Returns [exit_code, stderr_string].
34
+ # with no real qmd. Returns [exit_code, stderr_string, stdout_json].
33
35
  # stdin: raw PreToolUse JSON
34
36
  # capabilities: { qmd:, qmd_fresh: }
35
37
  # reindex: callable fired on the STALE path
36
38
  def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
37
39
  payload = parse(stdin)
38
- return [0, nil] unless payload
40
+ return [0, nil, nil] unless payload
39
41
 
40
42
  tool_name = payload["tool_name"].to_s
41
43
  tool_input = payload["tool_input"]
@@ -49,14 +51,23 @@ module RetrievalGateHook
49
51
  ) { |_sig| bypassed = true }
50
52
 
51
53
  if reason
52
- [2, "PLASTIC GATE #{reason}"]
53
- elsif bypassed
54
- [0, "PLASTIC GATE — bypassed via # qmd-ok"]
54
+ # Advisory (intent 108, D8): reads and searches are never denied. The
55
+ # would-block reason becomes a hint the agent sees alongside the result.
56
+ hint = JSON.generate(
57
+ "hookSpecificOutput" => {
58
+ "hookEventName" => "PreToolUse",
59
+ "additionalContext" =>
60
+ "PLASTIC advisory: #{reason} (this search ran; the hint is not a block)"
61
+ }
62
+ )
63
+ [0, nil, hint]
55
64
  else
56
- [0, nil]
65
+ # The `# qmd-ok` bypass token is still accepted (bypassed is set), but
66
+ # with nothing to bypass it no longer announces itself.
67
+ [0, nil, nil]
57
68
  end
58
69
  rescue StandardError
59
- [0, nil] # fail-open
70
+ [0, nil, nil] # fail-open
60
71
  end
61
72
 
62
73
  def parse(raw)
@@ -120,7 +131,7 @@ if $PROGRAM_NAME == __FILE__
120
131
  plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
121
132
  cwd = Dir.pwd
122
133
 
123
- code, err = begin
134
+ code, err, out = begin
124
135
  caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
125
136
  RetrievalGateHook.run(
126
137
  stdin: raw, plastic_home: plastic_home, cwd: cwd,
@@ -128,9 +139,10 @@ if $PROGRAM_NAME == __FILE__
128
139
  reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
129
140
  )
130
141
  rescue StandardError
131
- [0, nil] # fail-open at the outermost boundary too
142
+ [0, nil, nil] # fail-open at the outermost boundary too
132
143
  end
133
144
 
134
145
  $stderr.puts(err) if err && !err.empty?
146
+ print out if out && !out.empty?
135
147
  exit code
136
148
  end
@@ -8,6 +8,7 @@ require "tempfile"
8
8
  require "digest"
9
9
  require "socket"
10
10
  require_relative "worktree"
11
+ require_relative "lock"
11
12
 
12
13
  module Bridge
13
14
  STAGES = %w[what why how exec done].freeze
@@ -54,6 +55,31 @@ module Bridge
54
55
  value.nil? || value.to_s.strip.empty?
55
56
  end
56
57
 
58
+ # Raised by arm when the delivery lock cannot be acquired (held elsewhere,
59
+ # stale, excluded, or corrupt). The message names the resolving command.
60
+ class LockHeldError < StandardError; end
61
+
62
+ # The absolute intent dir a bridge points at, or nil.
63
+ def self.bridge_intent_dir(bridge_data)
64
+ return nil unless bridge_data.is_a?(Hash)
65
+ info = bridge_data["intent"] || {}
66
+ store = info["store"]
67
+ dir = info["dir"]
68
+ (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
69
+ end
70
+
71
+ # Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
72
+ # CACHE; the file is the truth). Never carries a pid.
73
+ def self.lock_cache(lock_data)
74
+ {
75
+ "owner_session" => lock_data["owner_session"],
76
+ "acquired_at" => lock_data["acquired_at"],
77
+ "host" => lock_data["host"],
78
+ "type" => lock_data["type"],
79
+ "delegates" => Array(lock_data["delegates"]),
80
+ }
81
+ end
82
+
57
83
  # Deterministic, session-id-less bridge key derived from store + intent id.
58
84
  # Stable across processes so a session-less arm and a later session-less
59
85
  # gate-check resolve to the same bridge file.
@@ -206,6 +232,13 @@ module Bridge
206
232
  id = data.dig("intent", "id")
207
233
  store = data.dig("intent", "store")
208
234
  keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
235
+ # Never purge a bridge whose intent still holds a delivery lock
236
+ # (intent 108, D6): the End tail clears the lock BEFORE the bridge
237
+ # becomes purge-eligible, so a held lock means the tail is not done.
238
+ unless keep
239
+ dir = bridge_intent_dir(data)
240
+ keep = !dir.nil? && File.exist?(Lock.path(dir))
241
+ end
209
242
  end
210
243
  next if keep
211
244
  File.delete(f)
@@ -476,7 +509,7 @@ module Bridge
476
509
  lines.length
477
510
  end
478
511
 
479
- def self.derive(session, intent_id:, intent_dir:, store:, name:)
512
+ def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
480
513
  stage = derive_stage(intent_dir)
481
514
  has = has_files(intent_dir)
482
515
  missing = missing_for_stage(stage, intent_dir) - has
@@ -516,17 +549,18 @@ module Bridge
516
549
  "store_branch" => nil,
517
550
  "provisioned" => false
518
551
  },
519
- # Delivery lock block (intent 73c). The bridge IS the lock; the owner is
520
- # whoever armed it. Born unowned; arm_auto stamps owner_session/pid/etc.
552
+ # Delivery-lock CACHE block (intent 108, D2). The durable truth is the
553
+ # delivery.lock file in the intent dir; arm fills this cache from it.
521
554
  "lock" => {
522
555
  "owner_session" => nil,
523
- "pid" => nil,
524
556
  "acquired_at" => nil,
525
- "host" => nil
557
+ "host" => nil,
558
+ "type" => nil,
559
+ "delegates" => []
526
560
  }
527
561
  }
528
562
 
529
- write(session, data)
563
+ write(session, data, tmp: tmp)
530
564
  data
531
565
  end
532
566
 
@@ -599,14 +633,27 @@ module Bridge
599
633
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
600
634
  data["build"]["auto"] = auto
601
635
 
602
- # Acquire the delivery lock: this armed bridge is now the single owner of the
603
- # intent's delivery (intent 73c). Stamp owner + pid liveness fields.
604
- data["lock"] = {
605
- "owner_session" => key,
606
- "pid" => Process.pid,
607
- "acquired_at" => Time.now.utc.iso8601,
608
- "host" => (Socket.gethostname rescue nil)
609
- }
636
+ # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
637
+ # intent dir. The bridge lock block is a cache of the file.
638
+ intent_dir_abs = File.expand_path(intent_dir)
639
+ status, lock_data = Lock.acquire(intent_dir_abs, session: key)
640
+ case status
641
+ when :acquired, :owned
642
+ data["lock"] = lock_cache(lock_data)
643
+ when :held
644
+ raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
645
+ "session #{lock_data && lock_data['owner_session']}; run /plastic-lock status"
646
+ when :stale
647
+ raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
648
+ "(owner #{lock_data && lock_data['owner_session']}); run /plastic-lock " \
649
+ "reclaim to take it over with an audit"
650
+ when :excluded
651
+ raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
652
+ "intent #{intent_id}; run /plastic-lock status"
653
+ when :corrupt
654
+ raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
655
+ "run /plastic-lock fix"
656
+ end
610
657
 
611
658
  # Provision the per-intent worktrees (mandatory code worktree for project
612
659
  # intents; fail-open for non-git / global-only). Never let a provision error
@@ -636,7 +683,11 @@ module Bridge
636
683
  arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
637
684
  end
638
685
 
639
- # Disarm auto mode. No-op if no bridge exists for the session.
686
+ # Disarm. No-op if no bridge exists for the session. End-tail order (D6):
687
+ # worktrees are merged/removed FIRST (the verify step is the caller's,
688
+ # before disarm), then the delivery lock is cleared, and only then does the
689
+ # bridge become purge-eligible. purge_done_bridges enforces the same order
690
+ # defensively by skipping any bridge whose intent still holds a lock.
640
691
  def self.disarm_auto(session)
641
692
  data = read(session)
642
693
  return nil unless data
@@ -652,11 +703,73 @@ module Bridge
652
703
  $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
653
704
  end
654
705
 
706
+ dir = bridge_intent_dir(data)
707
+ if dir
708
+ owner = data.dig("lock", "owner_session")
709
+ owner = session if blank?(owner)
710
+ Lock.release(dir, session: owner)
711
+ end
712
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
713
+ "host" => nil, "type" => nil, "delegates" => [] }
714
+
655
715
  write(session, data)
656
716
  purge_done_bridges(session: session)
657
717
  data
658
718
  end
659
719
 
720
+ # One deterministic, idempotent repair (intent 108, D5): diagnose, remove
721
+ # faulty own-side state, rebuild the durable lock AND the bridge cache from
722
+ # disk truth for the current session. Legacy /tmp-only pid locks are
723
+ # migrated here: the delivery.lock file is created and the cache rebuilt
724
+ # without a pid. NEVER touches a fresh foreign lock (reports "held"); a
725
+ # stale foreign lock reports "stale" and is taken only by the explicit
726
+ # reclaim verb (Lock.takeover). Two entry points call this: the
727
+ # plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
728
+ def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
729
+ now: Time.now, tmp: tmp_dir)
730
+ key = resolve_session(session, intent_id: intent_id, store: store)
731
+ dir = File.expand_path(intent_dir)
732
+ actions = []
733
+
734
+ if Lock.corrupt?(dir)
735
+ File.delete(Lock.path(dir))
736
+ actions << "removed corrupt delivery.lock"
737
+ end
738
+
739
+ lock = Lock.read(dir)
740
+ if lock && !Lock.authorized?(lock, key)
741
+ if Lock.fresh?(dir, now: now)
742
+ return { "status" => "held", "owner" => lock["owner_session"],
743
+ "actions" => actions, "session" => key }
744
+ end
745
+ return { "status" => "stale", "owner" => lock["owner_session"],
746
+ "actions" => actions, "session" => key,
747
+ "hint" => "run /plastic-lock reclaim to take over with an audit" }
748
+ end
749
+
750
+ if lock
751
+ Lock.heartbeat(dir, session: key, now: now)
752
+ lock_data = Lock.read(dir)
753
+ role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
754
+ actions << "lock kept (#{role})"
755
+ else
756
+ status, lock_data = Lock.acquire(dir, session: key, now: now)
757
+ actions << "lock #{status}"
758
+ end
759
+
760
+ previous = read(key, tmp: tmp)
761
+ auto = !!(previous && previous.dig("build", "auto"))
762
+ data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
763
+ name: name, tmp: tmp)
764
+ data["build"]["auto"] = auto
765
+ data["lock"] = lock_cache(lock_data)
766
+ write(key, data, tmp: tmp)
767
+ actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
768
+
769
+ { "status" => "repaired", "actions" => actions,
770
+ "lock" => lock_data, "session" => key }
771
+ end
772
+
660
773
  # Decide whether a code edit should be blocked while auto mode is armed.
661
774
  # Returns a reason string to BLOCK, or nil to ALLOW.
662
775
  #
@@ -693,58 +806,58 @@ module Bridge
693
806
 
694
807
  # --- Fail-closed lock gate (intent 96) -------------------------------------
695
808
 
696
- # Returns a reason String to BLOCK, or nil to ALLOW. bridge_data is whatever
697
- # discover_bridge(session:, cwd:) resolved for THIS session (own-session strict,
698
- # or the lone headless/derived-key bridge), or nil = no lock held.
699
- #
700
- # BLOCK iff the target is an ACTIVE intent's lifecycle dir AND this session does
701
- # NOT hold a LIVE lock FOR THAT SAME INTENT. ALLOW otherwise: a lock held for the
702
- # target intent; a not-yet-active intent (creation, What); project code; anything
703
- # that is not an active intent dir. The lock must match the TARGET intent: holding
704
- # a live lock on intent A never green-lights a mutating write into intent B's dir.
705
- # Project code is NOT gated here (D2) - it is isolated per-intent by worktree +
706
- # branch, not by a write-time lock.
707
- def self.lock_gate_decision(bridge_data, file_path)
809
+ # Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
810
+ # durable delivery.lock in the TARGET intent dir (D2): the bridge argument
811
+ # only supplies a fallback session id, so a missing or disagreeing bridge
812
+ # never changes the verdict. Every deny names the exact resolving command
813
+ # (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
814
+ # target's lock names as owner or delegate (even when stale: a stale lock is
815
+ # still its owner's until an explicit takeover).
816
+ def self.lock_gate_decision(bridge_data, file_path, session: nil,
817
+ ttl: Lock::TTL_SECONDS, now: Time.now)
708
818
  return nil if blank?(file_path)
709
819
 
710
- # Resolve the TARGET intent first so allow-by-lock is scoped to that intent.
711
820
  target_dir = intent_dir_for(file_path)
712
- return nil unless target_dir # not an intent dir (project code, scratch) -> ALLOW
713
- id = intent_id_from_dir(target_dir) # "<id>" from "<id>--<slug>"
714
- store = File.dirname(target_dir) # the store/ dir holding the intent
715
- return nil unless id && intent_active?(id, store: store) # not-yet-active (creation/What) -> ALLOW
716
-
717
- # ALLOW only when this session holds a live lock for THIS SAME intent. A live
718
- # lock on a different intent does not authorize writing into this intent's dir.
719
- return nil if holds_live_lock?(bridge_data) &&
720
- bridge_data.is_a?(Hash) &&
721
- bridge_data.dig("intent", "id").to_s == id.to_s
722
-
723
- "run /plastic-intent-starting to lock and begin"
724
- end
725
-
726
- # A discovered bridge counts as a held lock only if its lock is stamped AND the
727
- # owner process is live on THIS host. Staleness/identity lives HERE (gate read
728
- # path), never in arm_auto's write path. On host-mismatch we cannot probe the
729
- # pid, so fall back to "held" (a remote live owner should still block).
730
- def self.holds_live_lock?(bridge_data)
731
- return false unless bridge_data.is_a?(Hash)
732
- lock = bridge_data["lock"] || {}
733
- return false if blank?(lock["owner_session"])
734
- pid = lock["pid"]
735
- host = lock["host"]
736
- this_host = (Socket.gethostname rescue nil)
737
- if host && this_host && host == this_host && pid.is_a?(Integer)
738
- begin
739
- Process.kill(0, pid) # raises Errno::ESRCH if dead
740
- return true
741
- rescue Errno::ESRCH
742
- return false # dead owner -> not a held lock -> gate blocks (re-lock)
743
- rescue Errno::EPERM
744
- return true # exists, not ours to signal -> live
821
+ return nil unless target_dir
822
+ id = intent_id_from_dir(target_dir)
823
+ store = File.dirname(target_dir)
824
+ return nil unless id && intent_active?(id, store: store)
825
+
826
+ sess = session
827
+ sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
828
+
829
+ lock = Lock.read(target_dir)
830
+ if lock
831
+ return nil if Lock.authorized?(lock, sess)
832
+ if Lock.fresh?(target_dir, ttl: ttl, now: now)
833
+ return "intent #{id} delivery lock is held by session " \
834
+ "#{lock['owner_session']}. Back off; if you are the owner's " \
835
+ "subagent, the owner must run: plastic-lock delegate " \
836
+ "--intent-dir #{target_dir} --session <your-session-id>. " \
837
+ "Inspect with /plastic-lock status"
745
838
  end
839
+ return "intent #{id} has a stale delivery lock (owner " \
840
+ "#{lock['owner_session']}); run /plastic-lock reclaim to take " \
841
+ "it over, or /plastic-lock fix"
842
+ end
843
+ if Lock.corrupt?(target_dir)
844
+ return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
746
845
  end
747
- true # different/unknown host: treat the stamped lock as held
846
+ "no delivery lock held for intent #{id}; run /plastic-intent-starting " \
847
+ "to lock and begin"
848
+ end
849
+
850
+ # A session holds an intent's lock iff the durable delivery.lock in the
851
+ # intent dir names it as owner or delegate (D1/D4). The bridge is only a
852
+ # cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
853
+ # strands the owner. No pid is consulted anywhere.
854
+ def self.holds_live_lock?(bridge_data, session: nil)
855
+ sess = session
856
+ sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
857
+ return false if blank?(sess)
858
+ dir = bridge_intent_dir(bridge_data)
859
+ return false unless dir
860
+ Lock.holds?(dir, session: sess)
748
861
  end
749
862
 
750
863
  # "<id>" from a ".../store/<id>--<slug>" dir, else nil.
@@ -782,18 +895,24 @@ module Bridge
782
895
  under_own_intent = intent_dir_abs &&
783
896
  (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
784
897
 
785
- # Rule 1: provisioned code worktree confines project-code edits.
898
+ # Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
899
+ # inside the project repo. The repo root is derived from the provisioned
900
+ # code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
901
+ # construction, so no git call is needed. Paths outside the repo (agent
902
+ # memory dirs, scratch files, unrelated checkouts) are not this gate's
903
+ # business; the 2026-07-02 memory-dir denial came from treating everything
904
+ # outside the worktree as the shared checkout.
786
905
  worktree = bridge_data["worktree"] || {}
787
906
  if worktree["provisioned"] == true
788
907
  code = worktree["code"].to_s
789
- # Project code = outside ~/.plastic and outside this intent's store dir.
790
- is_project_code = !under_plastic && !under_own_intent
791
- if is_project_code && !blank?(code)
908
+ if !blank?(code) && !under_plastic && !under_own_intent
792
909
  code_abs = File.expand_path(code)
910
+ repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
911
+ inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
793
912
  inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
794
- unless inside_code
913
+ if inside_repo && !inside_code
795
914
  id = intent_info["id"]
796
- return "intent #{id} is isolated to its worktree edit project code " \
915
+ return "intent #{id} is isolated to its worktree - edit project code " \
797
916
  "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
798
917
  end
799
918
  end
@@ -877,6 +996,7 @@ module Bridge
877
996
  # Split on command separators for per-segment utility parsing.
878
997
  command.split(/[;\n]|&&|\|\||\|/).each do |segment|
879
998
  targets.concat(bash_utility_targets(segment))
999
+ targets.concat(interpreter_write_targets(segment))
880
1000
  end
881
1001
  targets.uniq
882
1002
  end
@@ -952,18 +1072,56 @@ module Bridge
952
1072
  path == "/dev/null" || path.start_with?("/dev/")
953
1073
  end
954
1074
 
955
- # Decide whether a Bash command should be blocked under the auto-mode code
956
- # gate. Resolves each write target against cwd and applies the SAME policy as
957
- # code_gate_decision. Returns the first block reason, or nil to allow.
958
- def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
1075
+ # --- Interpreter inline-code writes (intent 108, D7) ---
1076
+
1077
+ INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
1078
+
1079
+ # Write verbs that mark inline code as file-mutating. Conservative: reads
1080
+ # (File.read, puts) never match.
1081
+ WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
1082
+ open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
1083
+
1084
+ # Quoted absolute or ~/ paths inside the inline code.
1085
+ INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
1086
+
1087
+ # Paths an interpreter one-liner writes. Flagged only when the inline code
1088
+ # has BOTH a write verb AND a quoted absolute path; everything else (reads,
1089
+ # ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
1090
+ def self.interpreter_write_targets(segment)
1091
+ m = INTERPRETER_RE.match(segment.to_s)
1092
+ return [] unless m
1093
+ util, flag, code = m[1], m[2], m[3]
1094
+ expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
1095
+ "perl" => "-e", "node" => "-e" }[util]
1096
+ return [] unless flag == expected
1097
+ return [] unless WRITE_VERB_RE.match?(code)
1098
+ code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
1099
+ end
1100
+
1101
+ # Decide whether a Bash command should be blocked. Every write target runs
1102
+ # through the SAME policy stack as a direct tool write: the auto-mode code
1103
+ # gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
1104
+ # writes cannot bypass the lock. Returns the first block reason, or nil.
1105
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
959
1106
  bash_write_targets(command).each do |target|
960
1107
  abs = File.absolute_path?(target) ? target : File.join(cwd, target)
961
- reason = code_gate_decision(bridge_data, abs, home: home)
1108
+ abs = File.expand_path(abs)
1109
+ reason = code_gate_decision(bridge_data, abs, home: home) ||
1110
+ lock_gate_decision(bridge_data, abs, session: session)
962
1111
  return reason if reason
963
1112
  end
964
1113
  nil
965
1114
  end
966
1115
 
1116
+ # A TRAILING `# plastic-ok` shell comment: the auditable escape for
1117
+ # sanctioned bash/interpreter writes (mirrors the retrieval gate's
1118
+ # `# qmd-ok`). The hook logs every use to ~/.plastic/.cache/gate-escapes.log.
1119
+ PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
1120
+
1121
+ def self.bash_escape?(command)
1122
+ PLASTIC_OK_RE.match?(command.to_s.chomp)
1123
+ end
1124
+
967
1125
  def self.deep_merge(base, overlay)
968
1126
  result = base.dup
969
1127
  overlay.each do |key, value|
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # HookRegistry: THE single source of truth for Plastic's hook registration
5
+ # (intent 108, D7). Three consumers, none of which may hand-roll matchers:
6
+ # - InstallerCore#merge_claude_hooks builds settings.json entries from it
7
+ # - hooks/hooks.json (legacy plugin surface) is pinned to it by test
8
+ # - doctor's hooks_match_registry check compares live settings against it
9
+ # Change registrations HERE and only here.
10
+ module HookRegistry
11
+ module_function
12
+
13
+ # MCP tools that MUTATE files. Every write/lock/create gate must match them,
14
+ # or symbolic edits bypass the whole gate layer (the universal MCP-edit
15
+ # bypass found in 108's gate inventory).
16
+ SERENA_EDIT_TOOLS = %w[
17
+ mcp__serena__replace_content
18
+ mcp__serena__replace_symbol_body
19
+ mcp__serena__insert_after_symbol
20
+ mcp__serena__insert_before_symbol
21
+ mcp__serena__safe_delete_symbol
22
+ mcp__serena__rename_symbol
23
+ ].freeze
24
+
25
+ WRITE_MATCHER = (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).join("|")
26
+ CREATE_MATCHER = (%w[Write Edit] + SERENA_EDIT_TOOLS).join("|")
27
+
28
+ # event => ordered list of { "matcher" =>, "hooks" => [{ "name" =>, "status" => }] }
29
+ # The name is the hooks/<name> launcher; the flat install ships it as
30
+ # ~/.claude/hooks/plastic-<name>.
31
+ def events
32
+ {
33
+ "SessionStart" => [
34
+ { "matcher" => "", "hooks" => [
35
+ { "name" => "session-start", "status" => "Loading Plastic context..." },
36
+ { "name" => "check-update", "status" => "" },
37
+ ] },
38
+ ],
39
+ "PreCompact" => [
40
+ { "matcher" => "", "hooks" => [
41
+ { "name" => "savepoint", "status" => "Saving Plastic intent state..." },
42
+ ] },
43
+ ],
44
+ "PreToolUse" => [
45
+ { "matcher" => WRITE_MATCHER, "hooks" => [
46
+ { "name" => "code-gate", "status" => "Checking lifecycle gate..." },
47
+ { "name" => "lock-gate", "status" => "Checking lock gate..." },
48
+ ] },
49
+ { "matcher" => "Write|Edit", "hooks" => [
50
+ { "name" => "savepoint-pre", "status" => "Recording stage start..." },
51
+ ] },
52
+ { "matcher" => CREATE_MATCHER, "hooks" => [
53
+ { "name" => "create-gate", "status" => "Checking create gate..." },
54
+ ] },
55
+ { "matcher" => "Bash", "hooks" => [
56
+ { "name" => "bash-gate", "status" => "Checking lifecycle gate..." },
57
+ ] },
58
+ { "matcher" => "Bash|Read|Grep|Glob", "hooks" => [
59
+ { "name" => "retrieval-gate", "status" => "Checking retrieval gate..." },
60
+ ] },
61
+ ],
62
+ "PostToolUse" => [
63
+ { "matcher" => "Write|Edit", "hooks" => [
64
+ { "name" => "gate-check", "status" => "Checking lifecycle gates..." },
65
+ ] },
66
+ ],
67
+ "UserPromptSubmit" => [
68
+ { "matcher" => "", "hooks" => [
69
+ { "name" => "continue", "status" => "Checking for continue..." },
70
+ { "name" => "future-intent-check", "status" => "Checking future intents..." },
71
+ { "name" => "auto-arm", "status" => "Checking auto mode..." },
72
+ { "name" => "qmd-search", "status" => "Searching QMD..." },
73
+ ] },
74
+ ],
75
+ }
76
+ end
77
+
78
+ # The settings.json shape merge_claude_hooks expects: single-group events map
79
+ # to a Hash, multi-group events to an Array (the merge loop handles both).
80
+ def claude_settings_hooks(hook_dir:)
81
+ events.each_with_object({}) do |(event, groups), out|
82
+ mapped = groups.map do |g|
83
+ {
84
+ "matcher" => g["matcher"],
85
+ "hooks" => g["hooks"].map do |h|
86
+ { "type" => "command",
87
+ "command" => File.join(hook_dir, "plastic-#{h['name']}"),
88
+ "statusMessage" => h["status"] }
89
+ end,
90
+ }
91
+ end
92
+ out[event] = mapped.length == 1 ? mapped.first : mapped
93
+ end
94
+ end
95
+ end