Skill: bharathjanumpally/claw-permission-firewall (version 1.0.0)

# Claw Permission Firewall Runtime least-privilege firewall for agent/skill actions. It evaluates a requested action and returns one of: - **ALLOW** (safe to execute) - **DENY** (blocked by policy) - **NEED_CONFIRMATION** (risky; require explicit confirmation) It also returns a **sanitizedAction** with secrets redacted, plus a structured **audit** record. > This is not a gateway hardening tool. It complements gateway security scanners by enforcing per-action policy at runtime. --- ## What it protects against - Exfiltration to unknown domains - Prompt-injection “send secrets” attempts (secret detection + redaction) - Reading sensitive local files (`~/.ssh`, `~/.aws`, `.env`, etc.) - Unsafe execution patterns (`rm -rf`, `curl | sh`, etc.) --- ## Inputs Provide an action object to evaluate: ```json { "traceId": "optional-uuid", "caller": { "skillName": "SomeSkill", "skillVersion": "1.2.0" }, "action": { "type": "http_request | file_read | file_write | exec", "method": "GET|POST|PUT|DELETE", "url": "https://api.github.com/...", "headers": { "authorization": "Bearer ..." }, "body": "...", "path": "./reports/out.json", "command": "rm -rf /" }, "context": { "workspaceRoot": "/workspace", "mode": "strict | balanced | permissive", "confirmed": false } } ``` --- ## Outputs ```json { "decision": "ALLOW | DENY | NEED_CONFIRMATION", "riskScore": 0.42, "reasons": [{"ruleId":"...","message":"..."}], "sanitizedAction": { "...": "..." }, "confirmation": { "required": true, "prompt": "..." }, "audit": { "traceId":"...", "policyVersion":"...", "actionFingerprint":"..." } } ``` --- ## Default policy behavior (v1) - **Exec disabled** by default - HTTP requires **TLS** - Denylist blocks common exfil hosts (pastebins, raw script hosts) - File access is jailed to **workspaceRoot** - Always redacts `Authorization`, `Cookie`, `X-API-Key`, and common token patterns --- ## Recommended usage pattern 1) Your skill creates an action object. 2) Call this skill to evaluate it. 3) If **ALLOW** → execute sanitizedAction. 4) If **NEED_CONFIRMATION** → ask user and re-run with `context.confirmed=true`. 5) If **DENY** → stop and show the reasons. --- ## Files - `policy.yaml` contains the policy (edit for your environment).

path: policy.yaml
content: version: "2026-02-04.1"

modeDefaults:
  strict:
    requireConfirmationAboveRisk: 0.25
  balanced:
    requireConfirmationAboveRisk: 0.50
  permissive:
    requireConfirmationAboveRisk: 0.75

http:
  allowDomains:
    - "api.github.com"
    - "slack.com"
  denyDomains:
    - "pastebin.com"
    - "raw.githubusercontent.com"
  allowMethods: ["GET", "POST"]
  requireTLS: true
  blockQueryKeys: ["token", "apikey", "key", "secret", "access_token"]

files:
  workspaceRootOnly: true
  allowReadGlobs: ["**/*.md", "**/*.txt", "**/*.json"]
  allowWriteGlobs: ["**/*.md", "**/*.json", "reports/**"]
  denyPathGlobs:
    - "**/.env"
    - "**/*id_rsa*"
    - "**/*credentials*"
    - "**/*secret*"
  denyPathPrefixes:
    - "/etc"
    - "/var"
    - "~/.ssh"
    - "~/.aws"
    - "~/.config"

exec:
  enabled: false
  denyPatterns:
    - "rm -rf"
    - "curl | sh"
    - "chmod +x"
    - "powershell -enc"
    - "base64 -d"

secrets:
  redactHeaders: ["authorization", "cookie", "x-api-key"]
  redactRegex:
    - name: "AWS Access Key"
      pattern: "(A3T[A-Z0-9]|AKIA|ASIA)[A-Z0-9]{16}"
    - name: "JWT"
      pattern: "eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+"
  denyIfFoundInOutbound: true


path: CHANGELOG.md
content: # Changelog

## 1.0.0
- Initial release.
- Policy-driven ALLOW / DENY / NEED_CONFIRMATION decisions.
- Default strict mode, TLS required for HTTP.
- Workspace-root file jail + denylist for sensitive locations.
- Header + regex secret redaction.
- Outbound secret detection and blocking.
- Structured audit record per decision (traceId, policyVersion, fingerprint).


path: README.md
content: # claw-permission-firewall

