← openharness report

T20 — 6-layer hierarchical permission evaluation + path normalization

OpenHarness đánh giá mọi tool call qua 6 layers theo thứ tự cố định: sensitive path → deny tool → allow tool → path rules → command deny → mode fallback. Mỗi layer có thể short-circuit. Path normalization sinh 2 variants (có/không trailing slash) để chặn bypass kinh điển.
Nhóm: E — Permission & SafetyFile: permissions/checker.py:75-169Layers: 6 (ordered, short-circuit)ID: E.3

Tổng quan Overview

Hầu hết agent harness implement permission check như một điều kiện đơn hoặc danh sách flat rules. OpenHarness chọn kiến trúc phân tầng: 6 layers đánh giá tuần tự, mỗi layer chỉ chạy nếu layer trước không đưa ra quyết định. Thứ tự không phải tùy tiện — nó phản ánh mức độ ưu tiên từ "tuyệt đối" (sensitive paths) đến "mặc định theo cấu hình" (mode).

Punchline kiến trúc: "deny rules block built-in tool invocations, not Bash subprocesses — agent can always exec arbitrary shell via bash_tool if Bash itself is allowed." Layer 2-5 chỉ kiểm tra tool call interface — không inspect những gì bên trong Bash command nếu bash_tool đã được allow ở layer 3.
Adversa CC-643 — 50-subcommand pipeline bypass: "Adversa CC-643: splitting a command into 50+ subcommands via shell piping bypassed Claude Code's permission checker — deny rules matched on single commands, not compound pipelines." OpenHarness fix bằng cách check toàn bộ pipeline: mỗi segment của pipe được kiểm tra riêng trước khi cho phép thực thi.
dontAsk mode bị hiểu nhầm nhiều nhất: "dontAsk mode is the most misunderstood: it doesn't mean 'allow everything', it means 'don't interrupt me — but sensitive paths and deny rules still apply'." Layer 1 (T19) và Layer 2 (deny tool list) vẫn active trong mọi mode — không có cấu hình nào tắt được chúng.
Path normalization — trailing slash bypass: "Path normalization generates two variants: stripped (no trailing slash) and with trailing slash — preventing the classic '/secret/' vs '/secret' bypass." _normalize_path_variants() sinh cả "/secret" lẫn "/secret/" — pattern rules được check với cả hai variant.

Phân tích code: permissions/checker.py:75-169 Anatomy

PermissionChecker.check() — 6-layer pipeline

permissions/checker.py:75-130 — main check() method

PY
{`
class PermissionChecker:
    def check(
        self,
        tool_name: str,
        path: str | None,
        command: str | None,
        context: PermissionContext,
    ) -> CheckResult:
        # Layer 1: sensitive path (unconditional)
        if path and is_sensitive_path(path):
            return CheckResult.BLOCK("Sensitive path")

        # Layer 2: deny tool list
        if tool_name in self._deny_tools:
            return CheckResult.BLOCK(f"Tool {tool_name!r} is denied")

        # Layer 3: allow tool list (early exit — skip remaining layers)
        if tool_name in self._allow_tools:
            return CheckResult.ALLOW()

        # Layer 4: path rules
        if path:
            result = self._check_path_rules(path)
            if result is not None:
                return result

        # Layer 5: command deny patterns
        if command:
            result = self._check_command_deny(command)
            if result is not None:
                return result

        # Layer 6: permission mode (fallback)
        return self._check_mode(tool_name, context.mode)
`}

Mỗi layer trả về CheckResult (BLOCK hoặc ALLOW) để short-circuit, hoặc None để fall through đến layer tiếp theo. Layer 3 (allow tool list) là điểm đặc biệt: nếu tool nằm trong allow list, các layers 4-5 bị bỏ qua hoàn toàn — ngay cả khi path rules hay command patterns có thể block nó.

Path normalization — chặn trailing-slash bypass

permissions/checker.py:131-155 — _normalize_path_variants() và _check_path_rules()

