@sushanglewis/lincoln 1.6.2 → 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 (54) 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 +83 -80
  17. package/framework/.claude/skills/draft-product-design/prompts/main.md +27 -14
  18. package/framework/.claude/stages/clarify.yaml +7 -5
  19. package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
  20. package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
  21. package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
  22. package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
  23. package/framework/.claude/stages/product-design-docs.yaml +6 -6
  24. package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
  25. package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
  26. package/framework/.claude-plugin/marketplace.json +2 -2
  27. package/framework/.claude-plugin/plugin.json +1 -1
  28. package/framework/.version-bump.json +1 -1
  29. package/framework/README.en.md +24 -5
  30. package/framework/README.md +24 -5
  31. package/framework/RELEASE.md +41 -0
  32. package/framework/SKILL.md +15 -6
  33. package/framework/manifest.json +96 -56
  34. package/framework/scripts/check-harness-drift.sh +11 -1
  35. package/framework/scripts/lincoln-setup.py +17 -0
  36. package/framework/scripts/lincoln_condenser.py +110 -0
  37. package/framework/scripts/lincoln_friction.py +266 -0
  38. package/framework/scripts/lincoln_harness_adapter.py +185 -13
  39. package/framework/scripts/lincoln_mr_mine.py +146 -0
  40. package/framework/scripts/lincoln_recall.py +142 -0
  41. package/framework/scripts/lincoln_render.py +209 -6
  42. package/framework/scripts/lincoln_trace.py +119 -3
  43. package/framework/scripts/security_analyzer.py +234 -0
  44. package/framework/scripts/stage_loader.py +124 -1
  45. package/framework/tools/lincoln/package-lock.json +2 -2
  46. package/framework/tools/lincoln/package.json +1 -1
  47. package/framework/tools/lincoln-installer/package.json +6 -1
  48. package/package.json +6 -1
  49. package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +0 -108
  50. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +0 -134
  51. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +0 -248
  52. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +0 -122
  53. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +0 -106
  54. package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +0 -173
@@ -189,6 +189,91 @@ def _escape_markdown_for_script(markdown: str) -> str:
189
189
  return markdown.replace("</script>", "<\\/script>")
190
190
 
191
191
 
192
+ def _derive_uid_from_target(target: str) -> str:
193
+ """Derive a stable UID from the target filename."""
194
+ name = Path(target).name
195
+ # Strip a single well-known extension.
196
+ for ext in (".html", ".htm"):
197
+ if name.lower().endswith(ext):
198
+ return name[: -len(ext)]
199
+ return name
200
+
201
+
202
+ def _detect_version_from_markdown(source: str | Path) -> str | None:
203
+ """Extract a version marker from Markdown text or a Markdown file path."""
204
+ text = ""
205
+ if isinstance(source, Path):
206
+ if not source.exists():
207
+ return None
208
+ try:
209
+ text = source.read_text(encoding="utf-8")
210
+ except Exception:
211
+ return None
212
+ else:
213
+ text = source
214
+ for line in text.splitlines()[:20]:
215
+ m = VERSION_COMMENT_RE.search(line)
216
+ if m:
217
+ return m.group(1)
218
+ return None
219
+
220
+
221
+ def _derive_defaults(
222
+ markdown_path: str | Path | None,
223
+ target: str,
224
+ title: str,
225
+ nav_label: str | None,
226
+ nav_group: str | None,
227
+ version: str | None,
228
+ uid: str | None,
229
+ stage_mark: str | None,
230
+ stage_id: str,
231
+ ) -> dict[str, str]:
232
+ """Return coarse defaults for page-level placeholders.
233
+
234
+ The CLI already provides defaults for some fields; this helper centralizes
235
+ the "derive from title/target/markdown" logic so both single-page and batch
236
+ rendering behave the same way.
237
+ """
238
+ md_path: Path | None = None
239
+ md_content: str | None = None
240
+ if markdown_path:
241
+ if isinstance(markdown_path, Path):
242
+ md_path = markdown_path
243
+ elif "\n" in markdown_path or len(markdown_path) > 260 or not (PROJECT_ROOT / markdown_path).exists():
244
+ # Treat as inline Markdown content rather than a file path.
245
+ md_content = markdown_path
246
+ else:
247
+ md_path = PROJECT_ROOT / markdown_path
248
+
249
+ derived_title = title
250
+ derived_nav_label = nav_label if nav_label is not None else title
251
+ derived_nav_group = nav_group if nav_group is not None else "Docs"
252
+ derived_version = version if version is not None else ""
253
+ derived_uid = uid if uid is not None else ""
254
+ derived_stage_mark = stage_mark if stage_mark is not None else stage_id
255
+
256
+ if not derived_version:
257
+ if md_path and md_path.exists():
258
+ derived_version = _detect_version_from_markdown(md_path) or ""
259
+ elif md_content:
260
+ derived_version = _detect_version_from_markdown(md_content) or ""
261
+ if not derived_version:
262
+ derived_version = "v1.0"
263
+
264
+ if not derived_uid:
265
+ derived_uid = _derive_uid_from_target(target)
266
+
267
+ return {
268
+ "title": derived_title,
269
+ "nav_label": derived_nav_label,
270
+ "nav_group": derived_nav_group,
271
+ "version": derived_version,
272
+ "uid": derived_uid,
273
+ "stage_mark": derived_stage_mark,
274
+ }
275
+
276
+
192
277
  def _html_attr(value: str) -> str:
