Skip to content
Merged
22 changes: 14 additions & 8 deletions .agent_harness/command_guard_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
_CLOUD_COMMANDS = {"aws", "gcloud", "az"}
_SECRET_FILE_READ_COMMANDS = {"cat", "less", "more", "head", "tail", "sed", "awk", "python", "python3", "node", "ruby", "perl", "sqlite3"}
_CWD_CHANGING_COMMANDS = {"cd", "pushd", "popd"}
_SHELL_COMMAND_PREFIX_KEYWORDS = {"if", "then", "elif", "else", "while", "until", "do"}
_SECRET_PATH_RE = re.compile(r"(?i)(?:\.env\b|\.env\.|\.pem\b|\.key\b|id_rsa|id_ed25519|credentials?|secrets?)")
ANSI_C_QUOTE_RE = re.compile(r"\$'")

Expand Down Expand Up @@ -135,10 +136,6 @@
VARIABLE_COMMAND_RE = re.compile(r"^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})$")

_FORBIDDEN_COMMAND_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(SEARCH_OR_DISCOVERY_RE, "shell search/file-discovery command is blocked; use dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll instead."),
(GLOBAL_CDIDX_RE, "global cdidx is blocked; use dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll instead, or the fully expanded installed path documented in CLOUD_BOOTSTRAP_PROMPT.md for no-SDK cloud bootstrap."),
(GIT_GREP_RE, "git grep is blocked; use dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll instead."),
(EVAL_RE, "eval is blocked; use a direct reviewed command or script file instead."),
(re.compile(r"(?i)\brm\s+-[^\n;|&]*r[^\n;|&]*f\b|\brm\s+-[^\n;|&]*f[^\n;|&]*r\b"), "recursive forced rm is blocked"),
(re.compile(r"(?i)\brm\s+-r\b"), "recursive rm is blocked"),
(re.compile(r"(?i)\b(?:rmdir|unlink|shred|srm|truncate)\b"), "destructive filesystem command is blocked"),
Expand All @@ -152,7 +149,6 @@
(re.compile(r"(?i)\b(?:curl|wget)\b.*\|\s*(?:sh|bash|zsh|python|ruby|perl)\b"), "download-and-execute is blocked"),
(re.compile(r"(?i)\b(?:ssh|scp|sftp|rsync|rclone|nc|ncat|netcat|socat|telnet|ftp)\b"), "remote shell/file transfer is blocked"),
(re.compile(r"(?i)\b(?:pbcopy|pbpaste)\b"), "clipboard access is blocked"),
(re.compile(r"(?i)\b(?:open|osascript|automator)\b|\bshortcuts\s+run\b"), "macOS automation/app launching is blocked"),
(re.compile(r"(?i)\b(?:launchctl|security|tccutil|spctl|csrutil|tmutil)\b"), "macOS security/system command is blocked"),
(re.compile(r"(?i)\bdefaults\s+write\b|\bplutil\s+-replace\b"), "macOS preference modification is blocked"),
(re.compile(r"(?i)\bgit\s+tag\b"), "git tag is blocked unless explicitly performed by the user"),
Expand Down Expand Up @@ -307,7 +303,11 @@ def _token_is_forbidden_cdidx_executable(token: str, cwd: Path) -> bool:

def _command_mentions_forbidden_cdidx_executable(command: str, cwd: Path) -> bool:
tokens = _split_command(command)
return any(_token_is_forbidden_cdidx_executable(token, cwd) for token in tokens)
for segment in _token_segments(tokens):
segment = _strip_transparent_script_wrappers(_strip_leading_env_assignments(segment))
if segment and _token_is_forbidden_cdidx_executable(segment[0], cwd):
return True
return False


def _command_is_safe_expanded_installed_cdidx(command: str, cwd: Path) -> bool:
Expand Down Expand Up @@ -542,11 +542,17 @@ def _tokenized_forbidden_tokens_reason(tokens: list[str]) -> str | None:
if _env_chdir_wrapper_present(tokens):
return "env chdir wrappers are blocked; run from the target cwd directly"