PY
{`
def _normalize_path_variants(path: str) -> list[str]:
    """
    Generate normalized variants to prevent bypass via trailing slash.
    '/secret/' vs '/secret' both checked.
    """
    stripped = path.rstrip("/")
    return [stripped, stripped + "/"]

def _check_path_rules(self, path: str) -> CheckResult | None:
    variants = _normalize_path_variants(path)
    for rule in self._path_rules:
        for variant in variants:
            if fnmatch.fnmatch(variant, rule.pattern):
                if rule.action == "deny":
                    return CheckResult.BLOCK(f"Path denied by rule: {rule.pattern}")
                if rule.action == "allow":
                    return CheckResult.ALLOW()
    return None  # no rule matched, continue to next layer
`}

Trailing-slash bypass là kỹ thuật cũ nhưng vẫn hiệu quả: nếu rule deny "/secret" nhưng agent request "/secret/", pattern matching đơn giản sẽ miss. _normalize_path_variants() sinh ra cả hai — rule chỉ cần match một trong hai là đủ để block.

Adversa CC-643 — pipeline bypass và mitigation

permissions/checker.py — CC-643 fix: check_full_pipeline()

TS
{`
# Adversa CC-643: 50-subcommand bypass
# Attack: instead of "rm -rf /important", agent uses:
# "echo x | cmd1 | cmd2 | ... | cmd49 | rm -rf /important"
# Each subcommand individually matched "allowed" patterns
# The full pipeline was never checked as a unit

# OpenHarness mitigation: check ALL subcommands in pipeline
import shlex

def check_full_pipeline(command: str) -> bool:
    """Check each segment of a piped command."""
    segments = command.split("|")
    for seg in segments:
        tokens = shlex.split(seg.strip(), posix=True)
        if tokens and _is_dangerous_command(tokens[0]):
            return False  # block entire pipeline
    return True

# Khi _check_command_deny() nhận command, nó gọi check_full_pipeline()
# thay vì chỉ check whole string một lần.
# "echo x | cat /etc/passwd" → segment 2 fail → toàn pipeline bị block
`}

Tương tác với các kỹ thuật khác Interaction

T20 (6-layer eval) tương tác với: T19 (Sensitive path protection) └─ T19 CHÍNH LÀ Layer 1 của T20 is_sensitive_path() được gọi đầu tiên trong check() "T20 is the frame; T19 is the first — and hardest — brick" T18 (Permission mode) └─ T18 là Layer 6 (layer cuối) của T20 — fallback sau khi layers 1-5 không đưa ra quyết định FULL_AUTO chỉ áp dụng nếu path không sensitive (layer 1 pass) và tool không bị deny (layer 2 pass) T21 (Async approval flow) └─ T21 được kích hoạt sau khi T20.check() trả về kết quả cần approval T20 quyết định WHETHER cần approval T21 quyết định HOW approval được collect Sensitive path block (layer 1) xảy ra TRƯỚC khi T21 UI hiện T10 (Bash tool) └─ Bash tool args (command string) được check ở Layer 5 check_full_pipeline() phân tách pipe segments Nhưng nếu bash_tool nằm trong allow_tools (layer 3) → layers 4-5 bị skip → command string không được inspect → "allow tool list too broad" là failure mode #2 Layer precedence summary: L1 T19 (unconditional) > L2 deny tool > L3 allow tool (skip L4-L5) > L4 path rules > L5 command deny > L6 T18 mode fallback

Failure modes Failures

Failure 1: Adversa CC-643 nếu pipeline check không đầy đủ

CC-643 variant: nested subshell bypass

TS
{`
# check_full_pipeline() split theo "|" — nhưng Bash có nhiều cách
# compose commands không dùng pipe:

# Semicolon: "safe_cmd; rm -rf /important"
# split("|") → ["safe_cmd; rm -rf /important"] — 1 segment, pass check
# shlex sees: ["safe_cmd;", "rm", "-rf", "/important"]
# _is_dangerous_command("safe_cmd;") → False

# Subshell: "safe_cmd $(rm -rf /important)"
# Nested command substitution không bị detect bởi simple split

# Fix: parse với shlex.split() + check từng token,
# hoặc dùng AST bash parser (bashlex library)
# Đây là lý do tại sao "deny rules block tool invocations, not Bash subprocesses"
`}

Failure 2: Allow tool list quá rộng — bypass layers 4-5

Kịch bản: bash_tool trong allow_tools → command không được inspect

