Skip to content

Accept capability and limit customization, driven by the policy packs - #10

Merged
uchibeke merged 5 commits into
mainfrom
feat/oap-capability-limits-customization
Sep 19, 2026
Merged

uchibeke merged 5 commits into
mainfrom
feat/oap-capability-limits-customization

Conversation

@uchibeke

Copy link
Copy Markdown
Contributor

/api/issue could only ever mint the framework preset. A caller asking for an agent that reads but never writes, or one capped at $5 a transaction, got a generic passport — there was nowhere to put either constraint.

It now accepts optional capabilities and limits. Omit both and nothing changes, which is the point: the preset is what makes a passport useful on day one and remains the default path.

Nothing about OAP is written in this repo

spec/aport-policies and spec/aport-spec are submodules pinned by commit. scripts/build-oap-registry.mjs compiles every active policy pack into functions/lib/generated/oap-registry.json:

capability -> { policy_id, policy_version, min_assurance, limits_required }

plus the capability id pattern read out of passport-schema.json rather than restated. 22 capabilities from 21 packs. Supporting a new capability is a submodule bump and a regenerate — no code change here. --check fails CI when the artifact goes stale, and the generator refuses to run without the submodules rather than guessing.

It also skips non-active packs, so a draft cannot silently license a capability, and fails loudly if two active packs claim one capability with different rules.

The two merge rules, and why they differ

limits deep-merge over the preset. Limits nest four levels in practice — payments.charge.currency_limits.USD.max_per_tx — so a shallow merge is not a smaller version of this, it is silent data loss. Proven:

after {payments.charge:{currency_limits:{USD:{max_per_tx:500}}}}
USD.max_per_tx 500 ← changed
USD.daily_cap 100000 ← kept
EUR untouched
allowed_countries, blocked_categories kept
other capabilities' limits kept

Merging also means a default added here later reaches every passport, including ones minted by callers that have never heard of it. A replacement would mean every integration silently opted out of every future default.

capabilities select. A supplied list names the set to grant, because merging can add or replace an entry but never drop one — which would make "read but never write" inexpressible. Each selected entry still keeps its preset params, with the caller's deep-merged over them.

Arrays replace rather than concatenate. allowed_countries: ["US"] is a narrowing statement; appending to the preset's list would widen the very thing being restricted. A merge rule that fails open on the security-relevant case is the wrong rule.

What is deliberately not enforced here

Assurance is not gated — aport.io validates on the way in. And an unrecognised capability id is logged rather than refused, because the framework presets themselves ship ids the registry does not carry (repo.push, web.search); refusing those would break working mints to enforce a rule already enforced downstream. validateGrant() exists for a caller that wants the strict check (unknown capability, assurance floor, missing required limits) before sending.

Verification

13 suites, 148 tests. Fixtures in test/oap.test.ts are selected by property from the submodule — the first L0 pack with no limits, the first L3 pack — rather than by name, so the suite follows the spec instead of pinning a snapshot of it. A test hardcoding data.file.read would stop testing anything the day it is renamed.

Not yet exercised over HTTP. The tests call the resolvers directly; the path through onRequestPost and its interaction with the deliverable block has not been run against a live request. Worth doing before this is relied on.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wy45ojceVnqzG6GLx9HJRD

/api/issue could only ever mint the framework preset. A caller asking for an
agent that reads but never writes, or one capped at $5 a transaction, got a
generic passport: there was nowhere to put either constraint.

It now accepts optional `capabilities` and `limits`. Omit both and nothing
changes, which is the point -- the preset is what makes a passport useful on
day one and remains the default path.

Nothing about the OAP vocabulary is written in this repo. spec/aport-policies
and spec/aport-spec are submodules pinned by commit, and
scripts/build-oap-registry.mjs compiles every active policy pack into
functions/lib/generated/oap-registry.json: capability -> min_assurance,
limits_required, policy id and version, plus the capability id pattern read
out of passport-schema.json rather than restated. 22 capabilities from 21
packs. Supporting a new one is a submodule bump and a regenerate. `--check`
fails when the artifact goes stale.

The two merge rules, and why they differ:

- `limits` DEEP-merges over the preset. Limits nest four levels in practice
  (payments.charge.currency_limits.USD.max_per_tx), so a shallow merge is not
  a smaller version of this, it is silent data loss: changing one currency's
  per-transaction cap would drop that currency's daily cap and every other
  currency with it. Merging also means a default added here later reaches
  every passport, including ones minted by callers that never heard of it.
- `capabilities` SELECT. A supplied list names the set to grant, because
  merging can add or replace an entry but never drop one, which would make
  "read but never write" inexpressible. Each selected entry still keeps its
  preset params, with the caller's deep-merged over them.

Arrays replace rather than concatenate. `allowed_countries: ["US"]` is a
narrowing statement, and appending to the preset's list would widen the very
thing being restricted.

Assurance is NOT enforced here: aport.io validates on the way in. An
unrecognised capability id is logged rather than refused, because the
framework presets themselves ship ids the registry does not carry (repo.push,
web.search) and refusing those would break working mints. validateGrant()
exists for a caller that wants the strict check before sending.

13 suites, 148 tests. Fixtures in test/oap.test.ts are selected by property
from the submodule rather than by name, so the suite follows the spec instead
of pinning a snapshot of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy45ojceVnqzG6GLx9HJRD
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-19T13:02:08.133762Z ac62c30 Manual request
🔒 Security Review ✅ Completed 2026-09-19T00:27:33.317101Z 4d527e7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

capabilities.push({
id: "deliverable.task.complete",
params: deliverableParams,
});

P2 Badge Replace the selected deliverable capability instead of appending

When a request supplies both capabilities: [{ id: "deliverable.task.complete", params: ... }] and the top-level deliverable configuration, the resolver keeps the caller's entry and this unconditional push adds a second entry with the authoritative parameters. The resulting passport request has duplicate capability IDs, and if they are preserved, app/passport/page.tsx uses .find(...) and therefore displays the caller's first parameter set instead of the deliverable block. Remove or replace the existing entry before adding the authoritative one.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/oap.test.ts