for index, token in enumerate(tokens):
for segment in _token_segments(tokens):
segment = _strip_transparent_script_wrappers(_strip_leading_env_assignments(segment))
while segment and segment[0] in _SHELL_COMMAND_PREFIX_KEYWORDS:
segment = _strip_transparent_script_wrappers(_strip_leading_env_assignments(segment[1:]))
if not segment:
continue
token = segment[0]
if not token or token.startswith("-") or _is_env_assignment(token):
continue
name = _token_command_name(token)
args = tokens[index + 1 :]
args = segment[1:]
if name in _SEARCH_OR_DISCOVERY_COMMANDS:
return "shell search/file-discovery command is blocked; use dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll instead."
if name in _NETWORK_COMMANDS:
Expand Down
41 changes: 41 additions & 0 deletions .agent_harness/tests/test_command_guard_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,31 @@ def test_denies_global_cdidx_and_search_tools(self) -> None:
decision = core.evaluate_bash_command(command, cwd=root, project_root=root)
self.assertFalse(decision.allowed)

def test_allows_forbidden_command_words_inside_gh_arguments(self) -> None:
root = Path("/tmp")
for command in (
'gh issue create --title "cdidx" --body "plain issue text"',
'gh issue create --title "find and open are issue words" --body "cdidx find appears in docs"',
'gh issue list --state open --search "grep in the issue body"',
'gh pr create --title "search: find hint" --body "users may type git grep or open here"',
):
with self.subTest(command=command):
decision = core.evaluate_bash_command(command, cwd=root, project_root=root)

self.assertTrue(decision.allowed, decision.reason)

def test_denies_forbidden_commands_inside_shell_constructs(self) -> None:
root = Path("/tmp")
for command in (
"if grep SymbolExtractor src; then echo yes; fi",
"while git grep SymbolExtractor; do break; done",
"until find . -name '*.cs'; do break; done",
):
with self.subTest(command=command):
decision = core.evaluate_bash_command(command, cwd=root, project_root=root)

self.assertFalse(decision.allowed)

