From 06db38be46b0f4f9876c8cf31a30cad8048e38d6 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Mon, 13 Jul 2026 14:27:24 +1000 Subject: [PATCH] agent guardrails: tclsh has no -e one-liner flag - AGENTS.md pitfall entry + claude PreToolUse deny hook Stock tclsh treats any leading-dash arg other than -encoding as argv for an interactive/stdin session: on a console it hangs at a prompt, with piped stdin it exits 0 silently ignoring the supposed one-liner. Agents habitually reach for -e (perl/python/node reflex), wasting time waiting on hung processes. - root AGENTS.md User Preferences: document the misparse, the correct patterns (temp .tcl file or script via stdin) and the defensive stdin-redirect habit - .claude/settings.json: PreToolUse hook denying Bash/PowerShell invocations of tclsh with -e, anchored to command position (start/pipe/semicolon/paren) so prose mentions in commit messages or grep args do not false-positive; corrective guidance returned to the agent - .gitignore: narrow the .claude ignore so shared settings.json is tracked while session-local files stay ignored - .fossil-settings: ignore-glob hand-derived per the no-negation rule (.claude/* wholesale + explicit fossil add of settings.json), exception set in AGENTS.md updated; fossil extras under-ignore check verified empty Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- .claude/settings.json | 50 ++++++++++++++++++++++++++++++++++++ .fossil-settings/AGENTS.md | 2 +- .fossil-settings/ignore-glob | 3 ++- .gitignore | 4 ++- AGENTS.md | 1 + 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..c5c3710c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,50 @@ +{ + "permissions": { + "allow": [ + "Bash(tclsh90 src/tests/runtests.tcl *)", + "Bash(tclsh src/tests/runtests.tcl *)", + "Bash(/c/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh90 src/tests/runtests.tcl *)", + "Bash(\"C:/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh.exe\" src/tests/runtests.tcl *)", + "Bash(./bin/punk91.exe src/tests/runtests.tcl *)", + "Bash(tclsh src/make.tcl *)", + "Bash(tclsh90 src/make.tcl *)", + "Bash(tclsh87 src/make.tcl *)", + "Bash(/c/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh90 src/make.tcl *)", + "Bash(\"C:/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh.exe\" src/make.tcl *)", + "Bash(./bin/punk91.exe src/make.tcl *)", + "Bash(./bin/punk902z.exe src/make.tcl *)", + "Bash(./bin/punksys.exe src/make.tcl *)", + "Bash(tclsh scriptlib/developer/goals_lint.tcl *)", + "Bash(tclsh90 scriptlib/developer/goals_lint.tcl *)", + "Bash(/c/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh90 scriptlib/developer/goals_lint.tcl *)", + "Bash(\"C:/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh.exe\" scriptlib/developer/goals_lint.tcl *)", + "PowerShell(tclsh scriptlib/developer/goals_lint.tcl *)", + "PowerShell(tclsh90 scriptlib/developer/goals_lint.tcl *)", + "PowerShell(& \"C:\\Users\\sleek\\AppData\\Local\\Apps\\Tcl903\\bin\\tclsh90.exe\" scriptlib/developer/goals_lint.tcl *)", + "PowerShell(tclsh src/make.tcl *)", + "PowerShell(tclsh90 src/make.tcl *)", + "PowerShell(& \"C:\\Users\\sleek\\AppData\\Local\\Apps\\Tcl903\\bin\\tclsh90.exe\" src/make.tcl *)", + "PowerShell(& c:\\repo\\jn\\shellspy\\bin\\punk902z.exe src/make.tcl *)", + "PowerShell(Get-Process *)", + "PowerShell(git status *)", + "PowerShell(git log *)", + "PowerShell(git diff *)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "cmd=$(jq -r '.tool_input.command // empty'); if printf '%s' \"$cmd\" | grep -qiE '(^|[|;&(]|\\$\\()[[:space:]]*[^|;&[:space:]]*tclsh[0-9.]*(\\.exe)?\"?[[:space:]]+(-encoding[[:space:]]+[^[:space:]]+[[:space:]]+)?-e([[:space:]]|$)'; then printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"tclsh has no -e one-liner flag. An argument starting with - is not treated as a script file: all args land in $argv and tclsh reads commands from stdin instead (hangs forever at an interactive prompt on a console; silently ignores the -e script when stdin is piped). Write the code to a temp .tcl file and run: tclsh path/to/file.tcl - or pipe the script via stdin. See AGENTS.md User Preferences.\"}}'; fi", + "shell": "bash", + "timeout": 15, + "statusMessage": "Checking for tclsh -e misuse" + } + ] + } + ] + } +} diff --git a/.fossil-settings/AGENTS.md b/.fossil-settings/AGENTS.md index 69ca422c..f8fe9bf4 100644 --- a/.fossil-settings/AGENTS.md +++ b/.fossil-settings/AGENTS.md @@ -24,7 +24,7 @@ Versioned fossil settings (one setting value or glob list per file) for this dua ### ignore-glob derivation rules (verified against fossil 2.28) -- **No negation exists in fossil globs.** Git's `/bin/*` + `!exception` pattern becomes: ignore `bin/*` wholesale and explicitly `fossil add` the tracked exceptions (ignore-glob never affects already-managed files, so they stay tracked). The exception set is whatever git tracks under an ignored tree - at the time of writing: `bin/AGENTS.md`, `bin/*.cmd`, `bin/*.kit`, `bin/*.tcl`, `bin/*.sh`, `bin/*.bash`, plus force-tracked one-offs (`bin/libssp-0.dll`, `src/vfs/punk9magicsplat.vfs/lib/nmake/x86_64-w64-mingw32-nmakehlp.exe`). Derive the current set with the verification comparison below rather than trusting this list. +- **No negation exists in fossil globs.** Git's `/bin/*` + `!exception` pattern becomes: ignore `bin/*` wholesale and explicitly `fossil add` the tracked exceptions (ignore-glob never affects already-managed files, so they stay tracked). The exception set is whatever git tracks under an ignored tree - at the time of writing: `bin/AGENTS.md`, `bin/*.cmd`, `bin/*.kit`, `bin/*.tcl`, `bin/*.sh`, `bin/*.bash`, `.claude/settings.json` (shared claude harness hooks/permissions; session-local `.claude` files stay ignored), plus force-tracked one-offs (`bin/libssp-0.dll`, `src/vfs/punk9magicsplat.vfs/lib/nmake/x86_64-w64-mingw32-nmakehlp.exe`). Derive the current set with the verification comparison below rather than trusting this list. - A bare directory name prunes that whole tree; `*` crosses `/`; git patterns intended to match at any depth need an additional `*/` variant; `#` comment lines are honoured. - Do not add the fossil-generated names (`manifest`, `manifest.uuid`, `manifest.tags`) to `ignore-glob` - fossil handles them itself. - **Nested `.gitignore` files are git-only**: ignore files inside subtrees (e.g. the project-layout templates under `src/project_layouts/`) are honoured by git as nested ignores of the outer repo but invisible to fossil - template content they match is untracked in git yet managed by fossil. Treat such divergence as a signal to review the git side (usually `git add -f` of the affected template files). diff --git a/.fossil-settings/ignore-glob b/.fossil-settings/ignore-glob index f3a06eef..d97f2ac3 100644 --- a/.fossil-settings/ignore-glob +++ b/.fossil-settings/ignore-glob @@ -22,7 +22,8 @@ tmp .vscode */.vscode -.claude +#claude harness config: shared .claude/settings.json is a tracked exception by explicit add (see header) +.claude/* */.claude .omo */.omo diff --git a/.gitignore b/.gitignore index fefe5f42..deedc6d4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,9 @@ /tmp/ .vscode -.claude +#claude harness config: track the shared project settings (hooks/permissions), ignore everything else (session-local settings etc) +.claude/* +!.claude/settings.json .omo /logs/ diff --git a/AGENTS.md b/AGENTS.md index 77e400f9..5425694c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ When the user requests a durable behavior change, record it here or in the relev - If the active editor is on a source-derived snapshot, bootstrap copy, or build output path such as `src/bootsupport/`, root `modules/`, root `lib/`, `modules_tcl8/`, `modules_tcl9/`, `lib_tcl8/`, or `lib_tcl9/`, confirm the intended target before editing unless the user explicitly named that path. - cmd.exe PATH truncation (this machine, and any Windows machine with a heavily populated PATH): cmd.exe truncates a long PATH, so a tool that resolves fine in PowerShell may be "not found" when invoked via `cmd.exe /c`. Use absolute executable paths inside any `cmd /c` command line, and prefer PowerShell-native invocation unless a console host is specifically required (e.g. hidden-console test harnesses). If a tool is missing only under cmd.exe, suspect truncation before absence. - Agent-authored text is plain ASCII by default: no em/en dashes, curly quotes, arrow or ellipsis characters, or other typographic Unicode - use ASCII equivalents (" - ", straight quotes, "->", "..."). This applies with extra force to outward-bound artifacts (ticket drafts, bug reports, emails, commit messages, anything likely to be pasted into an external system): those must be pure ASCII, verified before handover (e.g. grep for `[^\x00-\x7F]`). Legitimate exceptions: content whose subject matter is itself non-ASCII (encoding/Unicode/ANSI-art test data, or documentation demonstrating such behaviour), verbatim quotes of existing material, and cases where the user explicitly requests non-ASCII. Existing files are not to be bulk-retrofitted - the rule governs newly written text. +- Tcl has no `-e`/`-c` one-liner flag (a reflex agents carry over from perl/python/node). Stock `tclsh`/`tclsh86`/`tclsh90` recognise only `-encoding name` as a leading option; any other argument starting with `-` is NOT treated as a script file - all arguments land in `$argv` and tclsh reads commands from stdin. On a console that hangs forever at an interactive prompt; with piped/redirected stdin it exits 0 having silently ignored the supposed one-liner and executed stdin instead. (The punk kits differ: they treat `-e` as a script filename and error out immediately - no hang, but still no one-liner.) To run ad-hoc Tcl: write a temp `.tcl` file and run `tclsh path/to/file.tcl`, or pipe the script to stdin (`echo 'puts hi' | tclsh`, or a bash heredoc). Defensive habit regardless: when exec'ing tclsh non-interactively, redirect stdin (``, delete the directory, then any `fossil all` command prunes the orphaned `ckout:` row. - Do not commit new executable binaries (shared libs, .exe, native .so/.dll/.dylib, bare ELF/Mach-O, or zip-based .tm modules embedding executables) to the repository. Existing binaries in `bin/`, `src/vfs/`, `src/vendorlib/`, `src/vendormodules/`, and `src/bootsupport/` are there intentionally pending the build/retrieval infrastructure tracked by goals G-004/G-005/G-006; do not flag, "fix", or hassle the developer about these — they are known and will be removed once G-005 (zig build) or G-006 (pre-built download) provides an alternative. This rule stops agents from adding new binaries; it does not block the developer's interim commits of existing vendor/vfs binaries. It is workflow policy only - deliberately NOT enforced at the local VCS layer: fossil `binary-glob` is `*` (versioned in `.fossil-settings/`) so binary checkins proceed without warnings/prompts, here and in sub-projects like tomlish.