loki-mode 9.30.1 → 9.32.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.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v9.30.1
6
+ # Loki Mode v9.32.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.30.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.32.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.30.1
1
+ 9.32.0
package/autonomy/run.sh CHANGED
@@ -2812,14 +2812,43 @@ check_policy() {
2812
2812
  local context_json="${2:-}"
2813
2813
  [ -z "$context_json" ] && context_json='{}'
2814
2814
 
2815
- # Only check if policy files exist
2816
- if [ ! -f ".loki/policies.json" ] && [ ! -f ".loki/policies.yaml" ]; then
2815
+ # Only check if a policy file exists. This gate MUST agree with what
2816
+ # src/policies/engine.js:_init actually loads, or the two disagree silently:
2817
+ # engine.js falls back to the global ~/.loki policy (which is the only place
2818
+ # the dashboard writes, api_v2.py:75,105), and a project-local-only test
2819
+ # here would short-circuit before that fallback was ever consulted.
2820
+ local _pol_global_dir="${LOKI_DATA_DIR:-$HOME/.loki}"
2821
+ if [ ! -f ".loki/policies.json" ] && [ ! -f ".loki/policies.yaml" ] \
2822
+ && [ ! -f "$_pol_global_dir/policies.json" ] \
2823
+ && [ ! -f "$_pol_global_dir/policies.yaml" ]; then
2817
2824
  return 0
2818
2825
  fi
2819
2826
 
2820
- # Requires Node.js
2827
+ # Requires Node.js. Reaching here means a policy file EXISTS, so the
2828
+ # operator has expressed intent to enforce. Returning 0 for a missing
2829
+ # runtime silently disables that enforcement and reports nothing: the run
2830
+ # proceeds exactly as if every action were permitted. That is the worst
2831
+ # shape a security control can take, because the operator believes it is on.
2832
+ #
2833
+ # Fail CLOSED instead, matching what check.js already does for a corrupt
2834
+ # policy file (tests/test-policy-failclosed.sh). LOKI_POLICY_REQUIRE_NODE=0
2835
+ # restores the old fail-open for a host that genuinely cannot install node
2836
+ # and accepts running unenforced.
2821
2837
  if ! command -v node >/dev/null 2>&1; then
2822
- return 0
2838
+ if [ "${LOKI_POLICY_REQUIRE_NODE:-1}" = "0" ]; then
2839
+ log_warn "Policy file present but node is unavailable; enforcement SKIPPED (LOKI_POLICY_REQUIRE_NODE=0)"
2840
+ return 0
2841
+ fi
2842
+ log_error "Policy file present but node is unavailable: cannot evaluate policy, refusing the action (fail-closed). Set LOKI_POLICY_REQUIRE_NODE=0 to run unenforced."
2843
+ # Recorded like any other block. A refusal that leaves no trace is
2844
+ # indistinguishable from a run that was never gated, and the receipt
2845
+ # must be able to say WHY the action did not happen. The reason is
2846
+ # distinct from a DENY: the policy was never evaluated.
2847
+ audit_agent_action "policy_unevaluable" "Policy present but node unavailable" "enforcement=$enforcement_point"
2848
+ emit_event_json "policy_unevaluable" \
2849
+ "enforcement=$enforcement_point" \
2850
+ "reason=node_unavailable"
2851
+ return 1
2823
2852
  fi
2824
2853
 
2825
2854
  local result
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.30.1"
10
+ __version__ = "9.32.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v9.30.1
5
+ **Version:** v9.32.0
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var St=Object.create;var{getPrototypeOf:yt,defineProperty:jG,getOwnPropertyNames:bt}=Object;var ft=Object.prototype.hasOwnProperty;function _t($){return this[$]}var vt,ht,gt=($,X,Q)=>{var z=$!=null&&typeof $==="object";if(z){var Z=X?vt??=new WeakMap:ht??=new WeakMap,K=Z.get($);if(K)return K}Q=$!=null?St(yt($)):{};let J=X||!$||!$.__esModule?jG(Q,"default",{value:$,enumerable:!0}):Q;for(let q of bt($))if(!ft.call(J,q))jG(J,q,{get:_t.bind($,q),enumerable:!0});if(z)Z.set($,J);return J};var zq=($,X)=>()=>(X||$((X={exports:{}}).exports,X),X.exports);var mt=($)=>$;function ut($,X){this[$]=mt.bind(null,X)}var B1=($,X)=>{for(var Q in X)jG($,Q,{get:X[Q],enumerable:!0,configurable:!0,set:ut.bind(X,Q)})};var s=($,X)=>()=>($&&(X=$($=0)),X);var w5=import.meta.require;var YR={};B1(YR,{lokiDir:()=>h0,homeLokiDir:()=>HQ,findRepoRootForVersion:()=>AG,REPO_ROOT:()=>L1});import{resolve as h2,dirname as LG}from"path";import{fileURLToPath as dt}from"url";import{existsSync as Zq}from"fs";import{homedir as pt}from"os";function ct(){let $=VR;for(let X=0;X<6;X++){if(Zq(h2($,"VERSION"))&&Zq(h2($,"autonomy/run.sh")))return $;let Q=LG($);if(Q===$)break;$=Q}return h2(VR,"..","..","..")}function AG($){let X=$;for(let Q=0;Q<6;Q++){if(Zq(h2(X,"VERSION"))&&Zq(h2(X,"autonomy/run.sh")))return X;let z=LG(X);if(z===X)break;X=z}return h2($,"..","..","..")}function h0(){return process.env.LOKI_DIR??h2(process.cwd(),".loki")}function HQ(){return h2(pt(),".loki")}var VR,L1;var k1=s(()=>{VR=LG(dt(import.meta.url));L1=ct()});import{readFileSync as lt}from"fs";import{resolve as it,dirname as at}from"path";import{fileURLToPath as ot}from"url";function j9(){if(n3!==null)return n3;let $="9.30.1";if(typeof $==="string"&&$.length>0)return n3=$,n3;try{let X=at(ot(import.meta.url)),Q=AG(X);n3=lt(it(Q,"VERSION"),"utf-8").trim()}catch{n3="unknown"}return n3}var n3=null;var Kq=s(()=>{k1()});var GR={};B1(GR,{runOrThrow:()=>Ue,run:()=>$1,readStreamCapped:()=>Jq,commandVersion:()=>We,commandExists:()=>g5,ShellError:()=>CG,MAX_STDOUT_BYTES:()=>WR});async function Jq($,X=WR){let Q=$.getReader(),z=new TextDecoder,Z="",K=0;try{while(K<X){let{done:J,value:q}=await Q.read();if(J)break;if(!q)continue;if(K+=q.byteLength,K>X){let V=q.byteLength-(K-X);Z+=z.decode(q.subarray(0,V),{stream:!0});break}Z+=z.decode(q,{stream:!0})}Z+=z.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return Z}async function $1($,X={}){let Q=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),z,Z;if(X.timeoutMs&&X.timeoutMs>0)z=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[K,J,q]=await Promise.all([Jq(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:K,stderr:J,exitCode:q}}finally{if(z)clearTimeout(z);if(Z)clearTimeout(Z)}}async function Ue($,X={}){let Q=await $1($,X);if(Q.exitCode!==0)throw new CG(`command failed (${Q.exitCode}): ${$.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function g5($){let X=He($),Q=await $1(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function He($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function We($,X="--version"){if(!await g5($))return null;let z=await $1([$,X],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var WR=16777216,CG;var y8=s(()=>{CG=class CG extends Error{message;exitCode;stdout;stderr;constructor($,X,Q,z){super($);this.message=$;this.exitCode=X;this.stdout=Q;this.stderr=z;this.name="ShellError"}}});function g2($){return Ge?"":$}var Ge,p0,$5,q1,L61,A1,f1,m5,r;var t7=s(()=>{Ge=(process.env.NO_COLOR??"").length>0;p0=g2("\x1B[0;31m"),$5=g2("\x1B[0;32m"),q1=g2("\x1B[1;33m"),L61=g2("\x1B[0;34m"),A1=g2("\x1B[0;36m"),f1=g2("\x1B[1m"),m5=g2("\x1B[2m"),r=g2("\x1B[0m")});import{existsSync as De}from"fs";async function Z2(){if(GQ!==void 0)return GQ;let $="/opt/homebrew/bin/python3.12";if(De($))return GQ=$,$;let X=await g5("python3.12");if(X)return GQ=X,X;let Q=await g5("python3");return GQ=Q,Q}async function I4($,X={}){let Q=await Z2();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return $1([Q,"-c",$],X)}var GQ;var m2=s(()=>{y8()});var yR={};B1(yR,{runStatus:()=>oe});import{existsSync as u5,readFileSync as A9,readdirSync as RR,statSync as IR}from"fs";import{resolve as A5,basename as ge}from"path";import{homedir as me}from"os";function wR($){let X=Math.trunc($);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function PR($,X,Q){if(X===0)return null;let z=Math.trunc($*100/X),Z=Math.trunc($*Vq/X);if(Z>Vq)Z=Vq;let K=Vq-Z,J=$5;if(z>=80)J=p0;else if(z>=50)J=q1;let q="=".repeat(Math.max(0,Z))+" ".repeat(Math.max(0,K)),V=wR($),Y=wR(X);return` ${f1}${Q}${r} ${J}[${q}]${r} ${z}% (${V} / ${Y})`}async function de(){if(await g5("jq"))return!0;return process.stdout.write(`${p0}Error: jq is required but not installed.${r}
2
+ var St=Object.create;var{getPrototypeOf:yt,defineProperty:jG,getOwnPropertyNames:bt}=Object;var ft=Object.prototype.hasOwnProperty;function _t($){return this[$]}var vt,ht,gt=($,X,Q)=>{var z=$!=null&&typeof $==="object";if(z){var Z=X?vt??=new WeakMap:ht??=new WeakMap,K=Z.get($);if(K)return K}Q=$!=null?St(yt($)):{};let J=X||!$||!$.__esModule?jG(Q,"default",{value:$,enumerable:!0}):Q;for(let q of bt($))if(!ft.call(J,q))jG(J,q,{get:_t.bind($,q),enumerable:!0});if(z)Z.set($,J);return J};var zq=($,X)=>()=>(X||$((X={exports:{}}).exports,X),X.exports);var mt=($)=>$;function ut($,X){this[$]=mt.bind(null,X)}var B1=($,X)=>{for(var Q in X)jG($,Q,{get:X[Q],enumerable:!0,configurable:!0,set:ut.bind(X,Q)})};var s=($,X)=>()=>($&&(X=$($=0)),X);var w5=import.meta.require;var YR={};B1(YR,{lokiDir:()=>h0,homeLokiDir:()=>HQ,findRepoRootForVersion:()=>AG,REPO_ROOT:()=>L1});import{resolve as h2,dirname as LG}from"path";import{fileURLToPath as dt}from"url";import{existsSync as Zq}from"fs";import{homedir as pt}from"os";function ct(){let $=VR;for(let X=0;X<6;X++){if(Zq(h2($,"VERSION"))&&Zq(h2($,"autonomy/run.sh")))return $;let Q=LG($);if(Q===$)break;$=Q}return h2(VR,"..","..","..")}function AG($){let X=$;for(let Q=0;Q<6;Q++){if(Zq(h2(X,"VERSION"))&&Zq(h2(X,"autonomy/run.sh")))return X;let z=LG(X);if(z===X)break;X=z}return h2($,"..","..","..")}function h0(){return process.env.LOKI_DIR??h2(process.cwd(),".loki")}function HQ(){return h2(pt(),".loki")}var VR,L1;var k1=s(()=>{VR=LG(dt(import.meta.url));L1=ct()});import{readFileSync as lt}from"fs";import{resolve as it,dirname as at}from"path";import{fileURLToPath as ot}from"url";function j9(){if(n3!==null)return n3;let $="9.32.0";if(typeof $==="string"&&$.length>0)return n3=$,n3;try{let X=at(ot(import.meta.url)),Q=AG(X);n3=lt(it(Q,"VERSION"),"utf-8").trim()}catch{n3="unknown"}return n3}var n3=null;var Kq=s(()=>{k1()});var GR={};B1(GR,{runOrThrow:()=>Ue,run:()=>$1,readStreamCapped:()=>Jq,commandVersion:()=>We,commandExists:()=>g5,ShellError:()=>CG,MAX_STDOUT_BYTES:()=>WR});async function Jq($,X=WR){let Q=$.getReader(),z=new TextDecoder,Z="",K=0;try{while(K<X){let{done:J,value:q}=await Q.read();if(J)break;if(!q)continue;if(K+=q.byteLength,K>X){let V=q.byteLength-(K-X);Z+=z.decode(q.subarray(0,V),{stream:!0});break}Z+=z.decode(q,{stream:!0})}Z+=z.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return Z}async function $1($,X={}){let Q=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),z,Z;if(X.timeoutMs&&X.timeoutMs>0)z=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[K,J,q]=await Promise.all([Jq(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:K,stderr:J,exitCode:q}}finally{if(z)clearTimeout(z);if(Z)clearTimeout(Z)}}async function Ue($,X={}){let Q=await $1($,X);if(Q.exitCode!==0)throw new CG(`command failed (${Q.exitCode}): ${$.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function g5($){let X=He($),Q=await $1(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function He($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function We($,X="--version"){if(!await g5($))return null;let z=await $1([$,X],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var WR=16777216,CG;var y8=s(()=>{CG=class CG extends Error{message;exitCode;stdout;stderr;constructor($,X,Q,z){super($);this.message=$;this.exitCode=X;this.stdout=Q;this.stderr=z;this.name="ShellError"}}});function g2($){return Ge?"":$}var Ge,p0,$5,q1,L61,A1,f1,m5,r;var t7=s(()=>{Ge=(process.env.NO_COLOR??"").length>0;p0=g2("\x1B[0;31m"),$5=g2("\x1B[0;32m"),q1=g2("\x1B[1;33m"),L61=g2("\x1B[0;34m"),A1=g2("\x1B[0;36m"),f1=g2("\x1B[1m"),m5=g2("\x1B[2m"),r=g2("\x1B[0m")});import{existsSync as De}from"fs";async function Z2(){if(GQ!==void 0)return GQ;let $="/opt/homebrew/bin/python3.12";if(De($))return GQ=$,$;let X=await g5("python3.12");if(X)return GQ=X,X;let Q=await g5("python3");return GQ=Q,Q}async function I4($,X={}){let Q=await Z2();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return $1([Q,"-c",$],X)}var GQ;var m2=s(()=>{y8()});var yR={};B1(yR,{runStatus:()=>oe});import{existsSync as u5,readFileSync as A9,readdirSync as RR,statSync as IR}from"fs";import{resolve as A5,basename as ge}from"path";import{homedir as me}from"os";function wR($){let X=Math.trunc($);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function PR($,X,Q){if(X===0)return null;let z=Math.trunc($*100/X),Z=Math.trunc($*Vq/X);if(Z>Vq)Z=Vq;let K=Vq-Z,J=$5;if(z>=80)J=p0;else if(z>=50)J=q1;let q="=".repeat(Math.max(0,Z))+" ".repeat(Math.max(0,K)),V=wR($),Y=wR(X);return` ${f1}${Q}${r} ${J}[${q}]${r} ${z}% (${V} / ${Y})`}async function de(){if(await g5("jq"))return!0;return process.stdout.write(`${p0}Error: jq is required but not installed.${r}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -1334,4 +1334,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
1334
1334
  `),2}case"start":{let{runStart:z}=await Promise.resolve().then(() => (Et(),Pt));return z(Q)}default:return process.stderr.write(`Unknown command: ${X}
1335
1335
  `),process.stderr.write(xt),2}}DR();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var X61=await $61(Bun.argv.slice(2));process.exit(X61);
1336
1336
 
1337
- //# debugId=1EB5CB676655413C8A4F19102A2C918F
1337
+ //# debugId=191E4536498A68A53512020705523C23
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.30.1'
78
+ __version__ = '9.32.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "9.30.1",
4
+ "version": "9.32.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider, opencode).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "9.30.1",
5
+ "version": "9.32.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -20,6 +20,11 @@ var EVENT_TO_AUDIT = {
20
20
  'session_end': { what: 'session_end', why: 'Loki session terminated' },
21
21
  'phase_change': { what: 'phase_change', why: 'RARV phase transition' },
22
22
  'policy_denied': { what: 'policy_violation', why: 'Policy engine blocked action' },
23
+ // Distinct from policy_denied: the policy was never EVALUATED. A policy
24
+ // file was present (so enforcement was intended) but node was unavailable,
25
+ // so run.sh refused fail-closed rather than proceeding unenforced. The
26
+ // receipt must be able to tell "denied by a rule" from "could not check".
27
+ 'policy_unevaluable': { what: 'policy_unevaluable', why: 'Policy present but could not be evaluated; action refused fail-closed' },
23
28
  'policy_approval_required': { what: 'policy_approval', why: 'Policy requires approval' },
24
29
  'otel_span_start': null, // Skip OTEL internal events
25
30
  'otel_span_end': null, // Skip OTEL internal events
@@ -18,6 +18,7 @@
18
18
 
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
+ const os = require('os');
21
22
  const { Decision, evaluateRule, scanContent, validatePreExecution,
22
23
  validatePreDeployment, validateResource, validateData,
23
24
  validateApprovalGate, RULE_EVALUATORS } = require('./types');
@@ -250,11 +251,29 @@ class PolicyEngine {
250
251
  const jsonPath = path.join(lokiDir, 'policies.json');
251
252
  const yamlPath = path.join(lokiDir, 'policies.yaml');
252
253
 
253
- // Try JSON first, then YAML
254
+ // The dashboard writes policies to ~/.loki/policies.json
255
+ // (dashboard/api_v2.py:75,105 via LOKI_DATA_DIR, default ~/.loki) while this
256
+ // reader only ever looked project-local. The ONE existing writer therefore
257
+ // fed a file this reader never opened, and an operator who created a policy
258
+ // through the UI got no enforcement at all and no error saying why.
259
+ //
260
+ // Project-local still WINS: a repo that ships its own policy must not be
261
+ // silently overridden by a machine-global one. The global file is a
262
+ // fallback, consulted only when the project defines nothing.
263
+ const globalDir = path.join(
264
+ process.env.LOKI_DATA_DIR || path.join(os.homedir(), '.loki'));
265
+ const globalJson = path.join(globalDir, 'policies.json');
266
+ const globalYaml = path.join(globalDir, 'policies.yaml');
267
+
268
+ // Try JSON first, then YAML, project-local before global.
254
269
  if (fs.existsSync(jsonPath)) {
255
270
  this._policyPath = jsonPath;
256
271
  } else if (fs.existsSync(yamlPath)) {
257
272
  this._policyPath = yamlPath;
273
+ } else if (fs.existsSync(globalJson)) {
274
+ this._policyPath = globalJson;
275
+ } else if (fs.existsSync(globalYaml)) {
276
+ this._policyPath = globalYaml;
258
277
  } else {
259
278
  // No policy file -- zero overhead mode
260
279
  this._policies = null;
package/web-app/server.py CHANGED
@@ -7776,15 +7776,74 @@ class CreateTeamRequest(BaseModel):
7776
7776
 
7777
7777
  class AddMemberRequest(BaseModel):
7778
7778
  email: str
7779
+ # NOT AN AUTHORIZATION CONTROL. This value is stored on the member record
7780
+ # and echoed back; nothing in this server reads it to permit or deny
7781
+ # anything (measured: the only other `role` occurrences in this file are
7782
+ # chat-message roles around line 4166). /api/teams, /api/teams/{id}/members
7783
+ # and /api/audit-log carry no auth dependency at all, and web-app/auth.py:1-4
7784
+ # states that without DATABASE_URL authentication is completely disabled and
7785
+ # every endpoint is open.
7786
+ #
7787
+ # It is kept because the UI displays it and removing it would break that
7788
+ # surface, but a field that LOOKS like a permission and enforces nothing is
7789
+ # worse than no field: a buyer may rely on it. Wiring real enforcement means
7790
+ # adding an auth dependency to these routes first; until then this label is
7791
+ # descriptive only.
7779
7792
  role: str = "viewer"
7780
7793
 
7781
7794
 
7782
- def _audit(action: str, user: str = "system", target: str = "", details: str = ""):
7783
- """Record an audit log entry."""
7795
+ def _audit_actor(request=None) -> tuple:
7796
+ """Resolve WHO performed an action, honestly.
7797
+
7798
+ Returns (actor, actor_state). actor_state is one of:
7799
+ "identified" - an authenticated principal; actor is their id
7800
+ "unauthenticated"- auth is not configured, so the actor is genuinely
7801
+ unknowable. web-app/auth.py:1-4 states that with no
7802
+ DATABASE_URL, authentication is completely disabled and
7803
+ all endpoints are open. That is the DEFAULT.
7804
+ "unreadable" - an actor should have been resolvable but the lookup
7805
+ failed; recorded so it is never mistaken for the above
7806
+
7807
+ Why this exists: _audit() previously hardcoded user="system" for every
7808
+ entry. An audit trail that cannot say who did something is not an audit
7809
+ trail, and "system" is worse than blank because it asserts an actor that was
7810
+ never established. Never invent an actor; say which of the three states
7811
+ applies and let the reader judge.
7812
+ """
7813
+ if request is None:
7814
+ return ("unknown", "unauthenticated")
7815
+ try:
7816
+ principal = getattr(request.state, "user", None)
7817
+ if isinstance(principal, dict):
7818
+ ident = principal.get("sub") or principal.get("email") or principal.get("id")
7819
+ if ident:
7820
+ return (str(ident), "identified")
7821
+ elif principal:
7822
+ return (str(principal), "identified")
7823
+ return ("unknown", "unauthenticated")
7824
+ except Exception: # noqa: BLE001 - never let attribution break the action
7825
+ return ("unknown", "unreadable")
7826
+
7827
+
7828
+ def _audit(action: str, user: str = "", target: str = "", details: str = "",
7829
+ request=None):
7830
+ """Record an audit log entry.
7831
+
7832
+ NOTE ON WHAT THIS LOG IS: _append_audit_log rewrites a 500-entry JSON array
7833
+ in place on every call. It is a recent-activity view, NOT tamper-evident and
7834
+ NOT append-only, and it is a different thing from the hash-chained log in
7835
+ dashboard/audit.py. Do not cite it as audit evidence. See
7836
+ docs/AUDIT-CHAIN-THREAT-MODEL.md.
7837
+ """
7838
+ if user:
7839
+ actor, actor_state = (user, "identified")
7840
+ else:
7841
+ actor, actor_state = _audit_actor(request)
7784
7842
  entry = {
7785
7843
  "id": str(uuid.uuid4()),
7786
7844
  "action": action,
7787
- "user": user,
7845
+ "user": actor,
7846
+ "actor_state": actor_state,
7788
7847
  "target": target,
7789
7848
  "timestamp": datetime.now().isoformat(),
7790
7849
  "details": details,
@@ -7800,7 +7859,7 @@ async def list_teams() -> JSONResponse:
7800
7859
 
7801
7860
 
7802
7861
  @app.post("/api/teams")
7803
- async def create_team(req: CreateTeamRequest) -> JSONResponse:
7862
+ async def create_team(req: CreateTeamRequest, request: Request = None) -> JSONResponse:
7804
7863
  """Create a new team."""
7805
7864
  team_id = f"team-{uuid.uuid4().hex[:8]}"
7806
7865
  team = {
@@ -7812,7 +7871,7 @@ async def create_team(req: CreateTeamRequest) -> JSONResponse:
7812
7871
  store = _load_teams()
7813
7872
  store[team_id] = team
7814
7873
  _save_teams(store)
7815
- _audit("team.created", target=req.name)
7874
+ _audit("team.created", target=req.name, request=request)
7816
7875
  return JSONResponse(content={"id": team_id, "name": req.name, "created": True})
7817
7876
 
7818
7877
 
@@ -7827,7 +7886,7 @@ async def list_team_members(team_id: str) -> JSONResponse:
7827
7886
 
7828
7887
 
7829
7888
  @app.post("/api/teams/{team_id}/members")
7830
- async def add_team_member(team_id: str, req: AddMemberRequest) -> JSONResponse:
7889
+ async def add_team_member(team_id: str, req: AddMemberRequest, request: Request = None) -> JSONResponse:
7831
7890
  """Add a member to a team."""
7832
7891
  store = _load_teams()
7833
7892
  team = store.get(team_id)
@@ -7843,7 +7902,7 @@ async def add_team_member(team_id: str, req: AddMemberRequest) -> JSONResponse:
7843
7902
  }
7844
7903
  team.setdefault("members", []).append(member)
7845
7904
  _save_teams(store)
7846
- _audit("member.added", target=req.email, details=f"Role: {req.role}")
7905
+ _audit("member.added", target=req.email, details=f"Role: {req.role}", request=request)
7847
7906
  return JSONResponse(content={"added": True, "member_id": member_id})
7848
7907
 
7849
7908