@sushanglewis/lincoln 1.6.3 → 1.7.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 (45) hide show
  1. package/framework/.claude/agents/pm.md +2 -2
  2. package/framework/.claude/agents/security-reviewer.md +57 -0
  3. package/framework/.claude/harnesses/codex.yaml +31 -2
  4. package/framework/.claude/harnesses/command-map.yaml +4 -0
  5. package/framework/.claude/harnesses/opencode.yaml +33 -3
  6. package/framework/.claude/hooks/hooks.yaml +37 -0
  7. package/framework/.claude/hooks/on-session-start.sh +20 -5
  8. package/framework/.claude/hooks/on-stop.sh +22 -0
  9. package/framework/.claude/hooks/pre-tool-use.sh +22 -0
  10. package/framework/.claude/mcp/mcp.yaml +34 -0
  11. package/framework/.claude/policies/friction.yaml +18 -0
  12. package/framework/.claude/policies/security.yaml +46 -0
  13. package/framework/.claude/schemas/design-review-feedback.json +32 -0
  14. package/framework/.claude/schemas/stage-definition.schema.json +22 -0
  15. package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
  16. package/framework/.claude/skills/clarify-requirements/prompts/main.md +44 -10
  17. package/framework/.claude/stages/clarify.yaml +3 -1
  18. package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
  19. package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
  20. package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
  21. package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
  22. package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
  23. package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
  24. package/framework/.claude-plugin/marketplace.json +2 -2
  25. package/framework/.claude-plugin/plugin.json +1 -1
  26. package/framework/.version-bump.json +1 -1
  27. package/framework/README.en.md +24 -5
  28. package/framework/README.md +24 -5
  29. package/framework/RELEASE.md +22 -0
  30. package/framework/SKILL.md +15 -6
  31. package/framework/manifest.json +93 -37
  32. package/framework/scripts/check-harness-drift.sh +11 -1
  33. package/framework/scripts/lincoln-setup.py +17 -0
  34. package/framework/scripts/lincoln_condenser.py +110 -0
  35. package/framework/scripts/lincoln_friction.py +266 -0
  36. package/framework/scripts/lincoln_harness_adapter.py +185 -13
  37. package/framework/scripts/lincoln_mr_mine.py +146 -0
  38. package/framework/scripts/lincoln_recall.py +142 -0
  39. package/framework/scripts/lincoln_trace.py +119 -3
  40. package/framework/scripts/security_analyzer.py +234 -0
  41. package/framework/scripts/stage_loader.py +124 -1
  42. package/framework/tools/lincoln/package-lock.json +2 -2
  43. package/framework/tools/lincoln/package.json +1 -1
  44. package/framework/tools/lincoln-installer/package.json +6 -1
  45. package/package.json +6 -1
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Lincoln MR / PR knowledge miner.
3
+
4
+ Extracts decision-related review comments from a merged pull request and writes
5
+ structured decision notes to the issue work package. These notes stay in
6
+ issue-<N>/docs/decisions/ until the sync-knowledge stage promotes them to
7
+ knowledge/.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ DECISION_KEYWORDS = ["decision", "decided", "agreed", "rejected", "note:", "approve", "approved"]
21
+
22
+
23
+ def _run_gh(args: list[str]) -> dict[str, Any] | list[Any]:
24
+ """Call the gh CLI and return parsed JSON."""
25
+ cmd = ["gh", *args]
26
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
27
+ if result.returncode != 0:
28
+ raise RuntimeError(result.stderr.strip() or "gh CLI failed")
29
+ return json.loads(result.stdout or "[]")
30
+
31
+
32
+ def _extract_decision_snippets(text: str) -> list[str]:
33
+ """Pull decision-oriented sentences or paragraphs from a comment body."""
34
+ if not text:
35
+ return []
36
+ snippets = []
37
+ for paragraph in re.split(r"\n\s*\n", text.strip()):
38
+ paragraph = paragraph.strip()
39
+ if not paragraph:
40
+ continue
41
+ lowered = paragraph.lower()
42
+ if any(keyword in lowered for keyword in DECISION_KEYWORDS):
43
+ snippets.append(paragraph)
44
+ return snippets
45
+
46
+
47
+ def _sanitize_filename(text: str) -> str:
48
+ """Reduce a comment snippet to a safe filename slug."""
49
+ text = text.lower()
50
+ text = re.sub(r"[^a-z0-9]+", "-", text)
51
+ text = text.strip("-")
52
+ return text[:60]
53
+
54
+
55
+ def mine_pr_decisions(
56
+ owner: str,
57
+ repo: str,
58
+ pr_number: int,
59
+ issue_number: int | None = None,
60
+ output_dir: Path | None = None,
61
+ ) -> list[Path]:
62
+ """Mine decision comments from a PR and write them to the issue package."""
63
+ if output_dir is None:
64
+ if issue_number is None:
65
+ raise ValueError("issue_number or output_dir is required")
66
+ output_dir = Path(f"issue-{issue_number}") / "docs" / "decisions"
67
+ output_dir = Path(output_dir)
68
+ output_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ comments = _run_gh(
71
+ ["api", f"repos/{owner}/{repo}/pulls/{pr_number}/comments"]
72
+ )
73
+ review_comments = _run_gh(
74
+ ["api", f"repos/{owner}/{repo}/pulls/{pr_number}/reviews"]
75
+ )
76
+
77
+ if isinstance(comments, dict):
78
+ comments = [comments]
79
+ if isinstance(review_comments, dict):
80
+ review_comments = [review_comments]
81
+
82
+ all_comments = list(comments) + list(review_comments)
83
+
84
+ written: list[Path] = []
85
+ seen: set[str] = set()
86
+
87
+ for comment in all_comments:
88
+ body = comment.get("body") or ""
89
+ snippets = _extract_decision_snippets(body)
90
+ for snippet in snippets:
91
+ if snippet in seen:
92
+ continue
93
+ seen.add(snippet)
94
+ slug = _sanitize_filename(snippet)
95
+ filename = f"pr-{pr_number}-{slug}.md"
96
+ path = output_dir / filename
97
+ if path.exists():
98
+ continue
99
+
100
+ content = f"""# PR {pr_number} Decision Note
101
+
102
+ **Source:** {owner}/{repo}#{pr_number}
103
+ **Author:** {comment.get('author', {}).get('login', 'unknown')}
104
+ **Created:** {comment.get('createdAt', 'unknown')}
105
+ **Comment ID:** {comment.get('id', 'unknown')}
106
+
107
+ ## Snippet
108
+
109
+ {snippet}
110
+
111
+ ## Keywords
112
+
113
+ {', '.join(DECISION_KEYWORDS)}
114
+
115
+ ---
116
+ *Auto-generated by scripts/lincoln_mr_mine.py*
117
+ """
118
+ path.write_text(content, encoding="utf-8")
119
+ written.append(path)
120
+
121
+ return written
122
+
123
+
124
+ def main(argv: list[str] | None = None) -> int:
125
+ parser = argparse.ArgumentParser(description="Mine PR review decisions")
126
+ parser.add_argument("--owner", required=True)
127
+ parser.add_argument("--repo", required=True)
128
+ parser.add_argument("--pr", type=int, required=True)
129
+ parser.add_argument("--issue", type=int, default=None)
130
+ parser.add_argument("--output-dir", type=Path, default=None)
131
+ args = parser.parse_args(argv)
132
+
133
+ paths = mine_pr_decisions(
134
+ owner=args.owner,
135
+ repo=args.repo,
136
+ pr_number=args.pr,
137
+ issue_number=args.issue,
138
+ output_dir=args.output_dir,
139
+ )
140
+ for path in paths:
141
+ print(path)
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Lincoln recall precheck.
3
+
4
+ Given a task description, scores knowledge base documents by keyword overlap
5
+ (and optionally by embedding similarity) and returns the top-K matches.
6
+ Designed to be called from on-session-start.sh before injecting context.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ DEFAULT_KNOWLEDGE_DIR = "knowledge"
19
+ DEFAULT_TOP_K = 5
20
+
21
+
22
+ def _tokenize(text: str) -> set[str]:
23
+ """Extract lowercase alphanumeric tokens."""
24
+ return set(re.findall(r"[a-zA-Z0-9一-鿿]+", text.lower()))
25
+
26
+
27
+ def _load_documents(knowledge_dir: Path) -> list[dict[str, Any]]:
28
+ docs: list[dict[str, Any]] = []
29
+ if not knowledge_dir.exists():
30
+ return docs
31
+ for path in sorted(knowledge_dir.rglob("*.md")):
32
+ try:
33
+ text = path.read_text(encoding="utf-8")
34
+ docs.append(
35
+ {
36
+ "path": str(path.relative_to(knowledge_dir)),
37
+ "tokens": _tokenize(text),
38
+ "title": path.stem,
39
+ "_text": text[:2000],
40
+ }
41
+ )
42
+ except Exception:
43
+ continue
44
+ return docs
45
+
46
+
47
+ def _score_by_keywords(query_tokens: set[str], doc: dict[str, Any]) -> float:
48
+ if not query_tokens:
49
+ return 0.0
50
+ doc_tokens = doc["tokens"]
51
+ if not doc_tokens:
52
+ return 0.0
53
+ overlap = len(query_tokens & doc_tokens)
54
+ return overlap / max(len(query_tokens), len(doc_tokens))
55
+
56
+
57
+ def _try_embedding_score(query: str, docs: list[dict[str, Any]]) -> list[float] | None:
58
+ """Attempt embedding-based scoring if sentence-transformers is installed."""
59
+ try:
60
+ from sentence_transformers import SentenceTransformer, util # type: ignore
61
+
62
+ model = SentenceTransformer("all-MiniLM-L6-v2")
63
+ query_embedding = model.encode(query, convert_to_tensor=True)
64
+ doc_texts = [doc.get("_text", "") for doc in docs]
65
+ doc_embeddings = model.encode(doc_texts, convert_to_tensor=True)
66
+ scores = util.cos_sim(query_embedding, doc_embeddings)[0].tolist()
67
+ return list(scores)
68
+ except Exception:
69
+ return None
70
+
71
+
72
+ def recall(
73
+ query: str,
74
+ knowledge_dir: Path | None = None,
75
+ top_k: int = DEFAULT_TOP_K,
76
+ use_embedding: bool = True,
77
+ ) -> dict[str, Any]:
78
+ """Return top-K knowledge documents for a query."""
79
+ knowledge_dir = knowledge_dir or Path(DEFAULT_KNOWLEDGE_DIR)
80
+ docs = _load_documents(knowledge_dir)
81
+ query_tokens = _tokenize(query)
82
+
83
+ if use_embedding:
84
+ embedding_scores = _try_embedding_score(query, docs)
85
+ else:
86
+ embedding_scores = None
87
+
88
+ results = []
89
+ for idx, doc in enumerate(docs):
90
+ keyword_score = _score_by_keywords(query_tokens, doc)
91
+ if embedding_scores is not None:
92
+ score = 0.5 * keyword_score + 0.5 * embedding_scores[idx]
93
+ else:
94
+ score = keyword_score
95
+ results.append(
96
+ {
97
+ "path": doc["path"],
98
+ "title": doc["title"],
99
+ "score": round(score, 4),
100
+ "method": "embedding+keywords" if embedding_scores else "keywords",
101
+ }
102
+ )
103
+
104
+ results.sort(key=lambda r: r["score"], reverse=True)
105
+ return {
106
+ "query": query,
107
+ "top_k": top_k,
108
+ "docs": [r for r in results[:top_k] if r["score"] > 0],
109
+ "method": "embedding+keywords" if embedding_scores else "keywords",
110
+ }
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(description="Recall relevant knowledge documents")
115
+ parser.add_argument("--query", required=True)
116
+ parser.add_argument("--knowledge-dir", type=Path, default=None)
117
+ parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K)
118
+ parser.add_argument("--format", choices=["json", "markdown"], default="json")
119
+ parser.add_argument("--no-embedding", action="store_true", default=False)
120
+ args = parser.parse_args(argv)
121
+
122
+ result = recall(
123
+ query=args.query,
124
+ knowledge_dir=args.knowledge_dir,
125
+ top_k=args.top_k,
126
+ use_embedding=not args.no_embedding,
127
+ )
128
+
129
+ if args.format == "json":
130
+ print(json.dumps(result, ensure_ascii=False, indent=2))
131
+ else:
132
+ print(f"# 相关知识({result['method']})\n")
133
+ if result["docs"]:
134
+ for doc in result["docs"]:
135
+ print(f"- [{doc['title']}]({doc['path']}) — score {doc['score']}")
136
+ else:
137
+ print("_未找到相关知识。_")
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())
@@ -31,7 +31,7 @@ from scripts.lincoln_paths import ( # noqa: E402
31
31
  process_package_root,
32
32
  )