A policy “gatekeeper” for skills/agents. Use it to enforce least-privilege rules for:
- HTTP requests (domain/method/TLS)
- File reads/writes (workspace jail + deny paths)
- Command execution (disabled by default)
- Secret redaction + outbound secret blocking
- Risk scoring and confirmations
- Audit logs per decision

## Install (developer)
```bash
npm i
npm run build
```

## Run (CLI)
```bash
node dist/index.js evaluate --input examples/http-allow.json
```

## Policy
Edit `policy.yaml` to allow domains, file globs, and thresholds per mode (strict/balanced/permissive).


path: package.json
content: {
  "name": "claw-permission-firewall",
  "version": "1.0.0",
  "description": "Runtime least-privilege firewall for agent skill actions (HTTP/files/exec) with redaction, risk scoring, and audit logs.",
  "license": "MIT",
  "type": "module",
  "bin": {
    "claw-permission-firewall": "dist/index.js"
  },
  "files": [
    "dist",
    "policy.yaml",
    "README.md",
    "SKILL.md",
    "CHANGELOG.md"
  ],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "dev": "node --loader ts-node/esm src/index.ts",
    "test:smoke": "node dist/index.js evaluate --input examples/http-allow.json",
    "prepack": "npm run build"
  },
  "dependencies": {
    "js-yaml": "^4.1.0",
    "minimatch": "^9.0.5"
  },
  "devDependencies": {
    "@types/node": "^22.10.7",
    "ts-node": "^10.9.2",
    "typescript": "^5.7.3"
  }
}

path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

path: src/match.ts
content: import minimatch from "minimatch";

export function matchGlob(filePath: string, pattern: string): boolean {
  return minimatch(filePath, pattern, { nocase: true, dot: true, matchBase: true });
}

export function matchDomain(host: string, list: string[]): boolean {
  const h = host.toLowerCase();
  for (const d of list) {
    const dd = d.toLowerCase();
    if (h === dd) return true;
    if (h.endsWith("." + dd)) return true;
  }
  return false;
}

export function hasDenyPattern(command: string, patterns: string[]): boolean {
  const c = command.toLowerCase();
  return patterns.some((p) => c.includes(p.toLowerCase()));
}


path: src/evaluate.ts
content: import crypto from "node:crypto";
import path from "node:path";
import { loadPolicy, type Policy } from "./policy.js";
import { normalizeAction, type NormalizedAction } from "./normalize.js";
import { matchDomain, matchGlob, hasDenyPattern } from "./match.js";
import { redactAction, detectSecretsInOutbound } from "./redact.js";
import { scoreRisk } from "./risk.js";
import { buildAudit } from "./audit.js";

export type Decision = "ALLOW" | "DENY" | "NEED_CONFIRMATION";

export type EvaluateInput = {
  traceId?: string;
  caller?: { skillName?: string; skillVersion?: string };
  action: any;
  context?: {
    workspaceRoot?: string;
    mode?: "strict" | "balanced" | "permissive";
    confirmed?: boolean;
    userIntent?: string;
  };
};

export type EvaluateOutput = {
  decision: Decision;
  riskScore: number;
  reasons: { ruleId: string; message: string }[];
  sanitizedAction: any;
  confirmation?: { required: boolean; prompt: string };
  audit: any;
};

function sha256(s: string) {
  return crypto.createHash("sha256").update(s).digest("hex");
}

function expandHome(p: string) {
  if (!p.startsWith("~")) return p;
  const home = process.env.HOME || "";
  return p.replace(/^~(?=$|\/|\\)/, home);
}

function finalize(
  decision: Decision,
  riskScore: number,
  sanitizedAction: any,
  reasons: { ruleId: string; message: string }[],
  input: EvaluateInput,
  policy: Policy,
  confirmation?: { required: boolean; prompt: string }
): EvaluateOutput {
  const traceId = input.traceId ?? crypto.randomUUID();
  const audit = buildAudit({
    traceId,
    caller: input.caller ?? {},
    policyVersion: policy.version,
    decision,
    riskScore,
    reasons,
    fingerprint: sha256(JSON.stringify({ action: sanitizedAction, policyVersion: policy.version }))
  });

  return { decision, riskScore, reasons, sanitizedAction, confirmation, audit };
}

