@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.
- package/framework/.claude/agents/pm.md +2 -2
- package/framework/.claude/agents/security-reviewer.md +57 -0
- package/framework/.claude/harnesses/codex.yaml +31 -2
- package/framework/.claude/harnesses/command-map.yaml +4 -0
- package/framework/.claude/harnesses/opencode.yaml +33 -3
- package/framework/.claude/hooks/hooks.yaml +37 -0
- package/framework/.claude/hooks/on-session-start.sh +20 -5
- package/framework/.claude/hooks/on-stop.sh +22 -0
- package/framework/.claude/hooks/pre-tool-use.sh +22 -0
- package/framework/.claude/mcp/mcp.yaml +34 -0
- package/framework/.claude/policies/friction.yaml +18 -0
- package/framework/.claude/policies/security.yaml +46 -0
- package/framework/.claude/schemas/design-review-feedback.json +32 -0
- package/framework/.claude/schemas/stage-definition.schema.json +22 -0
- package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
- package/framework/.claude/skills/clarify-requirements/prompts/main.md +83 -80
- package/framework/.claude/skills/draft-product-design/prompts/main.md +27 -14
- package/framework/.claude/stages/clarify.yaml +7 -5
- package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
- package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
- package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
- package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
- package/framework/.claude/stages/product-design-docs.yaml +6 -6
- package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
- package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
- package/framework/.claude-plugin/marketplace.json +2 -2
- package/framework/.claude-plugin/plugin.json +1 -1
- package/framework/.version-bump.json +1 -1
- package/framework/README.en.md +24 -5
- package/framework/README.md +24 -5
- package/framework/RELEASE.md +41 -0
- package/framework/SKILL.md +15 -6
- package/framework/manifest.json +96 -56
- package/framework/scripts/check-harness-drift.sh +11 -1
- package/framework/scripts/lincoln-setup.py +17 -0
- package/framework/scripts/lincoln_condenser.py +110 -0
- package/framework/scripts/lincoln_friction.py +266 -0
- package/framework/scripts/lincoln_harness_adapter.py +185 -13
- package/framework/scripts/lincoln_mr_mine.py +146 -0
- package/framework/scripts/lincoln_recall.py +142 -0
- package/framework/scripts/lincoln_render.py +209 -6
- package/framework/scripts/lincoln_trace.py +119 -3
- package/framework/scripts/security_analyzer.py +234 -0
- package/framework/scripts/stage_loader.py +124 -1
- package/framework/tools/lincoln/package-lock.json +2 -2
- package/framework/tools/lincoln/package.json +1 -1
- package/framework/tools/lincoln-installer/package.json +6 -1
- package/package.json +6 -1
- package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +0 -108
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +0 -134
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +0 -248
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +0 -122
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +0 -106
- 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",
|
|
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=
|
|
555
|
-
parser.add_argument("--nav-group", default=
|
|
556
|
-
parser.add_argument("--version", default=
|
|
557
|
-
parser.add_argument("--uid", default=
|
|
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=
|
|
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 = "
|
|
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
|
|
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(
|
|
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))
|