33
33
 
34
- TRACE_SCHEMA_VERSION = "1.0.0"
34
+ TRACE_SCHEMA_VERSION = "2.0.0"
35
35
 
36
36
  # Tools whose invocations are not traced to avoid recursion or noise.
37
37
  NO_TRACE_TOOLS = {"Read", "Grep", "Glob"}
@@ -46,6 +46,26 @@ TASK_TOOLS = frozenset({
46
46
  "TaskStop",
47
47
  })
48
48
 
49
+ # All valid trace categories. Tool categories are auto-derived; lifecycle categories
50
+ # are written explicitly by stage_loader and hooks.
51
+ VALID_CATEGORIES = frozenset({
52
+ "tool",
53
+ "skill",
54
+ "agent",
55
+ "read",
56
+ "write",
57
+ "edit",
58
+ "bash",
59
+ "task",
60
+ "mcp",
61
+ "handoff",
62
+ "other",
63
+ "stage_lifecycle",
64
+ "gate",
65
+ "session",
66
+ "condensation",
67
+ })
68
+
49
69
  # Maximum length stored for a Bash command summary in the trace entry.
50
70
  COMMAND_SUMMARY_MAX_LEN = 500
51
71
 
@@ -202,6 +222,11 @@ def _resolve_trace_file(state: dict[str, Any] | None, state_path: Path | None) -
202
222
  return trace_dir / "lc-trace.jsonl"