def test_denies_quote_concatenated_high_risk_commands(self) -> None:
root = Path("/tmp")
for command in (
Expand Down Expand Up @@ -371,6 +396,22 @@ def test_check_script_file_denies_forbidden_content(self) -> None:

self.assertFalse(decision.allowed)

def test_check_script_file_denies_search_commands_inside_shell_constructs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
script = root / "tools" / "guard.sh"
script.parent.mkdir(parents=True, exist_ok=True)

for body in (
"if grep SymbolExtractor src; then echo yes; fi\n",
"if git grep SymbolExtractor; then echo yes; fi\n",
):
with self.subTest(body=body):
script.write_text(body, encoding="utf-8")
decision = core.check_script_file(script, project_root=root)

self.assertFalse(decision.allowed)

def test_check_script_file_denies_quote_concatenated_forbidden_content(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Output controls:
| Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date/process-stamped log files. Use `--log-format text|json`, `--log-retain-count <N>`, `--log-max-size-mb <N>`, `CDIDX_LOG_*`, or `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` to make lifecycle logs JSONL-friendly and rotate them for aggregation. |
| Checked-in configuration | Use `.cdidx/config.json` for repository defaults such as `search.limit`, `search.snippet_lines`, and `search.max_line_width`; run `cdidx validate-config` to validate the discovered file and `cdidx config show` to inspect precedence. |
| Workspaces | Use `cdidx.workspace.json` or `.cdidx-workspace.json` to declare monorepo members, `cdidx workspace list` to inspect them, and `cdidx workspace use <name>` / `cdidx workspace current` for a persisted active workspace. |
| ASCII-only terminal output | Use `--ascii`, `CDIDX_ASCII=1`, `NO_UNICODE`, `TERM=dumb`, accessibility env hints, or a non-UTF-8 locale. Spinners use pipe, slash, dash, and backslash frames; progress bars use `#` / `-`; very narrow terminals fall back to percentage-only progress. |
| ASCII-only terminal output | Use `--ascii`, `CDIDX_ASCII=1`, `NO_UNICODE`, `TERM=dumb`, accessibility env hints, or a non-UTF-8 locale. Spinners use pipe, slash, dash, and backslash frames; progress bars use `#` / `-`; very narrow terminals fall back to percentage-only progress. Use `--no-progress`, `CDIDX_DISABLE_PROGRESS=1`, or `PREFERS_REDUCED_MOTION` to keep static progress text without animation. |
| Color and terminal capability | `--color auto` emits ANSI only for capable interactive terminals; `TERM=dumb`, `CI=true`, missing Unix terminal hints, `NO_COLOR`, or `CLICOLOR=0` disable ANSI/progress control sequences. `--palette basic|256|truecolor` can override the `COLORTERM` / `TERM` color-depth detection. |
| UTF-8 JSON pipelines | CLI `--json` output is written as UTF-8 without a BOM and never includes ANSI escape sequences, even when color is forced for human output. |
| Script-friendly query pipelines | Use `--quiet`, `-q`, `--silent`, or `CDIDX_QUIET=1` to suppress informational stderr text while preserving errors. `--quiet` takes precedence over `--verbose`. Read commands that support `--format` can emit `count`, `compact`, `csv`, or `tsv` output when callers need smaller or table-shaped payloads instead of full excerpts. |
Expand Down Expand Up @@ -349,7 +349,7 @@ extractor fixture を確認できます。詳細は
| POSIX の persistent stderr log を owner-only にする | global tool stderr log は開くたびに `0600` 権限へ補正され、既存の日付付き log file も同じ扱いになります。 |
| checked-in configuration | repository 既定値には `.cdidx/config.json` を使えます。例: `search.limit`、`search.snippet_lines`、`search.max_line_width`。検出された file は `cdidx validate-config` で検証でき、`cdidx config show` で優先順位を確認できます。 |
| workspaces | monorepo member は `cdidx.workspace.json` または `.cdidx-workspace.json` で宣言し、`cdidx workspace list` で確認できます。`cdidx workspace use <name>` / `cdidx workspace current` は永続 active workspace を扱います。 |
| ASCII-only 端末で崩れない表示にする | `--ascii`、`CDIDX_ASCII=1`、`NO_UNICODE`、`TERM=dumb`、accessibility 系の環境変数、非 UTF-8 locale を使います。スピナーは pipe、slash、dash、backslash の frame、進捗バーは `#` / `-` になり、幅が非常に狭い端末では percentage-only になります。 |
| ASCII-only 端末で崩れない表示にする | `--ascii`、`CDIDX_ASCII=1`、`NO_UNICODE`、`TERM=dumb`、accessibility 系の環境変数、非 UTF-8 locale を使います。スピナーは pipe、slash、dash、backslash の frame、進捗バーは `#` / `-` になり、幅が非常に狭い端末では percentage-only になります。`--no-progress`、`CDIDX_DISABLE_PROGRESS=1`、`PREFERS_REDUCED_MOTION` を使うと animation なしの静的な進捗表示になります。 |
| color と端末 capability | `--color auto` は対応する interactive terminal でだけ ANSI を出力します。`TERM=dumb`、`CI=true`、Unix で端末 hint が無い場合、`NO_COLOR`、`CLICOLOR=0` では ANSI / progress 制御シーケンスを抑止します。`--palette basic|256|truecolor` で `COLORTERM` / `TERM` による color-depth 判定を上書きできます。 |
| UTF-8 JSON pipeline | CLI の `--json` 出力は BOM なし UTF-8 で書き出され、human output 向けに色を強制していても ANSI escape sequence を含みません。 |
| script 向け query pipeline の stderr を静かにする | `--quiet`、`-q`、`--silent`、`CDIDX_QUIET=1` で informational stderr を抑制し、error 行だけを残します。`--quiet` は `--verbose` より優先されます。 |
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1429.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 1429
affected:
- src/CodeIndex/Cli/QueryCommandRunner.cs
- src/CodeIndex/Cli/CliFlagSchema.cs
- tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
---

## English

- **`excerpt` accepts MCP-style line aliases (#1429)** - `cdidx excerpt` now accepts `--start-line` and `--end-line` as aliases for `--start` and `--end`, reducing friction when moving between MCP and CLI usage.

## 日本語

- **`excerpt` が MCP 形式の行番号 alias を受け付けるようになりました (#1429)** - `cdidx excerpt` は `--start` / `--end` の alias として `--start-line` / `--end-line` を受け付けるようになり、MCP と CLI の間を行き来するときのつまずきを減らします。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1432.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 1432
affected:
- .agent_harness/command_guard_core.py
- .agent_harness/tests/test_command_guard_core.py
---

## English

- **Agent bash guard no longer blocks command-name words inside GitHub arguments (#1432)** - the shared guard now applies command-name checks to executed command tokens instead of quoted title/body text, so `gh issue` and `gh pr` workflows can mention words such as `find`, `open`, `grep`, or `cdidx`.

## 日本語

- **エージェント用 bash guard が GitHub 引数内のコマンド名単語で誤拒否しなくなりました (#1432)** - 共有 guard は quoted title/body ではなく実行されるコマンド token に対してコマンド名チェックを行うため、`gh issue` や `gh pr` の本文で `find`、`open`、`grep`、`cdidx` などの語を扱えます。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/1630.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: added
issues:
- 1630
affected:
- src/CodeIndex/Cli/ConsoleUi.cs
- src/CodeIndex/Cli/ProgramRunner.cs
- src/CodeIndex/Cli/CliFlagSchema.cs
- README.md
- tests/CodeIndex.Tests/ConsoleUiTests.cs
---

## English

- **Progress animation can now be disabled (#1630)** - `cdidx` honors `--no-progress`, `CDIDX_DISABLE_PROGRESS=1`, and `PREFERS_REDUCED_MOTION` by printing static progress text instead of animated spinner frames.

## 日本語

- **進捗 animation を無効化できるようになりました (#1630)** - `cdidx` は `--no-progress`、`CDIDX_DISABLE_PROGRESS=1`、`PREFERS_REDUCED_MOTION` を尊重し、animated spinner frame の代わりに静的な進捗テキストを表示します。
3 changes: 3 additions & 0 deletions src/CodeIndex/Cli/CliFlagSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ private static IReadOnlyList<CliFlag> BuildAll()
new() { Name = "--exact-name", Description = "Exact symbol-name equality", Commands = Set(ExactNameCommands), AlsoAcceptedBy = Set("search") },
new() { Name = "--exact-substring", Description = "Search-only exact substring match", Commands = Set("search"), AlsoAcceptedBy = Set(ExactSubstringAccepted) },
new() { Name = "--prefix", Description = "Trailing-asterisk prefix shorthand", Commands = Set("search") },
new() { Name = "--no-progress", Description = "Disable animated progress and spinner output", Commands = Set(AllCommands.ToArray()) },
new() { Name = "--name", ValuePlaceholder = "<name>", Description = "Exact symbol name", Commands = Set("symbols") },
new() { Name = "--max-line-width", ValuePlaceholder = "<n>", Description = "Clamp long single-line payloads (0 disables clamping)", Commands = Set(MaxLineWidthCommands) },
new() { Name = "--snippet-lines", ValuePlaceholder = "<n>", Description = "Snippet length", Commands = Set("search", "find", "references", "callers", "callees", "impact") },
Expand All @@ -256,7 +257,9 @@ private static IReadOnlyList<CliFlag> BuildAll()
new() { Name = "--before", ValuePlaceholder = "<n>", Description = "Context lines before", Commands = Set("find", "excerpt") },
new() { Name = "--after", ValuePlaceholder = "<n>", Description = "Context lines after", Commands = Set("find", "excerpt") },
new() { Name = "--start", ValuePlaceholder = "<line>", Description = "Start line", Commands = Set("excerpt") },
new() { Name = "--start-line", ValuePlaceholder = "<line>", Description = "Alias for --start", Commands = Set("excerpt") },
new() { Name = "--end", ValuePlaceholder = "<line>", Description = "End line", Commands = Set("excerpt") },
new() { Name = "--end-line", ValuePlaceholder = "<line>", Description = "Alias for --end", Commands = Set("excerpt") },
new() { Name = "--focus-line", ValuePlaceholder = "<line>", Description = "Focused line to keep visible when clamping", Commands = Set("find", "excerpt") },
new() { Name = "--focus-column", ValuePlaceholder = "<n>", Description = "Focused column to keep visible when clamping", Commands = Set("find", "excerpt") },
new() { Name = "--focus-length", ValuePlaceholder = "<n>", Description = "Focused span width when clamping", Commands = Set("excerpt") },
Expand Down
Loading
Loading