diff --git a/README.md b/README.md index 5bb825a..eb9f217 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ the same name overrides it. `config/env.example` documents each setting with its asked for was done. `--sandbox danger-full-access` is an explicit option only. Worktree runs share caches below `BASE/.jpdev-shared`; `--small` enforces the manifest handover paths and line bound after the run; `--resume` continues the named thread. Every detected thread id is written - to `.jpdev/output/thread`. + to `.jpdev/output/thread`. A directory passed to `--add` adds every non-ignored file below it, + except the handover files and caches, and is refused with its path when nothing remains to add. - `jpdev codex review --brief FILE --model M --effort E --dir CLONE [--pr N | --issue N] [--repo OWNER/NAME] [--out FILE] [--sandbox read-only|workspace-write] [--network]`: checks the brief and runs `codex exec` with the model, effort and sandbox explicit and stdin closed. It writes the @@ -150,8 +151,9 @@ the same name overrides it. `config/env.example` documents each setting with its `--review` prints the reviewer skeleton with its read-only rules and findings-first report. - `jpdev codex check-brief FILE`: exits 1 and names the neutral phrasing for each word of [`config/codex-words.txt`](config/codex-words.txt) (and of the file `JPDEV_CODEX_WORDS` names) - that the brief holds. `codex run` runs the same check first and does not start the run on a - refusal. + that the brief holds. A word inside backticks is treated as part of an identifier, path or + command and is ignored; the product term “adversarial” is not in the base list. `codex run` runs + the same check first and does not start the run on a refusal. - `jpdev env`: the effective settings and where each came from. ## The caps diff --git a/bin/jpdev b/bin/jpdev index 7c3a0f4..98cb281 100755 --- a/bin/jpdev +++ b/bin/jpdev @@ -733,7 +733,7 @@ PY codex_publish() { local dir=$1 branch=$2 push=$3 pr=$4 repo=$5 base=$6 shift 6 - local trailer="Co-Authored-By: Codex " tmpd + local trailer="Co-Authored-By: Codex " tmpd msg_tracked=0 pr_tracked=0 cd "$dir" || exit 2 if [ ! -s msg.txt ]; then echo "jpdev codex run: refused: codex wrote no msg.txt (the commit message); nothing was committed" >&2 @@ -743,9 +743,16 @@ codex_publish() { echo "jpdev codex run: refused: codex wrote no pr.md (the pull request body); nothing was committed" >&2 exit 1 fi + git ls-files --error-unmatch -- msg.txt > /dev/null 2>&1 && msg_tracked=1 + git ls-files --error-unmatch -- pr.md > /dev/null 2>&1 && pr_tracked=1 git add -u || exit 1 - local path + local path pending add_dirs=() for path in "$@"; do + if [ -d "$path" ]; then + add_dirs+=("$path") + pending=$(git status --porcelain --untracked-files=all -- "$path") || exit 1 + if [ -z "$pending" ]; then continue; fi + fi git add -- "$path" || { echo "jpdev codex run: --add $path could not be staged" >&2; exit 1; } done local cache_paths=() @@ -760,11 +767,46 @@ codex_publish() { if git diff --cached --name-only | grep -qE '(^|/)\.(jpdev|jpdev-shared|gocache|npm-cache|xdg-cache)(/|$)'; then jpdev_die "codex run: a sandbox cache directory remained staged" fi - if git diff --cached --name-only | grep -qxE 'msg\.txt|pr\.md'; then + local tracked_handover=0 handover_paths=() nested_tracked_handover=() add_path + local -A seen_nested_handover=() + for add_path in "${add_dirs[@]}"; do + while IFS= read -r -d '' path; do + case $path in + */msg.txt | */pr.md) + if [ -n "${seen_nested_handover[$path]:-}" ]; then continue; fi + seen_nested_handover[$path]=1 + if git cat-file -e "HEAD:$path" 2>/dev/null; then + nested_tracked_handover+=("$path") + else + handover_paths+=("$path") + fi + ;; + esac + done < <(git diff --cached --name-only -z -- "$add_path") + done + if ! git diff --cached --quiet -- msg.txt; then + if [ "$msg_tracked" -eq 1 ]; then tracked_handover=1; else handover_paths+=(msg.txt); fi + fi + if ! git diff --cached --quiet -- pr.md; then + if [ "$pr_tracked" -eq 1 ]; then tracked_handover=1; else handover_paths+=(pr.md); fi + fi + if [ "${#handover_paths[@]}" -gt 0 ]; then + git rm --cached --quiet --ignore-unmatch -- "${handover_paths[@]}" || exit 1 + fi + if [ "${#nested_tracked_handover[@]}" -gt 0 ]; then + git reset -q -- "${nested_tracked_handover[@]}" || exit 1 + fi + if [ "$tracked_handover" -eq 1 ]; then git reset -q -- msg.txt pr.md echo "jpdev codex run: refused: msg.txt or pr.md is tracked and changed; they are read, never committed" >&2 exit 1 fi + for path in "${add_dirs[@]}"; do + if git diff --cached --quiet -- "$path"; then + echo "jpdev codex run: --add $path: directory holds nothing to add" >&2 + exit 1 + fi + done if git diff --cached --quiet; then echo "jpdev codex run: nothing to commit: codex made no change to a tracked file" >&2 exit 1 diff --git a/config/codex-words.txt b/config/codex-words.txt index b5b57ba..3ee5649 100644 --- a/config/codex-words.txt +++ b/config/codex-words.txt @@ -15,7 +15,6 @@ hack => describe the unsupported use and the refusal that documents it breach => describe the boundary and the check that must hold at it malicious => describe the input as unexpected, unsupported or not allowed hostile => describe the input as unexpected, unsupported or not allowed -adversar => describe the input as unexpected or not allowed, and the refusal jailbreak => describe the instruction the model must not follow and the refusal prompt injection => describe the untrusted text in the input and the refusal that keeps it from acting as an instruction code injection => describe the untrusted text in the input and the refusal diff --git a/lib/codex_words.py b/lib/codex_words.py index ce11742..91c1d54 100644 --- a/lib/codex_words.py +++ b/lib/codex_words.py @@ -6,7 +6,9 @@ start of a word, with any letters after it, and a TERM of several words matches across any run of whitespace and hyphens (so a term wrapped over two lines, or written with hyphens, is found; the line reported is the line the match starts on). Evasion inside a single word (a letter -replaced or inserted) is out of scope: this is a vocabulary check, not a filter. A word file that does not exist is skipped (the config may extend the list). +replaced or inserted) is out of scope: this is a vocabulary check, not a filter. Text inside a +paired run of backticks is an identifier, path or command and is not checked. A word file that +does not exist is skipped (the config may extend the list). Prints one line per hit and exits 1 when there is any, 0 when the brief is clean, 2 on a usage or read error. """ @@ -31,6 +33,58 @@ def load(path): return entries +def blank(text): + """Blank text while preserving line boundaries and offsets.""" + return re.sub(r"[^\n]", " ", text) + + +def mask_inline_backticks(line): + """Blank paired inline spans, or return the whole line unchanged if unbalanced.""" + runs = list(re.finditer(r"`+", line)) + spans = [] + index = 0 + while index < len(runs): + opener = runs[index] + for closing in range(index + 1, len(runs)): + if len(runs[closing].group(0)) == len(opener.group(0)): + spans.append((opener.start(), runs[closing].end())) + index = closing + 1 + break + else: + return line + chars = list(line) + for start, end in spans: + chars[start:end] = blank(line[start:end]) + return "".join(chars) + + +def mask_backticks(text): + """Blank balanced inline spans and fenced blocks, preserving unbalanced text.""" + lines = text.splitlines(keepends=True) + masked = [] + index = 0 + while index < len(lines): + body = lines[index].rstrip("\r\n") + opened = re.fullmatch(r"[ \t]{0,3}(`{3,})[^`]*", body) + if not opened: + masked.append(mask_inline_backticks(lines[index])) + index += 1 + continue + width = len(opened.group(1)) + closing = index + 1 + while closing < len(lines): + candidate = lines[closing].rstrip("\r\n") + if re.fullmatch(r"[ \t]{0,3}`{%d,}[ \t]*" % width, candidate): + break + closing += 1 + if closing == len(lines): + masked.extend(lines[index:]) + break + masked.extend(blank(line) for line in lines[index:closing + 1]) + index = closing + 1 + return "".join(masked) + + def main(argv): if len(argv) < 3: print("usage: codex_words.py BRIEF WORDFILE...", file=sys.stderr) @@ -45,7 +99,7 @@ def main(argv): except OSError as err: print("codex_words.py: %s" % err, file=sys.stderr) return 2 - text = "\n".join(lines) + text = mask_backticks("\n".join(lines)) hits = 0 for term, suggestion in entries: words = [re.escape(part) for part in term.split()] diff --git a/tests/run.sh b/tests/run.sh index 9b41655..08ac44a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -750,6 +750,28 @@ expect "check-brief matches regardless of case and ending" 1 'line 2: "EXPLOITAT expect "check-brief suggests the neutral phrasing" 1 'describe the input the code receives and the refusal it must produce' "$out" "$s" expect "check-brief points at the documented refusal" 1 'describe the documented refusal instead' "$out" "$s" absent "check-brief does not flag a word that merely contains a listed one" 'shackle' "$out" + # shellcheck disable=SC2016 # a literal expression the test writes into a fixture +printf 'Name the `exploitParser` identifier.\n\n## Before you write msg.txt\n' > "$work/backticked-brief.md" +out=$("$jpdev" codex check-brief "$work/backticked-brief.md" 2>&1) && s=0 || s=$? +expect "check-brief ignores a listed word inside backticks" 0 'no word of the filter list' "$out" "$s" + # shellcheck disable=SC2016 # literal unmatched and paired delimiters in the fixture +printf 'An unmatched ` marker leaves exploitParser bare before `safe`.\n\n## Before you write msg.txt\n' > "$work/unbalanced-backtick-brief.md" +out=$("$jpdev" codex check-brief "$work/unbalanced-backtick-brief.md" 2>&1) && s=0 || s=$? +expect "check-brief fails closed on an odd run of backticks" 1 'line 1: "exploitParser"' "$out" "$s" + # shellcheck disable=SC2016 # literal fenced blocks in the fixtures +printf '```text\nexploitParser\n```\n\n## Before you write msg.txt\n' > "$work/balanced-fence-brief.md" +out=$("$jpdev" codex check-brief "$work/balanced-fence-brief.md" 2>&1) && s=0 || s=$? +expect "check-brief exempts a balanced fenced block" 0 'no word of the filter list' "$out" "$s" + # shellcheck disable=SC2016 # literal unclosed fenced block in the fixture +printf '```text\nexploitParser\n\n## Before you write msg.txt\n' > "$work/unbalanced-fence-brief.md" +out=$("$jpdev" codex check-brief "$work/unbalanced-fence-brief.md" 2>&1) && s=0 || s=$? +expect "check-brief fails closed on an unbalanced fenced block" 1 'line 2: "exploitParser"' "$out" "$s" +printf 'Name the exploitParser identifier.\n\n## Before you write msg.txt\n' > "$work/bare-brief.md" +out=$("$jpdev" codex check-brief "$work/bare-brief.md" 2>&1) && s=0 || s=$? +expect "check-brief still refuses the same word bare" 1 'line 1: "exploitParser": describe the malformed or unexpected input and the documented refusal' "$out" "$s" +printf 'Describe the adversarial review.\n\n## Before you write msg.txt\n' > "$work/product-term-brief.md" +out=$("$jpdev" codex check-brief "$work/product-term-brief.md" 2>&1) && s=0 || s=$? +expect "check-brief accepts the product term in prose" 0 'no word of the filter list' "$out" "$s" n=0 while IFS= read -r line; do case $line in '' | '#'*) continue ;; esac @@ -823,7 +845,7 @@ git init -q -b main "$cxrepo" printf '# Rules\n' > "$cxrepo/AGENTS.md" printf 'one\n' > "$cxrepo/file.txt" printf '{"version":1,"checks":[{"name":"one","run":"true"}],"handover":{"shared_paths":["file.txt"],"boundary_paths":[]}}\n' > "$cxrepo/dev.json" -printf 'msg.txt\npr.md\n' > "$cxrepo/.gitignore" +printf 'ignored.txt\n' > "$cxrepo/.gitignore" git -C "$cxrepo" add .gitignore AGENTS.md dev.json file.txt git -C "$cxrepo" commit -q -m seed git -C "$cxrepo" remote add origin "$cxorigin" @@ -889,6 +911,32 @@ out=$(STUB_CODEX_EDIT=1 STUB_CODEX_MSG=$work/msg-plain.txt cx_run --add created. expect "codex run --add commits a new file named" 0 'committed' "$out" "$s" if [ "$(git -C "$cxrepo" show --name-only --format= HEAD | sort | paste -sd' ')" = "created.txt file.txt" ]; then ok "--add puts the named file in the commit"; else bad "--add puts the named file in the commit"; fi +cx_reset +mkdir -p "$cxrepo/bundle" +printf 'one\n' > "$cxrepo/bundle/one.txt" +printf 'two\n' > "$cxrepo/bundle/two.txt" +printf 'ignored\n' > "$cxrepo/bundle/ignored.txt" +mkdir -p "$cxrepo/bundle/handover" +printf 'nested message\n' > "$cxrepo/bundle/handover/msg.txt" +printf 'nested body\n' > "$cxrepo/bundle/handover/pr.md" +out=$(STUB_CODEX_EDIT=1 STUB_CODEX_MSG=$work/msg-plain.txt cx_run --add bundle) && s=0 || s=$? +expect "codex run --add accepts a directory" 0 'committed' "$out" "$s" +names=$(git -C "$cxrepo" show --name-only --format= HEAD | sort | paste -sd' ') +if [ "$names" = "bundle/one.txt bundle/two.txt file.txt" ]; then ok "--add commits every non-ignored new file in a directory"; else bad "--add commits every non-ignored new file in a directory" "$names"; fi + +cx_reset +mkdir -p "$cxrepo/nested/handover" +printf 'nested message\n' > "$cxrepo/nested/handover/msg.txt" +printf 'nested body\n' > "$cxrepo/nested/handover/pr.md" +out=$(STUB_CODEX_MSG=$work/msg-plain.txt cx_run --add nested) && s=0 || s=$? +expect "codex run --add refuses a directory holding only nested handover files" 1 'codex run: --add nested: directory holds nothing to add' "$out" "$s" +if [ "$(git -C "$cxrepo" rev-list --count "$base..HEAD")" = 0 ]; then ok "a nested handover-only directory makes no commit"; else bad "a nested handover-only directory makes no commit"; fi + +cx_reset +out=$(STUB_CODEX_MSG=$work/msg-plain.txt cx_run --add .) && s=0 || s=$? +expect "codex run --add refuses a directory holding only the handover files" 1 'codex run: --add \.: directory holds nothing to add' "$out" "$s" +if [ "$(git -C "$cxrepo" rev-list --count "$base..HEAD")" = 0 ]; then ok "an empty --add directory makes no commit"; else bad "an empty --add directory makes no commit"; fi + cx_reset for cache in .jpdev .gocache .npm-cache .xdg-cache; do mkdir -p "$cxrepo/$cache" @@ -938,6 +986,14 @@ out=$(STUB_CODEX_EDIT=1 STUB_CODEX_EXIT=4 STUB_CODEX_MSG=$work/msg-plain.txt cx_ expect "codex run does not commit after a failed codex run" 4 'codex exit 4' "$out" "$s" if [ "$(git -C "$cxrepo" rev-list --count "$base..HEAD")" = 0 ]; then ok "a failed codex run: no commit"; else bad "a failed codex run: no commit"; fi cx_reset +printf 'tracked message\n' > "$cxrepo/msg.txt" +git -C "$cxrepo" add -f msg.txt +git -C "$cxrepo" commit -q -m 'track handover message for refusal probe' +tracked_base=$(git -C "$cxrepo" rev-parse HEAD) +out=$(STUB_CODEX_EDIT=1 STUB_CODEX_MSG=$work/msg-plain.txt cx_run) && s=0 || s=$? +expect "codex run still refuses a tracked and changed handover file" 1 'msg.txt or pr.md is tracked and changed' "$out" "$s" +if [ "$(git -C "$cxrepo" rev-parse HEAD)" = "$tracked_base" ]; then ok "a tracked handover refusal makes no commit"; else bad "a tracked handover refusal makes no commit"; fi +cx_reset out=$(STUB_CODEX_EDIT=1 STUB_CODEX_MSG=$work/msg-plain.txt cx_run --add msg.txt) && s=0 || s=$? expect "codex run refuses --add msg.txt" 2 'read, never committed' "$out" "$s" out=$(STUB_CODEX_EDIT=1 STUB_CODEX_MSG=$work/msg-plain.txt cx_run --add .jpdev) && s=0 || s=$?