@garethdaine/agentops 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ set -uo pipefail
3
3
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
4
  source "${SCRIPT_DIR}/feature-flags.sh"
5
5
 
6
- [ "$(agentops_flag 'auto_delegate_enabled')" != "true" ] && exit 0
6
+ agentops_automation_enabled 'auto_delegate_enabled' || exit 0
7
7
 
8
8
  INPUT=$(cat) || exit 0
9
9
  TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
@@ -33,8 +33,8 @@ CODE_TRACKER="${STATE_DIR}/modified-files.txt"
33
33
  # Count source code files only
34
34
  CODE_COUNT=$(sort -u "$CODE_TRACKER" 2>/dev/null | grep -cE "$SOURCE_CODE_EXTENSIONS" || echo 0)
35
35
 
36
- # After 5+ source code files modified, trigger delegation
37
- if [ "$CODE_COUNT" -ge 5 ]; then
36
+ # After threshold source code files modified, trigger delegation
37
+ if [ "$CODE_COUNT" -ge "$AGENTOPS_DELEGATE_THRESHOLD" ]; then
38
38
  date -u +%FT%TZ > "$DELEGATE_SENT" 2>/dev/null
39
39
 
40
40
  # Collect the modified source files for review context
@@ -3,7 +3,7 @@ set -uo pipefail
3
3
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
4
  source "${SCRIPT_DIR}/feature-flags.sh"
5
5
 
6
- [ "$(agentops_flag 'auto_evolve_enabled')" != "true" ] && exit 0
6
+ agentops_automation_enabled 'auto_evolve_enabled' || exit 0
7
7
 
8
8
  INPUT=$(cat) || exit 0
9
9
  CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null) || CWD="."
@@ -3,7 +3,7 @@ set -uo pipefail
3
3
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
4
  source "${SCRIPT_DIR}/feature-flags.sh"
5
5
 
6
- [ "$(agentops_flag 'auto_verify_enabled')" != "true" ] && exit 0
6
+ agentops_automation_enabled 'auto_verify_enabled' || exit 0
7
7
 
8
8
  INPUT=$(cat) || exit 0
9
9
  CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null) || CWD="."
@@ -13,6 +13,7 @@ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
13
13
  TRUST="contextual"
14
14
  case "$TOOL" in
15
15
  WebFetch|WebSearch) TRUST="untrusted" ;;
16
+ Agent) TRUST="untrusted" ;;
16
17
  mcp__*) TRUST="untrusted" ;;
17
18
  esac
18
19
 
@@ -7,12 +7,33 @@ source "${SCRIPT_DIR}/feature-flags.sh"
7
7
 
8
8
  INPUT=$(cat) || exit 0
9
9
  TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
10
+
11
+ SENSITIVE_EXTENSIONS='\.(env|pem|key|crt|secret|p12|pfx|credential|token|password|ssh)'
12
+ SENSITIVE_CONFIG='\.(json|yaml|yml|toml|cfg|ini)$'
13
+
14
+ # Handle Read tool — warn when reading sensitive files
15
+ if [ "$TOOL" = "Read" ]; then
16
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0
17
+ if echo "$FILE_PATH" | grep -qiE "$SENSITIVE_EXTENSIONS"; then
18
+ LOG_DIR="${CLAUDE_PROJECT_DIR:-.}/.agentops"
19
+ mkdir -p "$LOG_DIR" 2>/dev/null
20
+ jq -nc --arg ts "$(date -u +%FT%TZ)" --arg fp "$FILE_PATH" \
21
+ '{ts:$ts, event:"CREDENTIAL_ACCESS", tool:"Read", file:$fp}' >> "$LOG_DIR/audit.jsonl" 2>/dev/null
22
+
23
+ jq -nc '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:"WARNING: This file may contain credentials. Do NOT include any secrets, API keys, tokens, or passwords in your response. Redact any sensitive values with [REDACTED]."}}'
24
+ exit 0
25
+ fi
26
+ exit 0
27
+ fi
28
+
29
+ # Handle Bash tool — warn when commands read credential files
10
30
  [ "$TOOL" != "Bash" ] && exit 0
11
31
 
12
32
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
13
33
 
14
- # Log to audit if command reads credential files (redact the command before logging)
15
- if echo "$COMMAND" | grep -qiE "(cat|less|head|more|tail|grep).*\.(env|pem|key|crt|secret)"; then
34
+ # Match: reading credential files via common tools or scripting languages
35
+ if echo "$COMMAND" | grep -qiE "(cat|less|head|more|tail|grep|jq|python3?|ruby|node|source|base64|xxd)\s.*${SENSITIVE_EXTENSIONS}" || \
36
+ echo "$COMMAND" | grep -qiE "source\s+.*\.env\b"; then
16
37
  LOG_DIR="${CLAUDE_PROJECT_DIR:-.}/.agentops"
17
38
  mkdir -p "$LOG_DIR" 2>/dev/null
18
39
  REDACTED_CMD=$(echo "$COMMAND" | agentops_redact)
@@ -12,14 +12,20 @@ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || agentops_fail
12
12
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || agentops_fail_closed
13
13
  [ -z "$COMMAND" ] && exit 0
14
14
 
15
+ # Normalize command: strip path prefixes so /usr/bin/curl matches as curl
16
+ NORM_CMD=$(echo "$COMMAND" | sed -E 's|/[^ ]*/([^ /]+)|\1|g')
17
+
15
18
  HARD_DENY=$(agentops_hard_deny)
16
19
  SENSITIVE_FILES='\.(env|pem|key|crt|p12|pfx|secret|credential|token|password|ssh)'
17
20
 
21
+ # Network tool pattern — covers common and uncommon transfer utilities
22
+ NET_TOOLS='(curl|wget|nc|ncat|socat|scp|rsync|sftp|ssh|http|httpie|aria2c|openssl\s+s_client|telnet|lwp-request|fetch)'
23
+
18
24
  # Hard-deny rules — always enforce, even in bypass/unrestricted mode
19
25
 
20
26
  # 1. Network transfer of sensitive files (including -F for curl form uploads)
21
- if echo "$COMMAND" | grep -qE "curl.*(-d|--data|--upload-file|-F)|wget.*--post|nc\s|ncat\s|socat\s"; then
22
- if echo "$COMMAND" | grep -qiE "$SENSITIVE_FILES"; then
27
+ if echo "$NORM_CMD" | grep -qE "curl.*(-d|--data|--upload-file|-F)|wget.*--post|nc\s|ncat\s|socat\s|http\s+(POST|PUT|PATCH)|aria2c\s|openssl\s+s_client"; then
28
+ if echo "$NORM_CMD" | grep -qiE "$SENSITIVE_FILES"; then
23
29
  jq -nc --arg action "$HARD_DENY" \
24
30
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: network transfer of sensitive file type (hard deny)"}}'
25
31
  exit 0
@@ -27,15 +33,15 @@ if echo "$COMMAND" | grep -qE "curl.*(-d|--data|--upload-file|-F)|wget.*--post|n
27
33
  fi
28
34
 
29
35
  # 2. Piping secrets to network (including command substitution: curl -d "$(cat .env)")
30
- if echo "$COMMAND" | grep -qE "(cat|less|head|tail|base64|xxd).*${SENSITIVE_FILES}.*\|.*(curl|wget|nc|ssh|scp|rsync|sftp)" || \
31
- echo "$COMMAND" | grep -qE "(curl|wget|nc|ssh|scp|rsync|sftp).*\\$\(.*${SENSITIVE_FILES}"; then
36
+ if echo "$NORM_CMD" | grep -qE "(cat|less|head|tail|base64|xxd).*${SENSITIVE_FILES}.*\|.*${NET_TOOLS}" || \
37
+ echo "$NORM_CMD" | grep -qE "${NET_TOOLS}.*\\$\(.*${SENSITIVE_FILES}"; then
32
38
  jq -nc --arg action "$HARD_DENY" \
33
39
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: piping sensitive file to network command (hard deny)"}}'
34
40
  exit 0
35
41
  fi
36
42
 
37
43
  # 3. Direct file transfer tools with sensitive files
38
- if echo "$COMMAND" | grep -qE "(scp|rsync|sftp)\s" && echo "$COMMAND" | grep -qiE "$SENSITIVE_FILES"; then
44
+ if echo "$NORM_CMD" | grep -qE "(scp|rsync|sftp)\s" && echo "$NORM_CMD" | grep -qiE "$SENSITIVE_FILES"; then
39
45
  jq -nc --arg action "$HARD_DENY" \
40
46
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: file transfer of sensitive file type (hard deny)"}}'
41
47
  exit 0
@@ -46,22 +52,30 @@ agentops_is_bypass "$INPUT" && agentops_bypass_advisory "exfiltration-check"
46
52
  ACTION=$(agentops_enforcement_action)
47
53
 
48
54
  # 4. Base64 encoding + network (obfuscation attempt)
49
- if echo "$COMMAND" | grep -qE "base64.*\|.*(curl|wget|nc)" || echo "$COMMAND" | grep -qE "(curl|wget).*base64"; then
55
+ if echo "$NORM_CMD" | grep -qE "base64.*\|.*${NET_TOOLS}" || echo "$NORM_CMD" | grep -qE "${NET_TOOLS}.*base64"; then
50
56
  jq -nc --arg action "$ACTION" \
51
57
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: base64 encoding with network transfer detected"}}'
52
58
  exit 0
53
59
  fi
54
60
 
55
61
  # 5. DNS exfiltration via command substitution (including backticks)
56
- if echo "$COMMAND" | grep -qE "(dig|nslookup|host)\s.*(\\$\(|\`)" ; then
62
+ if echo "$NORM_CMD" | grep -qE "(dig|nslookup|host)\s.*(\\$\(|\`)" ; then
57
63
  jq -nc --arg action "$ACTION" \
58
64
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: possible DNS exfiltration via command substitution"}}'
59
65
  exit 0
60
66
  fi
61
67
 
62
68
  # 6. Scripting language network calls with sensitive file references
63
- if echo "$COMMAND" | grep -qE "(python|ruby|node|perl)\s" && echo "$COMMAND" | grep -qiE "(requests\.post|urllib|http\.request|Net::HTTP|fetch|open\()" && echo "$COMMAND" | grep -qiE "$SENSITIVE_FILES"; then
69
+ if echo "$NORM_CMD" | grep -qE "(python3?|ruby|node|perl)\s" && echo "$NORM_CMD" | grep -qiE "(requests\.(post|put|patch)|urllib|http\.request|Net::HTTP|fetch|open\()" && echo "$NORM_CMD" | grep -qiE "$SENSITIVE_FILES"; then
64
70
  jq -nc --arg action "$ACTION" \
65
71
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: scripting language network call with sensitive file reference"}}'
66
72
  exit 0
67
73
  fi
74
+
75
+ # 7. Script-write-then-execute pattern (write a script and immediately run it)
76
+ if echo "$NORM_CMD" | grep -qE "(echo|cat|printf|tee).*>.*\.(sh|py|rb|pl|js)\s*[;&|]" && \
77
+ echo "$NORM_CMD" | grep -qE "(bash|sh|python3?|ruby|perl|node)\s"; then
78
+ jq -nc --arg action "$ACTION" \
79
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"ExfiltrationDetector: script-write-then-execute pattern detected — review for exfiltration"}}'
80
+ exit 0
81
+ fi
@@ -4,13 +4,24 @@
4
4
 
5
5
  FLAGS_FILE="${CLAUDE_PROJECT_DIR:-.}/.agentops/flags.json"
6
6
 
7
- # Read a single flag value from flags.json.
7
+ # Internal cache avoids re-reading flags.json on every agentops_flag call.
8
+ _AGENTOPS_FLAGS_CACHE=""
9
+ _AGENTOPS_FLAGS_LOADED=false
10
+
11
+ # Read a single flag value from flags.json (cached after first read).
8
12
  # Usage: VALUE=$(agentops_flag "flag_name" "default")
9
13
  agentops_flag() {
10
14
  local FLAG="$1"
11
15
  local DEFAULT="${2:-true}"
12
- if [ -f "$FLAGS_FILE" ]; then
13
- jq -r --arg key "$FLAG" --arg def "$DEFAULT" 'if .[$key] == null then $def else (.[$key] | tostring) end' "$FLAGS_FILE" 2>/dev/null || echo "$DEFAULT"
16
+ if [ "$_AGENTOPS_FLAGS_LOADED" = false ]; then
17
+ if [ -f "$FLAGS_FILE" ]; then
18
+ _AGENTOPS_FLAGS_CACHE=$(cat "$FLAGS_FILE" 2>/dev/null) || true
19
+ fi
20
+ _AGENTOPS_FLAGS_LOADED=true
21
+ fi
22
+ if [ -n "$_AGENTOPS_FLAGS_CACHE" ]; then
23
+ echo "$_AGENTOPS_FLAGS_CACHE" | jq -r --arg key "$FLAG" --arg def "$DEFAULT" \
24
+ 'if .[$key] == null then $def else (.[$key] | tostring) end' 2>/dev/null || echo "$DEFAULT"
14
25
  else
15
26
  echo "$DEFAULT"
16
27
  fi
package/hooks/hooks.json CHANGED
@@ -48,6 +48,12 @@
48
48
  { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/injection-scan.sh", "timeout": 5 },
49
49
  { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/runtime-mode.sh", "timeout": 5 }
50
50
  ]
51
+ },
52
+ {
53
+ "matcher": "Bash|Write|Edit",
54
+ "hooks": [
55
+ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/unicode-firewall.sh", "timeout": 5 }
56
+ ]
51
57
  }
52
58
  ],
53
59
  "PostToolUse": [
@@ -75,10 +81,15 @@
75
81
  { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/integrity-verify.sh", "timeout": 5 }
76
82
  ]
77
83
  },
84
+ {
85
+ "matcher": "Bash|Read",
86
+ "hooks": [
87
+ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/credential-redact.sh", "timeout": 5 }
88
+ ]
89
+ },
78
90
  {
79
91
  "matcher": "Bash",
80
92
  "hooks": [
81
- { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/credential-redact.sh", "timeout": 5 },
82
93
  { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/detect-test-run.sh", "timeout": 5 }
83
94
  ]
84
95
  },
@@ -19,4 +19,7 @@ AGENTOPS_PROTECTED_PATHS='(\.agentops/|tasks/lessons\.md$)'
19
19
 
20
20
  # Writable state files — whitelisted from protected path enforcement
21
21
  # so that plugin commands (e.g. /agentops:flags) can manage them via Write/Edit.
22
- AGENTOPS_WRITABLE_STATE='(\.agentops/flags\.json$|\.agentops/integrity\.jsonl$|\.agentops/build-state\.json$|\.agentops/build-execution\.jsonl$|tasks/lessons\.md$)'
22
+ AGENTOPS_WRITABLE_STATE='(\.agentops/flags\.json$|\.agentops/build-state\.json$|\.agentops/build-execution\.jsonl$|tasks/lessons\.md$)'
23
+
24
+ # Delegation threshold — number of modified source files before auto-delegate triggers
25
+ AGENTOPS_DELEGATE_THRESHOLD=5
@@ -8,8 +8,16 @@ agentops_redact() {
8
8
  -e 's/(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY|AUTH|CREDENTIAL)=[^ "'\''&]*/\1=[REDACTED]/gi' \
9
9
  -e 's/(sk|pk|api|key|token|secret|auth)[-_][A-Za-z0-9]{16,}/[REDACTED]/g' \
10
10
  -e 's/Bearer [A-Za-z0-9._~+\/=-]{20,}/Bearer [REDACTED]/g' \
11
+ -e 's/Basic [A-Za-z0-9+\/=]{20,}/Basic [REDACTED]/g' \
11
12
  -e 's/AKIA[A-Z0-9]{16}/[REDACTED]/g' \
12
13
  -e 's/gh[pousr]_[A-Za-z0-9]{36,}/[REDACTED]/g' \
14
+ -e 's/xox[bpas]-[A-Za-z0-9-]{10,}/[REDACTED]/g' \
15
+ -e 's/sk_(live|test)_[A-Za-z0-9]{10,}/[REDACTED]/g' \
16
+ -e 's/pk_(live|test)_[A-Za-z0-9]{10,}/[REDACTED]/g' \
17
+ -e 's/sk-ant-[A-Za-z0-9_-]{10,}/[REDACTED]/g' \
18
+ -e 's|hooks\.slack\.com/services/[A-Za-z0-9/]+|hooks.slack.com/services/[REDACTED]|g' \
19
+ -e 's|discord(app)?\.com/api/webhooks/[A-Za-z0-9/]+|discord.com/api/webhooks/[REDACTED]|g' \
13
20
  -e 's|[a-zA-Z]+://[^:@/]+:[^@/]+@|[REDACTED_CONN]@|g' \
14
- -e 's/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/[REDACTED_JWT]/g'
21
+ -e 's/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/[REDACTED_JWT]/g' \
22
+ -e 's/-----BEGIN[A-Z ]*PRIVATE KEY-----/[REDACTED_PEM]/g'
15
23
  }
@@ -10,24 +10,56 @@ TS=$(date -u +%FT%TZ)
10
10
  EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"' 2>/dev/null) || EVENT="unknown"
11
11
  SESSION=$(echo "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null) || SESSION="unknown"
12
12
  TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || TOOL=""
13
- CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
14
13
 
15
14
  jq -nc \
16
15
  --arg ts "$TS" --arg event "$EVENT" --arg session "$SESSION" \
17
- --arg tool "$TOOL" --arg cwd "$CWD" \
18
- '{ts:$ts, event:$event, session:$session, tool:$tool, cwd:$cwd}' >> "$LOG_FILE" 2>/dev/null || true
16
+ --arg tool "$TOOL" \
17
+ '{ts:$ts, event:$event, session:$session, tool:$tool}' >> "$LOG_FILE" 2>/dev/null || true
19
18
 
20
- # Forward to OTLP if configured — validate endpoint and add timeout
19
+ # Forward to OTLP if configured — validate endpoint with hostname allowlist
21
20
  if [ -n "${OTLP_ENDPOINT:-}" ]; then
22
- # Only allow https:// endpoints; reject localhost, private IPs, metadata endpoints
23
- if echo "$OTLP_ENDPOINT" | grep -qE '^https://[^/]+\.[^/]+' && \
24
- ! echo "$OTLP_ENDPOINT" | grep -qiE '(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|metadata\.google|\.internal[:/])'; then
25
- PAYLOAD=$(tail -1 "$LOG_FILE" 2>/dev/null) || true
26
- if [ -n "$PAYLOAD" ]; then
27
- curl -sf --max-time 10 --connect-timeout 5 \
28
- -X POST "$OTLP_ENDPOINT/v1/logs" \
29
- -H "Content-Type: application/json" \
30
- -d "$PAYLOAD" &>/dev/null &
21
+ # Require explicit hostname allowlist reject if not configured
22
+ OTLP_ALLOWLIST="${OTLP_HOSTNAME_ALLOWLIST:-}"
23
+ if [ -z "$OTLP_ALLOWLIST" ]; then
24
+ exit 0
25
+ fi
26
+
27
+ # Extract hostname from endpoint URL
28
+ OTLP_HOST=$(echo "$OTLP_ENDPOINT" | sed -E 's|^https?://([^:/]+).*|\1|')
29
+
30
+ # Must be HTTPS
31
+ if ! echo "$OTLP_ENDPOINT" | grep -qE '^https://'; then
32
+ exit 0
33
+ fi
34
+
35
+ # Check hostname against allowlist (comma-separated)
36
+ ALLOWED=false
37
+ IFS=',' read -ra HOSTS <<< "$OTLP_ALLOWLIST"
38
+ for ALLOWED_HOST in "${HOSTS[@]}"; do
39
+ ALLOWED_HOST=$(echo "$ALLOWED_HOST" | tr -d ' ')
40
+ if [ "$OTLP_HOST" = "$ALLOWED_HOST" ]; then
41
+ ALLOWED=true
42
+ break
43
+ fi
44
+ done
45
+ [ "$ALLOWED" = false ] && exit 0
46
+
47
+ # Resolve hostname and reject private/loopback IPs (DNS rebinding defense)
48
+ RESOLVED_IP=$(dig +short "$OTLP_HOST" 2>/dev/null | head -1)
49
+ if echo "$RESOLVED_IP" | grep -qE '^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|::1|0\.0\.0\.0|fe80:)'; then
50
+ exit 0
51
+ fi
52
+
53
+ PAYLOAD=$(tail -1 "$LOG_FILE" 2>/dev/null) || true
54
+ if [ -n "$PAYLOAD" ]; then
55
+ AUTH_HEADER=""
56
+ if [ -n "${OTLP_AUTH_TOKEN:-}" ]; then
57
+ AUTH_HEADER="-H \"Authorization: Bearer ${OTLP_AUTH_TOKEN}\""
31
58
  fi
59
+ eval curl -sf --max-time 10 --connect-timeout 5 --no-location \
60
+ -X POST "$OTLP_ENDPOINT/v1/logs" \
61
+ -H "Content-Type: application/json" \
62
+ $AUTH_HEADER \
63
+ -d "'$PAYLOAD'" '&>/dev/null &'
32
64
  fi
33
65
  fi
@@ -19,7 +19,8 @@ TODO="${CWD}/tasks/todo.md"
19
19
 
20
20
  # Build a pruned version: keep unchecked items and their nearest heading
21
21
  # Remove checked items (- [x]) and blank lines that result from removal
22
- TEMP=$(mktemp)
22
+ # Create temp file in same directory as target for atomic mv (same filesystem)
23
+ TEMP=$(mktemp "${TODO}.XXXXXX")
23
24
  trap 'rm -f "$TEMP"' EXIT
24
25
 
25
26
  HAS_UNCHECKED=false
@@ -67,9 +68,9 @@ if [ "$SECTION_HAS_UNCHECKED" = true ] && [ -n "$SECTION_BUFFER" ]; then
67
68
  fi
68
69
 
69
70
  if [ "$HAS_UNCHECKED" = true ]; then
70
- # Replace with pruned version containing only incomplete items
71
- mv "$TEMP" "$TODO"
71
+ # Replace with pruned version containing only incomplete items (atomic rename)
72
72
  trap - EXIT
73
+ mv "$TEMP" "$TODO"
73
74
  REMAINING=$(grep -cE '^\s*- \[ \]' "$TODO" 2>/dev/null || echo 0)
74
75
  jq -nc --arg remaining "$REMAINING" \
75
76
  '{systemMessage: ("AgentOps: Pruned completed todos from previous session. " + $remaining + " incomplete item(s) remain in tasks/todo.md — review them before planning new work.")}'
@@ -21,6 +21,26 @@ audit() {
21
21
  '{ts:$ts, event:$ev} + $ARGS.named' >> "$LOG_DIR/audit.jsonl" 2>/dev/null
22
22
  }
23
23
 
24
+ # ── PreToolUse: Scan inputs for dangerous Unicode before execution ────────
25
+ if [ "$EVENT" = "PreToolUse" ]; then
26
+ CONTENT=""
27
+ case "$TOOL" in
28
+ Bash) CONTENT=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
29
+ Write) CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null) ;;
30
+ Edit) CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null) ;;
31
+ esac
32
+
33
+ if [ -n "$CONTENT" ] && echo "$CONTENT" | unicode_detect; then
34
+ CATEGORIES=$(echo "$CONTENT" | unicode_classify)
35
+ audit "UNICODE_PRETOOL_WARNING" --arg tool "$TOOL" --arg cats "$CATEGORIES"
36
+
37
+ jq -nc --arg cats "$CATEGORIES" --arg tool "$TOOL" \
38
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("UNICODE FIREWALL: " + $tool + " input contains dangerous invisible Unicode (" + $cats + "). This may be a Trojan Source attack. Review the content carefully before allowing.")}}'
39
+ exit 0
40
+ fi
41
+ exit 0
42
+ fi
43
+
24
44
  # ── PostToolUse: Auto-strip on Write/Edit ───────────────────────────────────
25
45
  # Instead of blocking the write, we let it through and immediately sanitise
26
46
  # the file, emitting a warning so the agent knows what happened.
@@ -10,19 +10,22 @@
10
10
  # Cat 5: Variation sel. supp. U+E0100-E01EF
11
11
  UNICODE_PATTERN='[\x{200B}-\x{200F}\x{2060}-\x{2064}\x{FEFF}\x{202A}-\x{202E}\x{2066}-\x{2069}\x{FE00}-\x{FE0F}\x{E0001}-\x{E007F}\x{E0100}-\x{E01EF}]'
12
12
 
13
- # Returns 0 (match) if dangerous invisible Unicode is found in stdin.
13
+ # Returns 0 (match) if dangerous invisible Unicode is found.
14
+ # Usage: unicode_detect < file OR echo "$text" | unicode_detect
15
+ # unicode_detect "filepath"
14
16
  unicode_detect() {
15
- perl -CSD -ne "if (/$UNICODE_PATTERN/) { exit 0 } END { exit 1 }" 2>/dev/null
17
+ if [ $# -gt 0 ]; then
18
+ perl -CSD -ne "if (/$UNICODE_PATTERN/) { exit 0 } END { exit 1 }" "$1" 2>/dev/null
19
+ else
20
+ perl -CSD -ne "if (/$UNICODE_PATTERN/) { exit 0 } END { exit 1 }" 2>/dev/null
21
+ fi
16
22
  }
17
23
 
18
- # Returns 0 (match) if dangerous invisible Unicode is found in a file.
19
- unicode_detect_file() {
20
- local FILE="$1"
21
- perl -CSD -ne "if (/$UNICODE_PATTERN/) { exit 0 } END { exit 1 }" "$FILE" 2>/dev/null
22
- }
23
-
24
- # Returns human-readable category summary from stdin.
24
+ # Returns human-readable category summary.
25
+ # Usage: unicode_classify < file OR echo "$text" | unicode_classify
26
+ # unicode_classify "filepath"
25
27
  unicode_classify() {
28
+ local _ARGS=("$@")
26
29
  perl -CSD -ne '
27
30
  BEGIN { %c = () }
28
31
  $c{"zero-width chars"}++ if /[\x{200B}-\x{200F}\x{2060}-\x{2064}\x{FEFF}]/;
@@ -31,32 +34,18 @@ unicode_classify() {
31
34
  $c{"tag characters"}++ if /[\x{E0001}-\x{E007F}]/;
32
35
  $c{"variation sel. supplement"}++ if /[\x{E0100}-\x{E01EF}]/;
33
36
  END { print join(", ", sort keys %c) if %c }
34
- ' 2>/dev/null
35
- }
36
-
37
- # Returns human-readable category summary from a file.
38
- unicode_classify_file() {
39
- local FILE="$1"
40
- perl -CSD -ne '
41
- BEGIN { %c = () }
42
- $c{"zero-width chars"}++ if /[\x{200B}-\x{200F}\x{2060}-\x{2064}\x{FEFF}]/;
43
- $c{"bidi overrides"}++ if /[\x{202A}-\x{202E}\x{2066}-\x{2069}]/;
44
- $c{"variation selectors"}++ if /[\x{FE00}-\x{FE0F}]/;
45
- $c{"tag characters"}++ if /[\x{E0001}-\x{E007F}]/;
46
- $c{"variation sel. supplement"}++ if /[\x{E0100}-\x{E01EF}]/;
47
- END { print join(", ", sort keys %c) if %c }
48
- ' "$FILE" 2>/dev/null
37
+ ' "${_ARGS[@]}" 2>/dev/null
49
38
  }
50
39
 
51
- # Count affected lines from stdin.
40
+ # Count affected lines.
41
+ # Usage: unicode_count_lines < file OR echo "$text" | unicode_count_lines
42
+ # unicode_count_lines "filepath"
52
43
  unicode_count_lines() {
53
- perl -CSD -ne "print if /$UNICODE_PATTERN/" 2>/dev/null | wc -l | tr -d ' '
54
- }
55
-
56
- # Count affected lines in a file.
57
- unicode_count_lines_file() {
58
- local FILE="$1"
59
- perl -CSD -ne "print if /$UNICODE_PATTERN/" "$FILE" 2>/dev/null | wc -l | tr -d ' '
44
+ if [ $# -gt 0 ]; then
45
+ perl -CSD -ne "print if /$UNICODE_PATTERN/" "$1" 2>/dev/null | wc -l | tr -d ' '
46
+ else
47
+ perl -CSD -ne "print if /$UNICODE_PATTERN/" 2>/dev/null | wc -l | tr -d ' '
48
+ fi
60
49
  }
61
50
 
62
51
  # Strip dangerous Unicode from a file in-place.
@@ -64,3 +53,8 @@ unicode_strip_file() {
64
53
  local FILE="$1"
65
54
  perl -CSD -pi -e "s/$UNICODE_PATTERN//g" "$FILE" 2>/dev/null
66
55
  }
56
+
57
+ # Legacy aliases for backward compatibility with unicode-scan-session.sh
58
+ unicode_detect_file() { unicode_detect "$1"; }
59
+ unicode_classify_file() { unicode_classify "$1"; }
60
+ unicode_count_lines_file() { unicode_count_lines "$1"; }
@@ -13,7 +13,7 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || a
13
13
  HARD_DENY=$(agentops_hard_deny)
14
14
 
15
15
  # 1. Destructive system commands (always deny, even in bypass/unrestricted)
16
- if echo "$COMMAND" | grep -qE "(rm\s+-rf\s+/[^t]|mkfs\s|dd\s+if=|shutdown|reboot|init\s+[06])"; then
16
+ if echo "$COMMAND" | grep -qE "(rm\s+-rf\s+/([^t]|t[^m]|tm[^p]|tmp[^/])|mkfs\s|dd\s+if=|shutdown|reboot|init\s+[06])"; then
17
17
  jq -nc --arg action "$HARD_DENY" \
18
18
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"Destructive system command blocked by AgentOps CommandPolicy (hard deny)"}}'
19
19
  exit 0
@@ -51,7 +51,7 @@ if echo "$COMMAND" | grep -qE '>\s*[^ ]*\.agentops/' \
51
51
  fi
52
52
  fi
53
53
 
54
- # 4b. Tampering with hooks/ directory via Bash (always deny — prevent hook tampering)
54
+ # 4b. Tampering with hooks/ directory via Bash write operations (always deny — prevent hook tampering)
55
55
  if echo "$COMMAND" | grep -qE '>\s*[^ ]*hooks/' \
56
56
  || echo "$COMMAND" | grep -qE '(tee|cp|mv|install)\s+[^ ]*\s+[^ ]*hooks/' \
57
57
  || echo "$COMMAND" | grep -qE 'sed\s+-i[^ ]*\s+[^ ]*hooks/' \
@@ -63,6 +63,14 @@ if echo "$COMMAND" | grep -qE '>\s*[^ ]*hooks/' \
63
63
  exit 0
64
64
  fi
65
65
 
66
+ # 4c. Scripting language file I/O to protected paths (always deny — prevent bypass via python/ruby/node)
67
+ if echo "$COMMAND" | grep -qE "(python3?|ruby|node|perl)\s+(-c|(-e\s))" && \
68
+ echo "$COMMAND" | grep -qE '(\.agentops/|hooks/)'; then
69
+ jq -nc --arg action "$HARD_DENY" \
70
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:"Scripting language referencing protected paths blocked by AgentOps CommandPolicy (hard deny)"}}'
71
+ exit 0
72
+ fi
73
+
66
74
  # Soft rules below — bypass/unrestricted can downgrade these
