| Where | What |
|---|---|
src/klock/policy/access-rules.ts | evaluateAccess, wouldLockOut, compileRules, validateAccessRule, and the value parsers (parseCidr, parseAsn, parseClientSelector, parseTimeWindow, …). |
src/klock/policy/profiles.ts | validateProfile / defineProfile, resolveProfile, evaluateSignals, findingToEscalationEvent, toLocalPolicy / evaluateLocal, SIGNAL_DEFAULTS, ACTION_LEVELS. |
src/klock/policy/presets.ts | The seven built-in profile presets (PROFILE_PRESETS, listProfilePresets, getProfilePreset). |
src/klock/policy/profile.schema.json | The profile JSON Schema (https://prod-orm.io/schemas/klock-profile.v1.json). |
src/logging/escalation/presets.ts | The klock-behaviour escalation preset that profile findings route through. |
src/cli/basic/commands/{access,behaviour,lockdown}.ts | prorm access …, prorm behaviour … and prorm lockdown. |
ts-prorm-orm/klock/policy. Nothing in it doesaccess_rules table:| Field | Values | Default | Meaning |
|---|---|---|---|
scopeKind | account, team, database, store, device | required | What the rule is attached to. |
scopeId | an id, or null for the account | null | Which team, database, store or device (dev_…). |
effect | allow, block | required | Admit only matching requests (allow), or refuse matching ones (block). |
dimension | see dimensions | required | Which property of the request is matched. |
value | depends on the dimension | required | What to match. |
appliesTo | capability patterns (["data:*"]), a JSON list or a comma-separated string | null (every request) | Limit the rule to requests needing one of these capabilities. |
mandatory | boolean (or 0/1) | true for team, false otherwise | A broader allowlist that narrower scopes cannot widen. See precedence. |
mode | enforce, report | enforce | A report rule never refuses anything; it records what it would have refused. |
enabled | boolean (or 0/1) | true | A disabled rule is dropped before evaluation. |
expiresAt | epoch ms, epoch seconds, an ISO string or a Date | null (never) | After this moment the rule takes no part. |
note | text | – | Kept with the rule. |
validateAccessRule(rule) returns the problems with one rule (an empty listcompileRules(rules) pre-parses a whole set once socompileRulesAccessRuleError listing every bad rule. With { lenient: true }allow rule thereforevalidateAccessRule.| Dimension | Value | Matched against | Notes |
|---|---|---|---|
ip | An IPv4 or IPv6 address, or a CIDR (203.0.113.0/24, 2001:db8::/32) | The client address | IPv4 is held as IPv4-mapped IPv6, so 0.0.0.0/0 matches every IPv4 address and ::/0 every address. Host bits are cleared. Octets with leading zeros (010) are refused, because parsers disagree on whether they are octal. Brackets and zone ids are tolerated in the request address. |
country | ISO 3166 alpha-2 (GB) | The request's country | Case-insensitive. Three-letter codes are refused. |
asn | An AS number: 13335, AS13335 or as13335 | The request's network | 0 to 4294967295. |
device | A device id (dev_…) | The device that signed the request | Exact match. |
cert | A certificate SHA-256 | The client certificate's fingerprint | 64 hex characters. Colons, spaces, case and a sha256: prefix are ignored. |
principal | A Kerberos principal, with optional * wildcards (*@EXAMPLE.COM) | The request's principal | Case-sensitive, because Kerberos names and realms are. |
time | A time window: { "days": [1,2,3,4,5], "start": "08:00", "end": "19:00", "timezone": "Europe/London" }, as an object or JSON | The time of the request | The escalation TimeWindow shape: days 0 (Sunday) to 6, HH:MM from 00:00 to 24:00, an IANA timezone. Every key is optional. |
client | A client selector: cli, mcp, mcp:name=Claude Desktop, mcp:name=Claude* | The calling program | The type and attribute keys are case-insensitive; * globs are allowed in the type and in values. Several attributes are comma-separated (mcp:name=X,version=2). |
api-key | An API key prefix (prk_ab12cd34), optionally written api-key:prk_ab12cd34 | The key's prefix | Exact match. |
clientInfo.name) is chosen by the clientclient rule on it as advisory, not as proof of whichtime always has a value: the request's time.evaluateAccess(rules, ctx, opts?) decides one request:appliesTo covers the request's capabilities take part.block rule, in any scope, denies theallow rules, the most specific scope that has any wins, and the requestdevice (4) > database = store (3) > team (2) > account (1). Whenmandatoryenforce rules only. The set is thenreport rules counted. If that second pass wouldaccess.would-deny findingsreportOnly, and its mode is report.appliesTo, an expiry), not add an allow.evaluateAccess returns:| Field | Meaning |
|---|---|
decision | allow or deny. |
mode | enforce, or report when the request is allowed only because the rules that would deny it are in report mode. |
reason | One line: allowed, the deny reasons joined, or allowed (report mode would deny: …). |
reasons | For a deny, one entry per failure: code (blocked, not-allowlisted, mandatory-allowlist or missing-context), dimension, scopeKind, scopeId, ruleIds, mode, message. |
rule | For a deny, the deciding rule: the block that matched, or the first allowlist that was not met. |
matched | The blocks that fired, or the allows that admitted the request. |
reportOnly | { event: 'access.would-deny', reason } for each report-mode failure. |
appliesToappliesTo takes part only when the request needs one of the*, ns:* (for example data:*) or anctx.caps:caps undefined means "unknown", and every rule applies. This failscaps: [] means "needs nothing", and only rules without appliesTo apply.evaluateAccess for the rules and203.0.113.0/24; the databaseapp blocks 203.0.113.7.| Request | Decision | Reason |
|---|---|---|
ip 203.0.113.7 | deny (blocked) | blocked by ip rule d1 (database app) |
ip 203.0.113.8 | allow | allowed |
GBapp allows FR. For requests toapp, the database's allowlist replaces the account's.| Request | Decision | Reason |
|---|---|---|
country FR | allow | allowed |
country GB | deny (not-allowlisted) | not in the country allowlist of database app |
acme allows GB; theapp allows FR and GB. The team's rule is mandatory by default,| Request | Decision | Reason |
|---|---|---|
country FR | deny (mandatory-allowlist) | not in the mandatory country allowlist of team acme |
country GB | allow | allowed |
198.51.100.0/24; the device dev_A allows the client cli.| Request | Decision | Reason |
|---|---|---|
ip 198.51.100.9, client cli | allow | allowed |
ip 198.51.100.9, client mcp (name Claude Desktop) | deny (not-allowlisted) | not in the client allowlist of device dev_A |
no ip, client cli | deny (missing-context) | the request carries no ip of account |
FR, in report mode.| Request | Decision | Mode | Reason |
|---|---|---|---|
country FR | allow | report | allowed (report mode would deny: blocked by country rule blk-xx (account)) |
appliesTo and time. The account allows weekdays 08:00–19:00data:write and resource:delete only.| Request | Decision | Reason |
|---|---|---|
Saturday 23:00 London, caps: ['data:read'] | allow | the rule does not apply |
Saturday 23:00 London, caps: ['data:write'] | deny (not-allowlisted) | not in the time allowlist of account |
Monday 11:00 London, caps: ['data:write'] | allow | allowed |
Saturday 23:00 London, caps unknown | deny (not-allowlisted) | unknown capabilities: every rule applies |
enabled. A block on 0.0.0.0/0 that expired onenabled: 0, takes no part: the request isimport { evaluateAccess, type AccessRule } from 'ts-prorm-orm/klock/policy';
const rules: AccessRule[] = [
{ id: 'team-gb', scopeKind: 'team', scopeId: 'acme', effect: 'allow', dimension: 'country', value: 'GB' },
{ id: 'db-fr', scopeKind: 'database', scopeId: 'app', effect: 'allow', dimension: 'country', value: 'FR' },
{ id: 'db-gb', scopeKind: 'database', scopeId: 'app', effect: 'allow', dimension: 'country', value: 'GB' },
];
const d = evaluateAccess(rules, { country: 'FR', client: 'cli', caps: ['data:read'] });
console.log(d.decision, d.reasons[0]?.code); // deny mandatory-allowlistmode. A report rule never changes a decision; itaccess.would-deny findings in reportOnly when enforcing it would--enforce-now (see the API).--report-only keeps a rule in report mode until you change it.wouldLockOut(rules, actorCtx, opts?) is the save-time check. It evaluates| Field | Meaning |
|---|---|
lockedOut | True when the set, fully enforced, would deny the actor. |
deniedNow | True when it denies the actor right now (enforce-mode rules only). |
reasons, ruleIds | Why, and the rules responsible. |
allow country US in report mode, fromGB, gives lockedOut: true, deniedNow: false, with the reasonnot in the country allowlist of account.prorm access allow|block|add runs this guard locally before it sends theGET /security/access-rules/context: address, country, network), addscli, and checks only the rules it can--team), onRefusing to save: this rule would lock you out of your own account. and{ "error": "would_lock_you_out", … } with --json).--force saves it anyway, after a confirmation. The request carriesforce: true, which the API accepts only with a fresh step-up and a$ prorm access allow country US
✗ Refusing to save: this rule would lock you out of your own account.
not in the country allowlist of account
Fix the rule, or pass --force (the API asks for a step-up).
$ prorm access allow country GB
✓ Allowed country GB on account:42 (rule 5b0e2c1a-…).
It starts in report mode (24 hours) unless --enforce-now: findings only, nothing refused.409
would_lock_you_out. The CLI reports that the same way.[!CAUTION] --forceis only as safe as your email. The API accepts a forced save only
with a fresh step-up and a verified address, because
recovery is by email. Check thatprorm whoamishows the address you can still read before forcing anything.
evaluateAccess and wouldLockOut take bypassScopes: scopes whose rulesaccount rules.team rules (not on data routes).allow country US rule above, evaluated with{ bypassScopes: ['account'] } from GB, allows the request.prorm lockdown is the other side of recovery: after a scare, it revokes--include-services also revokes service sessions (databases, CILOCKDOWN (or pass --yes) andprorm lockdown release/security/lockdown (POST and DELETE).| Command | Does |
|---|---|
prorm access list [--scope …] | Lists rules: scope, effect, dimension, value, mode, appliesTo, expiry, note. Alias prorm access. |
prorm access show <id> | One rule in full. |
prorm access allow <dimension> <value> · prorm access block <dimension> <value> | Adds a rule, after validating it and running the lock-out guard. |
prorm access add <allow|block> <dimension> <value> | The same, with the effect as an argument. |
prorm access rm <id> | Removes a rule, after a confirmation. Alias access remove. |
prorm access test [--ip … --country … --asn … --device … --client … --caps … --at …] | Evaluates your rules locally with evaluateAccess, for this CLI's own request by default, or for the one described by the flags. Exits non-zero on a deny, and prints report-mode would-denies. |
--scope is account (the default), team:<t>, db:<d> (ordatabase:<d>), store:<s> or device:<id>. The rule flags are --until24h or 7d, or a date), --caps (the appliesTo--report-only, --enforce-now, --mandatory,--note and --force. A time value is given as JSON:access-rule:read; allow, block, add andrm need access-rule:write. Setting mandatory rules for a team needsaccess-rule:team in that team. See capabilities.prorm db access is a different thing: the database's network settings,allowedIps. It is unchanged.payments team runs a ledger database. Its security lead wants:| Id | Scope | Rule | Why |
|---|---|---|---|
uk, ie | team | allow country GB, IE | The team works from the UK and Ireland. Team rules are mandatory, so no database or member can widen this. |
bad-asn | team | block asn AS64500 | A hosting network that attacks have come from. A block beats every allow. |
vpn | team | allow ip 203.0.113.0/24, for migration:apply, resource:delete and key:write | Only from the office VPN for the dangerous operations. Reads and ordinary writes work from anywhere in the allowed countries. |
hours | team | allow time weekdays 07:00–20:00 London, for migration:apply and resource:delete | No migrations at the weekend. |
no-mcp-writes | database ledger | allow client cli, for data:write | AI clients may read the ledger, but may not write to it. |
access.would-deny in the audit201 { "rule": { …, "mode": "report", "enforceAt": "…" } }.#/teams/:id?tab=access),ledger database's Access rules tab.prorm access test runs the same engine locally. With no flags it judges$ prorm access test --scope team:payments --country GB --ip 198.51.100.20 --caps migration:apply
✗ Denied: not in the ip allowlist of team payments
$ prorm access test --scope team:payments --country GB --ip 203.0.113.9 --caps migration:apply --at 2026-09-28T10:00:00+01:00
✓ Allowed: allowed
$ prorm access test --scope team:payments --country GB --ip 203.0.113.9 --caps migration:apply --at 2026-10-03T22:00:00+01:00
✗ Denied: not in the time allowlist of team payments--enforce-now for a rule you are sure of, and--report-only for one you want to watch indefinitely.evaluateAccess (Monday 28 September 2026,| Request | Decision | Reason |
|---|---|---|
Read from home in GB, cli | allow | allowed |
| Read from France | deny (not-allowlisted) | not in the country allowlist of team payments |
migration:apply from home | deny (not-allowlisted) | not in the ip allowlist of team payments |
migration:apply on the VPN | allow | allowed |
migration:apply on the VPN, Saturday 22:30 | deny (not-allowlisted) | not in the time allowlist of team payments |
data:write from mcp:name=Claude Desktop | deny (not-allowlisted) | not in the client allowlist of database ledger |
data:read from mcp:name=Claude Desktop | allow | allowed |
| Anything from AS64500 | deny (blocked) | blocked by asn rule bad-asn (team payments) |
import { evaluateAccess, type AccessRule } from 'ts-prorm-orm/klock/policy';
const office = { days: [1, 2, 3, 4, 5], start: '07:00', end: '20:00', timezone: 'Europe/London' };
const team = { scopeKind: 'team', scopeId: 'payments' } as const;
const rules: AccessRule[] = [
{ id: 'uk', ...team, effect: 'allow', dimension: 'country', value: 'GB' },
{ id: 'ie', ...team, effect: 'allow', dimension: 'country', value: 'IE' },
{ id: 'bad-asn', ...team, effect: 'block', dimension: 'asn', value: 'AS64500' },
{ id: 'vpn', ...team, effect: 'allow', dimension: 'ip', value: '203.0.113.0/24',
appliesTo: ['migration:apply', 'resource:delete', 'key:write'] },
{ id: 'hours', ...team, effect: 'allow', dimension: 'time', value: office,
appliesTo: ['migration:apply', 'resource:delete'] },
{ id: 'no-mcp-writes', scopeKind: 'database', scopeId: 'ledger', effect: 'allow',
dimension: 'client', value: 'cli', appliesTo: ['data:write'] },
];
const monday = Date.parse('2026-09-28T09:00:00Z');
const d = evaluateAccess(rules, {
ip: '198.51.100.20',
country: 'GB',
client: 'mcp:name=Claude Desktop',
caps: ['data:write'],
now: monday,
});
console.log(d.decision, d.reason); // deny not in the client allowlist of database ledger[!NOTE]
Theno-mcp-writesrule keys on the credential, not on what the client
says: klock CLI sessions arecli, and delegated tokens and forwarded MCP
calls aremcp. A rule onmcp:name=…would only be advisory, because the
name is the client's own claim.
profile.schema.json:{
"name": "ops-laptops",
"extends": "preset:standard-user", // optional: start from a preset
"applies": { "kinds": ["interactive"], "tiers": ["admin"], "caps": ["data:*"] },
"normal": {
"locations": { "countries": ["GB"], "asns": [13335], "cidrs": ["203.0.113.0/24"], "learn": true, "maxNewPerWeek": 1 },
"hours": [ { "days": [1, 2, 3, 4, 5], "start": "08:00", "end": "19:00", "timezone": "Europe/London" } ],
"devices": { "ids": ["dev_…"], "requireKeychain": true, "maxNew": 0, "learn": true },
"clients": ["cli", "mcp:name=Claude Code"],
"operations": {
"rates": [ { "cap": "data:read", "max": 1000, "per": "1h" } ],
"volumes": [ { "metric": "rowsRead", "max": 5000000, "per": "1d" },
{ "metric": "transferBytes", "max": "5GB", "per": "1d" } ]
}
},
"capabilities": { "allow": ["data:read"], "deny": ["resource:delete"] },
"on": [ { "when": "location.new-country", "do": ["alert", "step-up"] },
{ "when": "rate.exceeded", "do": ["rate-limit"], "rateLimit": { "max": 10, "per": "1m", "for": "15m" } } ],
"escalation": { "level": "warning", "preset": "klock-behaviour", "tags": ["ops"] }
}| Section | Meaning |
|---|---|
applies | Which requests the profile covers: session kinds, tiers, and capability patterns in caps. A fact the observation does not carry counts as covered, so an incomplete observation is still checked. |
normal.locations | Declared countries, networks (asns) and address ranges (cidrs); learn (default true) widens the learned baseline; maxNewPerWeek caps how fast it widens. |
normal.hours | Time windows, in the same shape as a time access rule. |
normal.devices | Declared device ids; requireKeychain flags a device key held in a file; maxNew caps new devices; learn. |
normal.clients | Client selectors, as for a client access rule. |
normal.operations | rates (a capability, a maximum and a window) and volumes (rowsRead, transferBytes or filesBytes, a maximum and a window). A byte size is SI (5GB) or binary (5GiB); rowsRead takes a count. Durations are 30s, 15m, 1h, 1d and so on. |
capabilities | A subset ceiling applied on top of the grant: a capability that matches deny, or does not match a given allow, raises cap.outside-profile. |
on | Which actions a signal leads to. when is a signal, <namespace>.* (for example location.*) or *. The most specific match wins (exact, then namespace, then *); entries of equal specificity combine. A signal with no matching entry uses its default actions. |
escalation | level is a floor for every finding's level; preset is the escalation preset findings route through (default security-failed-access; every built-in preset uses klock-behaviour); tags are added to each event. |
validateProfile(input) (an object or a JSON string) returns every problem:extends preset, an on entry listed twice, anamespace.* that matches no signal, and a rateLimit without therate-limit action. defineProfile(input) returns the validated spec orProfileValidationError listing them.extends"extends": "preset:<name>" starts from a preset. resolveProfile(spec)on entries merge by when, andescalation.tags accumulate. An unknown preset or a cycle throws.maxNewPerWeek and maxNewsrc/klock/policy/presets.ts:| Preset | Applies to | Capability subset | Freezes on | Revokes on | Level floor |
|---|---|---|---|---|---|
strict-admin | tiers: admin; kinds: interactive, browser, device | – | impossible-travel, agent.tamper | sync.rollback | warning |
standard-user | kinds: interactive, browser, device | – | agent.tamper, sync.rollback | – | – |
ci-service | kinds: service | deny resource:delete, billing:*, team:*, key:write | client.unexpected, cap.outside-profile | location.new-country, location.outside, device.unknown, device.new, agent.tamper, sync.rollback | warning |
read-only-analyst | kinds: interactive, browser, device (extends standard-user) | allow 13 read and self ids; deny data:write, migration:*, resource:delete, resource:provision, files:send | agent.tamper, sync.rollback | – | – |
db-gateway | kinds: service | allow passwordless:use, data:read, data:write | location.new-country, location.new-asn, location.outside, cap.outside-profile | device.unknown, agent.tamper, sync.rollback | warning |
on-device-agent | kinds: service | allow transfer:report, passwordless:use, klock:sync | cap.outside-profile | device.unknown, agent.tamper, sync.rollback | notice |
guest | kinds: guest | allow the five public capabilities | – | – | info |
strict-admin: known devices only (maxNew: 0), keychain-held keys,cli, browser andmcp, and at most 500 data:write and 5 resource:delete an hour. Itstandard-user: learned locations (three new a week) and devicesci-service: one device, no locations learned, clients cli and apimigration:apply and 10,000 data:write an hour.read-only-analyst: standard-user plus reads only, 5,000,000 rowsdb-gateway: a passwordless database gateway: a fixed keychain-heldapi and agent, 100,000 passwordless:use an hour andon-device-agent: the on-device transfer agent: one keychain-heldagent and cli, 600 transfer:report an hour; a staleguest: anonymous guest device keys: the public capabilities only,prorm behaviour presets lists them, and prorm behaviour presets <name>evaluateSignals(profile, baseline, observation, opts?) compares one| Signal | Severity (level) | Default actions | Raised when |
|---|---|---|---|
location.new-country | medium (warning) | alert | A country not in the learned baseline. |
location.new-asn | low (notice) | log | A network (AS) not in the learned baseline. |
location.outside | high (error) | alert, step-up | Outside the declared countries, networks or CIDRs. |
impossible-travel | high (error) | alert, step-up | Two locations too far apart for the time between them. |
hours.outside | medium (warning) | log | Outside the declared hours. |
device.unknown | high (error) | alert, step-up | No device, a device outside the declared list, or past maxNew. |
device.new | medium (warning) | alert | A device not in the learned baseline. |
device.file-key | low (notice) | log | The device key is held in a file, not the OS keychain. |
client.unexpected | medium (warning) | alert | A client that matches none of the declared selectors. |
cap.outside-profile | medium (warning) | log | A capability outside the profile's subset. |
rate.exceeded | medium (warning) | rate-limit | An operation rate above its ceiling. |
volume.exceeded | high (error) | alert | Rows read or bytes moved above the ceiling. |
agent.tamper | critical (critical) | alert, freeze | The device agent reported tampering (integrity failure). |
sync.stale | low (notice) | log | The device has not synced within the allowed time (24 hours by default, staleAfter). |
sync.rollback | critical (critical) | alert, freeze | The device presented an older sync state than it had. |
impossible-travel fires above 1,000 km/h (maxTravelKmh) for jumps of atminTravelKm), from the baseline's lastSeen sighting and thegeo.| Action | Level | What it means |
|---|---|---|
log | info | An audit entry. |
alert | warning | Notify the owner or the team's admins. |
step-up | warning | Clear the session's step-up, so the next gated call asks for one. |
rate-limit | warning | A per-session token bucket: rateLimit from the on entry, else 60 a minute for 15 minutes. When several findings rate-limit, the tightest wins. |
freeze | error | Every request is refused until the session is unfrozen with a step-up. |
revoke | critical | End the session. |
findings, the actions to apply now, wouldApplystrongest action, the highest level,rateLimit, and learn: the countries, networks and devices theescalation.level floor.report mode the onlylog, and wouldApply still says what enforce mode would{ mode: 'report' }.learn, unless learn: false, or maxNewPerWeek (locations)maxNew (devices) has been reached. Past maxNew, a new device raisesdevice.unknown instead of device.new.{ "name": "mine", "extends": "preset:strict-admin" } with aGB and dev_A, observing an admin's interactive request fromFR on dev_A with a file-held key:| Mode | Findings | Actions | wouldApply |
|---|---|---|---|
| enforce | location.new-country (medium, warning), device.file-key (low, raised to warning by the floor) | alert, step-up | alert, step-up |
| report | the same two, at warning | log | alert, step-up |
learn.countries is ["FR"]: strict-admin allows one newimport { defineProfile, evaluateSignals } from 'ts-prorm-orm/klock/policy';
const profile = defineProfile({ name: 'mine', extends: 'preset:strict-admin' });
const result = evaluateSignals(
profile,
{ countries: ['GB'], devices: ['dev_A'] },
{ kind: 'interactive', tier: 'admin', country: 'FR', deviceId: 'dev_A', deviceKeyStore: 'file', client: 'cli' },
);
console.log(result.findings.map((f) => f.signal), result.actions);
// [ 'location.new-country', 'device.file-key' ] [ 'alert', 'step-up' ]profileCounters(profile) listsrates and volumes, keyed by rateKey(rule)data:read@1h) or volumeKey(rule) (rowsRead@1d), or by the bare| Where | Signals |
|---|---|
| The API, per request | Location, hours, device, client and the capability subset; rates through Durable Object counters. |
| An hourly API cron | Volumes, and baseline learning. |
| The klock service, at sync | location.new-country, location.new-asn, location.outside, impossible-travel, device.unknown, device.new, device.file-key, agent.tamper, sync.stale and sync.rollback: the signals only it sees. |
| The device agent (advisory) | hours.outside, cap.outside-profile and rate.exceeded (LOCAL_SIGNALS), from the compiled local policy in the sync answer's policy.local. |
ApiInternal.recordFindings) before it mints the grant. The API accepts afreeze or revoke means that same sync gets notoLocalPolicy(profile) compiles theevaluateLocal(policy, observation)promptStepUp when afindingToEscalationEvent(finding, profile, ctx?) turns a finding into anEscalationEvent for the API's EscalationManager:source: 'klock', category: 'security', operation: <signal>, and alevel (escalation rules may notify on it but not re-grade it);klock, profile:<name>, signal:<signal>, report-mode for adata: the profile, signal, severity, mode, the strongest actionlog in report mode), all actions, and sessionRef, deviceId, ip,country, value and threshold.klock-behaviour escalation preset routes those events:agent.tamper, sync.rollback and impossible-travel are raised tofreeze or revoke action is raised to error and goes to security.| Command | Does |
|---|---|
prorm behaviour list [--scope …] | Profiles on a scope. Alias prorm behaviour. |
prorm behaviour presets [name] | The presets, or one in full. |
prorm behaviour show <id> | One profile. |
prorm behaviour apply <preset> [--scope …] [--mode report|enforce] [--name …] | Applies a preset (report mode by default). Alias behaviour add. |
prorm behaviour set --file profile.json [--scope …] [--mode …] | Saves a profile from a file, validated first. |
prorm behaviour rm <id> | Removes a profile. |
prorm behaviour test [preset] [--file …] [--mode …] [--country … --ip … --asn … --device … --key-store … --client … --kind … --tier … --caps … --at … --baseline file.json] | Evaluates a profile locally against a described request: which signals fire, and what it would do. |
prorm behaviour findings [--ack <id>] [--all] | What the profiles noticed, newest first; --ack acknowledges one. |
behavior is accepted for behaviour in every command. --scope isaccount (the default), team:<t>, device:<id>, session:<id> orservice:<id>. A profile is validated and its extends chain resolvedbehaviour:read; apply, set andrm need behaviour:write; team profiles need behaviour:team.standard-user and{
"name": "payments-engineers",
"extends": "preset:standard-user",
"applies": { "kinds": ["interactive", "device"] },
"normal": {
"locations": { "countries": ["GB", "IE"], "learn": true, "maxNewPerWeek": 1 },
"hours": [{ "days": [1, 2, 3, 4, 5], "start": "07:00", "end": "20:00", "timezone": "Europe/London" }],
"devices": { "requireKeychain": true, "maxNew": 2, "learn": true },
"clients": ["cli", "browser", "mcp:name=Claude Code"],
"operations": {
"rates": [
{ "cap": "data:write", "max": 2000, "per": "1h" },
{ "cap": "resource:delete", "max": 2, "per": "1d" }
],
"volumes": [{ "metric": "rowsRead", "max": 2000000, "per": "1d" }]
}
},
"capabilities": { "deny": ["billing:manage"] },
"on": [
{ "when": "location.outside", "do": ["alert", "freeze"] },
{ "when": "hours.outside", "do": ["alert"] },
{ "when": "device.file-key", "do": ["step-up"] },
{ "when": "rate.exceeded", "do": ["alert", "rate-limit"], "rateLimit": { "max": 20, "per": "1m", "for": "30m" } }
],
"escalation": { "level": "warning", "preset": "klock-behaviour", "tags": ["payments"] }
}| Part | What it says |
|---|---|
extends | Keep standard-user's defaults (impossible-travel step-up, freeze on tamper or rollback) and override the rest. Lists replace the preset's; on entries merge by when. |
applies.kinds | People only. Give the team's CI service sessions the ci-service preset instead. |
normal.locations | The declared countries. Leaving them raises location.outside, which this profile turns into alert and freeze. A new country inside them is learned, at most one a week. |
normal.hours | Outside office hours is worth an email (alert), not a refusal: on-call engineers work at night. |
normal.devices | A key in a file rather than the OS keychain asks for a step-up. At most two new devices are learned; a third raises device.unknown. |
normal.clients | Claude Code is expected. Any other MCP client raises client.unexpected, which alerts by default. |
normal.operations | 2,000 writes an hour and two deletes a day are normal. Past that, rate-limit gives the session 20 requests a minute for 30 minutes. The hourly cron checks the 2,000,000-rows-a-day read volume. |
capabilities.deny | Engineers never touch billing. A session that uses billing:manage raises cap.outside-profile (logged). The team role still decides whether it is allowed. |
escalation | Every finding is at least warning, tagged payments, and goes through the klock-behaviour escalation preset. |
prorm behaviour test validates the file and evaluates it against the{"countries":["GB"],"devices":["dev_7QK2"]}):$ prorm behaviour test --file payments-engineers.json --baseline baseline.json \
--kind interactive --country GB --device dev_7QK2 --key-store keychain --client cli \
--caps data:read --at 2026-09-28T10:00:00+01:00
✓ Normal: no signal fires.
$ prorm behaviour test --file payments-engineers.json --baseline baseline.json \
--kind interactive --country PT --device dev_7QK2 --key-store keychain --client cli \
--caps data:write --at 2026-10-03T23:30:00+01:00
! location.outside (high): outside the declared locations: country PT → alert, freeze
! location.new-country (medium): new country PT → alert
! hours.outside (medium): outside the declared hours → alert
Actions (enforce): alert, freeze
$ prorm behaviour test --file payments-engineers.json --baseline baseline.json \
--kind interactive --country GB --device dev_7QK2 --key-store file \
--client 'mcp:name=Claude Desktop' --caps billing:manage --at 2026-09-28T10:00:00+01:00
! device.file-key (low): the device key is held in a file → step-up
! client.unexpected (medium): unexpected client mcp:name=Claude Desktop → alert
! cap.outside-profile (medium): outside the profile: billing:manage → log
Actions (enforce): log, alert, step-up$ prorm behaviour set --file payments-engineers.json --scope team:payments
✓ Profile “payments-engineers” on team:7 (report).
Report mode: deviations are recorded as findings; nothing is refused.log, andwouldApply records what enforce mode would have done.GET /teams/:teamId/security/findings and on thebehaviour:team). Widen the profile where theversion rather than adding[!WARNING] freezestops the session until someone unfreezes it with a step-up from
another session. An engineer on their only laptop, abroad, has to use the
console or another signed-in device. Keepfreezefor signals you are sure
of, such aslocation.outsidehere, and usealertorstep-upfor the rest.
api/middleware/accessRules.js) is mounted before the routers. It works outapi/controllers/accessRules.ts andapi/controllers/behaviour.ts. All paths are under the API's base path.| Method and path | Body or query | Answer |
|---|---|---|
GET /security/access-rules | ?scope=account, team:<id|uuid|slug>, database:<id|uuid|name> (or db:), store:<…> or device:<dev_…>. Without a scope: every scope you can see. | { rules } |
GET /security/access-rules/context | – | { ip, country, asn, deviceId, client }: this request as the rules see it |
GET /security/access-rules/:id | – | { rule } |
POST /security/access-rules | { scopeKind, scopeId?, effect, dimension, value, appliesTo?, mode?, enforceNow?, expiresAt?, note?, mandatory?, force?, totp? | password? } | 201 { rule, forced? } |
DELETE /security/access-rules/:id | { force?, totp? | password? } | { id, removed: true, rule } |
{ id, uuid, scopeKind, scopeId, scope, effect,
dimension, value, appliesTo, mandatory, mode, enforceAt?, note, expiresAt,
enabled, createdAt, createdBy, lockdown?, legacy? }. mode is the modeenforceAt is present while a new rule is in its report period.| Scope | Who |
|---|---|
| account | you, with access-rule:write |
| device | a device you have a session on |
| database, store | the owner, or a team role with access:write |
| team | a team role with access-rule:team (owner, admin, security) |
404. A scope holds at most[!NOTE]
Every write (creating or removing a rule or profile, acknowledging a
finding) needs a verified email address. There is no route that edits a
rule: to change one, remove it and add the new one.
x-prorm-team header, a /teams/:teamId/… path, or a request to/provision/databases/:id, /provision/stores/:id).appliesTo is matched against the capability the route needs, from theappliesTo apply to it.403 { error: "access_denied" },access.denied (sampled)klock.access-denied database event, soaccess.would-deny, sampled to one per user and rule set every 10 minutes.x-prorm-geo-country and x-prorm-geo-asn by the Worker entry after anyTRUST_GEO_HEADERS=1, because they would be the client's own words. Thex-prorm-client-cert-sha256.client dimension sees cli (klock interactive and devicemcp or mcp:name=<clientName>api (API keys), agentbrowser. An MCP client's name is self-reported, soACCESS_RULES_CACHE_MS (30 000 by default). A write drops the cache inmode: "report" keeps aenforceNow: true (or mode: "enforce")409 would_lock_you_out with reasons (code,dimension, scope, message) and ruleIds. It judges every rule asforce:
true saves anyway only with a fresh step-up (or totp or password in the409. A forced save isalert.database_access.blockedIps isdatabase/block/ip rules, marked legacy: "blockedIps"legacy:blockedIps), in report mode. They are visible and recordPUT /provision/databases/:id/access, when a database's rules areallowedIps, is a different layer and is not a| Method and path | Body or query | Answer |
|---|---|---|
GET /security/behaviour | ?scope=account, team:<t>, device:<id>, session:<jti|id> or service:<jti|id> | { profiles } |
GET /security/behaviour/presets | – | { presets: [{ name, description, spec }] } |
GET /security/behaviour/findings | ?all=1 includes acknowledged ones | { findings }, newest first, at most 500 |
POST /security/behaviour/findings/:id/ack | – | { finding, acknowledged: true } |
GET /security/behaviour/:id | – | { profile } |
POST /security/behaviour | { scopeKind, scopeId?, name, preset?, spec?, mode?, totp? | password? } | 201 (new) or 200 (updated) { profile, created } |
DELETE /security/behaviour/:id | { totp? | password? } | { id, removed: true } |
version. spec may be left outpreset is given (it becomes extends: "preset:<name>"). A new profilemode says otherwise. Weakening an enforcing| Where | Signals |
|---|---|
| Per request, in the middleware | location.new-country, location.new-asn, location.outside, hours.outside, device.unknown, device.new, device.file-key, client.unexpected, cap.outside-profile, and rate.exceeded, counted in the DeviceGuard Durable Object per profile and session |
The hourly cron (api/basic/controllers/behaviourCron.ts) | volume.exceeded for rowsRead (the query log: your rows, and your databases' rows) and transferBytes (finished transfer jobs), one finding per profile, user and window. filesBytes has no source yet: klock file transfers do not feed the cron. |
session:<jti> or service:<jti>). Baselines of countries, ASNs andlearn: false, maxNewPerWeek andmaxNew stop them widening. The klock service evaluates the profiles thatimpossible-travel,agent.tamper, sync.stale and sync.rollback there, from what the deviceklock.tamper-detected, klock.sync-rollback) and what only klockbehaviour_findings rows withdata.source: "klock". alert emails are not sent for klock findings yet;log.| Action | Effect |
|---|---|
log | An audit row. |
alert | The account is emailed, at most once per profile and signal an hour. |
step-up | The session's step-up is cleared, so its next gated call needs one. |
rate-limit | The session gets a budget (rateLimit { max, per, for }, 10 a minute for 15 minutes by default); past it, 429 rate_limited with Retry-After. |
freeze | The session answers 423 session_frozen until it is unfrozen, with a step-up, from another session. |
revoke | The session is revoked: 401 session_revoked. |
behaviour_findings rows, immutable except forbehaviour.deviation audit row and anfindingToEscalationEvent and stored on thedata.escalation, and it is handed to the escalation listenersklock.deviationklock.deviation and klock.access-denied are in the policy/teams/:teamId/security:| Route | Needs |
|---|---|
GET /access-rules | any active member |
POST /access-rules, DELETE /access-rules/:ruleId | access-rule:team. A new team rule is mandatory unless you say otherwise. |
GET /behaviour | any active member |
POST /behaviour, DELETE /behaviour/:profileId | behaviour:team |
GET /findings[?all=1], POST /findings/:findingId/ack | behaviour:team |
POST /lockdown/:memberId, DELETE /lockdown/:memberId | session:manage-members, with { reason } |
:memberId is the team-member row id, as in /teams/:teamId/members/:memberId./teams/:id/security/*alert with the actor's id, and the member is emailed. Only the owner mayDELETE reverses| Route | Body | Answer |
|---|---|---|
POST /security/recovery/start | { email } | Always 202 { message }, for an unknown address too. At most 10 an hour per client address (429 rate_limited), and 3 links an hour per account (silently). |
POST /security/recovery/complete | { code, password, totp? | recoveryCode? } | { token, tokenType, recovery: true, expiresIn: 3600, expiresAt, disabledRules, message, user } |
#/recover?code=…, valid 30 minutesrecovery: pending review, so you can fix them;alert (access-rule.recovery) and the account is emailed./security/lockdown is the emergency stop for an account you think is| Route | Body | Answer |
|---|---|---|
POST /security/lockdown | { includeServices?, totp? | password? } | { lockedDown: true, revoked, suspended, devices, lockdownUntil } |
DELETE /security/lockdown | { totp? | password? } | { lockedDown: false, released } |
GET /security/lockdown | – | { lockedDown, lockdownUntil } |
includeServices: true;423 { error: "lockdown", lockdownUntil }, including sessions signed inalert and emails the account.401 fresh_signin_required orstep_up_required. Devices stay suspended: reinstate them one by one withprorm devices reinstate <id>.access_denied (403), lockdown (423), would_lock_you_out (409),invalid_rule and invalid_profile (400), rate_limited (429),recovery_failed (400 or 401) and fresh_signin_required (401). The routesstep_up_required, totp_required, session_frozen,session_revoked, capability_missing and too_many_attempts. Each has anApiInternal.compiledRules): the owner's account rules, the rules of teamsgrantError: "access_denied", and only remove credentialaccess.denied audit row withdetail.layer: "klock".POST /v2/enrol). A deny is 403 access_denied, before/v2/credentials/*). A deny is 403 access_denied;appliesTo) apply too, to the capabilitiesaccess.would-deny. If the rules cannot be read, klock fails closed: a syncgrantError: "policy_unavailable" and no grant, and enrolment and503 policy_unavailable. A rule change takes up toACCESS_RULES_CACHE_MS (30 seconds) plus the device's next sync to reach a.klock file, grants, sessions and devicesprorm command