TS
{`
# Cấu hình: allow_tools = {"bash_tool", "read_file", "glob_tool"}
# Agent call: bash_tool(command="rm -rf /project/src")

# Layer 1: path=None → skip T19
# Layer 2: "bash_tool" not in deny_tools → continue
# Layer 3: "bash_tool" IN allow_tools → ALLOW (short-circuit)
# Layers 4-5: SKIPPED — never reached
# Command "rm -rf /project/src" không bao giờ được check

# Nguyên tắc: NEVER put bash_tool in allow_tools
# Thay vào đó: để bash_tool fall through đến layer 5 (command deny)
# và define DANGEROUS_CMDS patterns đủ rộng
`}

Failure 3: Path rules conflict — child rule override parent deny

Rule ordering: deny parent nhưng allow child — child match trước

TS
{`
# Rules (theo thứ tự iteration):
# rule 1: pattern="/project/src/*", action="allow"
# rule 2: pattern="/project/*",     action="deny"

# Request: path="/project/src/secret.ts"
# _check_path_rules() iterate rules theo thứ tự:
# - rule 1: fnmatch("/project/src/secret.ts", "/project/src/*") → True
#   → action="allow" → ALLOW (return ngay)
# - rule 2: never reached

# Ý định: deny toàn bộ /project/ trừ src/
# Kết quả: correct IF rule order is [deny /project/*, allow /project/src/*]
# Nhưng nếu thứ tự ngược lại → allow rule match trước → bypass deny

# Fix: document rõ "first-match wins" và sort rules: specific trước, broad sau
# Hoặc implement priority field trong rule definition
`}

So sánh với các harness khác Compare

HarnessSố layersPath normalizationPipeline checkDeny bypass qua Bash
OpenHarness6 layers (ordered, short-circuit)Có — trailing slash variantsCó — CC-643 fix (split("|"))Có (documented: allow list quá rộng)
Claude CodeHierarchy tương tựPartialPartialCó (documented)
Aider1 layer (--yes flag)KhôngKhôngN/A
LangGraphNode-level interruptKhôngKhôngN/A

OpenHarness và Claude Code đều có hierarchy nhiều layers, nhưng OpenHarness document rõ thứ tự và fix Adversa CC-643. Aider dùng --yes flag đơn giản — không có hierarchy. LangGraph interrupt ở node level trong graph — không phải tool-call level.

Implementation recipe Recipe

Minimal 6-layer permission checker — đủ dùng cho agent harness mới, bao gồm path normalization và pipeline check:

permission_checker.py — minimal 6-layer implementation

PY
{`
import fnmatch

DANGEROUS_CMDS = {"rm", "dd", "mkfs", "curl", "wget", "nc", "python", "python3"}

def check_permission(
    tool: str, path: str | None, cmd: str | None,
    deny_tools: set, allow_tools: set, path_rules: list, mode: str
) -> str:  # "allow" | "block" | "ask"

    # L1: sensitive paths (từ T19)
    if path and is_sensitive(path):
        return "block"

    # L2: explicit deny
    if tool in deny_tools:
        return "block"

    # L3: explicit allow (short-circuit — skip L4-L5)
    if tool in allow_tools:
        return "allow"

    # L4: path rules (first-match wins, specific rules first)
    if path:
        variants = [path.rstrip("/"), path.rstrip("/") + "/"]
        for rule in path_rules:
            for v in variants:
                if fnmatch.fnmatch(v, rule["pattern"]):
                    return rule["action"]  # "allow" or "block"

    # L5: command deny (check full pipeline)
    if cmd:
        for seg in cmd.split("|"):
            import shlex
            try:
                tokens = shlex.split(seg.strip(), posix=True)
            except ValueError:
                return "block"  # unparseable → block
            if tokens and tokens[0] in DANGEROUS_CMDS:
                return "block"

    # L6: mode fallback (từ T18)
    if mode == "full_auto":
        return "allow"
    if mode == "plan":
        # block mutations, allow reads
        WRITE_OPS = {"bash_tool", "write_file", "delete_file"}
        return "block" if tool in WRITE_OPS else "allow"
    return "ask"  # DEFAULT: ask user

# Lưu ý triển khai:
# - L3 allow_tools KHÔNG nên bao gồm bash_tool (bypass L4-L5)
# - path_rules nên sort: specific (dài hơn) trước broad (ngắn hơn)
# - L5 split("|") chưa handle semicolon hay subshell — cần bashlex cho full coverage
`}

Tham khảo Refs

Nguồn chính