193
278
  return html.escape(value, quote=True)
194
279
 
@@ -461,6 +546,86 @@ def _ensure_version_comment(html: str, version: str) -> str:
461
546
  return html + marker
462
547
 
463
548
 
549
+ GLOB_CHARS = frozenset("*?[]")
550
+
551
+
552
+ def _is_glob_pattern(pattern: str) -> bool:
553
+ return any(ch in GLOB_CHARS for ch in pattern)
554
+
555
+
556
+ def _render_stage_batch(stage_id: str, state_file: str | Path | None) -> list[str]:
557
+ """Render all missing artifact pages declared by a stage definition.
558
+
559
+ Only exact (non-glob) patterns are rendered automatically. If a matching
560
+ .md file exists next to the target, it is used as the Markdown source.
561
+ Returns the list of rendered target paths.
562
+ """
563
+ stage_data = _load_stage(stage_id)
564
+ runtime = _load_runtime_variables(state_file)
565
+ variables: dict[str, str] = {k: "" for k in RUNTIME_VARIABLES}
566
+ variables.update(runtime)
567
+
568
+ raw_templates = stage_data.get("artifact_templates", {}) or {}
569
+ interpolated_templates = {
570
+ _replace_variables(str(pattern), variables): str(template_path)
571
+ for pattern, template_path in raw_templates.items()
572
+ }
573
+
574
+ rendered: list[str] = []
575
+ for pattern, template_path in interpolated_templates.items():
576
+ if _is_glob_pattern(pattern):
577
+ print(f"Skipping glob pattern in batch render: {pattern}")
578
+ continue
579
+
580
+ target_path = PROJECT_ROOT / pattern
581
+ if target_path.exists():
582
+ continue
583
+
584
+ # Look for a matching Markdown file (e.g. design-review.html -> design-review.md).
585
+ md_path = target_path.with_suffix(".md")
586
+ markdown_source = ""
587
+ if md_path.exists():
588
+ markdown_source = md_path.read_text(encoding="utf-8")
589
+
590
+ defaults = _derive_defaults(
591
+ markdown_path=md_path if markdown_source else None,
592
+ target=pattern,
593
+ title="",
594
+ nav_label=None,
595
+ nav_group=None,
596
+ version=None,
597
+ uid=None,
598
+ stage_mark=None,
599
+ stage_id=stage_id,
600
+ )
601
+
602
+ try:
603
+ html = render_page(
604
+ stage_id=stage_id,
605
+ target=pattern,
606
+ title=defaults["title"],
607
+ nav_label=defaults["nav_label"],
608
+ nav_group=defaults["nav_group"],
609
+ version=defaults["version"],
610
+ uid=defaults["uid"],
611
+ stage_mark=defaults["stage_mark"],
612
+ markdown_source=markdown_source,
613
+ page_data=None,
614
+ extra_variables=None,
615
+ state_file=state_file,
616
+ )
617
+ except Exception as exc:
618
+ print(f"Failed to render {pattern}: {exc}", file=sys.stderr)
619
+ continue
620
+
621
+ target_path.parent.mkdir(parents=True, exist_ok=True)
622
+ atomic_write_text(target_path, html)
623
+ print(f"Rendered {target_path}")
624
+ rendered.append(pattern)
625
+
626
+ return rendered
627
+
628
+
464
629
  PAGE_UID_META_RE = re.compile(r'<meta\s+name="page-uid"', re.IGNORECASE)
