Skip to content

Releases: razukc/writmint

v0.5.2

Choose a tag to compare

@razukc razukc released this 07 Jun 07:44

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.

IPv4192.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).

IPv6ff00::/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

Choose a tag to compare

@razukc razukc released this 07 Jun 05:17

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-dynamic in depth: a complete hostPolicy
    example with per-clause defaults (https-only scheme, per-scheme
    ports, default-on denyPrivate), the transport conformance contract
    (resolvedIp pin, no auto-follow on redirects), the
    construction-time permission.network.no_resolver check, and
    src/host-policy.ts in the repository layout. Beat 2's quoted
    host_wildcard rejection 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 a permissions
    key — e.g. .claude/settings.local.json — was denied as a broken
    manifest.

979/979 tests pass. 24/24 demos pass.

v0.5.0

Choose a tag to compare

@razukc razukc released this 07 Jun 04:28

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 to type: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 fails registrable_domain_invalid and
    forces an actual policy decision.
  • NetworkTransport gains optional resolve?(hostname); required at
    construction time when any network-dynamic permission is declared.
    Transports must not auto-follow redirects — each 3xx hop re-enters
    the broker to be policy-checked.
  • Fixture ops.url-health-check migrated to network-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

Choose a tag to compare

@razukc razukc released this 03 Jun 06:04

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/N mentioned → permission.reason.no_action_ref (existing)
  • 1..N-1/N mentioned → permission.reason.action_ref_incomplete (new)
  • N/N mentioned → 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.2

v0.4.1

Choose a tag to compare

@razukc razukc released this 02 Jun 16:30

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.1

v0.4.0

Choose a tag to compare

@razukc razukc released this 02 Jun 15:57

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 StructuredError object
  • validate_manifest was further inconsistent — its rejection rode
    the success arm of the envelope as {ok:false, errors} while every
    other handler used isError:true on 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.0

v0.3.1

Choose a tag to compare

@razukc razukc released this 31 May 18:03

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.1

v0.3.0

Choose a tag to compare

@razukc razukc released this 31 May 17:09

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.0

v0.2.1 — replay JSON-stability fix

Choose a tag to compare

@razukc razukc released this 25 May 15:43

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

  • deepEqual in src/replay.ts is 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.emit and storage.list recorders/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.ts pins the wire round-trip
    property for audit.emit (no payload), audit.emit (non-envelope
    payload), storage.put (undefined key/value), and storage.list (no
    prefix). 775 tests pass; 24 demo assertions still green.

v0.2.0 — capability/permission vocabulary split + manifest hardening

Choose a tag to compare

@razukc razukc released this 15 May 12:16
de9b27a

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 throws ApprovalError on the first hardening error. New return shape SubmitResult extends CapabilityRecord with warnings: 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 — every permission.reason now opens with "Used by <action.id>…"; fixture passes hardening with 0 errors, 0 warnings.

Migration for any v0.1 consumer

  1. Update import names per the two tables above.
  2. Rename capabilitiespermissions in every manifest object (outer and inside actions).
  3. Re-submit and re-approve every manifest (hash shift).
  4. Lengthen any reason or action description shorter 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.