@opengsd/gsd-core 1.12.0 → 1.13.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-executor.md +63 -35
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +239 -67
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +195 -50
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-registry.cjs +514 -114
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +120 -4
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +85 -2
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +337 -13
- package/gsd-core/bin/lib/config-loader.cjs +3 -0
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +137 -23
- package/gsd-core/bin/lib/gap-checker.cjs +22 -13
- package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/host-integration.cjs +57 -5
- package/gsd-core/bin/lib/init-command-router.cjs +14 -0
- package/gsd-core/bin/lib/init.cjs +132 -15
- package/gsd-core/bin/lib/install-engine.cjs +184 -12
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +22 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/milestone.cjs +22 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-id.cjs +251 -9
- package/gsd-core/bin/lib/phase.cjs +774 -35
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
- package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
- package/gsd-core/bin/lib/roadmap.cjs +187 -58
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
- package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
- package/gsd-core/bin/lib/smart-entry.cjs +7 -9
- package/gsd-core/bin/lib/state-document.cjs +30 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
- package/gsd-core/bin/lib/state-transition.cjs +333 -44
- package/gsd-core/bin/lib/state.cjs +684 -125
- package/gsd-core/bin/lib/surface.cjs +23 -8
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1419 -515
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +273 -12
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +346 -16
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/edge-probe.md +17 -13
- package/gsd-core/references/execute-mvp-tdd.md +18 -16
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +75 -2
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/tdd.md +14 -9
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/add-backlog.md +2 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +2 -0
- package/gsd-core/workflows/audit-uat.md +2 -0
- package/gsd-core/workflows/autonomous.md +2 -0
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
- package/gsd-core/workflows/code-review-fix.md +2 -0
- package/gsd-core/workflows/code-review.md +73 -31
- package/gsd-core/workflows/complete-milestone.md +13 -4
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +43 -13
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +32 -14
- package/gsd-core/workflows/execute-plan.md +8 -8
- package/gsd-core/workflows/explore.md +2 -0
- package/gsd-core/workflows/extract-learnings.md +2 -0
- package/gsd-core/workflows/fast.md +6 -0
- package/gsd-core/workflows/forensics.md +2 -0
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +3 -3
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +2 -0
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +2 -0
- package/gsd-core/workflows/list-workspaces.md +2 -0
- package/gsd-core/workflows/manager.md +3 -3
- package/gsd-core/workflows/map-codebase.md +2 -0
- package/gsd-core/workflows/milestone-summary.md +2 -0
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +1 -1
- package/gsd-core/workflows/new-project.md +5 -3
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -0
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +19 -4
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
- package/gsd-core/workflows/plan-phase.md +130 -12
- package/gsd-core/workflows/plan-review-convergence.md +102 -10
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +11 -3
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +25 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
- package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +13 -3
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +2 -0
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +6 -2
- package/gsd-core/workflows/review.md +215 -10
- package/gsd-core/workflows/scan.md +2 -0
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings-integrations.md +9 -8
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +10 -10
- package/gsd-core/workflows/sketch-wrap-up.md +2 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +24 -19
- package/gsd-core/workflows/spike-wrap-up.md +2 -0
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +2 -0
- package/gsd-core/workflows/sync-skills.md +12 -4
- package/gsd-core/workflows/thread.md +2 -0
- package/gsd-core/workflows/transition.md +2 -0
- package/gsd-core/workflows/ui-phase.md +26 -5
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +2 -0
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/update.md +41 -38
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +49 -3
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +283 -12
- package/hooks/dist/gsd-node-runner.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +30 -5
- package/hooks/dist/gsd-read-guard.js +2 -0
- package/hooks/dist/gsd-read-injection-scanner.js +5 -5
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-statusline.js +7 -3
- package/hooks/dist/gsd-validate-commit.sh +444 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +283 -12
- package/hooks/gsd-node-runner.sh +1 -0
- package/hooks/gsd-prompt-guard.js +30 -5
- package/hooks/gsd-read-guard.js +2 -0
- package/hooks/gsd-read-injection-scanner.js +5 -5
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-statusline.js +7 -3
- package/hooks/gsd-validate-commit.sh +444 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -5
- package/scripts/build-hooks.js +11 -4
- package/scripts/ci-test-scope.cjs +7 -0
- package/scripts/docs-guard-registry.cjs +10 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-test-file-count.allowlist.json +3 -1
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// GSD Secret Read Guard — PreToolUse hook (Read | Grep | Bash)
|
|
4
|
+
//
|
|
5
|
+
// Blocks reads of secret files — `.env`, `.env.<suffix>`, `.secrets` — by any
|
|
6
|
+
// of the three tools that can put file contents into the conversation: the
|
|
7
|
+
// Read tool (file_path), the Grep tool (an explicit path or a glob that
|
|
8
|
+
// selects the secret namespace), and Bash (a command whose operands or input
|
|
9
|
+
// redirects name a secret file, including inside `$( )`, backticks, `<( )`,
|
|
10
|
+
// `bash -c '…'` / `eval "…"` bodies, and `git show <ref>:<path>` shapes).
|
|
11
|
+
//
|
|
12
|
+
// Why a hook and not permission rules (#4221): since #768 the installer wrote
|
|
13
|
+
// three `Read(.env)` / `Read(.env.*)` / `Read(.secrets)` deny rules into
|
|
14
|
+
// settings.json. Claude Code 2.1.259 hardened the Bash-side enforcement of
|
|
15
|
+
// Read() deny rules so that ANY `cd DIR && cat/grep relative-path` compound
|
|
16
|
+
// prompts for approval whenever any Read() deny rule exists — even in `auto`
|
|
17
|
+
// permission mode. GSD subagents emit hundreds of those per session. A
|
|
18
|
+
// PreToolUse denial is not a permission rule, so it never arms that check,
|
|
19
|
+
// and it applies in `auto` and `bypassPermissions` modes alike. The three
|
|
20
|
+
// installer-written strings are retired by the same installer change (they
|
|
21
|
+
// are filtered out as legacy entries on install and uninstall).
|
|
22
|
+
//
|
|
23
|
+
// What counts as a secret name (basename match, no path resolution, matched
|
|
24
|
+
// case-INSENSITIVELY so `.ENV` / `.Secrets` are caught on the macOS/Windows
|
|
25
|
+
// filesystems where they ARE the secret file — the write guard's `/i` stance):
|
|
26
|
+
// .env, .secrets, and .env.<suffix> — EXCEPT .env.example / .env.sample /
|
|
27
|
+
// .env.template / .env.dist, which are the non-secret templates GSD's own
|
|
28
|
+
// phase prompt tells executors to read.
|
|
29
|
+
// Stated cost: this is narrower than the retired `Read(.env.*)` rule — a
|
|
30
|
+
// real secret stored in `.env.example` is not protected.
|
|
31
|
+
// A token containing `:` is also tested on the part after its LAST `:`,
|
|
32
|
+
// so `git show HEAD:.env`, `origin/main:config/.env` and `C:\proj\.env`
|
|
33
|
+
// are caught without git-specific parsing. No whitespace trimming: the
|
|
34
|
+
// commit message `fix: .env parsing` yields ` .env parsing`, not a name.
|
|
35
|
+
//
|
|
36
|
+
// Bash analysis is a two-pass token scan, not a shell:
|
|
37
|
+
// pass 1 tokenizes with quote state, comments, redirect operators (with fd
|
|
38
|
+
// digits and `>&N` dups), separators (recording the operator text), `$( )` /
|
|
39
|
+
// backtick / `<( )` / `>( )` spans (recursed as nested commands, depth ≤ 3),
|
|
40
|
+
// and heredocs (one token per body, carrying its `<<` segment). A heredoc
|
|
41
|
+
// body is only ever run as a script when its segment's command is a shell
|
|
42
|
+
// interpreter (below); a DATA heredoc — `cat <<EOF … EOF`, the agent-
|
|
43
|
+
// populated bodies in GSD's own workflows, `git commit -m "$(cat <<'EOF' …
|
|
44
|
+
// EOF)"` — is never operand-checked, so prose mentioning `.env` is safe.
|
|
45
|
+
// pass 2 groups tokens by segment and evaluates each on its own:
|
|
46
|
+
// input redirect targets (`<`, `N<`) are always checked; the command word is
|
|
47
|
+
// located past `sudo`/`env VAR=x`/`nohup`-style prefixes; a closed set of
|
|
48
|
+
// NON-READING commands (test/[/ls/stat/rm/touch/echo/…) exempts that
|
|
49
|
+
// segment's operands — `[ -f .env ]` and `ls .env*` are existence checks
|
|
50
|
+
// GSD's own agents run — while `cp`/`mv`/`ln`/`git` are deliberately NOT
|
|
51
|
+
// exempt (`cp .env x && cat x` launders the name; `git show HEAD:.env`
|
|
52
|
+
// reads). A shell interpreter (bash/sh/zsh/dash/ksh/su) has its script scanned
|
|
53
|
+
// whether it arrives via `-c '…'`, a `<( )` file operand, a heredoc /
|
|
54
|
+
// here-string, or a pipe from a knowable `echo`/`printf` source
|
|
55
|
+
// (`echo cat .env | bash`); `eval` scans its joined operands; `source`/`.`
|
|
56
|
+
// scans a `<( )` operand; and `find … | xargs cat` infers the upstream
|
|
57
|
+
// segment's names as the sub-command's read operands.
|
|
58
|
+
//
|
|
59
|
+
// Grep globs are judged per brace alternative (never on the whole glob, so
|
|
60
|
+
// `{.env.local,zzz.ts}` cannot hide behind a benign sibling): a pure-wildcard
|
|
61
|
+
// alternative (`*`, `**`) is allowed — Grep already skips gitignored files,
|
|
62
|
+
// so it is equivalent to no glob; any other alternative is denied when its
|
|
63
|
+
// literal prefix is a prefix of `.env.`/`.secrets` (`.e*`, `.env*`, `.s*`) or
|
|
64
|
+
// when it matches a probe secret name (`*.local`, `*.*`, `*.env*`). More than
|
|
65
|
+
// 64 alternatives is denied as `glob-too-complex` (cheap to retry narrower).
|
|
66
|
+
//
|
|
67
|
+
// Documented gaps (none are statically resolvable by a hook, and Claude
|
|
68
|
+
// Code's own 2.1.259 Bash-side enforcement does not resolve them either):
|
|
69
|
+
// `$VAR` indirection (`bash -c "$TEST_CMD"`, `cat "$F"`), shell globs
|
|
70
|
+
// (`cat .e*`), interpreter one-liners (`python -c "open('.env')"`), a piped
|
|
71
|
+
// script from a non-echo source (`cat gen.sh | bash`, `curl … | sh`), reads
|
|
72
|
+
// inside scripts the agent executes, and `glob: '*'` reaching a
|
|
73
|
+
// NON-gitignored `.env`. The promise is "no looser than the retired rules
|
|
74
|
+
// on plain commands, without arming the compound-`cd` prompt". Writes to
|
|
75
|
+
// secret files are out of scope (Write/Edit were never gated). Commands
|
|
76
|
+
// over 1 MiB are denied outright (`command-too-large`) rather than
|
|
77
|
+
// scanned partially or waved through.
|
|
78
|
+
//
|
|
79
|
+
// Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
|
|
80
|
+
// Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
|
|
81
|
+
// glob-too-complex | command-too-large
|
|
82
|
+
// No-op: other tools, non-secret targets, hook errors (fail open — a parser
|
|
83
|
+
// bug in a hook that runs on EVERY Bash call must never brick a
|
|
84
|
+
// session; the crash policy is declared once below).
|
|
85
|
+
|
|
86
|
+
'use strict';
|
|
87
|
+
|
|
88
|
+
const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
|
|
89
|
+
|
|
90
|
+
// Fail open on a hook-internal error (see header). Declared ONCE so the
|
|
91
|
+
// outer catch states its policy explicitly (#3911).
|
|
92
|
+
const ON_CRASH = HOOK_ON_CRASH.ALLOW;
|
|
93
|
+
|
|
94
|
+
// Commands longer than this are denied rather than scanned (see header).
|
|
95
|
+
const MAX_COMMAND_LENGTH = 1024 * 1024;
|
|
96
|
+
|
|
97
|
+
// Recursion budget for `$( )` / backtick / `<( )` / nested-shell rescans.
|
|
98
|
+
const MAX_NESTING_DEPTH = 3;
|
|
99
|
+
|
|
100
|
+
// Brace-alternative budget for a Grep glob before it is denied as too complex.
|
|
101
|
+
const MAX_GLOB_ALTERNATIVES = 64;
|
|
102
|
+
|
|
103
|
+
// `.env.<suffix>` names that are templates, not secrets (case-insensitive).
|
|
104
|
+
const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
|
|
105
|
+
|
|
106
|
+
// Command-prefix wrappers to look through when locating the command word at
|
|
107
|
+
// the head of a segment (same set as hooks/gsd-windsurf-pre-command.js).
|
|
108
|
+
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
|
|
109
|
+
|
|
110
|
+
// Commands whose ordinary operands are file NAMES, never file CONTENTS. A
|
|
111
|
+
// closed set on purpose: anything not listed is assumed to read.
|
|
112
|
+
const NON_READING_COMMANDS = new Set([
|
|
113
|
+
'test', '[', '[[', 'ls', 'stat', 'touch', 'rm', 'chmod', 'chown', 'mkdir',
|
|
114
|
+
'basename', 'dirname', 'realpath', 'file', 'echo', 'printf',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
// Shell interpreters that run a script from `-c`, a file operand, or stdin
|
|
118
|
+
// (heredoc / here-string / piped `echo`|`printf`). `su` is here for its `-c`
|
|
119
|
+
// form (`su [user] -c 'cmd'`); a bare `su user` resolves to file mode, which
|
|
120
|
+
// only runs the ordinary operand check. `eval`, `source`/`.` and `xargs` are
|
|
121
|
+
// their own cases below; they are not in this set.
|
|
122
|
+
const SHELL_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'su']);
|
|
123
|
+
|
|
124
|
+
// Shell flags whose VALUE is the next operand (`bash -o pipefail`,
|
|
125
|
+
// `bash --rcfile x <<EOF`): skipped when locating a script-file operand, so a
|
|
126
|
+
// flag value is not mistaken for the script and stdin mode still applies.
|
|
127
|
+
const SHELL_VALUE_FLAGS = new Set(['-o', '-O', '+o', '+O', '--rcfile', '--init-file']);
|
|
128
|
+
|
|
129
|
+
// Value-taking `xargs` flags (long `--flag=value` forms are single words).
|
|
130
|
+
// `-a`/`--arg-file` additionally replaces stdin, so it suppresses the pipeline
|
|
131
|
+
// inference below.
|
|
132
|
+
const XARGS_VALUE_FLAGS = new Set(['-n', '-I', '-i', '-L', '-l', '-P', '-s', '-d', '-E', '-a']);
|
|
133
|
+
|
|
134
|
+
// Probe names a Grep glob alternative is matched against. `.env` and
|
|
135
|
+
// `.secrets` are the exact names; the rest stand in for the open-ended
|
|
136
|
+
// `.env.<suffix>` family so empty-literal-prefix selectors (`*.local`,
|
|
137
|
+
// `*.production`, `*env.*`) are caught. Residual, stated in the header:
|
|
138
|
+
// an alternative like `*.ts` matches no probe and is allowed even though a
|
|
139
|
+
// `.env.foo.ts` would satisfy the name predicate.
|
|
140
|
+
const GLOB_PROBES = [
|
|
141
|
+
'.env', '.secrets', '.env.local', '.env.development', '.env.production',
|
|
142
|
+
'.env.staging', '.env.test', '.env.development.local', '.env.production.local',
|
|
143
|
+
'.env.zzq',
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Secret-name predicate
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function isSecretBasename(name) {
|
|
151
|
+
if (name === '.env' || name === '.secrets') return true;
|
|
152
|
+
if (name.startsWith('.env.')) {
|
|
153
|
+
const suffix = name.slice('.env.'.length);
|
|
154
|
+
return suffix !== '' && !NON_SECRET_ENV_SUFFIXES.has(suffix.toLowerCase());
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Last `/`- or `\`-separated segment, ignoring trailing separators.
|
|
160
|
+
function lastSegment(tok) {
|
|
161
|
+
const s = tok.replace(/[\\/]+$/, '');
|
|
162
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
|
|
163
|
+
return i === -1 ? s : s.slice(i + 1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// True when the token's basename — or the basename of the part after its
|
|
167
|
+
// last `:` (git `<ref>:<path>`, Windows drive) — is a secret name. Folded to
|
|
168
|
+
// lower case once at the top so `.ENV` / `.Secrets` match on the
|
|
169
|
+
// case-insensitive filesystems (macOS, Windows) where they ARE the secret file
|
|
170
|
+
// — the same stance as the write guard's `/i` patterns.
|
|
171
|
+
function namesSecret(tok) {
|
|
172
|
+
if (typeof tok !== 'string' || tok === '') return false;
|
|
173
|
+
const lower = tok.toLowerCase();
|
|
174
|
+
if (isSecretBasename(lastSegment(lower))) return true;
|
|
175
|
+
const colon = lower.lastIndexOf(':');
|
|
176
|
+
return colon !== -1 && isSecretBasename(lastSegment(lower.slice(colon + 1)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Grep glob analysis
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
// Expand `{a,b,…}` (nested allowed) into the list of alternatives, or null
|
|
184
|
+
// when the list would exceed MAX_GLOB_ALTERNATIVES. Malformed braces are
|
|
185
|
+
// treated literally.
|
|
186
|
+
function expandBraces(glob) {
|
|
187
|
+
const open = glob.indexOf('{');
|
|
188
|
+
if (open === -1) return [glob];
|
|
189
|
+
let depth = 0;
|
|
190
|
+
let close = -1;
|
|
191
|
+
const commas = [];
|
|
192
|
+
for (let i = open; i < glob.length; i++) {
|
|
193
|
+
const ch = glob[i];
|
|
194
|
+
if (ch === '{') depth++;
|
|
195
|
+
else if (ch === '}') {
|
|
196
|
+
depth--;
|
|
197
|
+
if (depth === 0) { close = i; break; }
|
|
198
|
+
} else if (ch === ',' && depth === 1) commas.push(i);
|
|
199
|
+
}
|
|
200
|
+
if (close === -1) return [glob];
|
|
201
|
+
const pre = glob.slice(0, open);
|
|
202
|
+
const post = glob.slice(close + 1);
|
|
203
|
+
const inner = glob.slice(open + 1, close);
|
|
204
|
+
const parts = [];
|
|
205
|
+
let start = 0;
|
|
206
|
+
for (const c of commas) {
|
|
207
|
+
parts.push(inner.slice(start, c - open - 1));
|
|
208
|
+
start = c - open;
|
|
209
|
+
}
|
|
210
|
+
parts.push(inner.slice(start));
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const part of parts) {
|
|
213
|
+
const expanded = expandBraces(pre + part + post);
|
|
214
|
+
if (expanded === null) return null;
|
|
215
|
+
for (const alt of expanded) {
|
|
216
|
+
out.push(alt);
|
|
217
|
+
if (out.length > MAX_GLOB_ALTERNATIVES) return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Anchored regex for one brace-free glob alternative (`*` → `[^/]*`,
|
|
224
|
+
// `?` → `[^/]`, `[…]` classes passed through with `[!` → `[^`).
|
|
225
|
+
function globAltToRegex(alt) {
|
|
226
|
+
let out = '^';
|
|
227
|
+
for (let i = 0; i < alt.length; i++) {
|
|
228
|
+
const ch = alt[i];
|
|
229
|
+
if (ch === '*') out += '[^/]*';
|
|
230
|
+
else if (ch === '?') out += '[^/]';
|
|
231
|
+
else if (ch === '[') {
|
|
232
|
+
const j = alt.indexOf(']', i + 1);
|
|
233
|
+
if (j === -1) out += '\\[';
|
|
234
|
+
else {
|
|
235
|
+
const body = alt.slice(i + 1, j);
|
|
236
|
+
out += '[' + (body.startsWith('!') ? '^' + body.slice(1) : body).replace(/\\/g, '\\\\') + ']';
|
|
237
|
+
i = j;
|
|
238
|
+
}
|
|
239
|
+
} else out += ch.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
240
|
+
}
|
|
241
|
+
return new RegExp(out + '$');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Does this single alternative select any secret name? (See header.)
|
|
245
|
+
function globAltSelectsSecret(alt) {
|
|
246
|
+
if (alt === '') return false;
|
|
247
|
+
if (/^[*?]+$/.test(alt)) return false; // pure wildcard: equivalent to no glob
|
|
248
|
+
const wild = alt.search(/[*?[]/);
|
|
249
|
+
const lit = wild === -1 ? alt : alt.slice(0, wild);
|
|
250
|
+
if (lit.startsWith('.env.')) return true;
|
|
251
|
+
if (lit !== '' && ('.env.'.startsWith(lit) || '.secrets'.startsWith(lit))) return true;
|
|
252
|
+
let re;
|
|
253
|
+
try {
|
|
254
|
+
re = globAltToRegex(alt);
|
|
255
|
+
} catch {
|
|
256
|
+
return true; // an unparsable class — Grep would reject it too; deny is the safe side
|
|
257
|
+
}
|
|
258
|
+
return GLOB_PROBES.some((probe) => re.test(probe));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Returns null (allowed), 'secret-read', or 'glob-too-complex'.
|
|
262
|
+
function classifyGrepGlob(glob) {
|
|
263
|
+
const segIdx = glob.replace(/\/+$/, '').lastIndexOf('/');
|
|
264
|
+
// Case-fold the last segment (GLOB_PROBES are lower case) so `.ENV*` and
|
|
265
|
+
// `*.ENV` select the secret namespace on case-insensitive filesystems.
|
|
266
|
+
const segment = (segIdx === -1 ? glob : glob.slice(segIdx + 1)).toLowerCase();
|
|
267
|
+
const alts = expandBraces(segment);
|
|
268
|
+
if (alts === null) return 'glob-too-complex';
|
|
269
|
+
return alts.some(globAltSelectsSecret) ? 'secret-read' : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Bash command scan — pass 1: tokenizer
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
// Index of the `)` closing a `$(` / `<(` / `>(` opened just before `i`, or
|
|
277
|
+
// str.length when unterminated. Quote- and heredoc-aware so a `)` inside a
|
|
278
|
+
// quoted string or a heredoc body never closes the span early.
|
|
279
|
+
function findParenClose(str, i) {
|
|
280
|
+
let depth = 1;
|
|
281
|
+
let heredocTags = [];
|
|
282
|
+
while (i < str.length) {
|
|
283
|
+
const ch = str[i];
|
|
284
|
+
if (ch === '\\') { i += 2; continue; }
|
|
285
|
+
if (ch === "'") {
|
|
286
|
+
const j = str.indexOf("'", i + 1);
|
|
287
|
+
i = j === -1 ? str.length : j + 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (ch === '"') {
|
|
291
|
+
i++;
|
|
292
|
+
while (i < str.length && str[i] !== '"') {
|
|
293
|
+
if (str[i] === '\\') { i += 2; continue; }
|
|
294
|
+
if (str[i] === '$' && str[i + 1] === '(') { i = findParenClose(str, i + 2) + 1; continue; }
|
|
295
|
+
if (str[i] === '`') {
|
|
296
|
+
const j = str.indexOf('`', i + 1);
|
|
297
|
+
i = j === -1 ? str.length : j + 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
i++;
|
|
301
|
+
}
|
|
302
|
+
i++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (ch === '`') {
|
|
306
|
+
const j = str.indexOf('`', i + 1);
|
|
307
|
+
i = j === -1 ? str.length : j + 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
|
|
311
|
+
const tag = readHeredocTag(str, i + 2);
|
|
312
|
+
heredocTags.push(tag);
|
|
313
|
+
i = tag.end;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (ch === '\n' && heredocTags.length) {
|
|
317
|
+
i = consumeHeredocBodies(str, i + 1, heredocTags).end;
|
|
318
|
+
heredocTags = [];
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (ch === '(') depth++;
|
|
322
|
+
else if (ch === ')') {
|
|
323
|
+
depth--;
|
|
324
|
+
if (depth === 0) return i;
|
|
325
|
+
}
|
|
326
|
+
i++;
|
|
327
|
+
}
|
|
328
|
+
return str.length;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Reads the tag word after `<<` / `<<-` starting at `i`.
|
|
332
|
+
function readHeredocTag(str, i) {
|
|
333
|
+
let stripTabs = false;
|
|
334
|
+
if (str[i] === '-') { stripTabs = true; i++; }
|
|
335
|
+
while (str[i] === ' ' || str[i] === '\t') i++;
|
|
336
|
+
let quoted = false;
|
|
337
|
+
let tag = '';
|
|
338
|
+
if (str[i] === "'" || str[i] === '"') {
|
|
339
|
+
const q = str[i];
|
|
340
|
+
const j = str.indexOf(q, i + 1);
|
|
341
|
+
tag = str.slice(i + 1, j === -1 ? str.length : j);
|
|
342
|
+
quoted = true;
|
|
343
|
+
i = j === -1 ? str.length : j + 1;
|
|
344
|
+
} else {
|
|
345
|
+
if (str[i] === '\\') { quoted = true; i++; }
|
|
346
|
+
while (i < str.length && !/[\s;&|<>()]/.test(str[i])) tag += str[i++];
|
|
347
|
+
}
|
|
348
|
+
return { tag, quoted, stripTabs, end: i };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// From `i` (start of the line after the heredoc-opening line), consume one
|
|
352
|
+
// body per pending tag in order. Returns every body with its `quoted`/`seg`
|
|
353
|
+
// (the caller emits a token per body and recurses substitutions only for
|
|
354
|
+
// unquoted ones) and the index just past the last terminator line. An
|
|
355
|
+
// unterminated body consumes to end of input.
|
|
356
|
+
function consumeHeredocBodies(str, i, tags) {
|
|
357
|
+
const bodies = [];
|
|
358
|
+
for (const t of tags) {
|
|
359
|
+
let body = '';
|
|
360
|
+
let terminated = false;
|
|
361
|
+
while (i < str.length) {
|
|
362
|
+
const nl = str.indexOf('\n', i);
|
|
363
|
+
const lineEnd = nl === -1 ? str.length : nl;
|
|
364
|
+
const line = str.slice(i, lineEnd);
|
|
365
|
+
i = nl === -1 ? str.length : nl + 1;
|
|
366
|
+
const probe = t.stripTabs ? line.replace(/^\t+/, '') : line;
|
|
367
|
+
if (probe === t.tag) { terminated = true; break; }
|
|
368
|
+
body += line + '\n';
|
|
369
|
+
}
|
|
370
|
+
bodies.push({ body, quoted: t.quoted, seg: t.seg });
|
|
371
|
+
if (!terminated) break;
|
|
372
|
+
}
|
|
373
|
+
return { bodies, end: i };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// `$( )` and backtick spans inside an unquoted heredoc body.
|
|
377
|
+
function collectSubstitutions(body, nested) {
|
|
378
|
+
let i = 0;
|
|
379
|
+
while (i < body.length) {
|
|
380
|
+
if (body[i] === '$' && body[i + 1] === '(') {
|
|
381
|
+
const e = findParenClose(body, i + 2);
|
|
382
|
+
nested.push(body.slice(i + 2, e));
|
|
383
|
+
i = e + 1;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (body[i] === '`') {
|
|
387
|
+
const j = body.indexOf('`', i + 1);
|
|
388
|
+
const e = j === -1 ? body.length : j;
|
|
389
|
+
nested.push(body.slice(i + 1, e));
|
|
390
|
+
i = e + 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
i++;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Tokens: { kind: 'word'|'op'|'sep', text, quoted: 'none'|'single'|'double', seg }.
|
|
398
|
+
// `op` tokens carry `read` (an input redirect) and `dup` (`>&N`, consumes no
|
|
399
|
+
// target). Nested command strings are collected separately.
|
|
400
|
+
function tokenize(str) {
|
|
401
|
+
const tokens = [];
|
|
402
|
+
const nested = [];
|
|
403
|
+
let buf = '';
|
|
404
|
+
let quoted = 'none';
|
|
405
|
+
let hasWord = false;
|
|
406
|
+
let seg = 0;
|
|
407
|
+
let heredocs = [];
|
|
408
|
+
let expectTag = null;
|
|
409
|
+
|
|
410
|
+
const flush = () => {
|
|
411
|
+
if (!hasWord) return;
|
|
412
|
+
if (expectTag) {
|
|
413
|
+
// Record the current seg (still the `<<` segment — flush runs before the
|
|
414
|
+
// newline sep increments it) so pass 2 can attach the body to the shell.
|
|
415
|
+
heredocs.push({ tag: buf, quoted: quoted !== 'none', stripTabs: expectTag.stripTabs, seg });
|
|
416
|
+
expectTag = null;
|
|
417
|
+
} else {
|
|
418
|
+
tokens.push({ kind: 'word', text: buf, quoted, seg });
|
|
419
|
+
}
|
|
420
|
+
buf = '';
|
|
421
|
+
quoted = 'none';
|
|
422
|
+
hasWord = false;
|
|
423
|
+
};
|
|
424
|
+
// The operator text ends segment `seg`; pass 2 reads it to tell `a | bash`
|
|
425
|
+
// (pipe inference) from `a || bash` and to skip grouping seps.
|
|
426
|
+
const sep = (text) => {
|
|
427
|
+
flush();
|
|
428
|
+
tokens.push({ kind: 'sep', text, quoted: 'none', seg });
|
|
429
|
+
seg++;
|
|
430
|
+
};
|
|
431
|
+
const op = (text, read, dup) => {
|
|
432
|
+
tokens.push({ kind: 'op', text, quoted: 'none', seg, read, dup });
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
let i = 0;
|
|
436
|
+
while (i < str.length) {
|
|
437
|
+
const ch = str[i];
|
|
438
|
+
|
|
439
|
+
if (ch === "'") {
|
|
440
|
+
hasWord = true;
|
|
441
|
+
if (quoted === 'none') quoted = 'single';
|
|
442
|
+
const j = str.indexOf("'", i + 1);
|
|
443
|
+
const end = j === -1 ? str.length : j;
|
|
444
|
+
buf += str.slice(i + 1, end);
|
|
445
|
+
i = end + 1;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (ch === '"') {
|
|
450
|
+
hasWord = true;
|
|
451
|
+
if (quoted === 'none') quoted = 'double';
|
|
452
|
+
i++;
|
|
453
|
+
while (i < str.length && str[i] !== '"') {
|
|
454
|
+
const c = str[i];
|
|
455
|
+
if (c === '\\' && i + 1 < str.length && '"\\$`\n'.includes(str[i + 1])) {
|
|
456
|
+
if (str[i + 1] !== '\n') buf += str[i + 1];
|
|
457
|
+
i += 2;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (c === '$' && str[i + 1] === '(') {
|
|
461
|
+
const e = findParenClose(str, i + 2);
|
|
462
|
+
nested.push(str.slice(i + 2, e));
|
|
463
|
+
i = e + 1;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (c === '`') {
|
|
467
|
+
const j = str.indexOf('`', i + 1);
|
|
468
|
+
const e = j === -1 ? str.length : j;
|
|
469
|
+
nested.push(str.slice(i + 1, e));
|
|
470
|
+
i = e + 1;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
buf += c;
|
|
474
|
+
i++;
|
|
475
|
+
}
|
|
476
|
+
i++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (ch === '\\') {
|
|
481
|
+
if (str[i + 1] === '\n') { i += 2; continue; } // line continuation
|
|
482
|
+
hasWord = true;
|
|
483
|
+
if (i + 1 < str.length) buf += str[i + 1];
|
|
484
|
+
i += 2;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (ch === '$' && str[i + 1] === '(') {
|
|
489
|
+
hasWord = true;
|
|
490
|
+
const e = findParenClose(str, i + 2);
|
|
491
|
+
nested.push(str.slice(i + 2, e));
|
|
492
|
+
i = e + 1;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (ch === '$' && str[i + 1] === '{') {
|
|
497
|
+
hasWord = true;
|
|
498
|
+
const j = str.indexOf('}', i);
|
|
499
|
+
const e = j === -1 ? str.length - 1 : j;
|
|
500
|
+
buf += str.slice(i, e + 1);
|
|
501
|
+
i = e + 1;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (ch === '`') {
|
|
506
|
+
hasWord = true;
|
|
507
|
+
const j = str.indexOf('`', i + 1);
|
|
508
|
+
const e = j === -1 ? str.length : j;
|
|
509
|
+
nested.push(str.slice(i + 1, e));
|
|
510
|
+
i = e + 1;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if ((ch === '<' || ch === '>') && str[i + 1] === '(') {
|
|
515
|
+
flush();
|
|
516
|
+
const e = findParenClose(str, i + 2);
|
|
517
|
+
const inner = str.slice(i + 2, e);
|
|
518
|
+
nested.push(inner);
|
|
519
|
+
// Emit a word carrying the inner script so a shell / `source` operand
|
|
520
|
+
// (`sh <(echo 'cat .env')`) can reconstruct it; the bare nested recursion
|
|
521
|
+
// above only sees `echo …`, whose operands are not read.
|
|
522
|
+
tokens.push({ kind: 'word', text: str.slice(i, e + 1), quoted: 'none', seg, procsub: inner });
|
|
523
|
+
i = e + 1;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (ch === '\n') {
|
|
528
|
+
sep('\n');
|
|
529
|
+
i++;
|
|
530
|
+
if (heredocs.length) {
|
|
531
|
+
const r = consumeHeredocBodies(str, i, heredocs);
|
|
532
|
+
for (const b of r.bodies) {
|
|
533
|
+
// Emit a heredoc token per body (quoted included) — the body is the
|
|
534
|
+
// stdin script only a shell interpreter runs. Kept out of `words`.
|
|
535
|
+
tokens.push({ kind: 'heredoc', text: b.body, quoted: b.quoted, seg: b.seg });
|
|
536
|
+
if (!b.quoted) collectSubstitutions(b.body, nested); // bash expands $( ) here
|
|
537
|
+
}
|
|
538
|
+
heredocs = [];
|
|
539
|
+
i = r.end;
|
|
540
|
+
}
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
545
|
+
flush();
|
|
546
|
+
i++;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (ch === '#' && !hasWord) {
|
|
551
|
+
const j = str.indexOf('\n', i);
|
|
552
|
+
i = j === -1 ? str.length : j;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (ch === '<' || ch === '>' || (ch === '&' && str[i + 1] === '>')) {
|
|
557
|
+
let fd = '';
|
|
558
|
+
if (hasWord && quoted === 'none' && /^\d+$/.test(buf)) {
|
|
559
|
+
fd = buf;
|
|
560
|
+
buf = '';
|
|
561
|
+
hasWord = false;
|
|
562
|
+
} else {
|
|
563
|
+
flush();
|
|
564
|
+
}
|
|
565
|
+
let j = i;
|
|
566
|
+
let text;
|
|
567
|
+
if (str.startsWith('<<<', j)) { text = '<<<'; j += 3; }
|
|
568
|
+
else if (str.startsWith('<<-', j)) { text = '<<-'; j += 3; }
|
|
569
|
+
else if (str.startsWith('<<', j)) { text = '<<'; j += 2; }
|
|
570
|
+
else if (str.startsWith('&>>', j)) { text = '&>>'; j += 3; }
|
|
571
|
+
else if (str.startsWith('&>', j)) { text = '&>'; j += 2; }
|
|
572
|
+
else if (str.startsWith('>>', j)) { text = '>>'; j += 2; }
|
|
573
|
+
else if (str.startsWith('>|', j)) { text = '>|'; j += 2; }
|
|
574
|
+
else { text = ch; j += 1; }
|
|
575
|
+
if (text === '<<' || text === '<<-') {
|
|
576
|
+
expectTag = { stripTabs: text === '<<-' };
|
|
577
|
+
i = j;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
let dup = false;
|
|
581
|
+
if ((text === '<' || text === '>') && str[j] === '&' && /[\d-]/.test(str[j + 1] || '')) {
|
|
582
|
+
let k = j + 1;
|
|
583
|
+
while (k < str.length && /[\d-]/.test(str[k])) k++;
|
|
584
|
+
text += str.slice(j, k);
|
|
585
|
+
j = k;
|
|
586
|
+
dup = true;
|
|
587
|
+
}
|
|
588
|
+
op(fd + text, text[0] === '<' && text !== '<<<', dup);
|
|
589
|
+
i = j;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Lookahead first, THEN record the full operator, so `a || bash` reports
|
|
594
|
+
// `||` (no pipe inference) and `a | bash` reports `|` (pipe inference).
|
|
595
|
+
if (ch === ';') {
|
|
596
|
+
let text = ';';
|
|
597
|
+
i++;
|
|
598
|
+
if (str[i] === ';') { text = ';;'; i++; }
|
|
599
|
+
sep(text);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (ch === '|') {
|
|
603
|
+
let text = '|';
|
|
604
|
+
i++;
|
|
605
|
+
if (str[i] === '|') { text = '||'; i++; }
|
|
606
|
+
else if (str[i] === '&') { text = '|&'; i++; }
|
|
607
|
+
sep(text);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (ch === '&') {
|
|
611
|
+
let text = '&';
|
|
612
|
+
i++;
|
|
613
|
+
if (str[i] === '&') { text = '&&'; i++; }
|
|
614
|
+
sep(text);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (ch === '(' || ch === ')') {
|
|
618
|
+
sep(ch);
|
|
619
|
+
i++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if ((ch === '{' || ch === '}') && !hasWord && (i + 1 >= str.length || /[\s;&|)]/.test(str[i + 1]))) {
|
|
623
|
+
sep(ch);
|
|
624
|
+
i++;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
hasWord = true;
|
|
629
|
+
buf += ch;
|
|
630
|
+
i++;
|
|
631
|
+
}
|
|
632
|
+
flush();
|
|
633
|
+
return { tokens, nested };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Bash command scan — pass 2: per-segment evaluation
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
// `@file` (curl -d), `--flag=value`, `-Xvalue` → the operand that names the file.
|
|
641
|
+
function normalizeOperand(text) {
|
|
642
|
+
let v = text;
|
|
643
|
+
if (v.startsWith('@')) v = v.slice(1);
|
|
644
|
+
if (v.startsWith('--')) {
|
|
645
|
+
const eq = v.indexOf('=');
|
|
646
|
+
if (eq !== -1) v = v.slice(eq + 1);
|
|
647
|
+
} else if (/^-[A-Za-z]./.test(v)) {
|
|
648
|
+
v = v.slice(2);
|
|
649
|
+
}
|
|
650
|
+
return v;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
654
|
+
|
|
655
|
+
// `-c`, or a combined short flag ending in `c` (`-lc`, `-ec`, `-euc`): mode c.
|
|
656
|
+
const DASH_C_RE = /^-[A-Za-z]*c$/;
|
|
657
|
+
|
|
658
|
+
const GROUPING_SEPS = new Set(['(', ')', '{', '}']);
|
|
659
|
+
|
|
660
|
+
// Command base + operands after leading `VAR=val` assignments and prefix
|
|
661
|
+
// wrappers (`sudo`, `env VAR=x`, …), or null when nothing but prefixes remain.
|
|
662
|
+
function resolveCommand(words) {
|
|
663
|
+
let idx = 0;
|
|
664
|
+
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
|
|
665
|
+
while (idx < words.length) {
|
|
666
|
+
const base = lastSegment(words[idx].text).toLowerCase();
|
|
667
|
+
if (!CMD_PREFIXES.has(base)) break;
|
|
668
|
+
idx++;
|
|
669
|
+
if (base === 'env') {
|
|
670
|
+
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (idx >= words.length) return null;
|
|
674
|
+
return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// The statically-knowable stdin a segment writes: `echo`/`printf` operands
|
|
678
|
+
// joined by a space (for `echo`, leading `-neE` flags dropped). Any other
|
|
679
|
+
// source (`cat gen.sh | bash`, `curl … | sh`) is not knowable → null.
|
|
680
|
+
function reconstructedScript(words) {
|
|
681
|
+
const cmd = resolveCommand(words);
|
|
682
|
+
if (!cmd) return null;
|
|
683
|
+
if (cmd.base === 'echo') {
|
|
684
|
+
let start = 0;
|
|
685
|
+
while (start < cmd.operands.length && /^-[neE]+$/.test(cmd.operands[start].text)) start++;
|
|
686
|
+
return cmd.operands.slice(start).map((w) => w.text).join(' ');
|
|
687
|
+
}
|
|
688
|
+
if (cmd.base === 'printf') return cmd.operands.map((w) => w.text).join(' ');
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Same rule applied to a `<( … )` / `>( … )` inner script's first segment.
|
|
693
|
+
function reconstructedProcsub(inner) {
|
|
694
|
+
const { tokens } = tokenize(inner);
|
|
695
|
+
const words = [];
|
|
696
|
+
for (const t of tokens) {
|
|
697
|
+
if (t.kind === 'sep') break;
|
|
698
|
+
if (t.kind === 'word') words.push(t);
|
|
699
|
+
}
|
|
700
|
+
return reconstructedScript(words);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// The operator connecting segment `s` to the nearest PRECEDING segment that has
|
|
704
|
+
// word tokens, skipping empty grouping segments (`(echo cat .env) | bash` has an
|
|
705
|
+
// empty segment between `)` and `|`). Returns { op, prevSeg }.
|
|
706
|
+
function precedingOp(s, bySeg, sepAfter) {
|
|
707
|
+
let p = s - 1;
|
|
708
|
+
while (p >= 0 && !(bySeg.get(p) || []).some((t) => t.kind === 'word')) p--;
|
|
709
|
+
if (p < 0) return { op: undefined, prevSeg: -1 };
|
|
710
|
+
let op;
|
|
711
|
+
for (let q = p; q < s; q++) {
|
|
712
|
+
const text = sepAfter.get(q);
|
|
713
|
+
if (text !== undefined && !GROUPING_SEPS.has(text)) op = text; // last non-grouping wins
|
|
714
|
+
}
|
|
715
|
+
return { op, prevSeg: p };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Returns the offending token text, or null.
|
|
719
|
+
function findSecretRead(command, depth) {
|
|
720
|
+
const { tokens, nested } = tokenize(command);
|
|
721
|
+
|
|
722
|
+
for (const sub of nested) {
|
|
723
|
+
if (depth < MAX_NESTING_DEPTH) {
|
|
724
|
+
const hit = findSecretRead(sub, depth + 1);
|
|
725
|
+
if (hit) return hit;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Group by seg, not separator order: heredoc tokens carry their `<<`
|
|
730
|
+
// segment's seg and must reach the shell even though a data heredoc sits
|
|
731
|
+
// between other separators. Heredocs are kept OUT of `words` so a data body
|
|
732
|
+
// is never operand-checked (`cat <<EOF\n.env\nEOF` stays allowed).
|
|
733
|
+
const bySeg = new Map();
|
|
734
|
+
const heredocsBySeg = new Map();
|
|
735
|
+
const sepAfter = new Map();
|
|
736
|
+
let maxSeg = 0;
|
|
737
|
+
for (const t of tokens) {
|
|
738
|
+
if (t.seg > maxSeg) maxSeg = t.seg;
|
|
739
|
+
if (t.kind === 'sep') {
|
|
740
|
+
sepAfter.set(t.seg, t.text);
|
|
741
|
+
} else if (t.kind === 'heredoc') {
|
|
742
|
+
if (!heredocsBySeg.has(t.seg)) heredocsBySeg.set(t.seg, []);
|
|
743
|
+
heredocsBySeg.get(t.seg).push(t);
|
|
744
|
+
} else {
|
|
745
|
+
if (!bySeg.has(t.seg)) bySeg.set(t.seg, []);
|
|
746
|
+
bySeg.get(t.seg).push(t);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
for (let s = 0; s <= maxSeg; s++) {
|
|
751
|
+
const segTokens = bySeg.get(s);
|
|
752
|
+
if (!segTokens) continue;
|
|
753
|
+
|
|
754
|
+
const words = [];
|
|
755
|
+
const hereStrings = [];
|
|
756
|
+
for (let k = 0; k < segTokens.length; k++) {
|
|
757
|
+
const t = segTokens[k];
|
|
758
|
+
if (t.kind === 'op') {
|
|
759
|
+
if (t.dup) continue;
|
|
760
|
+
const target = segTokens[k + 1];
|
|
761
|
+
if (target && target.kind === 'word') {
|
|
762
|
+
k++;
|
|
763
|
+
if (t.text.endsWith('<<<')) hereStrings.push(target.text); // stdin data for a shell
|
|
764
|
+
// Input redirects are reads regardless of the command's exemption.
|
|
765
|
+
else if (t.read && namesSecret(target.text)) return target.text;
|
|
766
|
+
}
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
words.push(t);
|
|
770
|
+
}
|
|
771
|
+
if (!words.length) continue;
|
|
772
|
+
|
|
773
|
+
const cmd = resolveCommand(words);
|
|
774
|
+
if (!cmd) continue;
|
|
775
|
+
const { base, operands } = cmd;
|
|
776
|
+
const heredocs = heredocsBySeg.get(s) || [];
|
|
777
|
+
|
|
778
|
+
// eval concatenates ALL its operands and runs the result.
|
|
779
|
+
if (base === 'eval') {
|
|
780
|
+
if (depth < MAX_NESTING_DEPTH) {
|
|
781
|
+
const hit = findSecretRead(operands.map((w) => w.text).join(' '), depth + 1);
|
|
782
|
+
if (hit) return hit;
|
|
783
|
+
}
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// `source` / `.` reads a file (or a process-substitution script).
|
|
788
|
+
if (base === 'source' || base === '.') {
|
|
789
|
+
for (const w of operands) {
|
|
790
|
+
if (w.procsub !== undefined && depth < MAX_NESTING_DEPTH) {
|
|
791
|
+
const src = reconstructedProcsub(w.procsub);
|
|
792
|
+
if (src !== null) {
|
|
793
|
+
const hit = findSecretRead(src, depth + 1);
|
|
794
|
+
if (hit) return hit;
|
|
795
|
+
}
|
|
796
|
+
} else if (namesSecret(normalizeOperand(w.text))) return w.text;
|
|
797
|
+
}
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// xargs turns stdin file names into a sub-command's operands.
|
|
802
|
+
if (base === 'xargs' && depth < MAX_NESTING_DEPTH) {
|
|
803
|
+
const hit = scanXargsPipe(operands, s, bySeg, sepAfter, depth);
|
|
804
|
+
if (hit) return hit;
|
|
805
|
+
// `.env` given to xargs itself (`xargs -a .env cat`) is an ordinary
|
|
806
|
+
// operand — fall through to the operand check below.
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (SHELL_INTERPRETERS.has(base) && depth < MAX_NESTING_DEPTH) {
|
|
810
|
+
const hit = scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth);
|
|
811
|
+
if (hit) return hit;
|
|
812
|
+
// `bash .env` (file mode) is caught by the operand check below.
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (NON_READING_COMMANDS.has(base)) continue;
|
|
816
|
+
|
|
817
|
+
for (const w of operands) {
|
|
818
|
+
if (namesSecret(normalizeOperand(w.text))) return w.text;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// A shell interpreter's script comes from `-c`, a file operand, or stdin.
|
|
825
|
+
function scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth) {
|
|
826
|
+
const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
|
|
827
|
+
if (cIdx !== -1) {
|
|
828
|
+
// Mode c: the next operand is the script; stdin is DATA (not scanned).
|
|
829
|
+
const script = operands[cIdx + 1];
|
|
830
|
+
if (script) return findSecretRead(script.text, depth + 1);
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
let fileTok;
|
|
834
|
+
for (let m = 0; m < operands.length; m++) {
|
|
835
|
+
if (SHELL_VALUE_FLAGS.has(operands[m].text)) { m++; continue; }
|
|
836
|
+
if (!operands[m].text.startsWith('-')) { fileTok = operands[m]; break; }
|
|
837
|
+
}
|
|
838
|
+
if (fileTok) {
|
|
839
|
+
// Mode file: `bash <(echo 'cat .env')`; a plain file is checked as an operand.
|
|
840
|
+
if (fileTok.procsub !== undefined) {
|
|
841
|
+
const src = reconstructedProcsub(fileTok.procsub);
|
|
842
|
+
if (src !== null) return findSecretRead(src, depth + 1);
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
// Mode stdin: heredoc bodies, here-strings, and a piped echo/printf source.
|
|
847
|
+
for (const h of heredocs) {
|
|
848
|
+
const hit = findSecretRead(h.text, depth + 1);
|
|
849
|
+
if (hit) return hit;
|
|
850
|
+
}
|
|
851
|
+
for (const hs of hereStrings) {
|
|
852
|
+
const hit = findSecretRead(hs, depth + 1);
|
|
853
|
+
if (hit) return hit;
|
|
854
|
+
}
|
|
855
|
+
const { op, prevSeg } = precedingOp(s, bySeg, sepAfter);
|
|
856
|
+
if ((op === '|' || op === '|&') && prevSeg >= 0) {
|
|
857
|
+
const src = reconstructedScript((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
|
|
858
|
+
if (src !== null) return findSecretRead(src, depth + 1);
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// `find … | xargs cat`: the upstream segment's operands become file names the
|
|
864
|
+
// sub-command reads. Only inferred across a real pipe and when stdin is not
|
|
865
|
+
// redirected by `-a`/`--arg-file`. A sub-command that is itself a shell
|
|
866
|
+
// (`xargs -I{} sh -c 'cat .env'`) carries a literal script and is scanned in
|
|
867
|
+
// mode c whether or not a pipe feeds it.
|
|
868
|
+
function scanXargsPipe(operands, s, bySeg, sepAfter, depth) {
|
|
869
|
+
let argFile = false;
|
|
870
|
+
let subIdx = -1;
|
|
871
|
+
for (let m = 0; m < operands.length; m++) {
|
|
872
|
+
const t = operands[m].text;
|
|
873
|
+
if (t === '-a' || t === '--arg-file') { argFile = true; m++; continue; }
|
|
874
|
+
if (t.startsWith('--arg-file=')) { argFile = true; continue; }
|
|
875
|
+
if (XARGS_VALUE_FLAGS.has(t)) { m++; continue; }
|
|
876
|
+
if (t.startsWith('--') && t.includes('=')) continue;
|
|
877
|
+
if (t.startsWith('-')) continue; // no-value flag (-0 -r -t -p) or long flag
|
|
878
|
+
subIdx = m;
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
if (subIdx === -1) return null; // no sub-command: xargs defaults to echo
|
|
882
|
+
|
|
883
|
+
const subBase = lastSegment(operands[subIdx].text).toLowerCase();
|
|
884
|
+
if (SHELL_INTERPRETERS.has(subBase)) {
|
|
885
|
+
// Heredocs/here-strings belong to xargs, not the sub-shell; pass none.
|
|
886
|
+
const hit = scanShellInterpreter(operands.slice(subIdx + 1), [], [], s, bySeg, sepAfter, depth);
|
|
887
|
+
if (hit) return hit;
|
|
888
|
+
}
|
|
889
|
+
if (argFile) return null; // stdin replaced by a file — no pipeline inference
|
|
890
|
+
if (NON_READING_COMMANDS.has(subBase)) return null;
|
|
891
|
+
|
|
892
|
+
const { op, prevSeg } = precedingOp(s, bySeg, sepAfter);
|
|
893
|
+
if (op !== '|' && op !== '|&') return null;
|
|
894
|
+
if (prevSeg < 0) return null;
|
|
895
|
+
|
|
896
|
+
// Every upstream operand is a candidate file name — the NON_READING
|
|
897
|
+
// exemption is bypassed for it, but the `.env.example|…` suffix exemption in
|
|
898
|
+
// isSecretBasename still holds.
|
|
899
|
+
const prevCmd = resolveCommand((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
|
|
900
|
+
if (!prevCmd) return null;
|
|
901
|
+
for (const w of prevCmd.operands) {
|
|
902
|
+
if (namesSecret(normalizeOperand(w.text))) return w.text;
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
// Emission
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
const PATTERN_TEXT = '.env, .env.<suffix> (except .env.example/.sample/.template/.dist), .secrets';
|
|
912
|
+
|
|
913
|
+
function reasonFor(code, tool, target) {
|
|
914
|
+
if (code === 'command-too-large') {
|
|
915
|
+
return `Secret read guard: this Bash command is over ${MAX_COMMAND_LENGTH} characters and ` +
|
|
916
|
+
'cannot be checked for secret-file reads. Split it into smaller commands.';
|
|
917
|
+
}
|
|
918
|
+
if (code === 'glob-too-complex') {
|
|
919
|
+
return `Secret read guard: the Grep glob '${target}' expands to more than ${MAX_GLOB_ALTERNATIVES} ` +
|
|
920
|
+
'alternatives and cannot be checked for secret-file matches. Use a narrower glob.';
|
|
921
|
+
}
|
|
922
|
+
return `Secret read guard: ${tool} would read '${target}', which matches a protected secret-file ` +
|
|
923
|
+
`pattern (${PATTERN_TEXT}). Secret values must not be read into the conversation. ` +
|
|
924
|
+
'If you need a specific value, ask the user for it; if you need the variable NAMES, ' +
|
|
925
|
+
'read the non-secret template (.env.example) instead.';
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// stdout gets the typed JSON block; stderr gets the plain reason string
|
|
929
|
+
// (Kimi's hook bus reads stderr verbatim back to the model — #3911).
|
|
930
|
+
function emitBlock(code, tool, target) {
|
|
931
|
+
const reason = reasonFor(code, tool, target);
|
|
932
|
+
deny({ decision: 'block', code, tool, path: target, reason }, reason);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Strips a `module:` prefix so Kimi's `kimi_cli.tools.file:Grep` (not in the
|
|
936
|
+
// KIMI_TOOL_NAMES map — Grep has the same name on both buses) matches.
|
|
937
|
+
function bareToolName(raw) {
|
|
938
|
+
return typeof raw === 'string' ? raw.slice(raw.lastIndexOf(':') + 1) : '';
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the
|
|
942
|
+
// payload (ReadFile / Shell) and `path` instead of `file_path`; the map and
|
|
943
|
+
// normalizer below are the byte-identical copy every guard carries (bound by
|
|
944
|
+
// tests/kimi-guard-normalization-parity.test.cjs — do not edit locally).
|
|
945
|
+
// Grep keeps its name on Kimi and is not in the map; bareToolName() above
|
|
946
|
+
// strips the module prefix for it.
|
|
947
|
+
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
|
|
948
|
+
function normalizeKimiPayload(data) {
|
|
949
|
+
// #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
|
|
950
|
+
// payloads reached the `data.tool_name` read below and threw — falsifying
|
|
951
|
+
// this function's own "total over the inputs JSON can express" claim, which
|
|
952
|
+
// property (e) now tests directly. Harmless in practice (a null payload has
|
|
953
|
+
// nothing to guard, and the throw landed in the same fail-open catch as the
|
|
954
|
+
// exit-0 it now takes deliberately) but the claim should be true as stated.
|
|
955
|
+
if (data === null || typeof data !== 'object') return data;
|
|
956
|
+
const raw = data.tool_name;
|
|
957
|
+
if (typeof raw !== 'string') return data;
|
|
958
|
+
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
|
|
959
|
+
if (!mapped) return data;
|
|
960
|
+
data.tool_name = mapped;
|
|
961
|
+
if (data.tool_response === undefined && data.tool_output !== undefined) {
|
|
962
|
+
data.tool_response = data.tool_output;
|
|
963
|
+
}
|
|
964
|
+
const input = data.tool_input;
|
|
965
|
+
if (input && typeof input === 'object') {
|
|
966
|
+
// #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
|
|
967
|
+
// not merely fill in when `file_path` happens to be absent. kimi-cli's file
|
|
968
|
+
// tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
|
|
969
|
+
// replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
|
|
970
|
+
// model's raw json-parsed
|
|
971
|
+
// arguments to PreToolUse verbatim, doing typed validation only later inside
|
|
972
|
+
// tool.call() — after the hook has already decided. So a `file_path` in a
|
|
973
|
+
// Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
|
|
974
|
+
// condition it SHADOWED the field kimi-cli actually executes on. A payload
|
|
975
|
+
// pairing a cross-root `path` with a spurious `file_path: ""` left every
|
|
976
|
+
// guard reading an empty string and exiting 0, while the identical write
|
|
977
|
+
// without the extra key blocked — a bypass needing no crash at all. The same
|
|
978
|
+
// shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
|
|
979
|
+
// inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
|
|
980
|
+
// `catch { process.exit(0) }`: the same crash-to-allow this fix closes
|
|
981
|
+
// elsewhere, reached through the guard's own read rather than through
|
|
982
|
+
// normalization. Overwriting can only ever narrow what a guard inspects to
|
|
983
|
+
// the path that will actually be written, so it cannot under-block.
|
|
984
|
+
if (typeof input.path === 'string') {
|
|
985
|
+
input.file_path = input.path;
|
|
986
|
+
}
|
|
987
|
+
const edits = Array.isArray(input.edit) ? input.edit
|
|
988
|
+
: (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
|
|
989
|
+
if (edits.length) {
|
|
990
|
+
// #2547: `e?.old`, not `e.old` — `??` guards the value, not the
|
|
991
|
+
// dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
|
|
992
|
+
// here. normalizeKimiPayload runs before any tool dispatch, so that throw
|
|
993
|
+
// reached each guard's outer `catch { process.exit(0) }` and silently
|
|
994
|
+
// downgraded a should-BLOCK call into an allow. (A string/number entry
|
|
995
|
+
// never threw — `('x').old` is a legal read yielding undefined.)
|
|
996
|
+
//
|
|
997
|
+
// The String() coercion is guarded for the same reason: `{"toString":
|
|
998
|
+
// null}` is valid JSON that throws "Cannot convert object to primitive
|
|
999
|
+
// value", which is the identical crash-to-allow with a different
|
|
1000
|
+
// trigger. Degrading only the non-coercible entry to '' keeps
|
|
1001
|
+
// stringification intact for every value that CAN coerce (numbers,
|
|
1002
|
+
// arrays, plain objects), so nothing downstream — including
|
|
1003
|
+
// gsd-prompt-guard's scan of new_string — loses content it saw before.
|
|
1004
|
+
const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
|
|
1005
|
+
// #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
|
|
1006
|
+
// `path` decision above rather than merely filling in when the field
|
|
1007
|
+
// happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
|
|
1008
|
+
// `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
|
|
1009
|
+
// no `old_string`/`new_string` at all, so either field appearing in a
|
|
1010
|
+
// Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
|
|
1011
|
+
// the old `=== undefined` condition a model-supplied `new_string: ""`
|
|
1012
|
+
// SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
|
|
1013
|
+
// reading '' and exiting at its `if (!content)` before it ever saw the
|
|
1014
|
+
// real `edit[].new` — a one-key bypass of the very scan this fix's
|
|
1015
|
+
// guarded coercion exists to keep fed. A `typeof` test would NOT close
|
|
1016
|
+
// it: a benign non-empty string shadows just as effectively as ''.
|
|
1017
|
+
input.old_string = edits.map((e) => editText(e?.old)).join('\n');
|
|
1018
|
+
input.new_string = edits.map((e) => editText(e?.new)).join('\n');
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return data;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
let input = '';
|
|
1025
|
+
const stdinTimeout = setTimeout(() => allow(undefined), 3000);
|
|
1026
|
+
process.stdin.setEncoding('utf8');
|
|
1027
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
1028
|
+
process.stdin.on('end', () => {
|
|
1029
|
+
clearTimeout(stdinTimeout);
|
|
1030
|
+
try {
|
|
1031
|
+
const data = normalizeKimiPayload(JSON.parse(input));
|
|
1032
|
+
|
|
1033
|
+
// A null/primitive payload has nothing to guard — exit deliberately
|
|
1034
|
+
// rather than throwing into the fail-open catch below (#2595 class).
|
|
1035
|
+
if (data === null || typeof data !== 'object') {
|
|
1036
|
+
allow(undefined);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const tool = bareToolName(data.tool_name);
|
|
1040
|
+
if (tool !== 'Read' && tool !== 'Grep' && tool !== 'Bash') {
|
|
1041
|
+
allow(undefined);
|
|
1042
|
+
}
|
|
1043
|
+
if (!data.tool_input || typeof data.tool_input !== 'object') {
|
|
1044
|
+
allow(undefined);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Every payload field is read TYPED in a single statement (#2547 class):
|
|
1048
|
+
// `[]`/`{}` are truthy and a non-string degrades to '' here.
|
|
1049
|
+
if (tool === 'Read') {
|
|
1050
|
+
const filePath = typeof data.tool_input.file_path === 'string' ? data.tool_input.file_path : '';
|
|
1051
|
+
if (namesSecret(filePath)) emitBlock('secret-read', tool, filePath);
|
|
1052
|
+
allow(undefined);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (tool === 'Grep') {
|
|
1056
|
+
const grepPath = typeof data.tool_input.path === 'string' ? data.tool_input.path
|
|
1057
|
+
: (typeof data.tool_input.file_path === 'string' ? data.tool_input.file_path : '');
|
|
1058
|
+
if (namesSecret(grepPath)) emitBlock('secret-read', tool, grepPath);
|
|
1059
|
+
const glob = typeof data.tool_input.glob === 'string' ? data.tool_input.glob : '';
|
|
1060
|
+
if (glob !== '') {
|
|
1061
|
+
const verdict = classifyGrepGlob(glob);
|
|
1062
|
+
if (verdict) emitBlock(verdict, tool, glob);
|
|
1063
|
+
}
|
|
1064
|
+
allow(undefined);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Bash
|
|
1068
|
+
const command = typeof data.tool_input.command === 'string' ? data.tool_input.command : '';
|
|
1069
|
+
if (command === '') allow(undefined);
|
|
1070
|
+
if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
|
|
1071
|
+
const hit = findSecretRead(command, 0);
|
|
1072
|
+
if (hit !== null) emitBlock('secret-read', tool, hit);
|
|
1073
|
+
allow(undefined);
|
|
1074
|
+
} catch {
|
|
1075
|
+
// Fail open — never block valid tool calls due to hook errors.
|
|
1076
|
+
// ON_CRASH is declared ALLOW at module top (#3911).
|
|
1077
|
+
crash(ON_CRASH, undefined);
|
|
1078
|
+
}
|
|
1079
|
+
});
|