Releases: razukc/writmint
Release list
v0.5.2
A hardening patch completing the denyPrivate deny set. No new error
codes, no manifest schema change — new range tags ride inside the
existing private_ip_literal / resolved_to_private payloads, and
hashes, tapes, and the approval lifecycle are untouched.
Changed
Completed the denyPrivate deny set
The v0.5.0 deny set shipped with documented, deliberate exclusions.
This release closes them — per the strictness-over-tolerance principle,
a resolver answer in any of these ranges is never a legitimate public
destination; it is a misconfiguration or an SSRF probe, and the broker
fails closed.
IPv4 — 192.0.0.0/24 (IETF protocol assignments, incl. DS-Lite;
the globally-reachable PCP/TURN carve-outs are intentionally denied),
TEST-NET-1/2/3 (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24),
192.88.99.0/24 (deprecated 6to4 relay anycast), 198.18.0.0/15
(benchmarking), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved,
incl. broadcast).
IPv6 — ff00::/8 (multicast), 2001:db8::/32 (documentation),
100::/64 (discard-only), 64:ff9b:1::/48 (local-use NAT64).
Tunnel forms that embed an IPv4 address — NAT64 well-known prefix
64:ff9b::/96, 6to4 2002::/16, Teredo 2001::/32 (bit-inverted
client address) — now classify the embedded IPv4: private-embedded is
denied reporting the v4 range (2002:a00:1:: → rfc1918-10/8),
public-embedded stays allowed. An attacker can no longer smuggle a
private target past the classifier by wrapping it in a tunnel prefix.
Behavioral tightening: a manifest whose transport resolves to any
of these ranges now fails where it previously passed — notably
TEST-NET addresses used as "safe example" stand-ins. Opting back into
denied space remains a deferred IPNet-clause feature.
Documented-not-denied (deliberate): ORCHIDv2 2001:20::/28,
deprecated site-local fec0::/10, deprecated IPv4-compatible
::x.x.x.x — each pinned by a test so future tightening is a
decision, not an accident.
1050/1050 tests pass (71 new). 24/24 demos pass.
v0.5.1
A docs-and-tooling patch. No library code changes — published so the
npm package's README carries the full network-dynamic documentation
rather than the v0.5.0 release-day summary.
Changed
- README documents
network-dynamicin depth: a completehostPolicy
example with per-clause defaults (https-only scheme, per-scheme
ports, default-ondenyPrivate), the transport conformance contract
(resolvedIppin, no auto-follow on redirects), the
construction-timepermission.network.no_resolvercheck, and
src/host-policy.tsin the repository layout. Beat 2's quoted
host_wildcardrejection now shows the current fix-hint, including
the tail clause routing call-time-URL authors to
type:network-dynamic.
Fixed
- Dogfood PreToolUse hook (repo tooling, not in the npm tarball): only
files named like manifests (*manifest.json/.jsonc) are checked
for manifest shape. Previously any JSON file carrying apermissions
key — e.g..claude/settings.local.json— was denied as a broken
manifest.
979/979 tests pass. 24/24 demos pass.
v0.5.0
A minor release closing the dynamic-host tension from the dogfood
corpus (open item #4): capabilities whose actions take user-supplied
URLs at call time now have an honest manifest shape instead of
inventing hostnames. Additive — existing type: "network" manifests,
their tapes, and their hashes are unchanged. The one new obligation
falls on hosts that declare network-dynamic permissions: their
NetworkTransport must provide resolve() and must honor the
resolvedIp pin / no-auto-redirect contract.
Added
type: "network-dynamic" permission shape
The existing type: "network" permission requires hosts: string[]
enumerated at author time, with wildcards banned. That bar is
load-bearing when the host is fixed — and impossible to meet when the
action's input is the URL. Two dogfood passes resolved this by
inventing plausible hostnames no real deployment would use.
network-dynamic declares a hostPolicy instead of hosts:
{
"type": "network-dynamic",
"id": "net.ping",
"hostPolicy": {
"registrableDomain": ["acme.com"], // required, label-boundary suffix match
"scheme": ["https"], // optional, defaults to https-only
"port": [443], // optional, defaults pair per scheme
"denyPrivate": true, // optional, default true
"pathPrefix": ["/status/"] // optional, omit = any path
},
"methods": ["GET"],
"reason": "Used by ops.url-health-check.ping to GET a user-supplied URL under the declared registrable domains."
}registrableDomain entries are literal label-boundary suffixes:
acme.com covers status.acme.com but never evilacme.com.
Wildcards remain banned (permission.network-dynamic.registrable_domain_invalid).
At call time the broker checks method, scheme, port, and path, then
classifies the host. IP literals in private/loopback/link-local/CGNAT
ranges are rejected (private_ip_literal); hostnames are matched
against the policy (host_policy_denied), resolved once per action
scope via the new NetworkTransport.resolve(), rejected if any
resolved IP is private (resolved_to_private), and the surviving IP
is pinned into the request as resolvedIp — a conforming transport
connects to exactly that address rather than re-resolving
(DNS-rebinding defense).
Parser-based IPv6 private-range classification
src/host-policy.ts owns the deny table: RFC 1918, loopback,
link-local, CGNAT (100.64.0.0/10 — Alibaba's metadata endpoint
lives there), IPv6 ULA/link-local/loopback/unspecified, and
IPv4-mapped IPv6 in every spelling. IPv6 classification expands any
form to its 8 hextets first, so expanded/uncompressed/uppercase
resolver output lands in the same ranges as canonical spellings.
Resolver strings that parse as neither IPv4 nor IPv6 fail closed
(resolve_failed).
network.resolve tape event
Each unique resolution records one strict-ordered tape entry. Tapes
from network-only manifests are byte-identical to before — zero
network.resolve entries. Replay divergences surface unchanged
through the broker, never wrapped as resolve_failed.
21 new structured-error codes
8 runtime (permission.network.{no_resolver, scheme_denied, port_denied, path_denied, host_policy_denied, private_ip_literal, resolved_to_private, resolve_failed}), 10 structural
(permission.network-dynamic.*), 3 hardening (mutual exclusivity in
both directions plus domain-entry validity). Every one carries the
full {code, where, expected, actual, fixHint} payload.
Changed
permission.network.host_wildcard's fix-hint now routes authors
with call-time URLs totype:network-dynamic— the redesign is
reachable from the rejection alone.- hostPolicy fix-hints illustrate with the
"<your-domain>"fill-in
marker instead of a real-looking domain. Dogfooding showed agents
paste the hint's example verbatim; a valid domain sails through
hardening, while the marker failsregistrable_domain_invalidand
forces an actual policy decision. NetworkTransportgains optionalresolve?(hostname); required at
construction time when anynetwork-dynamicpermission is declared.
Transports must not auto-follow redirects — each 3xx hop re-enters
the broker to be policy-checked.- Fixture
ops.url-health-checkmigrated tonetwork-dynamic
(manifest v0.2.0).
Measured
Dogfood pass 05b reran the skill-disabled authoring experiment against
the new shape. The host-policy segment — previously a policy dead-end
that forced the agent to invent hostnames — became a 2-round-trip /
2-code mechanical recovery, with zero invented hostnames. Routing
fired via the permission.type enum's expected field one layer
before the host_wildcard hint was even needed. Full
attempt-by-attempt record in fixtures/url-health-check-no-skill-rerun/.
979/979 tests pass. 24/24 demos pass.
v0.4.2
A patch release tightening the agent-authored reason-string contract,
and bringing the README back into sync with the v0.3.x / v0.4.x
changes that had drifted out of it. Pure-additive — no breaking
changes; existing manifests continue to validate, submit, and approve
unchanged.
Added
permission.reason.action_ref_incomplete hardening warning
hardenManifest() now emits a structured warning when a permission's
reason mentions some but not all of the actions that reference it.
The existing permission.reason.no_action_ref warning only fires when
the reason mentions none of the referencing actions, so a permission
used by [a.run, a.purge, a.snapshot] whose reason names only a.run
slipped through silently — a.purge and a.snapshot were left
undocumented in the very field meant to enumerate them.
The two rules now partition the failure space:
0/Nmentioned →permission.reason.no_action_ref(existing)1..N-1/Nmentioned →permission.reason.action_ref_incomplete(new)N/Nmentioned → clean
The new rule only fires when N >= 2: a permission referenced by
exactly one action cannot be "partially named", so the 0/1 case
falls cleanly into no_action_ref's territory.
The actual field reports both the mention count and the missing
action ids — showing what's already there lets the agent fix without
rewriting the whole reason:
mentions 1 of 3 (example.load); missing: example.purge, example.snapshot
Warning, not error: matches the strictness of no_action_ref. Closes
v0.3 candidate #1. 5 new tests pin the rule.
Changed
README refresh
The README had not been updated since v0.2.0; this release brings the
status banner, hardening rule list, destructive-action gate, MCP
server section, repository layout, and test counts in sync with
v0.4.x. Adds a v0.2.x / v0.3.x / v0.4.x pointer at the bottom for the
full record.
Notes
- 830 tests pass.
- Pre-stable. Public API surface may still change before v1.0.
- Requires Node ≥ 22.
Install
npm install writmint@0.4.2v0.4.1
A security-driven patch release. Bumps vitest and
@vitest/coverage-v8 from ^4.0.15 to ^4.1.8 to pick up the fix for
GHSA-9crc-q9x8-hgqq
("When Vitest UI server is listening, arbitrary file can be read and
executed"). The transitive brace-expansion DoS finding
(GHSA-jxxr-4gwj-5jf2)
is resolved by npm audit fix. Both dependencies are devDependencies
only — they do not ship to npm consumers — but the fixes matter for
anyone running the test suite or building from source. npm audit now
reports zero vulnerabilities.
Fixed
Vitest 4 migration
poolOptions was removed in vitest 4; execArgv and maxWorkers are
now top-level test config keys. Updated vitest.config.ts
accordingly.
The previous --expose-gc worker flag is dropped: Node 22+ rejects it
as a worker_threads argv (ERR_WORKER_INVALID_EXEC_ARGV), and the
memory-leak tests already guard global.gc calls. The
should not leak memory with hostContext over multiple cycles
threshold was raised from 600 KB to 1000 KB to absorb vitest 4.1.x's
~200 KB additional worker-pool overhead plus the V8-fragmentation
noise the missing gc introduces. This test measures harness overhead
more than Runtime retention; the comment in the test file documents
the rationale. If a real Runtime retention regression lands, the
threshold should be lowered after the regression is fixed, not before.
Notes
- 825 tests pass.
- Pre-stable. Public API surface may still change before v1.0.
- Requires Node ≥ 22.
Install
npm install writmint@0.4.1v0.4.0
A minor-numbered breaking release at the MCP wire boundary. Every
MCP handler's response now follows a tagged-union envelope, collapsing
per-handler success shapes and the bare-object error path into a single
{ ok, data } / { ok, errors } form. The Node API is purely
additive — RuntimeError grew allErrors[] mirroring v0.3.1's
ApprovalError change, and wrapStructured reads it automatically.
The version bump is governed by the wire-side break: a v0.3.x MCP
client does not deserialize v0.4.0 server output cleanly.
Breaking changes
MCP handler response shape — tagged-union envelope
The text body of every MCP tool call now follows a tagged union:
// success (envelope.isError unset)
{ "ok": true, "data": { /* handler-specific */ } }
// failure (envelope.isError === true)
{ "ok": false, "errors": [ /* StructuredError[] */ ] }The MCP-level isError flag and inner-text ok are redundant by
design and never disagree: callers branch on either channel and reach
the same conclusion.
Pre-v0.4.0:
- Every handler had its own success shape (
{hash},{state, hash, manifestId},{events}, etc.) - Error path was a bare
StructuredErrorobject validate_manifestwas further inconsistent — its rejection rode
the success arm of the envelope as{ok:false, errors}while every
other handler usedisError:trueon failure- Robotic callers had to branch on shape, not on a single field
The new envelope collapses all of that.
Migration
- const text = JSON.parse(result.content[0].text);
- if (result.isError) {
- // text is a bare StructuredError
- recover(text);
- } else {
- // text is {hash}, {state,...}, {events}, etc.
- use(text);
- }
+ const env = JSON.parse(result.content[0].text);
+ if (env.ok) {
+ use(env.data);
+ } else {
+ recover(env.errors); // always an array
+ }replay's divergence finding stays in the success arm
({ok:true, data:{divergence}}) because divergence is a successful
detection, not a failure to detect.
Added
RuntimeError.allErrors
RuntimeError carries readonly allErrors: readonly StructuredError[]
alongside the existing structured: StructuredError, mirroring v0.3.1's
ApprovalError change. The constructor accepts either the old 2-arg
shape (new RuntimeError(s, { cause })) or the new 3-arg shape
(new RuntimeError(s, allErrors, { cause? })) — existing throws keep
working unchanged. wrapStructured in the MCP layer pulls allErrors
when present, so a single thrown RuntimeError carrying N violations
surfaces all N in the wire response without further plumbing. Used by
the validate_manifest handler to surface every verifyManifest
violation on one MCP call.
Notes
- 825 tests pass.
- Pre-stable. Public API surface may still change before v1.0.
- Requires Node ≥ 22.
Install
npm install writmint@0.4.0v0.3.1
A patch release that closes the last short-circuit in the authoring
rejection path. Every Writmint check is internally exhaustive — the
structural validator collects all errors before returning, hardening
collects all errors before returning, the unknown-field rule warns on
every offender — but the pipeline between stages was fail-fast. A
first-draft manifest with one structural error and four hardening
errors returned one error, forced the agent to fix it, retry, see the
next four, and pay a second round-trip. v0.3.1 collapses both seams
(the MCP validate_manifest / hook boundary, and the submit()
boundary) so every authoring rejection arrives complete.
Pure-additive — no breaking changes; existing call sites that use
validateCapabilityManifest, hardenManifest, or read
ApprovalError.structured keep working unchanged.
Added
verifyManifest()
New combined entry point on src/capability-manifest.ts that runs
structural validation and hardening in one pass and returns
{ valid, errors, warnings }. Structural errors mark broken subtrees
(e.g. a permissions[2] that isn't an object, a non-array actions)
and hardening skips those subtrees while still running on the rest of
the manifest. The dogfood round-trip ceiling for a mixed
structural-and-hardening first draft drops from 2 to 1 because every
violation surfaces in the first rejection payload. The MCP
validate_manifest handler and the Layer 3 hook script
(tools/dogfood/validate-on-write.ts) now call verifyManifest();
validateCapabilityManifest() and hardenManifest() are still
exported for callers that want each stage separately. hardenManifest()
grew an optional { skipPaths?: ReadonlySet<string> } second argument
so it can be composed cleanly with structural-validation output.
Surfaced by dogfood pass 06: a manifest with 1 structural and 4
hardening violations returned 1 error pre-verifyManifest;
reproducing the same fixture now returns 5. 9 new tests pin the rule.
ApprovalError.allErrors
ApprovalError carries readonly allErrors: readonly StructuredError[]
alongside the existing structured: StructuredError. submit() now
populates allErrors with every hardening error rather than just the
first; existing callers that read .structured keep working unchanged
because that field still points to the first entry of allErrors.
Closes the in-stage short-circuit at the submit() boundary the same
way verifyManifest() closes it at the validate-manifest boundary.
Notes
- 823 tests pass.
- Pre-stable. Public API surface may still change before v1.0.
- Requires Node ≥ 22.
Install
npm install writmint@0.3.1v0.3.0
A minor release with two pure-additive hardening features, both surfaced
by the dogfood harness running against the MCP server. No breaking
changes; existing manifests continue to validate, submit, and approve
unchanged. The companion writmint-authoring skill grew a destructive-
actions section spelling out the submit()-silent / approve()-loud
behavior that pass 03b documented.
Added
manifest.unknown_field hardening warning
hardenManifest() now surfaces a structured warning for any field that
isn't part of the v1 schema at the manifest top-level, inside a
permission (canonical key set varies by permission type), or inside an
action. Surfaced by dogfood pass 05: an agent authoring without the
writmint-authoring skill shipped a manifest with stray kind fields
on permissions and a stray title field on an action; the validator
silently accepted them. Accepted-and-ignored read as
accepted-and-meaningful, which is the authoring-time footgun the warning
closes. JSONSchema bodies inside input / output / config are not
checked — additionalProperties and similar are legitimate JSONSchema
fields, not Writmint errors. Warning, not error: it's the safer first
cut; can be promoted later after the warning has been observed in
dogfood for a release. 7 new tests pin the rule.
Opt-in two-person rule on destructive approval
ActionManifest now carries an optional
requireDistinctDestructiveApprover: boolean. When true on any
destructive action, approve() rejects identical approvedBy and
destructiveApprovedBy with the new structured code
approval.destructive.same_approver. The existing destructive_required
check still runs first (missing field beats identity check), and a
non-destructive action setting the flag is a no-op (destructive: true
is the trigger, not the flag alone). The flag lives per-action so it is
hash-bound — a same-actor approval cannot be done by unflagging
post-submit. Surfaced by dogfood pass 03b: approvedBy and
destructiveApprovedBy were both free-form strings with no
required-distinct check, so anyone who knew one string knew the other,
defeating the point of a two-person gate. Opt-in (not strict by default)
so carryover destructive capabilities keep working. 9 new approval tests
cover both the existing destructive_required gate (no prior coverage)
and the new same-approver rule.
Notes
- 813 tests pass.
- Pre-stable. Public API surface may still change before v1.0.
- Requires Node ≥ 22.
Install
npm install writmint@0.3.0v0.2.1 — replay JSON-stability fix
A patch release that closes a replay-divergence bug class surfaced by the
first dogfood pass through the Writmint MCP server. Recordings cross JSON
boundaries (MCP wire, on-disk fixtures), where JSON.stringify drops
undefined-valued keys. The in-memory deepEqual was strict about key
presence, so any broker call with an undefined optional field (audit.emit
with no payload, storage.list with no prefix, storage.put with undefined
key/value, etc.) diverged after a wire round-trip — with expected and
actual rendering identically because stringify dropped the same keys
on the display side. Unactionable from the agent's seat.
Fixed
deepEqualinsrc/replay.tsis now JSON-semantic: undefined-valued
keys are treated as absent on both sides, matching what survives the
wire. One change at the comparator closes the bug class for every
current and future broker path with optional fields.- Site-level hardening:
audit.emitandstorage.listrecorders/replayers
now also omit undefined fields before push. This is redundant with the
comparator fix but reduces wire payload size and is symmetrical with the
shape the comparator expects.
Added
tests/unit/replay-json-stability.test.tspins the wire round-trip
property foraudit.emit(no payload),audit.emit(non-envelope
payload),storage.put(undefined key/value), andstorage.list(no
prefix). 775 tests pass; 24 demo assertions still green.
v0.2.0 — capability/permission vocabulary split + manifest hardening
A breaking release. Splits the public-API vocabulary into an outer identity layer (the capability — the manifest as a unit of governance) and an inner permission layer (the individual grants the manifest declares). Adds manifest hardening that runs at submit-time so an agent gets a structured rejection before approval, not after.
Locked tagline: Writmint is a verifier for capabilities an author can't author past.
Breaking changes
Outer identity layer renamed: feature → capability
| v0.1 | v0.2 |
|---|---|
FeatureManifest |
CapabilityManifest |
MemoryFeatureStore |
MemoryCapabilityStore |
FeatureStore interface |
CapabilityStore |
FeatureRecord |
CapabilityRecord |
FeatureStatus |
CapabilityStatus |
ApproveInput.featureId |
ApproveInput.capabilityId |
AuditEvent.featureId / featureVersionHash |
AuditEvent.capabilityId / capabilityVersionHash |
AuditEventKind value feature_emit |
capability_emit (and capability_call / capability_denied) |
error code approval.unknown_feature |
approval.unknown_capability |
src/feature-manifest.ts |
src/capability-manifest.ts |
Inner permission layer renamed: capabilities[] → permissions[]
| v0.1 | v0.2 |
|---|---|
CapabilityManifest.capabilities[] |
permissions[] |
ActionManifest.capabilities[] |
permissions[] |
CapabilityError class |
PermissionError |
broker envelope field capabilityId (inner) |
permissionId |
AuditTransport.emit({capabilityId,…}) |
AuditTransport.emit({permissionId,…}) |
createFeatureCapabilityRegistry() |
createPermissionRegistry() |
src/capabilities.ts |
src/permissions.ts |
capability.* error codes |
permission.* (same suffixes) |
manifest.capabilities.type validator code |
manifest.permissions.type |
action.capability_ref.type / .unknown |
action.permission_ref.type / .unknown |
The outer capabilityId on AuditEvent is manifest identity; the inner permissionId (also on AuditEvent, and on the broker emit envelope) is the specific permission entry the event came through. Both fields ride on every event.
Hash shift
hashManifest() is unchanged in algorithm, but every shipped manifest will produce a different versionHash under v0.2 because the renamed object keys (permissions[], etc.) are part of the canonical hash input. Re-submit and re-approve any manifest carried over from v0.1.
Added
hardenManifest()(src/capability-manifest.ts) — runs after structural validation. Five rules:permission.reason.too_short— reason ≥ 5 words.action.description.too_short— description ≥ 5 words.permission.network.host_wildcard— no*in any host.permission.storage.scope_wildcard— no*in any scope.permission.reason.no_action_ref(warning) — every permission's reason should mention an action that references it.
ApprovalLifecycle.submit()now runs hardening and throwsApprovalErroron the first hardening error. New return shapeSubmitResultextendsCapabilityRecordwithwarnings: ManifestWarning[].- 13 new tests covering each rule + wiring. Total: 737 → 750.
- README rewritten around a show-by-failing opener.
Changed
fixtures/suspicious-transaction-triage/manifest.ts— everypermission.reasonnow opens with "Used by<action.id>…"; fixture passes hardening with 0 errors, 0 warnings.
Migration for any v0.1 consumer
- Update import names per the two tables above.
- Rename
capabilities→permissionsin every manifest object (outer and inside actions). - Re-submit and re-approve every manifest (hash shift).
- Lengthen any
reasonor actiondescriptionshorter than 5 words; remove any wildcard hosts/scopes; consider adding the referencing action id to each reason to clear the no-action-ref warning.
Notes
- Pre-stable. Public API surface may change before v1.0.
- Requires Node ≥ 22.
- 750 tests / 48 files. All 24 demo phases pass.