function packCapabilities() {
const out: Array<{ id: string; min_assurance: string; limits_required: string[] }> = [];
for (const dir of readdirSync(POLICIES)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialize policy submodules before running tests

In the checked .github/workflows/deploy.yml test job, actions/checkout@v4 is used without enabling submodules, whose documented default is false (checkout v4 usage). Consequently this directory is empty in every fresh CI checkout, FROM_PACKS contains no entries, and the new registry assertions fail on every push and pull request. Configure checkout to fetch the submodules before this filesystem-driven suite runs.

Useful? React with 👍 / 👎.

Comment thread functions/lib/oap.ts Outdated
Comment on lines +215 to +216
if (isPlainObject(value) && isPlainObject(out[key])) {
out[key] = deepMerge(out[key], value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject prototype keys before recursing

Because override is public JSON input and out[key] reads inherited properties, a key named __proto__ resolves to Object.prototype and enters this recursive branch. A roughly 70 KB limits object containing about 5,000 nested __proto__ keys exhausts the JavaScript stack, and resolveLimits runs before the endpoint's issuance try block, so the request terminates with an uncaught RangeError. Reject prototype-related keys or use own-property/null-prototype objects with a nesting limit.

Useful? React with 👍 / 👎.

uchibeke and others added 2 commits September 18, 2026 21:19
…erable entry

Three findings from the review on #10, all of them real.

CI never had the policy packs (P1). actions/checkout defaults submodules to
false, so spec/ was empty in every fresh checkout: FROM_PACKS came back empty
and the OAP suite asserted against nothing on every push. The test job now
checks out recursively and runs `registry:check`, so a stale generated registry
fails the build rather than deploying. Pulling the submodules in put their own
TypeScript into tsc and eslint, which would have turned CI red a different way,
so spec/ is excluded from both.

deepMerge recursed into Object.prototype (P2). JSON.parse makes `__proto__` an
own property, so it reached the merge from a request body: reading `out.__proto__`
returned Object.prototype, which is a mergeable object, and a body of nested
`__proto__` keys exhausted the stack with a RangeError thrown before the
issuance try block. Writing it ran the setter and reparented the object instead
of storing a limit. Prototype-reaching keys are now dropped, reads are
own-property only, and a depth cap backstops shapes the key list does not cover.

The deliverable block appended a duplicate capability (P2). A request carrying
both `deliverable` and `deliverable.task.complete` in `capabilities` produced
two entries with that id; consumers read it back with `.find`, so the passport
card showed the caller's parameters while the policy evaluated the block's.
`upsertCapability` replaces in place, which is the invariant resolveCapabilities
already held.

157 tests (up from 148), type-check clean, lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy45ojceVnqzG6GLx9HJRD
Found by an end-to-end audit of the mint path against the real defaults and the
real passport schema: the two mint paths disagreed.

`identity.manage_roles` is in this build's DEFAULT_CAPABILITIES and in the
capability list aport.io publishes, and it fails the OAP passport schema's own
id pattern, which allows no underscore. So minting the defaults granted it,
while a caller narrowing to a subset that named it had it silently dropped —
asking for less than the default could not reproduce the default.

The pattern gate is there to stop a caller injecting an id we would not
otherwise mint. Selecting one the preset already carries is not that, so a
preset id is now allowed through and logged. An id that is neither valid nor in
the preset is still dropped.

This does not decide whether the spec's pattern or the capability's name is the
thing that is wrong — that is aport.io's call, and it is worth raising, because
every default passport this endpoint mints today carries an id the published
schema rejects.

160 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy45ojceVnqzG6GLx9HJRD
@uchibeke

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51159dfeb8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/build-oap-registry.mjs Outdated
spec_commit: process.env.APORT_SPEC_COMMIT ?? null,
capability_id_pattern: capabilityIdPattern(),
/** Assurance ladder, lowest first. Order is what makes a comparison possible. */
assurance_order: ["L0", "L1", "L2", "L3", "L4"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize the assurance levels emitted by the service

When validateGrant receives the assurance level returned by createBuilderPassport for a KYC-completed passport, it receives L4KYC (and the service also documents L4FIN), but neither value exists in this hard-coded order. meetsAssurance consequently returns false and reports assurance_too_low even for an L0 capability, so callers cannot validate grants using the service's own passport values; normalize the variants or include them at their appropriate level.

Useful? React with 👍 / 👎.

Comment thread scripts/build-oap-registry.mjs Outdated
Comment on lines +131 to +139
const strip = (s) => {
try {
const { policies_commit, spec_commit, ...rest } = JSON.parse(s);
return JSON.stringify(rest);
} catch {
return s;
}
};
if (strip(current) !== strip(serialized)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep registry provenance synchronized with the submodules

When either submodule is bumped without changing the extracted behavioral fields, --check removes both commit fields before comparing and therefore accepts an artifact that still names the previous revisions. In addition, the documented registry:build command supplies neither environment variable and rewrites both values to null. Since registryProvenance() exposes these fields specifically to identify the source versions, derive them from the gitlinks or include them in the freshness check rather than discarding them.

Useful? React with 👍 / 👎.

…thority

FRAMEWORK_PRESET_ALLOWLIST was a hardcoded copy of the key set of
/api/public/framework-passport-presets — checked two lines before this handler
asked that same endpoint. A local cache of a remote list, consulted immediately
before consulting the remote.

It had drifted, and was refusing five presets that exist and work: `github`,
`langgraph`, `vercel-ai-sdk`, `autogen` and `Custom`. Asking aport.id to mint a
`github` passport returned generic defaults instead of the GitHub Repository
Guard preset, because a constant here had not been updated.

The lookup decides now. A framework with a preset gets it; one without falls
through to the defaults — the same outcome the allowlist produced for an
unknown value, without the drift. `sanitizeFrameworks` still bounds length,
charset and count, and the narrower /^[A-Za-z0-9-]+$/ still runs before the
value is put in a URL path. Those were always the checks doing the work.

The new test asserts the property the allowlist should have had: that the preset
endpoint is a superset of what bin/agent-guardrails declares installable. It is,
today, for all ten.

163 tests, type-check and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy45ojceVnqzG6GLx9HJRD
@uchibeke

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10c43e5075

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread functions/api/issue.ts
Comment on lines +297 to +298
let capabilities = resolveCapabilities(presetCapabilities, body.capabilities);
const limits: Record<string, any> = resolveLimits(presetLimits, body.limits);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize flat defaults before merging namespaced overrides

For requests without a framework preset, presetLimits comes from the flat DEFAULT_LIMITS object (currency_limits is top-level), while the newly documented customization shape is capability-scoped. Therefore an override such as {"payments.charge":{"currency_limits":{"USD":{"max_per_tx":500}}}} creates a new subtree instead of merging the existing defaults, so payments.charge.currency_limits.USD.daily_cap and the other currencies are absent from the scoped limits sent upstream. This breaks the promised sibling-preserving behavior for generic passports and can discard financial guardrails; normalize the legacy flat defaults or merge them into the capability namespace first.

Useful? React with 👍 / 👎.

Comment thread test/framework-presets.test.ts Outdated
Comment on lines +46 to +49
const [presetsRes, installerRes] = await Promise.all([
fetch(PRESETS_URL),
fetch(GUARDRAILS, { headers: { Accept: "application/vnd.github.raw" } }),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle rejected network requests in the live preset test

When either external host is unreachable—for example during an offline local run or a transient DNS/TLS outage—fetch rejects and Promise.all throws before the subsequent res.ok guard, failing the entire test suite. The comment says missing network should skip the check, but only HTTP error responses are currently skipped; catch network failures or replace these live requests with deterministic fixtures.

Useful? React with 👍 / 👎.

…r, provenance

Four findings on #10, all real.

NAMESPACED OVERRIDES LANDED BESIDE THE DEFAULTS, NOT ON THEM (P1). Limits come
in two shapes and both are real: DEFAULT_LIMITS is flat, with `currency_limits`
at the top level, while the OAP shape a caller sends is scoped per capability.
A deep merge sees two unrelated keys, so `{"payments.charge":{"currency_limits":
{"USD":{"max_per_tx":500}}}}` produced a namespace containing exactly that and
nothing else — no daily cap, no other currency, no allowed countries — while the
real defaults sat above it, shadowed. The sibling-preserving behaviour this
module exists for was inverted precisely where it matters most, and the values
it dropped were the financial ones.

A namespace the caller mentions is now seeded from the flat defaults first.
Which flat keys belong to a capability is not guessed: `limits_required` in its
policy pack says so. Only mentioned namespaces are seeded, because creating them
all would rewrite every default mint's limits to prove a point about one.

THE ASSURANCE LADDER WAS TYPED OUT, AND WRONG (P2). It read ["L0".."L4"]. The
schema permits L0, L1, L2, L3, L4KYC and L4FIN — "L4" is not a value at all —
and createBuilderPassport returns L4KYC for a KYC-completed passport. So the
most verified passport we can issue failed every capability check, including L0
ones. The ladder now comes from passport-schema.json's enum, and ranking is by
the tier in the name so L4KYC and L4FIN compare as the peers they are. The tests
had the same hardcoded value and so agreed with the bug; they read it from the
registry now.

PROVENANCE WAS DISCARDED BY ITS OWN BUILD COMMAND (P2). The commits came from
APORT_POLICIES_COMMIT / APORT_SPEC_COMMIT, which the documented `registry:build`
does not set — so running it wrote null into both. And `--check` stripped them
before comparing, so a submodule bump that changed no rule left the artifact
naming the previous revision, which is the one thing these fields exist to get
right. Both now come from the submodule gitlinks and are compared.

A LIVE TEST FAILED OFFLINE INSTEAD OF SKIPPING (P2). `fetch` rejects on a
missing network rather than returning a non-ok response, so the `res.ok` guard
was never reached and the suite failed — the opposite of what its comment
promised.

174 tests, type-check and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy45ojceVnqzG6GLx9HJRD
@uchibeke

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: ac62c3067f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@uchibeke
uchibeke merged commit e37e8d5 into main Sep 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant