@irisrun/auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ import type { Principal, GovernedAction } from "./identity.js";
2
+ import type { ApprovalPolicy } from "./policy.js";
3
+ export type RawApproval = {
4
+ principal: Principal;
5
+ intent: "approve" | "deny";
6
+ };
7
+ export type GovernedApproval = {
8
+ approved: boolean;
9
+ intent: "approve" | "deny";
10
+ authorized: boolean;
11
+ principal: Principal;
12
+ action: GovernedAction;
13
+ reason: string;
14
+ };
15
+ /** Combine a human decision with the policy. Pure. Tool runs only on
16
+ * approve + authorized; an unauthorized "approve" yields approved:false. */
17
+ export declare function decideApproval(input: {
18
+ policy: ApprovalPolicy;
19
+ principal: Principal;
20
+ intent: "approve" | "deny";
21
+ action: GovernedAction;
22
+ }): GovernedApproval;
@@ -0,0 +1,20 @@
1
+ import { authorize } from "./policy.js";
2
+ /** Combine a human decision with the policy. Pure. Tool runs only on
3
+ * approve + authorized; an unauthorized "approve" yields approved:false. */
4
+ export function decideApproval(input) {
5
+ const { policy, principal, intent, action } = input;
6
+ const auth = authorize(policy, principal, action);
7
+ const authorized = auth.permit;
8
+ const approved = intent === "approve" && authorized;
9
+ let reason;
10
+ if (intent === "deny") {
11
+ reason = `denied by '${principal.id}'`;
12
+ }
13
+ else if (authorized) {
14
+ reason = `approved by '${principal.id}' (${auth.reason})`;
15
+ }
16
+ else {
17
+ reason = `unauthorized: ${auth.reason}`;
18
+ }
19
+ return { approved, intent, authorized, principal, action, reason };
20
+ }
@@ -0,0 +1,25 @@
1
+ import type { SessionInspection } from "@irisrun/inspect";
2
+ import type { StateStore } from "@irisrun/core";
3
+ import type { Principal } from "./identity.js";
4
+ export type ApprovalAuditEntry = {
5
+ seq: number;
6
+ ts: number;
7
+ callId: string;
8
+ tool: string | null;
9
+ principal: Principal | null;
10
+ intent: "approve" | "deny" | null;
11
+ approved: boolean;
12
+ authorized: boolean | null;
13
+ reason: string | null;
14
+ };
15
+ /** Project the approval trail from an already-inspected session. NOTE: an inspection
16
+ * covers only the post-snapshot tail (see the RETENTION CONTRACT above); for a
17
+ * complete trail prefer `auditApprovals`. Pure. */
18
+ export declare function approvalAudit(inspection: SessionInspection): ApprovalAuditEntry[];
19
+ /** Read the FULL retained journal (from seq 0) and project the approval trail. This
20
+ * is the complete-trail entry point: it sees every approval still retained, including
21
+ * ones before a snapshot boundary (which `inspectSession` would omit). Completeness
22
+ * across truncation requires retained history (see the RETENTION CONTRACT above). */
23
+ export declare function auditApprovals(store: StateStore, sessionId: string): Promise<ApprovalAuditEntry[]>;
24
+ /** Deterministic one-line-per-entry rendering of an approval trail. */
25
+ export declare function renderApprovalAudit(entries: ApprovalAuditEntry[]): string;
package/dist/audit.js ADDED
@@ -0,0 +1,101 @@
1
+ // The journaled approval audit trail (roadmap P1-5, done-when #2). Pure read over a
2
+ // recorded session: every governed (or legacy) approval is already a journaled
3
+ // `signal_recv` effect result, so the audit is a projection of the journal — nothing
4
+ // new is stored.
5
+ //
6
+ // RETENTION CONTRACT (important): the trail is only as complete as the RETAINED
7
+ // journal. The engine snapshots and TRUNCATES the journal past each snapshot unless a
8
+ // turn runs with `keepHistory: true` (@irisrun/core engine.ts). So:
9
+ // • `auditApprovals` reads the FULL retained journal (from seq 0) — it sees every
10
+ // approval still on disk, INCLUDING ones before a snapshot boundary. For a
11
+ // COMPLETE compliance trail across a long session, retain history (run governed
12
+ // turns with keepHistory — see the CLI `keepHistory` option); a session that
13
+ // truncates keeps only the surviving tail, and truncated approvals are gone.
14
+ // • `approvalAudit(inspection)` projects whatever inspection it is given. Note that
15
+ // `inspectSession` reads only the POST-snapshot tail, so it OMITS approvals before
16
+ // the snapshot boundary even when history is retained — prefer `auditApprovals`
17
+ // for a complete trail.
18
+ import { inspectSession } from "@irisrun/inspect";
19
+ import { decode } from "@irisrun/core";
20
+ const HITL_PREFIX = "hitl:";
21
+ function asObject(v) {
22
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
23
+ }
24
+ function asPrincipal(v) {
25
+ const o = v === undefined ? null : asObject(v);
26
+ return o && typeof o.id === "string" ? o : null;
27
+ }
28
+ /** Project an ordered approval trail from a set of journal rows. Pure. */
29
+ function projectApprovals(rows) {
30
+ // 1) hitl signal_recv intents → callId, keyed by effectId for the result join.
31
+ const callIdByEffect = new Map();
32
+ for (const r of rows) {
33
+ if (r.kind !== "effect_intent")
34
+ continue;
35
+ const p = asObject(r.detail);
36
+ if (!p || p.effectKind !== "signal_recv" || typeof p.effectId !== "string")
37
+ continue;
38
+ const req = asObject(p.request);
39
+ const name = req && typeof req.name === "string" ? req.name : null;
40
+ if (name && name.startsWith(HITL_PREFIX))
41
+ callIdByEffect.set(p.effectId, name.slice(HITL_PREFIX.length));
42
+ }
43
+ // 2) project each matching effect_result's value (governed OR legacy bare {approved}).
44
+ const entries = [];
45
+ for (const r of rows) {
46
+ if (r.kind !== "effect_result")
47
+ continue;
48
+ const p = asObject(r.detail);
49
+ if (!p || typeof p.effectId !== "string" || !callIdByEffect.has(p.effectId))
50
+ continue;
51
+ const callId = callIdByEffect.get(p.effectId);
52
+ const outcome = asObject(p.outcome);
53
+ const ok = outcome ? outcome.ok === true : false;
54
+ const v = ok && outcome ? asObject(outcome.value) : null;
55
+ const action = v ? asObject(v.action) : null;
56
+ entries.push({
57
+ seq: r.seq,
58
+ ts: r.ts,
59
+ callId,
60
+ tool: action && typeof action.name === "string" ? action.name : null,
61
+ principal: v ? asPrincipal(v.principal) : null,
62
+ intent: v && (v.intent === "approve" || v.intent === "deny") ? v.intent : null,
63
+ approved: v ? v.approved === true : false,
64
+ authorized: v && typeof v.authorized === "boolean" ? v.authorized : null,
65
+ reason: v && typeof v.reason === "string" ? v.reason : ok ? null : "effect failed",
66
+ });
67
+ }
68
+ // Records arrive in journal order; sort defensively so the trail is seq-ordered.
69
+ entries.sort((a, b) => a.seq - b.seq);
70
+ return entries;
71
+ }
72
+ /** Project the approval trail from an already-inspected session. NOTE: an inspection
73
+ * covers only the post-snapshot tail (see the RETENTION CONTRACT above); for a
74
+ * complete trail prefer `auditApprovals`. Pure. */
75
+ export function approvalAudit(inspection) {
76
+ return projectApprovals(inspection.records);
77
+ }
78
+ /** Read the FULL retained journal (from seq 0) and project the approval trail. This
79
+ * is the complete-trail entry point: it sees every approval still retained, including
80
+ * ones before a snapshot boundary (which `inspectSession` would omit). Completeness
81
+ * across truncation requires retained history (see the RETENTION CONTRACT above). */
82
+ export async function auditApprovals(store, sessionId) {
83
+ const rows = await store.readJournal(sessionId, 0);
84
+ const records = rows.map((row) => {
85
+ const rec = decode(row.bytes);
86
+ return { seq: rec.seq, ts: rec.ts, kind: rec.kind, detail: rec.payload };
87
+ });
88
+ return projectApprovals(records);
89
+ }
90
+ /** Deterministic one-line-per-entry rendering of an approval trail. */
91
+ export function renderApprovalAudit(entries) {
92
+ if (entries.length === 0)
93
+ return "no approvals recorded";
94
+ return entries
95
+ .map((e) => {
96
+ const who = e.principal ? e.principal.id : "—";
97
+ const verdict = e.approved ? "APPROVED" : "skipped";
98
+ return `#${e.seq} ${e.callId} ${e.tool ?? "?"} — ${verdict} by ${who} (intent:${e.intent ?? "?"}, authorized:${e.authorized ?? "?"})`;
99
+ })
100
+ .join("\n");
101
+ }
@@ -0,0 +1,8 @@
1
+ export type Principal = {
2
+ id: string;
3
+ roles?: string[];
4
+ };
5
+ export type GovernedAction = {
6
+ name: string;
7
+ callId: string;
8
+ };
@@ -0,0 +1,5 @@
1
+ // Domain nouns for governance. Json-rideable → `type` aliases, NOT interfaces:
2
+ // these values ride a journaled `Json` (the signal_recv approval value), and an
3
+ // interface does not get the implicit `[k: string]: Json` index signature an object
4
+ // literal needs to be assignable to Json (convention: @irisrun/core harness/seams.ts:1-11).
5
+ export {};
@@ -0,0 +1,10 @@
1
+ export declare const PACKAGE = "@irisrun/auth";
2
+ export type { Principal, GovernedAction } from "./identity.js";
3
+ export { authorize } from "./policy.js";
4
+ export type { ApprovalPolicy, ApprovalRule, AuthDecision } from "./policy.js";
5
+ export { decideApproval } from "./approval.js";
6
+ export type { RawApproval, GovernedApproval } from "./approval.js";
7
+ export { createApprovalInbox, makeGovernedApprovalPerformer } from "./performer.js";
8
+ export type { ApprovalInbox } from "./performer.js";
9
+ export { approvalAudit, auditApprovals, renderApprovalAudit } from "./audit.js";
10
+ export type { ApprovalAuditEntry } from "./audit.js";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ // @irisrun/auth — the governance layer (roadmap P1-5). Identity + a declarative
2
+ // who-may-approve authorization policy on the existing HITL approval gate, plus a
3
+ // journaled, queryable approval audit trail. Pure: the governed decision rides the
4
+ // existing journaled `signal_recv` effect result (the kernel's `foldApproval` reads
5
+ // only `approved===true`), so governance enriches that value with ZERO kernel change.
6
+ export const PACKAGE = "@irisrun/auth";
7
+ // policy.ts — who-may-approve authorization (done-when #1)
8
+ export { authorize } from "./policy.js";
9
+ // approval.ts — combine human intent + policy into the journaled value
10
+ export { decideApproval } from "./approval.js";
11
+ // performer.ts — the first real governed signal_recv performer + the approval inbox
12
+ export { createApprovalInbox, makeGovernedApprovalPerformer } from "./performer.js";
13
+ // audit.ts — the journaled, queryable approval trail (done-when #2)
14
+ export { approvalAudit, auditApprovals, renderApprovalAudit } from "./audit.js";
@@ -0,0 +1,17 @@
1
+ import type { Performer } from "@irisrun/core";
2
+ import type { GovernedAction } from "./identity.js";
3
+ import type { RawApproval } from "./approval.js";
4
+ import type { ApprovalPolicy } from "./policy.js";
5
+ export interface ApprovalInbox {
6
+ submit(action: GovernedAction, decision: RawApproval): void;
7
+ get(callId: string): {
8
+ action: GovernedAction;
9
+ decision: RawApproval;
10
+ } | undefined;
11
+ }
12
+ export declare function createApprovalInbox(): ApprovalInbox;
13
+ /** Build the governed `signal_recv` performer for a policy + inbox. */
14
+ export declare function makeGovernedApprovalPerformer(opts: {
15
+ policy: ApprovalPolicy;
16
+ inbox: ApprovalInbox;
17
+ }): Performer;
@@ -0,0 +1,52 @@
1
+ import { decideApproval } from "./approval.js";
2
+ const HITL_PREFIX = "hitl:";
3
+ export function createApprovalInbox() {
4
+ const decisions = new Map();
5
+ return {
6
+ submit(action, decision) {
7
+ decisions.set(action.callId, { action, decision });
8
+ },
9
+ get(callId) {
10
+ return decisions.get(callId);
11
+ },
12
+ };
13
+ }
14
+ /** Build the governed `signal_recv` performer for a policy + inbox. */
15
+ export function makeGovernedApprovalPerformer(opts) {
16
+ const { policy, inbox } = opts;
17
+ return async (request) => {
18
+ // Outer guard: this performer is folded in `recv_hitl`, a phase with NO failure
19
+ // handler — ANY throw or {ok:false} is journaled and re-throws forever on replay
20
+ // (poisons the session). So even an OPERATOR misconfiguration (e.g. a malformed
21
+ // `policy`) must fail safe, not loud. Per-request input is guarded below; this
22
+ // catch is the last-resort backstop that keeps the {ok:true} contract absolute.
23
+ try {
24
+ // Guard the boundary: the kernel hands `{ name: "hitl:<callId>" }`. Anything else
25
+ // (a malformed request, a non-HITL signal) fails SAFE, never loud.
26
+ const name = request !== null && typeof request === "object" && !Array.isArray(request)
27
+ ? request.name
28
+ : undefined;
29
+ if (typeof name !== "string" || !name.startsWith(HITL_PREFIX)) {
30
+ return { ok: true, value: { approved: false, reason: `governed approval: not a hitl signal (${JSON.stringify(name ?? null)})` } };
31
+ }
32
+ const callId = name.slice(HITL_PREFIX.length);
33
+ const recorded = inbox.get(callId);
34
+ if (!recorded) {
35
+ // The decision must be submitted before the resume signal; absent is a wiring
36
+ // gap, not an error — skip the tool with a clear reason rather than poison.
37
+ return { ok: true, value: { approved: false, reason: `no approval decision recorded for '${callId}'` } };
38
+ }
39
+ const governed = decideApproval({
40
+ policy,
41
+ principal: recorded.decision.principal,
42
+ intent: recorded.decision.intent,
43
+ action: recorded.action,
44
+ });
45
+ return { ok: true, value: governed };
46
+ }
47
+ catch (e) {
48
+ const message = e instanceof Error ? e.message : String(e);
49
+ return { ok: true, value: { approved: false, reason: `governance error (denied for safety): ${message}` } };
50
+ }
51
+ };
52
+ }
@@ -0,0 +1,16 @@
1
+ import type { Principal, GovernedAction } from "./identity.js";
2
+ export type ApprovalRule = {
3
+ tool?: string;
4
+ anyOfRoles?: string[];
5
+ principals?: string[];
6
+ };
7
+ export type ApprovalPolicy = {
8
+ rules: ApprovalRule[];
9
+ default?: "deny" | "permit";
10
+ };
11
+ export type AuthDecision = {
12
+ permit: boolean;
13
+ reason: string;
14
+ };
15
+ /** Does `principal` may-approve `action` under `policy`? Pure. */
16
+ export declare function authorize(policy: ApprovalPolicy, principal: Principal, action: GovernedAction): AuthDecision;
package/dist/policy.js ADDED
@@ -0,0 +1,23 @@
1
+ function toolMatches(rule, action) {
2
+ return rule.tool === undefined || rule.tool === "*" || rule.tool === action.name;
3
+ }
4
+ /** Does `principal` may-approve `action` under `policy`? Pure. */
5
+ export function authorize(policy, principal, action) {
6
+ const roles = principal.roles ?? [];
7
+ for (const rule of policy.rules) {
8
+ if (!toolMatches(rule, action))
9
+ continue;
10
+ const byRole = (rule.anyOfRoles ?? []).some((r) => roles.includes(r));
11
+ const byId = (rule.principals ?? []).includes(principal.id);
12
+ if (byRole || byId) {
13
+ return {
14
+ permit: true,
15
+ reason: `granted: '${principal.id}' satisfies a rule for '${action.name}' (by ${byRole ? "role" : "principal id"})`,
16
+ };
17
+ }
18
+ }
19
+ const fallback = policy.default ?? "deny";
20
+ return fallback === "permit"
21
+ ? { permit: true, reason: `default permit: no rule matched '${action.name}'` }
22
+ : { permit: false, reason: `denied: no rule grants '${principal.id}' approval of '${action.name}'` };
23
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@irisrun/auth",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Iris governance layer — identity + a declarative who-may-approve authorization policy on the existing HITL gate, plus a journaled, queryable approval audit trail. Pure: enriches the journaled signal_recv approval value (zero kernel change). Deps @irisrun/core + @irisrun/inspect only.",
6
+ "exports": {
7
+ ".": {
8
+ "iris-src": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@irisrun/core": "^0.1.0",
15
+ "@irisrun/inspect": "^0.1.0"
16
+ },
17
+ "license": "MIT",
18
+ "engines": {
19
+ "node": ">=24"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/xoai/iris.git",
27
+ "directory": "packages/auth"
28
+ },
29
+ "homepage": "https://github.com/xoai/iris#readme",
30
+ "files": [
31
+ "dist"
32
+ ]
33
+ }