67
75
  agentops_is_bypass "$INPUT" && agentops_bypass_advisory "validate-command"
68
76
  ACTION=$(agentops_enforcement_action)
@@ -22,7 +22,8 @@ for VAR in $VARS; do
22
22
  VAR_UPPER=$(echo "$VAR" | tr '[:lower:]' '[:upper:]')
23
23
 
24
24
  # Forbidden keys — LD_PRELOAD, PATH, HOME etc. are always dangerous
25
- if echo "$VAR_UPPER" | grep -qE "^(PATH|HOME|SHELL|USER|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_)"; then
25
+ if echo "$VAR_UPPER" | grep -qE "^(PATH|HOME|SHELL|USER|LD_PRELOAD|LD_LIBRARY_PATH|NODE_OPTIONS|NODE_PATH|PYTHONPATH|PYTHONSTARTUP|RUBYOPT|PERL5OPT|CLASSPATH)$" || \
26
+ echo "$VAR_UPPER" | grep -qE "^DYLD_"; then
26
27
  jq -nc --arg var "$VAR" --arg action "$HARD_DENY" \
27
28
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$action,permissionDecisionReason:("EnvPolicy: forbidden env var " + $var + " (hard deny)")}}'
28
29
  exit 0
@@ -38,14 +38,12 @@ if [ ${#FILE_PATH} -gt 1024 ]; then
38
38
  exit 0
39
39
  fi
40
40
 
41
- # Canonicalize for symlink resolution (for Write/Edit, resolve existing paths)
41
+ # Canonicalize for symlink resolution (resolve existing paths for all tools)
42
42
  CANONICAL="$FILE_PATH"
43
- if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ]; then
44
- PARENT_DIR=$(dirname "$FILE_PATH")
45
- if [ -d "$PARENT_DIR" ]; then
46
- RESOLVED=$(realpath -m "$FILE_PATH" 2>/dev/null) || RESOLVED="$FILE_PATH"
47
- CANONICAL="$RESOLVED"
48
- fi
43
+ PARENT_DIR=$(dirname "$FILE_PATH")
44
+ if [ -d "$PARENT_DIR" ]; then
45
+ RESOLVED=$(realpath -m "$FILE_PATH" 2>/dev/null) || RESOLVED="$FILE_PATH"
46
+ CANONICAL="$RESOLVED"
49
47
  fi
50
48
 
51
49
  # 4. Protect plugin state files from agent writes (hard deny)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garethdaine/agentops",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Enterprise guardrails, delivery lifecycle, and self-evolution for Claude Code CLI",
5
5
  "author": {
6
6
  "name": "Gareth Daine",
@@ -27,6 +27,10 @@
27
27
  "compliance",
28
28
  "audit"
29
29
  ],
30
+ "scripts": {
31
+ "test": "bats tests/",
32
+ "lint": "shellcheck hooks/*.sh"
33
+ },
30
34
  "files": [
31
35
  ".claude-plugin/",
32
36
  "agents/",
package/settings.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [],
4
- "deny": []
4
+ "deny": [
5
+ "Bash(rm -rf /*)",
6
+ "Bash(curl*|*bash)",
7
+ "Bash(curl*|*sh)",
8
+ "Bash(wget*|*bash)",
9
+ "Bash(wget*|*sh)",
10
+ "Write(/etc/*)",
11
+ "Write(/sys/*)",
12
+ "Write(/proc/*)",
13
+ "Write(/dev/*)",
14
+ "Write(/boot/*)"
15
+ ]
5
16
  }
6
17
  }