203
223
 
204
224
 
225
+ def get_trace_file(state: dict[str, Any] | None = None, state_path: Path | None = None) -> Path:
226
+ """Public accessor for the resolved trace file path."""
227
+ return _resolve_trace_file(state, state_path)
228
+
229
+
205
230
  def _get_run_id(state: dict[str, Any] | None, run_id: str | None) -> str:
206
231
  if run_id:
207
232
  return run_id
@@ -250,12 +275,26 @@ def _build_trace_target(tool: str, args_summary: dict[str, Any]) -> str:
250
275
  return str(args_summary.get("path", args_summary.get("name", "")))
251
276
 
252
277
 
278
+ def _get_node_id(state: dict[str, Any] | None, node_id: str | None) -> str | None:
279
+ if node_id:
280
+ return node_id
281
+ if state:
282
+ current_run = state.get("current_run", {})
283
+ # Find the in_progress node for the current stage.
284
+ current_stage = current_run.get("current_stage", "")
285
+ for node in state.get("nodes", []):
286
+ if node.get("stage_id") == current_stage and node.get("status") == "in_progress":
287
+ return str(node.get("node_id", "")) or None
288
+ return None
289
+
290
+
253
291
  def _build_trace_entry(
254
292
  tool: str,
255
293
  args_summary: dict[str, Any],
256
294
  exit_code: int,
257
295
  run_id: str,
258
296
  stage: str,
297
+ node_id: str | None = None,
259
298
  ) -> dict[str, Any]:
260
299
  return {
261
300
  "schema_version": TRACE_SCHEMA_VERSION,
@@ -263,6 +302,7 @@ def _build_trace_entry(
263
302
  "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
264
303
  "run_id": run_id,
265
304
  "stage": stage,
305
+ "node_id": node_id,
266
306
  "tool": tool,
267
307
  "category": categorize_tool(tool),
268
308
  "target": _build_trace_target(tool, args_summary),
@@ -290,8 +330,9 @@ def append_trace_entry(
290
330
  exit_code: int,
291
331
  stage: str | None = None,
292
332
  run_id: str | None = None,
333
+ node_id: str | None = None,
293
334
  ) -> Path | None:
294
- """Append a single trace entry to the appropriate JSONL file.
335
+ """Append a single trace entry for a tool invocation.
295
336
 
296
337
  The write is atomic with respect to other processes on the same host: the
297
338
  file is locked while appending, so concurrent parent and child agents do
@@ -303,9 +344,84 @@ def append_trace_entry(
303
344
  state = _load_state(state_path)
304
345
  resolved_run_id = _get_run_id(state, run_id)
305
346
  resolved_stage = _get_stage(state, stage)
347
+ resolved_node_id = _get_node_id(state, node_id)
306
348
  trace_file = _resolve_trace_file(state, state_path)
307
349
  args_summary = redact_args(tool, _safe_json_loads(args))
308
- entry = _build_trace_entry(tool, args_summary, exit_code, resolved_run_id, resolved_stage)
350
+ entry = _build_trace_entry(
351
+ tool, args_summary, exit_code, resolved_run_id, resolved_stage, resolved_node_id
352
+ )
353
+ _append_trace_line(trace_file, entry)
354
+ return trace_file
355
+
356
+
357
+ def append_event_entry(
358
+ state_path: Path | None,
359
+ category: str,
360
+ payload: dict[str, Any],
361
+ stage: str | None = None,
362
+ run_id: str | None = None,
363
+ node_id: str | None = None,
364
+ ) -> Path | None:
365
+ """Append a non-tool lifecycle event to the trace.
366
+
367
+ Categories must be one of VALID_CATEGORIES. The payload is stored under
368
+ ``args_summary`` for backward compatibility with existing readers.
369
+ """
370
+ if category not in VALID_CATEGORIES:
371
+ raise ValueError(f"Invalid trace category: {category!r}")
372
+
373
+ state = _load_state(state_path)
374
+ resolved_run_id = _get_run_id(state, run_id)
375
+ resolved_stage = _get_stage(state, stage)
376
+ resolved_node_id = _get_node_id(state, node_id)
377
+ trace_file = _resolve_trace_file(state, state_path)
378
+
379
+ entry: dict[str, Any] = {
380
+ "schema_version": TRACE_SCHEMA_VERSION,
381
+ "sequence_id": generate_sequence_id(),
382
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
383
+ "run_id": resolved_run_id,
384
+ "stage": resolved_stage,
385
+ "node_id": resolved_node_id,
386
+ "tool": "",
387
+ "category": category,
388
+ "target": str(payload.get("event_type", payload.get("type", "")))[:200],
389
+ "exit_code": int(payload.get("exit_code", 0)),
390
+ "args_summary": payload,
391
+ }
392
+ _append_trace_line(trace_file, entry)
393
+ return trace_file
394
+
395
+
396
+ def append_event_to_file(
397
+ trace_file: Path,
398
+ category: str,
399
+ payload: dict[str, Any],
400
+ stage: str | None = None,
401
+ run_id: str | None = None,
402
+ node_id: str | None = None,
403
+ ) -> Path:
404
+ """Append a non-tool lifecycle event to an explicit trace file path.
405
+
406
+ This variant is useful when the caller has already resolved the trace file
407
+ (e.g. the condenser) and wants to avoid re-resolving it from state.
408
+ """
409
+ if category not in VALID_CATEGORIES:
410
+ raise ValueError(f"Invalid trace category: {category!r}")
411
+
412
+ entry: dict[str, Any] = {
413
+ "schema_version": TRACE_SCHEMA_VERSION,
414
+ "sequence_id": generate_sequence_id(),
415
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
416
+ "run_id": run_id or "unknown",
417
+ "stage": stage or "unknown",
418
+ "node_id": node_id or "unknown",
419
+ "tool": "",
420
+ "category": category,
421
+ "target": str(payload.get("event_type", payload.get("type", "")))[:200],
422
+ "exit_code": int(payload.get("exit_code", 0)),
423
+ "args_summary": payload,
424
+ }
309
425
  _append_trace_line(trace_file, entry)
310
426
  return trace_file
311
427
 
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """Risk analyzer for Lincoln pre-tool-use security gate.
3
+
4
+ Loads `.claude/policies/security.yaml` and evaluates a tool call against the
5
+ configured policies. Designed to be invoked from `.claude/hooks/pre-tool-use.sh`
6
+ and from generated harness-specific hook scripts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ DEFAULT_POLICY_PATH = ".claude/policies/security.yaml"
21
+
22
+
23
+ def load_policy(root: Path) -> dict[str, Any]:
24
+ """Load the risk policy YAML, returning an empty policy if missing."""
25
+ path = root / DEFAULT_POLICY_PATH
26
+ if not path.exists():
27
+ return {"policies": []}
28
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {"policies": []}
29
+
30
+
31
+ def _normalize_command(command: str) -> str:
32
+ """Strip leading whitespace and collapse internal spaces for matching."""
33
+ return re.sub(r"\s+", " ", command.strip())
34
+
35
+
36
+ def _split_command_segments(command: str) -> list[str]:
37
+ """Split a shell command on chain operators so each segment is matched.
38
+
39
+ Handles `&&`, `||`, `;`, and `|`.
40
+ """
41
+ segments = re.split(r"\s*(&&|\|\||;|\|)\s*", command)
42
+ return [_normalize_command(seg) for seg in segments if seg and not re.match(r"^(&&|\|\||;|\|)$", seg.strip())]
43
+
44
+
45
+ def _strip_command_prefix(segment: str) -> str:
46
+ """Remove common leading prefixes that should not hide the real command.
47
+
48
+ Examples: `sudo rm foo`, `env VAR=1 rm foo`, `cd /tmp && rm foo`.
49
+ """
50
+ segment = _normalize_command(segment)
51
+ while re.match(r"^[A-Za-z_][A-Za-z0-9_]*=\S+\s+", segment):
52
+ segment = _normalize_command(re.sub(r"^[A-Za-z_][A-Za-z0-9_]*=\S+\s+", "", segment))
53
+ if re.match(r"^(sudo|doas)\s+", segment):
54
+ segment = _normalize_command(re.sub(r"^(sudo|doas)\s+", "", segment))
55
+ if re.match(r"^cd\s+\S+\s*(&&|;|\|)", segment):
56
+ segment = _normalize_command(re.sub(r"^cd\s+\S+\s*(&&|;|\|)", "", segment))
57
+ return segment
58
+
59
+
60
+ def _command_matches(condition: str, command: str) -> bool:
61
+ """Evaluate a human-readable condition against a bash command."""
62
+ lowered = condition.lower()
63
+ segments = _split_command_segments(command)
64
+
65
+ def _any_segment(pattern: str) -> bool:
66
+ return any(re.search(pattern, _strip_command_prefix(seg)) for seg in segments)
67
+
68
+ if "rm -rf" in lowered or 'command matches "rm -rf"' in lowered:
69
+ if _any_segment(r"\brm\s+-rf\b"):
70
+ return True
71
+
72
+ if 'command matches "rm -r" or "rm -rf"' in lowered:
73
+ if _any_segment(r"\brm\s+-r(?:f)?\b"):
74
+ return True
75
+
76
+ if 'command matches any of ["rm", "rmdir"]' in lowered:
77
+ if _any_segment(r"(?:^|\s)(?:rm\b|rmdir\b)"):
78
+ return True
79
+
80
+ if 'command matches "git push"' in lowered:
81
+ if _any_segment(r"\bgit\s+push\b"):
82
+ return True
83
+
84
+ if "external http client" in lowered or "curl, wget" in lowered:
85
+ if _any_segment(r"\b(?:curl|wget|httpie)\b"):
86
+ return True
87
+
88
+ if 'command matches "rm"' in lowered and "rmdir" not in lowered:
89
+ if _any_segment(r"\brm\b"):
90
+ return True
91
+
92
+ return False
93
+
94
+
95
+ def _resolve_target_path(target: str, root: Path) -> Path:
96
+ """Resolve a Write/Edit target to an absolute, canonical path."""
97
+ path = Path(target)
98
+ if not path.is_absolute():
99
+ path = root / path
100
+ try:
101
+ return path.resolve()
102
+ except (OSError, ValueError):
103
+ return path.absolute()
104
+
105
+
106
+ def _target_matches(condition: str, target: str, process_slug: str, root: Path) -> bool:
107
+ """Evaluate a file-target condition using canonical absolute paths."""
108
+ lowered = condition.lower()
109
+ resolved = _resolve_target_path(target, root)
110
+ try:
111
+ package_root = (root / process_slug).resolve()
112
+ except (OSError, ValueError):
113
+ package_root = (root / process_slug).absolute()
114
+
115
+ if "outside current process_slug" in lowered:
116
+ try:
117
+ resolved.relative_to(package_root)
118
+ return False
119
+ except ValueError:
120
+ return True
121
+
122
+ if "inside current process_slug" in lowered:
123
+ try:
124
+ resolved.relative_to(package_root)
125
+ return True
126
+ except ValueError:
127
+ return False
128
+
129
+ return False
130
+
131
+
132
+ def _evaluate_policy(policy: dict[str, Any], tool_name: str, tool_args: dict[str, Any], process_slug: str, root: Path) -> bool:
133
+ """Return True if the policy applies to this tool call."""
134
+ tools = [t.strip() for t in policy.get("tool", "").split("|")]
135
+ if tool_name not in tools:
136
+ return False
137
+
138
+ condition = policy.get("condition", "")
139
+ if tool_name == "Bash":
140
+ command = tool_args.get("command", "")
141
+ return _command_matches(condition, command)
142
+
143
+ if tool_name in ("Write", "Edit"):
144
+ target = tool_args.get("file_path") or tool_args.get("path") or ""
145
+ return _target_matches(condition, target, process_slug, root)
146
+
147
+ return False
148
+
149
+
150
+ def _log_security_event(
151
+ log_dir: Path,
152
+ tool_name: str,
153
+ tool_args: dict[str, Any],
154
+ result: dict[str, Any],
155
+ ) -> None:
156
+ """Append a JSON line to issue-<N>/logs/security.log."""
157
+ log_dir.mkdir(parents=True, exist_ok=True)
158
+ log_file = log_dir / "security.log"
159
+ entry = {
160
+ "timestamp": datetime.now(timezone.utc).isoformat(),
161
+ "tool": tool_name,
162
+ "level": result["level"],
163
+ "policy": result.get("policy", ""),
164
+ "action": "blocked" if result["confirm_required"] else "allowed",
165
+ "args": {k: v for k, v in tool_args.items() if k in ("command", "file_path", "path")},
166
+ }
167
+ with log_file.open("a", encoding="utf-8") as f:
168
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
169
+
170
+
171
+ def analyze(
172
+ root: Path,
173
+ tool_name: str,
174
+ tool_args: dict[str, Any],
175
+ process_slug: str = "",
176
+ log_dir: str | None = None,
177
+ ) -> dict[str, Any]:
178
+ """Analyze a tool call and return risk assessment."""
179
+ if os.environ.get("LINCOLN_SECURITY_MODE") == "permissive":
180
+ return {
181
+ "level": "low",
182
+ "policy": "",
183
+ "message": "Security analyzer is in permissive mode.",
184
+ "confirm_required": False,
185
+ "log": False,
186
+ }
187
+
188
+ policy_data = load_policy(root)
189
+ policies = policy_data.get("policies", [])
190
+
191
+ for policy in policies:
192
+ if not _evaluate_policy(policy, tool_name, tool_args, process_slug, root):
193
+ continue
194
+
195
+ result = {
196
+ "level": policy.get("level", "unknown"),
197
+ "policy": policy.get("name", ""),
198
+ "message": policy.get("message", ""),
199
+ "confirm_required": bool(policy.get("confirm", False)),
200
+ "log": bool(policy.get("log", False)),
201
+ }
202
+ if log_dir:
203
+ _log_security_event(Path(log_dir), tool_name, tool_args, result)
204
+ return result
205
+
206
+ return {
207
+ "level": "low",
208
+ "policy": "",
209
+ "message": "",
210
+ "confirm_required": False,
211
+ "log": False,
212
+ }
213
+
214
+
215
+ if __name__ == "__main__":
216
+ import argparse
217
+
218
+ parser = argparse.ArgumentParser(description="Analyze tool call risk")
219
+ parser.add_argument("--root", type=Path, default=Path.cwd())
220
+ parser.add_argument("--tool", required=True)
221
+ parser.add_argument("--args", default="{}")
222
+ parser.add_argument("--process-slug", default="")
223
+ parser.add_argument("--log-dir", default=None)
224
+ args = parser.parse_args()
225
+
226
+ tool_args = json.loads(args.args)
227
+ result = analyze(
228
+ args.root,
229
+ args.tool,
230
+ tool_args,
231
+ process_slug=args.process_slug,
232
+ log_dir=args.log_dir,
233
+ )
234
+ print(json.dumps(result, ensure_ascii=False))