export async function evaluate(input: EvaluateInput, policyPath = "policy.yaml"): Promise<EvaluateOutput> {
  const policy: Policy = await loadPolicy(policyPath);
  const mode = input.context?.mode ?? "strict";
  const threshold = policy.modeDefaults?.[mode]?.requireConfirmationAboveRisk ?? 0.5;
  const workspaceRoot = input.context?.workspaceRoot ?? process.cwd();

  const reasons: { ruleId: string; message: string }[] = [];
  const normalized: NormalizedAction = normalizeAction(input.action, workspaceRoot);

  // Always redact first (so even deny results don't leak).
  const sanitized = redactAction(normalized, policy, reasons);

  // HARD DENIES
  if (sanitized.type === "exec") {
    if (!policy.exec?.enabled) {
      reasons.push({ ruleId: "exec.disabled", message: "Command execution is disabled by policy." });
      return finalize("DENY", 1.0, sanitized, reasons, input, policy);
    }
    if (hasDenyPattern(sanitized.command ?? "", policy.exec?.denyPatterns ?? [])) {
      reasons.push({ ruleId: "exec.deny.pattern", message: "Command matched a denied pattern." });
      return finalize("DENY", 1.0, sanitized, reasons, input, policy);
    }
  }

  if (sanitized.type === "file_read" || sanitized.type === "file_write") {
    const p = sanitized.path ?? "";
    if (policy.files?.workspaceRootOnly) {
      const root = path.resolve(workspaceRoot) + path.sep;
      if (!p.startsWith(root)) {
        reasons.push({ ruleId: "files.outside.workspace", message: "File path is outside workspaceRoot." });
        return finalize("DENY", 0.95, sanitized, reasons, input, policy);
      }
    }
    for (const pref of policy.files?.denyPathPrefixes ?? []) {
      const expanded = expandHome(pref);
      if (p.startsWith(expanded)) {
        reasons.push({ ruleId: "files.deny.prefix", message: `Path blocked by deny prefix: ${pref}` });
        return finalize("DENY", 0.95, sanitized, reasons, input, policy);
      }
    }
    for (const g of policy.files?.denyPathGlobs ?? []) {
      if (matchGlob(p, g)) {
        reasons.push({ ruleId: "files.deny.glob", message: `Path blocked by deny glob: ${g}` });
        return finalize("DENY", 0.95, sanitized, reasons, input, policy);
      }
    }
  }

  if (sanitized.type === "http_request") {
    if (policy.http?.requireTLS && sanitized.url?.startsWith("http://")) {
      reasons.push({ ruleId: "http.require.tls", message: "Non-TLS HTTP is denied." });
      return finalize("DENY", 0.9, sanitized, reasons, input, policy);
    }

    const host = sanitized.host ?? "";
    if (matchDomain(host, policy.http?.denyDomains ?? [])) {
      reasons.push({ ruleId: "http.deny.domain", message: `Domain is denylisted: ${host}` });
      return finalize("DENY", 0.9, sanitized, reasons, input, policy);
    }

    // Method allowlist (soft deny -> risk)
    const method = (sanitized.method ?? "GET").toUpperCase();
    if (policy.http?.allowMethods && !policy.http.allowMethods.includes(method)) {
      reasons.push({ ruleId: "http.method.not.allowlisted", message: `HTTP method not allowlisted: ${method}` });
    }
  }

  // Risk scoring (non-hard blocks)
  const risk = scoreRisk(sanitized, policy, workspaceRoot, reasons);

  // Outbound secret detection
  if (policy.secrets?.denyIfFoundInOutbound && detectSecretsInOutbound(sanitized, policy)) {
    reasons.push({ ruleId: "secrets.outbound.detected", message: "Outbound payload contains secret-like material." });
    return finalize("DENY", Math.max(risk, 0.85), sanitized, reasons, input, policy);
  }

  const confirmed = Boolean(input.context?.confirmed);
  if (risk >= threshold && !confirmed) {
    return finalize(
      "NEED_CONFIRMATION",
      risk,
      sanitized,
      reasons,
      input,
      policy,
      { required: true, prompt: `This action is risky (riskScore=${risk.toFixed(2)}). Confirm to proceed.` }
    );
  }

  return finalize("ALLOW", risk, sanitized, reasons, input, policy);
}


path: src/risk.ts
content: import path from "node:path";
import { matchDomain, matchGlob } from "./match.js";
import type { Policy } from "./policy.js";

export function scoreRisk(
  action: any,
  policy: Policy,
  workspaceRoot: string,
  reasons: { ruleId: string; message: string }[]
): number {
  let risk = 0.0;

  if (action.type === "http_request") {
    const host = String(action.host ?? "").toLowerCase();
    const allow = policy.http?.allowDomains ?? [];
    const deny = policy.http?.denyDomains ?? [];

    if (!matchDomain(host, allow)) {
      risk += 0.4;
      reasons.push({ ruleId: "http.not.allowlisted", message: `Domain not in allowlist: ${host}` });
    }
    if (matchDomain(host, deny)) {
      risk += 0.6; // should be hard denied earlier
    }

    const url = String(action.url ?? "");
    const blockKeys = (policy.http?.blockQueryKeys ?? []).map((k) => k.toLowerCase());
    for (const k of blockKeys) {
      if (url.toLowerCase().includes(`${k}=`)) {
        risk += 0.3;
        reasons.push({ ruleId: "http.query.sensitive", message: `Sensitive query key detected: ${k}` });
      }
    }

    const method = String(action.method ?? "GET").toUpperCase();
    if (method === "DELETE") risk += 0.35;
    if (method === "PUT" || method === "PATCH") risk += 0.2;
  }

  if (action.type === "file_read" || action.type === "file_write") {
    const p = String(action.path ?? "");
    const root = path.resolve(workspaceRoot) + path.sep;
    if (!p.startsWith(root)) risk += 0.8;

    const isWrite = action.type === "file_write";
    const allowGlobs = isWrite ? policy.files?.allowWriteGlobs ?? [] : policy.files?.allowReadGlobs ?? [];
    if (allowGlobs.length && !allowGlobs.some((g) => matchGlob(p, g))) {
      risk += 0.35;
      reasons.push({
        ruleId: "files.not.allowlisted",
        message: `Path not allowlisted for ${action.type}: ${p}`
      });
    }

    if (isWrite) risk += 0.15;
  }

  if (action.type === "exec") {
    risk += 0.9;
    reasons.push({ ruleId: "exec.high.risk", message: "Execution is inherently high risk." });
  }

  // clamp 0..1
  if (risk < 0) risk = 0;
  if (risk > 1) risk = 1;
  return risk;
}


path: src/redact.ts
content: import type { Policy } from "./policy.js";

export function redactAction(action: any, policy: Policy, reasons: { ruleId: string; message: string }[]) {
  const out = JSON.parse(JSON.stringify(action ?? {}));

  // redact headers
  const redactHeaders = (policy.secrets?.redactHeaders ?? []).map((h) => h.toLowerCase());
  if (out.type === "http_request" && out.headers) {
    for (const k of Object.keys(out.headers)) {
      if (redactHeaders.includes(k.toLowerCase())) {
        out.headers[k] = "[REDACTED]";
        reasons.push({ ruleId: "secrets.redact.header", message: `Redacted header: ${k}` });
      }
    }
  }

  // redact regex in body/content/command/url/etc
  const rules = policy.secrets?.redactRegex ?? [];
  const apply = (s: string) => {
    let changed = s;
    for (const r of rules) {
      const re = new RegExp(r.pattern, "g");
      if (re.test(changed)) {
        changed = changed.replace(re, "[REDACTED]");
        reasons.push({ ruleId: "secrets.redact.regex", message: `Redacted pattern: ${r.name}` });
      }
    }
    return changed;
  };

  const walk = (v: any): any => {
    if (typeof v === "string") return apply(v);
    if (Array.isArray(v)) return v.map(walk);
    if (v && typeof v === "object") {
      for (const k of Object.keys(v)) v[k] = walk(v[k]);
      return v;
    }
    return v;
  };

  return walk(out);
}

export function detectSecretsInOutbound(action: any, policy: Policy): boolean {
  const rules = policy.secrets?.redactRegex ?? [];
  const s = JSON.stringify(action ?? {});
  for (const r of rules) {
    const re = new RegExp(r.pattern, "g");
    if (re.test(s)) return true;
  }
  return false;
}


path: src/policy.ts
content: import fs from "node:fs";
import yaml from "js-yaml";

export type Policy = {
  version: string;
  modeDefaults?: Record<string, { requireConfirmationAboveRisk: number }>;
  http?: {
    allowDomains?: string[];
    denyDomains?: string[];
    allowMethods?: string[];
    requireTLS?: boolean;
    blockQueryKeys?: string[];
  };
  files?: {
    workspaceRootOnly?: boolean;
    allowReadGlobs?: string[];
    allowWriteGlobs?: string[];
    denyPathGlobs?: string[];
    denyPathPrefixes?: string[];
  };
  exec?: {
    enabled?: boolean;
    denyPatterns?: string[];
  };
  secrets?: {
    redactHeaders?: string[];
    redactRegex?: { name: string; pattern: string }[];
    denyIfFoundInOutbound?: boolean;
  };
};

export async function loadPolicy(policyPath: string): Promise<Policy> {
  const text = fs.readFileSync(policyPath, "utf8");
  const obj = yaml.load(text) as Policy;
  if (!obj?.version) throw new Error("policy.yaml missing 'version'");
  return obj;
}


path: src/normalize.ts
content: import path from "node:path";
import { URL } from "node:url";

export type NormalizedAction =
  | { type: "http_request"; method: string; url: string; host: string; headers: Record<string, string>; body?: any }
  | { type: "file_read" | "file_write"; path: string; content?: any }
  | { type: "exec"; command: string }
  | { type: string; [k: string]: any };

function normalizeHeaders(h: any): Record<string, string> {
  const out: Record<string, string> = {};
  if (!h || typeof h !== "object") return out;
  for (const [k, v] of Object.entries(h)) {
    out[String(k).toLowerCase()] = typeof v === "string" ? v : JSON.stringify(v);
  }
  return out;
}

export function normalizeAction(action: any, workspaceRoot: string): NormalizedAction {
  const type = action?.type;

  if (type === "http_request") {
    const method = String(action.method ?? "GET").toUpperCase();
    const url = String(action.url ?? "");
    const u = new URL(url);
    return {
      type,
      method,
      url,
      host: u.hostname.toLowerCase(),
      headers: normalizeHeaders(action.headers),
      body: action.body
    };
  }

  if (type === "file_read" || type === "file_write") {
    const p = String(action.path ?? "");
    const resolved = path.resolve(workspaceRoot, p);
    return { type, path: resolved, content: action.content };
  }

  if (type === "exec") {
    return { type, command: String(action.command ?? "") };
  }

  return { type: String(type ?? "unknown"), ...action };
}


path: src/index.ts
content: #!/usr/bin/env node
import fs from "node:fs";
import { evaluate } from "./evaluate.js";

function usage() {
  console.error(
    `Usage:
  claw-permission-firewall evaluate --input action.json [--policy policy.yaml]

Exit codes:
  0: ALLOW or NEED_CONFIRMATION
  1: DENY
  2: usage error`
  );
}

async function main() {
  const args = process.argv.slice(2);
  const cmd = args[0];

  if (cmd !== "evaluate") {
    usage();
    process.exit(2);
  }

  const inputIdx = args.indexOf("--input");
  const policyIdx = args.indexOf("--policy");

  const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
  const policyPath = policyIdx !== -1 ? args[policyIdx + 1] : "policy.yaml";

  if (!inputPath) {
    console.error("Missing --input");
    usage();
    process.exit(2);
  }

  const raw = fs.readFileSync(inputPath, "utf8");
  const input = JSON.parse(raw);

  const out = await evaluate(input, policyPath);
  process.stdout.write(JSON.stringify(out, null, 2) + "\n");

  if (out.decision === "DENY") process.exit(1);
}
main().catch((e) => {
  console.error(e);
  process.exit(1);
});


path: src/audit.ts
content: export function buildAudit(input: {
  traceId: string;
  caller: any;
  policyVersion: string;
  decision: string;
  riskScore: number;
  reasons: any[];
  fingerprint: string;
}) {
  return {
    traceId: input.traceId,
    timestamp: new Date().toISOString(),
    caller: input.caller,
    policyVersion: input.policyVersion,
    decision: input.decision,
    riskScore: input.riskScore,
    reasons: input.reasons,
    actionFingerprint: input.fingerprint
  };
}


path: examples/http-deny-domain.json
content: {
  "action": {
    "type": "http_request",
    "method": "GET",
    "url": "https://raw.githubusercontent.com/some/repo/main/install.sh",
    "headers": {
      "x-api-key": "[REDACTED_SECRET]"
    }
  },
  "context": {
    "workspaceRoot": "/workspace",
    "mode": "strict"
  }
}

path: examples/file-deny-sensitive.json
content: {
  "action": {
    "type": "file_read",
    "path": "~/.ssh/id_rsa"
  },
  "context": {
    "workspaceRoot": "/workspace",
    "mode": "strict"
  }
}

path: examples/http-allow.json
content: {
  "traceId": "11111111-1111-1111-1111-111111111111",
  "caller": {
    "skillName": "DemoSkill",
    "skillVersion": "0.1.0"
  },
  "action": {
    "type": "http_request",
    "method": "GET",
    "url": "https://api.github.com/repos/octocat/Hello-World",
    "headers": {
      "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc.def"
    }
  },
  "context": {
    "workspaceRoot": "/workspace",
    "mode": "strict",
    "confirmed": false
  }
}

path: examples/exec-deny.json
content: {
  "action": {
    "type": "exec",
    "command": "rm -rf /"
  },
  "context": {
    "workspaceRoot": "/workspace",
    "mode": "strict"
  }
}

Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)