{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../../src/core/safety/policy.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AA4DhF,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,cAAc,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,mBAAmB;IACnC,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,KAAK,OAAO,CAAC;IACvE,aAAa,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACjD;AA4CD,eAAO,MAAM,cAAc,EAAE,UAAU,EAkGtC,CAAC;AAEF,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsB;IAE9C,YAAY,KAAK,GAAE,UAAU,EAAmB,EAAE,OAAO,GAAE,mBAAwB,EAGlF;IAED,uEAAuE;IACvE,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,gBAAgB,CAoB7C;IAED,6EAA6E;IAC7E,OAAO,CAAC,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,gBAAgB,CAa3D;CACD;AAED,+DAA+D;AAC/D,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKxD","sourcesContent":["import path from \"node:path\";\nimport type { ToolEffectScope, ToolEffects } from \"@apholdings/jensen-agent-core\";\nimport type { PolicyDecision, PolicyEvaluation, PolicyInput } from \"./types.js\";\n\n/**\n * Deterministic policy engine.\n *\n * Precedence (highest to lowest): deny > require_approval > allow > default.\n * A deny rule can never be overridden by a lower-priority rule, model output,\n * web content, tool results, workspace instructions, or a provider hint. The\n * engine never reads model prose or tool output: policy truth comes only from\n * the structured PolicyInput.\n */\n\nfunction precedence(outcome: PolicyDecision[\"outcome\"]): number {\n\tswitch (outcome) {\n\t\tcase \"deny\":\n\t\t\treturn 4;\n\t\tcase \"require_approval\":\n\t\t\treturn 3;\n\t\tcase \"allow\":\n\t\t\treturn 2;\n\t\tdefault:\n\t\t\treturn 1;\n\t}\n}\n\nfunction isMutating(effects: ToolEffects): boolean {\n\treturn (\n\t\teffects.writesWorkspace ||\n\t\teffects.deletesFiles ||\n\t\teffects.mutatesGit ||\n\t\teffects.mutatesExternalState ||\n\t\teffects.executesProcesses ||\n\t\teffects.startsPersistentProcesses\n\t);\n}\n\nconst PROTECTED_BRANCHES = new Set([\"main\", \"master\"]);\nconst SECRET_PATH_PATTERNS = [\n\t/\\.pem$/i,\n\t/\\.key$/i,\n\t/\\.p12$/i,\n\t/\\.pfx$/i,\n\t/\\.keystore$/i,\n\t/(^|[\\\\/])id_rsa([^\\\\/]*)?$/,\n\t/(^|[\\\\/])id_ed25519([^\\\\/]*)?$/,\n\t/(^|[\\\\/])id_ecdsa([^\\\\/]*)?$/,\n\t/(^|[\\\\/])\\.credentials$/i,\n\t/(^|[\\\\/])credentials\\.json$/i,\n\t/(^|[\\\\/])\\.env$/,\n];\n\nconst POLICY_BYPASS_MARKERS = [\n\t\"--no-verify\",\n\t\"--no-commit-verify\",\n\t\"JENSEN_SKIP_POLICY\",\n\t\"JENSEN_ALLOW_UNSAFE\",\n\t\"jensen-skip-policy\",\n\t\"--no-policy\",\n];\n\nexport function isSecretPath(p: string): boolean {\n\treturn SECRET_PATH_PATTERNS.some((re) => re.test(p));\n}\n\nexport interface PolicyRule {\n\tid: string;\n\tdescription: string;\n\t/** Return a decision if this rule applies, otherwise null. */\n\tevaluate(input: PolicyInput): PolicyDecision | null;\n}\n\nexport interface PolicyEngineOptions {\n\tallowApprovalResolver?: (scope: string, input: PolicyInput) => boolean;\n\truleOverrides?: Partial<Record<string, boolean>>;\n}\n\n/**\n * Default (lowest precedence) decision derived from execution mode.\n * The model cannot promote itself between modes: mode is provided by the\n * caller from durable state.\n */\nfunction defaultDecision(input: PolicyInput): PolicyDecision {\n\tconst mutates = isMutating(input.effects);\n\tif (input.executionMode === \"observe\") {\n\t\tif (mutates || input.effects.accessesNetwork) {\n\t\t\treturn {\n\t\t\t\toutcome: \"deny\",\n\t\t\t\truleId: \"mode.observe\",\n\t\t\t\treasonCode: \"observe_mode_blocks_mutation\",\n\t\t\t};\n\t\t}\n\t\treturn { outcome: \"allow\", ruleId: \"mode.observe\", reasonCode: \"observe_read_allowed\" };\n\t}\n\tif (input.executionMode === \"plan\") {\n\t\tif (mutates) {\n\t\t\treturn {\n\t\t\t\toutcome: \"require_approval\",\n\t\t\t\truleId: \"mode.plan\",\n\t\t\t\treasonCode: \"plan_mode_blocks_mutation\",\n\t\t\t\tapprovalScope: \"plan_execute_override\",\n\t\t\t};\n\t\t}\n\t\treturn { outcome: \"allow\", ruleId: \"mode.plan\", reasonCode: \"plan_read_allowed\" };\n\t}\n\t// execute\n\tif (!mutates) {\n\t\treturn { outcome: \"allow\", ruleId: \"mode.execute\", reasonCode: \"execute_read_allowed\" };\n\t}\n\t// A mutating operation in execute mode is never auto-approved: it needs an\n\t// explicit allow rule or an explicit approval resolution.\n\treturn {\n\t\toutcome: \"require_approval\",\n\t\truleId: \"mode.execute\",\n\t\treasonCode: \"execute_requires_authorization\",\n\t\tapprovalScope: \"mutation\",\n\t};\n}\n\nexport const BASELINE_RULES: PolicyRule[] = [\n\t{\n\t\tid: \"deny.destructive_shell\",\n\t\tdescription: \"Deny destructive shell patterns against unrelated or external work.\",\n\t\tevaluate(input) {\n\t\t\tconst cmd = input.requestedCommand ?? \"\";\n\t\t\t// rm -rf on root\n\t\t\tif (/\\brm\\s+(-{1,2}[rifd]*\\s+)*[/~](\\s|$)/.test(cmd) && /-rf/.test(cmd)) {\n\t\t\t\treturn { outcome: \"deny\", ruleId: \"deny.destructive_shell\", reasonCode: \"rm_rf_root\" };\n\t\t\t}\n\t\t\t// git reset --hard\n\t\t\tif (/\\bgit\\s+reset\\s+--hard\\b/.test(cmd)) {\n\t\t\t\treturn { outcome: \"deny\", ruleId: \"deny.destructive_shell\", reasonCode: \"git_reset_hard\" };\n\t\t\t}\n\t\t\t// git clean -fd\n\t\t\tif (/\\bgit\\s+clean\\s+-[a-z]*f[a-z]*d\\b/.test(cmd) || /\\bgit\\s+clean\\s+-[a-z]*d[a-z]*f\\b/.test(cmd)) {\n\t\t\t\treturn {\n\t\t\t\t\toutcome: \"require_approval\",\n\t\t\t\t\truleId: \"deny.destructive_shell\",\n\t\t\t\t\treasonCode: \"git_clean_requires_scope\",\n\t\t\t\t\tapprovalScope: \"git_clean_scoped\",\n\t\t\t\t};\n\t\t\t}\n\t\t\t// git push --force to protected branch\n\t\t\tconst forcePush = /\\bgit\\s+push\\b[^&;]*\\s--force(-with-lease)?\\b/.test(cmd);\n\t\t\tconst protectedBranch = PROTECTED_BRANCHES.has(input.currentBranch ?? \"\");\n\t\t\tif (forcePush && protectedBranch) {\n\t\t\t\tif (input.releaseAuthorized) return null;\n\t\t\t\treturn {\n\t\t\t\t\toutcome: \"deny\",\n\t\t\t\t\truleId: \"deny.destructive_shell\",\n\t\t\t\t\treasonCode: \"force_push_protected_branch\",\n\t\t\t\t};\n\t\t\t}\n\t\t\t// Explicit policy-bypass markers in a command are denied outright.\n\t\t\tif (POLICY_BYPASS_MARKERS.some((m) => cmd.includes(m))) {\n\t\t\t\treturn {\n\t\t\t\t\toutcome: \"deny\",\n\t\t\t\t\truleId: \"deny.destructive_shell\",\n\t\t\t\t\treasonCode: \"policy_bypass_marker\",\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\t{\n\t\tid: \"deny.paths_outside_workspace\",\n\t\tdescription: \"Deny targets resolved outside the authorized workspace.\",\n\t\tevaluate(input) {\n\t\t\tif (!input.resolvedPaths?.length) return null;\n\t\t\tfor (const p of input.resolvedPaths) {\n\t\t\t\t// Marker produced by the boundary validator for escapes.\n\t\t\t\tif (p.includes(\"\\u0000\")) {\n\t\t\t\t\treturn { outcome: \"deny\", ruleId: \"deny.paths_outside_workspace\", reasonCode: \"escaped_path\" };\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\t{\n\t\tid: \"deny.secrets\",\n\t\tdescription: \"Deny reading or publishing private key material.\",\n\t\tevaluate(input) {\n\t\t\tconst targets = input.resolvedPaths ?? [input.requestedCommand ?? \"\"];\n\t\t\tconst secret = targets.some((t) => isSecretPath(t));\n\t\t\tif (secret) {\n\t\t\t\treturn { outcome: \"deny\", ruleId: \"deny.secrets\", reasonCode: \"secret_material\" };\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\t{\n\t\tid: \"allow.authorized_release\",\n\t\tdescription: \"Publication is allowed only under explicit release authorization.\",\n\t\tevaluate(input) {\n\t\t\tif (!input.releaseAuthorized) return null;\n\t\t\tif (input.releaseAuthorized && (input.effects.mutatesExternalState || input.effects.mutatesGit)) {\n\t\t\t\treturn { outcome: \"allow\", ruleId: \"allow.authorized_release\", reasonCode: \"release_authorized\" };\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\t{\n\t\tid: \"deny.unknown_effects\",\n\t\tdescription: \"Dynamic or unknown-effect tools fail conservative validation.\",\n\t\tevaluate(input) {\n\t\t\tconst hasUnknown = (input.effects.scopes ?? []).some((s: ToolEffectScope) => s.kind === \"unknown\");\n\t\t\tif (hasUnknown) {\n\t\t\t\treturn {\n\t\t\t\t\toutcome: \"require_approval\",\n\t\t\t\t\truleId: \"deny.unknown_effects\",\n\t\t\t\t\treasonCode: \"unknown_effects_conservative\",\n\t\t\t\t\tapprovalScope: \"unknown_effects\",\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n];\n\nexport class PolicyEngine {\n\tprivate readonly rules: PolicyRule[];\n\tprivate readonly options: PolicyEngineOptions;\n\n\tconstructor(rules: PolicyRule[] = BASELINE_RULES, options: PolicyEngineOptions = {}) {\n\t\tthis.rules = rules.filter((r) => options.ruleOverrides?.[r.id] !== false);\n\t\tthis.options = options;\n\t}\n\n\t/** Evaluate the policy for a single tool invocation. Deterministic. */\n\tevaluate(input: PolicyInput): PolicyEvaluation {\n\t\tlet best: PolicyDecision | null = null;\n\t\tfor (const rule of this.rules) {\n\t\t\tlet decision: PolicyDecision | null = null;\n\t\t\ttry {\n\t\t\t\tdecision = rule.evaluate(input);\n\t\t\t} catch {\n\t\t\t\t// A throwing rule is a policy failure: deny conservatively.\n\t\t\t\tdecision = { outcome: \"deny\", ruleId: rule.id, reasonCode: \"policy_rule_error\" };\n\t\t\t}\n\t\t\tif (!decision) continue;\n\t\t\tconst p = precedence(decision.outcome);\n\t\t\tif (!best || p > precedence(best.outcome)) {\n\t\t\t\tbest = decision;\n\t\t\t}\n\t\t}\n\t\tif (!best) {\n\t\t\tbest = defaultDecision(input);\n\t\t}\n\t\treturn { decision: best, key: input.workspaceId };\n\t}\n\n\t/** Structured allow when the caller has resolved an approval for a scope. */\n\tapprove(input: PolicyInput, scope: string): PolicyEvaluation {\n\t\tconst resolver = this.options.allowApprovalResolver;\n\t\tconst allowed = resolver ? resolver(scope, input) : true;\n\t\tif (!allowed) {\n\t\t\treturn {\n\t\t\t\tdecision: { outcome: \"deny\", ruleId: \"approval\", reasonCode: \"approval_rejected\" },\n\t\t\t\tkey: input.workspaceId,\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tdecision: { outcome: \"allow\", ruleId: \"approval\", reasonCode: \"approval_granted\" },\n\t\t\tkey: input.workspaceId,\n\t\t};\n\t}\n}\n\n/** Derive a workspace id from the canonical workspace root. */\nexport function workspaceIdFromRoot(root: string): string {\n\treturn path\n\t\t.resolve(root)\n\t\t.replace(/[\\\\/]+/g, \"/\")\n\t\t.toLowerCase();\n}\n"]}