refactor: 분리되어 관리하고 있던 문서 시스템을 하나로 통일
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# Terminal Evidence Renderer
|
||||
|
||||
실제 명령 출력 원문을 보존한 뒤, 그 원문을 terminal UI 형태의 SVG로 렌더링한다.
|
||||
|
||||
## Evidence rule
|
||||
|
||||
`SVG`가 정본이 아니다. **실행한 명령의 raw output과 metadata가 정본**이고 SVG는 문서에 넣기 위한 표현이다.
|
||||
|
||||
```text
|
||||
command execution
|
||||
├── raw/<name>.txt # stdout/stderr 원문
|
||||
├── meta/<name>.json # command, cwd, executedAt, exitCode, revision
|
||||
└── terminal/<name>.svg # raw에서 생성
|
||||
```
|
||||
|
||||
시각화 단계에서는 Authorization Bearer, Cookie, token/password/client_secret/API key 형태의 값을 `[REDACTED]`로 치환한다. 그래도 raw 파일에 secret이 들어간 채 보관하면 안 된다. **명령 자체를 secret이 출력되지 않도록 구성하고, raw 저장 전에도 검사한다.**
|
||||
|
||||
## Render
|
||||
|
||||
```bash
|
||||
python3 /shared/Tech-Log-Document/tools/terminal-evidence/render_terminal.py \
|
||||
evidence/raw/gradle-test.txt \
|
||||
evidence/terminal/gradle-test.svg \
|
||||
--command './gradlew test' \
|
||||
--cwd '/shared/codebase/my-project' \
|
||||
--exit-code 0 \
|
||||
--executed-at '2026-08-28T15:00:00+09:00'
|
||||
```
|
||||
|
||||
## Capture pattern
|
||||
|
||||
Agent가 명령을 실제로 실행할 때 stdout/stderr, exit code, 실행 시간, cwd를 함께 기록한다. 실패 명령도 Evidence가 될 수 있으므로 exit code를 버리지 않는다.
|
||||
|
||||
예시 shell pattern:
|
||||
|
||||
```bash
|
||||
set +e
|
||||
executed_at=$(date --iso-8601=seconds)
|
||||
pwd_value=$(pwd)
|
||||
./gradlew test > evidence/raw/gradle-test.txt 2>&1
|
||||
exit_code=$?
|
||||
set -e
|
||||
```
|
||||
|
||||
그 뒤 metadata JSON을 쓰고 renderer를 호출한다. command에 credential을 직접 넣지 않는다.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"command": "python3 --version; git --version; printf workspace=<cwd>",
|
||||
"cwd": "/shared/Tech-Log-Document/tools/terminal-evidence",
|
||||
"executedAt": "2026-08-28T06:03:38+00:00",
|
||||
"exitCode": 0,
|
||||
"raw": "../raw/toolchain.txt",
|
||||
"rendered": "../terminal/toolchain.svg"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Python 3.12.3
|
||||
git version 2.43.0
|
||||
workspace=/shared/Tech-Log-Document/tools/terminal-evidence
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="242" viewBox="0 0 1200 242" role="img">
|
||||
<title>terminal evidence</title>
|
||||
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
|
||||
<rect x="1" y="1" width="1198" height="240" rx="14" fill="#0d1117" stroke="#30363d"/>
|
||||
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
|
||||
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
|
||||
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
|
||||
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
|
||||
<circle cx="64" cy="22" r="6" fill="#28c840"/>
|
||||
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
|
||||
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">$ python3 --version; git --version; printf workspace=<cwd></text>
|
||||
<text x="24" y="92" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">cwd: /shared/Tech-Log-Document/tools/terminal-evidence</text>
|
||||
<text x="24" y="116" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">time: 2026-08-28T06:03:38+00:00 · exit 0</text>
|
||||
<line x1="24" y1="130" x2="1176" y2="130" stroke="#30363d"/>
|
||||
<text x="24" y="170" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">Python 3.12.3</text>
|
||||
<text x="24" y="192" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">git version 2.43.0</text>
|
||||
<text x="24" y="214" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">workspace=/shared/Tech-Log-Document/tools/terminal-evidence</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
||||
_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"),
|
||||
(re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"),
|
||||
(
|
||||
re.compile(
|
||||
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)"
|
||||
),
|
||||
r"\1[REDACTED]",
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r'(?i)(["\'](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|client[_-]?secret|api[_-]?key|secret)["\']\s*:\s*["\'])(.*?)(["\'])'
|
||||
),
|
||||
r"\1[REDACTED]\3",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def strip_ansi(value: str) -> str:
|
||||
return ANSI_RE.sub("", value)
|
||||
|
||||
|
||||
def redact_line(line: str) -> str:
|
||||
redacted = strip_ansi(line)
|
||||
for pattern, replacement in _REDACTION_PATTERNS:
|
||||
redacted = pattern.sub(replacement, redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return html.escape(value, quote=True)
|
||||
|
||||
|
||||
def _display_lines(output: str, max_lines: int) -> list[str]:
|
||||
if max_lines < 1:
|
||||
raise ValueError("max_lines must be >= 1")
|
||||
|
||||
source_lines = output.splitlines()
|
||||
if not source_lines and output == "":
|
||||
source_lines = [""]
|
||||
|
||||
visible = source_lines[:max_lines]
|
||||
rendered = [redact_line(line) for line in visible]
|
||||
omitted = len(source_lines) - len(visible)
|
||||
if omitted > 0:
|
||||
rendered.append(f"[{omitted} more lines omitted]")
|
||||
return rendered
|
||||
|
||||
|
||||
def render_svg(
|
||||
output: str,
|
||||
*,
|
||||
command: str,
|
||||
cwd: str,
|
||||
exit_code: int,
|
||||
executed_at: str,
|
||||
max_lines: int = 120,
|
||||
width: int = 1200,
|
||||
) -> str:
|
||||
lines = _display_lines(output, max_lines=max_lines)
|
||||
|
||||
line_height = 22
|
||||
top_bar = 44
|
||||
metadata_height = 86
|
||||
output_top = top_bar + metadata_height + 18
|
||||
bottom_padding = 28
|
||||
height = output_top + max(1, len(lines)) * line_height + bottom_padding
|
||||
|
||||
safe_command = _escape(redact_line(command))
|
||||
safe_cwd = _escape(redact_line(cwd))
|
||||
safe_time = _escape(executed_at)
|
||||
safe_exit = _escape(str(exit_code))
|
||||
|
||||
parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img">',
|
||||
"<title>terminal evidence</title>",
|
||||
"<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>",
|
||||
f'<rect x="1" y="1" width="{width - 2}" height="{height - 2}" rx="14" fill="#0d1117" stroke="#30363d"/>',
|
||||
f'<rect x="1" y="1" width="{width - 2}" height="{top_bar}" rx="14" fill="#161b22"/>',
|
||||
f'<rect x="1" y="{top_bar - 14}" width="{width - 2}" height="14" fill="#161b22"/>',
|
||||
'<circle cx="24" cy="22" r="6" fill="#ff5f57"/>',
|
||||
'<circle cx="44" cy="22" r="6" fill="#febc2e"/>',
|
||||
'<circle cx="64" cy="22" r="6" fill="#28c840"/>',
|
||||
'<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>',
|
||||
f'<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">$ {safe_command}</text>',
|
||||
f'<text x="24" y="92" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">cwd: {safe_cwd}</text>',
|
||||
f'<text x="24" y="116" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">time: {safe_time} · exit {safe_exit}</text>',
|
||||
f'<line x1="24" y1="{top_bar + metadata_height}" x2="{width - 24}" y2="{top_bar + metadata_height}" stroke="#30363d"/>',
|
||||
]
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
y = output_top + (index + 1) * line_height
|
||||
escaped = _escape(line)
|
||||
# Keep all text derived from output. The SVG viewport clips extreme-width lines
|
||||
# rather than inventing wrapped content or changing the raw evidence.
|
||||
fill = "#d2a8ff" if line.startswith("[") and line.endswith("more lines omitted]") else "#e6edf3"
|
||||
parts.append(
|
||||
f'<text x="24" y="{y}" fill="{fill}" font-size="14" '
|
||||
'font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">'
|
||||
f"{escaped}</text>"
|
||||
)
|
||||
|
||||
parts.append("</svg>")
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render retained command output as a terminal-style SVG evidence asset."
|
||||
)
|
||||
parser.add_argument("input", type=Path, help="UTF-8 raw command output file")
|
||||
parser.add_argument("output", type=Path, help="Destination SVG")
|
||||
parser.add_argument("--command", required=True, help="Command that produced the raw output")
|
||||
parser.add_argument("--cwd", required=True, help="Working directory of the command")
|
||||
parser.add_argument("--exit-code", type=int, required=True, help="Actual process exit code")
|
||||
parser.add_argument("--executed-at", required=True, help="Actual ISO-8601 execution timestamp")
|
||||
parser.add_argument("--max-lines", type=int, default=120, help="Maximum raw lines to render")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
raw = args.input.read_text(encoding="utf-8", errors="replace")
|
||||
svg = render_svg(
|
||||
raw,
|
||||
command=args.command,
|
||||
cwd=args.cwd,
|
||||
exit_code=args.exit_code,
|
||||
executed_at=args.executed_at,
|
||||
max_lines=args.max_lines,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(svg, encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
@@ -0,0 +1,79 @@
|
||||
import re
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from render_terminal import redact_line, render_svg
|
||||
|
||||
|
||||
class RenderTerminalTest(unittest.TestCase):
|
||||
def test_svg_is_valid_xml_and_escapes_output(self):
|
||||
svg = render_svg(
|
||||
"<tag>& value\nsecond",
|
||||
command="printf '<tag>& value'",
|
||||
cwd="/shared/codebase/demo",
|
||||
exit_code=0,
|
||||
executed_at="2026-08-28T06:00:00Z",
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
self.assertIn("<tag>& value", svg)
|
||||
self.assertNotIn("<tag>& value", svg)
|
||||
|
||||
def test_metadata_is_rendered(self):
|
||||
svg = render_svg(
|
||||
"BUILD SUCCESSFUL",
|
||||
command="./gradlew test",
|
||||
cwd="/shared/codebase/demo",
|
||||
exit_code=0,
|
||||
executed_at="2026-08-28T06:00:00Z",
|
||||
)
|
||||
self.assertIn("./gradlew test", svg)
|
||||
self.assertIn("/shared/codebase/demo", svg)
|
||||
self.assertIn("exit 0", svg)
|
||||
self.assertIn("2026-08-28T06:00:00Z", svg)
|
||||
|
||||
def test_obvious_secrets_are_redacted(self):
|
||||
cases = {
|
||||
"Authorization: Bearer abc.def.ghi": "Authorization: Bearer [REDACTED]",
|
||||
"TOKEN=super-secret": "TOKEN=[REDACTED]",
|
||||
"PASSWORD=hunter2": "PASSWORD=[REDACTED]",
|
||||
"client_secret: abc123": "client_secret: [REDACTED]",
|
||||
"Cookie: SESSION=abcdef": "Cookie: [REDACTED]",
|
||||
}
|
||||
for raw, expected in cases.items():
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(expected, redact_line(raw))
|
||||
|
||||
def test_normal_output_is_not_changed_by_redaction(self):
|
||||
line = "GET /api/me -> 200 in 14ms"
|
||||
self.assertEqual(line, redact_line(line))
|
||||
|
||||
def test_truncation_marker_is_rendered_without_fabricating_hidden_lines(self):
|
||||
output = "\n".join(f"line-{i}" for i in range(8))
|
||||
svg = render_svg(
|
||||
output,
|
||||
command="demo",
|
||||
cwd="/tmp",
|
||||
exit_code=1,
|
||||
executed_at="2026-08-28T06:00:00Z",
|
||||
max_lines=3,
|
||||
)
|
||||
self.assertIn("line-0", svg)
|
||||
self.assertIn("line-2", svg)
|
||||
self.assertNotIn("line-3", svg)
|
||||
self.assertIn("[5 more lines omitted]", svg)
|
||||
|
||||
def test_terminal_chrome_and_monospace_are_present(self):
|
||||
svg = render_svg(
|
||||
"ok",
|
||||
command="echo ok",
|
||||
cwd="/tmp",
|
||||
exit_code=0,
|
||||
executed_at="2026-08-28T06:00:00Z",
|
||||
)
|
||||
self.assertGreaterEqual(len(re.findall(r"<circle\b", svg)), 3)
|
||||
self.assertIn("monospace", svg)
|
||||
self.assertIn("terminal evidence", svg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user