465
630
 
466
631
 
@@ -488,6 +653,24 @@ def render_page(
488
653
  """Render a single HTML page and return the rendered text."""
489
654
  stage_data = _load_stage(stage_id)
490
655
 
656
+ defaults = _derive_defaults(
657
+ markdown_path=markdown_source if markdown_source else None,
658
+ target=target,
659
+ title=title,
660
+ nav_label=nav_label,
661
+ nav_group=nav_group,
662
+ version=version,
663
+ uid=uid,
664
+ stage_mark=stage_mark,
665
+ stage_id=stage_id,
666
+ )
667
+ title = defaults["title"]
668
+ nav_label = defaults["nav_label"]
669
+ nav_group = defaults["nav_group"]
670
+ version = defaults["version"]
671
+ uid = defaults["uid"]
672
+ stage_mark = defaults["stage_mark"]
673
+
491
674
  runtime = _load_runtime_variables(state_file)
492
675
  variables: dict[str, str] = {k: "" for k in RUNTIME_VARIABLES}
493
676
  variables.update(runtime)
@@ -548,15 +731,15 @@ def _build_argument_parser() -> argparse.ArgumentParser:
548
731
  )
549
732
  parser.add_argument("--stage", required=True, help="Stage ID (e.g. clarify)")
550
733
  parser.add_argument(
551
- "--target", required=True, help="Repo-relative target artifact path"
734
+ "--target", default="", help="Repo-relative target artifact path"
552
735
  )
553
736
  parser.add_argument("--title", default="", help="Page title")
554
- parser.add_argument("--nav-label", default="", help="Navigation label")
555
- parser.add_argument("--nav-group", default="Docs", help="Navigation group")
556
- parser.add_argument("--version", default="", help="Version marker (e.g. v1.0)")
557
- parser.add_argument("--uid", default="", help="Stable page/doc UID")
737
+ parser.add_argument("--nav-label", default=None, help="Navigation label (defaults to title)")
738
+ parser.add_argument("--nav-group", default=None, help="Navigation group (defaults to Docs)")
739
+ parser.add_argument("--version", default=None, help="Version marker (defaults to v1.0 or from Markdown)")
740
+ parser.add_argument("--uid", default=None, help="Stable page/doc UID (defaults to target filename)")
558
741
  parser.add_argument(
559
- "--stage-mark", default="", help="Stage label to display (defaults to stage ID)"
742
+ "--stage-mark", default=None, help="Stage label to display (defaults to stage ID)"
560
743
  )
561
744
  parser.add_argument(
562
745
  "--markdown",
@@ -579,6 +762,11 @@ def _build_argument_parser() -> argparse.ArgumentParser:
579
762
  default=None,
580
763
  help="Path to workflow-stage.yaml (defaults to canonical discovery)",
581
764
  )
765
+ parser.add_argument(
766
+ "--render-stage",
767
+ action="store_true",
768
+ help="Render all missing artifact pages declared by --stage (requires --state-file)",
769
+ )
582
770
  parser.add_argument(
583
771
  "--dry-run",
584
772
  action="store_true",
@@ -601,6 +789,21 @@ def main(argv: list[str] | None = None) -> int:
601
789
  parser = _build_argument_parser()
602
790
  args = parser.parse_args(argv)
603
791
 
792
+ if args.render_stage:
793
+ if not args.state_file:
794
+ print("--render-stage requires --state-file", file=sys.stderr)
795
+ return 1
796
+ try:
797
+ _render_stage_batch(args.stage, args.state_file)
798
+ except Exception as exc:
799
+ print(f"Batch render failed: {exc}", file=sys.stderr)
800
+ return 1
801
+ return 0
802
+
803
+ if not args.target:
804
+ print("--target is required unless using --render-stage", file=sys.stderr)
805
+ return 1
806
+
604
807
  markdown_source = ""
605
808
  if args.markdown:
606
809
  md_path = PROJECT_ROOT / args.markdown
@@ -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))