From 479eb0dc177179b88d1fd0ca97c7001554858886 Mon Sep 17 00:00:00 2001 From: webeventualsapp-cpu Date: Mon, 22 Jun 2026 15:40:47 +0530 Subject: [PATCH] feat(skills): add least-privilege-plan skill Add a read-only least-privilege-plan skill that compares declared grants against bounded, receipt-derived run history and classifies each grant as keep, reduce, revoke, or needs_human_review. Includes evidence, rationale, and operational risk for each decision. - SKILL.md: full procedure, output schema, worked example, edge cases - X.yaml: execution profile with readonly sandbox and typed inputs - run.mjs: deterministic CLI runner producing structured JSON output - fixtures: over-granted-plan-reduces and minimal-grants-unchanged harness cases Refs auscaster/frantic-board#81 Signed-off-by: webeventualsapp-cpu --- skills/least-privilege-plan/SKILL.md | 248 ++++++++++++++ skills/least-privilege-plan/X.yaml | 45 +++ .../fixtures/minimal-grants-unchanged.yaml | 51 +++ .../fixtures/over-granted-plan-reduces.yaml | 90 +++++ skills/least-privilege-plan/run.mjs | 312 ++++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 skills/least-privilege-plan/SKILL.md create mode 100644 skills/least-privilege-plan/X.yaml create mode 100644 skills/least-privilege-plan/fixtures/minimal-grants-unchanged.yaml create mode 100644 skills/least-privilege-plan/fixtures/over-granted-plan-reduces.yaml create mode 100644 skills/least-privilege-plan/run.mjs diff --git a/skills/least-privilege-plan/SKILL.md b/skills/least-privilege-plan/SKILL.md new file mode 100644 index 00000000..790734ad --- /dev/null +++ b/skills/least-privilege-plan/SKILL.md @@ -0,0 +1,248 @@ +--- +name: least-privilege-plan +description: Generate a least-privilege grant plan that compares declared grants against receipt-derived run history and produces the narrowest set of permissions that still covers real usage. +runx: + category: security +--- + +# Least-Privilege Grant Plan + +Produce a reviewable, evidence-grounded plan that narrows a set of declared +grants to the minimum permissions required by observed usage. + +This skill reads a set of declared or proposed grants and compares them against +bounded, receipt-derived run history. It classifies each grant as `keep`, +`reduce`, `revoke`, or `needs_human_review`, then emits a structured plan with +evidence, rationale, and operational risk for every decision. The output is a +plan for a human reviewer — it never applies changes automatically. + +## What this skill does + +1. Accept declared grants and receipt-backed usage evidence. +2. Compare each declared grant against observed usage from receipts. +3. Classify every grant into one of four actions: + - `keep` — observed usage requires the grant exactly as declared. + - `reduce` — observed usage fits a strictly narrower permission. + - `revoke` — no observed usage supports the grant. + - `needs_human_review` — evidence is ambiguous, conflicting, or the grant + carries policy or compliance constraints that require human judgment. +4. Produce a structured plan with per-grant evidence, rationale, and risk. +5. State residual risk after the proposed plan is applied. +6. Emit receipt expectations so downstream verification can confirm the plan. + +## When to use this skill + +- Before granting authority to a skill, principal, or service account, to + verify that requested permissions are the minimum necessary. +- During periodic privilege review, to identify grants that can be safely + narrowed or revoked based on actual usage patterns. +- After an incident, to produce an actionable remediation plan that removes + unnecessary authority without breaking observed behavior. +- Before publishing or promoting a skill to wider distribution, to prove that + its grant surface is minimal. + +## When not to use this skill + +- To expand or add new authority. This skill only narrows; widening is a human + decision routed through the appropriate approval flow. +- When no receipt evidence exists. The skill returns `needs_more_evidence` + rather than guessing grants down to nothing. +- For secret material handling or credential rotation. Use the appropriate + secret-management or vault flow instead. +- When the operator wants automatic permission changes applied. This skill + produces a plan and stops; a separate approved delivery lane must apply it. + +## Procedure + +1. Scope the plan target. + - Identify `subject`, the grant source, the receipt window or receipt ids, + and whether receipts are from the same principal or skill version. + - Gate: if the subject, grant list, or usage source is ambiguous, stop with + `needs_input`. + +2. Normalize declared grants. + - Parse each grant into action, resource, path or namespace, conditions, and + wildcard breadth. + - Preserve original grant strings. Do not rewrite policy syntax. + - Gate: if a grant cannot be parsed, classify it as `needs_human_review` and + request the missing policy semantics. + +3. Build the usage model from receipts. + - Extract actual exercised actions and resources from receipt steps, tool + calls, policy checks, and completion status. + - Count successful use separately from denied or dry-run checks. + - Do not infer grant usage from a successful high-level task alone; cite the + receipt step or policy check that exercised the authority. + +4. Classify every declared grant. + - `keep`: at least one observed successful use requires the grant as + declared, or a reserved / break-glass policy explicitly requires it. + - `reduce`: all observed uses fit a strictly smaller action, resource, + namespace, condition, or path. + - `revoke`: no observed use, denied check, or documented reserved purpose + supports the grant. + - `needs_human_review`: evidence is conflicting, receipt attribution is + weak, or policy semantics are unknown. + +5. Produce the grant plan. + - For each grant, emit the proposed action, the evidence that supports it, + the rationale, and the operational risk if the action is applied. + - Aggregate into a planned grant set (the minimal set after applying all + proposed actions). + +6. State residual risk and reviewer action. + - Name what the planned grant set can still do. + - Name any broad grant kept despite thin evidence and why. + - Separate `apply_now` from `needs_policy_decision`. + +7. Emit receipt expectations. + - A valid receipt for this skill should record input grant count, receipt + sources, classification counts, proposed changes, stop status, and + unresolved questions. + +## Edge cases and stop conditions + +- Empty or unattributable usage evidence: return `needs_more_evidence`; do not + revoke all grants by default. +- Missing declared grants: return `needs_input`; there is no baseline to plan + against. +- Receipt subject mismatch: return `needs_input` with the mismatched subject or + version. +- Conflicting receipts: classify affected grants as `needs_human_review` and + return `needs_human` if the conflict changes the plan. +- Wildcard grants: reduce only to observed resource prefixes when receipt + coverage is representative; otherwise keep and flag residual risk. +- Reserved, compliance, or break-glass grants: keep unless the operator provides + explicit policy authority to revoke them. +- Dry-run-only use: do not count as successful exercised authority unless the + grant exists solely for validation. +- Grant already matches usage: return `no_change` with the evidence summary. +- User asks to hide or omit unused authority: refuse that part and report the + complete grant diff. + +## Output schema + +Return a structured report with these fields: + +```yaml +status: plan_proposed | no_change | needs_more_evidence | needs_input | needs_human | refused +subject: string +evidence: + receipt_ids: [string] + receipt_window: string | null + grant_source: string | null + limitations: [string] +grant_plan: + - declared_grant: string + normalized: + action: string | null + resource: string | null + conditions: object | null + observed_use: + count: number + actions: [string] + resources: [string] + receipt_refs: [string] + classification: keep | reduce | revoke | needs_human_review + proposed_grant: string | null + rationale: string + operational_risk: string +planned_grant_set: [string] +revoked_grants: [string] +reduced_grants: + - from: string + to: string +kept_grants: [string] +deferred_grants: [string] +residual_risk: [string] +reviewer_action: apply_now | needs_policy_decision | gather_more_receipts | none +receipt_expectations: + classification_counts: object + stop_status: string + unresolved_questions: [string] +``` + +## Worked example + +Input: + +```yaml +subject: ops/deploy-pipeline +grants: + - cluster:deploy:production + - cluster:deploy:staging + - secrets:read:* + - secrets:write:* + - logs:read:* +run_history: + receipt_ids: [rx_201, rx_202, rx_203] + observed: + - grant: cluster:deploy:staging + count: 12 + refs: [rx_201:step_2, rx_202:step_2, rx_203:step_2] + - grant: secrets:read:deploy-keys + count: 12 + refs: [rx_201:step_1, rx_202:step_1, rx_203:step_1] + - grant: logs:read:deploy-* + count: 8 + refs: [rx_201:step_4, rx_203:step_4] +``` + +Output: + +```yaml +status: plan_proposed +subject: ops/deploy-pipeline +grant_plan: + - declared_grant: cluster:deploy:production + classification: revoke + rationale: No receipt exercised production deploy authority across 3 observed runs. + operational_risk: "Low — a future production deploy would re-request the grant." + - declared_grant: cluster:deploy:staging + classification: keep + rationale: All 12 observed deploys targeted staging; grant is exercised as declared. + operational_risk: None. + - declared_grant: "secrets:read:*" + classification: reduce + proposed_grant: secrets:read:deploy-keys + rationale: All 12 observed reads targeted deploy-keys only; wildcard is over-broad. + operational_risk: "Low — a new secret path would re-request the broader grant." + - declared_grant: "secrets:write:*" + classification: revoke + rationale: No receipt exercised secrets write authority. + operational_risk: "Low — removal cannot break observed behavior." + - declared_grant: "logs:read:*" + classification: reduce + proposed_grant: "logs:read:deploy-*" + rationale: All 8 observed reads matched the deploy-* prefix. + operational_risk: "Low — reading other log namespaces would re-request the broader grant." +planned_grant_set: + - cluster:deploy:staging + - secrets:read:deploy-keys + - "logs:read:deploy-*" +revoked_grants: + - cluster:deploy:production + - "secrets:write:*" +reduced_grants: + - from: "secrets:read:*" + to: secrets:read:deploy-keys + - from: "logs:read:*" + to: "logs:read:deploy-*" +residual_risk: + - The pipeline can still deploy to staging and read deploy-key secrets. +reviewer_action: apply_now +``` + +## Inputs + +- `subject` (optional): skill id, grant id, principal, or other label for what + is being planned. +- `grants` (required): the current or proposed grants to evaluate, + preferably in canonical policy syntax. +- `run_history` (required): receipt-derived usage history. Include receipt ids, + step refs, observed actions, resources, success or denial status, and the + time window when available. +- `policy_constraints` (optional): reserved grants, compliance constraints, or + human-approved exceptions that affect revocation decisions. +- `objective` (optional): operator intent that focuses the plan, such as + "prepare for production hardening" or "post-incident least-privilege review". diff --git a/skills/least-privilege-plan/X.yaml b/skills/least-privilege-plan/X.yaml new file mode 100644 index 00000000..d7ef0f2f --- /dev/null +++ b/skills/least-privilege-plan/X.yaml @@ -0,0 +1,45 @@ +skill: least-privilege-plan +version: "0.1.0" +catalog: + kind: skill + audience: operator + visibility: public + role: canonical +runners: + plan: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + grant_plan: object + plan_summary: array + verdict: string + artifacts: + wrap_as: least_privilege_plan_packet + packet: runx.security.least_privilege_plan.v1 + inputs: + subject: + type: string + required: false + description: "Label for what is being planned: a skill id, grant id, or principal." + grants: + type: json + required: true + description: The grants to evaluate against observed usage. + run_history: + type: json + required: true + description: "Receipt-derived run history: for each grant or resource, the actions actually exercised." + policy_constraints: + type: json + required: false + description: Reserved grants, compliance constraints, or human-approved exceptions. + objective: + type: string + required: false + description: Operator intent that focuses the plan. +sandbox: + profile: readonly + cwd_policy: skill-directory diff --git a/skills/least-privilege-plan/fixtures/minimal-grants-unchanged.yaml b/skills/least-privilege-plan/fixtures/minimal-grants-unchanged.yaml new file mode 100644 index 00000000..a82687f9 --- /dev/null +++ b/skills/least-privilege-plan/fixtures/minimal-grants-unchanged.yaml @@ -0,0 +1,51 @@ +name: minimal-grants-unchanged +kind: skill +target: .. +runner: plan +inputs: + subject: analytics/daily-report + grants: + - reports:read:daily + - reports:write:daily + run_history: + receipt_ids: + - rx_301 + - rx_302 + observed: + - grant: reports:read:daily + count: 14 + refs: + - rx_301:step_1 + - rx_302:step_1 + - grant: reports:write:daily + count: 7 + refs: + - rx_301:step_3 + - rx_302:step_3 + objective: Verify the report generator grant is already minimal. +caller: + answers: + agent_task.least-privilege-plan.output: + grant_plan: + subject: analytics/daily-report + grant_plan: + - declared_grant: reports:read:daily + classification: keep + rationale: Observed receipt usage exercised this exact authority. + operational_risk: None. + - declared_grant: reports:write:daily + classification: keep + rationale: Observed receipt usage exercised this exact authority. + operational_risk: None. + plan_summary: [] + verdict: "no_change: observed usage matches the declared grants" +expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed +metadata: + public_skill: least-privilege-plan + source_case: minimal-grants-unchanged + source: skills-fixture diff --git a/skills/least-privilege-plan/fixtures/over-granted-plan-reduces.yaml b/skills/least-privilege-plan/fixtures/over-granted-plan-reduces.yaml new file mode 100644 index 00000000..bea52744 --- /dev/null +++ b/skills/least-privilege-plan/fixtures/over-granted-plan-reduces.yaml @@ -0,0 +1,90 @@ +name: over-granted-plan-reduces +kind: skill +target: .. +runner: plan +inputs: + subject: ops/deploy-pipeline + grants: + - cluster:deploy:production + - cluster:deploy:staging + - "secrets:read:*" + - "secrets:write:*" + - "logs:read:*" + run_history: + receipt_ids: + - rx_201 + - rx_202 + - rx_203 + observed: + - grant: cluster:deploy:staging + count: 12 + refs: + - rx_201:step_2 + - rx_202:step_2 + - rx_203:step_2 + - grant: secrets:read:deploy-keys + count: 12 + refs: + - rx_201:step_1 + - rx_202:step_1 + - rx_203:step_1 + - grant: "logs:read:deploy-logs" + count: 8 + refs: + - rx_201:step_4 + - rx_203:step_4 + objective: Tighten the deploy pipeline to the narrowest grant set before production hardening. +caller: + answers: + agent_task.least-privilege-plan.output: + grant_plan: + subject: ops/deploy-pipeline + grant_plan: + - declared_grant: cluster:deploy:production + classification: revoke + rationale: No receipt exercised production deploy authority across 3 observed runs. + operational_risk: "Low — a future production deploy would re-request the grant." + - declared_grant: cluster:deploy:staging + classification: keep + rationale: All 12 observed deploys targeted staging; grant is exercised as declared. + operational_risk: None. + - declared_grant: "secrets:read:*" + classification: reduce + proposed_grant: secrets:read:deploy-keys + rationale: All 12 observed reads targeted deploy-keys only; wildcard is over-broad. + operational_risk: "Low — a new secret path would re-request the broader grant." + - declared_grant: "secrets:write:*" + classification: revoke + rationale: No receipt exercised secrets write authority. + operational_risk: "Low — removal cannot break observed behavior." + - declared_grant: "logs:read:*" + classification: reduce + proposed_grant: "logs:read:deploy-logs" + rationale: All 8 observed reads matched the deploy-logs path. + operational_risk: "Low — reading other log namespaces would re-request the broader grant." + plan_summary: + - action: revoke + grant: cluster:deploy:production + rationale: No cited receipt exercised this authority. + - action: revoke + grant: "secrets:write:*" + rationale: No cited receipt exercised this authority. + - action: reduce + from: "secrets:read:*" + to: secrets:read:deploy-keys + rationale: Observed use fits the narrower grant. + - action: reduce + from: "logs:read:*" + to: "logs:read:deploy-logs" + rationale: Observed use fits the narrower grant. + verdict: "over-privileged: revoke 2, reduce 2" +expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed +metadata: + public_skill: least-privilege-plan + source_case: over-granted-plan-reduces + source: skills-fixture diff --git a/skills/least-privilege-plan/run.mjs b/skills/least-privilege-plan/run.mjs new file mode 100644 index 00000000..e7ffd160 --- /dev/null +++ b/skills/least-privilege-plan/run.mjs @@ -0,0 +1,312 @@ +import fs from "node:fs"; + +const inputs = readInputs(); +const subject = stringValue(inputs.subject) || "unknown"; +const grants = stringArray(inputs.grants, "grants"); +const runHistory = readRunHistory(inputs.run_history); +const policyConstraints = readPolicyConstraints(inputs.policy_constraints); +const observed = collectObservedUsage(runHistory); + +const grantPlan = grants.map((grant) => + classifyGrant(grant, observed, policyConstraints) +); +const revokedGrants = grantPlan + .filter((entry) => entry.classification === "revoke") + .map((entry) => entry.declared_grant); +const reducedGrants = grantPlan + .filter((entry) => entry.classification === "reduce" && entry.proposed_grant) + .map((entry) => ({ from: entry.declared_grant, to: entry.proposed_grant })); +const keptGrants = grantPlan + .filter((entry) => entry.classification === "keep") + .map((entry) => entry.declared_grant); +const deferredGrants = grantPlan + .filter((entry) => entry.classification === "needs_human_review") + .map((entry) => entry.declared_grant); +const plannedGrantSet = [ + ...keptGrants, + ...reducedGrants.map((entry) => entry.to), + ...deferredGrants, +]; + +const limitations = []; +if (observed.size === 0) { + limitations.push( + "No observed grant usage was provided; the plan cannot safely narrow grants." + ); +} + +const status = + observed.size === 0 + ? "needs_more_evidence" + : revokedGrants.length > 0 || reducedGrants.length > 0 + ? "plan_proposed" + : "no_change"; + +const packet = { + status, + subject, + evidence: { + receipt_ids: Array.isArray(runHistory.receipt_ids) + ? runHistory.receipt_ids.map(String) + : [], + receipt_window: stringValue(runHistory.receipt_window) || null, + grant_source: stringValue(inputs.grant_source) || null, + limitations, + }, + grant_plan: grantPlan, + planned_grant_set: plannedGrantSet, + revoked_grants: revokedGrants, + reduced_grants: reducedGrants, + kept_grants: keptGrants, + deferred_grants: deferredGrants, + residual_risk: residualRisk({ keptGrants, deferredGrants, limitations }), + reviewer_action: + status === "plan_proposed" + ? "apply_now" + : status === "needs_more_evidence" + ? "gather_more_receipts" + : "none", + receipt_expectations: { + classification_counts: countClassifications(grantPlan), + stop_status: status, + unresolved_questions: limitations, + }, +}; + +const result = { + grant_plan: packet, + plan_summary: [ + ...revokedGrants.map((grant) => ({ + action: "revoke", + grant, + rationale: "No cited receipt exercised this authority.", + })), + ...reducedGrants.map((entry) => ({ + action: "reduce", + from: entry.from, + to: entry.to, + rationale: "Observed use fits the narrower grant.", + })), + ], + verdict: renderVerdict(packet), +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +// --- helpers --- + +function readInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; + return JSON.parse(raw); +} + +function readRunHistory(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error( + "run_history must be an object with receipt_ids and observed usage" + ); + } + return value; +} + +function readPolicyConstraints(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { reserved_grants: [] }; + } + return { + reserved_grants: Array.isArray(value.reserved_grants) + ? value.reserved_grants.map(String) + : [], + }; +} + +function stringArray(value, field) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${field} must be a non-empty array`); + } + return value.map((entry) => { + if (typeof entry !== "string" || entry.trim().length === 0) { + throw new Error(`${field} entries must be non-empty strings`); + } + return entry.trim(); + }); +} + +function collectObservedUsage(history) { + const observed = new Map(); + const entries = Array.isArray(history.observed) ? history.observed : []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const grant = stringValue(entry.grant); + if (!grant) continue; + const current = observed.get(grant) || { count: 0, refs: [] }; + current.count += Number.isFinite(entry.count) + ? Math.max(0, Math.trunc(entry.count)) + : 1; + if (Array.isArray(entry.refs)) + current.refs.push(...entry.refs.map(String)); + observed.set(grant, current); + } + return observed; +} + +function classifyGrant(grant, observed, policyConstraints) { + const normalized = normalizeGrant(grant); + + // Check if this is a policy-reserved grant + if (policyConstraints.reserved_grants.includes(grant)) { + return planEntry({ + grant, + normalized, + observedUse: observed.get(grant) || { count: 0, refs: [] }, + classification: "needs_human_review", + proposedGrant: null, + rationale: + "Grant is marked as policy-reserved; human review required before any change.", + operationalRisk: + "Removing a policy-reserved grant may violate compliance requirements.", + }); + } + + // Exact match — grant is exercised as declared + const exact = observed.get(grant); + if (exact && exact.count > 0) { + return planEntry({ + grant, + normalized, + observedUse: { count: exact.count, receipt_refs: exact.refs }, + classification: "keep", + proposedGrant: null, + rationale: "Observed receipt usage exercised this exact authority.", + operationalRisk: "None.", + }); + } + + // Check for narrower observed usage under a wildcard grant + const narrower = observedNarrowerGrant(grant, observed); + if (narrower) { + const proposed = + commonGrantPrefix(narrower.grants) || narrower.grants[0]; + return planEntry({ + grant, + normalized, + observedUse: { + count: narrower.count, + receipt_refs: narrower.refs, + grants: narrower.grants, + }, + classification: "reduce", + proposedGrant: proposed, + rationale: + "Observed usage fits a narrower grant than the declared wildcard.", + operationalRisk: + "Low — a future use outside the narrowed scope would re-request the broader grant.", + }); + } + + // No observed usage at all + return planEntry({ + grant, + normalized, + observedUse: { count: 0, receipt_refs: [] }, + classification: "revoke", + proposedGrant: null, + rationale: "No cited receipt exercised this authority.", + operationalRisk: "Low — removal cannot break observed behavior.", + }); +} + +function observedNarrowerGrant(grant, observed) { + if (!grant.endsWith("*")) return null; + const prefix = grant.slice(0, -1); + const matches = [...observed.entries()].filter(([used]) => + used.startsWith(prefix) + ); + if (matches.length === 0) return null; + return { + grants: matches.map(([used]) => used), + count: matches.reduce((sum, [, usage]) => sum + usage.count, 0), + refs: matches.flatMap(([, usage]) => usage.refs), + }; +} + +function normalizeGrant(grant) { + const [actionPart, ...resourceParts] = grant.split(":"); + const resource = resourceParts.join(":") || null; + return { + action: actionPart || null, + resource, + conditions: null, + }; +} + +function planEntry({ + grant, + normalized, + observedUse, + classification, + proposedGrant, + rationale, + operationalRisk, +}) { + return { + declared_grant: grant, + normalized, + observed_use: { + count: observedUse.count, + actions: normalized.action ? [normalized.action] : [], + resources: normalized.resource ? [normalized.resource] : [], + receipt_refs: observedUse.receipt_refs || [], + }, + classification, + proposed_grant: proposedGrant, + rationale, + operational_risk: operationalRisk, + }; +} + +function commonGrantPrefix(grants) { + if (grants.length !== 1) return null; + return grants[0]; +} + +function countClassifications(entries) { + return entries.reduce((counts, entry) => { + counts[entry.classification] = (counts[entry.classification] || 0) + 1; + return counts; + }, {}); +} + +function residualRisk({ keptGrants, deferredGrants, limitations }) { + const risks = []; + if (keptGrants.length > 0) { + risks.push( + `The subject still retains ${keptGrants.length} observed grant(s).` + ); + } + if (deferredGrants.length > 0) { + risks.push( + `The subject has ${deferredGrants.length} grant(s) requiring human review.` + ); + } + risks.push(...limitations); + return risks; +} + +function renderVerdict(packet) { + if (packet.status === "plan_proposed") { + return `over-privileged: revoke ${packet.revoked_grants.length}, reduce ${packet.reduced_grants.length}`; + } + if (packet.status === "needs_more_evidence") { + return "needs_more_evidence: no exercised grants were provided"; + } + return "no_change: observed usage matches the declared grants"; +} + +function stringValue(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +}