vallum 0.3.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/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ /node_modules
2
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0]
9
+
10
+ ### Added
11
+ - `vallum doctor` — install/health self-check that validates the config file,
12
+ flags unknown `[optimizer] disabled` names, reports whether the Claude Code
13
+ hook is installed, checks that a `vallum` binary is on `PATH`, and probes the
14
+ log directory for writability. Exits non-zero only on a hard failure.
15
+ - `kubectl get` optimizer — collapses runs of healthy (`Running`/`Completed`)
16
+ resource rows while keeping the header and any pod in a problem state
17
+ (`CrashLoopBackOff`, `Pending`, `Evicted`, …).
18
+ - `terraform plan|apply` optimizer — collapses state-refresh chatter and
19
+ attribute-diff bodies while keeping per-resource action headers, the
20
+ `Plan:`/`Apply complete!` summary, and errors.
21
+ - Expanded secret-format coverage: GitLab (`glpat-`), SendGrid (`SG.`),
22
+ Twilio (`SK…`), npm (`npm_`), PyPI (`pypi-`), Hugging Face (`hf_`), OpenAI
23
+ project keys (`sk-proj-`), and bare (non-`Bearer`) JWTs.
24
+
25
+ ### Changed
26
+ - Documented a minimum supported Rust version (`rust-version = "1.85"`, raised
27
+ from 1.82 to track the `clap` 4.6 edition-2024 floor) and enforce it with a
28
+ dedicated, `--locked` CI job.
29
+
30
+ ### Security
31
+ - New scheduled `cargo audit` GitHub Actions workflow that fails on known
32
+ advisories in the dependency tree. Granted it `checks: write` so it can post
33
+ results instead of erroring on the check-run API.
34
+ - Bumped the `anyhow` dev-dependency to 1.0.103, clearing RUSTSEC-2026-0190
35
+ (unsoundness in `Error::downcast_mut`).
36
+
37
+ ### Distribution
38
+ - Prebuilt binaries for macOS (Intel + ARM) and Linux (x86_64 + aarch64, musl
39
+ static) published on tagged releases via `dist`, with shell, Homebrew,
40
+ `cargo install`, and npm installers, SHA-256 checksums, and GitHub build
41
+ provenance attestations.
42
+
43
+ ## [0.2.0]
44
+
45
+ ### Added
46
+ - ANSI stripping, whitespace collapse, token metrics, and `vallum stats`.
47
+ - Per-command optimizer framework with optimizers for `git status`/`diff`/`log`,
48
+ `cargo`, `pytest`, `npm`, `docker`, `go test`, `make`, `rg`/`grep`, and
49
+ `ls`/`find`/`fd`/`tree`.
50
+ - Concurrent bounded capture (byte cap, timeout, inherited stdin),
51
+ context-preserving truncation, and an optional `--features bpe` token counter.
52
+ - Claude Code integration: `install-hook`/`uninstall-hook`, the `vallum hook`
53
+ handler, `config show`/`config init`, shell completions, and `--tee` live log.
54
+ - Security pipeline: multilingual prompt-injection neutralization (with
55
+ invisible/bidi stripping and homoglyph folding, plus `--strict` fail-closed
56
+ mode), known-format secret redaction with context-gated entropy detection,
57
+ untrusted-output wrapping with marker defang, and private-by-default logging.
58
+
59
+ ## [0.1.0]
60
+
61
+ ### Added
62
+ - MVP: execute a command through the proxy, truncate, scrub secrets, and audit.
63
+
64
+ [0.3.0]: https://github.com/kahramanemir/Vallum/releases/tag/v0.3.0
65
+ [0.2.0]: https://github.com/kahramanemir/Vallum/releases/tag/v0.2.0
66
+ [0.1.0]: https://github.com/kahramanemir/Vallum/releases/tag/v0.1.0
package/LICENSE-APACHE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for describing the origin of the Work and
141
+ reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Support. While redistributing the Work or
166
+ Derivative Works thereof, You may choose to offer, and charge a
167
+ fee for, acceptance of support, warranty, indemnity, or other
168
+ liability obligations and/or rights consistent with this License.
169
+ However, in accepting such obligations, You may act only on Your
170
+ own behalf and on Your sole responsibility, not on behalf of any
171
+ other Contributor, and only if You agree to indemnify, defend,
172
+ and hold each Contributor harmless for any liability incurred by,
173
+ or claims asserted against, such Contributor by reason of your
174
+ accepting any such warranty or support.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Emir Kahraman
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
200
+ implied. See the License for the specific language governing
201
+ permissions and limitations under the License.
package/LICENSE-MIT ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emir Kahraman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,349 @@
1
+ # Vallum
2
+
3
+ *A security boundary between AI coding agents (Claude Code, Cursor, etc.) and your shell — secret redaction, prompt-injection defense, ANSI stripping, and command auditing in a single Rust binary.*
4
+
5
+ [![CI](https://github.com/kahramanemir/Vallum/actions/workflows/ci.yml/badge.svg)](https://github.com/kahramanemir/Vallum/actions/workflows/ci.yml)
6
+ [![crates.io](https://img.shields.io/crates/v/vallum.svg)](https://crates.io/crates/vallum)
7
+ [![license](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
8
+
9
+ A Rust CLI proxy that sits between AI agents and your shell as a **security boundary**. When an agent runs a command, Vallum redacts secrets, neutralizes prompt-injection attempts, wraps the result as untrusted data, preserves the child exit code, and audits everything — so what reaches the model is exactly what you intend it to see. As a side benefit, it strips ANSI noise and compresses long output, which also saves tokens.
10
+
11
+ ---
12
+
13
+ ## Why
14
+
15
+ When an AI agent runs shell commands on your behalf, the command output flows straight into the model's context. That output is **untrusted input**, and it creates three problems:
16
+
17
+ - It may contain **secrets** — API keys, tokens, credentials — that get forwarded to the model (and possibly logged by it).
18
+ - It may contain **adversarial text** — log lines, scraped pages, or error messages crafted to hijack the agent ("ignore previous instructions…").
19
+ - It is **unstructured and noisy**, burying the relevant signal and inflating token usage.
20
+
21
+ Vallum is a single binary that puts a controlled boundary between that output and the model.
22
+
23
+ > **Scope of the guarantees.** Secret redaction and injection neutralization are **best-effort, pattern-based** defenses. They raise the cost of an attack and catch common cases; they are not a substitute for treating all terminal output as untrusted. The untrusted-output wrapper is the durable control — keep your agent prompted to respect it.
24
+
25
+ ## Pipeline
26
+
27
+ ```mermaid
28
+ flowchart LR
29
+ A[AI Agent] -->|vallum run cmd| B[Executor]
30
+ B --> C[ANSI strip]
31
+ C --> D{Output small?}
32
+ D -->|yes| W[Whitespace collapse]
33
+ D -->|no| E{Known command?}
34
+ E -->|yes| F[Optimizer]
35
+ E -->|no| G[Whitespace collapse + Truncate]
36
+ F --> G
37
+ W --> H[Scrubber]
38
+ G --> H
39
+ H --> I[AI Agent]
40
+ B -. raw, opt-in .-> J[(~/.vallum/logs/raw.local.log)]
41
+ H -. sanitized .-> K[(~/.vallum/logs/sanitized.ai.log)]
42
+ B -.->|tokens_before| L[(~/.vallum/stats.jsonl)]
43
+ H -.->|tokens_after| L
44
+ ```
45
+
46
+ Each command flows through these stages:
47
+
48
+ 1. **Execute** — `stdout` and `stderr` are captured concurrently and merged in arrival order. Capture is bounded by a byte cap and a timeout (see Configuration). `stdin` is inherited so interactive commands work.
49
+ 2. **ANSI strip** — color and cursor-control escapes are removed.
50
+ 3. **Short-circuit** — if the output is below `min_optimize_tokens`, the optimize and truncate stages are skipped (no point summarizing a few lines). The security stages below always run.
51
+ 4. **Optimize** — if a registered `CommandOptimizer` matches (e.g. `git status`, `cargo test`, `pytest`, `npm test`), it produces a compressed view; otherwise the input passes through.
52
+ 5. **Whitespace collapse** — runs of three or more blank lines collapse to one; trailing spaces are stripped.
53
+ 6. **Truncate** — head/tail windows are preserved; important lines (errors, panics, failures) are kept **in place with surrounding context**, and ordinary gaps are elided.
54
+ 7. **Scrub** — API tokens (OpenAI, Anthropic, GitHub, GitLab, Slack, AWS, Google, Stripe, SendGrid, Twilio, npm, PyPI, Hugging Face), bearer/bare JWTs, connection-string passwords, and PEM private keys are redacted; known injection phrases are neutralized.
55
+ 8. **Wrap** — output is enclosed in `[UNTRUSTED TERMINAL OUTPUT]` markers; any forged markers inside the content are defanged so output can't break out of the wrapper.
56
+ 9. **Audit + Metrics** — the sanitized output is written under `~/.vallum/logs/` (raw logging is opt-in), and a per-command stats record is appended to `~/.vallum/stats.jsonl`.
57
+
58
+ ## Security model
59
+
60
+ Vallum applies four mechanism families to every command, in order:
61
+ prompt-injection neutralization (multilingual, with invisible/bidi character
62
+ stripping and a homoglyph-folded detection shadow; opt-in `--strict`
63
+ fail-closed mode), secret redaction (known-format patterns plus context-gated
64
+ entropy detection), untrusted-output wrapping with marker defang, and
65
+ private-by-default logging (raw log opt-in, `0600` permissions).
66
+
67
+ **Full threat model:** see [SECURITY.md](SECURITY.md) — what is protected,
68
+ by which mechanism, at what strength, and what is explicitly **not**
69
+ guaranteed.
70
+
71
+ ## Built-in Optimizers
72
+
73
+ - `git status`: summarizes large working-tree sections while keeping branch state and representative file entries
74
+ - `git diff` / `git log`: collapse large unchanged-context runs / long commit bodies while keeping headers, hunks, and changed lines
75
+ - `cargo build|test|check|clippy|run`: collapses compile/download noise and preserves summaries, failures, and diagnostics
76
+ - `pytest` and `python -m pytest`: hides progress-dot spam while keeping collection, failure, and summary sections
77
+ - `npm test|install|ci|run`: collapses repeated `PASS` and warning lines while preserving result summaries
78
+ - `docker build|compose`: collapse layer/step progress while keeping step headers, errors, and the final result
79
+ - `go test`: hide `=== RUN`/`--- PASS` spam while keeping failures and the summary
80
+ - `make`: surface errors/warnings while collapsing ordinary build noise
81
+ - `kubectl get`: collapse runs of healthy (`Running`/`Completed`) resources while keeping the header and any pod in a problem state (`CrashLoopBackOff`, `Pending`, `Evicted`, …)
82
+ - `terraform plan|apply`: collapse state-refresh chatter and attribute-diff bodies while keeping resource action headers, the `Plan:`/`Apply complete!` summary, and errors
83
+ - `rg` / `grep` (also `egrep`/`fgrep`): group matches by file, keep the first few per file, and summarize the rest with per-file and total counts
84
+ - `ls` / `find` / `fd` / `tree`: keep the leading entries and summarize the rest (with a top-directories breakdown for path lists); error lines are always preserved
85
+
86
+ ## Configuration
87
+
88
+ Vallum looks for `~/.vallum/config.toml` by default. For testing or per-project overrides, point `VALLUM_CONFIG` at a different file.
89
+
90
+ ```toml
91
+ [audit]
92
+ log_dir = "/tmp/vallum-logs"
93
+ raw_enabled = false # raw, unredacted logging is opt-in
94
+ sanitized_enabled = true
95
+
96
+ [pipeline]
97
+ head_lines = 20
98
+ tail_lines = 20
99
+ min_optimize_tokens = 50 # skip optimize/truncate below this token estimate
100
+ max_output_bytes = 10485760 # 10 MiB capture cap; excess is dropped with a marker
101
+ timeout_secs = 300 # kill the child after N seconds (0 = disabled)
102
+ max_line_length = 2000 # truncate single lines longer than this (0 disables)
103
+
104
+ [optimizer]
105
+ disabled = [] # optimizer names to turn off; all on by default
106
+
107
+ [scrubber]
108
+ entropy = true # context-gated entropy redaction of credential-ish values
109
+ normalize = true # strip invisible/bidi chars; fold homoglyphs for injection matching
110
+ extra_secret_patterns = [
111
+ { pattern = "token-[0-9]+", replacement = "token-***" }
112
+ ]
113
+
114
+ [security]
115
+ strict = false # block the entire output when a prompt injection is detected
116
+ ```
117
+
118
+ Supported settings:
119
+
120
+ - `audit.log_dir`: audit log directory override
121
+ - `audit.raw_enabled`: enable raw (unredacted) terminal logs — **default `false`**
122
+ - `audit.sanitized_enabled`: enable or disable sanitized logs
123
+ - `pipeline.head_lines` / `pipeline.tail_lines`: truncation window
124
+ - `pipeline.min_optimize_tokens`: outputs below this estimate skip optimize/truncate
125
+ - `pipeline.max_output_bytes`: maximum bytes captured from a command (default 10 MiB)
126
+ - `pipeline.timeout_secs`: command timeout in seconds; `0` disables it (default 300)
127
+ - `optimizer.disabled`: list of optimizer names to disable (git_status, git_diff, git_log, cargo, pytest, npm, docker, go_test, make, kubectl, terraform, grep, file_list) — default none
128
+ - `pipeline.max_line_length`: cap individual line length; longer lines are truncated mid-line with an elision marker — default 2000, `0` disables
129
+ - `scrubber.extra_secret_patterns`: extra regex-based redaction rules
130
+ - `scrubber.entropy`: context-gated entropy redaction of credential-ish assignment values — **default `true`**
131
+ - `scrubber.normalize`: strip invisible/bidi characters and fold homoglyphs for injection matching — **default `true`**
132
+ - `security.strict`: when `true` (or `--strict`), the output is replaced with `[OUTPUT BLOCKED: prompt injection detected]` if any injection is detected — **default `false`**
133
+
134
+ ## Install
135
+
136
+ **Shell (macOS + Linux):**
137
+
138
+ ```bash
139
+ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/kahramanemir/Vallum/releases/latest/download/vallum-installer.sh | sh
140
+ ```
141
+
142
+ **Homebrew:**
143
+
144
+ ```bash
145
+ brew install kahramanemir/homebrew-tap/vallum
146
+ ```
147
+
148
+ **Cargo:**
149
+
150
+ ```bash
151
+ cargo install vallum # heuristic token counts (default)
152
+ cargo install vallum --features bpe # exact BPE token counts (adds tiktoken-rs)
153
+ ```
154
+
155
+ **npm:**
156
+
157
+ ```bash
158
+ npm install -g vallum
159
+ ```
160
+
161
+ **Prebuilt binaries** for macOS (Intel + ARM) and Linux (x86_64 + aarch64) are
162
+ attached to every [GitHub Release](https://github.com/kahramanemir/Vallum/releases),
163
+ with SHA-256 checksums and build-provenance attestations. Verify a download with:
164
+
165
+ ```bash
166
+ gh attestation verify ./vallum --repo kahramanemir/Vallum
167
+ ```
168
+
169
+ ### Build from source
170
+
171
+ ```bash
172
+ cargo build --release # default: dependency-free heuristic token counts
173
+ cargo build --release --features bpe # exact BPE token counts (adds tiktoken-rs)
174
+ ```
175
+
176
+ The binary lands at `target/release/vallum`.
177
+
178
+ ## Usage
179
+
180
+ ```bash
181
+ vallum run <command> [args...] # run a command through the proxy
182
+ vallum run --json <command> ... # emit structured JSON
183
+ vallum run --strict <command> ... # block output if a prompt injection is detected
184
+ vallum run --tee <command> ... # also append raw output to ~/.vallum/live.log
185
+ vallum stats # show cumulative token savings
186
+ vallum stats --reset # delete collected stats
187
+
188
+ # Integration & UX
189
+ vallum install-hook # register vallum in ~/.claude/settings.json
190
+ vallum install-hook --project # register in <cwd>/.claude/settings.json
191
+ vallum uninstall-hook # remove the vallum hook entry
192
+ vallum hook # internal: invoked by Claude Code (don't run directly)
193
+ vallum config show # print effective merged config as TOML
194
+ vallum config init [--force] # scaffold ~/.vallum/config.toml
195
+ vallum doctor # self-check: config, hook, PATH, log dir
196
+ vallum completions <bash|zsh|fish|elvish|powershell> > completions/_vallum
197
+ ```
198
+
199
+ Examples:
200
+
201
+ ```bash
202
+ vallum run ls -la
203
+ vallum run cargo test
204
+ vallum run git status
205
+ vallum run pytest
206
+ vallum run npm test
207
+ vallum run sh -- -c 'exit 7' # preserves the child exit code
208
+ vallum run --json printf "hello\n"
209
+ ```
210
+
211
+ Example JSON output:
212
+
213
+ ```json
214
+ {
215
+ "command": "printf",
216
+ "args": ["hello\\n"],
217
+ "exit_code": 0,
218
+ "optimizer": null,
219
+ "tokens_before": 1,
220
+ "tokens_after": 18,
221
+ "sanitized_output": "[UNTRUSTED TERMINAL OUTPUT START]\nhello\n[UNTRUSTED TERMINAL OUTPUT END]\n"
222
+ }
223
+ ```
224
+
225
+ Note how a tiny output ends up *larger* after wrapping: the security wrapper has a fixed cost, and on short commands that cost dominates. Token savings show up on the large, noisy outputs (builds, test runs, big diffs) — see below.
226
+
227
+ ### Exit codes
228
+
229
+ - The child's own exit code is propagated as Vallum's exit code on success.
230
+ - Vallum-level failures (bad config, executor spawn error, JSON serialization error) exit **`125`** — the `env(1)` "command not invoked" convention — so they are distinguishable from the child's real exit 1.
231
+
232
+ ## Claude Code integration
233
+
234
+ `vallum install-hook` writes a `PreToolUse` entry into `~/.claude/settings.json` (default, user-level) or `<cwd>/.claude/settings.json` (`--project`). A timestamped `.bak-<unix_ts>` backup of the settings file is written before any modification. The command is idempotent — re-running it without `--force` is a no-op if the entry already exists; `--force` replaces an existing entry.
235
+
236
+ Once installed, Claude Code invokes `vallum hook` before every Bash tool call. The hook rewrites the command to `vallum run -- bash -c '<original>'` so the full Vallum pipeline (capture, ANSI strip, optimize, scrub, wrap) runs on every shell invocation without any change to how you or the agent writes commands. Because the hook wraps commands as `bash -c '<original>'`, Vallum unwraps simple scripts (no pipes, redirects, quoting, or other shell metacharacters) before optimizer matching, so `bash -c 'git status'` still hits the `git_status` optimizer; complex scripts fall back to generic compression. Known TUI programs (`vim`, `vi`, `nano`, `less`, `more`, `top`, `htop`, `tmux`, `screen`) are skipped because Vallum captures stdout and would break their TTY requirements. Commands already starting with `vallum` are skipped for idempotency.
237
+
238
+ To remove the hook, run `vallum uninstall-hook` — it removes only the Vallum entry, leaving the rest of your settings file untouched.
239
+
240
+ **Live progress.** `vallum run --tee` appends the child's raw stdout/stderr to `~/.vallum/live.log` as lines arrive. Watch it from a side terminal with `tail -f ~/.vallum/live.log`. The tee target is a private file (`0600`), not a stream the agent ever reads — the agent's input is still the wrapped, scrubbed pipeline output on stdout. Tee is best-effort: if the file can't be opened or written, the command runs normally without it.
241
+
242
+ ## Measuring savings
243
+
244
+ Every `vallum run` appends one JSON record to `~/.vallum/stats.jsonl` with raw and sanitized token estimates. Counting goes through a pluggable `TokenEstimator`; the default is a dependency-free heuristic (word runs + symbols) that tracks BPE better than a flat chars/4 ratio. `vallum stats` aggregates the file. Build with `--features bpe` to count tokens with an exact `tiktoken` (o200k_base) tokenizer instead of the default dependency-free heuristic; it is an OpenAI-family approximation of Claude's tokenizer.
245
+
246
+ ```
247
+ Vallum — Token savings report
248
+ ─────────────────────────────────────────
249
+ Commands run: 142
250
+ Tokens (raw): 58,420
251
+ Tokens (sanitized): 11,205
252
+ Saved: 47,215 (80.8%)
253
+
254
+ Top savings by command
255
+ ─────────────────────────────────────────
256
+ cargo build 18,940 saved (94%)
257
+ git status 12,103 saved (88%)
258
+ npm install 8,442 saved (76%)
259
+ ```
260
+
261
+ ### Reproducing the savings
262
+
263
+ Run `cargo bench` to time the full pipeline against seven committed fixtures (`git status`, `cargo build`, `pytest`, `npm install`, a minified blob, an `rg` match list, a `find` file list) and print a raw-vs-sanitized token table. Fixtures live in `benches/fixtures/` and are versioned with the repo, so the savings figures are reproducible from a clean checkout. The bench also prints a summary table to stderr after all criterion measurements complete, showing each fixture's before/after token counts.
264
+
265
+ ## Tests
266
+
267
+ **Property tests.** The scrubber, truncator, ansi, whitespace, and optimizer modules carry inline `proptest` invariants (no-panic, structural bounds, idempotency) that run under the normal `cargo test`.
268
+
269
+ ## Modules
270
+
271
+ | File | Responsibility |
272
+ | ----------------------------- | ---------------------------------------------------- |
273
+ | `src/cli.rs` | Argument parsing (`run`, `stats`, `hook`, `install-hook`/`uninstall-hook`, `config`, `completions`) |
274
+ | `src/config.rs` | Config loading, defaults, and validation |
275
+ | `src/executor.rs` | Concurrent capture with byte cap, timeout, stdin; optional tee to `~/.vallum/live.log` |
276
+ | `src/ansi.rs` | Stripping ANSI escape sequences |
277
+ | `src/whitespace.rs` | Collapsing blank-line runs, stripping trailing space |
278
+ | `src/optimizer/mod.rs` | `CommandOptimizer` trait + dispatch registry |
279
+ | `src/optimizer/cargo.rs` | Summary optimizer for noisy `cargo` output |
280
+ | `src/optimizer/docker.rs` | Summary optimizer for `docker build`/`compose` output |
281
+ | `src/optimizer/file_list.rs` | Entry-capping optimizer for `ls`/`find`/`fd`/`tree` |
282
+ | `src/optimizer/git_diff.rs` | Summary optimizer for `git diff` output |
283
+ | `src/optimizer/git_log.rs` | Summary optimizer for `git log` output |
284
+ | `src/optimizer/git_status.rs` | Summary optimizer for `git status` output |
285
+ | `src/optimizer/go_test.rs` | Summary optimizer for `go test` output |
286
+ | `src/optimizer/grep.rs` | Match-grouping optimizer for `rg`/`grep` output |
287
+ | `src/optimizer/kubectl.rs` | Healthy-row collapsing optimizer for `kubectl get` output |
288
+ | `src/optimizer/make.rs` | Summary optimizer for `make` output |
289
+ | `src/optimizer/npm.rs` | Summary optimizer for noisy `npm` output |
290
+ | `src/optimizer/pytest.rs` | Summary optimizer for noisy `pytest` output |
291
+ | `src/optimizer/terraform.rs` | Summary optimizer for `terraform plan`/`apply` output |
292
+ | `src/truncator.rs` | Context-preserving head/tail truncation |
293
+ | `src/scrubber/mod.rs` | Scrub pipeline: `sanitize`/`redact` orchestration + wrapper |
294
+ | `src/scrubber/secrets.rs` | Known-format secret redaction patterns |
295
+ | `src/scrubber/entropy.rs` | Context-gated entropy secret detection |
296
+ | `src/scrubber/injection.rs` | Prompt-injection neutralization |
297
+ | `src/scrubber/normalize.rs` | Invisible-char strip + homoglyph detection shadow |
298
+ | `src/scrubber/markers.rs` | Untrusted-output marker defang |
299
+ | `src/tokenizer.rs` | Pluggable `TokenEstimator` + heuristic default |
300
+ | `src/fsutil.rs` | Private (0600) append-file helper |
301
+ | `src/audit.rs` | Append-only log writer |
302
+ | `src/metrics.rs` | Token estimation + JSONL stats writer |
303
+ | `src/stats.rs` | `vallum stats` aggregation and reporting |
304
+ | `src/hook.rs` | Claude Code PreToolUse handler: rewrites Bash calls to `vallum run` |
305
+ | `src/install_hook.rs` | `install-hook`/`uninstall-hook`: read-modify-write of Claude Code settings.json |
306
+ | `src/doctor.rs` | `vallum doctor`: install/health self-checks (config, hook, PATH, log dir) |
307
+ | `src/main.rs` | Pipeline wiring |
308
+ | `src/lib.rs` | Library surface — re-exports modules so integration tests can exercise internals |
309
+
310
+ ## Roadmap
311
+
312
+ - [x] v0.1 — MVP: execute, truncate, scrub, audit
313
+ - [x] v0.2 — ANSI strip, whitespace collapse, token metrics, per-command optimizer framework, `vallum stats`
314
+ - [x] Post-v0.2 hardening — exit-code propagation, structured JSON output, configurable pipeline, cargo/pytest/npm optimizers
315
+ - [x] Security sweep — concurrent bounded capture (cap + timeout + stdin), context-preserving truncation, broadened injection neutralization, marker anti-spoofing, raw-logs-off-by-default with `0600` perms, small-output short-circuit, pluggable token estimator
316
+ - [x] Sub-project B — broader command coverage (git diff, git log, docker, go test, make), optimizer toggles (`[optimizer] disabled`), long-line truncation (`pipeline.max_line_length`), optional BPE token counting (`--features bpe`)
317
+ - [x] Sub-project C — integration/UX: `install-hook`/`uninstall-hook` (Claude Code PreToolUse), `vallum hook` handler, `config show`/`config init`, `vallum completions <shell>`, exit-125 convention
318
+ - [x] Sub-project D — live-tee (`vallum run --tee`, `~/.vallum/live.log`); PTY/streaming proper descoped because the hook skip-list (sub-project C) removed the urgency
319
+ - [x] Sub-project E — maturity: `proptest` invariants across scrubber/truncator/ansi/whitespace/optimizer modules; `criterion` benchmark harness with five versioned fixtures (`benches/fixtures/`); savings figures reproducible from a clean checkout
320
+ - [x] grep/file_list optimizers + hook-mode dispatch fix — `bash -c` unwrap so optimizers fire via the Claude Code hook; `rg`/`grep` match grouping; `ls`/`find`/`fd`/`tree` entry capping; two new bench fixtures (seven total)
321
+ - [x] Context-gated entropy secret detection — credential-ish assignment values with high Shannon entropy are masked; bare tokens (commit SHAs, UUIDs) structurally exempt; `[scrubber] entropy` flag (default on)
322
+ - [x] Injection precision tuning — reveal-family requires a possessive or system-directed object in all five languages; `System:`/`Assistant:` turn lines get a natural-language veto so log lines pass; entropy tokenizer captures separator runs (`key== "<value>"`); security corpus grown to 20 injections / 18 benign samples
323
+ - [x] Sub-project I — injection input normalization (strip invisible/bidi; NFKC + confusable-folded detection shadow; no-space ignore-family; `scrubber.normalize` flag)
324
+ - [x] Sub-project J — scrub-stage hardening: injection scan before secret masking (closes the secret-eats-trigger gap), reveal-family no-space detection in five languages, config extra-pattern compile-once (`CompiledRule`)
325
+ - [x] Sub-project K — broader infra/optimizer coverage: `kubectl get` (collapse healthy resource rows, keep problem-state pods) and `terraform plan|apply` (collapse refresh chatter + attribute diffs, keep action headers/summary/errors); expanded secret-format coverage (GitLab, SendGrid, Twilio, npm, PyPI, Hugging Face, OpenAI project keys, bare JWTs)
326
+ - [x] Sub-project L — `vallum doctor` install/health self-check: validates the config file, flags unknown `[optimizer] disabled` names, reports hook installation, checks the binary is on `PATH`, and probes log-dir writability (exit non-zero only on hard failures)
327
+ - [x] Sub-project M — distribution: `dist`-based tagged-release pipeline producing prebuilt binaries for macOS (Intel + ARM) and Linux (x86_64 + aarch64, musl static) with shell/Homebrew/`cargo install`/npm installers, SHA-256 checksums, and GitHub build-provenance attestations; crates.io publish on final tags; MSRV raised to 1.85 and the MSRV CI check pinned with `--locked`
328
+ - [ ] Deferred — Chinese-language injection, `cargo-fuzz`/libFuzzer harness, performance regression gating, Windows support (the `0600`/timeout-backed guarantees need a Windows equivalent first)
329
+
330
+ ## Name
331
+
332
+ **Vallum** — Latin for the defensive embankment along Roman frontier fortifications. The thing that stands between what's inside and what's outside.
333
+
334
+ ## License
335
+
336
+ Licensed under either of
337
+
338
+ - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
339
+ - MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
340
+
341
+ at your option.
342
+
343
+ ### Contribution
344
+
345
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the local workflow (fmt/clippy/test
346
+ gate, MSRV, how to add an optimizer or secret pattern) and [CHANGELOG.md](CHANGELOG.md)
347
+ for the release history.
348
+
349
+ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
@@ -0,0 +1,348 @@
1
+ const {
2
+ createWriteStream,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtemp,
6
+ rmSync,
7
+ } = require("fs");
8
+ const { join, sep } = require("path");
9
+ const { spawnSync } = require("child_process");
10
+ const { tmpdir } = require("os");
11
+
12
+ const https = require("node:https");
13
+ const http = require("node:http");
14
+
15
+ const tmpDir = tmpdir();
16
+
17
+ const error = (msg) => {
18
+ console.error(msg);
19
+ process.exit(1);
20
+ };
21
+
22
+ function getProxyForUrl(urlString) {
23
+ const url = new URL(urlString);
24
+ const isHttps = url.protocol === "https:";
25
+
26
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
27
+ if (noProxy === "*") return null;
28
+ if (noProxy) {
29
+ const hostname = url.hostname.toLowerCase();
30
+ const noProxyList = noProxy.split(",").map((s) => s.trim().toLowerCase());
31
+ for (const entry of noProxyList) {
32
+ if (hostname === entry || hostname.endsWith("." + entry)) {
33
+ return null;
34
+ }
35
+ }
36
+ }
37
+
38
+ const proxyEnv = isHttps
39
+ ? process.env.HTTPS_PROXY || process.env.https_proxy
40
+ : process.env.HTTP_PROXY || process.env.http_proxy;
41
+
42
+ if (!proxyEnv) return null;
43
+
44
+ const proxyUrl = new URL(proxyEnv);
45
+
46
+ let auth = null;
47
+ if (proxyUrl.username || proxyUrl.password) {
48
+ auth = `${proxyUrl.username}:${proxyUrl.password}`;
49
+ }
50
+
51
+ return {
52
+ hostname: proxyUrl.hostname,
53
+ port: proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80),
54
+ auth: auth,
55
+ };
56
+ }
57
+
58
+ function connectThroughProxy(proxy, target) {
59
+ return new Promise((resolve, reject) => {
60
+ const headers = {};
61
+ if (proxy.auth) {
62
+ headers["Proxy-Authorization"] =
63
+ "Basic " + Buffer.from(proxy.auth).toString("base64");
64
+ }
65
+
66
+ const connectReq = http.request({
67
+ hostname: proxy.hostname,
68
+ port: proxy.port,
69
+ method: "CONNECT",
70
+ path: `${target.hostname}:${target.port || 443}`,
71
+ headers,
72
+ });
73
+ connectReq.on("connect", (res, socket) => {
74
+ if (res.statusCode === 200) {
75
+ resolve(socket);
76
+ } else {
77
+ reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`));
78
+ }
79
+ });
80
+ connectReq.on("error", reject);
81
+ connectReq.end();
82
+ });
83
+ }
84
+
85
+ function download(urlString, maxRedirects) {
86
+ if (maxRedirects === undefined) maxRedirects = 5;
87
+ return new Promise((resolve, reject) => {
88
+ if (maxRedirects < 0) {
89
+ return reject(new Error("Too many redirects"));
90
+ }
91
+
92
+ const parsed = new URL(urlString);
93
+ const isHttps = parsed.protocol === "https:";
94
+ const mod = isHttps ? https : http;
95
+ const proxy = getProxyForUrl(urlString);
96
+
97
+ const doRequest = (extraOptions) => {
98
+ const options = Object.assign(
99
+ {
100
+ hostname: parsed.hostname,
101
+ port: parsed.port || (isHttps ? 443 : 80),
102
+ path: parsed.pathname + parsed.search,
103
+ method: "GET",
104
+ headers: { "User-Agent": "cargo-dist-npm-installer" },
105
+ },
106
+ extraOptions || {},
107
+ );
108
+
109
+ if (proxy && !isHttps) {
110
+ // HTTP through HTTP proxy: request the full URL via the proxy
111
+ options.hostname = proxy.hostname;
112
+ options.port = proxy.port;
113
+ options.path = urlString;
114
+ if (proxy.auth) {
115
+ options.headers["Proxy-Authorization"] =
116
+ "Basic " + Buffer.from(proxy.auth).toString("base64");
117
+ }
118
+ }
119
+
120
+ const req = mod.request(options, (res) => {
121
+ if (
122
+ res.statusCode >= 300 &&
123
+ res.statusCode < 400 &&
124
+ res.headers.location
125
+ ) {
126
+ res.resume();
127
+ const nextUrl = new URL(res.headers.location, urlString).toString();
128
+ return download(nextUrl, maxRedirects - 1).then(resolve, reject);
129
+ }
130
+ if (res.statusCode < 200 || res.statusCode >= 300) {
131
+ res.resume();
132
+ return reject(new Error(`HTTP ${res.statusCode} from ${urlString}`));
133
+ }
134
+ resolve(res);
135
+ });
136
+ req.on("error", reject);
137
+ req.end();
138
+ };
139
+
140
+ if (proxy && isHttps) {
141
+ connectThroughProxy(proxy, parsed).then(
142
+ (socket) => doRequest({ socket, agent: false }),
143
+ reject,
144
+ );
145
+ } else {
146
+ doRequest();
147
+ }
148
+ });
149
+ }
150
+
151
+ class Package {
152
+ constructor(platform, name, url, filename, zipExt, binaries) {
153
+ let errors = [];
154
+ if (typeof url !== "string") {
155
+ errors.push("url must be a string");
156
+ } else {
157
+ try {
158
+ new URL(url);
159
+ } catch (e) {
160
+ errors.push(e);
161
+ }
162
+ }
163
+ if (name && typeof name !== "string") {
164
+ errors.push("package name must be a string");
165
+ }
166
+ if (!name) {
167
+ errors.push("You must specify the name of your package");
168
+ }
169
+ if (binaries && typeof binaries !== "object") {
170
+ errors.push("binaries must be a string => string map");
171
+ }
172
+ if (!binaries) {
173
+ errors.push("You must specify the binaries in the package");
174
+ }
175
+
176
+ if (errors.length > 0) {
177
+ let errorMsg =
178
+ "One or more of the parameters you passed to the Binary constructor are invalid:\n";
179
+ errors.forEach((error) => {
180
+ errorMsg += error;
181
+ });
182
+ errorMsg +=
183
+ '\n\nCorrect usage: new Package("my-binary", "https://example.com/binary/download.tar.gz", {"my-binary": "my-binary"})';
184
+ error(errorMsg);
185
+ }
186
+
187
+ this.platform = platform;
188
+ this.url = url;
189
+ this.name = name;
190
+ this.filename = filename;
191
+ this.zipExt = zipExt;
192
+ this.installDirectory = join(__dirname, "node_modules", ".bin_real");
193
+ this.binaries = binaries;
194
+
195
+ if (!existsSync(this.installDirectory)) {
196
+ mkdirSync(this.installDirectory, { recursive: true });
197
+ }
198
+ }
199
+
200
+ exists() {
201
+ for (const binaryName in this.binaries) {
202
+ const binRelPath = this.binaries[binaryName];
203
+ const binPath = join(this.installDirectory, binRelPath);
204
+ if (!existsSync(binPath)) {
205
+ return false;
206
+ }
207
+ }
208
+ return true;
209
+ }
210
+
211
+ install(suppressLogs = false) {
212
+ if (this.exists()) {
213
+ if (!suppressLogs) {
214
+ console.error(
215
+ `${this.name} is already installed, skipping installation.`,
216
+ );
217
+ }
218
+ return Promise.resolve();
219
+ }
220
+
221
+ try {
222
+ rmSync(this.installDirectory, { recursive: true, force: true });
223
+ } catch {
224
+ // ignore - directory may not exist
225
+ }
226
+
227
+ mkdirSync(this.installDirectory, { recursive: true });
228
+
229
+ if (!suppressLogs) {
230
+ console.error(`Downloading release from ${this.url}`);
231
+ }
232
+
233
+ return download(this.url)
234
+ .then((res) => {
235
+ return new Promise((resolve, reject) => {
236
+ mkdtemp(`${tmpDir}${sep}`, (err, directory) => {
237
+ if (err) return reject(err);
238
+ let tempFile = join(directory, this.filename);
239
+ const sink = res.pipe(createWriteStream(tempFile));
240
+ sink.on("error", (err) => reject(err));
241
+ sink.on("close", () => {
242
+ if (/\.tar\.*/.test(this.zipExt)) {
243
+ const result = spawnSync("tar", [
244
+ "xf",
245
+ tempFile,
246
+ // The tarballs are stored with a leading directory
247
+ // component; we strip one component in the
248
+ // shell installers too.
249
+ "--strip-components",
250
+ "1",
251
+ "-C",
252
+ this.installDirectory,
253
+ ]);
254
+ if (result.status == 0) {
255
+ resolve();
256
+ } else if (result.error) {
257
+ reject(result.error);
258
+ } else {
259
+ reject(
260
+ new Error(
261
+ `An error occurred untarring the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
262
+ ),
263
+ );
264
+ }
265
+ } else if (this.zipExt == ".zip") {
266
+ let result;
267
+ if (this.platform.artifactName.includes("windows")) {
268
+ // Windows does not have "unzip" by default on many installations, instead
269
+ // we use Expand-Archive from powershell
270
+ result = spawnSync("powershell.exe", [
271
+ "-NoProfile",
272
+ "-NonInteractive",
273
+ "-Command",
274
+ `& {
275
+ param([string]$LiteralPath, [string]$DestinationPath)
276
+ Expand-Archive -LiteralPath $LiteralPath -DestinationPath $DestinationPath -Force
277
+ }`,
278
+ tempFile,
279
+ this.installDirectory,
280
+ ]);
281
+ } else {
282
+ result = spawnSync("unzip", [
283
+ "-q",
284
+ tempFile,
285
+ "-d",
286
+ this.installDirectory,
287
+ ]);
288
+ }
289
+
290
+ if (result.status == 0) {
291
+ resolve();
292
+ } else if (result.error) {
293
+ reject(result.error);
294
+ } else {
295
+ reject(
296
+ new Error(
297
+ `An error occurred unzipping the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
298
+ ),
299
+ );
300
+ }
301
+ } else {
302
+ reject(
303
+ new Error(`Unrecognized file extension: ${this.zipExt}`),
304
+ );
305
+ }
306
+ });
307
+ });
308
+ });
309
+ })
310
+ .then(() => {
311
+ if (!suppressLogs) {
312
+ console.error(`${this.name} has been installed!`);
313
+ }
314
+ })
315
+ .catch((e) => {
316
+ error(`Error fetching release: ${e.message}`);
317
+ });
318
+ }
319
+
320
+ run(binaryName) {
321
+ const promise = !this.exists() ? this.install(true) : Promise.resolve();
322
+
323
+ promise
324
+ .then(() => {
325
+ const [, , ...args] = process.argv;
326
+
327
+ const options = { cwd: process.cwd(), stdio: "inherit" };
328
+
329
+ const binRelPath = this.binaries[binaryName];
330
+ if (!binRelPath) {
331
+ error(`${binaryName} is not a known binary in ${this.name}`);
332
+ }
333
+ const binPath = join(this.installDirectory, binRelPath);
334
+ const result = spawnSync(binPath, args, options);
335
+
336
+ if (result.error) {
337
+ error(result.error);
338
+ }
339
+
340
+ process.exit(result.status);
341
+ })
342
+ .catch((e) => {
343
+ error(e.message);
344
+ });
345
+ }
346
+ }
347
+
348
+ module.exports.Package = Package;
package/binary.js ADDED
@@ -0,0 +1,124 @@
1
+ const { Package } = require("./binary-install");
2
+ const os = require("os");
3
+ const libc = require("detect-libc");
4
+
5
+ const error = (msg) => {
6
+ console.error(msg);
7
+ process.exit(1);
8
+ };
9
+
10
+ const {
11
+ name,
12
+ artifactDownloadUrls,
13
+ supportedPlatforms,
14
+ glibcMinimum,
15
+ } = require("./package.json");
16
+
17
+ // FIXME: implement NPM installer handling of fallback download URLs
18
+ const artifactDownloadUrl = artifactDownloadUrls[0];
19
+ const builderGlibcMajorVersion = glibcMinimum.major;
20
+ const builderGlibcMinorVersion = glibcMinimum.series;
21
+
22
+ const getPlatform = () => {
23
+ const rawOsType = os.type();
24
+ const rawArchitecture = os.arch();
25
+
26
+ // We want to use rust-style target triples as the canonical key
27
+ // for a platform, so translate the "os" library's concepts into rust ones
28
+ let osType = "";
29
+ switch (rawOsType) {
30
+ case "Windows_NT":
31
+ osType = "pc-windows-msvc";
32
+ break;
33
+ case "Darwin":
34
+ osType = "apple-darwin";
35
+ break;
36
+ case "Linux":
37
+ osType = "unknown-linux-gnu";
38
+ break;
39
+ }
40
+
41
+ let arch = "";
42
+ switch (rawArchitecture) {
43
+ case "x64":
44
+ arch = "x86_64";
45
+ break;
46
+ case "arm64":
47
+ arch = "aarch64";
48
+ break;
49
+ }
50
+
51
+ if (rawOsType === "Linux") {
52
+ if (libc.familySync() == "musl") {
53
+ osType = "unknown-linux-musl-dynamic";
54
+ } else if (libc.isNonGlibcLinuxSync()) {
55
+ console.warn(
56
+ "Your libc is neither glibc nor musl; trying static musl binary instead",
57
+ );
58
+ osType = "unknown-linux-musl-static";
59
+ } else {
60
+ let libcVersion = libc.versionSync();
61
+ let splitLibcVersion = libcVersion.split(".");
62
+ let libcMajorVersion = splitLibcVersion[0];
63
+ let libcMinorVersion = splitLibcVersion[1];
64
+ if (
65
+ libcMajorVersion != builderGlibcMajorVersion ||
66
+ libcMinorVersion < builderGlibcMinorVersion
67
+ ) {
68
+ // We can't run the glibc binaries, but we can run the static musl ones
69
+ // if they exist
70
+ console.warn(
71
+ "Your glibc isn't compatible; trying static musl binary instead",
72
+ );
73
+ osType = "unknown-linux-musl-static";
74
+ }
75
+ }
76
+ }
77
+
78
+ // Assume the above succeeded and build a target triple to look things up with.
79
+ // If any of it failed, this lookup will fail and we'll handle it like normal.
80
+ let targetTriple = `${arch}-${osType}`;
81
+ let platform = supportedPlatforms[targetTriple];
82
+
83
+ if (!platform) {
84
+ error(
85
+ `Platform with type "${rawOsType}" and architecture "${rawArchitecture}" is not supported by ${name}.\nYour system must be one of the following:\n\n${Object.keys(
86
+ supportedPlatforms,
87
+ ).join(",")}`,
88
+ );
89
+ }
90
+
91
+ return platform;
92
+ };
93
+
94
+ const getPackage = () => {
95
+ const platform = getPlatform();
96
+ const url = `${artifactDownloadUrl}/${platform.artifactName}`;
97
+ let filename = platform.artifactName;
98
+ let ext = platform.zipExt;
99
+ let binary = new Package(platform, name, url, filename, ext, platform.bins);
100
+
101
+ return binary;
102
+ };
103
+
104
+ const install = (suppressLogs) => {
105
+ if (!artifactDownloadUrl || artifactDownloadUrl.length === 0) {
106
+ console.warn("in demo mode, not installing binaries");
107
+ return;
108
+ }
109
+ const pkg = getPackage();
110
+
111
+ return pkg.install(suppressLogs);
112
+ };
113
+
114
+ const run = (binaryName) => {
115
+ const pkg = getPackage();
116
+
117
+ pkg.run(binaryName);
118
+ };
119
+
120
+ module.exports = {
121
+ install,
122
+ run,
123
+ getPackage,
124
+ };
package/install.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { install } = require("./binary");
4
+ install(false);
@@ -0,0 +1,52 @@
1
+ {
2
+ "lockfileVersion": 3,
3
+ "name": "vallum",
4
+ "packages": {
5
+ "": {
6
+ "bin": {
7
+ "vallum": "run-vallum.js"
8
+ },
9
+ "dependencies": {
10
+ "detect-libc": "^2.1.2"
11
+ },
12
+ "devDependencies": {
13
+ "prettier": "^3.8.3"
14
+ },
15
+ "engines": {
16
+ "node": ">=14.14",
17
+ "npm": ">=6"
18
+ },
19
+ "hasInstallScript": true,
20
+ "license": "MIT OR Apache-2.0",
21
+ "name": "vallum",
22
+ "version": "0.3.0"
23
+ },
24
+ "node_modules/detect-libc": {
25
+ "engines": {
26
+ "node": ">=8"
27
+ },
28
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
29
+ "license": "Apache-2.0",
30
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
31
+ "version": "2.1.2"
32
+ },
33
+ "node_modules/prettier": {
34
+ "bin": {
35
+ "prettier": "bin/prettier.cjs"
36
+ },
37
+ "dev": true,
38
+ "engines": {
39
+ "node": ">=14"
40
+ },
41
+ "funding": {
42
+ "url": "https://github.com/prettier/prettier?sponsor=1"
43
+ },
44
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
45
+ "license": "MIT",
46
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
47
+ "version": "3.8.3"
48
+ }
49
+ },
50
+ "requires": true,
51
+ "version": "0.3.0"
52
+ }
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "artifactDownloadUrls": [
3
+ "https://github.com/kahramanemir/Vallum/releases/download/v0.3.0"
4
+ ],
5
+ "author": "Emir Kahraman",
6
+ "bin": {
7
+ "vallum": "run-vallum.js"
8
+ },
9
+ "dependencies": {
10
+ "detect-libc": "^2.1.2"
11
+ },
12
+ "description": "A Rust CLI proxy between AI agents and your shell — sanitizes secrets, flags prompt injections, strips ANSI, compresses output, audits commands.",
13
+ "devDependencies": {
14
+ "prettier": "^3.8.3"
15
+ },
16
+ "engines": {
17
+ "node": ">=14.14",
18
+ "npm": ">=6"
19
+ },
20
+ "glibcMinimum": {
21
+ "major": 2,
22
+ "series": 31
23
+ },
24
+ "homepage": "https://github.com/kahramanemir/Vallum",
25
+ "keywords": [
26
+ "command-line-utilities",
27
+ "ai",
28
+ "cli",
29
+ "proxy",
30
+ "security",
31
+ "tokens"
32
+ ],
33
+ "license": "MIT OR Apache-2.0",
34
+ "name": "vallum",
35
+ "preferUnplugged": true,
36
+ "repository": "https://github.com/kahramanemir/Vallum",
37
+ "scripts": {
38
+ "fmt": "prettier --write **/*.js",
39
+ "fmt:check": "prettier --check **/*.js",
40
+ "postinstall": "node ./install.js"
41
+ },
42
+ "supportedPlatforms": {
43
+ "aarch64-apple-darwin": {
44
+ "artifactName": "vallum-aarch64-apple-darwin.tar.xz",
45
+ "bins": {
46
+ "vallum": "vallum"
47
+ },
48
+ "zipExt": ".tar.xz"
49
+ },
50
+ "aarch64-unknown-linux-gnu": {
51
+ "artifactName": "vallum-aarch64-unknown-linux-musl.tar.xz",
52
+ "bins": {
53
+ "vallum": "vallum"
54
+ },
55
+ "zipExt": ".tar.xz"
56
+ },
57
+ "aarch64-unknown-linux-musl-dynamic": {
58
+ "artifactName": "vallum-aarch64-unknown-linux-musl.tar.xz",
59
+ "bins": {
60
+ "vallum": "vallum"
61
+ },
62
+ "zipExt": ".tar.xz"
63
+ },
64
+ "aarch64-unknown-linux-musl-static": {
65
+ "artifactName": "vallum-aarch64-unknown-linux-musl.tar.xz",
66
+ "bins": {
67
+ "vallum": "vallum"
68
+ },
69
+ "zipExt": ".tar.xz"
70
+ },
71
+ "x86_64-apple-darwin": {
72
+ "artifactName": "vallum-x86_64-apple-darwin.tar.xz",
73
+ "bins": {
74
+ "vallum": "vallum"
75
+ },
76
+ "zipExt": ".tar.xz"
77
+ },
78
+ "x86_64-unknown-linux-gnu": {
79
+ "artifactName": "vallum-x86_64-unknown-linux-musl.tar.xz",
80
+ "bins": {
81
+ "vallum": "vallum"
82
+ },
83
+ "zipExt": ".tar.xz"
84
+ },
85
+ "x86_64-unknown-linux-musl-dynamic": {
86
+ "artifactName": "vallum-x86_64-unknown-linux-musl.tar.xz",
87
+ "bins": {
88
+ "vallum": "vallum"
89
+ },
90
+ "zipExt": ".tar.xz"
91
+ },
92
+ "x86_64-unknown-linux-musl-static": {
93
+ "artifactName": "vallum-x86_64-unknown-linux-musl.tar.xz",
94
+ "bins": {
95
+ "vallum": "vallum"
96
+ },
97
+ "zipExt": ".tar.xz"
98
+ }
99
+ },
100
+ "version": "0.3.0",
101
+ "volta": {
102
+ "node": "18.14.1",
103
+ "npm": "9.5.0"
104
+ }
105
+ }
package/run-vallum.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { run } = require("./binary");
4
+ run("vallum");